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

Test content with links.

"; + var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("Test content with links.", result); + } + + /// + /// Verifies Convert with integer parameter truncates and single-lines text. + /// + [Fact] + public void Convert_WithMaxLenParameter_CleansToSingleLineAndTruncates() + { + var input = "

First line

\n\n

Second line with a lot of details here.

"; + var result = _converter.Convert(input, typeof(string), 25, CultureInfo.InvariantCulture); + + Assert.Equal("First line Second line...", result); + } + + /// + /// Verifies Convert with string parameter parses integer and truncates. + /// + [Fact] + public void Convert_WithStringParameter_ParsesAndTruncates() + { + var input = "

First line

\n\n

Second line with a lot of details here.

"; + var result = _converter.Convert(input, typeof(string), "25", CultureInfo.InvariantCulture); + + Assert.Equal("First line Second line...", result); + } + + /// + /// Verifies Convert handles non-string input by returning value untouched. + /// + [Fact] + public void Convert_WithScriptAndStyleTags_StripsContents() + { + var input = "

Hello World

"; + var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("Hello World", result); + } + + /// + /// Verifies Convert handles non-string input by returning value untouched. + /// + [Fact] + public void Convert_NonStringValue_ReturnsOriginalValue() + { + var result = _converter.Convert(42, typeof(int), null, CultureInfo.InvariantCulture); + Assert.Equal(42, result); + } + + /// + /// Verifies ConvertBack throws NotSupportedException. + /// + [Fact] + public void ConvertBack_ThrowsNotSupportedException() + { + Assert.Throws(() => + _converter.ConvertBack("test", typeof(string), null, CultureInfo.InvariantCulture)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs new file mode 100644 index 000000000..0396848f4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using GenHub.Core.Extensions.Enums; +using GenHub.Core.Models.Enums; +using Xunit; + +namespace GenHub.Tests.Core.Extensions.Enums; + +/// +/// Tests for . +/// +public class ContentInstallTargetExtensionsTests +{ + /// + /// The four user directories must be copied out of CAS rather than hard-linked, because the game + /// engine writes into them in place and would otherwise rewrite the canonical CAS object. + /// + /// The user-writable target under test. + [Theory] + [InlineData(ContentInstallTarget.UserDataDirectory)] + [InlineData(ContentInstallTarget.UserMapsDirectory)] + [InlineData(ContentInstallTarget.UserReplaysDirectory)] + [InlineData(ContentInstallTarget.UserScreenshotsDirectory)] + public void IsUserWritableTarget_ForUserDirectories_ReturnsTrue(ContentInstallTarget installTarget) + { + Assert.True(installTarget.IsUserWritableTarget()); + } + + /// + /// Workspace and system installs are managed by GenHub rather than written to by the user, so + /// they remain eligible for hard links to CAS. + /// + /// The GenHub-managed target under test. + [Theory] + [InlineData(ContentInstallTarget.Workspace)] + [InlineData(ContentInstallTarget.System)] + public void IsUserWritableTarget_ForGenHubManagedTargets_ReturnsFalse(ContentInstallTarget installTarget) + { + Assert.False(installTarget.IsUserWritableTarget()); + } + + /// + /// An install target this method has never been taught about must fail towards copying. The path + /// resolver sends unmapped targets into the user data root, so answering "not user-writable" + /// would hard-link a CAS object straight into the user's Documents folder. + /// + [Fact] + public void IsUserWritableTarget_ForAnUnmappedTarget_FailsTowardsCopying() + { + var unmapped = Enum.GetValues().Cast().Max() + 1; + + Assert.True(((ContentInstallTarget)unmapped).IsUserWritableTarget()); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs new file mode 100644 index 000000000..ea4054d5a --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs @@ -0,0 +1,145 @@ +using GenHub.Core.Constants; +using GenHub.Core.Extensions; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameClients; +using GenHub.Core.Models.GameProfile; + +namespace GenHub.Tests.Core.Extensions; + +/// +/// Tests for . +/// +public class GameProfileExtensionsTests +{ + /// + /// Verifies that the publisher type identifies a GeneralsOnline profile regardless of casing. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData("generalsonline")] + [InlineData("GeneralsOnline")] + [InlineData("GENERALSONLINE")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlinePublisher_ReturnsTrue(string publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", []); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that other Zero Hour publishers are not mistaken for GeneralsOnline, which is what + /// kept their launches from overwriting the GeneralsOnline client's settings.json. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(PublisherTypeConstants.TheSuperHackers)] + [InlineData(CommunityOutpostConstants.PublisherType)] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsGeneralsOnlineProfile_WithOtherPublisher_ReturnsFalse(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", ["1.0.genhub.mod.test"]); + + // Act & Assert + Assert.False(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a recorded publisher settles the question, so a profile belonging to another + /// client is not reclassified by content it happens to enable or by its client name. Answering + /// otherwise would let it rewrite the GeneralsOnline client's global settings. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(PublisherTypeConstants.TheSuperHackers)] + [InlineData(CommunityOutpostConstants.PublisherType)] + public void IsGeneralsOnlineProfile_WithOtherPublisherAndGeneralsOnlineHints_ReturnsFalse(string publisherType) + { + // Arrange + var profile = CreateZeroHourProfile( + publisherType, + "GeneralsOnline Compatible", + ["1.9.generalsonline.gameclient.30hz"]); + + // Act & Assert + Assert.False(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile predating the recorded publisher type is still recognised by its + /// client name. Such a profile records no publisher at all, so null is its real shape. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlineClientName_ReturnsTrue(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "GeneralsOnline 30Hz", []); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile predating the recorded publisher type is still recognised by its + /// enabled content. Such a profile records no publisher at all, so null is its real shape. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlineContent_ReturnsTrue(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", ["1.9.generalsonline.gameclient.30hz"]); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile with no client at all, which is the shape the settings editor sees + /// while a profile is being created, falls back to its enabled content. + /// + /// The content the profile enables. + /// Whether that content makes it a GeneralsOnline profile. + [Theory] + [InlineData("1.9.generalsonline.gameclient.30hz", true)] + [InlineData("1.0.genhub.mod.test", false)] + public void IsGeneralsOnlineProfile_WithoutGameClient_FallsBackToContent(string contentId, bool expected) + { + // Arrange + var profile = new GameProfile + { + Id = "profile-1", + Name = "Test Profile", + EnabledContentIds = [contentId], + }; + + // Act & Assert + Assert.Equal(expected, profile.IsGeneralsOnlineProfile()); + } + + private static GameProfile CreateZeroHourProfile(string? publisherType, string clientName, List enabledContentIds) + { + return new GameProfile + { + Id = "profile-1", + Name = "Test Profile", + GameClient = new GameClient + { + Id = "client-1", + Name = clientName, + GameType = GameType.ZeroHour, + PublisherType = publisherType, + }, + EnabledContentIds = enabledContentIds, + }; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs new file mode 100644 index 000000000..2c5db6ecf --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs @@ -0,0 +1,535 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Features.AppUpdate.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.AppUpdate.Services; + +/// +/// Unit tests for . +/// +public class FastHttpClientFileDownloaderTests : IDisposable +{ + private sealed class TestHttpMessageHandler(Func handlerFunc) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(handlerFunc(request)); + } + } + + private readonly Mock> _mockLogger = new(); + private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-downloader-tests-{Guid.NewGuid():N}"); + + /// + /// Initializes a new instance of the class. + /// + public FastHttpClientFileDownloaderTests() + { + Directory.CreateDirectory(_tempDirectory); + } + + /// + /// Disposes test resources and cleans up temporary directories. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + // Ignore test directory cleanup failures + } + } + } + + /// + /// Tests that the downloader can be initialized with and without a logger. + /// + [Fact] + public void Constructor_ShouldInitializeSuccessfully() + { + var downloaderWithoutLogger = new FastHttpClientFileDownloader(); + var downloaderWithLogger = new FastHttpClientFileDownloader(_mockLogger.Object); + + Assert.NotNull(downloaderWithoutLogger); + Assert.NotNull(downloaderWithLogger); + } + + /// + /// Tests that DownloadFile throws ArgumentException when URL is invalid. + /// + /// The invalid URL string. + /// A representing the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadFile_WithInvalidUrl_ShouldThrowArgumentExceptionAsync(string? invalidUrl) + { + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object); + var targetFile = Path.Combine(_tempDirectory, "test.tmp"); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile(invalidUrl!, targetFile, _ => { }, null, 30)); + } + + /// + /// Tests that DownloadFile throws ArgumentException when target file path is invalid. + /// + /// The invalid target file path string. + /// A representing the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadFile_WithInvalidTargetFile_ShouldThrowArgumentExceptionAsync(string? invalidTargetFile) + { + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile("https://example.com/file.zip", invalidTargetFile!, _ => { }, null, 30)); + } + + /// + /// Tests that parallel chunk downloading correctly assembles multi-chunk files and reports progress monotonically. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ParallelRange_ValidAssembly_ShouldDownloadAndVerifyContentAsync() + { + // 6 MB file (3 chunks of 2 MB) + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 3; + var sourceBytes = new byte[totalBytes]; + new Random(42).NextBytes(sourceBytes); + + var progressHistory = new ConcurrentQueue(); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe request + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + var length = (int)(to - from + 1); + var chunkData = new byte[length]; + Array.Copy(sourceBytes, from, chunkData, 0, length); + + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(chunkData), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "parallel-output.bin"); + + await downloader.DownloadFile( + "https://github.com/community-outpost/GenHub/releases/download/v1.0.0/test.bin", + targetFile, + progressHistory.Enqueue, + null, + 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + + var progressList = progressHistory.ToList(); + Assert.NotEmpty(progressList); + Assert.Equal(100, progressList.Last()); + } + + /// + /// Tests that small files below the parallel threshold use single-stream mode without chunking. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_SmallFileBelowThreshold_ShouldUseSingleStreamAsync() + { + var smallBytes = new byte[1024 * 1024]; // 1 MB + new Random(42).NextBytes(smallBytes); + + var chunkRequestsCount = 0; + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response indicates 1MB file + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([smallBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, smallBytes.Length) { Unit = "bytes" }; + return probeResponse; + } + + if (range is not null) + { + Interlocked.Increment(ref chunkRequestsCount); + } + + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(smallBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "small-file.bin"); + + await downloader.DownloadFile("https://example.com/small.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(smallBytes, downloadedBytes); + Assert.Equal(0, chunkRequestsCount); + } + + /// + /// Tests that when the server ignores range headers (returning 200 OK on probe), the downloader streams directly without error. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ServerIgnoresRange_ShouldStreamProbeResponseDirectlyAsync() + { + var fileBytes = new byte[1024 * 512]; // 512 KB + new Random(1337).NextBytes(fileBytes); + + var handler = new TestHttpMessageHandler(request => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(fileBytes), + RequestMessage = request, + }; + return response; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "ignored-range.bin"); + + await downloader.DownloadFile("https://example.com/file.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(fileBytes, downloadedBytes); + } + + /// + /// Tests that when a chunk response returns an invalid Content-Range header, the downloader falls back to single-stream. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_InvalidContentRange_ShouldFallbackToSingleStreamAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(77).NextBytes(sourceBytes); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is not null) + { + // Return mismatched Content-Range + var badResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[100]), + RequestMessage = request, + }; + badResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(999, 1098, totalBytes) { Unit = "bytes" }; + return badResponse; + } + + // Fallback path sends full payload + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "fallback-invalid-range.bin"); + + await downloader.DownloadFile("https://example.com/large.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + } + + /// + /// Tests that when a chunk response streams fewer bytes than requested, the downloader falls back to single-stream. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ShortChunkStream_ShouldFallbackToSingleStreamAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(99).NextBytes(sourceBytes); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + // Return short stream (100 bytes instead of expected chunk length) + var shortResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[100]), + RequestMessage = request, + }; + shortResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return shortResponse; + } + + // Fallback path sends full payload + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "fallback-short-chunk.bin"); + + await downloader.DownloadFile("https://example.com/large.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + } + + /// + /// Tests that progress reporting is strictly monotonic (never moves backward) and throttled to at most 101 updates. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ProgressReporting_ShouldBeStrictlyMonotonicAndThrottledAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 3; // 24 MB + var sourceBytes = new byte[totalBytes]; + + var progressHistory = new ConcurrentQueue(); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([0]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + var length = (int)(to - from + 1); + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[length]), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "progress-test.bin"); + + await downloader.DownloadFile("https://example.com/file.bin", targetFile, progressHistory.Enqueue, null, 30); + + var progressList = progressHistory.ToList(); + + Assert.NotEmpty(progressList); + Assert.Equal(100, progressList.Last()); + + // Verify strictly monotonic ordering (each progress event >= previous) + for (var i = 1; i < progressList.Count; i++) + { + Assert.True(progressList[i] >= progressList[i - 1], $"Progress moved backward from {progressList[i - 1]} to {progressList[i]}"); + } + + // Verify throttling: no more than 101 progress updates (0 to 100) + Assert.True(progressList.Count <= 101, $"Progress was called {progressList.Count} times, exceeding maximum throttled limit of 101"); + } + + /// + /// Tests that cancellation tokens are properly observed and propagated. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_WhenCancelled_ShouldThrowOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var handler = new TestHttpMessageHandler(request => new HttpResponseMessage(HttpStatusCode.OK)); + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "canceled.bin"); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile("https://example.com/file.bin", targetFile, _ => { }, null, 30, cts.Token)); + } + + /// + /// Tests that when redirected to a cross-origin storage host, the Authorization header is omitted from chunk requests. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_WhenRedirectedToCrossOriginCdn_ShouldStripAuthorizationHeaderOnChunksAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(42).NextBytes(sourceBytes); + + var chunkAuthHeadersPresent = 0; + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://cdn.blob.core.windows.net/artifacts/file.zip"), + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + if (request.Headers.Contains("Authorization")) + { + Interlocked.Increment(ref chunkAuthHeadersPresent); + } + + var length = (int)(to - from + 1); + var chunkData = new byte[length]; + Array.Copy(sourceBytes, from, chunkData, 0, length); + + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(chunkData), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "cross-origin-test.bin"); + var headers = new Dictionary + { + { "Authorization", "Bearer test_pat_token" }, + { "User-Agent", "GenHub" }, + }; + + await downloader.DownloadFile( + "https://api.github.com/repos/community-outpost/GenHub/actions/artifacts/123/zip", + targetFile, + _ => { }, + headers, + 30); + + Assert.True(File.Exists(targetFile)); + Assert.Equal(sourceBytes, await File.ReadAllBytesAsync(targetFile)); + Assert.Equal(0, chunkAuthHeadersPresent); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs index 5719dac51..18d980766 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs @@ -3,6 +3,7 @@ using GenHub.Features.AppUpdate.Services; using Microsoft.Extensions.Logging; using Moq; +using Velopack.Sources; namespace GenHub.Tests.Core.Features.AppUpdate.Services; @@ -188,6 +189,28 @@ public async Task CheckForArtifactUpdatesAsync_WithoutPAT_ShouldReturnNullAsync( Assert.False(manager.HasArtifactUpdateAvailable); } + /// + /// Tests that VelopackUpdateManager accepts a custom IFileDownloader. + /// + [Fact] + public void Constructor_WithCustomFileDownloader_ShouldInitializeSuccessfully() + { + // Arrange + var customDownloader = new Mock().Object; + + // Act + var manager = new VelopackUpdateManager( + _mockLogger.Object, + _mockHttpClientFactory.Object, + _mockGitHubTokenStorage.Object, + _mockUserSettingsService.Object, + customDownloader); + + // Assert + Assert.NotNull(manager); + Assert.False(manager.IsUpdatePendingRestart); + } + /// /// Creates a new VelopackUpdateManager instance with mocked dependencies. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs index fed2373b8..5ad314dec 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs @@ -1,8 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Common; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.AppUpdate.ViewModels; using Microsoft.Extensions.Logging; using Moq; +using Xunit; namespace GenHub.Tests.Core.Features.AppUpdate.ViewModels; @@ -23,7 +30,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn .ReturnsAsync((Velopack.UpdateInfo?)null); var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( mockVelopack.Object, @@ -43,7 +50,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn public void Constructor_InitializesSuccessfully() { var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( Mock.Of(), @@ -63,7 +70,7 @@ public void Constructor_InitializesSuccessfully() public void IsCheckButtonEnabled_ReflectsCheckingState() { var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( Mock.Of(), @@ -72,4 +79,515 @@ public void IsCheckButtonEnabled_ReflectsCheckingState() Assert.True(vm.IsCheckButtonEnabled); } + + /// + /// Verifies that pull request display title formats properly with PR number and title. + /// + [Fact] + public void PullRequestInfo_DisplayTitle_ShouldIncludePrNumberAndTitle() + { + var prInfo = new PullRequestInfo + { + Number = 265, + Title = "feat: UI Downloads", + BranchName = "feat/ui-downloads", + Author = "developer", + State = "open", + UpdatedAt = DateTimeOffset.UtcNow, + }; + + Assert.Equal("#265 - feat: UI Downloads", prInfo.DisplayTitle); + } + + /// + /// Verifies that subscribing to a PR loads artifacts and auto-selects the latest version. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToPr_LoadsArtifactsAndAutoSelectsLatestVersionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var artifacts = new List + { + new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + new("0.0.1315-pr389", "a1b2c3d", 389, 1000, "https://github.com/test/run/0", 500, "genhub-velopack-linux-0.0.1315-pr389", DateTime.UtcNow.AddMinutes(-10), "https://github.com/test/art/0", 1024), + }; + + var loadTcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => loadTcs.TrySetCanceled(ct)); + return await loadTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToPrCommand.Execute(389); + + Assert.True(vm.IsLoadingVersions); + loadTcs.SetResult(artifacts); + + // wait briefly for async continuation + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.Equal(2, vm.AvailableVersions.Count); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version); + Assert.Equal("e1212a5", vm.SelectedVersion.GitHash); + Assert.True(vm.CanDownloadUpdate); + } + + /// + /// Verifies that subscribing to a branch loads artifacts and auto-selects the latest version. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_LoadsArtifactsAndAutoSelectsLatestVersionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var artifacts = new List + { + new("0.0.1320-development", "f4e3d2c", null, 2001, "https://github.com/test/run/2", 601, "genhub-velopack-linux-0.0.1320-development", DateTime.UtcNow, "https://github.com/test/art/2", 2048), + }; + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("development", It.IsAny())) + .ReturnsAsync(artifacts); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("development"); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1320-development", vm.SelectedVersion.Version); + } + + /// + /// Verifies that when switching PR subscriptions while a previous load is in flight, the old request is cancelled and only the new subscription artifacts are applied. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToPr_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewSubscriptionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var pr391Tcs = new TaskCompletionSource>(); + var pr389Tcs = new TaskCompletionSource>(); + + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => pr391Tcs.TrySetCanceled(ct)); + return await pr391Tcs.Task; + }); + + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => pr389Tcs.TrySetCanceled(ct)); + return await pr389Tcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + // subscribe to 391 first + vm.SubscribeToPrCommand.Execute(391); + Assert.True(vm.IsLoadingVersions); + + // immediately switch to 389 while 391 is loading + vm.SubscribeToPrCommand.Execute(389); + + // resolve 389 artifacts + var pr389Artifacts = new List + { + new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + }; + pr389Tcs.TrySetResult(pr389Artifacts); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(pr391Tcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version); + Assert.Equal(389, vm.SelectedVersion.PullRequestNumber); + } + + /// + /// Verifies that switching from a branch to another branch cancels the previous load and populates the new branch artifacts. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewBranchAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var branchOldTcs = new TaskCompletionSource>(); + var branchNewTcs = new TaskCompletionSource>(); + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("old-branch", It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + ct.Register(() => branchOldTcs.TrySetCanceled(ct)); + return await branchOldTcs.Task; + }); + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("new-branch", It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + ct.Register(() => branchNewTcs.TrySetCanceled(ct)); + return await branchNewTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("old-branch"); + Assert.True(vm.IsLoadingVersions); + + vm.SubscribeToBranchCommand.Execute("new-branch"); + + var newArtifacts = new List + { + new("0.0.1400-new-branch", "9998887", null, 3001, "https://github.com/test/run/3", 701, "genhub-velopack-linux-0.0.1400-new-branch", DateTime.UtcNow, "https://github.com/test/art/3", 2048), + }; + branchNewTcs.TrySetResult(newArtifacts); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(branchOldTcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1400-new-branch", vm.SelectedVersion.Version); + } + + /// + /// Verifies that unsubscribing cancels in-flight loads and clears available versions and selection. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task Unsubscribe_CancelsInFlightLoadsAndClearsAvailableVersionsAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var prTcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => prTcs.TrySetCanceled(ct)); + return await prTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToPrCommand.Execute(391); + Assert.True(vm.IsLoadingVersions); + + vm.UnsubscribeCommand.Execute(null); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while ((vm.IsLoadingVersions || vm.AvailableVersions.Count > 0) && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(prTcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Empty(vm.AvailableVersions); + Assert.Null(vm.SelectedVersion); + } + + /// + /// Verifies that OpenPullRequestUrlCommand executes without error for valid and invalid PR numbers. + /// + /// The PR number under test. + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void OpenPullRequestUrlCommand_ExecutesWithoutException(int prNumber) + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + // verify command execution does not throw + vm.OpenPullRequestUrlCommand.Execute(prNumber); + Assert.NotNull(vm); + } + + /// + /// Verifies that changing the sort option reorders available pull requests accordingly. + /// + [Fact] + public void SelectedSortOption_ReordersAvailablePullRequests() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + var now = DateTimeOffset.UtcNow; + var pr100 = new PullRequestInfo { Number = 100, Title = "PR 100", BranchName = "b1", Author = "a1", State = "open", UpdatedAt = now.AddDays(-2) }; + var pr200 = new PullRequestInfo { Number = 200, Title = "PR 200", BranchName = "b2", Author = "a2", State = "open", UpdatedAt = now.AddDays(-10) }; + var pr300 = new PullRequestInfo { Number = 300, Title = "PR 300", BranchName = "b3", Author = "a3", State = "open", UpdatedAt = now }; + + vm.AvailablePullRequests.Add(pr100); + vm.AvailablePullRequests.Add(pr200); + vm.AvailablePullRequests.Add(pr300); + + // sort by PR number descending + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberDesc; + Assert.Equal(300, vm.AvailablePullRequests[0].Number); + Assert.Equal(200, vm.AvailablePullRequests[1].Number); + Assert.Equal(100, vm.AvailablePullRequests[2].Number); + + // sort by PR number ascending + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberAsc; + Assert.Equal(100, vm.AvailablePullRequests[0].Number); + Assert.Equal(200, vm.AvailablePullRequests[1].Number); + Assert.Equal(300, vm.AvailablePullRequests[2].Number); + + // sort by last updated (newest first) + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionLastUpdated; + Assert.Equal(300, vm.AvailablePullRequests[0].Number); + Assert.Equal(100, vm.AvailablePullRequests[1].Number); + Assert.Equal(200, vm.AvailablePullRequests[2].Number); + } + + /// + /// Verifies that tab commands correctly switch between Update and Browse Builds tabs. + /// + [Fact] + public void TabCommands_UpdatesSelectedTabIndexAndIsBrowseTabSelected() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + vm.ShowBrowseBuildsTabCommand.Execute(null); + Assert.Equal(1, vm.SelectedTabIndex); + Assert.True(vm.IsBrowseTabSelected); + + vm.ShowUpdateTabCommand.Execute(null); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + vm.SelectTabCommand.Execute("1"); + Assert.Equal(1, vm.SelectedTabIndex); + Assert.True(vm.IsBrowseTabSelected); + + vm.SelectTabCommand.Execute(0); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + // Clamping out-of-range inputs + vm.SelectTabCommand.Execute(-1); + Assert.Equal(0, vm.SelectedTabIndex); + + vm.SelectTabCommand.Execute(5); + Assert.Equal(1, vm.SelectedTabIndex); + + vm.SelectTabCommand.Execute("99"); + Assert.Equal(1, vm.SelectedTabIndex); + } + + /// + /// Verifies that DisplayCurrentVersion and InstalledVersionDisplay return a valid non-empty version string. + /// + [Fact] + public void DisplayCurrentVersion_ReturnsNonEmptyVersion() + { + var displayVersion = UpdateNotificationViewModel.DisplayCurrentVersion; + Assert.False(string.IsNullOrWhiteSpace(displayVersion)); + Assert.StartsWith("v", displayVersion); + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(displayVersion, vm.InstalledVersionDisplay); + } + + /// + /// Verifies that setting SelectedVersion to a newer artifact updates StatusMessage and sets IsUpdateAvailable to true. + /// + [Fact] + public void SelectedVersion_WhenNewer_UpdatesStatusMessageAndIsUpdateAvailable() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + var newerArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024); + vm.SelectedVersion = newerArtifact; + + Assert.True(vm.IsUpdateAvailable); + Assert.Equal("0.0.99999-pr389", vm.LatestVersion); + Assert.Contains("0.0.99999-pr389", vm.StatusMessage); + } + + /// + /// Verifies that selecting an artifact matching dismissed version clears IsUpdateAvailable, LatestVersion, and ReleaseNotesUrl. + /// + [Fact] + public void SelectedVersion_WhenDismissed_ClearsUpdateAvailableState() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { DismissedUpdateVersion = "0.0.99999-pr389" }); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object) + { + IsUpdateAvailable = true, + LatestVersion = "0.0.88888", + ReleaseNotesUrl = "https://example.com/notes", + }; + + var dismissedArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024); + vm.SelectedVersion = dismissedArtifact; + + Assert.False(vm.IsUpdateAvailable); + Assert.Empty(vm.LatestVersion); + Assert.Empty(vm.ReleaseNotesUrl); + Assert.Contains("dismissed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that Unsubscribe resets subscription fields, clears update available state, and updates status message. + /// + [Fact] + public void Unsubscribe_ClearsArtifactUpdateStateAndSwitchesToMain() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 389 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 389); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object) + { + SubscribedPr = new PullRequestInfo + { + Number = 389, + Title = "Test PR", + BranchName = "feature/test", + Author = "testuser", + State = "open", + }, + SelectedVersion = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + IsUpdateAvailable = true, + LatestVersion = "0.0.99999-pr389", + ReleaseNotesUrl = "https://example.com/notes", + }; + + vm.UnsubscribeCommand.Execute(null); + + Assert.Null(vm.SubscribedPr); + Assert.Null(vm.SubscribedBranch); + Assert.Null(vm.SelectedVersion); + Assert.False(vm.IsUpdateAvailable); + Assert.Empty(vm.LatestVersion); + Assert.Empty(vm.ReleaseNotesUrl); + Assert.False(string.IsNullOrEmpty(vm.StatusMessage)); + Assert.Null(mockVelopack.Object.SubscribedPrNumber); + } + + /// + /// Verifies that InitializeAsync seeds SubscribedPr immediately from user settings. + /// + [Fact] + public void Constructor_WhenPrSubscribedInSettings_SeedsSubscribedPr() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 242 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(242, mockVelopack.Object.SubscribedPrNumber); + Assert.NotNull(vm.SubscribedPr); + Assert.Equal(242, vm.SubscribedPr.Number); + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 21b6cd066..3be9893e8 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -1,11 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; using GenHub.Core.Models.Validation; using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content; @@ -15,14 +22,15 @@ namespace GenHub.Tests.Core.Features.Content; public class BaseContentProviderTests { /// - /// Verifies that PrepareContentAsync validates manifest before preparation. + /// Verifies that PrepareContentAsync validates manifest before preparation and executes post-install steps. /// /// A task representing the asynchronous operation. [Fact] - public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() + public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallStepsAsync() { // Arrange var validatorMock = new Mock(); + var instructionsMock = new Mock(); var loggerMock = new Mock(); var discovererMock = new Mock(); var resolverMock = new Mock(); @@ -40,7 +48,22 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() }) .ReturnsAsync(validationResult); - var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object); + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); // Act var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); @@ -48,9 +71,101 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() // Assert Assert.True(result.Success); validatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once); + instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", "Test Provider", false, It.IsAny>(), It.IsAny()), Times.Once); validatorMock.Verify(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()), Times.Once); } + /// + /// Verifies that PrepareContentAsync fails and triggers rollback when post-install steps fail. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task PrepareContentAsync_FailsWhenPostInstallStepsFailAsync() + { + // Arrange + var validatorMock = new Mock(); + var instructionsMock = new Mock(); + var loggerMock = new Mock(); + var discovererMock = new Mock(); + var resolverMock = new Mock(); + var delivererMock = new Mock(); + + var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" }; + var validationResult = new ValidationResult(manifest.Id, new List()); + + validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(validationResult); + + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Post-install step execution error")); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); + + // Act + var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); + + // Assert + Assert.False(result.Success); + Assert.Contains("Post-install step execution error", result.FirstError); + Assert.True(provider.RollbackCalled); + } + + /// + /// Verifies that PrepareContentAsync triggers rollback when post-install steps are canceled. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task PrepareContentAsync_CancelsAndTriggersRollbackAsync() + { + // Arrange + var validatorMock = new Mock(); + var instructionsMock = new Mock(); + var loggerMock = new Mock(); + var discovererMock = new Mock(); + var resolverMock = new Mock(); + var delivererMock = new Mock(); + + var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" }; + var validationResult = new ValidationResult(manifest.Id, new List()); + + validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(validationResult); + + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => provider.PrepareContentAsync(manifest, "/tmp/test")); + + Assert.True(provider.RollbackCalled); + } + /// /// Verifies that PrepareContentAsync fails when manifest validation fails with errors. /// @@ -60,6 +175,7 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync( { // Arrange var validatorMock = new Mock(); + var instructionsMock = new Mock(); var loggerMock = new Mock(); var discovererMock = new Mock(); var resolverMock = new Mock(); @@ -75,7 +191,13 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync( validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) .ReturnsAsync(validationResult); - var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object); + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); // Act var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); @@ -94,13 +216,16 @@ private class TestContentProvider : BaseContentProvider private readonly IContentResolver _resolver; private readonly IContentDeliverer _deliverer; + public bool RollbackCalled { get; private set; } + public TestContentProvider( IContentValidator validator, + IInstallationInstructionsService instructionsService, ILogger logger, IContentDiscoverer discoverer, IContentResolver resolver, IContentDeliverer deliverer) - : base(validator, logger) + : base(validator, instructionsService, logger) { _discoverer = discoverer; _resolver = resolver; @@ -117,9 +242,19 @@ public TestContentProvider( protected override IContentDeliverer Deliverer => _deliverer; - public override Task> GetValidatedContentAsync(string contentId, CancellationToken cancellationToken = default) + public override Task> GetValidatedContentAsync( + string contentId, + CancellationToken cancellationToken = default) { - var manifest = new ContentManifest { Id = contentId, Name = $"Content {contentId}" }; + var manifest = new ContentManifest + { + Id = ManifestId.Create(contentId), + Name = "Test Content", + Version = "1.0.0", + ContentType = ContentType.Map, + TargetGame = GameType.Generals, + }; + return Task.FromResult(OperationResult.CreateSuccess(manifest)); } @@ -128,5 +263,15 @@ protected override Task> PrepareContentInternal { return Task.FromResult(OperationResult.CreateSuccess(manifest)); } + + protected override Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + RollbackCalled = true; + return Task.CompletedTask; + } } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs index 5422334ce..33d4f7d2a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -475,4 +475,62 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate await Assert.ThrowsAnyAsync( () => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token)); } + + /// + /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_DeduplicatesResultsById_PrefersSpecializedProviderOverGitHubAsync() + { + // Arrange + var specializedProviderMock = new Mock(); + var githubProviderMock = new Mock(); + + const string duplicateId = "1.0.thesuperhackers.patch.generalsgamepatch2"; + + var specializedResult = new ContentSearchResult + { + Id = duplicateId, + Name = "TheSuperHackers Patch 2", + ProviderName = "thesuperhackers", + }; + + var githubResult = new ContentSearchResult + { + Id = duplicateId, + Name = "GeneralsGamePatch2", + ProviderName = "GitHub", + }; + + specializedProviderMock.Setup(p => p.IsEnabled).Returns(true); + specializedProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([specializedResult])); + + githubProviderMock.Setup(p => p.IsEnabled).Returns(true); + githubProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([githubResult])); + + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [githubProviderMock.Object, specializedProviderMock.Object], + [], + [], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object); + + // Act + var result = await orchestrator.SearchAsync(new ContentSearchQuery()); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal("thesuperhackers", items[0].ProviderName); + Assert.Equal("TheSuperHackers Patch 2", items[0].Name); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index a50f687ff..8ecfe2931 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -46,12 +46,23 @@ public GitHubContentProviderTests() _validatorMock.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new ValidationResult("test", [])); + var instructionsMock = new Mock(); + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _provider = new GitHubContentProvider( [_discovererMock.Object], [_resolverMock.Object], [_delivererMock.Object], _loggerMock.Object, - _validatorMock.Object); + _validatorMock.Object, + instructionsMock.Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs new file mode 100644 index 000000000..2eb524a35 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -0,0 +1,1000 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content; + +/// +/// Unit tests for . +/// +public sealed class InstallationInstructionsServiceTests : IDisposable +{ + private readonly string _tempDirectory; + private readonly Mock _hashProviderMock; + private readonly Mock _notificationServiceMock; + private readonly Mock _userSettingsServiceMock; + private readonly UserSettings _userSettings; + private readonly InstallationInstructionsService _service; + + /// + /// Initializes a new instance of the class. + /// + public InstallationInstructionsServiceTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-inst-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDirectory); + + _hashProviderMock = new Mock(); + _notificationServiceMock = new Mock(); + _userSettingsServiceMock = new Mock(); + _userSettings = new UserSettings(); + + _userSettingsServiceMock.Setup(u => u.Get()).Returns(_userSettings); + _userSettingsServiceMock.Setup(u => u.Update(It.IsAny>())) + .Callback>(action => action(_userSettings)); + + _service = new InstallationInstructionsService( + _hashProviderMock.Object, + _notificationServiceMock.Object, + _userSettingsServiceMock.Object, + NullLogger.Instance); + } + + /// + /// Cleans up temporary resources after test execution. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + // Ignore cleanup error + } + } + } + + /// + /// Verifies that executing post-install steps succeeds when no steps are declared. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_NullOrEmptySteps_ReturnsSuccess() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions(); + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.True(result.Success); + } + + /// + /// Verifies that executing installer steps from an untrusted provider fails even if manifest metadata claims to be trusted. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_UntrustedProvider_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Malicious Executable", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "malicious.exe", + }, + ], + }; + + // Manifest claims GeneralsOnline, but providerSource is untrusted + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source"); + + Assert.False(result.Success); + Assert.Contains("not authorized to execute installation steps", result.FirstError); + } + + /// + /// Verifies that mutating steps like RemoveFile and RenameFile fail and do not modify files on disk when provider is untrusted. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_UntrustedProvider_MutatingSteps_FailExecution() + { + var importantFilePath = Path.Combine(_tempDirectory, "important.dat"); + var sourceFilePath = Path.Combine(_tempDirectory, "source.dat"); + var destFilePath = Path.Combine(_tempDirectory, "dest.dat"); + + await File.WriteAllTextAsync(importantFilePath, "important content"); + await File.WriteAllTextAsync(sourceFilePath, "source content"); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Delete Something", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = "important.dat", + }, + new InstallationStep + { + Name = "Rename Something", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "source.dat", + DestinationRelativePath = "dest.dat", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source"); + + Assert.False(result.Success); + Assert.Contains("not authorized to execute installation steps", result.FirstError); + Assert.True(File.Exists(importantFilePath)); + Assert.True(File.Exists(sourceFilePath)); + Assert.False(File.Exists(destFilePath)); + } + + /// + /// Verifies that paths attempting directory traversal are rejected. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_PathTraversalTarget_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Traverse Path", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = @"../../outside.exe", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that installer executables not declared in the manifest files list fail. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_FileNotInManifest_FailsExecution() + { + var targetFile = "installer.exe"; + var fullPath = Path.Combine(_tempDirectory, targetFile); + File.WriteAllText(fullPath, "binary content"); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = []; // Empty files list - installer not declared + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Undeclared Installer", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = targetFile, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("not declared in manifest files", result.FirstError); + } + + /// + /// Verifies that hash mismatch during installer integrity check fails execution. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_HashMismatch_FailsExecution() + { + var targetFile = "installer.exe"; + var fullPath = Path.Combine(_tempDirectory, targetFile); + File.WriteAllText(fullPath, "binary content"); + + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync("actual_hash_value"); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = targetFile, + Hash = "expected_different_hash", + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Corrupted Installer", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = targetFile, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("Integrity verification failed", result.FirstError); + } + + /// + /// Verifies that remove file steps successfully delete the target file. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RemoveFile_DeletesTargetFile() + { + var fileToRemove = "temp_cache.tmp"; + var fullPath = Path.Combine(_tempDirectory, fileToRemove); + File.WriteAllText(fullPath, "temporary content"); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Remove Cache", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = fileToRemove, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.False(File.Exists(fullPath)); + } + + /// + /// Verifies that rename file steps successfully move target files. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() + { + var sourceFile = "source.txt"; + var destFile = Path.Combine("subfolder", "dest.txt"); + var sourceFullPath = Path.Combine(_tempDirectory, sourceFile); + var destFullPath = Path.Combine(_tempDirectory, destFile); + + File.WriteAllText(sourceFullPath, "hello world"); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename File", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = sourceFile, + DestinationRelativePath = destFile, + StepKey = "test_rename_step", + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.False(File.Exists(sourceFullPath)); + Assert.True(File.Exists(destFullPath)); + Assert.Equal("hello world", File.ReadAllText(destFullPath)); + Assert.True(_userSettings.IsInstallationStepExecuted("test_rename_step")); + } + + /// + /// Verifies that verified installer execution runs and dispatches user notifications. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotification() + { + var scriptName = OperatingSystem.IsWindows() ? "test_installer.exe" : "test_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "test_installer_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [], + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.True(_userSettings.IsInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey)); + _notificationServiceMock.Verify( + n => n.ShowInfo( + GeneralsOnlineConstants.EacStepName, + GeneralsOnlineConstants.EacStatusMessage, + It.IsAny(), + It.IsAny()), + Times.Once); + _notificationServiceMock.Verify( + n => n.ShowSuccess( + "Installation Step Completed", + It.Is(msg => msg.Contains(GeneralsOnlineConstants.EacStepName)), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that run-once steps already recorded in user settings are skipped. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsExecution() + { + var scriptName = "installer.bat"; + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile { RelativePath = scriptName }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + // Mark as already executed + _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey); + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + + // Notification should NOT be shown for skipped step + _notificationServiceMock.Verify( + n => n.ShowInfo(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that forcing execution re-runs run-once steps even if recorded in settings. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_ExecutesEvenIfRecorded() + { + var scriptName = OperatingSystem.IsWindows() ? "test_force_installer.exe" : "test_force_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "test_force_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [], + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + // Mark as already executed in settings + _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey); + + // Force execution + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline, force: true); + + Assert.True(result.Success); + _notificationServiceMock.Verify( + n => n.ShowInfo( + GeneralsOnlineConstants.EacStepName, + GeneralsOnlineConstants.EacStatusMessage, + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that unknown installation step kinds return failure. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_UnknownKind_ReturnsFailure() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Unknown Step", + Kind = InstallationStepKind.Unknown, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("Unsupported installation step kind", result.FirstError); + } + + /// + /// Verifies that elevated steps fail with an unsupported result on non-Windows platforms. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_ElevationOnNonWindows_ReturnsFailure() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var scriptName = "elevated_script.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + const string expectedHash = "elevated_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Elevated Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + RequiresElevation = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("requires administrator elevation, which is only supported on Windows", result.FirstError); + } + + /// + /// Verifies that remove file steps reject paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RemoveFile_PathTraversalTarget_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Remove Escape", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = "../../outside.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that rename file steps reject source paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_SourcePathTraversal_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename Source Escape", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "../../outside.tmp", + DestinationRelativePath = "dest.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that rename file steps reject destination paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_DestinationPathTraversal_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename Destination Escape", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "source.tmp", + DestinationRelativePath = "../../outside.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that cancellation token terminates the running process and throws OperationCanceledException. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_CallerCancellation_TerminatesProcessAndThrows() + { + var scriptName = OperatingSystem.IsWindows() ? "sleep_installer.exe" : "sleep_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nsleep 30\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "sleep_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Long Running Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "ping", "-n", "30", "127.0.0.1"] : [], + }, + ], + }; + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(200)); + + await Assert.ThrowsAnyAsync(() => + _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline, + cancellationToken: cts.Token)); + } + + /// + /// Verifies that when a precondition is fulfilled, execution is skipped and the step key is recorded. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_PreconditionFulfilled_SkipsExecutionAndRecordsStepKey() + { + var preconditionMock = new Mock(); + preconditionMock.Setup(p => p.CanHandle(It.IsAny(), It.IsAny())).Returns(true); + preconditionMock.Setup(p => p.IsAlreadyFulfilled(It.IsAny(), It.IsAny())).Returns(true); + + var serviceWithPrecondition = new InstallationInstructionsService( + _hashProviderMock.Object, + _notificationServiceMock.Object, + _userSettingsServiceMock.Object, + [preconditionMock.Object], + NullLogger.Instance); + + const string stepKey = "test:precondition:step"; + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Preconditioned Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "nonexistent.exe", + StepKey = stepKey, + RunOnce = true, + }, + ], + }; + + var result = await serviceWithPrecondition.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.True(_userSettings.IsInstallationStepExecuted(stepKey)); + } + + /// + /// Verifies that verification fails when a step target file has no declared hash in the manifest. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_NoDeclaredHash_FailsVerification() + { + var scriptName = "installer_nohash.exe"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + File.WriteAllText(fullPath, "binary content"); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = string.Empty, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "No Hash Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("has no declared hash", result.FirstError); + } + + /// + /// Verifies that an installer process exiting with a non-zero exit code produces an execution failure. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_NonZeroExitCode_FailsExecution() + { + var scriptName = OperatingSystem.IsWindows() ? "exit_error.cmd" : "exit_error.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + File.WriteAllText(fullPath, "exit /b 42\r\n"); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 42\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "exit_error_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Failing Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("failed with exit code", result.FirstError); + } + + /// + /// 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/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs new file mode 100644 index 000000000..90ac6ba7d --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs @@ -0,0 +1,273 @@ +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); + } + + 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/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/GeneralsOnlineDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs index 4e5d7c096..ec5ff6a19 100644 --- 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 @@ -561,6 +561,74 @@ await Assert.ThrowsAsync( 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); 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 a016b37fc..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; @@ -91,4 +92,34 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectlyAsync() 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 index 04e9b80a9..443824a51 100644 --- 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 @@ -129,6 +129,115 @@ public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_MarksSix 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() { @@ -170,7 +279,7 @@ private void WriteFile(string relativePath) File.WriteAllText(fullPath, relativePath); } - private async Task CreateGameClientManifestAsync() + private async Task CreateGameClientManifestAsync(ContentManifest? originalManifest = null) { var providerLoader = new Mock(); var factory = new GeneralsOnlineManifestFactory( @@ -178,7 +287,7 @@ private async Task CreateGameClientManifestAsync() providerLoader.Object); var manifests = await factory.CreateManifestsFromExtractedContentAsync( - CreateOriginalManifest(), + 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 index 68a1e4434..200f82338 100644 --- 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 @@ -401,4 +401,33 @@ public void DependencyBuilder_GetDependenciesForGameData_ReturnsExpectedDependen 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/GitHub/GitHubContentDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs index 118fbb21a..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; @@ -83,4 +90,284 @@ public Task DeliverContentAsync_ShouldExtractZip_ForMatchingContentTypesAsync(Ge 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/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 3afee1b7b..0f9e6feb1 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -84,8 +84,9 @@ public async Task StartProcessAsync_WithExpectedChild_TracksTheChildWhileTheLaun { if (!OperatingSystem.IsWindows()) { - // Process.GetProcessesByName does not enumerate these processes on macOS, so adoption - // cannot be observed there. The behaviour is Windows-only in practice. + // 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; } @@ -163,7 +164,10 @@ public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_FailsWi stopwatch.Stop(); Assert.False(result.Success); - Assert.Contains("without starting", string.Join(", ", result.Errors)); + 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}."); @@ -195,10 +199,60 @@ public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_Reports Assert.False(result.Success); var errors = string.Join(", ", result.Errors); - Assert.Contains("without starting", 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 @@ -370,10 +424,14 @@ private sealed class LauncherHarness : IDisposable /// File the launcher writes its own PID into, so Dispose can stop it. private const string LauncherPidFileName = "launcher.pid"; - private LauncherHarness(string workingDirectory, string launcherPath) + /// 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. @@ -382,15 +440,24 @@ private LauncherHarness(string workingDirectory, string launcherPath) /// 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) + 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); @@ -415,7 +482,9 @@ public static LauncherHarness Create(bool spawnChild = true, bool exitImmediatel } else { - launcherPath = Path.Combine(workingDirectory, "genhublauncher.sh"); + 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"; @@ -427,8 +496,9 @@ public static LauncherHarness Create(bool spawnChild = true, bool exitImmediatel File.WriteAllText(launcherPath, script); MakeExecutable(launcherPath); MakeExecutable(childPath); + SignForLocalExecution(childPath); - return new LauncherHarness(workingDirectory, launcherPath); + return new LauncherHarness(workingDirectory, launcherPath, CanExecute(childPath)); } /// @@ -481,6 +551,82 @@ private static string LongRunningSystemBinary() 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()) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs new file mode 100644 index 000000000..c7490ef60 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs @@ -0,0 +1,799 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Common; +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.Features.GameProfiles.ViewModels; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels; + +/// +/// Contains tests for . +/// +public class AddLocalContentViewModelTests : IDisposable +{ + private readonly Mock _localContentServiceMock; + private readonly Mock _contentStorageServiceMock; + private readonly Mock _normalizationServiceMock; + private readonly Mock _dialogServiceMock; + private readonly List _tempDirectories = []; + private readonly List _viewModels = []; + + /// + /// Initializes a new instance of the class. + /// + public AddLocalContentViewModelTests() + { + _localContentServiceMock = new Mock(); + _contentStorageServiceMock = new Mock(); + _normalizationServiceMock = new Mock(); + _dialogServiceMock = new Mock(); + + _localContentServiceMock + .Setup(x => x.AllowedContentTypes) + .Returns(AddLocalContentViewModel.AllowedContentTypes); + + _normalizationServiceMock + .Setup(x => x.DetectGenLauncherFilesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new GenLauncherDetectionResult()); + } + + /// + /// Cleans up temporary test directories and viewmodels. + /// + public void Dispose() + { + foreach (var vm in _viewModels) + { + vm.Dispose(); + } + + foreach (var dir in _tempDirectories) + { + try + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that the ViewModel initializes with proper defaults. + /// + [Fact] + public void Constructor_InitializesWithDefaultValues() + { + var vm = CreateViewModel(); + + Assert.NotNull(vm); + Assert.Equal(ContentType.Mod, vm.SelectedContentType); + Assert.Equal(GameType.ZeroHour, vm.SelectedGameType); + Assert.Empty(vm.ContentName); + Assert.Empty(vm.SourcePath); + Assert.Empty(vm.FileTree); + Assert.False(vm.IsEditing); + Assert.False(vm.CanAdd); + Assert.False(vm.ShowExecutableSelection); + Assert.Null(vm.SelectedExecutableItem); + Assert.Equal(0, vm.ExecutableCount); + Assert.Equal("Add Local Content", vm.DialogTitle); + Assert.Equal("Add to Library", vm.ActionButtonText); + Assert.Contains(ContentType.GameClient, AddLocalContentViewModel.AllowedContentTypes); + Assert.Contains(ContentType.ModdingTool, AddLocalContentViewModel.AllowedContentTypes); + Assert.Contains(ContentType.Executable, AddLocalContentViewModel.AllowedContentTypes); + } + + /// + /// Verifies that PreviewIdleText changes based on SelectedContentType. + /// + /// The content type under test. + /// The expected idle description text. + [Theory] + [InlineData(ContentType.Mod, "Import mod content (e.g. .big, .zip)")] + [InlineData(ContentType.GameClient, "Import GameClient")] + [InlineData(ContentType.Executable, "Import executable")] + [InlineData(ContentType.ModdingTool, "Import tool executable")] + [InlineData(ContentType.Patch, "Import patch")] + [InlineData(ContentType.Addon, "Import addon content")] + [InlineData(ContentType.Map, "Import map files")] + [InlineData(ContentType.MapPack, "Import map pack files")] + [InlineData(ContentType.Mission, "Import mission content")] + public void PreviewIdleText_ReturnsExpectedDescriptions(ContentType type, string expectedText) + { + var vm = CreateViewModel(); + vm.SelectedContentType = type; + + Assert.Equal(expectedText, vm.PreviewIdleText); + } + + /// + /// Verifies that ShowExecutableSelection is true when ExecutableCount > 0 for GameClient, ModdingTool, and Executable. + /// + /// The content type under test. + /// The number of detected executables. + /// The expected boolean indicating whether executable selection is shown. + [Theory] + [InlineData(ContentType.GameClient, 1, true)] + [InlineData(ContentType.GameClient, 2, true)] + [InlineData(ContentType.ModdingTool, 1, true)] + [InlineData(ContentType.ModdingTool, 2, true)] + [InlineData(ContentType.Executable, 1, true)] + [InlineData(ContentType.Executable, 2, true)] + [InlineData(ContentType.GameClient, 0, false)] + [InlineData(ContentType.ModdingTool, 0, false)] + [InlineData(ContentType.Executable, 0, false)] + [InlineData(ContentType.Mod, 1, false)] + [InlineData(ContentType.Mod, 2, false)] + [InlineData(ContentType.Patch, 1, false)] + [InlineData(ContentType.Map, 1, false)] + public void ShowExecutableSelection_EvaluatesCorrectly_BasedOnContentTypeAndExecutableCount( + ContentType contentType, + int executableCount, + bool expectedShow) + { + var vm = CreateViewModel(); + vm.SelectedContentType = contentType; + vm.ExecutableCount = executableCount; + + Assert.Equal(expectedShow, vm.ShowExecutableSelection); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for GameClient. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForGameClient_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "generals.exe"); + var dataPath = Path.Combine(tempDir, "data.ini"); + File.WriteAllText(exePath, "fake-exe-content"); + File.WriteAllText(dataPath, "fake-data"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("generals.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for ModdingTool. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForModdingTool_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "FinalBIG.exe"); + var dataPath = Path.Combine(tempDir, "readme.txt"); + File.WriteAllText(exePath, "fake-exe-content"); + File.WriteAllText(dataPath, "read me"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("FinalBIG.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for Executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForExecutable_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "WorldBuilder.exe"); + File.WriteAllText(exePath, "fake-exe-content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Executable; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("WorldBuilder.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that switching to an executable content type triggers auto-selection if an executable is in the tree. + /// + /// The executable content type to switch to. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task SelectedContentTypeChanged_ToExecutableType_AutoSelectsFirstExecutable(ContentType newType) + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Launcher.exe"); + File.WriteAllText(exePath, "fake-exe-content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Mod; + + await vm.ImportContentAsync(tempDir); + + // When imported as Mod, no auto-selection happened + Assert.Null(vm.SelectedExecutableItem); + Assert.False(vm.ShowExecutableSelection); + + // Switch to executable type + vm.SelectedContentType = newType; + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("Launcher.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + Assert.True(vm.ShowExecutableSelection); + } + + /// + /// Verifies manual selection of an executable via SelectExecutableCommand. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SelectExecutableCommand_SwitchesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + var exe1Path = Path.Combine(tempDir, "Primary.exe"); + var exe2Path = Path.Combine(tempDir, "Secondary.exe"); + File.WriteAllText(exe1Path, "fake-exe-1"); + File.WriteAllText(exe2Path, "fake-exe-2"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(2, vm.ExecutableCount); + Assert.NotNull(vm.SelectedExecutableItem); + + var initialSelected = vm.SelectedExecutableItem!; + var otherItem = FindInTree(vm.FileTree, f => f != initialSelected && f.IsExecutable); + Assert.NotNull(otherItem); + Assert.False(otherItem!.IsSelectedExecutable); + + // Select the other executable + vm.SelectExecutableCommand.Execute(otherItem); + + Assert.Equal(otherItem.Name, vm.SelectedExecutableItem.Name); + Assert.True(otherItem.IsSelectedExecutable); + Assert.False(initialSelected.IsSelectedExecutable); + } + + /// + /// Verifies that SelectExecutableCommand ignores non-executable files. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SelectExecutableCommand_IgnoresNonExecutableItem() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Tool.exe"); + var txtPath = Path.Combine(tempDir, "Doc.txt"); + File.WriteAllText(exePath, "fake-exe"); + File.WriteAllText(txtPath, "text"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Executable; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name); + + var txtItem = FindInTree(vm.FileTree, f => f.Name == "Doc.txt"); + Assert.NotNull(txtItem); + Assert.False(txtItem!.IsExecutable); + + vm.SelectExecutableCommand.Execute(txtItem); + + // Should still be Tool.exe + Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name); + Assert.False(txtItem.IsSelectedExecutable); + } + + /// + /// Verifies that CanAdd validation requires an executable for GameClient, ModdingTool, and Executable. + /// + /// The executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task Validation_CanAdd_RequiresExecutable_ForExecutableTypes(ContentType type) + { + var tempDir = CreateTempDirectory(); + var txtPath = Path.Combine(tempDir, "config.ini"); + File.WriteAllText(txtPath, "config"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Tool"; + + await vm.ImportContentAsync(tempDir); + + // No executable found, so CanAdd should be false + Assert.Null(vm.SelectedExecutableItem); + Assert.False(vm.CanAdd); + } + + /// + /// Verifies that CanAdd is true for non-executable types without an executable. + /// + /// The non-executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.Mod)] + [InlineData(ContentType.Patch)] + [InlineData(ContentType.Addon)] + [InlineData(ContentType.Map)] + [InlineData(ContentType.MapPack)] + [InlineData(ContentType.Mission)] + public async Task Validation_CanAdd_DoesNotRequireExecutable_ForNonExecutableTypes(ContentType type) + { + var tempDir = CreateTempDirectory(); + var txtPath = Path.Combine(tempDir, "mod_data.big"); + File.WriteAllText(txtPath, "big archive data"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Mod"; + + await vm.ImportContentAsync(tempDir); + + Assert.True(vm.CanAdd); + } + + /// + /// Verifies that CanAdd is true when an executable is present for GameClient, ModdingTool, and Executable. + /// + /// The executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task Validation_CanAdd_IsTrue_WhenExecutableIsPresent(ContentType type) + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Main.exe"); + File.WriteAllText(exePath, "exe content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Item"; + + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.True(vm.CanAdd); + } + + /// + /// Verifies that AddContentCommand forwards the relative entry point to ILocalContentService.CreateLocalContentManifestAsync. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_PassesEntryPoint_ToCreateLocalContentManifestAsync() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Game.exe"); + File.WriteAllText(exePath, "exe"); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.gameclient.test"), + Name = "Test Game Client", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + EntryPoint = "Game.exe", + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Game Client"; + + // Import individual file so it lands at the root of staging + await vm.ImportContentAsync(exePath); + + Assert.True(vm.CanAdd); + + await vm.AddContentCommand.ExecuteAsync(null); + + Assert.Equal("Game.exe", capturedEntryPoint); + Assert.NotNull(vm.CreatedContentItem); + } + + /// + /// Verifies that AddContentCommand with nested executable passes correct relative path as entryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_WithNestedExecutable_PassesRelativePathEntryPoint() + { + var tempDir = CreateTempDirectory(); + var subDir = Path.Combine(tempDir, "bin"); + Directory.CreateDirectory(subDir); + var exePath = Path.Combine(subDir, "tool.exe"); + File.WriteAllText(exePath, "tool exe"); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.moddingtool.tool"), + Name = "My Tool", + ContentType = ContentType.ModdingTool, + TargetGame = GameType.ZeroHour, + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + vm.ContentName = "My Tool"; + + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + + await vm.AddContentCommand.ExecuteAsync(null); + + var dirName = Path.GetFileName(tempDir); + Assert.Equal($"{dirName}/bin/tool.exe", capturedEntryPoint); + } + + /// + /// Verifies that LoadFromManifestAsync preserves the manifest EntryPoint when reloading for edit. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task LoadFromManifestAsync_PreservesManifestEntryPoint() + { + var manifestId = ManifestId.Create("1.0.local.gameclient.zh"); + + var manifest = new ContentManifest + { + Id = manifestId, + Name = "ZH Client", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + EntryPoint = "special.exe", + Files = + [ + new ManifestFile { RelativePath = "special.exe", IsExecutable = true }, + new ManifestFile { RelativePath = "bin/decoy.exe", IsExecutable = true }, + ], + }; + + _contentStorageServiceMock + .Setup(x => x.RetrieveContentAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, targetPath, _) => + { + Directory.CreateDirectory(targetPath); + File.WriteAllText(Path.Combine(targetPath, "special.exe"), "exe"); + var targetSub = Path.Combine(targetPath, "bin"); + Directory.CreateDirectory(targetSub); + File.WriteAllText(Path.Combine(targetSub, "decoy.exe"), "decoy"); + }) + .ReturnsAsync((ManifestId _, string targetPath, CancellationToken _) => OperationResult.CreateSuccess(targetPath)); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.UpdateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var item = new GenHub.Features.GameProfiles.ViewModels.ContentDisplayItem + { + Id = manifestId.Value, + ManifestId = manifestId, + DisplayName = "ZH Client", + ContentType = ContentType.GameClient, + GameType = GameType.ZeroHour, + InstallationType = GameInstallationType.Unknown, + Manifest = manifest, + }; + + var vm = CreateViewModel(); + await vm.LoadFromManifestAsync(item); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("special.exe", vm.SelectedExecutableItem.Name); + + await vm.AddContentCommand.ExecuteAsync(null); + Assert.Equal("special.exe", capturedEntryPoint); + } + + /// + /// Verifies that deleting an unrelated item preserves the previously selected executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteItemAsync_PreservesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + var readme = FindInTree(vm.FileTree, f => f.Name == "readme.txt"); + Assert.NotNull(readme); + await vm.DeleteItemCommand.ExecuteAsync(readme); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("second.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that deleting the currently selected executable falls back to auto-selecting the remaining executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteItemAsync_WhenSelectedExecutableDeleted_FallsBackToRemainingExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + await vm.DeleteItemCommand.ExecuteAsync(secondExe); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("first.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that switching content type away from executable and back preserves the selected entry point. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ContentTypeChanged_SwitchAwayAndBack_PreservesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + // Switch to Mod (non-executable type) + vm.SelectedContentType = ContentType.Mod; + Assert.Null(vm.SelectedExecutableItem); + + // Switch back to GameClient (executable type) + vm.SelectedContentType = ContentType.GameClient; + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("second.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that BuildDirectoryTree prioritizes directories containing executables over non-executable directories. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task BuildDirectoryTree_PrioritizesDirectoriesWithExecutables() + { + var tempDir = CreateTempDirectory(); + + // Create 25 directories named folder01 to folder25 + for (var i = 1; i <= 25; i++) + { + var folder = Path.Combine(tempDir, $"folder{i:D2}"); + Directory.CreateDirectory(folder); + File.WriteAllText(Path.Combine(folder, "data.txt"), "content"); + } + + // Put an executable only in the 25th folder + var targetFolder = Path.Combine(tempDir, "folder25"); + File.WriteAllText(Path.Combine(targetFolder, "game.exe"), "executable"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var folder25 = FindInTree(vm.FileTree, f => f.Name == "folder25"); + Assert.NotNull(folder25); + + var exe = FindInTree(folder25.Children, f => f.Name == "game.exe"); + Assert.NotNull(exe); + Assert.True(exe.IsExecutable); + } + + /// + /// Verifies that switching from an executable type to a non-executable type clears the selected executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ContentTypeChanged_FromExecutableToNonExecutable_ClearsSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "game.exe"), "game"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + + vm.SelectedContentType = ContentType.Mod; + + Assert.Null(vm.SelectedExecutableItem); + } + + /// + /// Verifies that AddContentCommand with non-executable content type passes null as entryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_WhenNonExecutableType_PassesNullEntryPoint() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "somefile.txt"), "text"); + + string? capturedEntryPoint = "INITIAL"; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.mod.test"), + Name = "My Mod", + ContentType = ContentType.Mod, + TargetGame = GameType.ZeroHour, + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Mod; + vm.ContentName = "My Mod"; + await vm.ImportContentAsync(tempDir); + + await vm.AddContentCommand.ExecuteAsync(null); + + Assert.Null(capturedEntryPoint); + } + + private static FileTreeItem? FindInTree(IEnumerable items, Func predicate) + { + foreach (var item in items) + { + if (predicate(item)) return item; + var child = FindInTree(item.Children, predicate); + if (child != null) return child; + } + + return null; + } + + private string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "AddLocalContentVmTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + _tempDirectories.Add(path); + return path; + } + + private AddLocalContentViewModel CreateViewModel() + { + var vm = new AddLocalContentViewModel( + _localContentServiceMock.Object, + _contentStorageServiceMock.Object, + _normalizationServiceMock.Object, + _dialogServiceMock.Object, + NullLogger.Instance); + _viewModels.Add(vm); + return vm; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs index 92737c49d..094306d9e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs @@ -18,6 +18,7 @@ using GenHub.Features.Content.Services.Publishers; using GenHub.Features.GameProfiles.Services; using GenHub.Features.GameProfiles.ViewModels; +using GenHub.Features.GameProfiles.ViewModels.Wizard; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -311,6 +312,95 @@ public void GenerateUniqueProfileName_CreatesUniqueName() Assert.Equal($"Test Profile {string.Format(ProfileConstants.CopyNameNumberedFormat, 3)}", uniqueName); } + /// + /// Verifies that ScanForGamesCommand creates zero profiles when the wizard is skipped/cancelled. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task ScanForGamesCommand_WhenWizardCancelled_CreatesZeroProfilesAsync() + { + var installationService = new Mock(); + var installation = new GameInstallation(Path.Combine("C:", "Steam", "Games"), GameInstallationType.Steam, new Mock>().Object); + installation.PopulateGameClients([ + new GameClient + { + Id = "cp-client", + Name = "Community Patch", + PublisherType = CommunityOutpostConstants.PublisherType, + GameType = GameType.ZeroHour, + }, + ]); + var installations = new List { installation }; + + installationService.Setup(x => x.GetAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess(installations)); + + var shortcutService = new Mock(); + var notificationService = new Mock(); + var publisherOrchestrator = new Mock(); + var profileManager = new Mock(); + var editorFacade = new Mock(); + + var setupWizardService = new Mock(); + setupWizardService.Setup(x => x.RunSetupWizardAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new SetupWizardResult + { + Confirmed = false, + CommunityPatchAction = GameClientConstants.WizardActionTypes.Install, + }); + + var vm = new GameProfileLauncherViewModel( + installationService.Object, + profileManager.Object, + null!, + null!, + editorFacade.Object, + null!, + null!, + shortcutService.Object, + publisherOrchestrator.Object, + new Mock().Object, + CreateProfileResourceService(), + new Mock().Object, + notificationService.Object, + setupWizardService.Object, + new Mock().Object, + NullLogger.Instance); + + await vm.ScanForGamesCommand.ExecuteAsync(null); + + Assert.Equal("Scan complete. Found 1 installations, created 0 profiles", vm.StatusMessage); + publisherOrchestrator.Verify( + x => x.CreateProfilesForPublisherClientAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + profileManager.Verify( + x => x.CreateProfileAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that SetupWizardItemViewModel strips leading 'v' or 'V' prefix. + /// + /// The input version string. + /// The expected sanitized version string. + [Theory] + [InlineData("v081326_QFE3", "081326_QFE3")] + [InlineData("vweekly-2026-08-14", "weekly-2026-08-14")] + [InlineData("v02-08-2026", "02-08-2026")] + [InlineData("V1.04", "1.04")] + [InlineData("1.08", "1.08")] + [InlineData(" v1.04 ", "1.04")] + [InlineData(" 1.08 ", "1.08")] + public void SetupWizardItemViewModel_Version_StripsLeadingVPrefix(string rawVersion, string expectedVersion) + { + var item = new SetupWizardItemViewModel + { + Version = rawVersion, + }; + + Assert.Equal(expectedVersion, item.Version); + } + private static ProfileResourceService CreateProfileResourceService() { return new ProfileResourceService(NullLogger.Instance); @@ -337,7 +427,8 @@ private static SuperHackersProvider CreateSuperHackersProvider() [resolverMock.Object], [delivererMock.Object], new Mock().Object, - NullLogger.Instance); + NullLogger.Instance, + new Mock().Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameSettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameSettingsViewModelTests.cs index fa0903692..206abcbb4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameSettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameSettingsViewModelTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using GenHub.Core.Constants; using GenHub.Core.Extensions; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Models.Enums; @@ -395,6 +397,461 @@ public async Task SaveSettings_Should_HandleFailureGracefullyAsync() Assert.Contains("Failed to save settings", _viewModel.StatusMessage); } + /// + /// Should keep settings.json keys the view model does not model when saving over them. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_PreserveUnknownGeneralsOnlineKeysAsync() + { + // Arrange + var existing = new GeneralsOnlineSettings(); + existing.AdditionalSettings["auth_token"] = JsonSerializer.Deserialize("\"preserve-me\""); + + _gameSettingsServiceMock.Setup(x => x.LoadOptionsAsync(GameType.ZeroHour)) + .ReturnsAsync(OperationResult.CreateSuccess(new IniOptions())); + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + Assert.True(saved.AdditionalSettings.ContainsKey("auth_token"), "client-owned key was dropped"); + Assert.Equal("preserve-me", saved.AdditionalSettings["auth_token"].GetString()); + } + + /// + /// Should leave settings.json alone when it could not be read, because a missing file reads as + /// defaults and reports success: a failed read means the client's own file exists and is + /// unreadable, and rewriting it from defaults would discard everything the client owns. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotRewriteGeneralsOnlineSettings_WhenTheyCannotBeReadAsync() + { + // Arrange + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + // The file was readable when the editor opened and is not when the save reads it again + _gameSettingsServiceMock.SetupSequence(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + Assert.Contains("settings.json is locked", _viewModel.StatusMessage); + } + + /// + /// Should save a settings.json that spells a nested section as an explicit null, which is + /// valid JSON and leaves the section null once deserialized. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_HandleNullGeneralsOnlineSectionsAsync() + { + // Arrange + var existing = new GeneralsOnlineSettings { Camera = null!, Chat = null!, Debug = null!, Render = null!, Social = null! }; + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var profile = CreateGeneralsOnlineProfile(); + profile.GoCameraMinHeight = 200.0f; + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + Assert.Equal(200.0f, saved.Camera.MinHeight); + Assert.Contains("saved successfully", _viewModel.StatusMessage); + } + + /// + /// Should read settings.json again immediately before rewriting it, rather than reusing what + /// initialization read. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_ReadGeneralsOnlineSettings_BeforeRewritingAsync() + { + // Arrange + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert - once to seed the view model, once more as the baseline for the rewrite + _gameSettingsServiceMock.Verify(x => x.LoadGeneralsOnlineSettingsAsync(), Times.Exactly(2)); + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.Is(s => s.ShowFps)), + Times.Once); + } + + /// + /// Should build every save on what settings.json holds at that moment, so that changes the + /// GeneralsOnline client made while this editor was open are not reverted by the rewrite. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_RewriteWhatSettingsJsonHoldsNow_NotWhatItHeldAtInitializationAsync() + { + // Arrange + var atInitialization = new GeneralsOnlineSettings(); + atInitialization.AdditionalSettings["auth_token"] = JsonSerializer.Deserialize("\"old-token\""); + + var writtenByTheClientSince = new GeneralsOnlineSettings { ChatFontSize = 24 }; + writtenByTheClientSince.AdditionalSettings["auth_token"] = JsonSerializer.Deserialize("\"new-token\""); + + _gameSettingsServiceMock.SetupSequence(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(atInitialization)) + .ReturnsAsync(OperationResult.CreateSuccess(writtenByTheClientSince)); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + Assert.True(saved.AdditionalSettings.ContainsKey("auth_token"), "client-owned key was dropped"); + Assert.Equal("new-token", saved.AdditionalSettings["auth_token"].GetString()); + } + + /// + /// Should leave settings.json alone when the view model was never seeded from it, because the + /// view model has no unset state and would otherwise write its own defaults over every option + /// the user configured inside the GeneralsOnline client. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotRewriteGeneralsOnlineSettings_WhenSeedingFailedAsync() + { + // Arrange - the read fails while the view model is seeded, then recovers before the save + _gameSettingsServiceMock.SetupSequence(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + Assert.Contains("Options.ini saved", _viewModel.StatusMessage); + Assert.Contains("GeneralsOnline settings not written", _viewModel.StatusMessage); + } + + /// + /// Should never report that nothing was saved once Options.ini has been written, because the + /// Options.ini write happens before the settings.json rewrite is gated and a user told the save + /// failed outright would redo work that is already on disk. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotReportTotalFailure_WhenOnlyTheGeneralsOnlineWriteIsSkippedAsync() + { + // Arrange - seeding fails, so the save may not rewrite settings.json + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.DoesNotContain("Failed to save settings", _viewModel.StatusMessage); + Assert.Contains("Options.ini saved", _viewModel.StatusMessage); + Assert.Contains("never read", _viewModel.StatusMessage); + Assert.True(_viewModel.OptionsFileExists); + } + + /// + /// Should report Options.ini as written when the settings.json rewrite itself is refused, which + /// is the same split outcome as a refused read reached through a later step. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_ReportOptionsIniSaved_WhenTheGeneralsOnlineWriteFailsAsync() + { + // Arrange + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is read-only")); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.DoesNotContain("Failed to save settings", _viewModel.StatusMessage); + Assert.Contains("Options.ini saved", _viewModel.StatusMessage); + Assert.Contains("settings.json is read-only", _viewModel.StatusMessage); + } + + /// + /// Should report settings.json as written when it is the Options.ini write that fails, because + /// the rewrite is attempted regardless of how the Options.ini write went. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_ReportGeneralsOnlineSaved_WhenTheOptionsIniWriteFailsAsync() + { + // Arrange + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Options.ini is read-only")); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.DoesNotContain("Failed to save settings", _viewModel.StatusMessage); + Assert.Contains("GeneralsOnline settings saved", _viewModel.StatusMessage); + Assert.Contains("Options.ini is read-only", _viewModel.StatusMessage); + } + + /// + /// Should still report a plain failure when neither file was written, so the split reporting + /// does not soften an outcome where nothing landed. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_ReportTotalFailure_WhenNeitherFileIsWrittenAsync() + { + // Arrange + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Options.ini is read-only")); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is read-only")); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.Contains("Failed to save settings", _viewModel.StatusMessage); + Assert.Contains("Options.ini is read-only", _viewModel.StatusMessage); + Assert.Contains("settings.json is read-only", _viewModel.StatusMessage); + } + + /// + /// Should not carry one profile's settings.json read into the next profile, because saving the + /// second profile would then rewrite the file from a reading taken for the first. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeForProfileAsync_Should_NotReuseThePreviousProfilesSettingsAsync() + { + // Arrange + _gameSettingsServiceMock.SetupSequence(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var first = CreateGeneralsOnlineProfile(); + first.GoShowFps = true; + + var second = CreateGeneralsOnlineProfile(); + second.Id = "go-profile-2"; + second.GoShowFps = false; + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", first); + await _viewModel.InitializeForProfileAsync("go-profile-2", second); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + } + + /// + /// Should keep the values a user configured inside the GeneralsOnline client when saving a + /// profile that declares only some GeneralsOnline options. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotOverwriteClientValues_TheProfileDoesNotDeclareAsync() + { + // Arrange - the client's values are all the opposite of the view model's defaults + var existing = new GeneralsOnlineSettings + { + ShowPing = false, + ShowPlayerRanks = false, + RememberUsername = false, + EnableNotifications = false, + EnableSoundNotifications = false, + ChatFontSize = 24, + }; + + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + Assert.True(saved.ShowFps); + Assert.False(saved.ShowPing); + Assert.False(saved.ShowPlayerRanks); + Assert.False(saved.RememberUsername); + Assert.False(saved.EnableNotifications); + Assert.False(saved.EnableSoundNotifications); + Assert.Equal(24, saved.ChatFontSize); + } + + /// + /// Should not turn the client's enabled toggles off when nothing has read them, which is what + /// a view model default of false would do to a model that defaults them to true. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotFlipEnabledTogglesOffAsync() + { + // Arrange - settings.json does not exist yet, which reads as defaults, so the defaults decide + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + var expected = new GeneralsOnlineSettings(); + Assert.Equal(expected.ShowPing, saved.ShowPing); + Assert.Equal(expected.ShowPlayerRanks, saved.ShowPlayerRanks); + Assert.Equal(expected.RememberUsername, saved.RememberUsername); + Assert.Equal(expected.EnableNotifications, saved.EnableNotifications); + Assert.Equal(expected.EnableSoundNotifications, saved.EnableSoundNotifications); + Assert.Equal(expected.ChatFontSize, saved.ChatFontSize); + } + + /// + /// Should leave the GeneralsOnline client's global settings.json alone when the profile being + /// edited runs some other client. + /// + /// The publisher the profile's client belongs to. + /// The game the profile targets. + /// A representing the asynchronous operation. + [Theory] + [InlineData(PublisherTypeConstants.TheSuperHackers, GameType.ZeroHour)] + [InlineData(CommunityOutpostConstants.PublisherType, GameType.ZeroHour)] + [InlineData(PublisherTypeConstants.TheSuperHackers, GameType.Generals)] + public async Task SaveSettings_Should_NotWriteGeneralsOnlineSettings_ForOtherPublishersAsync(string publisherType, GameType gameType) + { + // Arrange + var profile = new GameProfile + { + Id = "other-profile", + Name = "Other Profile", + GameClient = new GameClient { GameType = gameType, PublisherType = publisherType }, + VideoResolutionWidth = 1920, + }; + + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(gameType, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("other-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + Assert.Contains("saved successfully", _viewModel.StatusMessage); + } + /// /// Should update selected preset when resolution matches preset. /// @@ -418,4 +875,18 @@ public void ApplyOptionsToViewModel_Should_UpdateSelectedPreset_WhenResolutionMa // Assert Assert.Equal("1920x1080", _viewModel.SelectedResolutionPreset); } + + private static GameProfile CreateGeneralsOnlineProfile() + { + return new GameProfile + { + Id = "go-profile", + Name = "GeneralsOnline Profile", + GameClient = new GameClient + { + GameType = GameType.ZeroHour, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + }; + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs index ec8f7c927..e4f86269e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs @@ -1,5 +1,6 @@ using System.Reactive.Linq; using GenHub.Common.ViewModels; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; @@ -14,6 +15,8 @@ using GenHub.Core.Interfaces.Tools; using GenHub.Core.Interfaces.UserData; using GenHub.Core.Interfaces.Workspace; +using GenHub.Core.Messages; +using GenHub.Core.Models.AppUpdate; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Notifications; @@ -160,6 +163,368 @@ public async Task InitializeAsync_MultipleCallsAreSafeAsync() Assert.True(true); } + /// + /// Tests that receiving updates periodic update timer settings without throwing. + /// + [Fact] + public void Receive_UpdateSettingsChangedMessage_UpdatesPeriodicTimer() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var mockVelopackUpdateManager = new Mock(); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + // Act & Assert (should not throw when enabling/disabling or changing interval) + vm.Receive(new UpdateSettingsChangedMessage(false, true, 30)); + vm.Receive(new UpdateSettingsChangedMessage(false, false, 30)); + Assert.True(true); + } + + /// + /// Tests that when AutoCheckForUpdatesOnStartup is false, background update check is not triggered on initialize. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenAutoCheckForUpdatesOnStartupFalse_DoesNotCheckUpdatesOnStartupAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings + { + AutoCheckForUpdatesOnStartup = false, + AutoCheckForUpdatesPeriodically = false, + SubscribedBranch = "main", + }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var mockVelopackUpdateManager = new Mock(); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + await vm.InitializeAsync(); + await Task.Delay(100); + + mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.Never); + mockVelopackUpdateManager.Verify(x => x.CheckForUpdatesAsync(It.IsAny()), Times.Never); + } + + /// + /// Tests that background update check queries artifact updates when subscribed to a PR. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenSubscribedToPr_ChecksArtifactUpdatesAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 265 }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var updateCheckedTcs = new TaskCompletionSource(); + var mockVelopackUpdateManager = new Mock(); + mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(() => + { + updateCheckedTcs.TrySetResult(true); + return Task.FromResult(null); + }); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + await vm.InitializeAsync(); + + // Await deterministic completion of background check + await updateCheckedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Tests that background update check queries artifact updates when subscribed to a branch. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenSubscribedToBranch_ChecksArtifactUpdatesAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "main" }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var updateCheckedTcs = new TaskCompletionSource(); + var mockVelopackUpdateManager = new Mock(); + mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(() => + { + updateCheckedTcs.TrySetResult(true); + return Task.FromResult(null); + }); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + await vm.InitializeAsync(); + + // Await deterministic completion of background check + await updateCheckedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Tests that background update check shows an update notification with update action and triggers progress notification on click. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenArtifactUpdateAvailable_ShowsNotificationWithUpdateActionAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "main" }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var shownNotifications = new List(); + var notificationShownTcs = new TaskCompletionSource(); + + var artifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-main", + GitHash: "abcdef1", + 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 installStartedTcs = new TaskCompletionSource(); + var mockVelopackUpdateManager = new Mock(); + mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync(artifactInfo); + mockVelopackUpdateManager.Setup(x => x.InstallArtifactAsync( + artifactInfo, + It.IsAny>(), + It.IsAny())) + .Callback(() => installStartedTcs.TrySetResult(true)) + .Returns(Task.CompletedTask); + + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + shownNotifications.Add(msg); + if (msg.Title == AppUpdateConstants.BranchUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + // Act + await vm.InitializeAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + Assert.NotNull(updateNotification); + Assert.Equal(AppUpdateConstants.BranchUpdateAvailableNotificationTitle, updateNotification.Title); + Assert.Single(updateNotification.Actions); + Assert.Equal(AppUpdateConstants.UpdateAction, updateNotification.Actions[0].Text); + + // Act - simulate clicking the update action button + updateNotification.Actions[0].Callback?.Invoke(); + + // Await background install execution deterministically + await installStartedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert that progress notification was displayed + mockVelopackUpdateManager.Verify(x => x.InstallArtifactAsync(artifactInfo, It.IsAny>(), It.IsAny()), Times.Once); + Assert.Contains(shownNotifications, n => n.Title == AppUpdateConstants.UpdatingAppNotificationTitle); + } + + /// + /// Tests that background update check does not create duplicate notifications when the same update is detected repeatedly. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenSameArtifactUpdateCheckedRepeatedly_DeduplicatesNotificationAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "main" }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var shownNotifications = new List(); + var notificationShownTcs = new TaskCompletionSource(); + + var artifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-main", + GitHash: "abcdef1", + 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 mockVelopackUpdateManager = new Mock(); + mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync(artifactInfo); + + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + shownNotifications.Add(msg); + if (msg.Title == AppUpdateConstants.BranchUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + using var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + // Act + await vm.InitializeAsync(); + await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Re-trigger update check via second initialize call and message receipt + vm.Receive(new UpdateSettingsChangedMessage(true, true, 5)); + await vm.InitializeAsync(); + await Task.Delay(200); + + // Assert that branch update notification was shown exactly once + var branchUpdateNotifications = shownNotifications + .Where(n => n.Title == AppUpdateConstants.BranchUpdateAvailableNotificationTitle) + .ToList(); + Assert.Single(branchUpdateNotifications); + } + /// /// Tests that CurrentTabViewModel returns the correct ViewModel based on SelectedTab. /// @@ -253,6 +618,7 @@ private static (SettingsViewModel SettingsVm, Mock UserSet var mockInstallationService = new Mock(); var mockStorageLocationService = new Mock(); var mockUserDataTracker = new Mock(); + var mockDialogService = new Mock(); var mockGitHubTokenStorage = new Mock(); var settingsVm = new SettingsViewModel( @@ -268,6 +634,7 @@ private static (SettingsViewModel SettingsVm, Mock UserSet mockInstallationService.Object, mockStorageLocationService.Object, mockUserDataTracker.Object, + mockDialogService.Object, mockGitHubTokenStorage.Object); return (settingsVm, mockUserSettings); } @@ -365,6 +732,7 @@ private static Mock CreateNotificationServiceMock() 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/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs index caadee096..f0222fa22 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs @@ -39,6 +39,7 @@ public class SettingsViewModelTests private readonly Mock _mockInstallationService; private readonly Mock _mockStorageLocationService; private readonly Mock _mockUserDataTracker; + private readonly Mock _mockDialogService; private readonly UserSettings _defaultSettings; /// @@ -58,9 +59,13 @@ public SettingsViewModelTests() _mockInstallationService = new Mock(); _mockStorageLocationService = new Mock(); _mockUserDataTracker = new Mock(); + _mockDialogService = new Mock(); _defaultSettings = new UserSettings(); _mockConfigService.Setup(x => x.Get()).Returns(_defaultSettings); + _mockUserDataTracker + .Setup(x => x.DeleteAllUserDataAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); } /// @@ -93,7 +98,8 @@ public void Constructor_LoadsSettingsFromUserSettingsService() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Assert Assert.Equal("Light", viewModel.Theme); @@ -122,7 +128,8 @@ public async Task SaveSettingsCommand_UpdatesUserSettingsServiceAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { Theme = "Light", MaxConcurrentDownloads = 5, @@ -156,7 +163,8 @@ public async Task ResetToDefaultsCommand_ResetsAllPropertiesAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { Theme = "Light", MaxConcurrentDownloads = 10, @@ -171,6 +179,88 @@ public async Task ResetToDefaultsCommand_ResetsAllPropertiesAsync() Assert.Equal(3, viewModel.MaxConcurrentDownloads); Assert.False(viewModel.EnableDetailedLogging); Assert.Equal(WorkspaceConstants.DefaultWorkspaceStrategy, viewModel.DefaultWorkspaceStrategy); + Assert.True(viewModel.AutoCheckForUpdatesPeriodically); + Assert.Equal(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes, viewModel.PeriodicUpdateCheckIntervalMinutes); + } + + /// + /// Verifies that periodic update settings are correctly loaded from UserSettings. + /// + [Fact] + public void Constructor_LoadsPeriodicUpdateSettingsFromUserSettingsService() + { + // Arrange + var customSettings = new UserSettings + { + AutoCheckForUpdatesPeriodically = false, + PeriodicUpdateCheckIntervalMinutes = 15, + }; + + _mockConfigService.Setup(x => x.Get()).Returns(customSettings); + + // Act + var viewModel = new SettingsViewModel( + _mockConfigService.Object, + _mockLogger.Object, + _mockCasService.Object, + _mockProfileManager.Object, + _mockWorkspaceManager.Object, + _mockManifestPool.Object, + _mockUpdateManager.Object, + _mockNotificationService.Object, + _mockConfigurationProvider.Object, + _mockInstallationService.Object, + _mockStorageLocationService.Object, + _mockUserDataTracker.Object, + _mockDialogService.Object); + + // Assert + Assert.False(viewModel.AutoCheckForUpdatesPeriodically); + Assert.Equal(15, viewModel.PeriodicUpdateCheckIntervalMinutes); + } + + /// + /// Verifies that SaveSettingsCommand persists periodic update settings. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task SaveSettingsCommand_UpdatesPeriodicUpdateSettingsAsync() + { + // Arrange + var viewModel = new SettingsViewModel( + _mockConfigService.Object, + _mockLogger.Object, + _mockCasService.Object, + _mockProfileManager.Object, + _mockWorkspaceManager.Object, + _mockManifestPool.Object, + _mockUpdateManager.Object, + _mockNotificationService.Object, + _mockConfigurationProvider.Object, + _mockInstallationService.Object, + _mockStorageLocationService.Object, + _mockUserDataTracker.Object, + _mockDialogService.Object) + { + AutoCheckForUpdatesPeriodically = false, + PeriodicUpdateCheckIntervalMinutes = 45, + }; + + UserSettings? capturedSettings = null; + _mockConfigService.Setup(x => x.Update(It.IsAny>())) + .Callback>(action => + { + capturedSettings = new UserSettings(); + action(capturedSettings); + }); + + // Act + await Task.Run(() => viewModel.SaveSettingsCommand.Execute(null)); + + // Assert + Assert.NotNull(capturedSettings); + Assert.False(capturedSettings.AutoCheckForUpdatesPeriodically); + Assert.Equal(45, capturedSettings.PeriodicUpdateCheckIntervalMinutes); } /// @@ -192,7 +282,8 @@ public void MaxConcurrentDownloads_SetsValueWithinBounds() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { // Act & Assert - Test lower bound MaxConcurrentDownloads = 0, @@ -227,7 +318,8 @@ public void AvailableThemes_ReturnsExpectedValues() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act var themes = SettingsViewModel.AvailableThemes.ToList(); @@ -257,7 +349,8 @@ public void AvailableWorkspaceStrategies_ReturnsAllEnumValues() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act var strategies = SettingsViewModel.AvailableWorkspaceStrategies.ToList(); @@ -289,7 +382,8 @@ public async Task SaveSettingsCommand_HandlesUserSettingsServiceExceptionAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act await Task.Run(() => viewModel.SaveSettingsCommand.Execute(null)); @@ -327,7 +421,8 @@ public void Constructor_HandlesUserSettingsServiceException() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Assert - Should not throw and use defaults Assert.Equal("Dark", viewModel.Theme); @@ -367,7 +462,8 @@ public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabledAsyn _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act await viewModel.DeleteCasStorageCommand.ExecuteAsync(null); @@ -410,7 +506,8 @@ public async Task UninstallGenHubCommand_CallsServiceAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act await viewModel.UninstallGenHubCommand.ExecuteAsync(null); @@ -418,4 +515,207 @@ public async Task UninstallGenHubCommand_CallsServiceAsync() // Assert _mockUpdateManager.Verify(x => x.Uninstall(), Times.Once); } + + /// + /// Verifies that declining the confirmation prompt leaves every piece of application data alone. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WhenConfirmationDeclined_DeletesNothingAsync() + { + // Arrange + SetupDeletableData(); + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + _mockUserDataTracker.Verify(x => x.DeleteAllUserDataAsync(It.IsAny()), Times.Never); + _mockCasService.Verify(x => x.RunGarbageCollectionAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockInstallationService.Verify(x => x.InvalidateCache(), Times.Never); + _mockProfileManager.Verify(x => x.DeleteProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockWorkspaceManager.Verify(x => x.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockManifestPool.Verify(x => x.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that a confirmation prompt that fails to open — no main window, or an Avalonia + /// failure — is reported to the user instead of escaping the command unlogged, and that it still + /// deletes nothing. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WhenConfirmationThrows_ReportsErrorAndDeletesNothingAsync() + { + // Arrange + SetupDeletableData(); + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("no main window")); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + _mockNotificationService.Verify( + x => x.ShowError(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + _mockUserDataTracker.Verify(x => x.DeleteAllUserDataAsync(It.IsAny()), Times.Never); + _mockProfileManager.Verify(x => x.DeleteProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockWorkspaceManager.Verify(x => x.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockManifestPool.Verify(x => x.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that accepting the confirmation prompt performs the deletion. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WhenConfirmationAccepted_DeletesAllDataAsync() + { + // Arrange + SetupDeletableData(); + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + _mockUserDataTracker.Verify(x => x.DeleteAllUserDataAsync(It.IsAny()), Times.Once); + _mockCasService.Verify(x => x.RunGarbageCollectionAsync(true, It.IsAny()), Times.Once); + _mockInstallationService.Verify(x => x.InvalidateCache(), Times.Once); + _mockProfileManager.Verify(x => x.DeleteProfileAsync("profile-to-delete", It.IsAny()), Times.Once); + _mockWorkspaceManager.Verify(x => x.CleanupWorkspaceAsync("workspace-to-delete", It.IsAny()), Times.Once); + _mockManifestPool.Verify(x => x.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + /// + /// Verifies that a user data deletion that had to keep some data is not followed by a success + /// message claiming that data was deleted. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WhenUserDataPartiallyDeleted_DoesNotClaimSuccessAsync() + { + // Arrange + SetupDeletableData(); + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + _mockUserDataTracker + .Setup(x => x.DeleteAllUserDataAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Your originals were kept at 'backups'.")); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + _mockNotificationService.Verify( + x => x.ShowError("User Data Partially Deleted", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + _mockNotificationService.Verify( + x => x.ShowSuccess("Data Deleted", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockNotificationService.Verify( + x => x.ShowWarning("Data Partially Deleted", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + /// + /// Verifies that the confirmation prompt states the action is irreversible and that game data + /// backups are discarded, and that it cannot be suppressed by a "do not ask again" preference. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WarnsThatBackupsAreDiscardedAndCannotBeSuppressedAsync() + { + // Arrange + string? capturedMessage = null; + string? capturedSessionKey = null; + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((title, message, confirmText, cancelText, sessionKey) => + { + capturedMessage = message; + capturedSessionKey = sessionKey; + }) + .ReturnsAsync(false); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + Assert.Equal(AppConstants.DeleteAllDataConfirmationMessage, capturedMessage); + Assert.Contains("irreversible", capturedMessage!, StringComparison.OrdinalIgnoreCase); + Assert.Contains("backups", capturedMessage!, StringComparison.OrdinalIgnoreCase); + Assert.Null(capturedSessionKey); + } + + private void SetupDeletableData() + { + _mockProfileManager + .Setup(x => x.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([new GameProfile { Id = "profile-to-delete" }])); + _mockWorkspaceManager + .Setup(x => x.GetAllWorkspacesAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([new WorkspaceInfo { Id = "workspace-to-delete" }])); + _mockManifestPool + .Setup(x => x.GetAllManifestsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([new ContentManifest { Name = "manifest-to-delete" }])); + } + + private SettingsViewModel CreateViewModel() => new( + _mockConfigService.Object, + _mockLogger.Object, + _mockCasService.Object, + _mockProfileManager.Object, + _mockWorkspaceManager.Object, + _mockManifestPool.Object, + _mockUpdateManager.Object, + _mockNotificationService.Object, + _mockConfigurationProvider.Object, + _mockInstallationService.Object, + _mockStorageLocationService.Object, + _mockUserDataTracker.Object, + _mockDialogService.Object); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModelTests.cs new file mode 100644 index 000000000..f5e4fa111 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModelTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using GenHub.Features.GameProfiles.ViewModels.Wizard; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels.Wizard; + +/// +/// Unit tests for . +/// +public class SetupWizardViewModelTests +{ + /// + /// Verifies that the constructor initializes labels and items accurately. + /// + [Fact] + public void Constructor_InitializesLabelsAndItemsCorrectly() + { + var items = new List + { + new() { Title = "Item 1", IsSelected = true, IsMandatory = false }, + new() { Title = "Item 2", IsSelected = true, IsMandatory = false }, + new() { Title = "Item 3", IsSelected = false, IsMandatory = false }, + }; + + var vm = new SetupWizardViewModel(items); + + Assert.Equal(3, vm.Items.Count); + Assert.Equal("Setup Detected Content", vm.Title); + Assert.Equal("Skip", vm.CancelLabel); + Assert.Equal("Continue (2)", vm.ConfirmLabel); + Assert.False(vm.Confirmed); + } + + /// + /// Verifies that ToggleSelectionCommand toggles item selection for non-mandatory items. + /// + [Fact] + public void ToggleSelectionCommand_WhenItemNonMandatory_TogglesSelectionAndUpdatesLabel() + { + var item1 = new SetupWizardItemViewModel { Title = "Item 1", IsSelected = true, IsMandatory = false }; + var item2 = new SetupWizardItemViewModel { Title = "Item 2", IsSelected = false, IsMandatory = false }; + var vm = new SetupWizardViewModel([item1, item2]); + + Assert.Equal("Continue (1)", vm.ConfirmLabel); + + vm.ToggleSelectionCommand.Execute(item1); + + Assert.False(item1.IsSelected); + Assert.Equal("Continue", vm.ConfirmLabel); + + vm.ToggleSelectionCommand.Execute(item2); + + Assert.True(item2.IsSelected); + Assert.Equal("Continue (1)", vm.ConfirmLabel); + } + + /// + /// Verifies that ToggleSelectionCommand ignores mandatory items. + /// + [Fact] + public void ToggleSelectionCommand_WhenItemMandatory_DoesNotToggleSelection() + { + var mandatoryItem = new SetupWizardItemViewModel { Title = "Mandatory Item", IsSelected = true, IsMandatory = true }; + var vm = new SetupWizardViewModel([mandatoryItem]); + + Assert.Equal("Continue (1)", vm.ConfirmLabel); + + vm.ToggleSelectionCommand.Execute(mandatoryItem); + + Assert.True(mandatoryItem.IsSelected); + Assert.Equal("Continue (1)", vm.ConfirmLabel); + } + + /// + /// Verifies that ToggleSelectionCommand does nothing when item is null. + /// + [Fact] + public void ToggleSelectionCommand_WhenItemNull_DoesNothing() + { + var item = new SetupWizardItemViewModel { Title = "Item 1", IsSelected = true, IsMandatory = false }; + var vm = new SetupWizardViewModel([item]); + + vm.ToggleSelectionCommand.Execute(null); + + Assert.True(item.IsSelected); + Assert.Equal("Continue (1)", vm.ConfirmLabel); + } + + /// + /// Verifies that ConfirmCommand sets Confirmed to true and signals close. + /// + [Fact] + public void ConfirmCommand_SetsConfirmedAndFiresCloseRequested() + { + var item = new SetupWizardItemViewModel { Title = "Item 1", IsSelected = true }; + var vm = new SetupWizardViewModel([item]); + var closeFired = false; + vm.CloseRequested += (_, _) => closeFired = true; + + vm.ConfirmCommand.Execute(null); + + Assert.True(vm.Confirmed); + Assert.True(closeFired); + } + + /// + /// Verifies that CancelCommand sets Confirmed to false and signals close. + /// + [Fact] + public void CancelCommand_SetsConfirmedFalseAndFiresCloseRequested() + { + var item = new SetupWizardItemViewModel { Title = "Item 1", IsSelected = true }; + var vm = new SetupWizardViewModel([item]); + var closeFired = false; + vm.CloseRequested += (_, _) => closeFired = true; + + vm.CancelCommand.Execute(null); + + Assert.False(vm.Confirmed); + Assert.True(closeFired); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameSettings/GameSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameSettings/GameSettingsServiceTests.cs index 1b8831de0..99b3f6dd2 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameSettings/GameSettingsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameSettings/GameSettingsServiceTests.cs @@ -1,9 +1,11 @@ +using GenHub.Core.Constants; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameSettings; using GenHub.Features.GameSettings; using Microsoft.Extensions.Logging; using Moq; +using Moq.Protected; namespace GenHub.Tests.Core.Features.GameSettings; @@ -385,4 +387,160 @@ public async Task SaveOptionsAsync_Should_PreserveUnknownSectionsAsync() Assert.Contains("CustomKey=CustomValue", savedContent); Assert.Contains("AnotherKey=AnotherValue", savedContent); } + + /// + /// Should replace settings.json by moving a completed file over it, leaving nothing behind, + /// because a half-written settings.json costs the GeneralsOnline client every key it owns. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveGeneralsOnlineSettingsAsync_Should_ReplaceTheFileWithoutTruncatingItAsync() + { + // Arrange + var directory = Directory.CreateTempSubdirectory().FullName; + var settingsPath = Path.Combine(directory, GameSettingsGeneralsOnlineConstants.SettingsFileName); + await File.WriteAllTextAsync(settingsPath, "{ \"chat_font_size\": 8 }"); + var service = CreateServiceWritingGeneralsOnlineSettingsTo(settingsPath); + + try + { + // Act + var result = await service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings { ChatFontSize = 24 }); + + // Assert + Assert.True(result.Success, result.FirstError); + var reloaded = await service.LoadGeneralsOnlineSettingsAsync(); + Assert.True(reloaded.Success, reloaded.FirstError); + Assert.Equal(24, reloaded.Data!.ChatFontSize); + Assert.Empty(Directory.GetFiles(directory, $"*{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}")); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// Should report success for every one of a set of concurrent saves, which two GeneralsOnline + /// launches produce because the launch lock is per profile while settings.json is a single + /// global file. Which save wins is not defined, but none of them may be turned away: a launch + /// that reports a settings failure has lost the settings the user chose for that profile. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveGeneralsOnlineSettingsAsync_Should_SucceedForEverySave_WhenSavesOverlapAsync() + { + // Arrange + var directory = Directory.CreateTempSubdirectory().FullName; + var settingsPath = Path.Combine(directory, GameSettingsGeneralsOnlineConstants.SettingsFileName); + var service = CreateServiceWritingGeneralsOnlineSettingsTo(settingsPath); + + try + { + // Act + var fontSizes = Enumerable.Range( + GameSettingsGeneralsOnlineConstants.MinChatFontSize, + GameSettingsGeneralsOnlineConstants.MaxChatFontSize - GameSettingsGeneralsOnlineConstants.MinChatFontSize); + var results = await Task.WhenAll( + fontSizes.Select(fontSize => service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings { ChatFontSize = fontSize }))); + + // Assert + Assert.All(results, result => Assert.True(result.Success, result.FirstError)); + var reloaded = await service.LoadGeneralsOnlineSettingsAsync(); + Assert.True(reloaded.Success, reloaded.FirstError); + Assert.InRange( + reloaded.Data!.ChatFontSize, + GameSettingsGeneralsOnlineConstants.MinChatFontSize, + GameSettingsGeneralsOnlineConstants.MaxChatFontSize); + Assert.Empty(Directory.GetFiles(directory, $"*{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}")); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// Should keep both concurrent saves and concurrent loads working against the one global + /// settings.json. A load that overlaps the replacement of the file it is reading is the + /// other half of the same race, because the GameLauncher reads settings.json before every + /// save it makes. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task GeneralsOnlineSettings_Should_SucceedForEveryCall_WhenLoadsAndSavesOverlapAsync() + { + // Arrange + var directory = Directory.CreateTempSubdirectory().FullName; + var settingsPath = Path.Combine(directory, GameSettingsGeneralsOnlineConstants.SettingsFileName); + var service = CreateServiceWritingGeneralsOnlineSettingsTo(settingsPath); + await service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings { ChatFontSize = GameSettingsGeneralsOnlineConstants.DefaultChatFontSize }); + + try + { + // Act + var fontSizes = Enumerable.Range( + GameSettingsGeneralsOnlineConstants.MinChatFontSize, + GameSettingsGeneralsOnlineConstants.MaxChatFontSize - GameSettingsGeneralsOnlineConstants.MinChatFontSize) + .ToList(); + var saves = Task.WhenAll(fontSizes.Select(fontSize => service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings { ChatFontSize = fontSize }))); + var loads = Task.WhenAll(fontSizes.Select(_ => service.LoadGeneralsOnlineSettingsAsync())); + var saveResults = await saves; + var loadResults = await loads; + + // Assert + Assert.All(saveResults, result => Assert.True(result.Success, result.FirstError)); + Assert.All(loadResults, result => Assert.True(result.Success, result.FirstError)); + Assert.All( + loadResults, + result => Assert.InRange( + result.Data!.ChatFontSize, + GameSettingsGeneralsOnlineConstants.MinChatFontSize, + GameSettingsGeneralsOnlineConstants.MaxChatFontSize)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// Should report the failure once a replacement that cannot succeed has used up its + /// attempts, rather than retrying a real fault forever or claiming a save that never + /// happened, and should leave no temporary file behind when it does. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveGeneralsOnlineSettingsAsync_Should_ReportFailure_WhenTheReplacementNeverSucceedsAsync() + { + // Arrange + var directory = Directory.CreateTempSubdirectory().FullName; + var settingsPath = Path.Combine(directory, GameSettingsGeneralsOnlineConstants.SettingsFileName); + Directory.CreateDirectory(settingsPath); + var service = CreateServiceWritingGeneralsOnlineSettingsTo(settingsPath); + + try + { + // Act + var result = await service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings()); + + // Assert + Assert.False(result.Success); + Assert.Empty(Directory.GetFiles(directory, $"*{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}")); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private GameSettingsService CreateServiceWritingGeneralsOnlineSettingsTo(string settingsPath) + { + var mockService = new Mock(MockBehavior.Loose, _loggerMock.Object, _pathProviderMock.Object) + { + CallBase = true, + }; + mockService.Protected().Setup("GetGeneralsOnlineSettingsPath").Returns(settingsPath); + return mockService.Object; + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Info/DefaultInfoContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Info/DefaultInfoContentProviderTests.cs new file mode 100644 index 000000000..f61531fd4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Info/DefaultInfoContentProviderTests.cs @@ -0,0 +1,73 @@ +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Interfaces.Info; +using GenHub.Features.Info.Services; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Info; + +/// +/// Unit tests for . +/// +public class DefaultInfoContentProviderTests +{ + private readonly Mock _patchNotesServiceMock = new(); + private readonly DefaultInfoContentProvider _provider; + + /// + /// Initializes a new instance of the class. + /// + public DefaultInfoContentProviderTests() + { + _provider = new DefaultInfoContentProvider(_patchNotesServiceMock.Object); + } + + /// + /// Verifies that GetAllSectionsAsync returns all expected info sections. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task GetAllSectionsAsync_ReturnsOrderedSectionsAsync() + { + var sections = (await _provider.GetAllSectionsAsync()).ToList(); + + sections.Should().NotBeEmpty(); + sections.Should().Contain(s => s.Id == "workspaces"); + sections.Should().Contain(s => s.Id == "quickstart"); + } + + /// + /// Verifies that GetSectionAsync returns the workspace section with comprehensive strategy explanations. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task GetSectionAsync_WorkspaceSection_ContainsComprehensiveStrategyExplanationsAsync() + { + var section = await _provider.GetSectionAsync("workspaces"); + + section.Should().NotBeNull(); + section!.Title.Should().Be("Virtual Workspaces"); + section.Cards.Should().NotBeEmpty(); + + var titles = section.Cards.Select(c => c.Title).ToList(); + titles.Should().Contain("The Magic Mirror"); + titles.Should().Contain("Workspace Strategies Compared"); + titles.Should().Contain("Hardlinks vs Symlinks vs Copies: Deep Dive"); + titles.Should().Contain("Troubleshooting & Permissions"); + titles.Should().Contain("Performance Specs"); + + var comparisonCard = section.Cards.First(c => c.Title == "Workspace Strategies Compared"); + comparisonCard.DetailedContent.Should().Contain("HardLink"); + comparisonCard.DetailedContent.Should().Contain("SymlinkOnly"); + comparisonCard.DetailedContent.Should().Contain("HybridCopySymlink"); + comparisonCard.DetailedContent.Should().Contain("FullCopy"); + + var deepDiveCard = section.Cards.First(c => c.Title == "Hardlinks vs Symlinks vs Copies: Deep Dive"); + deepDiveCard.DetailedContent.Should().Contain("Hardlink"); + deepDiveCard.DetailedContent.Should().Contain("Symlink"); + deepDiveCard.DetailedContent.Should().Contain("Full Copy"); + deepDiveCard.DetailedContent.Should().Contain("Automatic Fallback"); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index 64f020d74..d178079dd 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Text.Json; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; @@ -91,6 +93,10 @@ public GameLauncherTests() .ReturnsAsync(OperationResult.CreateSuccess(new IniOptions())); _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(OperationResult.CreateSuccess(true)); + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); // Setup storage location service mock _storageLocationServiceMock.Setup(x => x.GetWorkspacePath(It.IsAny())) @@ -896,6 +902,155 @@ public async Task LaunchProfileAsync_WithoutProfileSettings_ShouldStillSaveOptio Times.Once); } + /// + /// Tests that a Zero Hour profile running some other client leaves the GeneralsOnline + /// client's settings.json alone, even when its name would match the heuristic that + /// identifies profiles with no recorded publisher. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithNonGeneralsOnlineZeroHourProfile_ShouldNotWriteGeneralsOnlineSettingsAsync() + { + // Arrange + var profile = CreateZeroHourProfile(PublisherTypeConstants.TheSuperHackers, "GeneralsOnline-compatible TheSuperHackers"); + ArrangeSuccessfulLaunch(profile); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + } + + /// + /// Tests that a GeneralsOnline profile does write its client settings. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithGeneralsOnlineProfile_ShouldWriteGeneralsOnlineSettingsAsync() + { + // Arrange + var profile = CreateZeroHourProfile(PublisherTypeConstants.GeneralsOnline, "GeneralsOnline"); + profile.GoShowFps = true; + ArrangeSuccessfulLaunch(profile); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.Is(s => s.ShowFps)), + Times.Once); + } + + /// + /// Tests that settings.json is left alone when it could not be read. A missing file reads as + /// defaults and reports success, so a failed read means the client's own file exists and is + /// unreadable, and rewriting it from defaults would discard everything the client owns. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithUnreadableGeneralsOnlineSettings_ShouldNotRewriteThemAsync() + { + // Arrange + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")); + + var profile = CreateZeroHourProfile(PublisherTypeConstants.GeneralsOnline, "GeneralsOnline"); + profile.GoShowFps = true; + ArrangeSuccessfulLaunch(profile); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + } + + /// + /// Tests that a settings.json spelling a nested section as an explicit null, which is valid + /// JSON, does not break the merge the launch performs. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithNullGeneralsOnlineSection_ShouldStillWriteSettingsAsync() + { + // Arrange + var existing = new GeneralsOnlineSettings { Camera = null! }; + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + + var profile = CreateZeroHourProfile(PublisherTypeConstants.GeneralsOnline, "GeneralsOnline"); + profile.GoCameraMinHeight = 200.0f; + ArrangeSuccessfulLaunch(profile); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + Assert.NotNull(saved); + Assert.Equal(200.0f, saved.Camera.MinHeight); + } + + /// + /// Tests that the values a user configured inside the GeneralsOnline client survive a launch + /// of a profile that says nothing about them. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithGeneralsOnlineProfile_ShouldPreserveSettingsTheProfileDoesNotSpecifyAsync() + { + // Arrange - every seeded value is the opposite of the GenHub default + var existing = new GeneralsOnlineSettings + { + ShowPing = false, + ChatFontSize = 24, + RememberUsername = false, + }; + existing.Render.FpsLimit = 60; + existing.AdditionalSettings["auth_token"] = JsonSerializer.Deserialize("\"preserve-me\""); + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + + var profile = CreateZeroHourProfile(PublisherTypeConstants.GeneralsOnline, "GeneralsOnline"); + profile.GoShowFps = true; + ArrangeSuccessfulLaunch(profile); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + Assert.NotNull(saved); + Assert.True(saved.ShowFps); + Assert.False(saved.ShowPing); + Assert.Equal(24, saved.ChatFontSize); + Assert.False(saved.RememberUsername); + Assert.Equal(60, saved.Render.FpsLimit); + Assert.True(saved.AdditionalSettings.ContainsKey("auth_token"), "client-owned key was dropped"); + Assert.Equal("preserve-me", saved.AdditionalSettings["auth_token"].GetString()); + } + /// /// Removes the temporary retail root. /// @@ -929,6 +1084,31 @@ private static GameProfile CreateTestProfile() }; } + /// + /// Creates a Zero Hour attributed to a specific publisher. + /// + /// The publisher the profile's client belongs to. + /// The client name, which is also consulted when identifying the publisher. + /// A valid Zero Hour . + private static GameProfile CreateZeroHourProfile(string publisherType, string clientName) + { + return new GameProfile + { + Id = Guid.NewGuid().ToString(), + Name = "Test Profile", + GameInstallationId = "install-1", + GameClient = new GameClient + { + Id = "version-1", + Name = clientName, + ExecutablePath = @"C:\Games\generals.exe", + GameType = GameType.ZeroHour, + PublisherType = publisherType, + }, + EnabledContentIds = ["1.0.genhub.mod.test"], + }; + } + private static bool HasArgument(GameLaunchConfiguration? config, string key) { return config?.Arguments is not null && config.Arguments.ContainsKey(key); @@ -964,4 +1144,38 @@ private static void CreateDirectoryAlias(string aliasPath, string targetPath) process.WaitForExit(); Assert.Equal(0, process.ExitCode); } + + /// + /// Wires the mocks a launch needs to reach the settings-writing step and succeed. + /// + /// The profile being launched. + private void ArrangeSuccessfulLaunch(GameProfile profile) + { + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" }; + var workspaceInfo = new WorkspaceInfo { Id = profile.Id, WorkspacePath = @"C:\workspace" }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + + // Zero Hour launches resolve their own installation path, so both roots are declared. + var installation = new GameInstallation(_retailRoot, GameInstallationType.Steam); + installation.SetPaths(_retailRoot, _retailRoot); + _gameInstallationServiceMock.Setup(x => x.GetInstallationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(installation)); + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + + _manifestPoolMock.Setup(x => x.GetManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs index aad8be697..eafa0df4c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs @@ -208,6 +208,60 @@ public void WithInstallationInstructions_SetsWorkspaceStrategy() Assert.Equal(WorkspaceStrategy.FullCopy, result.InstallationInstructions.WorkspaceStrategy); } + /// + /// Tests that WithInstallationInstructions sets the full installation instructions object. + /// + [Fact] + public void WithInstallationInstructions_SetsCompleteObject() + { + var instructions = new InstallationInstructions + { + WorkspaceStrategy = WorkspaceStrategy.FullCopy, + DownloadHash = "abc123hash", + PostInstallSteps = + [ + new InstallationStep + { + Name = "Step 1", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "setup.exe", + }, + ], + }; + + var result = _builder + .WithBasicInfo("Test Publisher", "Test Name", "1") + .WithInstallationInstructions(instructions) + .Build(); + + Assert.NotNull(result.InstallationInstructions); + Assert.Equal(WorkspaceStrategy.FullCopy, result.InstallationInstructions.WorkspaceStrategy); + Assert.Equal("abc123hash", result.InstallationInstructions.DownloadHash); + Assert.Single(result.InstallationInstructions.PostInstallSteps); + Assert.Equal("Step 1", result.InstallationInstructions.PostInstallSteps[0].Name); + } + + /// + /// Tests that AddPostInstallStep adds a structured installation step. + /// + [Fact] + public void AddPostInstallStep_AddsStepCorrectly() + { + var result = _builder + .WithBasicInfo("Test Publisher", "Test Name", "1") + .AddPostInstallStep("EAC Setup", InstallationStepKind.RunVerifiedInstaller, "EasyAntiCheat_EOS_Setup.exe", ["install", "12345"], requiresElevation: true, statusMessage: "Installing AntiCheat") + .Build(); + + Assert.NotNull(result.InstallationInstructions); + var step = Assert.Single(result.InstallationInstructions.PostInstallSteps); + Assert.Equal("EAC Setup", step.Name); + Assert.Equal(InstallationStepKind.RunVerifiedInstaller, step.Kind); + Assert.Equal("EasyAntiCheat_EOS_Setup.exe", step.TargetRelativePath); + Assert.True(step.RequiresElevation); + Assert.Equal("Installing AntiCheat", step.StatusMessage); + Assert.Equal(["install", "12345"], step.Arguments); + } + /// /// Tests that Build returns a valid manifest with minimal configuration. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/MapImportServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/MapImportServiceTests.cs new file mode 100644 index 000000000..0fbf776e4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/MapImportServiceTests.cs @@ -0,0 +1,179 @@ +using System.IO.Compression; +using System.Net.Http; +using System.Text; +using GenHub.Core.Interfaces.Tools.MapManager; +using GenHub.Core.Models.Enums; +using GenHub.Features.Tools.MapManager.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace GenHub.Tests.Core.Features.Tools.Services; + +/// +/// Tests how map ZIP archives are split into path segments, which drives both the traversal +/// check and the grouping of a map with its assets. +/// +public sealed class MapImportServiceTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubMapImport", + Guid.NewGuid().ToString("N")); + + private readonly string _mapDirectory; + private readonly MapImportService _service; + + /// + /// Initializes a new instance of the class. + /// + public MapImportServiceTests() + { + _mapDirectory = Path.Combine(_workingDirectory, "Maps"); + Directory.CreateDirectory(_mapDirectory); + + var directoryService = new Mock(); + directoryService.Setup(d => d.GetMapDirectory(It.IsAny())).Returns(_mapDirectory); + + _service = new MapImportService( + directoryService.Object, + new HttpClient(), + new MapNameParser(NullLogger.Instance), + NullLogger.Instance); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Rejects a backslash-separated traversal segment. Splitting on backslashes is what makes the + /// leading .. visible as its own segment. + /// + [Fact] + public void ValidateZip_RejectsBackslashTraversalSegment() + { + var zipPath = Path.Combine(_workingDirectory, "traversal.zip"); + CreateZip(zipPath, ("..\\escaped.map", "map")); + + var (isValid, errorMessage) = _service.ValidateZip(zipPath); + + Assert.False(isValid); + Assert.Contains("path traversal", errorMessage, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resolves a map and its asset to the same backslash-separated directory. Without splitting on + /// backslashes each entry becomes its own directory, and the asset is reported as a directory + /// holding no map. + /// + [Fact] + public void ValidateZip_ResolvesBackslashSeparatedEntriesToTheSameDirectory() + { + var zipPath = Path.Combine(_workingDirectory, "backslash.zip"); + CreateZip( + zipPath, + ("Desert\\desert.map", "map"), + ("Desert\\map.tga", "thumbnail")); + + var (isValid, errorMessage) = _service.ValidateZip(zipPath); + + Assert.True(isValid, errorMessage); + } + + /// + /// Keeps an apostrophe inside a directory name intact, so the map and its assets stay grouped + /// under the directory the archive actually declared. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_KeepsDirectoryNamesContainingApostrophesIntactAsync() + { + var zipPath = Path.Combine(_workingDirectory, "apostrophe.zip"); + CreateZip( + zipPath, + ("Bob's Map/bob.map", "map"), + ("Bob's Map/map.tga", "thumbnail")); + + var result = await _service.ImportFromZipAsync(zipPath, GameType.ZeroHour); + + Assert.True(result.Success, string.Join(" ", result.Errors)); + var imported = Assert.Single(result.ImportedMaps); + Assert.Equal("Bob's Map", imported.DirectoryName); + Assert.True(File.Exists(Path.Combine(_mapDirectory, "Bob's Map", "bob.map"))); + Assert.True(File.Exists(Path.Combine(_mapDirectory, "Bob's Map", "map.tga"))); + } + + /// + /// Surfaces a cancellation that lands part-way through an archive as a cancellation. Maps + /// extracted before the cancellation must not be reported as a successful import, because the + /// caller would otherwise treat a truncated map set as the whole archive. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_CancelledMidArchive_DoesNotReportSuccessAsync() + { + var zipPath = Path.Combine(_workingDirectory, "cancelled.zip"); + CreateZip( + zipPath, + ("First/first.map", "map"), + ("Second/second.map", "map")); + + using var cancellation = new CancellationTokenSource(); + + await Assert.ThrowsAnyAsync(() => + _service.ImportFromZipAsync( + zipPath, + GameType.ZeroHour, + new CancelOnFirstReport(cancellation), + cancellation.Token)); + + Assert.Single(Directory.GetDirectories(_mapDirectory)); + } + + /// + /// Skips only the map whose directory cannot be created and keeps importing the rest. Creating + /// that directory is the first thing done for a map and can fail on its own — here a file + /// already occupies the name — so it belongs inside the per-map handler rather than in front of + /// it, where one bad name would sink the whole archive. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_MapDirectoryThatCannotBeCreated_SkipsOnlyThatMapAsync() + { + var zipPath = Path.Combine(_workingDirectory, "blocked.zip"); + CreateZip( + zipPath, + ("Blocked/blocked.map", "map"), + ("Second/second.map", "map")); + await File.WriteAllTextAsync(Path.Combine(_mapDirectory, "Blocked"), "not a directory"); + + var result = await _service.ImportFromZipAsync(zipPath, GameType.ZeroHour); + + Assert.True(result.Success, string.Join(" ", result.Errors)); + var imported = Assert.Single(result.ImportedMaps); + Assert.Equal("Second", imported.DirectoryName); + Assert.NotEmpty(result.Errors); + Assert.False(Directory.Exists(Path.Combine(_mapDirectory, "Blocked"))); + } + + private static void CreateZip(string zipPath, params (string EntryName, string Content)[] entries) + { + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + foreach (var (entryName, content) in entries) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes(content)); + } + } + + private sealed class CancelOnFirstReport(CancellationTokenSource cancellation) : IProgress + { + public void Report(double value) => cancellation.Cancel(); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ReplayImportServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ReplayImportServiceTests.cs new file mode 100644 index 000000000..0e296ef2b --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ReplayImportServiceTests.cs @@ -0,0 +1,120 @@ +using System.IO.Compression; +using System.Text; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Enums; +using GenHub.Features.Tools.ReplayManager.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace GenHub.Tests.Core.Features.Tools.Services; + +/// +/// Tests how a replay archive import behaves when it is interrupted, which decides whether the +/// caller is told the archive was imported in full. +/// +public sealed class ReplayImportServiceTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubReplayImport", + Guid.NewGuid().ToString("N")); + + private readonly string _replayDirectory; + private readonly ReplayImportService _service; + + /// + /// Initializes a new instance of the class. + /// + public ReplayImportServiceTests() + { + _replayDirectory = Path.Combine(_workingDirectory, "Replays"); + Directory.CreateDirectory(_replayDirectory); + + var directoryService = new Mock(); + directoryService.Setup(d => d.GetReplayDirectory(It.IsAny())).Returns(_replayDirectory); + + var zipValidationService = new Mock(); + zipValidationService.Setup(z => z.ValidateZip(It.IsAny())).Returns((true, null)); + + _service = new ReplayImportService( + new Mock().Object, + directoryService.Object, + new Mock().Object, + zipValidationService.Object, + NullLogger.Instance); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Imports every entry of an archive that is never interrupted. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_ImportsEveryEntryAsync() + { + var zipPath = Path.Combine(_workingDirectory, "replays.zip"); + CreateZip(zipPath, "first.rep", "second.rep"); + + var result = await _service.ImportFromZipAsync(zipPath, GameType.ZeroHour); + + Assert.True(result.Success, string.Join(" ", result.Errors)); + Assert.Equal(2, result.FilesImported); + } + + /// + /// Surfaces a cancellation that lands part-way through an archive as a cancellation. Entries + /// imported before the cancellation must not be reported as a successful import, because the + /// caller would otherwise treat a truncated set of replays as the whole archive. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_CancelledMidArchive_DoesNotReportSuccessAsync() + { + var zipPath = Path.Combine(_workingDirectory, "cancelled.zip"); + CreateZip(zipPath, "first.rep", "second.rep"); + + using var cancellation = new CancellationTokenSource(); + + await Assert.ThrowsAnyAsync(() => + _service.ImportFromZipAsync( + zipPath, + GameType.ZeroHour, + new CancelOnceAnEntryIsImported(cancellation), + cancellation.Token)); + + Assert.Single(Directory.GetFiles(_replayDirectory)); + } + + private static void CreateZip(string zipPath, params string[] entryNames) + { + using var archive = ZipFile.Open(zipPath, 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 sealed class CancelOnceAnEntryIsImported(CancellationTokenSource cancellation) : IProgress + { + private int _reports; + + public void Report(double value) + { + if (++_reports > 1) + { + cancellation.Cancel(); + } + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs new file mode 100644 index 000000000..65682c712 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs @@ -0,0 +1,827 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Interfaces.Workspace; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.UserData.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.UserData; + +/// +/// Tests covering the data-safety guarantees of : deployed user +/// data must be independent of the CAS object it came from, and a pristine backup must survive a +/// deployed file the user has since modified. +/// +public sealed partial class UserDataTrackerServiceSafetyTests : IDisposable +{ + private const string TestManifestId = "1.1015255.generalsonline.patch.gamedata"; + private const string TestProfileId = "profile-zh-safety"; + private const string TestVersion = "101525_QFE5"; + private const string TestManifestName = "GameData Patch"; + private const string TestRelativePath = "GeneralsOnlineGameData/splash.bmp"; + private const string TestHash = "hash-splash-safety"; + private const string CasContent = "pristine-cas-content"; + + private readonly string _tempDir; + private readonly string _appDataDir; + private readonly string _casDir; + private readonly string _zeroHourDataDir; + private readonly Mock _configProviderMock; + private readonly Mock _fileOperationsMock; + private readonly Mock> _loggerMock; + private readonly Mock _pathProviderMock; + private readonly UserDataTrackerService _trackerService; + + /// + /// Initializes a new instance of the class. + /// + public UserDataTrackerServiceSafetyTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "GenHub_UserDataSafetyTests_" + Guid.NewGuid().ToString("N")); + _appDataDir = Path.Combine(_tempDir, "AppData"); + _casDir = Path.Combine(_tempDir, "Cas"); + _zeroHourDataDir = Path.Combine(_tempDir, GameSettingsConstants.FolderNames.ZeroHour); + + Directory.CreateDirectory(_appDataDir); + Directory.CreateDirectory(_casDir); + Directory.CreateDirectory(_zeroHourDataDir); + File.WriteAllText(Path.Combine(_casDir, TestHash), CasContent); + + _configProviderMock = new Mock(); + _configProviderMock.Setup(c => c.GetApplicationDataPath()).Returns(_appDataDir); + + _loggerMock = new Mock>(); + + _pathProviderMock = new Mock(); + _pathProviderMock.Setup(p => p.GetOptionsDirectory(GameType.ZeroHour)).Returns(_zeroHourDataDir); + + _fileOperationsMock = new Mock(); + + // Faithful CAS behaviour: a hard link really shares storage with the object, a copy does not. + _fileOperationsMock + .Setup(f => f.LinkFromCasAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((hash, targetPath, useHardLink, contentType, token) => + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + return Task.FromResult(TryCreateHardLink(Path.Combine(_casDir, hash), targetPath)); + }); + + _fileOperationsMock + .Setup(f => f.CopyFromCasAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((hash, targetPath, contentType, token) => + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Copy(Path.Combine(_casDir, hash), targetPath, overwrite: true); + return Task.FromResult(true); + }); + + _fileOperationsMock + .Setup(f => f.VerifyFileHashAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(FileHashVerification.Match); + + _trackerService = new UserDataTrackerService( + _configProviderMock.Object, + _fileOperationsMock.Object, + _loggerMock.Object, + _pathProviderMock.Object); + } + + /// + /// Cleans up test resources. + /// + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + // Ignore test cleanup errors + } + } + + /// + /// Verifies that a file installed into the user's game data directory is an independent copy, so + /// writing to it — as the game engine and GenHub's own settings writer both do — cannot reach the + /// CAS object that every profile referencing the hash shares. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task InstallUserDataAsync_UserWritableTarget_DeploysIndependentCopyAsync() + { + // Arrange + var casObjectPath = Path.Combine(_casDir, TestHash); + + // Act + var result = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + + // Assert + Assert.True(result.Success); + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Assert.True(File.Exists(deployedPath)); + Assert.Equal(CasContent, File.ReadAllText(deployedPath)); + + // The game writes into this directory in place; that must not reach the CAS object. + File.WriteAllText(deployedPath, "engine-rewrote-this-file-with-different-content"); + + Assert.Equal(CasContent, File.ReadAllText(casObjectPath)); + Assert.False(result.Data!.InstalledFiles[0].IsHardLink); + + _fileOperationsMock.Verify( + f => f.LinkFromCasAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that a deployed file the user has modified is moved aside rather than left in place, + /// so the pristine backup is still restored over the original path instead of being discarded. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_HashMismatch_PreservesModifiedFileAndRestoresBackupAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + const string modifiedContent = "the-user-edited-this"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + File.WriteAllText(deployedPath, modifiedContent); + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Mismatch); + + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.True(uninstallResult.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + + var preservedPath = deployedPath + UserDataConstants.UserModifiedSuffix; + Assert.True(File.Exists(preservedPath)); + Assert.Equal(modifiedContent, File.ReadAllText(preservedPath)); + } + + /// + /// A deployed file whose hash could not be computed at all — an IO error, or the running game + /// briefly holding it open — is not evidence that the user changed it. Moving it aside and + /// restoring over it would churn a pristine file and log a preserved edit that never happened, + /// so the file and its backup are both left alone. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_WhenVerificationFails_LeavesDeployedFileUntouchedAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath; + Assert.NotNull(backupPath); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Failed); + + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.False(uninstallResult.Success); + Assert.False(File.Exists(deployedPath + UserDataConstants.UserModifiedSuffix)); + Assert.Equal(CasContent, File.ReadAllText(deployedPath)); + Assert.True(File.Exists(backupPath)); + Assert.Equal(originalUserContent, File.ReadAllText(backupPath!)); + } + + /// + /// Pins the dangerous window an uninstall opens: the deployed file has already been moved aside + /// and the restore of the pristine original then fails, leaving the original path empty. The + /// uninstall must report that failure and keep its tracking data, because the manifest is the + /// only record tying a machine-named backup to the path it belongs at. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_WhenRestoreFailsAfterMoveAside_ReportsFailureAndKeepsTrackingDataAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath!; + + // The backup disappears in the window between the move-aside and the restore. + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + File.Delete(backupPath); + return FileHashVerification.Mismatch; + }); + + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.False(uninstallResult.Success); + Assert.False(File.Exists(deployedPath)); + + var preservedPath = deployedPath + UserDataConstants.UserModifiedSuffix; + Assert.True(File.Exists(preservedPath)); + Assert.Equal(CasContent, File.ReadAllText(preservedPath)); + + var manifestsPath = Path.Combine(_appDataDir, "UserData", "manifests"); + Assert.NotEmpty(Directory.GetFiles(manifestsPath, "*", SearchOption.AllDirectories)); + } + + /// + /// Profile cleanup runs the same uninstall, so it must not report success while an original the + /// user never asked to lose is still sitting in the backups tree. Every caller above it reads + /// this result and nothing else. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CleanupProfileAsync_WhenRestoreFails_ReportsTheUnfinishedUninstallAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath!; + + // The backup disappears in the window between the move-aside and the restore. + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + File.Delete(backupPath); + return FileHashVerification.Mismatch; + }); + + // Act + var cleanupResult = await _trackerService.CleanupProfileAsync(TestProfileId, CancellationToken.None); + + // Assert + Assert.False(cleanupResult.Success); + Assert.Contains(Path.Combine(_appDataDir, "UserData", "backups"), cleanupResult.FirstError); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "manifests"), "*", SearchOption.AllDirectories)); + } + + /// + /// Verifies that a restore failure keeps the backups directory intact, so the user's pristine + /// originals are still recoverable by hand after a delete-all. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WhenRestoreFails_RetainsBackupsAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Failed); + + // Act + var deleteResult = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + + // Assert + var backupsPath = Path.Combine(_appDataDir, "UserData", "backups"); + + // The caller must be told, and told where: "all user data deleted successfully" is a lie + // while the user's pristine originals are still sitting in the backups folder. + Assert.False(deleteResult.Success); + Assert.Contains(backupsPath, deleteResult.FirstError); + + Assert.True(Directory.Exists(backupsPath)); + Assert.NotEmpty(Directory.GetFiles(backupsPath, "*", SearchOption.AllDirectories)); + + // The manifests and the index are the only map from a machine-named backup file back to the + // path it belongs at, so retaining the backups while deleting them would strand them. + Assert.True(File.Exists(Path.Combine(_appDataDir, "UserData", "index.json"))); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "manifests"), "*", SearchOption.AllDirectories)); + } + + /// + /// A delete-all that retains backups keeps its tracking data, so retrying it once the restores + /// can succeed must still finish the job rather than leave the tracking directory behind forever. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_RetriedAfterRetention_ClearsEverythingAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Failed); + + var firstAttempt = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + Assert.False(firstAttempt.Success); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Match); + + // Act + var retry = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + + // Assert + Assert.True(retry.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + Assert.Empty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData"), "*", SearchOption.AllDirectories)); + } + + /// + /// Deactivation puts the user's original back at its own path, which consumes the backup. Keeping + /// the backup file and its recorded path would make the following uninstall read that restored + /// original as a user modification, move the byte-identical file aside and restore a duplicate. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeactivateThenUninstall_DoesNotDuplicateTheRestoredOriginalAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath; + Assert.NotNull(backupPath); + + // Only the deployed CAS content matches the recorded hash; the user's own file does not. + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string path, string hash, CancellationToken _) => + File.Exists(path) && File.ReadAllText(path) == CasContent + ? FileHashVerification.Match + : FileHashVerification.Mismatch); + + // Act + var deactivateResult = await _trackerService.DeactivateProfileUserDataAsync(TestProfileId, CancellationToken.None); + Assert.True(deactivateResult.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + Assert.False(File.Exists(backupPath)); + + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.True(uninstallResult.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + Assert.False(File.Exists(deployedPath + UserDataConstants.UserModifiedSuffix)); + } + + /// + /// The restore is what protects the user's data; deleting the consumed backup afterwards is + /// housekeeping. A delete that fails must not report the restore as failed, because the retry + /// would read the restored original as a modification and duplicate it. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_WhenConsumedBackupCannotBeDeleted_StillReportsSuccessAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath!; + var backupDir = Path.GetDirectoryName(backupPath)!; + + // Deleting the backup has to fail while reading it still works: an open handle does that on + // Windows, and a directory the process may not write to does it everywhere else. + FileStream? openBackupHandle = null; + UnixFileMode? originalDirectoryMode = null; + string? probePath = null; + if (OperatingSystem.IsWindows()) + { + openBackupHandle = new FileStream(backupPath, System.IO.FileMode.Open, FileAccess.Read, FileShare.Read); + } + else + { + probePath = Path.Combine(backupDir, "delete-permission-probe"); + File.WriteAllText(probePath, string.Empty); + + originalDirectoryMode = File.GetUnixFileMode(backupDir); + File.SetUnixFileMode(backupDir, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + if (DeleteSucceeds(probePath)) + { + // The mode is advisory for this process: root, and anything else holding + // CAP_DAC_OVERRIDE, deletes regardless. There is no failing delete left to set up, + // so the scenario cannot be reached here rather than the product being wrong. + File.SetUnixFileMode(backupDir, originalDirectoryMode.Value); + return; + } + } + + try + { + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.True(uninstallResult.Success); + Assert.True(File.Exists(backupPath)); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + Assert.False(File.Exists(deployedPath + UserDataConstants.UserModifiedSuffix)); + } + finally + { + openBackupHandle?.Dispose(); + if (!OperatingSystem.IsWindows() && originalDirectoryMode.HasValue) + { + File.SetUnixFileMode(backupDir, originalDirectoryMode.Value); + } + + if (probePath is not null) + { + File.Delete(probePath); + } + } + } + + /// + /// A cancelled delete-all must abort before any tracking metadata is destroyed. Swallowing the + /// cancellation and carrying on wipes the manifests and the index while the backups they describe + /// are still on disk, leaving the user's originals unrecoverable by anything but hand. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WhenCancelledMidCleanup_KeepsTrackingMetadataAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + using var cts = new CancellationTokenSource(); + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, token) => + { + cts.Cancel(); + token.ThrowIfCancellationRequested(); + return Task.FromResult(FileHashVerification.Match); + }); + + // Act + await Assert.ThrowsAnyAsync(() => _trackerService.DeleteAllUserDataAsync(cts.Token)); + + // Assert + Assert.True(File.Exists(Path.Combine(_appDataDir, "UserData", "index.json"))); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "manifests"), "*", SearchOption.AllDirectories)); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "backups"), "*", SearchOption.AllDirectories)); + } + + /// + /// Cancellation that lands on the manifest read itself must abort the delete-all too. Treating + /// the cancelled read as an unreadable manifest turns an abort into a retention decision and + /// carries on into the step that removes the tracking data. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WhenCancelledLoadingManifest_KeepsTrackingMetadataAsync() + { + // Arrange + const string secondHash = "hash-splash-safety-second"; + const string secondRelativePath = "GeneralsOnlineGameData/loading.bmp"; + File.WriteAllText(Path.Combine(_casDir, secondHash), CasContent); + + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + Assert.True((await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None)).Success); + + Assert.True((await _trackerService.InstallUserDataAsync( + TestManifestId + ".loading", + TestProfileId, + GameType.ZeroHour, + BuildFiles(secondRelativePath, secondHash), + TestVersion, + TestManifestName, + CancellationToken.None)).Success); + + // Cancel while the first installation is being cleaned up, so the cancellation is first + // observed by the read of the second installation's manifest. + using var cts = new CancellationTokenSource(); + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + cts.Cancel(); + return FileHashVerification.Match; + }); + + // Act + await Assert.ThrowsAnyAsync(() => _trackerService.DeleteAllUserDataAsync(cts.Token)); + + // Assert + Assert.True(File.Exists(Path.Combine(_appDataDir, "UserData", "index.json"))); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "manifests"), "*", SearchOption.AllDirectories)); + } + + /// + /// An index key whose manifest is already gone has nothing left to restore, so it must not put + /// delete-all into the retention path forever: "Delete All Application Data" would then never be + /// able to finish on an installation with one stale entry. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WithStaleIndexEntry_StillClearsEverythingAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var userDataPath = Path.Combine(_appDataDir, "UserData"); + foreach (var manifestFile in Directory.GetFiles(Path.Combine(userDataPath, "manifests"), "*", SearchOption.AllDirectories)) + { + File.Delete(manifestFile); + } + + // Act + var deleteResult = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + + // Assert + Assert.True(deleteResult.Success); + Assert.Empty(Directory.GetFiles(userDataPath, "*", SearchOption.AllDirectories)); + } + + /// + /// Verifies that a clean delete-all still restores the originals and clears the backups, so the + /// retention path does not become the permanent behaviour. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WhenRestoresSucceed_ClearsBackupsAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + // Act + var deleteResult = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + + // Assert + Assert.True(deleteResult.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + + var backupsPath = Path.Combine(_appDataDir, "UserData", "backups"); + Assert.True(Directory.Exists(backupsPath)); + Assert.Empty(Directory.GetFiles(backupsPath, "*", SearchOption.AllDirectories)); + } + + [LibraryImport("kernel32.dll", EntryPoint = "CreateHardLinkW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CreateHardLinkWindows(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + + [LibraryImport("libc", EntryPoint = "link", SetLastError = true, StringMarshalling = StringMarshalling.Utf8)] + private static partial int LinkUnix(string existingPath, string newPath); + + private static List BuildFiles() => BuildFiles(TestRelativePath, TestHash); + + private static List BuildFiles(string relativePath, string hash) => + [ + new() + { + RelativePath = relativePath, + Hash = hash, + Size = CasContent.Length, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + ]; + + private static bool TryCreateHardLink(string existingPath, string linkPath) + { + try + { + if (File.Exists(linkPath)) + { + File.Delete(linkPath); + } + + return OperatingSystem.IsWindows() + ? CreateHardLinkWindows(linkPath, existingPath, IntPtr.Zero) + : LinkUnix(existingPath, linkPath) == 0; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (EntryPointNotFoundException) + { + return false; + } + catch (DllNotFoundException) + { + return false; + } + } + + /// + /// Reports whether a delete inside a directory whose mode was just tightened still goes through. + /// A process holding CAP_DAC_OVERRIDE - root in a dev container or a privileged CI image - is + /// not bound by the mode, so a test that assumed the delete would fail would instead report the + /// product as broken. + /// + /// The probe file the tightened directory is meant to protect. + /// true when the delete succeeded despite the directory mode. + private static bool DeleteSucceeds(string path) + { + try + { + File.Delete(path); + return !File.Exists(path); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs index 2babf7343..ae8e591cd 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs @@ -83,6 +83,25 @@ public UserDataTrackerServiceTests() }) .ReturnsAsync(true); + // Default mock for CAS copying: user-writable destinations are always copied, never linked + _fileOperationsMock + .Setup(f => f.CopyFromCasAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((hash, targetPath, contentType, token) => + { + var dir = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText(targetPath, "cas-content-" + hash); + }) + .ReturnsAsync(true); + _fileOperationsMock .Setup(f => f.VerifyFileHashAsync( It.IsAny(), @@ -90,6 +109,13 @@ public UserDataTrackerServiceTests() It.IsAny())) .ReturnsAsync(true); + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(FileHashVerification.Match); + _trackerService = new UserDataTrackerService( _configProviderMock.Object, _fileOperationsMock.Object, diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs index 8340cc899..d62676a6d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs @@ -1,6 +1,7 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Results; using GenHub.Features.Workspace; using Microsoft.Extensions.Logging; @@ -49,6 +50,97 @@ public async Task CopyFileAsync_CreatesFileAsync() Assert.Equal("test content", await File.ReadAllTextAsync(dst)); } + /// + /// A copy that cannot even open its source must not have destroyed the file already sitting at + /// the destination: the destination is unlinked to break hard links, and doing that before the + /// source is known to be readable turns a failed copy into data loss. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CopyFileAsync_MissingSource_LeavesExistingDestinationIntactAsync() + { + var src = Path.Combine(_tempDir, "missing-source.txt"); + var dst = Path.Combine(_tempDir, "existing-destination.txt"); + + await File.WriteAllTextAsync(dst, "the file the user already had"); + + await Assert.ThrowsAsync(() => _service.CopyFileAsync(src, dst)); + + Assert.True(File.Exists(dst)); + Assert.Equal("the file the user already had", await File.ReadAllTextAsync(dst)); + } + + /// + /// Copying a file onto itself must leave it alone rather than unlinking it and then failing to + /// read the source it has just deleted. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CopyFileAsync_SameSourceAndDestination_LeavesFileIntactAsync() + { + var file = Path.Combine(_tempDir, "self.txt"); + await File.WriteAllTextAsync(file, "irreplaceable content"); + + await _service.CopyFileAsync(file, Path.Combine(_tempDir, ".", "self.txt")); + + Assert.True(File.Exists(file)); + Assert.Equal("irreplaceable content", await File.ReadAllTextAsync(file)); + } + + /// + /// A destination that is a leftover link to the source is exactly what callers copy to get rid + /// of: skipping the copy because the link resolves to the source leaves the workspace file + /// pointing at the shared CAS object, so later writes reach the object every profile shares. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CopyFileAsync_DestinationIsSymlinkToSource_ReplacesLinkWithIndependentCopyAsync() + { + var file = Path.Combine(_tempDir, "real.txt"); + var link = Path.Combine(_tempDir, "link.txt"); + await File.WriteAllTextAsync(file, "shared content"); + + if (!TryCreateSymbolicLink(link, file)) + { + return; + } + + await _service.CopyFileAsync(file, link); + + Assert.Null(File.ResolveLinkTarget(link, returnFinalTarget: true)); + Assert.Equal("shared content", await File.ReadAllTextAsync(link)); + + await File.WriteAllTextAsync(link, "workspace content"); + + Assert.Equal("shared content", await File.ReadAllTextAsync(file)); + Assert.Equal("workspace content", await File.ReadAllTextAsync(link)); + } + + /// + /// When the source is the link and the destination is the real file it points at, the + /// destination is already the independent copy the caller wants. Unlinking it would destroy the + /// only copy of the content, so the copy must be skipped. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CopyFileAsync_SourceIsSymlinkToDestination_LeavesFileIntactAsync() + { + var file = Path.Combine(_tempDir, "target.txt"); + var link = Path.Combine(_tempDir, "pointer.txt"); + await File.WriteAllTextAsync(file, "irreplaceable content"); + + if (!TryCreateSymbolicLink(link, file)) + { + return; + } + + await _service.CopyFileAsync(link, file); + + Assert.True(File.Exists(file)); + Assert.Null(File.ResolveLinkTarget(file, returnFinalTarget: true)); + Assert.Equal("irreplaceable content", await File.ReadAllTextAsync(file)); + } + /// /// Tests that CreateSymlinkAsync creates a symbolic link or falls back to copy on unsupported platforms. /// @@ -282,6 +374,22 @@ public async Task VerifyFileHashAsync_ReturnsFalse_WhenFileNotExistsAsync() Times.Never); } + /// + /// A file that is not there yields no hash at all, so it must be reported as a failed check + /// rather than as a confirmed difference that a destructive caller could act on. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckFileHashAsync_MissingFile_ReportsFailedAsync() + { + var missing = Path.Combine(_tempDir, "not-here.txt"); + + var result = await _service.CheckFileHashAsync(missing, "any-hash"); + + Assert.Equal(FileHashVerification.Failed, result); + Assert.False(await _service.VerifyFileHashAsync(missing, "any-hash")); + } + /// /// Tests that VerifyFileHashAsync handles exceptions gracefully. /// @@ -346,4 +454,32 @@ public void Dispose() { FileOperationsService.DeleteDirectoryIfExists(_tempDir); } + + /// + /// Creates a symbolic link, reporting failure rather than throwing when the platform withholds + /// the privilege it needs. + /// + /// The link to create. + /// The file the link points at. + /// True when the link was created. + private static bool TryCreateSymbolicLink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (PlatformNotSupportedException) + { + return false; + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/TestFileOperationsService.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/TestFileOperationsService.cs index f503f2603..159954c17 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/TestFileOperationsService.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/TestFileOperationsService.cs @@ -80,6 +80,10 @@ public Task CreateSymlinkAsync(string linkPath, string targetPath, bool allowFal public Task VerifyFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) => _innerService.VerifyFileHashAsync(filePath, expectedHash, cancellationToken); + /// + public Task CheckFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) + => _innerService.CheckFileHashAsync(filePath, expectedHash, cancellationToken); + /// public Task ApplyPatchAsync(string targetPath, string patchPath, CancellationToken cancellationToken = default) => _innerService.ApplyPatchAsync(targetPath, patchPath, cancellationToken); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs new file mode 100644 index 000000000..68107f185 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs @@ -0,0 +1,167 @@ +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public class AppUpdateVersionHelperTests +{ + /// + /// Tests that ExtractChannelKey extracts expected channel identifiers. + /// + /// The version string to extract the channel from. + /// The expected channel key. + [Theory] + [InlineData("0.0.1520-pr242", "pr242")] + [InlineData("0.0.1525-pr265", "pr265")] + [InlineData("0.0.1287-main", "main")] + [InlineData("0.0.1287-development", "development")] + [InlineData("0.0.0-ci.500", "ci")] + [InlineData("0.0.1300-fix-ci.9", "fix-ci.9")] + [InlineData("1.0.42", "release")] + [InlineData("0.0.1287", "release")] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData(null, null)] + public void ExtractChannelKey_WithVariousFormats_ShouldReturnExpectedChannel(string? version, string? expectedChannel) + { + var result = AppUpdateVersionHelper.ExtractChannelKey(version); + Assert.Equal(expectedChannel, result); + } + + /// + /// Tests that ExtractRunNumber extracts expected run numbers. + /// + /// The version string to extract the run number from. + /// The expected run number. + [Theory] + [InlineData("0.0.1282-pr265", 1282)] + [InlineData("0.0.1287-pr265", 1287)] + [InlineData("0.0.1287-main", 1287)] + [InlineData("0.0.1287-development", 1287)] + [InlineData("0.0.1300-fix-ci.9", 1300)] + [InlineData("0.0.1287", 1287)] + [InlineData("0.0.0-ci.500", 500)] + [InlineData("1.0.42", 0)] + [InlineData("1.2.5", 0)] + [InlineData("", 0)] + [InlineData(" ", 0)] + [InlineData(null, 0)] + [InlineData("abc", 0)] + public void ExtractRunNumber_WithVariousFormats_ShouldReturnExpectedNumber(string? version, int expectedRun) + { + var result = AppUpdateVersionHelper.ExtractRunNumber(version); + Assert.Equal(expectedRun, result); + } + + /// + /// Tests that IsArtifactVersionNewer returns true when new run is greater within the same channel. + /// + [Fact] + public void IsArtifactVersionNewer_WhenNewerRun_ShouldReturnTrue() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1287-pr265", "0.0.1282-pr265"); + Assert.True(result); + } + + /// + /// Tests that IsArtifactVersionNewer returns false when comparing builds from different PR channels. + /// + [Fact] + public void IsArtifactVersionNewer_WhenDifferentPrChannels_ShouldReturnFalse() + { + // PR #265 at run 1525 vs PR #242 at run 1520 must NOT be considered an upgrade + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1525-pr265", "0.0.1520-pr242"); + Assert.False(result); + } + + /// + /// Tests that IsArtifactVersionNewer returns false when comparing PR builds with branch builds. + /// + [Fact] + public void IsArtifactVersionNewer_WhenDifferentBranchChannels_ShouldReturnFalse() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1525-main", "0.0.1520-development"); + Assert.False(result); + + var prVsBranch = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1525-main", "0.0.1520-pr242"); + Assert.False(prVsBranch); + } + + /// + /// Tests that IsArtifactVersionNewer allows cross-channel comparison when explicitly requested. + /// + [Fact] + public void IsArtifactVersionNewer_WhenCrossChannelExplicitlyAllowed_ShouldReturnTrueForHigherRun() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1525-pr265", "0.0.1520-pr242", allowCrossChannel: true); + Assert.True(result); + } + + /// + /// Tests that IsArtifactVersionNewer returns false when same run. + /// + [Fact] + public void IsArtifactVersionNewer_WhenSameRun_ShouldReturnFalse() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-pr265", "0.0.1282-pr265"); + Assert.False(result); + } + + /// + /// Tests that IsArtifactVersionNewer returns false when older run. + /// + [Fact] + public void IsArtifactVersionNewer_WhenOlderRun_ShouldReturnFalse() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1280-pr265", "0.0.1282-pr265"); + Assert.False(result); + } + + /// + /// Tests that IsArtifactVersionNewer works for branch versions. + /// + [Fact] + public void IsArtifactVersionNewer_BranchVersions_ShouldCompareCorrectly() + { + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1287-main", "0.0.1282-main")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-main", "0.0.1282-main")); + } + + /// + /// Tests that IsArtifactVersionNewer handles null or empty inputs. + /// + [Fact] + public void IsArtifactVersionNewer_WithNullOrEmpty_ShouldHandleGracefully() + { + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer(null, "0.0.1282-pr265")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer(string.Empty, "0.0.1282-pr265")); + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-pr265", null)); + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-pr265", string.Empty)); + } + + /// + /// Tests that fallback versions like 0.0.0 are not treated as newer than installed builds. + /// + [Fact] + public void IsArtifactVersionNewer_FallbackZeroVersusValidRun_ShouldReturnFalse() + { + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.0", "0.0.1282-pr265")); + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-pr265", "0.0.0")); + } + + /// + /// Tests that standard version numbers compare correctly when no run number is present. + /// + [Fact] + public void IsArtifactVersionNewer_SemanticVersion_ShouldCompareCorrectly() + { + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("1.2.5", "1.1.9")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("1.1.9", "1.2.5")); + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("1.2.0", "1.1.0")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("1.1.0", "1.2.0")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("1.0.0", "1.0.0")); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs new file mode 100644 index 000000000..d9d9fa997 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs @@ -0,0 +1,234 @@ +using System; +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public sealed class CommandLineParserTests +{ + /// + /// Verifies that ExtractProfileId correctly extracts profile id from spaced argument. + /// + [Fact] + public void ExtractProfileId_WithSpacedArgument_ReturnsProfileId() + { + var args = new[] { "--other", "value", "--launch-profile", "test-profile-123" }; + + var result = CommandLineParser.ExtractProfileId(args); + + Assert.Equal("test-profile-123", result); + } + + /// + /// Verifies that ExtractProfileId correctly extracts profile id from inline argument. + /// + [Fact] + public void ExtractProfileId_WithInlineArgument_ReturnsProfileId() + { + var args = new[] { "--launch-profile=test-profile-456" }; + + var result = CommandLineParser.ExtractProfileId(args); + + Assert.Equal("test-profile-456", result); + } + + /// + /// Verifies that ExtractProfileId trims surrounding quotes. + /// + [Fact] + public void ExtractProfileId_WithQuotedValues_ReturnsTrimmedProfileId() + { + var argsSpaced = new[] { "--launch-profile", "\"quoted-profile\"" }; + var argsInline = new[] { "--launch-profile=\"quoted-profile\"" }; + + Assert.Equal("quoted-profile", CommandLineParser.ExtractProfileId(argsSpaced)); + Assert.Equal("quoted-profile", CommandLineParser.ExtractProfileId(argsInline)); + } + + /// + /// Verifies that ExtractProfileId returns null when launch profile argument is absent. + /// + [Fact] + public void ExtractProfileId_WhenMissing_ReturnsNull() + { + var args = new[] { "--verbose", "--other" }; + + var result = CommandLineParser.ExtractProfileId(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractProfileId returns null when spaced argument has no subsequent value. + /// + [Fact] + public void ExtractProfileId_WhenFlagAtEndWithoutValue_ReturnsNull() + { + var args = new[] { "--launch-profile" }; + + var result = CommandLineParser.ExtractProfileId(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractSubscriptionUrl parses direct catalog URLs. + /// + [Fact] + public void ExtractSubscriptionUrl_WithDirectUrl_ReturnsDecodedUrl() + { + var args = new[] { "genhub://subscribe?url=https://example.com/catalog.json" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl correctly decodes URL encoded parameters. + /// + [Fact] + public void ExtractSubscriptionUrl_WithUrlEncodedParameter_ReturnsDecodedUrl() + { + var args = new[] { "genhub://subscribe?url=https%3A%2F%2Fexample.com%2Fcatalog.json%3Fversion%3D1" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json?version=1", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl trims quotes around the url value. + /// + [Fact] + public void ExtractSubscriptionUrl_WithQuotedArgument_ReturnsTrimmedUrl() + { + var argsClean = new[] { "genhub://subscribe?url=\"https://example.com/catalog.json\"" }; + + Assert.Equal("https://example.com/catalog.json", CommandLineParser.ExtractSubscriptionUrl(argsClean)); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null when no subscribe URI is present. + /// + [Fact] + public void ExtractSubscriptionUrl_WhenNotPresent_ReturnsNull() + { + var args = new[] { "--launch-profile", "test" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractSubscriptionUrl is case insensitive with protocol prefix and query parameter. + /// + [Fact] + public void ExtractSubscriptionUrl_CaseInsensitivePrefix_ReturnsUrl() + { + var args = new[] { "GENHUB://SUBSCRIBE?URL=https://example.com/catalog.json" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null when subscribe URI lacks the url query parameter. + /// + [Fact] + public void ExtractSubscriptionUrl_WithoutUrlParameter_ReturnsNull() + { + var args = new[] { "genhub://subscribe" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null when the url query parameter is empty. + /// + [Fact] + public void ExtractSubscriptionUrl_WithEmptyUrlParameter_ReturnsNull() + { + var args = new[] { "genhub://subscribe?url=" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractSubscriptionUrl extracts the URL even when preceded by other arguments. + /// + [Fact] + public void ExtractSubscriptionUrl_WhenNotFirstArgument_ReturnsUrl() + { + var args = new[] { "--verbose", "--launch-profile", "test-profile", "genhub://subscribe?url=https://example.com/catalog.json" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns the first matching subscription URL when multiple are present. + /// + [Fact] + public void ExtractSubscriptionUrl_MultipleUrls_ReturnsFirstMatch() + { + var args = new[] + { + "genhub://subscribe?url=https://example.com/first.json", + "genhub://subscribe?url=https://example.com/second.json", + }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/first.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null for non-HTTP and non-HTTPS URI schemes. + /// + [Fact] + public void ExtractSubscriptionUrl_NonHttpOrHttpsScheme_ReturnsNull() + { + var fileSchemeArgs = new[] { "genhub://subscribe?url=file:///C:/malicious.exe" }; + var jsSchemeArgs = new[] { "genhub://subscribe?url=javascript:alert(1)" }; + + Assert.Null(CommandLineParser.ExtractSubscriptionUrl(fileSchemeArgs)); + Assert.Null(CommandLineParser.ExtractSubscriptionUrl(jsSchemeArgs)); + } + + /// + /// Verifies that ExtractSubscriptionUrl strips newlines and control characters from the URL. + /// + [Fact] + public void ExtractSubscriptionUrl_WithNewlinesAndControlChars_ReturnsSanitizedUrl() + { + var args = new[] { "genhub://subscribe?url=https%3A%2F%2Fexample.com%2Fcatalog.json%0D%0A" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null for non-command subscribe-prefixed URIs. + /// + [Fact] + public void ExtractSubscriptionUrl_WithNonCommandSubscribePrefixedUri_ReturnsNull() + { + var args = new[] { "genhub://subscribe-anything?url=https://example.com/catalog.json" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Null(result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs index e39b4e76e..7c68bf37c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs @@ -9,8 +9,14 @@ namespace GenHub.Tests.Core.Helpers; /// public class GameProcessSelectorTests { + /// A real client whose name is longer than a Unix kernel will report. + private const string LongClientName = "GeneralsOnlineZH_60"; + private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc); + /// The name a Unix kernel reports for . + private static readonly string TruncatedClientName = LongClientName[..ProcessConstants.UnixProcessNameMaxLength]; + // Native separators on both platforms: a real workspace path never mixes them, and comparing // like-for-like is what the non-separator tests are meant to exercise. private static readonly string Workspace = Path.Combine(Path.GetTempPath(), "genhub-workspace", "generalsonline"); @@ -156,6 +162,261 @@ public void SelectSpawnedGameProcess_WithNoNameMatch_ReturnsNull() Assert.Null(selected); } + /// + /// A Unix kernel keeps only characters + /// of a process name, so every client whose name is longer — which is most of the ones this + /// adoption path exists for — reports a truncated name and the full one survives only in the + /// image path. Matching on the reported name alone finds none of them. + /// + [Fact] + public void SelectSpawnedGameProcess_MatchesACandidateWhoseKernelTruncatedItsName() + { + var candidates = new[] + { + new GameProcessCandidate(1, TruncatedClientName, Now, Path.Combine(Workspace, LongClientName)), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, LongClientName, Workspace, Now); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + + /// + /// Two clients that share a truncated name are still different clients, and the image path is + /// what tells them apart. Matching on the truncated name alone would adopt either one. + /// + [Fact] + public void SelectSpawnedGameProcess_RejectsATruncatedNameBelongingToADifferentClient() + { + var otherClient = TruncatedClientName + "H_61"; + var candidates = new[] + { + new GameProcessCandidate(1, TruncatedClientName, Now, Path.Combine(Workspace, otherClient)), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, LongClientName, Workspace, Now); + + Assert.Null(selected); + } + + /// + /// With no image path to read, the truncated name the kernel reports is the only evidence + /// there is, so it has to be accepted where the kernel truncates and nowhere else. + /// + [Fact] + public void SelectSpawnedGameProcess_WithoutAnImagePath_FallsBackToTheTruncatedProcessName() + { + var candidates = new[] { new GameProcessCandidate(1, TruncatedClientName, Now, null) }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, LongClientName, null, Now); + + Assert.Equal(!OperatingSystem.IsWindows(), selected is not null); + } + + /// + /// Enumeration matches against the name the kernel kept, so a longer name has to be shortened + /// to the same prefix before it is asked for. Windows reports names in full. + /// + [Fact] + public void GetDiscoveryName_ShortensNamesTheUnixKernelWouldTruncate() + { + var discoveryName = GameProcessSelector.GetDiscoveryName(LongClientName); + + Assert.Equal(OperatingSystem.IsWindows() ? LongClientName : TruncatedClientName, discoveryName); + } + + /// + /// A name the kernel keeps whole is asked for exactly as it is on every platform. + /// + [Fact] + public void GetDiscoveryName_LeavesNamesTheKernelKeepsWhole() + { + Assert.Equal("generalszh", GameProcessSelector.GetDiscoveryName("generalszh")); + } + + /// + /// The operating system reports a fully symlink-resolved image path while a configured working + /// directory keeps whatever spelling it was given, so residence has to be decided against the + /// real directory rather than the two spellings of it. + /// + [Fact] + public void SelectSpawnedGameProcess_MatchesAWorkingDirectoryReachedThroughASymlink() + { + var root = CreateTempRoot(); + try + { + var real = Path.Combine(root, "real", "workspace"); + Directory.CreateDirectory(real); + + var link = Path.Combine(root, "link"); + if (!TryCreateDirectorySymbolicLink(link, Path.Combine(root, "real"))) + { + // The platform will not let this account create links, so there is nothing to test. + return; + } + + var candidates = new[] + { + new GameProcessCandidate(1, LongClientName, Now, Path.Combine(real, LongClientName)), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess( + candidates, LongClientName, Path.Combine(link, "workspace"), Now); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Residence follows the volume rather than a fixed string rule: a case-insensitive volume — + /// the macOS and Windows default — must not reject a differently cased spelling of the very + /// directory the game runs from, and a case-sensitive one must keep two such directories apart. + /// + [Fact] + public void SelectSpawnedGameProcess_FollowsTheVolumeCaseRulesWhenComparingResidence() + { + var root = CreateTempRoot(); + try + { + var onDisk = Path.Combine(root, "Workspace"); + Directory.CreateDirectory(onDisk); + + var lowerCased = Path.Combine(root, "workspace"); + var candidates = new[] + { + new GameProcessCandidate(1, LongClientName, Now, Path.Combine(onDisk, LongClientName)), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess( + candidates, LongClientName, lowerCased, Now); + + Assert.Equal(Directory.Exists(lowerCased), selected is not null); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// A launcher whose start time cannot be read leaves nothing to separate the child it spawned + /// from an instance of the same game already running in the same workspace, so adoption is + /// declined outright rather than gambling on the recency window. + /// + [Fact] + public void SelectAdoptableGameProcess_WithoutALauncherStartTime_AdoptsNothing() + { + var candidates = new[] { Candidate(1, LongClientName, Now, Workspace) }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime: null); + + Assert.Null(selected); + } + + /// + /// A known launcher start time disqualifies anything that was already running when the + /// launcher started, however recently it started. + /// + [Fact] + public void SelectAdoptableGameProcess_RejectsACandidateThatPredatesTheLauncher() + { + var launcherStartTime = Now.AddSeconds(-2); + var candidates = new[] { Candidate(1, LongClientName, launcherStartTime.AddSeconds(-1), Workspace) }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime); + + Assert.Null(selected); + } + + /// + /// The process the launcher started is the one adoption is for. + /// + [Fact] + public void SelectAdoptableGameProcess_AdoptsTheChildStartedAfterTheLauncher() + { + var launcherStartTime = Now.AddSeconds(-2); + var candidates = new[] + { + Candidate(1, LongClientName, launcherStartTime.AddSeconds(-1), Workspace), + Candidate(2, LongClientName, launcherStartTime.AddSeconds(1), Workspace), + }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime); + + Assert.NotNull(selected); + Assert.Equal(2, selected.ProcessId); + } + + /// + /// A child can be recorded as starting in the same clock tick as the launcher that spawned it, + /// so the launcher's own start time has to qualify rather than disqualify. + /// + [Fact] + public void SelectAdoptableGameProcess_AcceptsACandidateStartedAtTheLauncherStartTime() + { + var launcherStartTime = Now.AddSeconds(-2); + var candidates = new[] { Candidate(1, LongClientName, launcherStartTime, Workspace) }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + + /// + /// A launcher may take longer than to + /// make its child enumerable, and the discovery timeout the caller polls with is configurable + /// well past that. The child still started with this launch, so it must be adopted rather than + /// left running with nothing tracking it. Anchored to the real clock: the adoption path takes + /// no time of its own, so any recency window reintroduced here would have to read that clock. + /// + [Fact] + public void SelectAdoptableGameProcess_AdoptsAChildOlderThanTheRecencyWindow() + { + var launcherStartTime = DateTime.UtcNow.AddSeconds(-(ProcessConstants.EarlyExitThresholdSeconds + 20)); + var candidates = new[] { Candidate(1, LongClientName, launcherStartTime.AddSeconds(1), Workspace) }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + private static GameProcessCandidate Candidate(int id, string name, DateTime startTime, string directory) => new(id, name, startTime, Path.Combine(directory, name + ".exe")); + + private static string CreateTempRoot() + { + var root = Path.Combine(Path.GetTempPath(), "genhub-selector-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static bool TryCreateDirectorySymbolicLink(string path, string target) + { + try + { + Directory.CreateSymbolicLink(path, target); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameSettingsMapperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameSettingsMapperTests.cs index 96fe358f4..0968e395f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameSettingsMapperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameSettingsMapperTests.cs @@ -61,56 +61,62 @@ public void ApplyFromOptions_AllReductions_MapsToCorrectQuality(int reduction, T } /// - /// Verifies that a profile with no TheSuperHackers font sizes set falls back to the declared defaults. + /// Verifies that font sizes the profile leaves unset keep the values already in settings.json, + /// which is where the values a user configured inside the client itself live. /// [Fact] - public void ApplyToGeneralsOnlineSettings_UnsetFontSizes_UsesDeclaredDefaults() + public void ApplyToGeneralsOnlineSettings_UnsetFontSizes_PreservesExistingValues() { - // Arrange - seed with values the mapper must overwrite, so a missing assignment fails + // Arrange - seed with values no GenHub default would produce var profile = new GameProfile(); var settings = new GeneralsOnlineSettings { SystemTimeFontSize = 99, - NetworkLatencyFontSize = 99, - RenderFpsFontSize = 99, - ResolutionFontAdjustment = 99, + NetworkLatencyFontSize = 98, + RenderFpsFontSize = 97, + ResolutionFontAdjustment = 96, }; // Act GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); // Assert - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultSystemTimeFontSize, settings.SystemTimeFontSize); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultNetworkLatencyFontSize, settings.NetworkLatencyFontSize); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultRenderFpsFontSize, settings.RenderFpsFontSize); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultResolutionFontAdjustment, settings.ResolutionFontAdjustment); + Assert.Equal(99, settings.SystemTimeFontSize); + Assert.Equal(98, settings.NetworkLatencyFontSize); + Assert.Equal(97, settings.RenderFpsFontSize); + Assert.Equal(96, settings.ResolutionFontAdjustment); } /// - /// Verifies that the fallback defaults match the values declared on the settings model itself. + /// Verifies that GeneralsOnline options the profile leaves unset keep the values already in + /// settings.json rather than being reset to GenHub's defaults. /// [Fact] - public void ApplyToGeneralsOnlineSettings_UnsetFontSizes_MatchesModelDefaults() + public void ApplyToGeneralsOnlineSettings_UnsetGeneralsOnlineOptions_PreservesExistingValues() { - // Arrange - seed with values the mapper must overwrite, so a missing assignment fails - var profile = new GameProfile(); - var expected = new GeneralsOnlineSettings(); + // Arrange - the profile declares one option; everything else is the client's own + var profile = new GameProfile { GoShowFps = true }; var settings = new GeneralsOnlineSettings { - SystemTimeFontSize = 99, - NetworkLatencyFontSize = 99, - RenderFpsFontSize = 99, - ResolutionFontAdjustment = 99, + ShowPing = false, + RememberUsername = false, + ChatFontSize = 24, }; + settings.Camera.MinHeight = 42.0f; + settings.Render.FpsLimit = 60; + settings.Social.NotificationFriendComesOnlineMenus = false; // Act GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); // Assert - Assert.Equal(expected.SystemTimeFontSize, settings.SystemTimeFontSize); - Assert.Equal(expected.NetworkLatencyFontSize, settings.NetworkLatencyFontSize); - Assert.Equal(expected.RenderFpsFontSize, settings.RenderFpsFontSize); - Assert.Equal(expected.ResolutionFontAdjustment, settings.ResolutionFontAdjustment); + Assert.True(settings.ShowFps); + Assert.False(settings.ShowPing); + Assert.False(settings.RememberUsername); + Assert.Equal(24, settings.ChatFontSize); + Assert.Equal(42.0f, settings.Camera.MinHeight); + Assert.Equal(60, settings.Render.FpsLimit); + Assert.False(settings.Social.NotificationFriendComesOnlineMenus); } /// @@ -140,47 +146,33 @@ public void ApplyToGeneralsOnlineSettings_ExplicitFontSizes_ArePreserved() } /// - /// Verifies that a profile with no cursor capture, edge scroll or observer toggles set - /// falls back to the declared defaults. + /// Verifies that a fresh settings.json keeps money transaction audio audible, so that the + /// model default and the settings screen agree on what an unconfigured profile writes. /// [Fact] - public void ApplyToGeneralsOnlineSettings_UnsetToggles_UsesDeclaredDefaults() + public void ApplyToGeneralsOnlineSettings_UnsetMoneyTransactionVolume_StaysAudible() { - // Arrange - seed each toggle inverted, so a missing assignment fails + // Arrange var profile = new GameProfile(); - var settings = new GeneralsOnlineSettings - { - PlayerObserverEnabled = false, - CursorCaptureEnabledInFullscreenGame = false, - CursorCaptureEnabledInFullscreenMenu = false, - CursorCaptureEnabledInWindowedGame = false, - CursorCaptureEnabledInWindowedMenu = true, - ScreenEdgeScrollEnabledInFullscreenApp = false, - ScreenEdgeScrollEnabledInWindowedApp = true, - }; + var settings = new GeneralsOnlineSettings(); // Act GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); // Assert - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultPlayerObserverEnabled, settings.PlayerObserverEnabled); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInFullscreenGame, settings.CursorCaptureEnabledInFullscreenGame); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInFullscreenMenu, settings.CursorCaptureEnabledInFullscreenMenu); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInWindowedGame, settings.CursorCaptureEnabledInWindowedGame); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInWindowedMenu, settings.CursorCaptureEnabledInWindowedMenu); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInFullscreenApp, settings.ScreenEdgeScrollEnabledInFullscreenApp); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInWindowedApp, settings.ScreenEdgeScrollEnabledInWindowedApp); + Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultMoneyTransactionVolume, settings.MoneyTransactionVolume); + Assert.NotEqual(0, settings.MoneyTransactionVolume); } /// - /// Verifies that the toggle fallbacks match the values declared on the settings model itself. + /// Verifies that cursor capture, edge scroll and observer toggles the profile leaves unset + /// keep the values already in settings.json. /// [Fact] - public void ApplyToGeneralsOnlineSettings_UnsetToggles_MatchesModelDefaults() + public void ApplyToGeneralsOnlineSettings_UnsetToggles_PreservesExistingValues() { - // Arrange - seed each toggle inverted, so a missing assignment fails + // Arrange - seed each toggle inverted relative to its GenHub default var profile = new GameProfile(); - var expected = new GeneralsOnlineSettings(); var settings = new GeneralsOnlineSettings { PlayerObserverEnabled = false, @@ -196,13 +188,13 @@ public void ApplyToGeneralsOnlineSettings_UnsetToggles_MatchesModelDefaults() GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); // Assert - Assert.Equal(expected.PlayerObserverEnabled, settings.PlayerObserverEnabled); - Assert.Equal(expected.CursorCaptureEnabledInFullscreenGame, settings.CursorCaptureEnabledInFullscreenGame); - Assert.Equal(expected.CursorCaptureEnabledInFullscreenMenu, settings.CursorCaptureEnabledInFullscreenMenu); - Assert.Equal(expected.CursorCaptureEnabledInWindowedGame, settings.CursorCaptureEnabledInWindowedGame); - Assert.Equal(expected.CursorCaptureEnabledInWindowedMenu, settings.CursorCaptureEnabledInWindowedMenu); - Assert.Equal(expected.ScreenEdgeScrollEnabledInFullscreenApp, settings.ScreenEdgeScrollEnabledInFullscreenApp); - Assert.Equal(expected.ScreenEdgeScrollEnabledInWindowedApp, settings.ScreenEdgeScrollEnabledInWindowedApp); + Assert.False(settings.PlayerObserverEnabled); + Assert.False(settings.CursorCaptureEnabledInFullscreenGame); + Assert.False(settings.CursorCaptureEnabledInFullscreenMenu); + Assert.False(settings.CursorCaptureEnabledInWindowedGame); + Assert.True(settings.CursorCaptureEnabledInWindowedMenu); + Assert.False(settings.ScreenEdgeScrollEnabledInFullscreenApp); + Assert.True(settings.ScreenEdgeScrollEnabledInWindowedApp); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs new file mode 100644 index 000000000..574b95262 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs @@ -0,0 +1,147 @@ +using System; +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public sealed class HtmlTextHelperTests +{ + /// + /// Verifies that NormalizeHtml returns an empty string when input is null or whitespace. + /// + /// The test input string. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\r\n\t")] + public void NormalizeHtml_NullOrWhitespace_ReturnsEmptyString(string? input) + { + var result = HtmlTextHelper.NormalizeHtml(input); + Assert.Equal(string.Empty, result); + } + + /// + /// Verifies that NormalizeHtml converts paragraph tags into paragraphs separated by newlines. + /// + [Fact] + public void NormalizeHtml_ParagraphTags_ConvertsToParagraphsAndStripsTags() + { + var html = "

First paragraph.

Second paragraph.

"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"First paragraph.{Environment.NewLine}{Environment.NewLine}Second paragraph."; + Assert.Equal(expected, result); + } + + /// + /// Verifies that NormalizeHtml converts break tags to line breaks. + /// + [Fact] + public void NormalizeHtml_BreakTags_ConvertsToNewlines() + { + var html = "Line 1
Line 2
Line 3
Line 4"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"Line 1{Environment.NewLine}Line 2{Environment.NewLine}Line 3{Environment.NewLine}Line 4"; + Assert.Equal(expected, result); + } + + /// + /// Verifies that NormalizeHtml strips inline HTML formatting tags. + /// + [Fact] + public void NormalizeHtml_InlineTags_StripsTagsCleanly() + { + var html = "Bold Italic Link Text"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("Bold Italic Link Text", result); + } + + /// + /// Verifies that NormalizeHtml decodes HTML entities into appropriate characters. + /// + [Fact] + public void NormalizeHtml_HtmlEntities_DecodesCorrectly() + { + var html = ""Hello & Welcome's <World>"   –"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("\"Hello & Welcome's \" –", result); + } + + /// + /// Verifies that NormalizeHtml strips paragraph tags from CNC Labs description snippets. + /// + [Fact] + public void NormalizeHtml_CncLabsDescriptionWithPTags_ResolvesCleanly() + { + var html = "

The Ships and Boats War map is a game map that takes place almost

"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("The Ships and Boats War map is a game map that takes place almost", result); + } + + /// + /// Verifies that NormalizeHtml collapses runs of excess blank lines to a double newline. + /// + [Fact] + public void NormalizeHtml_ExcessBlankLines_CollapsedToDoubleNewline() + { + var html = "First paragraph\n\n\n\n\nSecond paragraph"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"First paragraph{Environment.NewLine}{Environment.NewLine}Second paragraph"; + Assert.Equal(expected, result); + } + + /// + /// Verifies that CleanToSingleLine collapses multiple whitespace characters and newlines into a single space. + /// + [Fact] + public void CleanToSingleLine_WithHtmlAndNewlines_CollapsesWhitespace() + { + var html = "

First line

\n\n

Second line\twith spaces

"; + var result = HtmlTextHelper.CleanToSingleLine(html); + + Assert.Equal("First line Second line with spaces", result); + } + + /// + /// Verifies that CleanToSingleLine truncates strings exceeding maximum length and appends an ellipsis. + /// + [Fact] + public void CleanToSingleLine_WithMaxLength_TruncatesWithEllipsis() + { + var html = "

The Ships and Boats War map is a game map that takes place almost

"; + var result = HtmlTextHelper.CleanToSingleLine(html, 30); + + Assert.Equal(30, result.Length); + Assert.EndsWith("...", result, StringComparison.Ordinal); + Assert.Equal("The Ships and Boats War map...", result); + } + + /// + /// Verifies that TruncateWithEllipsis handles various length inputs and edge cases. + /// + /// The test input string. + /// The maximum allowed length. + /// The expected truncated output. + [Theory] + [InlineData(null, 10, "")] + [InlineData("", 10, "")] + [InlineData("Short text", 20, "Short text")] + [InlineData("ExactLengthText", 15, "ExactLengthText")] + [InlineData("A very long string exceeding limit", 10, "A very ...")] + [InlineData("Abcdef", 3, "Abc")] + [InlineData("Abcdef", 2, "Ab")] + public void TruncateWithEllipsis_VariousInputs_BehavesCorrectly(string? input, int maxLength, string expected) + { + var result = HtmlTextHelper.TruncateWithEllipsis(input, maxLength); + Assert.Equal(expected, result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index 59fe70edc..9bdd35e8b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -34,4 +34,240 @@ public void PathComparer_UsesWindowsOnlyCaseFolding() Assert.Equal(OperatingSystem.IsWindows(), pathsAreEqual); } + + /// + /// Accepts the base directory itself and anything nested beneath it. + /// + /// A candidate path relative to the base directory. + [Theory] + [InlineData("")] + [InlineData("file.dat")] + [InlineData("nested/deeper/file.dat")] + [InlineData("nested/../file.dat")] + public void IsPathWithinDirectory_AcceptsContainedPaths(string relativeCandidate) + { + var baseDirectory = Path.Combine(Path.GetTempPath(), "GenHubContainment"); + var candidate = Path.Combine(baseDirectory, relativeCandidate); + + Assert.True(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + + /// + /// Rejects traversal segments, escapes that only appear after normalization, and sibling + /// directories that merely share a name prefix with the base directory. + /// + /// A candidate path relative to the base directory. + [Theory] + [InlineData("..")] + [InlineData("../escaped.dat")] + [InlineData("nested/../../escaped.dat")] + [InlineData("../GenHubContainmentEvil/escaped.dat")] + public void IsPathWithinDirectory_RejectsEscapingPaths(string relativeCandidate) + { + var baseDirectory = Path.Combine(Path.GetTempPath(), "GenHubContainment"); + var candidate = Path.Combine(baseDirectory, relativeCandidate); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + + /// + /// Rejects a rooted candidate that resolves outside the base directory. + /// + [Fact] + public void IsPathWithinDirectory_RejectsAbsolutePathOutsideBase() + { + var baseDirectory = Path.Combine(Path.GetTempPath(), "GenHubContainment"); + var candidate = Path.Combine(Path.GetTempPath(), "GenHubElsewhere", "escaped.dat"); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + + /// + /// Rejects a candidate that reads as contained but leaves the base directory through a symbolic + /// link, which textual normalization alone cannot see. GenHub builds symlinked workspaces, so a + /// link inside a directory being written to is an ordinary shape rather than a contrived one. + /// + [Fact] + public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(outside); + + if (!TryCreateDirectorySymbolicLink(Path.Combine(baseDirectory, "link"), outside)) + { + return; + } + + var candidate = Path.Combine(baseDirectory, "link", "escaped.dat"); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Rejects a candidate that leaves the base directory through an intermediate symbolic link + /// when the target file on the outside destination already exists on disk. + /// + [Fact] + public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink_WhenOutsideTargetFileExists() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(outside); + + var outsideFile = Path.Combine(outside, "installer.exe"); + File.WriteAllText(outsideFile, "payload"); + + if (!TryCreateDirectorySymbolicLink(Path.Combine(baseDirectory, "link"), outside)) + { + return; + } + + var candidate = Path.Combine(baseDirectory, "link", "installer.exe"); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Accepts a candidate beneath a symbolic link that stays inside the base directory, so + /// following links tightens the check without refusing content a link merely reorganizes. + /// + [Fact] + public void IsPathWithinDirectory_AcceptsCandidateBehindASymbolicLinkThatStaysInside() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var inside = Path.Combine(baseDirectory, "real"); + Directory.CreateDirectory(inside); + + if (!TryCreateDirectorySymbolicLink(Path.Combine(baseDirectory, "link"), inside)) + { + return; + } + + var candidate = Path.Combine(baseDirectory, "link", "contained.dat"); + + Assert.True(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Rejects a candidate that is a direct file symbolic link pointing to a file outside the base directory. + /// + [Fact] + public void IsPathWithinDirectory_RejectsCandidateThatIsDirectFileSymbolicLink_PointingOutside() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(outside); + + var outsideFile = Path.Combine(outside, "secret.dat"); + File.WriteAllText(outsideFile, "secret"); + + var linkFile = Path.Combine(baseDirectory, "link_file.dat"); + if (!TryCreateFileSymbolicLink(linkFile, outsideFile)) + { + return; + } + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, linkFile)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Verifies that NormalizeRelativePath standardizes path separators. + /// + [Fact] + public void NormalizeRelativePath_StandardizesSeparators() + { + var input = @"folder\subfolder/file.exe"; + var normalized = PathHelper.NormalizeRelativePath(input); + + var expected = Path.Combine("folder", "subfolder", "file.exe"); + Assert.Equal(expected, normalized); + } + + private static string CreateWorkingDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "GenHubContainmentLinks", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + return root; + } + + private static bool TryCreateDirectorySymbolicLink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs index 7c5c2afe9..e9c2a95be 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs @@ -34,6 +34,7 @@ public class ApplicationDataPathConventionTests { // The implementation of the convention itself has to start somewhere. ["ConfigurationProviderService.cs"] = "Defines the canonical path.", + ["AppConfiguration.cs"] = "Resolves the legacy roaming root the upgrade migration reads from.", ["UserSettingsService.cs"] = "Loads the settings file that stores the override; cannot depend on it.", // Displays the built-in default next to the user's override in the UI. @@ -41,6 +42,9 @@ public class ApplicationDataPathConventionTests // Core-layer fallback, overridden at the composition root by ContentPipelineModule. ["ProviderDefinitionLoader.cs"] = "Default only; the DI registration supplies an override.", + + // UI image cache service initialized outside DI container. + ["ImageCacheService.cs"] = "Static singleton image cache initialized outside DI.", }; /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ArchiveFixtures.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ArchiveFixtures.cs new file mode 100644 index 000000000..2bd636ef4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ArchiveFixtures.cs @@ -0,0 +1,69 @@ +using System.IO.Compression; + +namespace GenHub.Tests.Core.Infrastructure; + +/// +/// Builds archive fixtures for extraction tests. +/// +internal static class ArchiveFixtures +{ + private const int EndOfCentralDirectoryLength = 22; + private const int CentralDirectoryOffsetField = 16; + private const int CentralUncompressedSizeField = 24; + private const int CentralLocalHeaderOffsetField = 42; + private const int LocalUncompressedSizeField = 22; + private const int EndOfCentralDirectorySignature = 0x06054b50; + private const int CentralDirectorySignature = 0x02014b50; + private const int LocalFileHeaderSignature = 0x04034b50; + + /// + /// Writes a single-entry archive that advertises a harmless size and then inflates to a much + /// larger one, which is the shape of a hostile archive that only gives itself away part-way + /// through decompression. + /// + /// The archive to write. + /// The name of the single entry. + /// The number of bytes the entry really decompresses to. + /// The size the archive headers advertise. + public static void CreateWithSpoofedEntrySize( + string archivePath, + string entryName, + int actualBytes, + int declaredBytes) + { + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + entryStream.Write(new byte[actualBytes]); + } + + // Rewrite the uncompressed-size fields in both the central directory record and the local + // file header. Offsets follow the ZIP layout: the end-of-central-directory record ends the + // file and points at the central directory, whose record points back at the local header. + var bytes = File.ReadAllBytes(archivePath); + var endOfCentralDirectory = bytes.Length - EndOfCentralDirectoryLength; + RequireSignature(bytes, endOfCentralDirectory, EndOfCentralDirectorySignature, "end-of-central-directory record"); + + var centralDirectory = BitConverter.ToInt32(bytes, endOfCentralDirectory + CentralDirectoryOffsetField); + RequireSignature(bytes, centralDirectory, CentralDirectorySignature, "central directory record"); + + var localHeader = BitConverter.ToInt32(bytes, centralDirectory + CentralLocalHeaderOffsetField); + RequireSignature(bytes, localHeader, LocalFileHeaderSignature, "local file header"); + + BitConverter.GetBytes(declaredBytes).CopyTo(bytes, centralDirectory + CentralUncompressedSizeField); + BitConverter.GetBytes(declaredBytes).CopyTo(bytes, localHeader + LocalUncompressedSizeField); + File.WriteAllBytes(archivePath, bytes); + } + + private static void RequireSignature(byte[] bytes, int offset, int signature, string recordName) + { + if (offset < 0 || offset + sizeof(int) > bytes.Length || + BitConverter.ToInt32(bytes, offset) != signature) + { + throw new InvalidOperationException( + $"Expected a ZIP {recordName} at offset {offset}. The layout written by ZipFile has drifted, " + + "so patching these offsets would corrupt the fixture instead of resizing its entry."); + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs index 666f4a002..c63be62bb 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs @@ -97,12 +97,12 @@ public void Convert_WithAvarUri_DoesNotReturnNull() } /// - /// Tests that throws . + /// Tests that throws . /// [Fact] - public void ConvertBack_ThrowsNotImplementedException() + public void ConvertBack_ThrowsNotSupportedException() { - Assert.Throws(() => + Assert.Throws(() => _converter.ConvertBack(null, typeof(string), null, _culture)); } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs new file mode 100644 index 000000000..40d247a50 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Threading.Tasks; +using GenHub.Infrastructure.Services; +using Xunit; + +namespace GenHub.Tests.Core.Infrastructure.Services; + +/// +/// Unit tests for security methods. +/// +public class ImageCacheServiceTests +{ + /// + /// Verifies that private and loopback IPv4/IPv6 addresses are rejected as unsafe. + /// + /// The IP string to test. + [Theory] + [InlineData("127.0.0.1")] + [InlineData("10.0.0.1")] + [InlineData("172.16.0.1")] + [InlineData("172.31.255.255")] + [InlineData("192.168.1.1")] + [InlineData("169.254.1.1")] + [InlineData("100.64.0.1")] + [InlineData("::1")] + [InlineData("fc00::1")] + [InlineData("fe80::1")] + public void IsSafeIpAddress_PrivateOrLoopback_ReturnsFalse(string ipString) + { + var ip = IPAddress.Parse(ipString); + Assert.False(ImageCacheService.IsSafeIpAddress(ip)); + } + + /// + /// Verifies that public routable IP addresses are accepted as safe. + /// + /// The IP string to test. + [Theory] + [InlineData("8.8.8.8")] + [InlineData("1.1.1.1")] + [InlineData("142.250.190.46")] + [InlineData("2606:4700:4700::1111")] + public void IsSafeIpAddress_PublicRoutableIp_ReturnsTrue(string ipString) + { + var ip = IPAddress.Parse(ipString); + Assert.True(ImageCacheService.IsSafeIpAddress(ip)); + } + + /// + /// Verifies that localhost and invalid hostnames are rejected by . + /// + /// The host to test. + /// A task representing the asynchronous test. + [Theory] + [InlineData("localhost")] + [InlineData("127.0.0.1")] + [InlineData("192.168.0.1")] + [InlineData("")] + public async Task IsSafeHostAsync_UnsafeHost_ReturnsFalseAsync(string host) + { + var result = await ImageCacheService.IsSafeHostAsync(host); + Assert.False(result); + } + + /// + /// Verifies that non-HTTP/HTTPS and UNC paths are rejected by . + /// + /// The URL to test. + [Theory] + [InlineData("file:///C:/secret.txt")] + [InlineData("custom://example.com/image.png")] + [InlineData("\\\\server\\share\\image.png")] + [InlineData("javascript:alert(1)")] + [InlineData("https://localhost/test.png")] + [InlineData("https://127.0.0.1/test.png")] + [InlineData("https://192.168.1.1/test.png")] + public void IsSafeRemoteUrl_UnsafeUrl_ReturnsFalse(string url) + { + var result = ImageCacheService.IsSafeRemoteUrl(url, out _); + Assert.False(result); + } + + /// + /// Verifies that valid public HTTP/HTTPS URLs are accepted. + /// + /// The URL to test. + [Theory] + [InlineData("https://example.com/image.png")] + [InlineData("https://cdn.playgenerals.online/images/cover.jpg")] + [InlineData("https://8.8.8.8/image.jpg")] + public void IsSafeRemoteUrl_SafeUrl_ReturnsTrue(string url) + { + var result = ImageCacheService.IsSafeRemoteUrl(url, out var uri); + Assert.True(result); + Assert.NotNull(uri); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs index 913684ff1..6de3d7e9a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -81,7 +80,6 @@ public ContentReconciliationServiceTests() /// /// A representing the asynchronous unit test. [Fact] - [SuppressMessage("DeepSource", "CS-R1136", Justification = "Expression tree lambdas in Moq do not support null propagation")] public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToPool_AndUpdateProfilesAsync() { // Arrange @@ -128,7 +126,7 @@ public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToP _profileManagerMock.Verify( x => x.UpdateProfileAsync( "profile-1", - It.Is(r => r.GameClient != null && r.GameClient.Id == newId), + It.Is(r => MatchesGameClientId(r, newId)), It.IsAny()), Times.Once, "Should update profile with new manifest ID"); @@ -282,4 +280,7 @@ public async Task ScheduleGarbageCollectionAsync_WhenDisabled_ReturnsFailureAsyn result.FirstError.Should().Be( GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage); } + + private static bool MatchesGameClientId(UpdateProfileRequest request, string expectedId) => + request.GameClient?.Id == expectedId; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameProfileDeserializationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameProfileDeserializationTests.cs index b19efd094..39cf3009d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameProfileDeserializationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameProfileDeserializationTests.cs @@ -8,9 +8,11 @@ namespace GenHub.Tests.Core.Models; /// /// Tests to verify that GameProfile correctly applies default values during deserialization. -/// This addresses the bug where WorkspaceStrategy was defaulting to SymlinkOnly (enum default 0) -/// WorkspaceStrategyJsonConverter correctly handles the null/missing property, allowing -/// services to apply the global default fallback. +/// The numeric values exercised here are not the profile format of any release: v0.0.3 serialized +/// profiles with a string enum converter, so it wrote member names. Numbers only reach a profile +/// file from v0.0.2 and older, or from a build of the default branch made while the enum was +/// reordered. Pinning the mapping is a deliberate decision, because the ordinals below are the ones +/// v0.0.3 wrote into workspaces.json and the two formats have to agree. /// public class GameProfileDeserializationTests { @@ -43,12 +45,12 @@ public void Deserialize_ProfileWithoutWorkspaceStrategy_ShouldHaveNullStrategy() [Fact] public void Deserialize_ProfileWithSymlinkOnly_ShouldPreserveSymlinkOnly() { - // Arrange - JSON with explicit SymlinkOnly (1) + // Arrange - JSON with explicit SymlinkOnly (0) var json = """ { "Id": "test_profile", "Name": "Test Profile", - "WorkspaceStrategy": 1 + "WorkspaceStrategy": 0 } """; @@ -58,7 +60,6 @@ public void Deserialize_ProfileWithSymlinkOnly_ShouldPreserveSymlinkOnly() // Assert Assert.NotNull(profile); - // Should NOT be overridden to HardLink anymore Assert.Equal(WorkspaceStrategy.SymlinkOnly, profile.WorkspaceStrategy); } @@ -68,12 +69,12 @@ public void Deserialize_ProfileWithSymlinkOnly_ShouldPreserveSymlinkOnly() [Fact] public void Deserialize_ProfileWithExplicitHardLink_ShouldPreserveHardLink() { - // Arrange - JSON with explicit HardLink (0) + // Arrange - JSON with explicit HardLink (3) var json = """ { "Id": "test_profile", "Name": "Test Profile", - "WorkspaceStrategy": 0 + "WorkspaceStrategy": 3 } """; @@ -91,12 +92,12 @@ public void Deserialize_ProfileWithExplicitHardLink_ShouldPreserveHardLink() [Fact] public void Deserialize_ProfileWithCopyStrategy_ShouldPreserveCopy() { - // Arrange - JSON with explicit Copy strategy (2) + // Arrange - JSON with explicit Copy strategy (1) var json = """ { "Id": "test_profile", "Name": "Test Profile", - "WorkspaceStrategy": 2 + "WorkspaceStrategy": 1 } """; @@ -227,4 +228,39 @@ public void Deserialize_ProfileWithStringEnum_ShouldParseCorrectly() Assert.NotNull(profile); Assert.Equal(WorkspaceStrategy.HardLink, profile.WorkspaceStrategy); } + + /// + /// Verifies that a profile persisted by releases up to v0.0.3, which wrote the strategy as a + /// name using the repository serializer options, still resolves to the same strategy. + /// + /// The strategy name persisted in the profile file. + /// The strategy the profile must resolve to. + [Theory] + [InlineData("SymlinkOnly", WorkspaceStrategy.SymlinkOnly)] + [InlineData("FullCopy", WorkspaceStrategy.FullCopy)] + [InlineData("HybridCopySymlink", WorkspaceStrategy.HybridCopySymlink)] + [InlineData("HardLink", WorkspaceStrategy.HardLink)] + public void Deserialize_LegacyProfileFile_ShouldPreserveStrategy(string strategyName, WorkspaceStrategy expected) + { + // Arrange - profile file as written by GameProfileRepository before the move to a string enum + var json = $$""" + { + "id": "test_profile", + "name": "Test Profile", + "workspaceStrategy": "{{strategyName}}" + } + """; + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + // Act + var profile = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.NotNull(profile); + Assert.Equal(expected, profile.WorkspaceStrategy); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameSettings/GeneralsOnlineSettingsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameSettings/GeneralsOnlineSettingsTests.cs index fdc156cbf..bbd847342 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameSettings/GeneralsOnlineSettingsTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameSettings/GeneralsOnlineSettingsTests.cs @@ -89,4 +89,66 @@ public void Serialization_Should_ProduceNestedSnakeCase() Assert.Contains("\"fps_limit\": 144", json); Assert.Contains("\"verbose_logging\": true", json); } + + /// + /// Verifies that settings.json keys this model does not declare survive a load-modify-save + /// round trip, because saving replaces the GeneralsOnline client's file wholesale. + /// + [Fact] + public void RoundTrip_Should_PreserveUnknownKeys() + { + // Arrange + var json = @" +{ + ""show_ping"": true, + ""auth_token"": ""secret"", + ""unmodelled_toggle"": false, + ""camera"": { + ""min_height"": 100.0, + ""unmodelled_zoom_step"": 7 + } +}"; + + // Act + var settings = JsonSerializer.Deserialize(json, _options); + Assert.NotNull(settings); + settings.ShowPing = false; + var rewritten = JsonSerializer.Serialize(settings, _options); + var reloaded = JsonSerializer.Deserialize(rewritten, _options); + + // Assert + Assert.NotNull(reloaded); + Assert.False(reloaded.ShowPing); + Assert.Equal(100.0f, reloaded.Camera.MinHeight); + Assert.True(reloaded.AdditionalSettings.ContainsKey("auth_token"), "client-owned key was dropped"); + Assert.True(reloaded.AdditionalSettings.ContainsKey("unmodelled_toggle"), "client-owned key was dropped"); + Assert.True(reloaded.Camera.AdditionalSettings.ContainsKey("unmodelled_zoom_step"), "client-owned nested key was dropped"); + Assert.Equal("secret", reloaded.AdditionalSettings["auth_token"].GetString()); + Assert.False(reloaded.AdditionalSettings["unmodelled_toggle"].GetBoolean()); + Assert.Equal(7, reloaded.Camera.AdditionalSettings["unmodelled_zoom_step"].GetInt32()); + } + + /// + /// Verifies that a section spelled as an explicit null, which is valid JSON and overwrites the + /// property initializer, is restored so that merging into the loaded settings cannot throw. + /// + [Fact] + public void EnsureNestedSectionsInitialized_Should_ReplaceSectionsDeserializedAsNull() + { + // Arrange + var json = @"{ ""camera"": null, ""chat"": null, ""debug"": null, ""render"": null, ""social"": null }"; + var settings = JsonSerializer.Deserialize(json, _options); + Assert.NotNull(settings); + Assert.Null(settings.Camera); + + // Act + settings.EnsureNestedSectionsInitialized(); + + // Assert + Assert.NotNull(settings.Camera); + Assert.NotNull(settings.Chat); + Assert.NotNull(settings.Debug); + Assert.NotNull(settings.Render); + Assert.NotNull(settings.Social); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Workspace/WorkspaceMetadataDeserializationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Workspace/WorkspaceMetadataDeserializationTests.cs new file mode 100644 index 000000000..2b7e9c226 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Workspace/WorkspaceMetadataDeserializationTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Workspace; +using Xunit; + +namespace GenHub.Tests.Core.Models.Workspace; + +/// +/// Tests that workspace metadata written by releases up to v0.0.3 still resolves to the strategy it +/// was persisted with. A mismatch between the persisted strategy and the profile strategy makes +/// WorkspaceManager discard and rebuild the workspace. +/// +public class WorkspaceMetadataDeserializationTests +{ + private static readonly JsonSerializerOptions MetadataOptions = new() { WriteIndented = true }; + + /// + /// Verifies that the raw ordinals stored in workspaces.json map back to their original strategies. + /// + [Fact] + public void Deserialize_LegacyWorkspacesFile_MapsOrdinalsToOriginalStrategies() + { + var json = """ + [ + { + "Id": "symlink-workspace", + "WorkspacePath": "/data/workspaces/symlink-workspace", + "GameClientId": "generals-zh", + "Strategy": 0, + "IsPrepared": true + }, + { + "Id": "fullcopy-workspace", + "WorkspacePath": "/data/workspaces/fullcopy-workspace", + "GameClientId": "generals-zh", + "Strategy": 1, + "IsPrepared": true + }, + { + "Id": "hybrid-workspace", + "WorkspacePath": "/data/workspaces/hybrid-workspace", + "GameClientId": "generals-zh", + "Strategy": 2, + "IsPrepared": true + }, + { + "Id": "hardlink-workspace", + "WorkspacePath": "/data/workspaces/hardlink-workspace", + "GameClientId": "generals-zh", + "Strategy": 3, + "IsPrepared": true + } + ] + """; + + var workspaces = JsonSerializer.Deserialize>(json, MetadataOptions); + + Assert.NotNull(workspaces); + Assert.Equal( + new[] + { + WorkspaceStrategy.SymlinkOnly, + WorkspaceStrategy.FullCopy, + WorkspaceStrategy.HybridCopySymlink, + WorkspaceStrategy.HardLink, + }, + workspaces.Select(workspace => workspace.Strategy)); + } + + /// + /// Verifies that a legacy workspace and the profile that owns it agree on the strategy, which is + /// the comparison that decides whether an existing workspace can be reused. + /// + /// The ordinal persisted in workspaces.json. + /// The strategy name persisted in the profile. + [Theory] + [InlineData(0, "SymlinkOnly")] + [InlineData(1, "FullCopy")] + [InlineData(2, "HybridCopySymlink")] + [InlineData(3, "HardLink")] + public void Deserialize_LegacyWorkspaceAndProfile_AgreeOnStrategy(int workspaceOrdinal, string profileStrategyName) + { + var workspaceJson = $$""" + { "Id": "workspace", "Strategy": {{workspaceOrdinal}} } + """; + var profileJson = $"\"{profileStrategyName}\""; + + var workspace = JsonSerializer.Deserialize(workspaceJson, MetadataOptions); + var profileStrategy = JsonSerializer.Deserialize(profileJson); + + Assert.NotNull(workspace); + Assert.Equal(profileStrategy, workspace.Strategy); + } + + /// + /// Verifies that newly written workspace metadata stores the strategy name, so a future + /// reordering of the enum cannot corrupt it. + /// + [Fact] + public void Serialize_WorkspaceMetadata_WritesStrategyName() + { + var workspaces = new List + { + new() { Id = "workspace", Strategy = WorkspaceStrategy.HardLink }, + }; + + var json = JsonSerializer.Serialize(workspaces, MetadataOptions); + + Assert.Contains("\"Strategy\": \"HardLink\"", json); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Serialization/JsonWorkspaceStrategyConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Serialization/JsonWorkspaceStrategyConverterTests.cs new file mode 100644 index 000000000..13de8cc97 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Serialization/JsonWorkspaceStrategyConverterTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using GenHub.Core.Models.Enums; +using Xunit; + +namespace GenHub.Tests.Core.Serialization; + +/// +/// Tests for . +/// +public class JsonWorkspaceStrategyConverterTests +{ + /// + /// Verifies that the strategy is written as its member name rather than its ordinal. + /// + /// The strategy to serialize. + /// The expected JSON payload. + [Theory] + [InlineData(WorkspaceStrategy.SymlinkOnly, "\"SymlinkOnly\"")] + [InlineData(WorkspaceStrategy.FullCopy, "\"FullCopy\"")] + [InlineData(WorkspaceStrategy.HybridCopySymlink, "\"HybridCopySymlink\"")] + [InlineData(WorkspaceStrategy.HardLink, "\"HardLink\"")] + public void Serialize_WritesStrategyName(WorkspaceStrategy strategy, string expectedJson) + { + var json = JsonSerializer.Serialize(strategy); + + Assert.Equal(expectedJson, json); + } + + /// + /// Verifies that the ordinals written by releases up to v0.0.3 still map to the same strategies. + /// + /// The legacy numeric JSON payload. + /// The strategy the payload must resolve to. + [Theory] + [InlineData("0", WorkspaceStrategy.SymlinkOnly)] + [InlineData("1", WorkspaceStrategy.FullCopy)] + [InlineData("2", WorkspaceStrategy.HybridCopySymlink)] + [InlineData("3", WorkspaceStrategy.HardLink)] + public void Deserialize_LegacyNumericValue_ReturnsOriginalStrategy(string json, WorkspaceStrategy expected) + { + var result = JsonSerializer.Deserialize(json); + + Assert.Equal(expected, result); + } + + /// + /// Verifies that string payloads are still accepted. + /// + /// The string JSON payload. + /// The strategy the payload must resolve to. + [Theory] + [InlineData("\"SymlinkOnly\"", WorkspaceStrategy.SymlinkOnly)] + [InlineData("\"FullCopy\"", WorkspaceStrategy.FullCopy)] + [InlineData("\"HybridCopySymlink\"", WorkspaceStrategy.HybridCopySymlink)] + [InlineData("\"HardLink\"", WorkspaceStrategy.HardLink)] + [InlineData("\"hardlink\"", WorkspaceStrategy.HardLink)] + public void Deserialize_StringValue_ReturnsMatchingStrategy(string json, WorkspaceStrategy expected) + { + var result = JsonSerializer.Deserialize(json); + + Assert.Equal(expected, result); + } + + /// + /// Verifies that a round trip preserves the strategy and produces a string payload. + /// + /// The strategy to round trip. + [Theory] + [InlineData(WorkspaceStrategy.SymlinkOnly)] + [InlineData(WorkspaceStrategy.FullCopy)] + [InlineData(WorkspaceStrategy.HybridCopySymlink)] + [InlineData(WorkspaceStrategy.HardLink)] + public void RoundTrip_PreservesStrategy(WorkspaceStrategy strategy) + { + var json = JsonSerializer.Serialize(strategy); + + using (var document = JsonDocument.Parse(json)) + { + Assert.Equal(JsonValueKind.String, document.RootElement.ValueKind); + } + + Assert.Equal(strategy, JsonSerializer.Deserialize(json)); + } + + /// + /// Verifies that unrecognised payloads fall back to the default strategy. + /// + /// The unrecognised JSON payload. + [Theory] + [InlineData("999")] + [InlineData("\"NotAStrategy\"")] + public void Deserialize_UnknownValue_ReturnsHardLink(string json) + { + var result = JsonSerializer.Deserialize(json); + + Assert.Equal(WorkspaceStrategy.HardLink, result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/ArchiveEntryNameTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/ArchiveEntryNameTests.cs new file mode 100644 index 000000000..4b0073301 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/ArchiveEntryNameTests.cs @@ -0,0 +1,75 @@ +using GenHub.Core.Utilities; + +namespace GenHub.Tests.Core.Utilities; + +/// +/// Tests the screening applied to archive entry names before they become filesystem paths. +/// +public class ArchiveEntryNameTests +{ + /// + /// Accepts the ordinary relative names archives are made of, including the traversal segments + /// that the containment check rather than this screen is responsible for. + /// + /// The entry name under test. + [Theory] + [InlineData("readme.txt")] + [InlineData("patch/readme.txt")] + [InlineData("patch\\readme.txt")] + [InlineData("Bob's Map/bob.map")] + [InlineData("patch/../readme.txt")] + [InlineData("../escaped.big")] + public void IsExtractable_AcceptsNamesThatCanNameAFile(string entryName) + { + Assert.True(ArchiveEntryName.IsExtractable(entryName)); + } + + /// + /// Refuses names that cannot name a file. These are the dangerous ones: combined with the + /// extraction directory they resolve to that directory itself, so the write would land on the + /// directory rather than inside it, and the containment check sees nothing wrong. + /// + /// The entry name under test. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("/")] + [InlineData("patch/")] + [InlineData("patch\\")] + [InlineData("patch/ /readme.txt")] + [InlineData(".")] + [InlineData("..")] + [InlineData("patch/.")] + [InlineData("patch/..")] + public void IsExtractable_RefusesNamesThatCannotNameAFile(string? entryName) + { + Assert.False(ArchiveEntryName.IsExtractable(entryName)); + } + + /// + /// Refuses names the strictest supported host cannot represent, so an archive is extracted the + /// same way everywhere. The colon matters most: on NTFS it names an alternate data stream, which + /// writes content that ordinary directory listings never show. + /// + /// The entry name under test. + [Theory] + [InlineData("readme.txt:stream")] + [InlineData("patch/readme.txt:stream")] + [InlineData("bad|name.dat")] + [InlineData("badname.dat")] + [InlineData("bad?name.dat")] + [InlineData("bad*name.dat")] + [InlineData("bad\"name.dat")] + [InlineData("bad\u0001name.dat")] + [InlineData("trailing.")] + [InlineData("trailing ")] + [InlineData("CON")] + [InlineData("nul.txt")] + [InlineData("patch/LPT1.dat")] + public void IsExtractable_RefusesNamesTheStrictestHostCannotRepresent(string entryName) + { + Assert.False(ArchiveEntryName.IsExtractable(entryName)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs new file mode 100644 index 000000000..23b7b8033 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs @@ -0,0 +1,352 @@ +using System.IO.Compression; +using System.Text; +using GenHub.Core.Constants; +using GenHub.Core.Exceptions; +using GenHub.Core.Utilities; +using GenHub.Tests.Core.Infrastructure; +using SharpCompress.Archives; + +namespace GenHub.Tests.Core.Utilities; + +/// +/// Tests that archive entries are bounded by the bytes they actually expand to. +/// +public sealed class BoundedArchiveExtractorTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubBoundedExtractor", + Guid.NewGuid().ToString("N")); + + /// + /// Initializes a new instance of the class. + /// + public BoundedArchiveExtractorTests() + { + Directory.CreateDirectory(_workingDirectory); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Writes the whole entry and reports the byte count when it fits inside both budgets. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_WritesEntryWithinBudgetAsync() + { + var payload = Encoding.UTF8.GetBytes("map contents"); + using var source = new MemoryStream(payload); + var destination = Path.Combine(_workingDirectory, "entry.dat"); + + var written = await BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "entry.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 1024); + + Assert.Equal(payload.Length, written); + Assert.Equal(payload, await File.ReadAllBytesAsync(destination)); + } + + /// + /// Aborts and removes the partial output when an entry expands past its own cap. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_RejectsEntryOverPerEntryCapAndDeletesPartialOutputAsync() + { + using var source = new MemoryStream(new byte[64 * 1024]); + var destination = Path.Combine(_workingDirectory, "bomb.dat"); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "bomb.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: long.MaxValue)); + + Assert.Equal("bomb.dat", failure.EntryName); + Assert.Equal(1024, failure.LimitBytes); + Assert.False(File.Exists(destination)); + } + + /// + /// Aborts when an entry fits its own cap but exhausts what remains of the archive-wide budget. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_RejectsEntryOverRemainingAggregateBudgetAsync() + { + using var source = new MemoryStream(new byte[64 * 1024]); + var destination = Path.Combine(_workingDirectory, "aggregate.dat"); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "aggregate.dat", + maxEntryBytes: long.MaxValue, + remainingAggregateBytes: 2048)); + + Assert.Equal(2048, failure.LimitBytes); + Assert.False(File.Exists(destination)); + } + + /// + /// Leaves an existing destination untouched when overwriting is not permitted. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_KeepsExistingFileWhenOverwriteNotAllowedAsync() + { + var destination = Path.Combine(_workingDirectory, "existing.dat"); + await File.WriteAllTextAsync(destination, "original"); + using var source = new MemoryStream(Encoding.UTF8.GetBytes("replacement")); + + await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "existing.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 1024)); + + Assert.Equal("original", await File.ReadAllTextAsync(destination)); + } + + /// + /// Leaves the existing destination untouched when an overwriting copy fails part-way through. + /// The replacement is staged beside the destination, so the only file removed is the one this + /// call wrote. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_KeepsExistingFileWhenOverwritingCopyFailsAsync() + { + var destination = Path.Combine(_workingDirectory, "replaced.dat"); + await File.WriteAllTextAsync(destination, "original"); + using var source = new MemoryStream(new byte[64 * 1024]); + + await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "replaced.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: long.MaxValue, + overwrite: true)); + + Assert.Equal("original", await File.ReadAllTextAsync(destination)); + Assert.Equal([destination], Directory.GetFiles(_workingDirectory)); + } + + /// + /// Replaces the existing destination once an overwriting copy completes, leaving no staging + /// file behind. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_ReplacesExistingFileWhenOverwriteAllowedAsync() + { + var destination = Path.Combine(_workingDirectory, "replaced.dat"); + await File.WriteAllTextAsync(destination, "original"); + using var source = new MemoryStream(Encoding.UTF8.GetBytes("replacement")); + + var written = await BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "replaced.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 1024, + overwrite: true); + + Assert.Equal("replacement".Length, written); + Assert.Equal("replacement", await File.ReadAllTextAsync(destination)); + Assert.Equal([destination], Directory.GetFiles(_workingDirectory)); + } + + /// + /// Rejects an entry once the archive-wide budget is spent, even when the entry is empty and so + /// never reaches the read loop where the running total is checked. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_RejectsEmptyEntryOnceAggregateBudgetIsSpentAsync() + { + using var source = new MemoryStream([]); + var destination = Path.Combine(_workingDirectory, "empty.dat"); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "empty.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 0)); + + Assert.Equal("empty.dat", failure.EntryName); + Assert.False(File.Exists(destination)); + } + + /// + /// Shrinks the archive-wide budget across the entries of one archive the way its callers do, so + /// an entry that fits its own cap comfortably is still refused once earlier entries have spent + /// what the archive was allowed. Only the surviving entries are left on disk. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_ShrinksTheAggregateBudgetAcrossEntriesAsync() + { + const long aggregateBudget = 4096; + const long entryCap = 4096; + int[] entrySizes = [3000, 1000, 200]; + long expandedBytes = 0; + + for (var index = 0; index < entrySizes.Length - 1; index++) + { + using var source = new MemoryStream(new byte[entrySizes[index]]); + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + Path.Combine(_workingDirectory, $"entry{index}.dat"), + $"entry{index}.dat", + entryCap, + aggregateBudget - expandedBytes); + } + + Assert.Equal(4000, expandedBytes); + + using var lastSource = new MemoryStream(new byte[entrySizes[^1]]); + var lastDestination = Path.Combine(_workingDirectory, "entry2.dat"); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + lastSource, + lastDestination, + "entry2.dat", + entryCap, + aggregateBudget - expandedBytes)); + + Assert.Equal(aggregateBudget - expandedBytes, failure.LimitBytes); + Assert.False(File.Exists(lastDestination)); + Assert.Equal(2, Directory.GetFiles(_workingDirectory).Length); + } + + /// + /// Names the exhausted budget rather than the entry when the archive had nothing left to spend, + /// so a diagnostic does not report an entry as expanding past a limit of zero bytes. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_ReportsASpentBudgetSeparatelyFromAnOversizedEntryAsync() + { + using var spent = new MemoryStream(new byte[16]); + using var oversized = new MemoryStream(new byte[64 * 1024]); + + var spentFailure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + spent, + Path.Combine(_workingDirectory, "spent.dat"), + "spent.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 0)); + + var oversizedFailure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + oversized, + Path.Combine(_workingDirectory, "oversized.dat"), + "oversized.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: long.MaxValue)); + + Assert.Contains("budget was already spent", spentFailure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("expanded past", spentFailure.Message, StringComparison.Ordinal); + Assert.Contains("expanded past the allowed 1024 bytes", oversizedFailure.Message, StringComparison.Ordinal); + } + + /// + /// Stages an overwriting write under a name of its own rather than one built from the + /// destination, so a destination close to the Windows path limit is not pushed past it by the + /// staging name alone. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_StagesUnderANameThatDoesNotGrowWithTheDestinationAsync() + { + var destination = Path.Combine(_workingDirectory, new string('n', 120) + ".dat"); + await File.WriteAllTextAsync(destination, "original"); + using var source = new DirectoryObservingStream(_workingDirectory, 64 * 1024); + + await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "long.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: long.MaxValue, + overwrite: true)); + + var staged = Assert.Single(source.ObservedFiles.Where(file => file != destination).Distinct()); + Assert.EndsWith(IoConstants.StagingFileSuffix, staged, StringComparison.Ordinal); + Assert.True( + staged.Length < destination.Length, + $"the staging path '{staged}' is longer than the destination it replaces"); + } + + /// + /// Rejects an archive entry whose central-directory header understates its real size. The + /// archive claims four kilobytes and inflates to twelve megabytes, which is only visible while + /// decompressing, so the copy must abort mid-stream and leave no partial output behind. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_RejectsArchiveThatUnderstatesItsDeclaredSizeAsync() + { + const int actualBytes = 12 * 1024 * 1024; + const int declaredBytes = 4096; + const long entryCap = 1024 * 1024; + + var archivePath = Path.Combine(_workingDirectory, "spoofed.zip"); + ArchiveFixtures.CreateWithSpoofedEntrySize(archivePath, "bomb.dat", actualBytes, declaredBytes); + + using var archive = ArchiveFactory.OpenArchive(archivePath); + var entry = archive.Entries.First(e => !e.IsDirectory); + Assert.Equal(declaredBytes, entry.Size); + + var destination = Path.Combine(_workingDirectory, "bomb.extracted"); + await using var entryStream = entry.OpenEntryStream(); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + entryStream, + destination, + entry.Key ?? string.Empty, + maxEntryBytes: entryCap, + remainingAggregateBytes: long.MaxValue)); + + Assert.Equal(entryCap, failure.LimitBytes); + Assert.False(File.Exists(destination)); + } + + private sealed class DirectoryObservingStream(string directory, int length) + : MemoryStream(new byte[length]) + { + public List ObservedFiles { get; } = []; + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ObservedFiles.AddRange(Directory.GetFiles(directory)); + + return base.ReadAsync(buffer, cancellationToken); + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Linux/Gameinstallations/SteamInstallationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Linux/Gameinstallations/SteamInstallationTests.cs index 18a3b4b3a..18ce626d7 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Linux/Gameinstallations/SteamInstallationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Linux/Gameinstallations/SteamInstallationTests.cs @@ -1,5 +1,6 @@ using GenHub.Core.Models.Enums; using GenHub.Linux.GameInstallations; +using GenHub.Tests.Linux.Infrastructure.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; namespace GenHub.Tests.Linux.Gameinstallations; @@ -7,6 +8,7 @@ namespace GenHub.Tests.Linux.Gameinstallations; /// /// Unit tests for . /// +[Collection(ApplicationCompositionCollection.Name)] public class SteamInstallationTests { /// @@ -39,4 +41,75 @@ public void Constructor_WithFetch_RunsWithoutException() var exception = Record.Exception(() => new SteamInstallation(true, NullLogger.Instance)); Assert.Null(exception); } + + /// + /// Verifies SetPaths sets Generals and Zero Hour paths properly. + /// + [Fact] + public void SetPaths_SetsGeneralsAndZeroHourPaths() + { + var installation = new SteamInstallation(NullLogger.Instance); + installation.SetPaths("/home/user/games/Generals", "/home/user/games/ZeroHour"); + + Assert.True(installation.HasGenerals); + Assert.Equal("/home/user/games/Generals", installation.GeneralsPath); + Assert.True(installation.HasZeroHour); + Assert.Equal("/home/user/games/ZeroHour", installation.ZeroHourPath); + } + + /// + /// Verifies PopulateGameClients adds clients to AvailableGameClients. + /// + [Fact] + public void PopulateGameClients_AddsClientsSuccessfully() + { + var installation = new SteamInstallation(NullLogger.Instance); + var clients = new[] + { + new GenHub.Core.Models.GameClients.GameClient { Id = "test-client-1", Name = "Client 1" }, + }; + + installation.PopulateGameClients(clients); + + Assert.Single(installation.AvailableGameClients); + Assert.Equal("test-client-1", installation.AvailableGameClients[0].Id); + } + + /// + /// Verifies Fetch detects Flatpak Steam game installations from mock home directory. + /// + [Fact] + public void Fetch_WithFlatpakSteamDirectory_DetectsGameInstallation() + { + var tempHome = Path.Combine(Path.GetTempPath(), "genhub_test_home_" + Guid.NewGuid().ToString("N")); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try + { + var gameDir = Path.Combine( + tempHome, + ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common", + GenHub.Core.Constants.GameClientConstants.ZeroHourDirectoryNameAmpersandHyphen); + + Directory.CreateDirectory(gameDir); + File.WriteAllText(Path.Combine(gameDir, "generals.exe"), "mock exe content"); + + Environment.SetEnvironmentVariable("HOME", tempHome); + + var installation = new SteamInstallation(NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.IsSteamInstalled); + Assert.True(installation.HasZeroHour); + Assert.Equal(gameDir, installation.ZeroHourPath); + } + finally + { + Environment.SetEnvironmentVariable("HOME", originalHome); + if (Directory.Exists(tempHome)) + { + Directory.Delete(tempHome, true); + } + } + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs new file mode 100644 index 000000000..529be0d13 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; +using GenHub.Windows.Features.Shortcuts; +using Microsoft.Win32; +using Xunit; +using Xunit.Abstractions; + +namespace GenHub.Tests.Windows.Features.Shortcuts; + +/// +/// Unit tests for . +/// +/// Output helper for surfacing test diagnostic messages. +[Collection(WindowsRegistryCollection.Name)] +[SupportedOSPlatform("windows")] +public sealed class UriSchemeRegistrarTests(ITestOutputHelper testOutputHelper) : IDisposable +{ + private const string TargetKeyPath = @"Software\Classes\genhub"; + private readonly RegistryKeySnapshot? _snapshot = CaptureInitialSnapshot(); + private readonly bool _existedPrior = KeyExists(); + + /// + /// Verifies that Register creates or updates the genhub registry keys in HKCU. + /// + [Fact] + public void Register_CreatesOrUpdatesGenhubRegistryKey() + { + // Act + UriSchemeRegistrar.Register(); + + // Assert + using var key = Registry.CurrentUser.OpenSubKey(TargetKeyPath); + Assert.NotNull(key); + + var protocolValue = key.GetValue(string.Empty) as string; + Assert.Equal("URL:genhub protocol", protocolValue); + + var urlProtocolFlag = key.GetValue("URL Protocol"); + Assert.NotNull(urlProtocolFlag); + + using var commandKey = Registry.CurrentUser.OpenSubKey($@"{TargetKeyPath}\shell\open\command"); + Assert.NotNull(commandKey); + + var command = commandKey.GetValue(string.Empty) as string; + Assert.NotNull(command); + Assert.Contains("%1", command); + Assert.Contains(Environment.ProcessPath ?? string.Empty, command, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that Register can be invoked repeatedly without failure or unexpected mutations. + /// + [Fact] + public void Register_IsIdempotent() + { + // Act - Call twice in succession to ensure no exceptions or unintended side effects occur + UriSchemeRegistrar.Register(); + var ex = Record.Exception(() => UriSchemeRegistrar.Register()); + + // Assert + Assert.Null(ex); + } + + /// + public void Dispose() + { + try + { + if (_existedPrior && _snapshot != null) + { + using var rootKey = Registry.CurrentUser.CreateSubKey(TargetKeyPath, writable: true); + if (rootKey != null) + { + RestoreSnapshot(rootKey, _snapshot); + } + } + else + { + Registry.CurrentUser.DeleteSubKeyTree(TargetKeyPath, throwOnMissingSubKey: false); + } + } + catch (Exception ex) + { + testOutputHelper.WriteLine($"Failed to restore registry snapshot during test teardown: {ex.Message}"); + } + } + + private static bool KeyExists() + { + using var rootKey = Registry.CurrentUser.OpenSubKey(TargetKeyPath, writable: false); + return rootKey != null; + } + + private static RegistryKeySnapshot? CaptureInitialSnapshot() + { + using var rootKey = Registry.CurrentUser.OpenSubKey(TargetKeyPath, writable: false); + return rootKey != null ? CaptureSnapshot(rootKey) : null; + } + + private static RegistryKeySnapshot CaptureSnapshot(RegistryKey key) + { + var snapshot = new RegistryKeySnapshot + { + Name = Path.GetFileName(key.Name), + }; + + foreach (var valueName in key.GetValueNames()) + { + var value = key.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames); + var kind = key.GetValueKind(valueName); + snapshot.Values[valueName] = (value, kind); + } + + foreach (var subKeyName in key.GetSubKeyNames()) + { + using var subKey = key.OpenSubKey(subKeyName, writable: false); + if (subKey != null) + { + snapshot.SubKeys.Add(CaptureSnapshot(subKey)); + } + } + + return snapshot; + } + + private static void RestoreSnapshot(RegistryKey targetKey, RegistryKeySnapshot snapshot) + { + // Delete values not present in snapshot + foreach (var valueName in targetKey.GetValueNames()) + { + if (!snapshot.Values.ContainsKey(valueName)) + { + targetKey.DeleteValue(valueName, throwOnMissingValue: false); + } + } + + // Restore values + foreach (var (valueName, (value, kind)) in snapshot.Values) + { + if (value != null) + { + targetKey.SetValue(valueName, value, kind); + } + } + + // Delete subkeys not present in snapshot + var snapshotSubKeyNames = new HashSet(snapshot.SubKeys.Select(s => s.Name), StringComparer.OrdinalIgnoreCase); + foreach (var subKeyName in targetKey.GetSubKeyNames()) + { + if (!snapshotSubKeyNames.Contains(subKeyName)) + { + targetKey.DeleteSubKeyTree(subKeyName, throwOnMissingSubKey: false); + } + } + + // Restore subkeys recursively + foreach (var subKeySnapshot in snapshot.SubKeys) + { + using var subKey = targetKey.CreateSubKey(subKeySnapshot.Name, writable: true); + if (subKey != null) + { + RestoreSnapshot(subKey, subKeySnapshot); + } + } + } + + private sealed class RegistryKeySnapshot + { + public string Name { get; set; } = string.Empty; + + public Dictionary Values { get; } = []; + + public List SubKeys { get; } = []; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs new file mode 100644 index 000000000..23847849f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs @@ -0,0 +1,15 @@ +using Xunit; + +namespace GenHub.Tests.Windows.Features.Shortcuts; + +/// +/// Prevents registry tests from overlapping and racing. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class WindowsRegistryCollection +{ + /// + /// The xUnit collection name. + /// + public const string Name = "Windows registry"; +} diff --git a/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs new file mode 100644 index 000000000..1917cf585 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using GenHub.Core.Constants; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace GenHub.Windows.Features.Shortcuts; + +/// +/// Registers the genhub:// URI scheme with Windows so OS/browser links open GenHub. +/// +/// +/// +/// Windows resolves custom protocols through HKCU\Software\Classes\<scheme>. Without +/// that key the shell shows an "app not installed" dialog when a genhub:// link is clicked. +/// The app already parses genhub://subscribe?url=... from its own command line +/// (GenHub.Core.Helpers.CommandLineParser.ExtractSubscriptionUrl); this registrar wires the +/// OS shell to that path. +/// +/// +/// Writes to HKCU (per-user), so no elevation is required. The registration is idempotent +/// and self-repairs: it rewrites the command only when the executable path has changed, which is +/// what happens every time a debug rebuild or Velopack update lands at a new path. +/// +/// +public static class UriSchemeRegistrar +{ + private const string SchemeName = CommandLineConstants.SchemeName; + private const string ClassesSubKey = @"Software\Classes\" + SchemeName; + + /// + /// Registers the genhub:// scheme for the current user, pointing at the running + /// executable. Safe to call on every launch. + /// + /// Optional logger for diagnostics. + public static void Register(ILogger? logger = null) + { + var executablePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(executablePath) || !File.Exists(executablePath)) + { + logger?.LogWarning("Could not register genhub:// scheme: executable path unavailable."); + return; + } + + try + { + var desiredCommand = $"\"{executablePath}\" \"%1\""; + var desiredProtocol = $"URL:{SchemeName} protocol"; + var desiredIcon = $"{executablePath},0"; + + // Check if already registered and up-to-date before performing any writes + using (var existingClassesKey = Registry.CurrentUser.OpenSubKey(ClassesSubKey, writable: false)) + { + if (existingClassesKey != null) + { + var existingProtocol = existingClassesKey.GetValue(string.Empty) as string; + var existingUrlProtocol = existingClassesKey.GetValue("URL Protocol"); + + using var existingCommandKey = existingClassesKey.OpenSubKey(@"shell\open\command", writable: false); + var existingCommand = existingCommandKey?.GetValue(string.Empty) as string; + + if (string.Equals(existingProtocol, desiredProtocol, StringComparison.OrdinalIgnoreCase) && + existingUrlProtocol != null && + string.Equals(existingCommand, desiredCommand, StringComparison.OrdinalIgnoreCase)) + { + logger?.LogDebug("genhub:// scheme is already registered and up-to-date."); + return; + } + } + } + + using var classesKey = Registry.CurrentUser.CreateSubKey(ClassesSubKey, writable: true); + + // URL Protocol flag tells the shell this is a URI handler, not a normal file type. + classesKey.SetValue(string.Empty, desiredProtocol); + classesKey.SetValue("URL Protocol", string.Empty); + + using var iconKey = classesKey.CreateSubKey("DefaultIcon"); + iconKey.SetValue(string.Empty, desiredIcon); + + using var commandKey = classesKey.CreateSubKey(@"shell\open\command"); + commandKey.SetValue(string.Empty, desiredCommand); + + logger?.LogInformation("Registered genhub:// scheme -> {ExecutablePath}", executablePath); + } + catch (Exception ex) + { + // Registration failure must never block app startup; the in-app subscribe paths still + // work via direct command-line invocation. + logger?.LogWarning(ex, "Failed to register genhub:// scheme."); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/Workspace/WindowsFileOperationsService.cs b/GenHub/GenHub.Windows/Features/Workspace/WindowsFileOperationsService.cs index 6c2ba2531..92d155a20 100644 --- a/GenHub/GenHub.Windows/Features/Workspace/WindowsFileOperationsService.cs +++ b/GenHub/GenHub.Windows/Features/Workspace/WindowsFileOperationsService.cs @@ -33,6 +33,10 @@ public Task CreateSymlinkAsync(string linkPath, string targetPath, bool allowFal public Task VerifyFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) => baseService.VerifyFileHashAsync(filePath, expectedHash, cancellationToken); + /// + public Task CheckFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) + => baseService.CheckFileHashAsync(filePath, expectedHash, cancellationToken); + /// public Task DownloadFileAsync(Uri url, string destinationPath, IProgress? progress = null, CancellationToken cancellationToken = default) => baseService.DownloadFileAsync(url, destinationPath, progress, cancellationToken); diff --git a/GenHub/GenHub.Windows/Program.cs b/GenHub/GenHub.Windows/Program.cs index 031e8108f..996834a0d 100644 --- a/GenHub/GenHub.Windows/Program.cs +++ b/GenHub/GenHub.Windows/Program.cs @@ -52,7 +52,7 @@ public static void Main(string[] args) // Extract profile ID from args if present (for IPC forwarding) var profileId = CommandLineParser.ExtractProfileId(args); - // Extract subscription URL from args if present (for IPC forwarding) + // Extract genhub://subscribe?url=... target (catalog JSON today; definition URL later) var subscriptionUrl = CommandLineParser.ExtractSubscriptionUrl(args); // Check for multi-instance mode (useful for debugging with multiple instances) @@ -74,7 +74,7 @@ public static void Main(string[] args) SingleInstanceManager.SendCommandToPrimaryInstance($"{IpcCommands.LaunchProfilePrefix}{profileId}"); } - // Forward subscribe command to primary instance if we have a subscription URL + // Forward subscribe so the running UI can show the confirmation dialog if (!string.IsNullOrEmpty(subscriptionUrl)) { bootstrapLogger.LogInformation("Forwarding subscribe command to primary instance: {Url}", subscriptionUrl); @@ -94,6 +94,10 @@ public static void Main(string[] args) bootstrapLogger.LogInformation("Multi-instance mode enabled - skipping single-instance check"); } + // Register the genhub:// URI scheme with Windows so clicked links open this executable. + // Registered for primary instance only; idempotent and per-user (HKCU). + Features.Shortcuts.UriSchemeRegistrar.Register(bootstrapLogger); + try { bootstrapLogger.LogInformation("Starting GenHub Windows application"); diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 3017451f0..a2f92fc64 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -11,6 +11,8 @@ using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Enums; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -65,8 +67,8 @@ public override void OnFrameworkInitializationCompleted() // Subscribe to IPC commands from secondary instances (Windows only) SubscribeToSingleInstanceCommands(mainWindow); - // Handle launch profile from startup args (first launch with shortcut) - SafeFireAndForget(HandleLaunchProfileArgsAsync(desktop.Args, mainWindow), "HandleLaunchProfileArgsAsync"); + // Handle startup arguments sequentially (launch profile, then subscription if present) + SafeFireAndForget(HandleStartupArgsAsync(desktop.Args, mainWindow), nameof(HandleStartupArgsAsync)); } base.OnFrameworkInitializationCompleted(); @@ -168,6 +170,17 @@ private async void OnShutdownRequested(object? sender, ShutdownRequestedEventArg } } + private async Task HandleStartupArgsAsync(string[]? args, MainWindow mainWindow) + { + if (args == null || args.Length == 0) + { + return; + } + + await HandleLaunchProfileArgsAsync(args, mainWindow); + await HandleSubscriptionArgsAsync(args, mainWindow); + } + private async Task HandleLaunchProfileArgsAsync(string[]? args, MainWindow mainWindow) { if (args == null || args.Length == 0) @@ -187,6 +200,25 @@ private async Task HandleLaunchProfileArgsAsync(string[]? args, MainWindow mainW await LaunchProfileByIdAsync(profileId, mainWindow); } + private async Task HandleSubscriptionArgsAsync(string[]? args, MainWindow mainWindow) + { + if (args == null || args.Length == 0) + { + return; + } + + var subscriptionUrl = CommandLineParser.ExtractSubscriptionUrl(args); + if (string.IsNullOrWhiteSpace(subscriptionUrl)) + { + return; + } + + var logger = _serviceProvider.GetService>(); + logger?.LogInformation("Startup subscription detected for URL: {Url}", subscriptionUrl); + + await HandleSubscriptionUrlAsync(subscriptionUrl, mainWindow); + } + private void SubscribeToSingleInstanceCommands(MainWindow mainWindow) { // Get the SingleInstanceManager from AppLocator (set by Windows Program.cs) @@ -197,10 +229,7 @@ private void SubscribeToSingleInstanceCommands(MainWindow mainWindow) } singleInstanceManager.CommandReceived += (_, command) => - { - // Dispatch to UI thread since the event comes from a background pipe listener Dispatcher.UIThread.Post(() => HandleSingleInstanceCommand(command, mainWindow)); - }; var logger = _serviceProvider.GetService>(); logger?.LogDebug("Subscribed to single instance IPC commands"); @@ -216,7 +245,15 @@ private void HandleSingleInstanceCommand(string command, MainWindow mainWindow) logger?.LogInformation("Received IPC launch command for profile: {ProfileId}", profileId); // Launch the profile - SafeFireAndForget(LaunchProfileByIdAsync(profileId, mainWindow), "LaunchProfileByIdAsync"); + SafeFireAndForget(LaunchProfileByIdAsync(profileId, mainWindow), nameof(LaunchProfileByIdAsync)); + } + else if (command.StartsWith(IpcCommands.SubscribePrefix, StringComparison.OrdinalIgnoreCase)) + { + var subscriptionUrl = command[IpcCommands.SubscribePrefix.Length..]; + logger?.LogInformation("Received IPC subscribe command for URL: {Url}", subscriptionUrl); + + // Handle the subscription URL + SafeFireAndForget(HandleSubscriptionUrlAsync(subscriptionUrl, mainWindow), nameof(HandleSubscriptionUrlAsync)); } else { @@ -269,4 +306,48 @@ private async Task LaunchProfileByIdAsync(string profileId, MainWindow mainWindo logger?.LogError(ex, "Exception while launching profile {ProfileId}", profileId); } } + + private async Task HandleSubscriptionUrlAsync(string subscriptionUrl, MainWindow mainWindow) + { + var logger = _serviceProvider.GetService>(); + + try + { + var sanitizedUrl = subscriptionUrl.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim('"', '\'', ' ', '\t'); + if (!Uri.TryCreate(sanitizedUrl, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + logger?.LogWarning("Invalid or unsafe subscription URL: {Url}", subscriptionUrl); + return; + } + + logger?.LogInformation("Handling subscription URL: {Url}", uri.AbsoluteUri); + + var dialogService = _serviceProvider.GetService(); + if (dialogService != null) + { + var confirmed = await dialogService.ShowConfirmationAsync( + "Subscribe to Catalog", + $"Do you want to subscribe to content from:\n{uri.AbsoluteUri}", + "Subscribe", + "Cancel"); + + if (confirmed) + { + if (mainWindow?.DataContext is MainViewModel mainViewModel) + { + mainViewModel.SelectTab(NavigationTab.Downloads); + } + + logger?.LogInformation("User confirmed subscription to: {Url}", uri.AbsoluteUri); + var notificationService = _serviceProvider.GetService(); + notificationService?.ShowSuccess("Subscribed", $"Successfully subscribed to: {uri.AbsoluteUri}"); + } + } + } + catch (Exception ex) + { + logger?.LogError(ex, "Exception while handling subscription URL {Url}", subscriptionUrl); + } + } } diff --git a/GenHub/GenHub/Common/Services/AppConfiguration.cs b/GenHub/GenHub/Common/Services/AppConfiguration.cs index 5576d14f1..42963680f 100644 --- a/GenHub/GenHub/Common/Services/AppConfiguration.cs +++ b/GenHub/GenHub/Common/Services/AppConfiguration.cs @@ -226,4 +226,11 @@ public string GetConfiguredDataPath() ? configured : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppConstants.AppName); } + + /// + /// Gets the application data path used by releases up to v0.0.3, which stored data under the roaming profile. + /// + /// The legacy application data path as a string. + public string GetLegacyConfiguredDataPath() => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppConstants.AppName); } \ No newline at end of file diff --git a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs index 948cc7975..e103e2964 100644 --- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs +++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; @@ -20,11 +22,41 @@ public class ConfigurationProviderService( IUserSettingsService userSettings, ILogger logger) : IConfigurationProviderService { + private static readonly string[] LegacyRootDirectories = + [ + DirectoryNames.Profiles, + FileTypes.ManifestsDirectory, + DirectoryNames.UserData, + ]; + + private static readonly string[] LegacySettingsFileNames = + [ + FileTypes.SettingsFileName, + FileTypes.LegacySettingsFileName, + ]; + + /// + /// The sub-paths of the legacy data root a tracked entry may sit in, most recent layout first so + /// that a newer copy wins over an older one when both are present. + /// + private static readonly string[] LegacyRootLayouts = + [ + string.Empty, + DirectoryNames.LegacyContent, + ]; + private readonly IAppConfiguration _appConfig = appConfig ?? throw new ArgumentNullException(nameof(appConfig)); private readonly IUserSettingsService _userSettings = userSettings ?? throw new ArgumentNullException(nameof(userSettings)); private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly object _migrationLock = new(); - private bool _migrated; + + /// + /// Set once the migration has finished. Volatile because the fast path in + /// reads it outside : without + /// the release/acquire pair a second thread could observe the flag on a weakly ordered + /// architecture and read profiles or manifests before the moves that produced them are visible. + /// + private volatile bool _migrated; /// public string GetWorkspacePath() @@ -144,6 +176,23 @@ public bool GetAutoCheckForUpdatesOnStartup() return !settings.IsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesOnStartup)) || settings.AutoCheckForUpdatesOnStartup; // App default } + /// + public bool GetAutoCheckForUpdatesPeriodically() + { + var settings = _userSettings.Get(); + return !settings.IsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesPeriodically)) || settings.AutoCheckForUpdatesPeriodically; // App default + } + + /// + public int GetPeriodicUpdateCheckIntervalMinutes() + { + var settings = _userSettings.Get(); + var value = settings.IsExplicitlySet(nameof(UserSettings.PeriodicUpdateCheckIntervalMinutes)) && settings.PeriodicUpdateCheckIntervalMinutes > 0 + ? settings.PeriodicUpdateCheckIntervalMinutes + : AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + return Math.Clamp(value, AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + } + /// public bool GetEnableDetailedLogging() { @@ -215,6 +264,8 @@ public UserSettings GetEffectiveSettings() MaxConcurrentDownloads = GetMaxConcurrentDownloads(), AllowBackgroundDownloads = GetAllowBackgroundDownloads(), AutoCheckForUpdatesOnStartup = GetAutoCheckForUpdatesOnStartup(), + AutoCheckForUpdatesPeriodically = GetAutoCheckForUpdatesPeriodically(), + PeriodicUpdateCheckIntervalMinutes = GetPeriodicUpdateCheckIntervalMinutes(), LastUpdateCheckTimestamp = _userSettings.Get().LastUpdateCheckTimestamp, EnableDetailedLogging = GetEnableDetailedLogging(), DefaultWorkspaceStrategy = GetDefaultWorkspaceStrategy(), @@ -240,10 +291,11 @@ public List GetContentDirectories() return settings.ContentDirectories; } + var dataRoot = GetApplicationDataPath(); return [ - Path.Combine(_appConfig.GetConfiguredDataPath(), FileTypes.ManifestsDirectory), - Path.Combine(_appConfig.GetConfiguredDataPath(), "CustomManifests"), + Path.Combine(dataRoot, FileTypes.ManifestsDirectory), + Path.Combine(dataRoot, DirectoryNames.CustomManifests), Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Command and Conquer Generals Zero Hour Data", @@ -259,43 +311,28 @@ public List GetGitHubDiscoveryRepositories() settings.GitHubDiscoveryRepositories != null && settings.GitHubDiscoveryRepositories.Count > 0) return settings.GitHubDiscoveryRepositories; - return ["TheSuperHackers/GeneralsGameCode"]; + return + [ + $"{SuperHackersConstants.GeneralsGameCodeOwner}/{SuperHackersConstants.GeneralsGameCodeRepo}", + $"{SuperHackersConstants.GeneralsGamePatch2Owner}/{SuperHackersConstants.GeneralsGamePatch2Repo}", + ]; } /// public string GetApplicationDataPath() { - if (!_migrated) - { - lock (_migrationLock) - { - if (!_migrated) - { - // Double-check - MigrateContentDirectory(); - _migrated = true; - } - } - } - - var settings = _userSettings.Get(); - if (settings.IsExplicitlySet(nameof(UserSettings.ApplicationDataPath)) && - !string.IsNullOrWhiteSpace(settings.ApplicationDataPath)) - { - return settings.ApplicationDataPath; - } - - return _appConfig.GetConfiguredDataPath(); + EnsureLegacyDataMigrated(); + return ResolveApplicationDataPath(); } /// public string GetRootAppDataPath() => _appConfig.GetConfiguredDataPath(); /// - public string GetProfilesPath() => Path.Combine(_appConfig.GetConfiguredDataPath(), DirectoryNames.Profiles); + public string GetProfilesPath() => Path.Combine(GetApplicationDataPath(), DirectoryNames.Profiles); /// - public string GetManifestsPath() => Path.Combine(_appConfig.GetConfiguredDataPath(), FileTypes.ManifestsDirectory); + public string GetManifestsPath() => Path.Combine(GetApplicationDataPath(), FileTypes.ManifestsDirectory); /// /// @@ -334,99 +371,280 @@ public string GetLogsPath() DirectoryNames.Logs.ToLowerInvariant()); } - private void MigrateContentDirectory() + /// + /// Moves the data written by releases that stored everything under the roaming application data + /// folder into the current data root, so upgrading users keep their profiles, manifests, tracked + /// user data, workspace metadata and settings. + /// + /// The roaming data root used before the move to local application data. + /// The root every consumer of reads from. + /// The root the settings file is read from and written to. + /// + /// + /// The two destinations differ deliberately. Profiles, manifests, tracked user data and the + /// workspace metadata are all resolved through , so they have + /// to follow an explicitly configured override; + /// moving them into the configured root instead would leave them where nothing ever looks. The + /// settings file is resolved straight from + /// and therefore has to land there. + /// + /// + /// Releases up to v0.0.3 nested the manifests, tracked user data and workspace metadata under a + /// Content directory, so both that layout and the flat one are probed and flattened into + /// the destination. Data that a v0.0.3 install kept outside the legacy root, because an + /// override pointed elsewhere, is out of scope and + /// stays where it is. + /// + /// + /// The CAS pool is deliberately excluded: still defaults to the + /// legacy location, so moving the pool would orphan it. + /// + /// + internal void MigrateLegacyDataRoot(string legacyRoot, string dataRoot, string settingsRoot) { - try + if (!Directory.Exists(legacyRoot)) { - var rootPath = _appConfig.GetConfiguredDataPath(); - var contentPath = Path.Combine(rootPath, "Content"); + return; + } - if (!Directory.Exists(contentPath)) - { - return; - } + var directories = ResolveLegacyDirectories(legacyRoot, dataRoot); + var files = ResolveLegacyFiles(legacyRoot, dataRoot, settingsRoot); - _logger.LogInformation("Migrating content from {ContentPath} to root {RootPath}", contentPath, rootPath); + if (directories.Count == 0 && files.Count == 0) + { + return; + } - // 1. Move Manifests - MigrateDirectory(Path.Combine(contentPath, "Manifests"), Path.Combine(rootPath, "Manifests")); + _logger.LogInformation( + "Migrating legacy data root {LegacyRoot} into {DataRoot}, settings into {SettingsRoot}", + legacyRoot, + dataRoot, + settingsRoot); - // 2. Move UserData - MigrateDirectory(Path.Combine(contentPath, "UserData"), Path.Combine(rootPath, "UserData")); + if (directories.Count > 0) + { + Directory.CreateDirectory(dataRoot); + } - // 3. Move workspaces.json - var sourceWorkspaces = Path.Combine(contentPath, "workspaces.json"); - var destWorkspaces = Path.Combine(rootPath, "workspaces.json"); - if (File.Exists(sourceWorkspaces)) + foreach (var (source, destination) in directories) + { + try { - if (!File.Exists(destWorkspaces)) - { - File.Move(sourceWorkspaces, destWorkspaces); - _logger.LogInformation("Moved workspaces.json to root"); - } - else - { - _logger.LogWarning("workspaces.json already exists in root, keeping original in Content (backup)"); - } + MigrateDirectory(source, destination); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate legacy directory {Source}", source); + } + } - // 4. Try to delete Content if empty + foreach (var (source, destination) in files) + { try { - if (Directory.GetFiles(contentPath).Length == 0 && Directory.GetDirectories(contentPath).Length == 0) - { - Directory.Delete(contentPath); - _logger.LogInformation("Deleted empty Content directory"); - } + MigrateFile(source, destination); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate legacy file {Source}", source); } - catch + } + } + + private static List<(string Source, string Destination)> ResolveLegacyDirectories(string legacyRoot, string dataRoot) => + LegacyRootDirectories + .SelectMany( + _ => LegacyRootLayouts, + (name, layout) => (Source: Path.Combine(legacyRoot, layout, name), Destination: Path.Combine(dataRoot, name))) + .Where(entry => Directory.Exists(entry.Source) && !PathHelper.AreSamePath(entry.Source, entry.Destination)) + .ToList(); + + private static List<(string Source, string Destination)> ResolveLegacyFiles(string legacyRoot, string dataRoot, string settingsRoot) => + LegacyRootLayouts + .Select(layout => ( + Source: Path.Combine(legacyRoot, layout, FileTypes.WorkspaceMetadataFileName), + Destination: Path.Combine(dataRoot, FileTypes.WorkspaceMetadataFileName))) + .Concat(LegacySettingsFileNames + .Select(name => ( + Source: Path.Combine(legacyRoot, name), + Destination: Path.Combine(settingsRoot, FileTypes.SettingsFileName)))) + .Where(entry => File.Exists(entry.Source) && !PathHelper.AreSamePath(entry.Source, entry.Destination)) + .ToList(); + + private void EnsureLegacyDataMigrated() + { + if (_migrated) + { + return; + } + + lock (_migrationLock) + { + if (_migrated) + { + return; + } + + MigrateLegacyDataRoot(); + MigrateContentDirectory(); + _migrated = true; + } + } + + /// + /// Resolves the effective data root without triggering the legacy migration, so the migration + /// itself can ask where the app will read from. + /// + /// The explicitly configured override when set, otherwise the configured data root. + private string ResolveApplicationDataPath() + { + var settings = _userSettings.Get(); + return settings.IsExplicitlySet(nameof(UserSettings.ApplicationDataPath)) && + !string.IsNullOrWhiteSpace(settings.ApplicationDataPath) + ? settings.ApplicationDataPath + : _appConfig.GetConfiguredDataPath(); + } + + private void MigrateLegacyDataRoot() + { + try + { + MigrateLegacyDataRoot( + _appConfig.GetLegacyConfiguredDataPath(), + ResolveApplicationDataPath(), + _appConfig.GetConfiguredDataPath()); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate legacy data root"); + } + } + + private void MigrateContentDirectory() + { + try + { + var rootPath = ResolveApplicationDataPath(); + var contentPath = Path.Combine(rootPath, DirectoryNames.LegacyContent); + + if (!Directory.Exists(contentPath)) { - // Ignore if not empty + return; } + + _logger.LogInformation("Migrating content from {ContentPath} to root {RootPath}", contentPath, rootPath); + + MigrateDirectory(Path.Combine(contentPath, FileTypes.ManifestsDirectory), Path.Combine(rootPath, FileTypes.ManifestsDirectory)); + MigrateDirectory(Path.Combine(contentPath, DirectoryNames.UserData), Path.Combine(rootPath, DirectoryNames.UserData)); + MigrateFile( + Path.Combine(contentPath, FileTypes.WorkspaceMetadataFileName), + Path.Combine(rootPath, FileTypes.WorkspaceMetadataFileName)); + + TryDeleteEmptyDirectory(contentPath); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) { _logger.LogError(ex, "Failed to migrate Content directory"); } } + private void TryDeleteEmptyDirectory(string path) + { + try + { + if (!Directory.EnumerateFileSystemEntries(path).Any()) + { + Directory.Delete(path); + _logger.LogInformation("Deleted empty directory {Path}", path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogDebug(ex, "Could not delete {Path} after migration", path); + } + } + private void MigrateDirectory(string sourceDir, string destDir) { - if (!Directory.Exists(sourceDir)) return; + if (!Directory.Exists(sourceDir)) + { + return; + } if (!Directory.Exists(destDir)) { - Directory.Move(sourceDir, destDir); - _logger.LogInformation("Moved {Source} to {Dest}", sourceDir, destDir); - return; + try + { + Directory.Move(sourceDir, destDir); + _logger.LogInformation("Moved {Source} to {Dest}", sourceDir, destDir); + return; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Could not move {Source} to {Dest} directly, falling back to per-entry migration", sourceDir, destDir); + Directory.CreateDirectory(destDir); + } } - // Destination exists, move content foreach (var file in Directory.GetFiles(sourceDir)) { - var destFile = Path.Combine(destDir, Path.GetFileName(file)); - if (!File.Exists(destFile)) + try { - File.Move(file, destFile); + MigrateFile(file, Path.Combine(destDir, Path.GetFileName(file))); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate {Source}, leaving it in place", file); } } foreach (var subDir in Directory.GetDirectories(sourceDir)) { - var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir)); - MigrateDirectory(subDir, destSubDir); + try + { + MigrateDirectory(subDir, Path.Combine(destDir, Path.GetFileName(subDir))); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate {Source}, leaving it in place", subDir); + } + } + + TryDeleteEmptyDirectory(sourceDir); + } + + private void MigrateFile(string sourceFile, string destFile) + { + if (!File.Exists(sourceFile)) + { + return; + } + + if (File.Exists(destFile)) + { + _logger.LogInformation("Skipping {Source}, {Dest} already exists", sourceFile, destFile); + return; + } + + var destDir = Path.GetDirectoryName(destFile); + if (!string.IsNullOrEmpty(destDir)) + { + Directory.CreateDirectory(destDir); } - // Try delete source if empty try { - if (!Directory.EnumerateFileSystemEntries(sourceDir).Any()) - { - Directory.Delete(sourceDir); - } + File.Move(sourceFile, destFile); } - catch + catch (IOException ex) { + // File.Move cannot cross volumes on every platform; copy and only drop the source once + // the copy is on disk so a failure can never lose the file. + _logger.LogWarning(ex, "Could not move {Source} to {Dest} directly, copying instead", sourceFile, destFile); + File.Copy(sourceFile, destFile, overwrite: false); + File.Delete(sourceFile); } + + _logger.LogInformation("Moved {Source} to {Dest}", sourceFile, destFile); } } diff --git a/GenHub/GenHub/Common/Services/UserSettingsService.cs b/GenHub/GenHub/Common/Services/UserSettingsService.cs index ab3bacb67..51d728522 100644 --- a/GenHub/GenHub/Common/Services/UserSettingsService.cs +++ b/GenHub/GenHub/Common/Services/UserSettingsService.cs @@ -1,10 +1,13 @@ using System; using System.IO; +using System.Linq; +using System.Security; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; using Microsoft.Extensions.Logging; @@ -27,10 +30,21 @@ public class UserSettingsService : IUserSettingsService Converters = { new JsonStringEnumConverter() }, }; + /// + /// The settings file names to look for in the pre-upgrade data root, most recent first. + /// Releases up to v0.0.3 combined the data root with the JSON extension rather than the settings + /// file name, so their settings file is literally named .json. + /// + private static readonly string[] LegacySettingsFileNames = + [ + FileTypes.SettingsFileName, + FileTypes.LegacySettingsFileName, + ]; + private readonly ILogger _logger; private readonly IAppConfiguration _appConfig; private readonly object _lock = new(); - private string _settingsFilePath = string.Empty; + private SettingsFileTarget _target = SettingsFileTarget.Unverified(string.Empty); private UserSettings _settings = new(); /// @@ -48,7 +62,11 @@ public UserSettingsService(ILogger logger, IAppConfiguratio /// /// Logger instance. /// Application configuration service. - /// Whether to perform normal initialization. + /// + /// Whether to read the settings from disk. When the service starts from + /// defaults with no file it is allowed to write, until + /// establishes one. + /// protected UserSettingsService(ILogger logger, IAppConfiguration appConfig, bool initialize) { _logger = logger; @@ -58,12 +76,29 @@ protected UserSettingsService(ILogger logger, IAppConfigura { InitializeSettings(); } - else - { - // For testing - set defaults but don't load from file - _settingsFilePath = string.Empty; - _settings = new UserSettings(); - } + } + + /// + /// What reading a settings file produced, so the caller can tell the absence of a settings file + /// apart from a settings file it could not read. + /// + private enum SettingsLoadOutcome + { + /// + /// No settings were there to read, so starting from defaults loses nothing. + /// + Absent, + + /// + /// The settings were read from the file. + /// + Loaded, + + /// + /// Settings exist but could not be read, so the defaults returned alongside this outcome + /// must never be persisted over them. + /// + Failed, } /// @@ -90,13 +125,7 @@ public void Update(Action applyChanges) // Only update internal state if no exception occurred _settings = settingsCopy; - - // If the settings file path was changed, update the internal field - if (!string.IsNullOrWhiteSpace(_settings.SettingsFilePath) && - !string.Equals(_settings.SettingsFilePath, _settingsFilePath, StringComparison.OrdinalIgnoreCase)) - { - _settingsFilePath = _settings.SettingsFilePath; - } + RetargetLocked(_settings.SettingsFilePath); _logger.LogDebug("Settings updated in memory"); } @@ -107,18 +136,13 @@ public async Task TryUpdateAndSaveAsync(Func applyChan { ArgumentNullException.ThrowIfNull(applyChanges); - bool accepted; + var accepted = false; lock (_lock) { accepted = applyChanges(_settings); if (accepted) { - // propagate any internal path updates - if (!string.IsNullOrWhiteSpace(_settings.SettingsFilePath) && - !string.Equals(_settings.SettingsFilePath, _settingsFilePath, StringComparison.OrdinalIgnoreCase)) - { - _settingsFilePath = _settings.SettingsFilePath; - } + RetargetLocked(_settings.SettingsFilePath); } } @@ -144,16 +168,31 @@ public async Task TryUpdateAndSaveAsync(Func applyChan /// /// Cancellation token for the operation. /// A task that represents the asynchronous save operation. + /// + /// Thrown when the settings file the save would write has not been verified as safe to + /// overwrite, either because it could not be read or because the in-memory settings came from + /// a different file. + /// public async Task SaveAsync(CancellationToken cancellationToken = default) { - UserSettings settingsToSave; - string pathToSave; + var settingsToSave = new UserSettings(); + var target = SettingsFileTarget.Unverified(string.Empty); lock (_lock) { - pathToSave = _settingsFilePath; + target = _target; settingsToSave = Get(); } + var pathToSave = target.Path; + if (!target.CanWrite) + { + _logger.LogError( + "Refusing to save settings to {Path}: the settings held in memory were not read from it, so saving would replace its contents with unrelated values", + pathToSave); + throw new InvalidOperationException( + $"The settings file '{pathToSave}' was never read into the current settings; saving would overwrite it with values that did not come from it."); + } + try { var directory = Path.GetDirectoryName(pathToSave); @@ -185,17 +224,35 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) } /// - /// Sets the settings file path for testing purposes. + /// Adopts as the settings file, reading it into the in-memory settings. + /// This is the "start using this file" move, and it necessarily discards the settings currently + /// held in memory, which is why the settings the user is editing are never re-pointed through it. /// /// The path to set. /// Thrown when is null, empty, or consists only of white-space characters. protected void SetSettingsFilePath(string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path, nameof(path)); - _settingsFilePath = path; - _settings = LoadSettings(path); + + lock (_lock) + { + _settings = LoadSettings(path, out var outcome); + _target = TargetFor(path, outcome); + } } + /// + /// Pairs a settings file with what reading it produced, so a path can never be adopted without + /// the read that decides whether writing it is safe. + /// + /// The settings file that was read. + /// What reading it produced. + /// The target the service should hold. + private static SettingsFileTarget TargetFor(string path, SettingsLoadOutcome outcome) => + outcome == SettingsLoadOutcome.Failed + ? SettingsFileTarget.Unverified(path) + : SettingsFileTarget.Verified(path); + private static void NormalizeAndValidateLocked(UserSettings s, IAppConfiguration appConfig) { // Only apply basic validation/clamping, no defaults @@ -275,13 +332,27 @@ private static string ConvertJsonPropertyNameToCSharp(string jsonPropertyName) }; } - private UserSettings LoadSettings(string path) + /// + /// Reads the settings at , falling back to defaults on any failure. + /// + /// The settings file to read. + /// + /// Receives what the read produced. A missing or empty file is reported as + /// because it holds nothing a save could destroy; + /// anything else that stops the file from being turned into settings is reported as + /// . + /// + /// The settings that were read, or defaults when they could not be. + private UserSettings LoadSettings(string path, out SettingsLoadOutcome outcome) { + outcome = SettingsLoadOutcome.Failed; + try { if (!File.Exists(path)) { _logger.LogInformation("Settings file not found at {Path}, using defaults", path); + outcome = SettingsLoadOutcome.Absent; return new UserSettings(); } @@ -289,6 +360,7 @@ private UserSettings LoadSettings(string path) if (string.IsNullOrWhiteSpace(json)) { _logger.LogWarning("Settings file is empty at {Path}, using defaults", path); + outcome = SettingsLoadOutcome.Absent; return new UserSettings(); } @@ -303,6 +375,7 @@ private UserSettings LoadSettings(string path) MarkExplicitlySetPropertiesFromJson(settings, json); _logger.LogInformation("Settings loaded successfully from {Path}", path); + outcome = SettingsLoadOutcome.Loaded; return settings; } catch (IOException ex) @@ -322,6 +395,48 @@ private UserSettings LoadSettings(string path) } } + /// + /// Points saves at on behalf of a user who edited the settings file + /// location, reading it first so the move cannot leave the service treating an unread file as + /// safe to overwrite. + /// + /// + /// A path that already holds settings is adopted as the write target but left unverified, so + /// refuses instead of replacing that file with values derived from a + /// different one. Refusing rather than reloading is the only reading of the request that + /// destroys nothing: the file keeps its contents and the user keeps the edits they were saving, + /// and the ambiguity between "start using this file" and "save my settings there" is theirs to + /// resolve. Recovery needs no extra state, because pointing back at the verified file, or at + /// the same path once it no longer holds settings, verifies the target again. + /// + /// The requested settings file path. A blank path leaves the target alone. + private void RetargetLocked(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var moved = _target.MoveTo(path); + if (moved.CanWrite) + { + _target = moved; + return; + } + + LoadSettings(path, out var outcome); + if (outcome == SettingsLoadOutcome.Absent) + { + _target = SettingsFileTarget.Verified(path); + return; + } + + _logger.LogError( + "Refusing to adopt {Path} as the settings file: it already holds settings that the settings in memory were not read from, so saving there would replace them", + path); + _target = moved; + } + private string GetDefaultSettingsFilePath() { if (_appConfig == null) @@ -334,29 +449,159 @@ private string GetDefaultSettingsFilePath() return Path.Combine(_appConfig.GetConfiguredDataPath(), FileTypes.SettingsFileName); } + /// + /// Resolves the file the settings are read from. When the current data root holds no settings + /// file, the pre-upgrade roaming location is read instead so an upgrading user keeps their + /// settings on the first launch rather than starting from defaults and then overwriting the + /// migrated file on the first save. Writes always target ; moving + /// the file remains the responsibility of the legacy data root migration. + /// + /// The settings file path for the current data root. + /// The path the settings should be read from. + private string ResolveSettingsSourcePath(string defaultPath) + { + try + { + if (_appConfig == null || File.Exists(defaultPath)) + { + return defaultPath; + } + + var legacyRoot = _appConfig.GetLegacyConfiguredDataPath(); + var legacyPath = LegacySettingsFileNames + .Select(name => Path.Combine(legacyRoot, name)) + .FirstOrDefault(path => !PathHelper.AreSamePath(path, defaultPath) && File.Exists(path)); + + if (legacyPath is not null) + { + _logger.LogInformation( + "No settings file at {DefaultPath}, reading pre-upgrade settings from {LegacyPath}", + defaultPath, + legacyPath); + return legacyPath; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogWarning(ex, "Failed to look for pre-upgrade settings, falling back to {DefaultPath}", defaultPath); + } + + return defaultPath; + } + + /// + /// Loads the settings and resolves the path they are persisted to. + /// + /// + /// A failure here leaves the target unverified, which blocks + /// rather than letting the session persist defaults over a settings file that was never read. + /// That covers both the exceptions that escape to the outer catch and the ones + /// swallows, which is why the source it read has to report whether it + /// was absent, read, or unreadable: only an unreadable source has values a save could destroy, + /// and that holds for the pre-upgrade source just as much as for the current one. + /// Normalization is applied separately: clamping to an inconsistent configured range is no reason + /// to discard settings that loaded fine. + /// private void InitializeSettings() { - // 1. Load from default path to determine if a custom path is set. - var defaultPath = GetDefaultSettingsFilePath(); - var initialSettings = LoadSettings(defaultPath); + try + { + var defaultPath = GetDefaultSettingsFilePath(); + var initialSettings = LoadSettings(ResolveSettingsSourcePath(defaultPath), out var outcome); + + // If the user has a custom path, reload from there; otherwise keep what the default path gave us. + string writePath; + if (!string.IsNullOrWhiteSpace(initialSettings.SettingsFilePath) && + !PathHelper.AreSamePath(initialSettings.SettingsFilePath, defaultPath)) + { + writePath = initialSettings.SettingsFilePath; + _settings = LoadSettings(writePath, out outcome); + } + else + { + writePath = defaultPath; + _settings = initialSettings; + } + + _target = TargetFor(writePath, outcome); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize settings, continuing with defaults and without persistence"); + _settings = new UserSettings(); + _target = SettingsFileTarget.Unverified(string.Empty); + return; + } - // 2. If user has a custom path, reload from that path. Otherwise, use the settings from the default path. - if (!string.IsNullOrWhiteSpace(initialSettings.SettingsFilePath) && - !string.Equals(initialSettings.SettingsFilePath, defaultPath, StringComparison.OrdinalIgnoreCase)) + try { - _settingsFilePath = initialSettings.SettingsFilePath; - _settings = LoadSettings(_settingsFilePath); + lock (_lock) + { + NormalizeAndValidateLocked(_settings, _appConfig); + } } - else + catch (ArgumentException ex) { - _settingsFilePath = defaultPath; - _settings = initialSettings; + _logger.LogError(ex, "Failed to normalize settings, keeping the loaded values as they are"); } + } - // Apply validation and normalization - lock (_lock) + /// + /// The settings file a save writes to, paired with the file that was last verified as safe to + /// overwrite. + /// + /// + /// The pairing is what makes the guard hold structurally. The two facts live in one immutable + /// value with a private constructor, so a caller cannot move the write path and leave a stale + /// "already read" flag behind it: the only ways to produce a target are to state that a path was + /// verified, to state that it was not, or to move away from a verified path, which drops the + /// permission to write with it. + /// + private sealed class SettingsFileTarget + { + private SettingsFileTarget(string path, string verifiedPath) { - NormalizeAndValidateLocked(_settings, _appConfig); + Path = path; + VerifiedPath = verifiedPath; } + + /// + /// Gets the settings file a save writes to. + /// + public string Path { get; } + + /// + /// Gets the settings file last verified as safe to overwrite, either because it was read + /// into the in-memory settings or because it held nothing a save could destroy. Empty when + /// no file has been verified. + /// + public string VerifiedPath { get; } + + /// + /// Gets a value indicating whether saving writes the file the in-memory settings account + /// for rather than an unrelated one. + /// + public bool CanWrite => VerifiedPath.Length > 0 && PathHelper.AreSamePath(Path, VerifiedPath); + + /// + /// Creates a target for a file that was read, or that held nothing a save could destroy. + /// + /// The settings file. + /// A target that may be written. + public static SettingsFileTarget Verified(string path) => new(path, path); + + /// + /// Creates a target for a file holding settings the in-memory settings do not account for. + /// + /// The settings file. + /// A target that must not be written. + public static SettingsFileTarget Unverified(string path) => new(path, string.Empty); + + /// + /// Moves the write path, carrying the verified file rather than the permission to write. + /// + /// The settings file to write from now on. + /// The moved target, writable only when it lands back on the verified file. + public SettingsFileTarget MoveTo(string path) => new(path, VerifiedPath); } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs index b433c0f22..751255560 100644 --- a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs +++ b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs @@ -13,13 +13,16 @@ using CommunityToolkit.Mvvm.Messaging; using GenHub.Common.ViewModels.Dialogs; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Notifications; using GenHub.Core.Messages; +using GenHub.Core.Models.AppUpdate; using GenHub.Core.Models.Dialogs; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Notifications; using GenHub.Features.AppUpdate.Interfaces; +using GenHub.Features.AppUpdate.ViewModels; using GenHub.Features.Downloads.ViewModels; using GenHub.Features.GameProfiles.ViewModels; using GenHub.Features.Info.ViewModels; @@ -27,6 +30,7 @@ using GenHub.Features.Settings.ViewModels; using GenHub.Features.Tools.ViewModels; using Microsoft.Extensions.Logging; +using Velopack; namespace GenHub.Common.ViewModels; @@ -59,9 +63,11 @@ public partial class MainViewModel( IDialogService dialogService, NotificationFeedViewModel notificationFeedViewModel, InfoViewModel infoViewModel, - ILogger logger) : ObservableObject, IDisposable, IRecipient + ILogger logger) : ObservableObject, IDisposable, IRecipient, IRecipient { private readonly CancellationTokenSource _initializationCts = new(); + private Timer? _periodicUpdateTimer; + private string? _lastNotifiedUpdateIdentity; /// /// Initializes a new instance of the class for design-time support. @@ -161,6 +167,12 @@ public void Receive(NavigationMessage message) Dispatcher.UIThread.Post(() => SelectTab(message.Tab)); } + /// + public void Receive(UpdateSettingsChangedMessage message) + { + RestartPeriodicUpdateTimer(message.AutoCheckForUpdatesPeriodically, message.PeriodicUpdateCheckIntervalMinutes); + } + /// /// Selects the specified navigation tab. /// @@ -184,8 +196,15 @@ public async Task InitializeAsync() await InfoViewModel.InitializeAsync(); logger?.LogInformation("MainViewModel initialized"); - // Start background check with cancellation support - _ = CheckForUpdatesInBackgroundAsync(_initializationCts.Token); + var settings = userSettingsService.Get(); + if (settings.AutoCheckForUpdatesOnStartup) + { + // Start background check with cancellation support + _ = CheckForUpdatesInBackgroundAsync(_initializationCts.Token); + } + + // Initialize periodic update timer + RestartPeriodicUpdateTimer(settings.AutoCheckForUpdatesPeriodically, settings.PeriodicUpdateCheckIntervalMinutes); CheckForQuickStart(); } @@ -195,8 +214,10 @@ public async Task InitializeAsync() /// public void Dispose() { + _periodicUpdateTimer?.Dispose(); _initializationCts?.Cancel(); _initializationCts?.Dispose(); + WeakReferenceMessenger.Default.UnregisterAll(this); GC.SuppressFinalize(this); } @@ -223,7 +244,10 @@ private static NavigationTab LoadInitialTab(IConfigurationProviderService config // Register for messages private void RegisterMessages() { - WeakReferenceMessenger.Default.Register(this); + if (!WeakReferenceMessenger.Default.IsRegistered(this)) + { + WeakReferenceMessenger.Default.RegisterAll(this); + } } /// @@ -237,61 +261,167 @@ private async Task CheckForUpdatesAsync(CancellationToken cancellationToken = de { var settings = userSettingsService.Get(); - // Push settings to update manager (important context for other components) + // 1. check for subscribed pr artifacts if (settings.SubscribedPrNumber.HasValue) { - velopackUpdateManager.SubscribedPrNumber = settings.SubscribedPrNumber; + var prNumber = settings.SubscribedPrNumber.Value; + logger?.LogDebug("User subscribed to PR #{PrNumber}, checking for artifact updates", prNumber); + velopackUpdateManager.SubscribedPrNumber = prNumber; + velopackUpdateManager.SubscribedBranch = null; + + var artifactUpdate = await velopackUpdateManager.CheckForArtifactUpdatesAsync(cancellationToken); + if (artifactUpdate != null) + { + var currentVersionBase = UpdateNotificationViewModel.CurrentAppVersion.Split('+')[0]; + var artifactVersionBase = artifactUpdate.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(artifactVersionBase, currentVersionBase) && + !string.Equals(artifactVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + var updateIdentity = $"pr:{prNumber}:{artifactVersionBase}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug("Update notification already shown for {Identity}, skipping duplicate notification", updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("PR #{PrNumber} update available: {Version}", prNumber, artifactUpdate.DisplayVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.PrUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.PrUpdateNotificationFormat, artifactUpdate.DisplayVersion, prNumber), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(artifactUpdate, null, null), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + } + } + + return; + } + + // 2. check for subscribed branch artifacts + if (!string.IsNullOrWhiteSpace(settings.SubscribedBranch)) + { + var branch = settings.SubscribedBranch; + logger?.LogDebug("User subscribed to branch '{Branch}', checking for artifact updates", branch); + velopackUpdateManager.SubscribedBranch = branch; + velopackUpdateManager.SubscribedPrNumber = null; + + var artifactUpdate = await velopackUpdateManager.CheckForArtifactUpdatesAsync(cancellationToken); + if (artifactUpdate != null) + { + var currentVersionBase = UpdateNotificationViewModel.CurrentAppVersion.Split('+')[0]; + var artifactVersionBase = artifactUpdate.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(artifactVersionBase, currentVersionBase) && + !string.Equals(artifactVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + var updateIdentity = $"branch:{branch}:{artifactVersionBase}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug("Update notification already shown for {Identity}, skipping duplicate notification", updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("Branch '{Branch}' update available: {Version}", branch, artifactUpdate.DisplayVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.BranchUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.BranchUpdateNotificationFormat, artifactUpdate.DisplayVersion, branch), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(artifactUpdate, null, null), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + } + } + + return; } - // 1. Check for standard GitHub releases (Default) - if (string.IsNullOrEmpty(settings.SubscribedBranch)) + // 3. check for standard github releases + velopackUpdateManager.SubscribedPrNumber = null; + velopackUpdateManager.SubscribedBranch = null; + + var updateInfo = await velopackUpdateManager.CheckForUpdatesAsync(cancellationToken); + if (updateInfo != null) { - var updateInfo = await velopackUpdateManager.CheckForUpdatesAsync(cancellationToken); - if (updateInfo != null) + var version = updateInfo.TargetFullRelease.Version.ToString(); + if (!string.Equals(version, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) { - logger?.LogInformation("GitHub release update available: {Version}", updateInfo.TargetFullRelease.Version); - await Dispatcher.UIThread.InvokeAsync(() => notificationService.Show(new NotificationMessage( + var updateIdentity = $"release:{version}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug("Update notification already shown for {Identity}, skipping duplicate notification", updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("GitHub release update available: {Version}", version); + notificationService.Show(new NotificationMessage( NotificationType.Info, - "Update Available", - $"A new version ({updateInfo.TargetFullRelease.Version}) is available.", - null, // Persistent + AppUpdateConstants.UpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.ReleaseUpdateNotificationFormat, version), + autoDismissMilliseconds: null, actions: [ new NotificationAction( - "View Updates", - () => SettingsViewModel.OpenUpdateWindowCommand.Execute(null), + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(null, updateInfo, null), NotificationActionStyle.Primary, dismissOnExecute: true), - ]))); + ], + isPersistent: true, + showInBadge: true)); return; } } - else + else if (velopackUpdateManager.HasUpdateAvailableFromGitHub) { - // 2. Check for Subscribed Branch Artifacts - logger?.LogDebug("User subscribed to branch '{Branch}', checking for artifact updates", settings.SubscribedBranch); - velopackUpdateManager.SubscribedBranch = settings.SubscribedBranch; - velopackUpdateManager.SubscribedPrNumber = null; // Clear PR to avoid ambiguity - - var artifactUpdate = await velopackUpdateManager.CheckForArtifactUpdatesAsync(cancellationToken); - - if (artifactUpdate != null) + var githubVersion = velopackUpdateManager.LatestVersionFromGitHub; + if (!string.IsNullOrWhiteSpace(githubVersion) && + !string.Equals(githubVersion, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) { - var newVersionBase = artifactUpdate.Version.Split('+')[0]; + var updateIdentity = $"github:{githubVersion}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug("Update notification already shown for {Identity}, skipping duplicate notification", updateIdentity); + return; + } - await Dispatcher.UIThread.InvokeAsync(() => notificationService.Show(new NotificationMessage( + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("GitHub API release update available: {Version}", githubVersion); + notificationService.Show(new NotificationMessage( NotificationType.Info, - "Branch Update Available", - $"A new build ({newVersionBase}) is available on branch '{settings.SubscribedBranch}'.", - null, // Persistent + AppUpdateConstants.UpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.ReleaseUpdateNotificationFormat, githubVersion), + autoDismissMilliseconds: null, actions: [ new NotificationAction( - "View Updates", - () => SettingsViewModel.OpenUpdateWindowCommand.Execute(null), + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(null, null, githubVersion), NotificationActionStyle.Primary, dismissOnExecute: true), - ]))); + ], + isPersistent: true, + showInBadge: true)); } } } @@ -301,6 +431,99 @@ await Dispatcher.UIThread.InvokeAsync(() => notificationService.Show(new Notific } } + private async Task PerformOneClickUpdateAsync( + ArtifactUpdateInfo? artifactUpdate, + UpdateInfo? updateInfo, + string? githubVersion) + { + var progressNotificationId = Guid.NewGuid(); + + // show the progress notification immediately + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.UpdatingAppNotificationTitle, + AppUpdateConstants.UpdateStartingMessage, + autoDismissMilliseconds: null, + isPersistent: false, + showInBadge: false) + { + Id = progressNotificationId, + }); + + var progress = new Progress(p => + { + string statusText; + if (!string.IsNullOrWhiteSpace(p.Message)) + { + statusText = p.Message; + } + else if (!string.IsNullOrWhiteSpace(p.Status)) + { + statusText = p.Status; + } + else + { + statusText = $"{p.PercentComplete}%"; + } + + notificationService.Update( + progressNotificationId, + statusText, + AppUpdateConstants.UpdatingAppNotificationTitle); + }); + + try + { + if (artifactUpdate != null) + { + logger?.LogInformation("Starting one-click artifact install: {Version}", artifactUpdate.DisplayVersion); + await velopackUpdateManager.InstallArtifactAsync(artifactUpdate, progress, _initializationCts.Token); + notificationService.Update( + progressNotificationId, + AppUpdateConstants.UpdateCompleteRestartingMessage, + AppUpdateConstants.UpdatingAppNotificationTitle); + } + else if (updateInfo != null) + { + logger?.LogInformation("Starting one-click release update: {Version}", updateInfo.TargetFullRelease.Version); + await velopackUpdateManager.DownloadUpdatesAsync(updateInfo, progress, _initializationCts.Token); + notificationService.Update( + progressNotificationId, + AppUpdateConstants.UpdateDownloadedRestartingMessage, + AppUpdateConstants.UpdatingAppNotificationTitle); + velopackUpdateManager.ApplyUpdatesAndRestart(updateInfo); + } + else if (!string.IsNullOrWhiteSpace(githubVersion)) + { + logger?.LogInformation("Opening update window for GitHub API update: {Version}", githubVersion); + notificationService.Dismiss(progressNotificationId); + OpenUpdateSettings(); + } + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to install update"); + notificationService.Dismiss(progressNotificationId); + notificationService.ShowError( + AppUpdateConstants.UpdateFailedNotificationTitle, + string.Format(AppUpdateConstants.UpdateFailedNotificationFormat, ex.Message), + autoDismissMs: NotificationConstants.DefaultAutoDismissMs); + } + } + + private void OpenUpdateSettings() + { + SelectTab(NavigationTab.Settings); + if (Dispatcher.UIThread.CheckAccess()) + { + SettingsViewModel.OpenUpdateWindowCommand.Execute(null); + } + else + { + Dispatcher.UIThread.Post(() => SettingsViewModel.OpenUpdateWindowCommand.Execute(null)); + } + } + private async Task CheckForUpdatesInBackgroundAsync(CancellationToken ct) { try @@ -317,6 +540,42 @@ private async Task CheckForUpdatesInBackgroundAsync(CancellationToken ct) } } + private void RestartPeriodicUpdateTimer(bool enabled, int intervalMinutes) + { + _periodicUpdateTimer?.Dispose(); + _periodicUpdateTimer = null; + + if (!enabled || intervalMinutes <= 0) + { + return; + } + + var clampedInterval = Math.Clamp( + intervalMinutes, + AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes, + AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + + var interval = TimeSpan.FromMinutes(clampedInterval); + logger?.LogDebug("Starting periodic update check timer with interval: {Interval}", interval); + + _periodicUpdateTimer = new Timer( + OnPeriodicUpdateTimerCallback, + null, + interval, + interval); + } + + private void OnPeriodicUpdateTimerCallback(object? state) + { + if (_initializationCts.IsCancellationRequested) + { + return; + } + + logger?.LogDebug("Periodic update check timer triggered"); + _ = CheckForUpdatesInBackgroundAsync(_initializationCts.Token); + } + private void CheckForQuickStart() { var settings = userSettingsService.Get(); diff --git a/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs b/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs index c67e2f4de..6ab5e9417 100644 --- a/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs +++ b/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs @@ -1,6 +1,9 @@ using System; using Avalonia; using Avalonia.Controls; +#if DEBUG +using Avalonia.Diagnostics; +#endif using Avalonia.Input; using Avalonia.Markup.Xaml; using GenHub.Common.ViewModels.Dialogs; diff --git a/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml b/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml index 7a1c10fc9..1362960c1 100644 --- a/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml +++ b/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml @@ -10,6 +10,7 @@ Width="500" SizeToContent="Height" WindowStartupLocation="CenterOwner" SystemDecorations="None" + CanResize="False" TransparencyLevelHint="AcrylicBlur" Background="Transparent" ExtendClientAreaToDecorationsHint="True"> diff --git a/GenHub/GenHub/Common/Views/MainView.axaml b/GenHub/GenHub/Common/Views/MainView.axaml index 71eb8f4d8..086b651aa 100644 --- a/GenHub/GenHub/Common/Views/MainView.axaml +++ b/GenHub/GenHub/Common/Views/MainView.axaml @@ -29,69 +29,92 @@ - - + + + - - - + + + + + + + + + + + + @@ -44,23 +90,46 @@ Foreground="White" /> - + - + + + + + + +public class FastHttpClientFileDownloader( + ILogger? logger = null, + HttpMessageHandler? httpMessageHandler = null) : HttpClientFileDownloader +{ + private static readonly SocketsHttpHandler SharedSocketsHandler = new() + { + MaxConnectionsPerServer = 32, + EnableMultipleHttp2Connections = true, + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60), + ConnectTimeout = TimeSpan.FromSeconds(30), + }; + + private sealed class MonotonicProgressReporter(Action? progressCallback, long totalBytes) + { + private readonly object _sync = new(); + private int _lastReportedPercent = -1; + private long _totalBytesDownloaded; + + public void ReportBytesRead(int bytesRead) + { + if (progressCallback is null || totalBytes <= 0) + { + return; + } + + var currentTotal = Interlocked.Add(ref _totalBytesDownloaded, bytesRead); + var currentPercent = (int)Math.Clamp((double)currentTotal / totalBytes * 100, 0, 99); + + if (currentPercent <= Volatile.Read(ref _lastReportedPercent)) + { + return; + } + + lock (_sync) + { + if (currentPercent > _lastReportedPercent) + { + _lastReportedPercent = currentPercent; + progressCallback(currentPercent); + } + } + } + + public void Complete() + { + if (progressCallback is null) + { + return; + } + + lock (_sync) + { + if (_lastReportedPercent < 100) + { + _lastReportedPercent = 100; + progressCallback(100); + } + } + } + } + + /// + public override async Task DownloadFile( + string url, + string targetFile, + Action progress, + IDictionary? headers, + double timeout, + CancellationToken cancelToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(url); + ArgumentException.ThrowIfNullOrWhiteSpace(targetFile); + + var destinationDirectory = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrEmpty(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + using var client = CreateHttpClient(headers, timeout); + + try + { + // Probe range support and resolve redirects without holding open full stream + using var probeRequest = new HttpRequestMessage(HttpMethod.Get, url); + probeRequest.Headers.Range = new RangeHeaderValue(0, 0); + + using var probeResponse = await client.SendAsync( + probeRequest, + HttpCompletionOption.ResponseHeadersRead, + cancelToken).ConfigureAwait(false); + + probeResponse.EnsureSuccessStatusCode(); + + var resolvedUri = probeResponse.RequestMessage?.RequestUri ?? new Uri(url); + var contentRange = probeResponse.Content.Headers.ContentRange; + + // Validate that probe returned 206 Partial Content with valid byte range (bytes 0-0/totalLength) + var hasValidProbeRange = probeResponse.StatusCode == HttpStatusCode.PartialContent && + contentRange is not null && + string.Equals(contentRange.Unit, "bytes", StringComparison.OrdinalIgnoreCase) && + contentRange.From == 0 && + contentRange.To == 0 && + contentRange.Length is { } probeTotalLength && + probeTotalLength >= AppUpdateConstants.ParallelDownloadThresholdBytes; + + if (hasValidProbeRange) + { + var totalLength = contentRange!.Length!.Value; + probeResponse.Dispose(); + + logger?.LogInformation( + "Downloading {Url} via parallel chunk mode ({Concurrency} connections, Size: {Size:N0} bytes)", + url, + AppUpdateConstants.ParallelDownloadConcurrency, + totalLength); + + // If redirected to a third-party CDN/storage host (e.g. Azure Blob/S3), strip Authorization header to avoid 400 Bad Request on presigned URLs + HttpClient chunkClient = client; + HttpClient? cdnClient = null; + var originUri = new Uri(url); + if (!string.Equals(resolvedUri.Host, originUri.Host, StringComparison.OrdinalIgnoreCase) && headers?.ContainsKey("Authorization") == true) + { + var cdnHeaders = headers.Where(h => !string.Equals(h.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(h => h.Key, h => h.Value); + cdnClient = CreateHttpClient(cdnHeaders, timeout); + chunkClient = cdnClient; + } + + try + { + await DownloadParallelAsync( + chunkClient, + resolvedUri, + targetFile, + totalLength, + progress, + cancelToken).ConfigureAwait(false); + } + finally + { + cdnClient?.Dispose(); + } + + return; + } + + // If probe returned 200 OK (server ignored Range header), stream the probe response directly + if (probeResponse.StatusCode == HttpStatusCode.OK) + { + var totalBytes = probeResponse.Content.Headers.ContentLength ?? -1L; + await DownloadSingleStreamAsync(probeResponse, targetFile, totalBytes, progress, cancelToken).ConfigureAwait(false); + return; + } + + // Fallback to single-stream GET (e.g. for files below parallel threshold) + using var fullResponse = await client.GetAsync( + url, + HttpCompletionOption.ResponseHeadersRead, + cancelToken).ConfigureAwait(false); + + fullResponse.EnsureSuccessStatusCode(); + var fullBytes = fullResponse.Content.Headers.ContentLength ?? -1L; + await DownloadSingleStreamAsync(fullResponse, targetFile, fullBytes, progress, cancelToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + ex, + "Parallel download encountered an issue for {Url}. Falling back to default downloader", + url); + + await base.DownloadFile(url, targetFile, progress, headers, timeout, cancelToken).ConfigureAwait(false); + } + } + + /// + protected override HttpClient CreateHttpClient(IDictionary? headers, double timeout) + { + var handler = httpMessageHandler ?? SharedSocketsHandler; + var client = new HttpClient(handler, disposeHandler: false); + if (timeout > 0) + { + client.Timeout = TimeSpan.FromSeconds(timeout); + } + + if (headers != null) + { + foreach (var header in headers) + { + client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return client; + } + + private static async Task DownloadSingleStreamAsync( + HttpResponseMessage response, + string targetFile, + long totalBytes, + Action? progress, + CancellationToken cancelToken) + { + var progressReporter = new MonotonicProgressReporter(progress, totalBytes); + + await using var contentStream = await response.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false); + await using var fileStream = new FileStream( + targetFile, + FileMode.Create, + FileAccess.Write, + FileShare.None, + AppUpdateConstants.DefaultStreamBufferSize, + useAsync: true); + + var buffer = new byte[AppUpdateConstants.DefaultStreamBufferSize]; + int bytesRead = 0; + + while ((bytesRead = await contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancelToken).ConfigureAwait(false)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancelToken).ConfigureAwait(false); + progressReporter.ReportBytesRead(bytesRead); + } + + progressReporter.Complete(); + } + + private static async Task DownloadParallelAsync( + HttpClient client, + Uri uri, + string targetFile, + long totalBytes, + Action? progress, + CancellationToken cancelToken) + { + // Pre-allocate the full file on disk and open safe handle for lock-free parallel writes + using var fileHandle = File.OpenHandle( + targetFile, + FileMode.Create, + FileAccess.Write, + FileShare.ReadWrite, + FileOptions.Asynchronous); + + RandomAccess.SetLength(fileHandle, totalBytes); + + var chunkSize = AppUpdateConstants.DownloadChunkSizeBytes; + var chunkCount = (int)Math.Ceiling((double)totalBytes / chunkSize); + var progressReporter = new MonotonicProgressReporter(progress, totalBytes); + + using var semaphore = new SemaphoreSlim(AppUpdateConstants.ParallelDownloadConcurrency); + + var tasks = Enumerable.Range(0, chunkCount).Select(async chunkIndex => + { + await semaphore.WaitAsync(cancelToken).ConfigureAwait(false); + try + { + var start = chunkIndex * chunkSize; + var end = Math.Min(start + chunkSize - 1, totalBytes - 1); + var expectedChunkBytes = end - start + 1; + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Range = new RangeHeaderValue(start, end); + + using var chunkResponse = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancelToken).ConfigureAwait(false); + + if (chunkResponse.StatusCode != HttpStatusCode.PartialContent) + { + throw new InvalidOperationException( + $"Origin server returned status code {chunkResponse.StatusCode} instead of 206 Partial Content for range {start}-{end}."); + } + + var chunkRange = chunkResponse.Content.Headers.ContentRange; + if (chunkRange is null || + !string.Equals(chunkRange.Unit, "bytes", StringComparison.OrdinalIgnoreCase) || + chunkRange.From != start || + chunkRange.To != end || + (chunkRange.Length.HasValue && chunkRange.Length.Value != totalBytes)) + { + throw new InvalidOperationException( + $"Origin server returned invalid Content-Range ({chunkRange}) for requested range {start}-{end} with total size {totalBytes}."); + } + + await using var chunkStream = await chunkResponse.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false); + + var buffer = new byte[AppUpdateConstants.DefaultStreamBufferSize]; + var chunkBytesRead = 0L; + int bytesRead = 0; + + while ((bytesRead = await chunkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancelToken).ConfigureAwait(false)) > 0) + { + await RandomAccess.WriteAsync( + fileHandle, + buffer.AsMemory(0, bytesRead), + start + chunkBytesRead, + cancelToken).ConfigureAwait(false); + + chunkBytesRead += bytesRead; + progressReporter.ReportBytesRead(bytesRead); + } + + if (chunkBytesRead != expectedChunkBytes) + { + throw new InvalidOperationException( + $"Chunk range {start}-{end} received {chunkBytesRead} bytes, expected {expectedChunkBytes}."); + } + } + finally + { + semaphore.Release(); + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + progressReporter.Complete(); + } +} diff --git a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index 9732215b2..5f1d9f05d 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs @@ -40,6 +40,7 @@ public partial class VelopackUpdateManager : IVelopackUpdateManager, IDisposable private readonly IHttpClientFactory _httpClientFactory; private readonly IGitHubTokenStorage? _gitHubTokenStorage; private readonly IUserSettingsService? _userSettingsService; + private readonly IFileDownloader _fileDownloader; private readonly UpdateManager? _updateManager; private readonly GithubSource _githubSource; @@ -52,11 +53,16 @@ public partial class VelopackUpdateManager : IVelopackUpdateManager, IDisposable private UpdateInfo? _cachedUpdateInfo; private DateTime _lastArtifactCheckTime = DateTime.MinValue; private ArtifactUpdateInfo? _cachedArtifactUpdateInfo; + private int? _cachedArtifactSubscribedPrNumber; + private string? _cachedArtifactSubscribedBranch; private DateTime _lastPrListCheckTime = DateTime.MinValue; private IReadOnlyList? _cachedPrList; private DateTime _lastBranchListCheckTime = DateTime.MinValue; private IReadOnlyList? _cachedBranchList; + private int? _subscribedPrNumber; + private string? _subscribedBranch; + /// public bool HasArtifactUpdateAvailable => _latestArtifactUpdate != null; @@ -64,10 +70,34 @@ public partial class VelopackUpdateManager : IVelopackUpdateManager, IDisposable public ArtifactUpdateInfo? LatestArtifactUpdate => _latestArtifactUpdate; /// - public int? SubscribedPrNumber { get; set; } + public int? SubscribedPrNumber + { + get => _subscribedPrNumber; + set + { + if (_subscribedPrNumber != value) + { + _subscribedPrNumber = value; + _cachedArtifactUpdateInfo = null; + _lastArtifactCheckTime = DateTime.MinValue; + } + } + } /// - public string? SubscribedBranch { get; set; } + public string? SubscribedBranch + { + get => _subscribedBranch; + set + { + if (!string.Equals(_subscribedBranch, value, StringComparison.OrdinalIgnoreCase)) + { + _subscribedBranch = value; + _cachedArtifactUpdateInfo = null; + _lastArtifactCheckTime = DateTime.MinValue; + } + } + } /// public bool IsPrMergedOrClosed { get; private set; } @@ -79,19 +109,22 @@ public partial class VelopackUpdateManager : IVelopackUpdateManager, IDisposable /// The HTTP client factory for creating HttpClient instances. /// The GitHub token storage (optional). /// The user settings service (optional). + /// The high-performance file downloader (optional). public VelopackUpdateManager( ILogger logger, IHttpClientFactory httpClientFactory, IGitHubTokenStorage? gitHubTokenStorage = null, - IUserSettingsService? userSettingsService = null) + IUserSettingsService? userSettingsService = null, + IFileDownloader? fileDownloader = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _gitHubTokenStorage = gitHubTokenStorage; _userSettingsService = userSettingsService; + _fileDownloader = fileDownloader ?? new FastHttpClientFileDownloader(); - // Always initialize GithubSource for update checking - _githubSource = new GithubSource(AppConstants.GitHubRepositoryUrl, string.Empty, true); + // Always initialize GithubSource for update checking with high-performance downloader + _githubSource = new GithubSource(AppConstants.GitHubRepositoryUrl, string.Empty, true, _fileDownloader); try { @@ -370,8 +403,13 @@ public string? LatestVersionFromGitHub /// public async Task CheckForArtifactUpdatesAsync(CancellationToken cancellationToken = default) { - // Check cache - if (DateTime.UtcNow - _lastArtifactCheckTime < AppUpdateConstants.CacheDuration) + var targetPrNumber = SubscribedPrNumber; + var targetBranch = SubscribedBranch; + + // check cache + if (DateTime.UtcNow - _lastArtifactCheckTime < AppUpdateConstants.CacheDuration && + _cachedArtifactSubscribedPrNumber == targetPrNumber && + string.Equals(_cachedArtifactSubscribedBranch, targetBranch, StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Returning cached artifact update info (checked {TimeLess} ago)", (DateTime.UtcNow - _lastArtifactCheckTime).ToString(@"mm\:ss")); return _cachedArtifactUpdateInfo; @@ -387,34 +425,44 @@ public string? LatestVersionFromGitHub try { - // Reset latest artifact if switching modes/channels - _latestArtifactUpdate = null; + ArtifactUpdateInfo? artifactUpdate = null; - // Priority: - // 1. Subscribed PR - // 2. Subscribed Branch - // 3. Overall latest - if (SubscribedPrNumber.HasValue) + // priority: + // 1. subscribed pr + // 2. subscribed branch + // 3. overall latest + if (targetPrNumber.HasValue) { - _logger.LogInformation("Checking for artifacts for subscribed PR #{PrNumber}", SubscribedPrNumber.Value); + _logger.LogInformation("Checking for artifacts for subscribed PR #{PrNumber}", targetPrNumber.Value); var prs = await GetOpenPullRequestsAsync(cancellationToken); - var subscribedPr = prs.FirstOrDefault(p => p.Number == SubscribedPrNumber.Value); - _latestArtifactUpdate = subscribedPr?.LatestArtifact; + var subscribedPr = prs.FirstOrDefault(p => p.Number == targetPrNumber.Value); + artifactUpdate = subscribedPr?.LatestArtifact; } - else if (!string.IsNullOrEmpty(SubscribedBranch)) + else if (!string.IsNullOrEmpty(targetBranch)) { - _logger.LogInformation("Checking for artifacts for subscribed branch: {Branch}", SubscribedBranch); - _latestArtifactUpdate = await FindLatestArtifactAsync(SubscribedBranch, cancellationToken); + _logger.LogInformation("Checking for artifacts for subscribed branch: {Branch}", targetBranch); + artifactUpdate = await FindLatestArtifactAsync(targetBranch, cancellationToken); } else { _logger.LogInformation("Checking for overall latest artifact"); - _latestArtifactUpdate = await FindLatestArtifactAsync(null, cancellationToken); + artifactUpdate = await FindLatestArtifactAsync(null, cancellationToken); } - _cachedArtifactUpdateInfo = _latestArtifactUpdate; + // verify subscription did not change while awaiting + if (SubscribedPrNumber != targetPrNumber || + !string.Equals(SubscribedBranch, targetBranch, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Subscription changed during artifact check, discarding result"); + return null; + } + + _latestArtifactUpdate = artifactUpdate; + _cachedArtifactUpdateInfo = artifactUpdate; + _cachedArtifactSubscribedPrNumber = targetPrNumber; + _cachedArtifactSubscribedBranch = targetBranch; _lastArtifactCheckTime = DateTime.UtcNow; - return _latestArtifactUpdate; + return artifactUpdate; } catch (Exception ex) { @@ -519,7 +567,10 @@ public async Task> GetOpenPullRequestsAsync(Cance } var prInfos = await Task.WhenAll(prTasks); - results.AddRange(prInfos); + var sortedPrs = prInfos + .OrderByDescending(p => p.UpdatedAt ?? DateTimeOffset.MinValue) + .ToList(); + results.AddRange(sortedPrs); // Check if subscribed PR is still open subscribedPrFound = results.Any(p => p.Number == SubscribedPrNumber); @@ -659,7 +710,6 @@ public async Task InstallArtifactAsync( throw new InvalidOperationException("Failed to load GitHub PAT"); } - using var client = CreateConfiguredHttpClientWithToken(token); var owner = AppConstants.GitHubRepositoryOwner; var repo = AppConstants.GitHubRepositoryName; var artifactId = artifactInfo.ArtifactId; @@ -674,33 +724,36 @@ public async Task InstallArtifactAsync( var zipPath = Path.Combine(tempDir, "artifact.zip"); - // Download artifact - var downloadProgress = new Progress(p => + var headers = new Dictionary { - // Scale 0-100% download to 0-30% total progress - var totalPercent = (int)(p.PercentComplete * 0.3); + { "User-Agent", AppConstants.AppName }, + { "Accept", ApiConstants.GitHubApiHeaderAccept }, + }; - // Format decimal size if possible - string sizeInfo = string.Empty; - if (p.TotalBytes > 0) - { - double currentMb = p.BytesDownloaded / 1024.0 / 1024.0; - double totalMb = p.TotalBytes / 1024.0 / 1024.0; - double speedMb = p.BytesPerSecond / 1024.0 / 1024.0; - sizeInfo = $" ({currentMb:F1}/{totalMb:F1} MB, {speedMb:F1} MB/s)"; - } + UseSecureStringAsPlainText(token, plainText => + { + headers["Authorization"] = $"Bearer {plainText}"; + }); + + var downloadProgress = new Action(percent => + { + // Scale 0-100% download to 0-30% total progress + var totalPercent = (int)(percent * 0.3); progress?.Report(new UpdateProgress { - Status = $"Downloading artifact for {label}{commitInfo}... {p.PercentComplete}%{sizeInfo}", + Status = $"Downloading artifact for {label}{commitInfo}... {percent}%", PercentComplete = totalPercent, - BytesDownloaded = p.BytesDownloaded, - TotalBytes = p.TotalBytes, - BytesPerSecond = p.BytesPerSecond, }); }); - await DownloadFileWithProgressAsync(client, downloadUrl, zipPath, downloadProgress, cancellationToken); + await _fileDownloader.DownloadFile( + downloadUrl, + zipPath, + downloadProgress, + headers, + timeout: 300, + cancelToken: cancellationToken); progress?.Report(new UpdateProgress { Status = "Extracting artifact...", PercentComplete = 30 }); @@ -762,7 +815,7 @@ public async Task InstallArtifactAsync( progress?.Report(new UpdateProgress { Status = "Downloading update...", PercentComplete = 70 }); // Point Velopack to localhost - var source = new SimpleWebSource($"http://localhost:{port}/{server.SecretToken}/"); + var source = new SimpleWebSource($"http://localhost:{port}/{server.SecretToken}/", _fileDownloader); var localUpdateManager = new UpdateManager(source); try @@ -859,6 +912,8 @@ public void ClearCache() _cachedUpdateInfo = null; _lastArtifactCheckTime = DateTime.MinValue; _cachedArtifactUpdateInfo = null; + _cachedArtifactSubscribedPrNumber = null; + _cachedArtifactSubscribedBranch = null; _lastPrListCheckTime = DateTime.MinValue; _cachedPrList = null; _lastBranchListCheckTime = DateTime.MinValue; @@ -1050,80 +1105,6 @@ private static int FindAvailablePort() return port; } - /// - /// Downloads a file with progress reporting. - /// - private static async Task DownloadFileWithProgressAsync( - HttpClient client, - string requestUrl, - string destinationPath, - IProgress? progress, - CancellationToken cancellationToken) - { - using var response = await client.GetAsync(requestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); - - var totalBytes = response.Content.Headers.ContentLength ?? -1L; - - // Create temp directory if it doesn't exist - var directory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); - - var totalRead = 0L; - var buffer = new byte[8192]; - var isMoreToRead = true; - - var stopwatch = Stopwatch.StartNew(); - var lastReportTime = stopwatch.ElapsedMilliseconds; - - while (isMoreToRead) - { - var read = await contentStream.ReadAsync(buffer, cancellationToken); - if (read == 0) - { - isMoreToRead = false; - } - else - { - await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - - totalRead += read; - - var currentTime = stopwatch.ElapsedMilliseconds; - - // Report every 500ms - if (currentTime - lastReportTime >= 500 || !isMoreToRead) - { - if (progress != null) - { - var elapsedSeconds = stopwatch.Elapsed.TotalSeconds; - var bytesPerSecond = elapsedSeconds > 0 ? (long)(totalRead / elapsedSeconds) : 0L; - var percent = totalBytes > 0 ? (int)((double)totalRead / totalBytes * 100) : 0; - - progress.Report(new UpdateProgress - { - PercentComplete = percent, - BytesDownloaded = totalRead, - TotalBytes = totalBytes, - BytesPerSecond = bytesPerSecond, - Status = "Downloading...", - }); - } - - lastReportTime = currentTime; - } - } - } - - stopwatch.Stop(); - } - /// /// Gets or creates an HttpClient instance with proper configuration. /// @@ -1529,7 +1510,7 @@ private HttpClient CreateConfiguredHttpClientWithToken(SecureString token) } } - _logger.LogWarning("No suitable artifacts found in the last 10 'push' runs for branch {Branch}", branch ?? "any"); + _logger.LogWarning("No suitable artifacts found in workflow runs for branch {Branch}", branch ?? "any"); return null; } catch (Exception ex) @@ -1555,15 +1536,17 @@ private HttpClient CreateConfiguredHttpClientWithToken(SecureString token) var shortHash = headSha.Length >= AppConstants.GitShortHashLength ? headSha[..AppConstants.GitShortHashLength] : headSha; var actualBranch = run.TryGetProperty("head_branch", out var b) ? b.GetString() : branch ?? "unknown"; - if (!string.Equals(eventType, "push", StringComparison.OrdinalIgnoreCase)) + _logger.LogDebug("Checking run {RunId} ({EventType}) on branch {ActualBranch}", runId, eventType, actualBranch); + + if (!string.IsNullOrEmpty(branch) && !string.Equals(actualBranch, branch, StringComparison.Ordinal)) { - _logger.LogDebug("Skipping run {RunId} ({EventType}) - only 'push' events are valid for branch subscriptions", runId, eventType); + _logger.LogDebug("Skipping run {RunId} ({ActualBranch}) - does not match requested branch {Branch}", runId, actualBranch, branch); return null; } - if (!string.IsNullOrEmpty(branch) && !string.Equals(actualBranch, branch, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(branch) && !string.Equals(eventType, "push", StringComparison.OrdinalIgnoreCase) && !string.Equals(eventType, "workflow_dispatch", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("Skipping run {RunId} ({ActualBranch}) - does not match requested branch {Branch}", runId, actualBranch, branch); + _logger.LogDebug("Skipping run {RunId} ({EventType}) - not a push or workflow_dispatch event for branch {Branch}", runId, eventType, branch); return null; } @@ -1607,9 +1590,50 @@ private HttpClient CreateConfiguredHttpClientWithToken(SecureString token) return null; } + private bool IsMatchingWorkflowRun(JsonElement run, string? branchName, int? prNumber) + { + var actualBranch = run.TryGetProperty("head_branch", out var b) ? b.GetString() : branchName ?? "unknown"; + var eventType = run.TryGetProperty("event", out var e) ? e.GetString() : "unknown"; + + if (prNumber.HasValue) + { + if (run.TryGetProperty("pull_requests", out var prs) && prs.ValueKind == JsonValueKind.Array) + { + var prCount = 0; + foreach (var pr in prs.EnumerateArray()) + { + prCount++; + if (pr.TryGetProperty("number", out var num) && num.GetInt32() == prNumber.Value) + { + return true; + } + } + + if (prCount > 0) + { + return false; + } + } + + return string.IsNullOrEmpty(branchName) || string.Equals(actualBranch, branchName, StringComparison.Ordinal); + } + + if (!string.IsNullOrEmpty(branchName)) + { + if (!string.Equals(actualBranch, branchName, StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(eventType, "push", StringComparison.OrdinalIgnoreCase) || + string.Equals(eventType, "workflow_dispatch", StringComparison.OrdinalIgnoreCase); + } + + return true; + } + private async Task> FindArtifactsAsync(HttpClient client, string? branchName, int? prNumber, CancellationToken cancellationToken) { - var results = new List(); var owner = AppConstants.GitHubRepositoryOwner; var repo = AppConstants.GitHubRepositoryName; @@ -1618,13 +1642,18 @@ private async Task> FindArtifactsAsync(HttpCli : string.Format(ApiConstants.GitHubApiWorkflowRunsAllFormat, owner, repo); var runsResponse = await SendWithRetryAsync(client, runsUrl, cancellationToken); - if (runsResponse == null || !runsResponse.IsSuccessStatusCode) return []; + if (runsResponse == null || !runsResponse.IsSuccessStatusCode) + { + return []; + } var runsJson = await runsResponse.Content.ReadAsStringAsync(cancellationToken); using var runsDoc = JsonDocument.Parse(runsJson); - var workflowRuns = runsDoc.RootElement.GetProperty("workflow_runs"); + if (!runsDoc.RootElement.TryGetProperty("workflow_runs", out var workflowRuns)) + { + return []; + } - var addedVersions = new HashSet(); var platformFilter = GetCurrentPlatformFilter(); if (platformFilter == null) { @@ -1632,64 +1661,116 @@ private async Task> FindArtifactsAsync(HttpCli return []; } + var results = new List(); + var addedVersions = new HashSet(); + foreach (var run in workflowRuns.EnumerateArray()) { - var runId = run.GetProperty("id").GetInt64(); - var runNum = run.GetProperty("run_number").GetInt32(); - var createdAt = run.GetProperty("created_at").GetDateTimeOffset(); - var headSha = run.GetProperty("head_sha").GetString() ?? string.Empty; - var shortHash = headSha.Length >= 7 ? headSha[..7] : headSha; + if (!IsMatchingWorkflowRun(run, branchName, prNumber)) + { + continue; + } - var artifactsUrl = run.GetProperty("artifacts_url").GetString(); - if (string.IsNullOrEmpty(artifactsUrl)) continue; + await ExtractArtifactsFromWorkflowRunAsync(client, run, prNumber, platformFilter, addedVersions, results, cancellationToken); + } - var artifactsResponse = await SendWithRetryAsync(client, artifactsUrl, cancellationToken); - if (artifactsResponse == null || !artifactsResponse.IsSuccessStatusCode) continue; + return [.. results.OrderByDescending(r => r.CreatedAt)]; + } - var artifactsJson = await artifactsResponse.Content.ReadAsStringAsync(cancellationToken); - using var artifactsDoc = JsonDocument.Parse(artifactsJson); - var artifacts = artifactsDoc.RootElement.GetProperty("artifacts"); + private async Task ExtractArtifactsFromWorkflowRunAsync( + HttpClient client, + JsonElement run, + int? prNumber, + string platformFilter, + HashSet addedVersions, + List results, + CancellationToken cancellationToken) + { + var artifactsUrl = run.TryGetProperty("artifacts_url", out var u) ? u.GetString() : null; + if (string.IsNullOrEmpty(artifactsUrl)) + { + return; + } - foreach (var artifact in artifacts.EnumerateArray()) - { - var name = artifact.GetProperty("name").GetString(); - if (string.IsNullOrEmpty(name) || !name.Contains("velopack", StringComparison.OrdinalIgnoreCase)) continue; + var artifactsResponse = await SendWithRetryAsync(client, artifactsUrl, cancellationToken); + if (artifactsResponse == null || !artifactsResponse.IsSuccessStatusCode) + { + return; + } - if (!name.Contains(platformFilter, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Skipping artifact {Name} - doesn't match platform {Platform}", name, platformFilter); - continue; - } + var artifactsJson = await artifactsResponse.Content.ReadAsStringAsync(cancellationToken); + using var artifactsDoc = JsonDocument.Parse(artifactsJson); + if (!artifactsDoc.RootElement.TryGetProperty("artifacts", out var artifacts)) + { + return; + } - var version = ExtractVersionFromArtifactName(name) ?? $"0.0.0-ci.{runNum}"; - var uniqueKey = $"{version}|{shortHash}"; - if (!addedVersions.Add(uniqueKey)) - { - _logger.LogDebug("Skipping duplicate artifact: {Version} ({Hash})", version, shortHash); - continue; - } + if (!run.TryGetProperty("id", out var idProp) || !idProp.TryGetInt64(out var runId) || + !run.TryGetProperty("run_number", out var runNumProp) || !runNumProp.TryGetInt32(out var runNum) || + !run.TryGetProperty("created_at", out var createdAtProp) || !createdAtProp.TryGetDateTimeOffset(out var createdAt)) + { + return; + } - var id = artifact.GetProperty("id").GetInt64(); - var size = artifact.GetProperty("size_in_bytes").GetInt64(); - var downloadUrl = artifact.GetProperty("archive_download_url").GetString(); - var workflowRunUrl = run.GetProperty("html_url").GetString() ?? string.Empty; - - var info = new ArtifactUpdateInfo( - Version: version, - GitHash: shortHash, - PullRequestNumber: prNumber, - WorkflowRunId: runId, - WorkflowRunUrl: workflowRunUrl, - ArtifactId: id, - ArtifactName: name ?? "Unknown", - CreatedAt: createdAt.UtcDateTime, - DownloadUrl: downloadUrl, - Size: size); + var headSha = run.TryGetProperty("head_sha", out var sha) ? sha.GetString() ?? string.Empty : string.Empty; + var shortHash = headSha.Length >= AppConstants.GitShortHashLength ? headSha[..AppConstants.GitShortHashLength] : headSha; + var workflowRunUrl = run.TryGetProperty("html_url", out var html) ? html.GetString() ?? string.Empty : string.Empty; + foreach (var artifact in artifacts.EnumerateArray()) + { + var info = TryParseArtifactUpdateInfo(artifact, runId, runNum, createdAt.UtcDateTime, shortHash, workflowRunUrl, prNumber, platformFilter, addedVersions); + if (info != null) + { results.Add(info); } } + } - return [.. results.OrderByDescending(r => r.CreatedAt)]; + private ArtifactUpdateInfo? TryParseArtifactUpdateInfo( + JsonElement artifact, + long runId, + int runNum, + DateTime createdAtUtc, + string shortHash, + string workflowRunUrl, + int? prNumber, + string platformFilter, + HashSet addedVersions) + { + var name = artifact.TryGetProperty("name", out var n) ? n.GetString() : null; + if (string.IsNullOrEmpty(name) || !name.Contains("velopack", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!name.Contains(platformFilter, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Skipping artifact {Name} - doesn't match platform {Platform}", name, platformFilter); + return null; + } + + var version = ExtractVersionFromArtifactName(name) ?? $"0.0.0-ci.{runNum}"; + var uniqueKey = $"{version}|{shortHash}"; + if (!addedVersions.Add(uniqueKey)) + { + _logger.LogDebug("Skipping duplicate artifact: {Version} ({Hash})", version, shortHash); + return null; + } + + var id = artifact.GetProperty("id").GetInt64(); + var size = artifact.GetProperty("size_in_bytes").GetInt64(); + var downloadUrl = artifact.TryGetProperty("archive_download_url", out var dl) ? dl.GetString() : null; + + return new ArtifactUpdateInfo( + Version: version, + GitHash: shortHash, + PullRequestNumber: prNumber, + WorkflowRunId: runId, + WorkflowRunUrl: workflowRunUrl, + ArtifactId: id, + ArtifactName: name, + CreatedAt: createdAtUtc, + DownloadUrl: downloadUrl, + Size: size); } } diff --git a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs index ed34fe005..a50bd3cae 100644 --- a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs +++ b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs @@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Models.AppUpdate; @@ -25,34 +26,58 @@ namespace GenHub.Features.AppUpdate.ViewModels; ///
public partial class UpdateNotificationViewModel : ObservableObject, IDisposable { - private readonly IVelopackUpdateManager _velopackUpdateManager; - private readonly ILogger _logger; - private readonly IUserSettingsService _userSettingsService; - private readonly CancellationTokenSource _cancellationTokenSource; - private UpdateInfo? _currentUpdateInfo; + private static readonly Lazy CachedCurrentAppVersion = new(() => + { + try + { + // get actual installed version from velopack + var updateManager = new UpdateManager(new SimpleWebSource(string.Empty)); + var currentVersion = updateManager.CurrentVersion; + return currentVersion?.ToString() ?? AppConstants.AppVersion; + } + catch + { + // fallback to compile-time version if velopack fails + return AppConstants.AppVersion; + } + }); /// /// Gets the current application version. /// - public static string CurrentAppVersion + public static string CurrentAppVersion => CachedCurrentAppVersion.Value; + + /// + /// Gets the formatted display string of the currently installed application version. + /// + public static string DisplayCurrentVersion { get { - try - { - // Get actual installed version from Velopack - var updateManager = new UpdateManager(new SimpleWebSource(string.Empty)); - var currentVersion = updateManager.CurrentVersion; - return currentVersion?.ToString() ?? AppConstants.AppVersion; - } - catch + var version = CurrentAppVersion; + if (string.IsNullOrWhiteSpace(version)) { - // Fallback to compile-time version if Velopack fails - return AppConstants.AppVersion; + return "0.0.0"; } + + var cleanVersion = version.Split('+')[0].TrimStart('v', 'V'); + return $"v{cleanVersion}"; } } + /// + /// Gets the formatted display string of the currently installed application version for instance data binding. + /// + public string InstalledVersionDisplay => DisplayCurrentVersion; + + private readonly IVelopackUpdateManager _velopackUpdateManager; + private readonly ILogger _logger; + private readonly IUserSettingsService _userSettingsService; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly List _allPullRequests = []; + private CancellationTokenSource? _loadArtifactsCts; + private UpdateInfo? _currentUpdateInfo; + /// /// Gets or sets the status message. /// @@ -126,6 +151,39 @@ public static string CurrentAppVersion [ObservableProperty] private ObservableCollection _availablePullRequests = []; + /// + /// Gets or sets the selected tab index (0 = Update, 1 = Browse Builds). + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBrowseTabSelected))] + private int _selectedTabIndex; + + /// + /// Gets a value indicating whether the browse builds tab is selected. + /// + public bool IsBrowseTabSelected => SelectedTabIndex == AppUpdateConstants.BrowseBuildsTabIndex; + + /// + /// Gets the list of available sort options for pull requests. + /// + public IReadOnlyList AvailableSortOptions { get; } = + [ + AppUpdateConstants.SortOptionLastUpdated, + AppUpdateConstants.SortOptionPrNumberDesc, + AppUpdateConstants.SortOptionPrNumberAsc, + ]; + + /// + /// Gets or sets the selected sort option for pull requests. + /// + [ObservableProperty] + private string _selectedSortOption = AppUpdateConstants.SortOptionLastUpdated; + + partial void OnSelectedSortOptionChanged(string value) + { + ApplyPullRequestSorting(); + } + /// /// Gets or sets the currently subscribed PR. /// @@ -239,14 +297,14 @@ private async Task ForceRefresh() { await CheckForUpdatesAsync(); - // Also refresh PRs/Branches if in browse mode + // also refresh prs and branches if in browse mode if (HasPat) { await LoadPullRequestsAsync(); await LoadBranchesAsync(); } - // Refresh artifacts for current subscription + // refresh artifacts for current subscription if (IsSubscribedToAny) { await LoadArtifactsForSubscribedItemAsync(); @@ -275,22 +333,40 @@ public UpdateNotificationViewModel( ManualRefreshCommand = new AsyncRelayCommand(ManualRefreshAsync, () => !IsChecking); DismissCommand = new RelayCommand(DismissUpdate); - // Check if PAT is available + // check if pat is available HasPat = gitHubTokenStorage?.HasToken() == true; _logger.LogInformation("UpdateNotificationViewModel initialized with Velopack (HasPat={HasPat})", HasPat); - // Monitor collection changes to update placeholder text + // monitor collection changes to update placeholder text AvailableVersions.CollectionChanged += (s, e) => OnPropertyChanged(nameof(VersionPlaceholderText)); - // Automatically check for updates and load PRs when dialog opens + // automatically check for updates and load prs when dialog opens _ = InitializeAsync(); } private async Task LoadArtifactsForSubscribedItemAsync() { - // Cancel any previous loading if possible, or just guard - if (IsLoadingVersions) return; // Simple guard, could be improved with cancellation token + // cancel any previous in-flight load + _loadArtifactsCts?.Cancel(); + _loadArtifactsCts?.Dispose(); + _loadArtifactsCts = null; + + var targetPr = SubscribedPr; + var targetPrNumber = targetPr?.Number ?? _velopackUpdateManager.SubscribedPrNumber; + var targetBranch = SubscribedBranch; + + if (targetPrNumber == null && string.IsNullOrEmpty(targetBranch)) + { + IsLoadingVersions = false; + AvailableVersions.Clear(); + SelectedVersion = null; + return; + } + + var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token); + _loadArtifactsCts = cts; + var token = cts.Token; IsLoadingVersions = true; AvailableVersions.Clear(); @@ -300,18 +376,25 @@ private async Task LoadArtifactsForSubscribedItemAsync() { IReadOnlyList artifacts = []; - if (SubscribedPr != null) + if (targetPrNumber.HasValue) { - artifacts = await _velopackUpdateManager.GetArtifactsForPullRequestAsync(SubscribedPr.Number, _cancellationTokenSource.Token); + _logger.LogInformation("Loading artifacts for PR #{PrNumber}", targetPrNumber.Value); + artifacts = await _velopackUpdateManager.GetArtifactsForPullRequestAsync(targetPrNumber.Value, token); } - else if (!string.IsNullOrEmpty(SubscribedBranch)) + else if (!string.IsNullOrEmpty(targetBranch)) { - artifacts = await _velopackUpdateManager.GetArtifactsForBranchAsync(SubscribedBranch, _cancellationTokenSource.Token); + _logger.LogInformation("Loading artifacts for branch '{Branch}'", targetBranch); + artifacts = await _velopackUpdateManager.GetArtifactsForBranchAsync(targetBranch, token); + } + + if (token.IsCancellationRequested) + { + return; } _logger.LogInformation("Received {Count} platform-compatible artifacts from update manager", artifacts.Count); - // Use HashSet to prevent duplicates based on artifact ID + // use hashset to prevent duplicates based on artifact id var addedArtifactIds = new HashSet(); foreach (var artifact in artifacts) { @@ -328,15 +411,29 @@ private async Task LoadArtifactsForSubscribedItemAsync() _logger.LogInformation("Loaded {Count} artifacts into AvailableVersions", AvailableVersions.Count); - // Don't auto-select to avoid duplicate display in ComboBox + // auto-select latest version for improved user experience + if (AvailableVersions.Count > 0) + { + SelectedVersion = AvailableVersions[0]; + } + } + catch (OperationCanceledException) + { + _logger.LogDebug("Artifact loading cancelled for subscription change"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to load available versions"); + if (!token.IsCancellationRequested) + { + _logger.LogError(ex, "Failed to load available versions"); + } } finally { - IsLoadingVersions = false; + if (ReferenceEquals(_loadArtifactsCts, cts)) + { + IsLoadingVersions = false; + } } } @@ -345,12 +442,21 @@ private async Task LoadArtifactsForSubscribedItemAsync() ///
private async Task InitializeAsync() { - // Load subscribed PR and Branch from settings + // load subscribed pr and branch from settings var settings = _userSettingsService.Get(); if (settings.SubscribedPrNumber.HasValue) { - _velopackUpdateManager.SubscribedPrNumber = settings.SubscribedPrNumber; - _logger.LogInformation("Loaded subscribed PR #{PrNumber} from settings", settings.SubscribedPrNumber); + var prNumber = settings.SubscribedPrNumber.Value; + _velopackUpdateManager.SubscribedPrNumber = prNumber; + SubscribedPr = new PullRequestInfo + { + Number = prNumber, + Title = $"PR #{prNumber}", + BranchName = "unknown", + Author = "unknown", + State = "open", + }; + _logger.LogInformation("Loaded subscribed PR #{PrNumber} from settings", prNumber); } if (!string.IsNullOrEmpty(settings.SubscribedBranch)) @@ -359,16 +465,16 @@ private async Task InitializeAsync() _logger.LogInformation("Loaded subscribed branch '{Branch}' from settings", settings.SubscribedBranch); } - // Load data if we have a PAT + // load data if we have a pat if (HasPat) { - // Initial check/load + // initial check and load await Task.WhenAll( LoadPullRequestsAsync(), LoadBranchesAsync()); } - // Now check for updates - subscriptions will be properly populated + // check for updates after subscriptions are populated await CheckForUpdatesAsync(); } @@ -419,14 +525,14 @@ public string DisplayLatestVersion return GameClientConstants.UnknownVersion; } - // 1. PR Update takes precedence + // 1. pr update takes precedence if (SubscribedPr?.LatestArtifact != null && string.Equals(SubscribedPr.LatestArtifact.Version, LatestVersion, StringComparison.OrdinalIgnoreCase)) { return SubscribedPr.LatestArtifact.DisplayVersion; } - // 2. Branch Update + // 2. branch update if (!string.IsNullOrEmpty(SubscribedBranch)) { return LatestVersion.StartsWith(SubscribedBranch, StringComparison.OrdinalIgnoreCase) @@ -445,34 +551,135 @@ public string DisplayLatestVersion ///
public void Dispose() { + _loadArtifactsCts?.Cancel(); + _loadArtifactsCts?.Dispose(); + _loadArtifactsCts = null; + _cancellationTokenSource.Cancel(); _cancellationTokenSource.Dispose(); GC.SuppressFinalize(this); } - /// - /// Extracts the workflow run number from a version string like "0.0.641-pr241". - /// - private static int ExtractRunNumber(string version) + private void ProcessPrArtifactUpdate(ArtifactUpdateInfo artifact, int prNumber) { - // Try to extract the run number before the PR suffix - var match = System.Text.RegularExpressions.Regex.Match(version, @"(\d+)(?:-pr\d+|-\w+)?$"); - if (match.Success && int.TryParse(match.Groups[1].Value, out var runNumber)) + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var prVersionBase = artifact.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(prVersionBase, currentVersionBase)) + { + var settings = _userSettingsService.Get(); + if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = prVersionBase; + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{prNumber}"; + StatusMessage = $"New PR build available: {artifact.DisplayVersion}"; + _logger.LogInformation("Subscribed to PR #{PrNumber}, new build available: {Version}", prNumber, artifact.DisplayVersion); + return; + } + + StatusMessage = $"You dismissed the update for PR #{prNumber}"; + return; + } + + IsUpdateAvailable = false; + StatusMessage = $"You are on the latest build for PR #{prNumber}"; + } + + private void ProcessBranchArtifactUpdate(ArtifactUpdateInfo artifact, string branch) + { + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var branchVersionBase = artifact.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(branchVersionBase, currentVersionBase)) + { + var settings = _userSettingsService.Get(); + if (!string.Equals(branchVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = branchVersionBase; + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{branch}"; + StatusMessage = $"New {branch} build available: {artifact.DisplayVersion}"; + _logger.LogInformation("Branch '{Branch}' has new build: {Version}", branch, LatestVersion); + return; + } + + StatusMessage = $"You dismissed the update for branch '{branch}'"; + return; + } + + IsUpdateAvailable = false; + StatusMessage = $"You are on the latest build for {branch}"; + } + + partial void OnSelectedVersionChanged(ArtifactUpdateInfo? value) + { + UpdateCommandStates(); + + if (value == null) { - return runNumber; + return; } - // Fallback: try to parse the entire version as a number - var parts = version.Split('.', '-', '+'); - foreach (var part in parts.Reverse()) + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var selectedVersionBase = value.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(selectedVersionBase, currentVersionBase)) { - if (int.TryParse(part, out var number)) + var settings = _userSettingsService.Get(); + if (!string.Equals(selectedVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) { - return number; + IsUpdateAvailable = true; + LatestVersion = selectedVersionBase; + if (value.PullRequestNumber.HasValue) + { + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{value.PullRequestNumber.Value}"; + StatusMessage = $"New PR build available: {value.DisplayVersion}"; + } + else if (!string.IsNullOrEmpty(SubscribedBranch)) + { + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{SubscribedBranch}"; + StatusMessage = $"New {SubscribedBranch} build available: {value.DisplayVersion}"; + } + else + { + StatusMessage = $"New build available: {value.DisplayVersion}"; + } + + return; } + + IsUpdateAvailable = false; + LatestVersion = string.Empty; + ReleaseNotesUrl = string.Empty; + StatusMessage = $"You dismissed update {value.DisplayVersion}"; + return; } - return 0; + var currentRun = AppUpdateVersionHelper.ExtractRunNumber(currentVersionBase); + var selectedRun = AppUpdateVersionHelper.ExtractRunNumber(selectedVersionBase); + + if (currentRun > 0 && selectedRun > 0 && currentRun == selectedRun) + { + IsUpdateAvailable = false; + if (value.PullRequestNumber.HasValue) + { + StatusMessage = $"You are on the latest build for PR #{value.PullRequestNumber.Value}"; + } + else if (!string.IsNullOrEmpty(SubscribedBranch)) + { + StatusMessage = $"You are on the latest build for {SubscribedBranch}"; + } + else + { + StatusMessage = $"You are on the latest build ({value.DisplayVersion})"; + } + } + else + { + IsUpdateAvailable = false; + StatusMessage = $"Selected build: {value.DisplayVersion}"; + } } /// @@ -496,86 +703,32 @@ private async Task CheckForUpdatesAsync() _logger.LogInformation("Starting Velopack update check"); - // Check if subscribed to a PR + // check if subscribed to a pr if (SubscribedPr != null) { if (SubscribedPr.LatestArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var prVersionBase = SubscribedPr.LatestArtifact.Version.Split('+')[0]; - - // Extract run numbers for numeric comparison - var currentRun = ExtractRunNumber(currentVersionBase); - var prRun = ExtractRunNumber(prVersionBase); - - _logger.LogDebug("Comparing PR #{PrNumber} versions: current run #{CurrentRun} vs new run #{PrRun}", SubscribedPr.Number, currentRun, prRun); - - if (prRun > currentRun) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = prVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{SubscribedPr.Number}"; - StatusMessage = $"New PR build available: {SubscribedPr.LatestArtifact.DisplayVersion}"; - _logger.LogInformation("Subscribed to PR #{PrNumber}, new build available: run #{PrRun} (current: #{CurrentRun})", SubscribedPr.Number, prRun, currentRun); - return; - } - - StatusMessage = $"You dismissed the update for PR #{SubscribedPr.Number}"; - return; - } - - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; + ProcessPrArtifactUpdate(SubscribedPr.LatestArtifact, SubscribedPr.Number); return; } - // Try to fetch artifact for update check + // try to fetch artifact for update check _logger.LogInformation("PR #{PrNumber} has no cached artifact, fetching for update check", SubscribedPr.Number); var prArtifact = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (prArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var prVersionBase = prArtifact.Version.Split('+')[0]; - - // Extract run numbers for numeric comparison - var currentRun = ExtractRunNumber(currentVersionBase); - var prRun = ExtractRunNumber(prVersionBase); - - _logger.LogDebug("Comparing fetched PR #{PrNumber} versions: current run #{CurrentRun} vs new run #{PrRun}", SubscribedPr.Number, currentRun, prRun); - - if (prRun > currentRun) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = prVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{SubscribedPr.Number}"; - StatusMessage = $"New PR build available: {prArtifact.DisplayVersion}"; - _logger.LogInformation("Fetched PR #{PrNumber} artifact, new build available: run #{PrRun} (current: #{CurrentRun})", SubscribedPr.Number, prRun, currentRun); - return; - } - - StatusMessage = $"You dismissed the update for PR #{SubscribedPr.Number}"; - return; - } - - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; + ProcessPrArtifactUpdate(prArtifact, SubscribedPr.Number); return; } - // If subscribed to PR but no artifact found, don't fall through to main release + // if subscribed to pr but no artifact found, do not fall through to main release _logger.LogInformation("Subscribed to PR #{PrNumber} but no artifact available yet", SubscribedPr.Number); StatusMessage = $"Waiting for PR #{SubscribedPr.Number} build..."; IsUpdateAvailable = false; return; } - // Check Branch updates if subscribed + // check branch updates if subscribed if (!string.IsNullOrEmpty(SubscribedBranch)) { _logger.LogInformation("Checking for artifact updates on branch: {Branch}", SubscribedBranch); @@ -583,38 +736,18 @@ private async Task CheckForUpdatesAsync() if (branchArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var artifactVersionBase = branchArtifact.Version.Split('+')[0]; - - if (!string.Equals(artifactVersionBase, currentVersionBase, StringComparison.OrdinalIgnoreCase)) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(artifactVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = artifactVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{SubscribedBranch}"; - StatusMessage = $"New {SubscribedBranch} build available: {branchArtifact.Version}"; - _logger.LogInformation("Branch '{Branch}' has new build: {Version}", SubscribedBranch, LatestVersion); - return; - } - } - else - { - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for {SubscribedBranch}"; - return; - } + ProcessBranchArtifactUpdate(branchArtifact, SubscribedBranch); + return; } - // If subscribed to branch but no artifact found, don't fall through to main release + // if subscribed to branch but no artifact found, do not fall through to main release _logger.LogInformation("Subscribed to branch '{Branch}' but no artifact available yet", SubscribedBranch); StatusMessage = $"Waiting for {SubscribedBranch} build..."; IsUpdateAvailable = false; return; } - // Check main branch releases + // check main branch releases _currentUpdateInfo = await _velopackUpdateManager.CheckForUpdatesAsync(_cancellationTokenSource.Token); if (_currentUpdateInfo != null) @@ -681,7 +814,7 @@ private async Task ManualRefreshAsync() _logger.LogInformation("Manual refresh requested - clearing cache and dismissal status"); - // Clear dismissal status in settings so the user can see the update again + // clear dismissal status in settings so the user can see the update again var settings = _userSettingsService.Get(); if (!string.IsNullOrEmpty(settings.DismissedUpdateVersion)) { @@ -689,10 +822,10 @@ private async Task ManualRefreshAsync() await _userSettingsService.SaveAsync(); } - // Clear manager cache + // clear manager cache _velopackUpdateManager.ClearCache(); - // Reload data + // reload data if (HasPat) { await Task.WhenAll( @@ -703,6 +836,41 @@ await Task.WhenAll( await CheckForUpdatesAsync(); } + /// + /// Shows the update tab. + /// + [RelayCommand] + private void ShowUpdateTab() + { + SelectedTabIndex = AppUpdateConstants.UpdateTabIndex; + } + + /// + /// Shows the browse builds tab. + /// + [RelayCommand] + private void ShowBrowseBuildsTab() + { + SelectedTabIndex = AppUpdateConstants.BrowseBuildsTabIndex; + } + + /// + /// Selects the specified tab by index (0 = Update, 1 = Browse Builds). + /// + /// The tab index to select. + [RelayCommand] + private void SelectTab(object? parameter) + { + if (parameter is int i) + { + SelectedTabIndex = Math.Clamp(i, AppUpdateConstants.UpdateTabIndex, AppUpdateConstants.MaxTabIndex); + } + else if (parameter is string s && int.TryParse(s, out var parsed)) + { + SelectedTabIndex = Math.Clamp(parsed, AppUpdateConstants.UpdateTabIndex, AppUpdateConstants.MaxTabIndex); + } + } + /// /// Opens the release notes in the default browser. /// @@ -722,6 +890,29 @@ private void ViewReleaseNotes() } } + /// + /// Opens the specified pull request in the default browser. + /// + /// The PR number to open. + [RelayCommand] + private void OpenPullRequestUrl(int prNumber) + { + if (prNumber <= 0) + { + return; + } + + var url = $"{AppConstants.GitHubRepositoryUrl}/pull/{prNumber}"; + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open browser for PR #{PrNumber}", prNumber); + } + } + /// /// Downloads and applies the update using Velopack. /// @@ -733,7 +924,7 @@ private async Task InstallUpdateAsync() return; } - // 0. Handle Explicitly Selected Version + // 0. handle explicitly selected version if (SelectedVersion != null) { _logger.LogInformation("Installing selected artifact version: {Version}", SelectedVersion.DisplayVersion); @@ -741,7 +932,7 @@ private async Task InstallUpdateAsync() return; } - // 1. Handle PR Artifact Update (Auto-latest) + // 1. handle pr artifact update if (SubscribedPr?.LatestArtifact != null && string.Equals(SubscribedPr.LatestArtifact.Version, LatestVersion, StringComparison.OrdinalIgnoreCase)) { @@ -750,7 +941,7 @@ private async Task InstallUpdateAsync() return; } - // 1.5 Handle Branch Artifact Update (Auto-latest) + // 1.5 handle branch artifact update if (!string.IsNullOrEmpty(SubscribedBranch)) { _logger.LogInformation("Installing Branch '{Branch}' artifact update", SubscribedBranch); @@ -758,7 +949,7 @@ private async Task InstallUpdateAsync() return; } - // 2. Handle Standard Velopack Update + // 2. handle standard velopack update if (_currentUpdateInfo == null) { _logger.LogError("Cannot install update - UpdateInfo is null (app not installed via Setup.exe)"); @@ -858,10 +1049,10 @@ private async Task InstallPrArtifactAsync() ArtifactUpdateInfo? artifactToInstall = SubscribedPr.LatestArtifact; if (artifactToInstall == null) { - // Clear cache to force fresh check + // clear cache to force fresh check _velopackUpdateManager.ClearCache(); - // Try to fetch the latest artifact for the PR + // try to fetch the latest artifact for the pr artifactToInstall = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (artifactToInstall == null) { @@ -875,7 +1066,7 @@ private async Task InstallPrArtifactAsync() await _velopackUpdateManager.InstallArtifactAsync(artifactToInstall, progress, _cancellationTokenSource.Token); - // App will restart, this code won't execute + // app will restart, this code will not execute } catch (Exception ex) { @@ -932,10 +1123,10 @@ private async Task InstallBranchArtifactAsync() }); }); - // Clear cache to force fresh check + // clear cache to force fresh check _velopackUpdateManager.ClearCache(); - // Check for latest artifact for the subscribed branch + // check for latest artifact for the subscribed branch var artifactUpdate = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (artifactUpdate == null) { @@ -948,7 +1139,7 @@ private async Task InstallBranchArtifactAsync() await _velopackUpdateManager.InstallArtifactAsync(artifactUpdate, progress, _cancellationTokenSource.Token); - // App will restart, this code won't execute + // app will restart, this code will not execute } catch (Exception ex) { @@ -992,7 +1183,7 @@ private async Task InstallArtifactAsync(ArtifactUpdateInfo artifact) await _velopackUpdateManager.InstallArtifactAsync(artifact, progress, _cancellationTokenSource.Token); - // App will restart + // app will restart } catch (Exception ex) { @@ -1089,10 +1280,9 @@ private async Task LoadPullRequestsAsync() await Dispatcher.UIThread.InvokeAsync(() => { - foreach (var pr in prs) - { - AvailablePullRequests.Add(pr); - } + _allPullRequests.Clear(); + _allPullRequests.AddRange(prs); + ApplyPullRequestSorting(); }); if (_velopackUpdateManager.IsPrMergedOrClosed && _velopackUpdateManager.SubscribedPrNumber.HasValue) @@ -1102,9 +1292,13 @@ await Dispatcher.UIThread.InvokeAsync(() => _logger.LogInformation("Subscribed PR has been merged/closed, showing warning"); } - if (_velopackUpdateManager.SubscribedPrNumber.HasValue && SubscribedPr == null) + if (_velopackUpdateManager.SubscribedPrNumber.HasValue) { - SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == _velopackUpdateManager.SubscribedPrNumber); + var matchingPr = AvailablePullRequests.FirstOrDefault(p => p.Number == _velopackUpdateManager.SubscribedPrNumber.Value); + if (matchingPr != null && (SubscribedPr == null || SubscribedPr.Number == matchingPr.Number)) + { + SubscribedPr = matchingPr; + } } } catch (Exception ex) @@ -1118,6 +1312,33 @@ await Dispatcher.UIThread.InvokeAsync(() => } } + private void ApplyPullRequestSorting() + { + if (_allPullRequests.Count == 0 && AvailablePullRequests.Count == 0) + { + return; + } + + if (_allPullRequests.Count == 0 && AvailablePullRequests.Count > 0) + { + _allPullRequests.AddRange(AvailablePullRequests); + } + + IEnumerable sorted = SelectedSortOption switch + { + AppUpdateConstants.SortOptionPrNumberDesc => _allPullRequests.OrderByDescending(p => p.Number), + AppUpdateConstants.SortOptionPrNumberAsc => _allPullRequests.OrderBy(p => p.Number), + _ => _allPullRequests.OrderByDescending(p => p.UpdatedAt ?? DateTimeOffset.MinValue), + }; + + var sortedList = sorted.ToList(); + AvailablePullRequests.Clear(); + foreach (var pr in sortedList) + { + AvailablePullRequests.Add(pr); + } + } + [RelayCommand] private async Task LoadBranchesAsync() { @@ -1154,11 +1375,19 @@ await Dispatcher.UIThread.InvokeAsync(() => private void SubscribeToPr(int prNumber) { _velopackUpdateManager.SubscribedPrNumber = prNumber; - SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber); + _velopackUpdateManager.SubscribedBranch = null; SubscribedBranch = null; + SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber) ?? new PullRequestInfo + { + Number = prNumber, + Title = $"PR #{prNumber}", + BranchName = "unknown", + Author = "unknown", + State = "open", + }; ShowPrMergedWarning = false; - // Clear artifact cache to force fresh check + // clear artifact cache to force fresh check _velopackUpdateManager.ClearCache(); _userSettingsService.Update(settings => @@ -1168,11 +1397,8 @@ private void SubscribeToPr(int prNumber) }); _ = _userSettingsService.SaveAsync(); - if (SubscribedPr != null) - { - StatusMessage = $"Subscribed to PR #{prNumber}: {SubscribedPr.Title}"; - _logger.LogInformation("Subscribed to PR #{PrNumber}", prNumber); - } + StatusMessage = $"Subscribed to PR #{prNumber}: {SubscribedPr.Title}"; + _logger.LogInformation("Subscribed to PR #{PrNumber}", prNumber); } [RelayCommand] @@ -1180,12 +1406,13 @@ private void SubscribeToBranch(string branchName) { if (string.IsNullOrEmpty(branchName)) return; - SubscribedBranch = branchName; _velopackUpdateManager.SubscribedPrNumber = null; + _velopackUpdateManager.SubscribedBranch = branchName; SubscribedPr = null; + SubscribedBranch = branchName; ShowPrMergedWarning = false; - // Clear artifact cache to force fresh check + // clear artifact cache to force fresh check _velopackUpdateManager.ClearCache(); _userSettingsService.Update(settings => @@ -1201,6 +1428,7 @@ private void SubscribeToBranch(string branchName) partial void OnSubscribedBranchChanged(string? value) { + _velopackUpdateManager.SubscribedBranch = value; _ = LoadArtifactsForSubscribedItemAsync(); OnPropertyChanged(nameof(IsSubscribedToAny)); UpdateCommandStates(); @@ -1220,9 +1448,15 @@ partial void OnSubscribedPrChanged(PullRequestInfo? value) private void Unsubscribe() { _velopackUpdateManager.SubscribedPrNumber = null; + _velopackUpdateManager.SubscribedBranch = null; SubscribedPr = null; SubscribedBranch = null; + SelectedVersion = null; ShowPrMergedWarning = false; + IsUpdateAvailable = false; + LatestVersion = string.Empty; + ReleaseNotesUrl = string.Empty; + _currentUpdateInfo = null; StatusMessage = "Switched to MAIN branch updates"; _userSettingsService.Update(settings => @@ -1233,6 +1467,7 @@ private void Unsubscribe() _ = _userSettingsService.SaveAsync(); _logger.LogInformation("Unsubscribed from dev builds, switched to MAIN"); + _ = CheckForUpdatesAsync(); } [RelayCommand] diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml index fc4a522ba..63c35be95 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml @@ -11,39 +11,128 @@ - - + + + + + + + + + - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs index 5fd0a2bf2..ba60939db 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs @@ -62,23 +62,43 @@ public async Task InitializeAsync() private void InitializeComponent() => AvaloniaXamlLoader.Load(this); + /// + /// Handles the maximize/restore button click event. + /// + /// The sender. + /// The event args. + private void MaximizeButton_Click(object? sender, RoutedEventArgs e) + { + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + /// /// Handles the close button click event. /// /// The sender. /// The event args. - private void CloseButton_Click(object sender, RoutedEventArgs e) + private void CloseButton_Click(object? sender, RoutedEventArgs e) { Close(); } /// - /// Handles pointer pressed event for the title bar to enable window dragging. + /// Handles pointer pressed event for the title bar to enable window dragging and double-click maximize. /// /// The sender. /// The pointer event args. - private void TitleBar_PointerPressed(object sender, PointerPressedEventArgs e) + private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e) { - BeginMoveDrag(e); + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + if (e.ClickCount == 2) + { + MaximizeButton_Click(sender, new RoutedEventArgs()); + } + else + { + BeginMoveDrag(e); + } + } } } diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index be8788503..4087be255 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -1,4 +1,5 @@ using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameInstallations; @@ -13,8 +14,6 @@ using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; using SharpCompress.Archives; -using SharpCompress.Archives.SevenZip; -using SharpCompress.Common; using System; using System.Collections.Generic; using System.IO; @@ -81,7 +80,9 @@ private static string GetContentCodeFromManifest(ContentManifest manifest) /// /// Extracts an archive (ZIP, 7z, etc.) asynchronously using SharpCompress. - /// Automatically detects format. + /// Automatically detects format. Catalog archives are third-party input, so every entry is + /// confined to and the archive is held to entry-count and + /// expansion budgets measured against the bytes actually decompressed. /// private static async Task ExtractArchiveAsync( string archivePath, @@ -89,7 +90,7 @@ private static async Task ExtractArchiveAsync( CancellationToken cancellationToken) { await Task.Run( - () => + async () => { var fileInfo = new FileInfo(archivePath); if (!fileInfo.Exists || fileInfo.Length == 0) @@ -97,18 +98,49 @@ await Task.Run( throw new FileNotFoundException($"Archive file not found or empty: {archivePath}"); } - using var archive = ArchiveFactory.Open(archivePath); - foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) + using var archive = ArchiveFactory.OpenArchive(fileInfo); + var fileEntries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + + if (fileEntries.Count > CommunityOutpostConstants.MaxArchiveEntries) + { + throw new InvalidOperationException( + $"Archive contains too many entries ({fileEntries.Count} > {CommunityOutpostConstants.MaxArchiveEntries})."); + } + + long expandedBytes = 0; + + foreach (var entry in fileEntries) { cancellationToken.ThrowIfCancellationRequested(); - entry.WriteToDirectory( - extractPath, - new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = true, - }); + if (!ArchiveEntryName.IsExtractable(entry.Key)) + { + throw new InvalidOperationException( + $"Archive entry '{entry.Key}' has a name that cannot be extracted to a file."); + } + + var destinationPath = Path.GetFullPath(Path.Combine(extractPath, entry.Key)); + if (!PathHelper.IsPathWithinDirectory(extractPath, destinationPath)) + { + throw new InvalidOperationException( + $"Zip slip vulnerability detected: entry '{entry.Key}' attempts to extract outside target directory."); + } + + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + await using var entryStream = entry.OpenEntryStream(); + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + entryStream, + destinationPath, + entry.Key, + CommunityOutpostConstants.MaxEntryUncompressedBytes, + CommunityOutpostConstants.MaxAggregateUncompressedBytes - expandedBytes, + overwrite: true, + cancellationToken); } }, cancellationToken); @@ -269,6 +301,13 @@ public async Task> DeliverContentAsync( { await ExtractArchiveAsync(archivePath, extractPath, cancellationToken); } + catch (OperationCanceledException) + { + logger.LogInformation( + "Extraction of {Path} was cancelled; the downloaded archive is left in place", + archivePath); + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to extract archive from {Path}", archivePath); @@ -386,6 +425,10 @@ await ProcessAndMergeDependencyBigFilesAsync( return OperationResult.CreateSuccess(primaryManifest); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to deliver Community Outpost content"); @@ -805,7 +848,7 @@ private async Task ProcessAndMergeDependencyBigFilesAsync( // Ignore cleanup errors } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to process dependency {Name}", dep.Name); } diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs index 3ed1922ae..79ae5fcde 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs @@ -25,6 +25,7 @@ namespace GenHub.Features.Content.Services.CommunityOutpost; /// Available content resolvers. /// Available content deliverers. /// The content validator. +/// The installation instructions service. /// The logger. public class CommunityOutpostProvider( IProviderDefinitionLoader providerDefinitionLoader, @@ -32,11 +33,10 @@ public class CommunityOutpostProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, ILogger logger) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { - private readonly IProviderDefinitionLoader _providerDefinitionLoader = providerDefinitionLoader; - private readonly IContentDiscoverer _discoverer = discoverers.FirstOrDefault(d => d.SourceName.Contains(CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("No Community Outpost discoverer found"); @@ -127,7 +127,7 @@ public override async Task> GetValidatedContent } // Try to get from the loader (it should already be loaded at startup) - _cachedProviderDefinition = _providerDefinitionLoader.GetProvider(CommunityOutpostConstants.PublisherId); + _cachedProviderDefinition = providerDefinitionLoader.GetProvider(CommunityOutpostConstants.PublisherId); if (_cachedProviderDefinition == null) { diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs index 42ee8b423..73ac811e1 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs @@ -122,7 +122,8 @@ public async Task> DeliverContentAsync( packageManifest.Publisher?.Name ?? string.Empty, packageManifest.Publisher?.Website ?? string.Empty, packageManifest.Publisher?.SupportUrl ?? string.Empty, - packageManifest.Publisher?.ContactEmail ?? string.Empty) + packageManifest.Publisher?.ContactEmail ?? string.Empty, + packageManifest.Publisher?.PublisherType ?? string.Empty) .WithMetadata( packageManifest.Metadata?.Description ?? string.Empty, packageManifest.Metadata?.Tags, @@ -173,7 +174,7 @@ await manifestBuilder.AddContentAddressableFileAsync( // Add installation instructions if present if (packageManifest.InstallationInstructions != null) { - manifestBuilder.WithInstallationInstructions(packageManifest.InstallationInstructions.WorkspaceStrategy); + manifestBuilder.WithInstallationInstructions(packageManifest.InstallationInstructions); } var deliveredManifest = manifestBuilder.Build(); diff --git a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs index 1a2a4b154..53cdf9437 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs @@ -108,7 +108,7 @@ public async Task>> SearchAsync _logger.LogDebug("Starting orchestrated content search with query: {SearchTerm}, ContentType: {ContentType}", query.SearchTerm, query.ContentType); // Check cache first - var cacheKey = $"search::{query.ProviderName}::{query.SearchTerm}::{query.ContentType}::{query.Skip}::{query.Take}::{query.SortOrder}"; + var cacheKey = $"search::{query.ProviderName}::{query.SearchTerm}::{query.ContentType}::{query.TargetGame}::{query.AuthorName}::{query.GitHubAuthor}::{query.Language}::{query.Skip}::{query.Take}::{query.SortOrder}"; var cachedResults = await _cache.GetAsync>(cacheKey, cancellationToken); if (cachedResults != null) { @@ -187,8 +187,19 @@ public async Task>> SearchAsync // than an exception, which would otherwise surface here as an empty successful search. cancellationToken.ThrowIfCancellationRequested(); + // Deduplicate results by manifest ID across providers before sorting and pagination, + // preferring specialized publisher providers over generic GitHub providers. + var deduplicatedResults = allResults + .GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase) + .Select(g => g + .OrderByDescending(r => + !string.Equals(r.ProviderName, ContentSourceNames.GitHubDiscoverer, StringComparison.OrdinalIgnoreCase) && + !string.Equals(r.ProviderName, ContentSourceNames.GitHubReleasesDiscoverer, StringComparison.OrdinalIgnoreCase) ? 1 : 0) + .First()) + .ToList(); + // Apply orchestrator-level sorting and pagination - var sortedResults = ApplySorting(allResults, query.SortOrder) + var sortedResults = ApplySorting(deduplicatedResults, query.SortOrder) .Skip(query.Skip) .Take(query.Take) .ToList(); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs index dea3d0047..e071eb54b 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs @@ -21,7 +21,9 @@ public class AODMapsContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _aodMapsDiscoverer = discoverers.FirstOrDefault(d => string.Equals(d.SourceName, AODMapsConstants.DiscovererSourceName, StringComparison.OrdinalIgnoreCase)) diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index 0262f9655..be26d3495 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -18,13 +18,27 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// /// Base class for content providers with common pipeline orchestration logic. /// -public abstract class BaseContentProvider( - IContentValidator contentValidator, - ILogger logger -) : IContentProvider +public abstract class BaseContentProvider : IContentProvider { - private readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger)); - private readonly IContentValidator _contentValidator = contentValidator ?? throw new ArgumentNullException(nameof(contentValidator)); + private readonly IContentValidator _contentValidator; + private readonly IInstallationInstructionsService _installationInstructionsService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The content validator. + /// The installation instructions service. + /// The logger. + protected BaseContentProvider( + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, + ILogger logger) + { + _contentValidator = contentValidator; + _installationInstructionsService = installationInstructionsService; + _logger = logger; + } /// public abstract string SourceName { get; } @@ -89,7 +103,7 @@ public virtual async Task>> Sea Logger.LogWarning( "Resolution failed for {ContentName}: {Error}", discovered.Name, - resolutionResult.FirstError ?? "Unknown error"); + resolutionResult.FirstError); } } else @@ -101,12 +115,7 @@ public virtual async Task>> Sea return OperationResult>.CreateSuccess(resolvedResults); } - /// - /// Gets the manifest for the specified content ID. - /// - /// The content identifier. - /// A token to cancel the operation. - /// A result containing the game manifest. + /// public abstract Task> GetValidatedContentAsync( string contentId, CancellationToken cancellationToken = default); @@ -149,50 +158,99 @@ public virtual async Task> PrepareContentAsync( // Delegate to implementation-specific preparation var result = await PrepareContentInternalAsync(manifest, workingDirectory, progress, cancellationToken); - if (result.Success) + if (!result.Success) { - // Final validation of prepared content - progress?.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.ValidatingFiles, - CurrentOperation = "Validating prepared content...", - }); + return result; + } - // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress - IProgress? validationProgress = null; - if (progress != null) - { - validationProgress = new Progress(vp => - { - // Map validation progress to content acquisition progress for UI display - progress.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.ValidatingFiles, - ProgressPercentage = vp.PercentComplete, - CurrentOperation = vp.CurrentFile ?? "Validating files", - FilesProcessed = vp.Processed, - TotalFiles = vp.Total, - }); - }); - } + if (result.Data == null) + { + Logger.LogError("Content preparation returned success without manifest data for {ManifestId}", manifest.Id); + return OperationResult.CreateFailure($"Content preparation returned no manifest data for {manifest.Id}."); + } - var fullResult = await ContentValidator.ValidateAllAsync( + try + { + // Execute post-installation steps if declared on the delivered manifest + var stepExecutionResult = await _installationInstructionsService.ExecutePostInstallStepsAsync( + result.Data, workingDirectory, - result.Data!, - validationProgress, + providerSource: SourceName, + progress: progress, cancellationToken: cancellationToken); - if (!fullResult.IsValid) + if (!stepExecutionResult.Success) { - // Log as warning only - content may have been moved to CAS already - // CAS storage validates content hash on store, so this is informational - Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id); - foreach (var issue in fullResult.Issues.Take(5)) - { - Logger.LogDebug("Validation issue: {Message}", issue.Message); - } + Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure(stepExecutionResult.Errors); } } + catch (OperationCanceledException) + { + Logger.LogInformation("Post-installation execution was canceled for manifest {ManifestId}; rolling back prepared content", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Unexpected error executing post-installation steps for manifest {ManifestId}; rolling back prepared content", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure($"Post-installation execution failed: {ex.Message}"); + } + + // Final validation of prepared content + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.ValidatingFiles, + CurrentOperation = "Validating prepared content...", + }); + + // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress + IProgress? validationProgress = null; + if (progress != null) + { + validationProgress = new Progress(vp => + { + // Map validation progress to content acquisition progress for UI display + progress.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.ValidatingFiles, + ProgressPercentage = vp.PercentComplete, + CurrentOperation = vp.CurrentFile ?? "Validating files", + FilesProcessed = vp.Processed, + TotalFiles = vp.Total, + }); + }); + } + + var fullResult = await ContentValidator.ValidateAllAsync( + workingDirectory, + result.Data, + validationProgress, + cancellationToken: cancellationToken); + + if (!fullResult.IsValid) + { + Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id); + } + + try + { + await OnContentPreparationCompletedAsync(manifest, result.Data, workingDirectory, cancellationToken); + } + catch (OperationCanceledException) + { + Logger.LogInformation("Content preparation completion hook was canceled for manifest {ManifestId}; rolling back", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Content preparation completion hook failed for manifest {ManifestId}; rolling back", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure($"Content preparation completion hook failed: {ex.Message}"); + } return result; } @@ -208,16 +266,55 @@ public virtual async Task> PrepareContentAsync( } } + /// + /// Rolls back prepared content and registered manifests when post-preparation steps fail. + /// + /// The original requested manifest. + /// The prepared manifest returned by PrepareContentInternalAsync. + /// The working directory where content was prepared. + /// A token to cancel rollback operations. + /// A task representing the asynchronous operation. + protected virtual Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// Executes cleanup or finalization when content preparation and validation succeed. + /// + /// The original requested manifest. + /// The prepared manifest returned by PrepareContentInternalAsync. + /// The working directory where content was prepared. + /// A token to cancel finalization operations. + /// A task representing the asynchronous operation. + protected virtual Task OnContentPreparationCompletedAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + /// /// Gets the logger for this provider. /// - protected ILogger Logger => logger; + protected ILogger Logger => _logger; /// /// Gets the content validator for manifest validation. /// protected IContentValidator ContentValidator => _contentValidator; + /// + /// Gets the installation instructions service for post-install execution. + /// + protected IInstallationInstructionsService? InstallationInstructionsService => _installationInstructionsService; + /// /// Gets the discoverer for this provider. /// @@ -315,4 +412,19 @@ private ContentSearchResult CreateResolvedSearchResult(ContentSearchResult disco resolved.SetData(manifest); return resolved; } + + private async Task SafeRollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory) + { + try + { + await RollbackPreparedContentAsync(originalManifest, preparedManifest, workingDirectory, CancellationToken.None); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Rollback failed during error recovery for manifest {ManifestId}", originalManifest.Id); + } + } } diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs index 4c166bb66..8017522e7 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs @@ -21,8 +21,9 @@ public class CNCLabsContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _cncLabsDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.CNCLabsDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new ArgumentException("CNC Labs discoverer not found", nameof(discoverers)); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs index 16300c566..cbbac85a5 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs @@ -24,8 +24,9 @@ public class LocalFileSystemContentProvider( IEnumerable deliverers, ILogger logger, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, IConfigurationProviderService configurationProvider) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _fileSystemDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.FileSystemDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new InvalidOperationException("No FileSystem discoverer found"); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs index 8595f0d79..089ff48d6 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs @@ -21,8 +21,9 @@ public class ModDBContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _moddbDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.ModDBDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new ArgumentException("ModDB discoverer not found", nameof(discoverers)); diff --git a/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs b/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs index fc974fd03..fa5f87046 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Storage; @@ -47,7 +48,7 @@ private static OperationResult ValidateManifestSecurity(ContentManifest ma try { var fullPath = Path.GetFullPath(Path.Combine(baseDirectory, file.RelativePath)); - if (!IsPathWithinDirectory(normalizedBase, fullPath)) + if (!PathHelper.IsPathWithinDirectory(normalizedBase, fullPath)) { return OperationResult.CreateFailure($"File {file.RelativePath} attempts path traversal outside base directory"); } @@ -72,7 +73,7 @@ private static OperationResult ValidateManifestSecurity(ContentManifest ma ? Path.GetFullPath(file.SourcePath) : Path.GetFullPath(Path.Combine(baseDirectory, file.SourcePath)); - if (!IsPathWithinDirectory(normalizedBase, fullSource)) + if (!PathHelper.IsPathWithinDirectory(normalizedBase, fullSource)) { return OperationResult.CreateFailure($"File {file.RelativePath} specifies SourcePath {file.SourcePath} which traverses outside base directory"); } @@ -88,15 +89,6 @@ private static OperationResult ValidateManifestSecurity(ContentManifest ma return OperationResult.CreateSuccess(true); } - private static bool IsPathWithinDirectory(string normalizedBase, string fullPath) - { - var relative = Path.GetRelativePath(normalizedBase, fullPath); - return !relative.Equals("..", StringComparison.Ordinal) && - !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && - !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) && - !Path.IsPathRooted(relative); - } - private static async Task CalculateFileHashAsync(string filePath, CancellationToken cancellationToken) { using var sha256 = SHA256.Create(); diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs new file mode 100644 index 000000000..f538f8c05 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Runtime.Versioning; +using System.Security; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace GenHub.Features.Content.Services.GeneralsOnline; + +/// +/// Precondition that checks whether Easy Anti-Cheat EOS product ID is already registered in the Windows registry. +/// +/// Optional logger instance for diagnostics. +public class EasyAntiCheatPrecondition(ILogger? logger = null) : IInstallationStepPrecondition +{ + /// + public bool CanHandle(InstallationStep step, ContentManifest manifest) + { + if (!OperatingSystem.IsWindows() || step == null || manifest == null) + { + return false; + } + + if (step.Kind != InstallationStepKind.RunVerifiedInstaller) + { + return false; + } + + var isGeneralsOnline = string.Equals( + manifest.Publisher?.PublisherType, + PublisherTypeConstants.GeneralsOnline, + StringComparison.OrdinalIgnoreCase); + + if (!isGeneralsOnline) + { + return false; + } + + var fileName = Path.GetFileName(step.TargetRelativePath ?? string.Empty); + return string.Equals(fileName, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase); + } + + /// + public bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + return IsProductRegisteredOnWindows(step); + } + + [SupportedOSPlatform("windows")] + private bool IsProductRegisteredOnWindows(InstallationStep step) + { + try + { + var productId = (step.Arguments is { Count: > 1 } && !string.IsNullOrWhiteSpace(step.Arguments[1])) + ? step.Arguments[1] + : GeneralsOnlineConstants.EacProductId; + + if (string.IsNullOrWhiteSpace(productId)) + { + return false; + } + + var subKeyPath = $@"SOFTWARE\EasyAntiCheat_EOS\{productId}"; + + using var baseKey32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); + using var key32 = baseKey32.OpenSubKey(subKeyPath); + if (key32 != null) + { + return true; + } + + using var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var key64 = baseKey64.OpenSubKey(subKeyPath); + if (key64 != null) + { + return true; + } + } + catch (SecurityException ex) + { + logger?.LogWarning(ex, "Insufficient permissions to inspect Easy Anti-Cheat registry keys for step '{StepName}'", step.Name); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger?.LogWarning(ex, "Access denied when inspecting Easy Anti-Cheat registry keys for step '{StepName}'", step.Name); + return false; + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Error while checking Easy Anti-Cheat registry registration for step '{StepName}'", step.Name); + return false; + } + + return false; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs index 53e9cb95e..c04d78760 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs @@ -272,11 +272,20 @@ private static void CleanupTempArtifacts(string? zipPath, string? extractPath, I CurrentFile = zipFile.RelativePath, }); - logger.LogDebug("Downloading ZIP from {Url} to {Path}", zipFile.DownloadUrl, zipPath); + var expectedHash = !string.IsNullOrWhiteSpace(zipFile.Hash) + ? zipFile.Hash + : packageManifest.InstallationInstructions?.DownloadHash; + + if (string.IsNullOrWhiteSpace(expectedHash)) + { + expectedHash = null; + } + + logger.LogDebug("Downloading ZIP from {Url} to {Path} (expected hash: {Hash})", zipFile.DownloadUrl, zipPath, expectedHash); var downloadResult = await downloadService.DownloadFileAsync( new Uri(zipFile.DownloadUrl!), zipPath, - expectedHash: null, + expectedHash: expectedHash, progress: null, cancellationToken); diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs index d2ab6ed83..91f2ed956 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs @@ -148,6 +148,7 @@ private static GeneralsOnlineRelease CreateReleaseFromApiResponse(GeneralsOnline ReleaseDate = versionDate, PortableUrl = apiResponse.DownloadUrl, PortableSize = apiResponse.Size, + Sha256 = apiResponse.Sha256, Changelog = apiResponse.ReleaseNotes ?? $"Generals Online {apiResponse.Version}", }; } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 59a2eb756..712b76997 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -25,6 +25,21 @@ public class GeneralsOnlineManifestFactory( ILogger logger, IProviderDefinitionLoader providerLoader) : IPublisherManifestFactory { + /// + /// File info extracted from archive for manifest generation. + /// + /// The relative path within archive. + /// The file info. + /// The SHA-256 hash. + /// Whether this is a map file. + /// Whether this is a game data file. + private readonly record struct ExtractedFileInfo( + string RelativePath, + FileInfo FileInfo, + string Hash, + bool IsMap, + bool IsGameData); + /// public string PublisherId => PublisherTypeConstants.GeneralsOnline; @@ -96,10 +111,29 @@ public ContentManifest CreateVariantManifest( DownloadUrl = release.PortableUrl, Size = release.PortableSize ?? 0, // Use 0 when size is unknown SourceType = ContentSourceType.RemoteDownload, - Hash = string.Empty, + Hash = release.Sha256 ?? string.Empty, }, ], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion), + InstallationInstructions = new InstallationInstructions + { + WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, + DownloadHash = release.Sha256, + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }, }; } @@ -323,6 +357,7 @@ private ContentManifest CreateGameDataPatchManifest(GeneralsOnlineRelease releas // Files will be populated during extraction Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion), + InstallationInstructions = new InstallationInstructions(), }; } @@ -378,18 +413,19 @@ private ContentManifest CreateQuickMatchMapPackManifest(GeneralsOnlineRelease re // MapPack requires Zero Hour installation GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(), ], + InstallationInstructions = new InstallationInstructions(), }; } /// - /// Creates all variant manifests (60Hz, MapPack, and GameData Patch) from the original manifest. - /// This is called AFTER extraction - we use the original manifest's metadata to create variants. + /// Creates variant manifests (60Hz, QuickMatch MapPack, and GeneralsOnlineGameData data patch) from an original manifest. + /// This is used after downloading and extracting the portable ZIP. /// - /// The manifest from the Resolver (contains version, publisher info, etc.). - /// List of variant manifests ready for file hash population. + /// The original manifest (can be 60Hz or generic). + /// List of variant manifests with basic information populated. private List CreateVariantManifestsFromOriginal(ContentManifest originalManifest) { - var manifests = new List(); + List manifests = []; var version = originalManifest.Version ?? GeneralsOnlineConstants.UnknownVersion; var userVersion = ParseVersionForManifestId(version); @@ -440,6 +476,10 @@ private List CreateVariantManifestsFromOriginal(ContentManifest }, Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion), + InstallationInstructions = originalManifest.InstallationInstructions ?? new InstallationInstructions + { + WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, + }, }); // Create QuickMatch MapPack @@ -469,6 +509,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest [ GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(), ], + InstallationInstructions = new InstallationInstructions(), }); // Create GeneralsOnlineGameData data patch @@ -495,6 +536,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest }, Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion), + InstallationInstructions = new InstallationInstructions(), }); return manifests; @@ -520,10 +562,63 @@ private async Task> UpdateManifestsWithExtractedFiles( cancellationToken.ThrowIfCancellationRequested(); + var filesWithHashes = await ScanExtractedFilesAsync(extractPath, cancellationToken); + var updatedManifests = new List(); + + foreach (var manifest in manifests) + { + var manifestFiles = BuildManifestFilesForManifest(manifest, filesWithHashes); + + if (manifestFiles.Count == 0) + { + if (manifest.ContentType is ContentType.MapPack or ContentType.Patch) + { + logger.LogInformation( + "Skipping empty {Type} manifest '{Name}' because no matching files were found in extract path", + manifest.ContentType, + manifest.Name); + continue; + } + + logger.LogError( + "Manifest '{Name}' of type {Type} has zero files in extract path '{ExtractPath}'", + manifest.Name, + manifest.ContentType, + extractPath); + throw new InvalidDataException( + $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'."); + } + + var instructions = BuildInstallationInstructions(manifest, filesWithHashes); + + updatedManifests.Add(new ContentManifest + { + Id = manifest.Id, + Name = manifest.Name, + Version = manifest.Version, + ContentType = manifest.ContentType, + TargetGame = manifest.TargetGame, + Publisher = manifest.Publisher, + Metadata = manifest.Metadata, + Files = manifestFiles, + Dependencies = manifest.Dependencies, + InstallationInstructions = instructions, + }); + } + + ReconcileMissingMapPackDependencies(updatedManifests); + + return updatedManifests; + } + + private async Task> ScanExtractedFilesAsync( + string extractPath, + CancellationToken cancellationToken) + { var allFiles = Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories); logger.LogInformation("Processing {Count} files", allFiles.Length); - List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap, bool IsGameData)> filesWithHashes = []; + var filesWithHashes = new List(allFiles.Length); foreach (var filePath in allFiles) { @@ -532,11 +627,9 @@ private async Task> UpdateManifestsWithExtractedFiles( var relativePath = Path.GetRelativePath(extractPath, filePath); var fileInfo = new FileInfo(filePath); - // Determine if this file is inside the Maps directory var isMap = relativePath.StartsWith(GeneralsOnlineConstants.MapsSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith(GeneralsOnlineConstants.MapsSubdirectory + "/", StringComparison.OrdinalIgnoreCase); - // Determine if this file is inside the GeneralsOnlineGameData directory var isGameData = relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + "/", StringComparison.OrdinalIgnoreCase); @@ -547,151 +640,137 @@ private async Task> UpdateManifestsWithExtractedFiles( hash = Convert.ToHexString(hashBytes).ToLowerInvariant(); } - filesWithHashes.Add((relativePath, fileInfo, hash, isMap, isGameData)); + filesWithHashes.Add(new ExtractedFileInfo(relativePath, fileInfo, hash, isMap, isGameData)); logger.LogDebug("Processed file: {File} ({Size} bytes, hash: {Hash}, isMap: {IsMap}, isGameData: {IsGameData})", relativePath, fileInfo.Length, hash[..8], isMap, isGameData); } - List updatedManifests = []; + return filesWithHashes; + } - foreach (var manifest in manifests) - { - List manifestFiles = []; - var isMapPackManifest = manifest.ContentType == ContentType.MapPack; - var isPatchManifest = manifest.ContentType == ContentType.Patch; + private List BuildManifestFilesForManifest( + ContentManifest manifest, + List filesWithHashes) + { + var manifestFiles = new List(); - if (isMapPackManifest) + if (manifest.ContentType == ContentType.MapPack) + { + foreach (var file in filesWithHashes) { - // MapPack manifest: only include map files with UserMapsDirectory install target - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) + if (file.IsMap) { - if (!isMap) - { - continue; - } - - manifestFiles.Add(CreateMapManifestFile(relativePath, fileInfo, hash)); + manifestFiles.Add(CreateMapManifestFile(file.RelativePath, file.FileInfo, file.Hash)); } - - logger.LogInformation("MapPack manifest '{Name}' updated with {Count} map files", manifest.Name, manifestFiles.Count); } - else if (isPatchManifest) + + logger.LogInformation("MapPack manifest '{Name}' updated with {Count} map files", manifest.Name, manifestFiles.Count); + } + else if (manifest.ContentType == ContentType.Patch) + { + foreach (var file in filesWithHashes) { - // Data patch manifest: only include GeneralsOnlineGameData files with UserDataDirectory install target - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) + if (file.IsGameData) { - if (!isGameData) - { - continue; - } - - manifestFiles.Add(CreateGameDataManifestFile(relativePath, fileInfo, hash)); + manifestFiles.Add(CreateGameDataManifestFile(file.RelativePath, file.FileInfo, file.Hash)); } - - logger.LogInformation("GameData patch manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); } - else - { - // Game client manifest: include executables and shared files (skipping maps and game data files) - // Since 060526_QFE1 the portable ships an Easy Anti-Cheat bootstrapper that starts the - // binary named by EasyAntiCheat/Settings.json. When present it is the only launch target; - // the wrapped binary stays in the workspace as ordinary content for EAC to start. - var hasEacLauncher = filesWithHashes.Any(file => - !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable)); - - var targetExecutable = hasEacLauncher - ? GameClientConstants.GeneralsOnlineEacLauncherExecutable - : GameClientConstants.GeneralsOnline60HzExecutable; - - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) - { - var isExecutable = false; - - // Skip map files and game data files in GameClient manifests - if (isMap || isGameData) - { - continue; - } - - if (IsArchiveRootFile(relativePath, targetExecutable)) - { - isExecutable = true; - } - manifestFiles.Add(new ManifestFile - { - RelativePath = relativePath, - Size = fileInfo.Length, - Hash = hash, - SourceType = ContentSourceType.ContentAddressable, - SourcePath = fileInfo.FullName, - InstallTarget = ContentInstallTarget.Workspace, - IsExecutable = isExecutable, - }); - } + logger.LogInformation("GameData patch manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); + } + else + { + var hasEacLauncher = filesWithHashes.Any(file => + !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable)); - logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); - } + var targetExecutable = hasEacLauncher + ? GameClientConstants.GeneralsOnlineEacLauncherExecutable + : GameClientConstants.GeneralsOnline60HzExecutable; - if (manifestFiles.Count == 0) + foreach (var file in filesWithHashes) { - if (isMapPackManifest || isPatchManifest) + if (file.IsMap || file.IsGameData) { - logger.LogInformation( - "Skipping empty {Type} manifest '{Name}' because no matching files were found in extract path", - manifest.ContentType, - manifest.Name); continue; } - if (manifest.ContentType == ContentType.GameClient) - { - logger.LogError( - "GameClient manifest '{Name}' has zero files in extract path '{ExtractPath}'", - manifest.Name, - extractPath); - throw new InvalidDataException( - $"GameClient manifest '{manifest.Name}' has no files in extract path '{extractPath}'."); - } + var isExecutable = IsArchiveRootFile(file.RelativePath, targetExecutable); - logger.LogError( - "Manifest '{Name}' of type {Type} has zero files in extract path '{ExtractPath}'", - manifest.Name, - manifest.ContentType, - extractPath); - throw new InvalidDataException( - $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'."); + manifestFiles.Add(new ManifestFile + { + RelativePath = file.RelativePath, + Size = file.FileInfo.Length, + Hash = file.Hash, + SourceType = ContentSourceType.ContentAddressable, + SourcePath = file.FileInfo.FullName, + InstallTarget = ContentInstallTarget.Workspace, + IsExecutable = isExecutable, + }); } - updatedManifests.Add(new ContentManifest + logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); + } + + return manifestFiles; + } + + private InstallationInstructions BuildInstallationInstructions( + ContentManifest manifest, + List filesWithHashes) + { + var hasEacSetup = filesWithHashes.Any(file => + !file.IsMap && !file.IsGameData && + IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)); + + var inheritedPostSteps = (manifest.InstallationInstructions?.PostInstallSteps ?? []) + .Where(s => s != null && (hasEacSetup || !string.Equals( + s.TargetRelativePath, + GameClientConstants.GeneralsOnlineEacSetupExecutable, + StringComparison.OrdinalIgnoreCase))); + + var instructions = new InstallationInstructions + { + WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy, + DownloadHash = manifest.InstallationInstructions?.DownloadHash, + PostInstallSteps = [.. inheritedPostSteps], + }; + + if (manifest.ContentType == ContentType.GameClient && + hasEacSetup && + instructions.PostInstallSteps.All(s => s == null || (!string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase) && !string.Equals(s.StepKey, GeneralsOnlineConstants.EacStepKey, StringComparison.OrdinalIgnoreCase)))) + { + instructions.PostInstallSteps.Add(new InstallationStep { - Id = manifest.Id, - Name = manifest.Name, - Version = manifest.Version, - ContentType = manifest.ContentType, - TargetGame = manifest.TargetGame, - Publisher = manifest.Publisher, - Metadata = manifest.Metadata, - Files = manifestFiles, - Dependencies = manifest.Dependencies, + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, }); } - // If MapPack was not created from archive, remove MapPack dependency so dependency resolution does not fail - var hasMapPack = updatedManifests.Any(m => m.ContentType == ContentType.MapPack); - if (!hasMapPack) + return instructions; + } + + private void ReconcileMissingMapPackDependencies(List manifests) + { + var hasMapPack = manifests.Any(m => m.ContentType == ContentType.MapPack); + if (hasMapPack) { - foreach (var m in updatedManifests) + return; + } + + foreach (var m in manifests) + { + if (m.Dependencies.Any(d => d.DependencyType == ContentType.MapPack)) { - if (m.Dependencies.Any(d => d.DependencyType == ContentType.MapPack)) - { - logger.LogWarning( - "Removing MapPack dependency from manifest '{Name}' because MapPack was not found in archive", - m.Name); - m.Dependencies = m.Dependencies.Where(d => d.DependencyType != ContentType.MapPack).ToList(); - } + logger.LogWarning( + "Removing MapPack dependency from manifest '{Name}' because MapPack was not found in archive", + m.Name); + m.Dependencies = m.Dependencies.Where(d => d.DependencyType != ContentType.MapPack).ToList(); } } - - return updatedManifests; } } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index 3c842779c..6a800a122 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs @@ -1,3 +1,9 @@ +using System; +using System.Collections.Concurrent; +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.Manifest; @@ -10,11 +16,6 @@ using GenHub.Core.Models.Results.Content; using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace GenHub.Features.Content.Services.GeneralsOnline; @@ -28,10 +29,12 @@ public class GeneralsOnlineProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, IContentManifestPool manifestPool, ILogger logger) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { + private readonly ConcurrentDictionary> _preExistingManifestIdsByManifest = new(StringComparer.OrdinalIgnoreCase); private ProviderDefinition? _cachedProviderDefinition; /// @@ -201,6 +204,12 @@ protected override async Task> PrepareContentIn IProgress? progress, CancellationToken cancellationToken) { + if (!OperatingSystem.IsWindows()) + { + return OperationResult.CreateFailure( + "GeneralsOnline is currently supported only on Windows. Easy Anti-Cheat was not designed for Wine/Proton environments."); + } + Logger.LogInformation("Preparing Generals Online content: {Version}", manifest.Version); try @@ -212,6 +221,21 @@ protected override async Task> PrepareContentIn $"Cannot deliver content for manifest {manifest.Id}"); } + var existingPool = await manifestPool.GetAllManifestsAsync(cancellationToken); + if (!existingPool.Success || existingPool.Data == null) + { + return OperationResult.CreateFailure( + $"Failed to query existing manifests before delivery: {existingPool.FirstError}"); + } + + var preExisting = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var m in existingPool.Data) + { + preExisting.Add(m.Id); + } + + _preExistingManifestIdsByManifest[manifest.Id] = preExisting; + var deliveryResult = await Deliverer.DeliverContentAsync( manifest, workingDirectory, @@ -241,4 +265,63 @@ protected override async Task> PrepareContentIn $"Content preparation failed: {ex.Message}"); } } + + /// + protected override async Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + Logger.LogWarning("Rolling back Generals Online manifest registration for version {Version}", preparedManifest.Version); + + try + { + if (!_preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out var preExistingIds) || preExistingIds == null) + { + Logger.LogWarning( + "No pre-delivery manifest snapshot found for {ManifestId}; skipping rollback manifest unregistration to avoid removing existing content", + originalManifest.Id); + return; + } + + var allManifestsResult = await manifestPool.GetAllManifestsAsync(cancellationToken); + if (allManifestsResult.Success && allManifestsResult.Data != null) + { + var matchingManifests = allManifestsResult.Data + .Where(m => string.Equals(m.Version, preparedManifest.Version, StringComparison.OrdinalIgnoreCase) && + string.Equals(m.Publisher?.PublisherType, GeneralsOnlineConstants.PublisherType, StringComparison.OrdinalIgnoreCase) && + !preExistingIds.Contains(m.Id)) + .ToList(); + + foreach (var manifest in matchingManifests) + { + var removeResult = await manifestPool.RemoveManifestAsync(manifest.Id, cancellationToken: cancellationToken); + if (!removeResult.Success) + { + Logger.LogWarning("Failed to remove manifest {ManifestId} during rollback: {Error}", manifest.Id, removeResult.FirstError); + } + else + { + Logger.LogInformation("Unregistered manifest {ManifestId} during rollback", manifest.Id); + } + } + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error occurred during Generals Online manifest registration rollback"); + } + } + + /// + protected override Task OnContentPreparationCompletedAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + _preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out _); + return Task.CompletedTask; + } } diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs index 1814c5362..eed7c2a24 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Manifest; @@ -14,10 +15,10 @@ using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Core.Utilities; using GenHub.Features.Content.Services.Publishers; using Microsoft.Extensions.Logging; using SharpCompress.Archives; -using SharpCompress.Common; namespace GenHub.Features.Content.Services.GitHub; @@ -162,6 +163,13 @@ await ExtractArchiveAsync( logger.LogInformation("Extracted {ArchiveFile}", Path.GetFileName(archiveFile)); File.Delete(archiveFile); } + catch (OperationCanceledException) + { + logger.LogInformation( + "Extraction of {ArchiveFile} was cancelled; the downloaded archive is left in place", + Path.GetFileName(archiveFile)); + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to extract {ArchiveFile}", Path.GetFileName(archiveFile)); @@ -182,6 +190,10 @@ await ExtractArchiveAsync( // For content without archives, return original manifest return OperationResult.CreateSuccess(packageManifest); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to deliver GitHub content for manifest {ManifestId}", packageManifest.Id); @@ -245,17 +257,6 @@ private static bool IsArchiveFile(string filePath) ext == FileTypes.RarFileExtension; } - private static bool IsPathWithinDirectory(string normalizedBase, string fullPath) - { - var normalizedRoot = Path.GetFullPath(normalizedBase); - var normalizedTarget = Path.GetFullPath(fullPath); - 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); - } - /// /// Handles extracted content by using publisher-specific factories to create manifests. /// May return multiple manifests if the publisher factory detects multi-variant content. @@ -365,7 +366,9 @@ private async Task> HandleExtractedContentAsync } /// - /// Extracts an archive file asynchronously to prevent UI blocking. + /// Extracts an archive file asynchronously to prevent UI blocking. Release archives are remote + /// input, so every entry is confined to and the archive is + /// held to entry-count and expansion budgets measured against the bytes actually decompressed. /// /// Path to the archive file. /// Directory to extract files to. @@ -379,21 +382,38 @@ private async Task ExtractArchiveAsync( CancellationToken cancellationToken) { await Task.Run( - () => + async () => { - using var archive = ArchiveFactory.Open(archiveFile); - int totalEntries = archive.Entries.Count(e => !e.IsDirectory); - int currentEntry = 0; + using var archive = ArchiveFactory.OpenArchive(new FileInfo(archiveFile)); + var fileEntries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + + if (fileEntries.Count > GitHubConstants.MaxArchiveEntries) + { + throw new InvalidOperationException( + $"Archive contains too many entries ({fileEntries.Count} > {GitHubConstants.MaxArchiveEntries})."); + } - foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) + int totalEntries = fileEntries.Count; + int currentEntry = 0; + long expandedBytes = 0; + long expansionBudget = Math.Min( + GitHubConstants.MaxAggregateUncompressedBytes, + Math.Max( + GitHubConstants.MinArchiveExpansionBudgetBytes, + new FileInfo(archiveFile).Length * GitHubConstants.MaxArchiveExpansionRatio)); + + foreach (var entry in fileEntries) { - if (cancellationToken.IsCancellationRequested) + cancellationToken.ThrowIfCancellationRequested(); + + if (!ArchiveEntryName.IsExtractable(entry.Key)) { - break; + throw new InvalidOperationException( + $"Archive entry '{entry.Key}' has a name that cannot be extracted to a file."); } - var destinationPath = Path.GetFullPath(Path.Combine(targetDirectory, entry.Key ?? string.Empty)); - if (!IsPathWithinDirectory(targetDirectory, destinationPath)) + var destinationPath = Path.GetFullPath(Path.Combine(targetDirectory, entry.Key)); + if (!PathHelper.IsPathWithinDirectory(targetDirectory, destinationPath)) { throw new InvalidOperationException($"Zip slip vulnerability detected: entry '{entry.Key}' attempts to extract outside target directory."); } @@ -404,13 +424,17 @@ await Task.Run( Directory.CreateDirectory(destinationDir); } - entry.WriteToFile( - destinationPath, - new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = true, - }); + await using (var entryStream = entry.OpenEntryStream()) + { + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + entryStream, + destinationPath, + entry.Key, + GitHubConstants.MaxEntryUncompressedBytes, + expansionBudget - expandedBytes, + overwrite: true, + cancellationToken); + } currentEntry++; diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs index 4f9358e22..366cb6e41 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs @@ -24,8 +24,9 @@ public class GitHubContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { /// public override string SourceName => "GitHub"; diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs index f91075390..e7ac16dc3 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs @@ -186,6 +186,11 @@ await manifest.AddRemoteFileAsync( } var builtManifest = manifest.Build(); + if (!string.IsNullOrEmpty(release.TagName)) + { + builtManifest.Version = release.TagName; + } + logger.LogInformation("GitHubResolver: Built manifest with ID: {ManifestId}", builtManifest.Id); return OperationResult.CreateSuccess(builtManifest); } @@ -373,6 +378,11 @@ await manifest.AddRemoteFileAsync( logger.LogInformation("Successfully resolved single release asset: {AssetName}", asset.Name); var builtManifest = manifest.Build(); + if (!string.IsNullOrEmpty(tag)) + { + builtManifest.Version = tag; + } + logger.LogInformation("GitHubResolver (Single Asset): Built manifest with ID: {ManifestId}", builtManifest.Id); return OperationResult.CreateSuccess(builtManifest); } diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs new file mode 100644 index 000000000..bae5c14e2 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -0,0 +1,657 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services; + +/// +/// Service for validating and executing manifest-declared installation steps. +/// Enforces trust boundaries, path containment, and hash verification before execution. +/// +/// The file hash provider for integrity verification. +/// The notification service for user awareness. +/// The user settings service for tracking executed installation steps across updates. +/// Optional installation step preconditions for environment detection. +/// The logger instance. +public class InstallationInstructionsService( + IFileHashProvider hashProvider, + INotificationService notificationService, + IUserSettingsService? userSettingsService, + IEnumerable? preconditions, + ILogger logger) : IInstallationInstructionsService +{ + private static readonly TimeSpan InstallerStepTimeout = TimeSpan.FromMinutes(10); + private readonly SemaphoreSlim _executionGate = new(1, 1); + + /// + /// Initializes a new instance of the class. + /// + /// The file hash provider for integrity verification. + /// The notification service for user awareness. + /// The user settings service for tracking executed installation steps across updates. + /// The logger instance. + public InstallationInstructionsService( + IFileHashProvider hashProvider, + INotificationService notificationService, + IUserSettingsService? userSettingsService, + ILogger logger) + : this(hashProvider, notificationService, userSettingsService, null, logger) + { + } + + /// + public async Task ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + string? providerSource = null, + bool force = false, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (manifest.InstallationInstructions?.PostInstallSteps == null || + manifest.InstallationInstructions.PostInstallSteps.Count == 0) + { + return OperationResult.CreateSuccess(); + } + + logger.LogInformation( + "Executing {Count} post-install step(s) for manifest {ManifestId} from provider {Provider} (force: {Force})", + manifest.InstallationInstructions.PostInstallSteps.Count, + manifest.Id, + providerSource ?? "unspecified", + force); + + return await ExecuteStepsAsync( + manifest.InstallationInstructions.PostInstallSteps, + manifest, + workingDirectory, + providerSource, + force, + progress, + cancellationToken); + } + + private async Task ExecuteStepsAsync( + IReadOnlyList steps, + ContentManifest manifest, + string workingDirectory, + string? providerSource, + bool force, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(workingDirectory) || !Directory.Exists(workingDirectory)) + { + return OperationResult.CreateFailure($"Working directory does not exist: '{workingDirectory}'"); + } + + await _executionGate.WaitAsync(cancellationToken); + try + { + for (var i = 0; i < steps.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var step = steps[i]; + + if (step == null) + { + continue; + } + + var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, cancellationToken); + if (!stepResult.Success) + { + return stepResult; + } + } + + return OperationResult.CreateSuccess(); + } + finally + { + _executionGate.Release(); + } + } + + private async Task ExecuteSingleStepAsync( + InstallationStep step, + ContentManifest manifest, + string workingDirectory, + string? providerSource, + bool force, + IProgress? progress, + CancellationToken cancellationToken) + { + var stepKey = GetStepKey(step, manifest); + + if (!force && step.RunOnce && await ShouldSkipStepAsync(step, stepKey, manifest, cancellationToken)) + { + logger.LogInformation( + "Skipping installation step '{StepName}' for manifest {ManifestId} because it has already been executed (key: {StepKey})", + step.Name, + manifest.Id, + stepKey); + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Delivering, + CurrentOperation = $"Skipping {step.Name} (already installed)", + CurrentFile = step.TargetRelativePath ?? string.Empty, + }); + + return OperationResult.CreateSuccess(); + } + + var authResult = ValidateProviderAuthorization(providerSource, manifest, step); + if (!authResult.Success) + { + return authResult; + } + + var result = OperationResult.CreateFailure("Uninitialized step result"); + switch (step.Kind) + { + case InstallationStepKind.RunVerifiedInstaller: + result = await ExecuteRunVerifiedInstallerAsync(step, manifest, workingDirectory, progress, cancellationToken); + break; + + case InstallationStepKind.RemoveFile: + result = ExecuteRemoveFile(step, workingDirectory); + break; + + case InstallationStepKind.RenameFile: + result = ExecuteRenameFile(step, workingDirectory); + break; + + default: + logger.LogError("Unsupported installation step kind '{Kind}' in step '{StepName}'", step.Kind, step.Name); + return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'."); + } + + if (result.Success && step.RunOnce && !string.IsNullOrWhiteSpace(stepKey)) + { + await RecordStepExecutedAsync(stepKey, cancellationToken); + } + + return result; + } + + private async Task ShouldSkipStepAsync( + InstallationStep step, + string stepKey, + ContentManifest manifest, + CancellationToken cancellationToken) + { + if (userSettingsService?.Get().IsInstallationStepExecuted(stepKey) == true) + { + return true; + } + + if (preconditions != null) + { + foreach (var precondition in preconditions) + { + if (precondition.CanHandle(step, manifest) && precondition.IsAlreadyFulfilled(step, manifest)) + { + if (!string.IsNullOrWhiteSpace(stepKey)) + { + await RecordStepExecutedAsync(stepKey, cancellationToken); + } + + return true; + } + } + } + + return false; + } + + private async Task RecordStepExecutedAsync(string stepKey, CancellationToken cancellationToken) + { + if (userSettingsService == null || string.IsNullOrWhiteSpace(stepKey)) + { + return; + } + + userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey)); + + try + { + await userSettingsService.SaveAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist executed installation step key '{StepKey}'", stepKey); + } + } + + private string GetStepKey(InstallationStep step, ContentManifest manifest) + { + if (!string.IsNullOrWhiteSpace(step.StepKey)) + { + return step.StepKey; + } + + var publisher = manifest.Publisher?.PublisherType ?? "generic"; + var manifestId = manifest.Id.Value ?? string.Empty; + var name = step.Name; + var target = step.TargetRelativePath ?? string.Empty; + var args = step.Arguments is { Count: > 0 } ? string.Join(" ", step.Arguments) : string.Empty; + + return $"{publisher}:{manifestId}:{name}:{target}:{args}".TrimEnd(':'); + } + + private async Task ExecuteRunVerifiedInstallerAsync( + InstallationStep step, + ContentManifest manifest, + string workingDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + var pathResult = ValidateInstallerTargetPath(step, workingDirectory, out var targetFullPath); + if (!pathResult.Success) + { + return pathResult; + } + + var integrityResult = await VerifyInstallerIntegrityAsync(step, manifest, targetFullPath, cancellationToken); + if (!integrityResult.Success) + { + return integrityResult; + } + + NotifyStepStarting(step, progress); + + logger.LogInformation( + "Executing verified installer '{Target}' (Elevation: {RequiresElevation}) for manifest {ManifestId}", + step.TargetRelativePath, + step.RequiresElevation, + manifest.Id); + + return await RunInstallerProcessAsync(step, targetFullPath, workingDirectory, cancellationToken); + } + + private OperationResult ValidateProviderAuthorization(string? providerSource, ContentManifest manifest, InstallationStep step) + { + var effectiveSource = !string.IsNullOrWhiteSpace(providerSource) + ? providerSource + : string.Empty; + + var isTrusted = PublisherTypeConstants.TrustedExecutablePublishers.Contains(effectiveSource); + + if (!isTrusted) + { + logger.LogError( + "Untrusted provider '{ProviderSource}' attempted to execute step '{StepName}' (Kind: {Kind}) for manifest {ManifestId}", + effectiveSource, + step.Name, + step.Kind, + manifest.Id); + + return OperationResult.CreateFailure( + $"Provider '{(!string.IsNullOrEmpty(effectiveSource) ? effectiveSource : "unknown")}' is not authorized to execute installation steps."); + } + + return OperationResult.CreateSuccess(); + } + + private OperationResult ValidateInstallerTargetPath(InstallationStep step, string workingDirectory, out string targetFullPath) + { + targetFullPath = string.Empty; + + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for executable step '{step.Name}'."); + } + + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); + + if (!PathHelper.IsPathWithinDirectory(workingDirectory, targetFullPath)) + { + logger.LogError("Target installer path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Installer path '{step.TargetRelativePath}' escapes the working directory."); + } + + if (!File.Exists(targetFullPath)) + { + logger.LogError("Installer executable not found at '{Path}'", targetFullPath); + return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' was not found in delivered content."); + } + + return OperationResult.CreateSuccess(); + } + + private async Task VerifyInstallerIntegrityAsync( + InstallationStep step, + ContentManifest manifest, + string targetFullPath, + CancellationToken cancellationToken) + { + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath ?? string.Empty); + var manifestFile = manifest.Files?.FirstOrDefault(f => + string.Equals( + PathHelper.NormalizeRelativePath(f.RelativePath), + normalizedRelativePath, + PathHelper.PathComparison)); + + if (manifestFile == null) + { + logger.LogError("Executable '{Target}' is not declared in manifest files for {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' is not declared in manifest files."); + } + + if (string.IsNullOrWhiteSpace(manifestFile.Hash)) + { + logger.LogError("Installer '{Target}' has no declared hash in manifest {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure( + $"Installer '{step.TargetRelativePath}' has no declared hash and cannot be verified."); + } + + var computedHash = string.Empty; + try + { + computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to compute hash for installer '{Target}' in manifest {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure($"Failed to compute hash for installer '{step.TargetRelativePath}': {ex.Message}"); + } + + if (!string.Equals(computedHash, manifestFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + logger.LogError( + "Integrity verification failed for installer '{Target}'. Expected: {Expected}, Computed: {Computed}", + step.TargetRelativePath, + manifestFile.Hash, + computedHash); + + return OperationResult.CreateFailure( + $"Integrity verification failed for installer '{step.TargetRelativePath}'."); + } + + logger.LogDebug("Integrity verified for installer '{Target}'", step.TargetRelativePath); + return OperationResult.CreateSuccess(); + } + + private void NotifyStepStarting(InstallationStep step, IProgress? progress) + { + var displayTitle = !string.IsNullOrWhiteSpace(step.Name) ? step.Name : "Running Installation Step"; + var displayMessage = !string.IsNullOrWhiteSpace(step.StatusMessage) + ? step.StatusMessage + : $"Executing verified installer '{step.TargetRelativePath}'"; + + notificationService.ShowInfo( + displayTitle, + displayMessage, + NotificationConstants.DefaultAutoDismissMs); + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Delivering, + CurrentOperation = displayMessage, + CurrentFile = step.TargetRelativePath ?? string.Empty, + }); + } + + private async Task RunInstallerProcessAsync( + InstallationStep step, + string targetFullPath, + string workingDirectory, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + FileName = targetFullPath, + WorkingDirectory = workingDirectory, + }; + + if (step.Arguments is { Count: > 0 }) + { + foreach (var arg in step.Arguments) + { + startInfo.ArgumentList.Add(arg); + } + } + + if (step.RequiresElevation) + { + if (!OperatingSystem.IsWindows()) + { + logger.LogError("Installation step '{StepName}' requires administrator elevation, which is only supported on Windows", step.Name); + return OperationResult.CreateFailure( + $"Installation step '{step.Name}' requires administrator elevation, which is only supported on Windows."); + } + + startInfo.UseShellExecute = true; + startInfo.Verb = "runas"; + } + else + { + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + } + + try + { + using var process = Process.Start(startInfo); + if (process == null) + { + logger.LogError("Failed to start process for installer '{Target}'", step.TargetRelativePath); + notificationService.ShowError("Installation Step Failed", $"Failed to start installer '{step.Name}'."); + return OperationResult.CreateFailure($"Failed to start installer '{step.TargetRelativePath}'."); + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(InstallerStepTimeout); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError("Installer step '{StepName}' timed out", step.Name); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + catch (Exception killEx) + { + logger.LogWarning(killEx, "Failed to terminate timed-out installer step '{StepName}'", step.Name); + } + + notificationService.ShowError("Installation Step Failed", $"Step '{step.Name}' timed out."); + return OperationResult.CreateFailure($"Installation step '{step.Name}' timed out."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + logger.LogInformation("Installation step '{StepName}' was canceled by caller, killing process tree", step.Name); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + catch (Exception killEx) + { + logger.LogWarning(killEx, "Failed to terminate canceled installer step '{StepName}'", step.Name); + } + + throw; + } + + if (process.ExitCode != 0) + { + logger.LogError( + "Installer step '{StepName}' exited with error code {ExitCode}", + step.Name, + process.ExitCode); + + notificationService.ShowError( + "Installation Step Failed", + $"Step '{step.Name}' failed with exit code {process.ExitCode}."); + + return OperationResult.CreateFailure( + $"Installation step '{step.Name}' failed with exit code {process.ExitCode}."); + } + + logger.LogInformation("Successfully completed installer step '{StepName}'", step.Name); + notificationService.ShowSuccess( + "Installation Step Completed", + $"Successfully completed '{step.Name}'."); + + return OperationResult.CreateSuccess(); + } + catch (OperationCanceledException) + { + logger.LogInformation("Installation step '{StepName}' was canceled", step.Name); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to execute installer step '{StepName}'", step.Name); + notificationService.ShowError( + "Installation Step Error", + $"Error executing '{step.Name}': {ex.Message}"); + + return OperationResult.CreateFailure($"Execution of step '{step.Name}' failed: {ex.Message}"); + } + } + + private OperationResult ExecuteRemoveFile(InstallationStep step, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for remove file step '{step.Name}'."); + } + + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + var targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); + + if (!PathHelper.IsPathWithinDirectory(workingDirectory, targetFullPath)) + { + logger.LogError("Target remove path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Target file '{step.TargetRelativePath}' escapes the working directory."); + } + + try + { + if (File.Exists(targetFullPath)) + { + File.Delete(targetFullPath); + logger.LogInformation("Deleted file '{Target}' as part of step '{StepName}'", step.TargetRelativePath, step.Name); + } + else + { + logger.LogDebug("File '{Target}' already absent during remove step '{StepName}'", step.TargetRelativePath, step.Name); + } + + return OperationResult.CreateSuccess(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete file '{Target}' in step '{StepName}'", step.TargetRelativePath, step.Name); + return OperationResult.CreateFailure($"Failed to delete file '{step.TargetRelativePath}': {ex.Message}"); + } + } + + private OperationResult ExecuteRenameFile(InstallationStep step, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for rename step '{step.Name}'."); + } + + if (string.IsNullOrWhiteSpace(step.DestinationRelativePath)) + { + return OperationResult.CreateFailure($"Destination relative path is required for rename step '{step.Name}'."); + } + + var normalizedSourcePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + var normalizedDestPath = PathHelper.NormalizeRelativePath(step.DestinationRelativePath); + + var sourceFullPath = Path.Combine(workingDirectory, normalizedSourcePath); + var destFullPath = Path.Combine(workingDirectory, normalizedDestPath); + + if (!PathHelper.IsPathWithinDirectory(workingDirectory, sourceFullPath)) + { + logger.LogError("Source path '{Source}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Source path '{step.TargetRelativePath}' escapes the working directory."); + } + + if (!PathHelper.IsPathWithinDirectory(workingDirectory, destFullPath)) + { + logger.LogError("Destination path '{Dest}' escapes working directory '{Dir}'", step.DestinationRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Destination path '{step.DestinationRelativePath}' escapes the working directory."); + } + + try + { + if (File.Exists(sourceFullPath)) + { + var destDir = Path.GetDirectoryName(destFullPath); + if (!string.IsNullOrEmpty(destDir)) + { + Directory.CreateDirectory(destDir); + } + + File.Move(sourceFullPath, destFullPath, overwrite: true); + logger.LogInformation( + "Renamed '{Source}' to '{Dest}' in step '{StepName}'", + step.TargetRelativePath, + step.DestinationRelativePath, + step.Name); + } + else + { + logger.LogWarning("Source file '{Source}' does not exist for rename step '{StepName}'", step.TargetRelativePath, step.Name); + } + + return OperationResult.CreateSuccess(); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to rename '{Source}' to '{Dest}' in step '{StepName}'", + step.TargetRelativePath, + step.DestinationRelativePath, + step.Name); + + return OperationResult.CreateFailure( + $"Failed to rename '{step.TargetRelativePath}' to '{step.DestinationRelativePath}': {ex.Message}"); + } + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs b/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs index 411041ef5..0d62e14ec 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; -using GenHub.Core.Constants; using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using Microsoft.Extensions.Logging; @@ -28,14 +28,30 @@ public class PublisherManifestFactoryResolver(IEnumerable().FirstOrDefault(); + if (fallbackFactory != null) + { + logger.LogInformation( + "Resolved fallback {FactoryType} for manifest {ManifestId} (Publisher: {Publisher}, ContentType: {ContentType})", + fallbackFactory.GetType().Name, + manifest.Id, + manifest.Publisher?.PublisherType ?? "unknown", + manifest.ContentType); + return fallbackFactory; + } + } + logger.LogWarning( "No factory found for manifest {ManifestId} (Publisher: {Publisher}, ContentType: {ContentType})", manifest.Id, - manifest.Publisher?.PublisherType ?? GameClientConstants.UnknownVersion, + manifest.Publisher?.PublisherType ?? "unknown", manifest.ContentType); return null; diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs index b7bbf93a2..d653e90f4 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs @@ -28,8 +28,9 @@ public class SuperHackersProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, - ILogger logger) - : BaseContentProvider(contentValidator, logger) + ILogger logger, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentResolver _resolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(SuperHackersConstants.ResolverId, StringComparison.OrdinalIgnoreCase) == true) @@ -71,57 +72,98 @@ public override async Task>> Se { try { + cancellationToken.ThrowIfCancellationRequested(); var results = new List(); + var errors = new List(); - // Directly fetch latest release from TheSuperHackers/GeneralsGameCode - var latestRelease = await gitHubApiClient.GetLatestReleaseAsync( - SuperHackersConstants.GeneralsGameCodeOwner, - SuperHackersConstants.GeneralsGameCodeRepo, - cancellationToken); + var targets = new (string Owner, string Repo, ContentType ContentType, GameType? TargetGame, string DisplayName)[] + { + (SuperHackersConstants.GeneralsGameCodeOwner, SuperHackersConstants.GeneralsGameCodeRepo, ContentType.GameClient, GameType.Generals, SuperHackersConstants.PublisherName), + (SuperHackersConstants.GeneralsGamePatch2Owner, SuperHackersConstants.GeneralsGamePatch2Repo, ContentType.Patch, null, SuperHackersConstants.GeneralsGamePatch2DisplayName), + }; - if (latestRelease != null && - (string.IsNullOrWhiteSpace(query.AuthorName) || - query.AuthorName.Equals(SuperHackersConstants.GeneralsGameCodeOwner, StringComparison.OrdinalIgnoreCase)) && - (string.IsNullOrWhiteSpace(query.SearchTerm) || - latestRelease.Name?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true || - SuperHackersConstants.GeneralsGameCodeRepo.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase))) + var matchingTargets = targets.Where(t => + (!query.ContentType.HasValue || query.ContentType.Value == t.ContentType) && + (!query.TargetGame.HasValue || t.TargetGame == null || query.TargetGame.Value == t.TargetGame.Value) && + (string.IsNullOrWhiteSpace(query.AuthorName) || query.AuthorName.Equals(t.Owner, StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrWhiteSpace(query.GitHubAuthor) || query.GitHubAuthor.Equals(t.Owner, StringComparison.OrdinalIgnoreCase))).ToList(); + + foreach (var (owner, repo, contentType, targetGame, displayName) in matchingTargets) { - // Generate manifest ID - var manifestId = ManifestIdGenerator.GenerateGitHubContentId( - SuperHackersConstants.GeneralsGameCodeOwner, - SuperHackersConstants.GeneralsGameCodeRepo, - ContentType.GameClient, - latestRelease.TagName); - - var result = new ContentSearchResult + try { - Id = manifestId, - Name = latestRelease.Name ?? $"{SuperHackersConstants.PublisherName} {latestRelease.TagName}", - Description = latestRelease.Body ?? "SuperHackers release - details available after resolution", - Version = latestRelease.TagName ?? "latest", - AuthorName = SuperHackersConstants.GeneralsGameCodeOwner, - ContentType = ContentType.GameClient, - TargetGame = GameType.Generals, // Simplification, could infer - IsInferred = false, - ProviderName = SourceName, - RequiresResolution = true, - ResolverId = SuperHackersConstants.ResolverId, - SourceUrl = latestRelease.HtmlUrl, - LastUpdated = latestRelease.PublishedAt?.DateTime ?? latestRelease.CreatedAt.DateTime, - ResolverMetadata = + cancellationToken.ThrowIfCancellationRequested(); + + var latestRelease = await gitHubApiClient.GetLatestReleaseAsync( + owner, + repo, + cancellationToken); + + if (latestRelease != null && + (string.IsNullOrWhiteSpace(query.SearchTerm) || + latestRelease.Name?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true || + repo.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) || + displayName.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) || + latestRelease.Body?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true)) { - [GitHubConstants.OwnerMetadataKey] = SuperHackersConstants.GeneralsGameCodeOwner, - [GitHubConstants.RepoMetadataKey] = SuperHackersConstants.GeneralsGameCodeRepo, - [GitHubConstants.TagMetadataKey] = latestRelease.TagName ?? "latest", - }, - }; - - result.SetData(latestRelease); - results.Add(result); + var manifestId = ManifestIdGenerator.GenerateGitHubContentId( + owner, + repo, + contentType, + latestRelease.TagName); + + var resolvedTargetGame = targetGame ?? query.TargetGame ?? GameType.Unknown; + + var result = new ContentSearchResult + { + Id = manifestId, + Name = !string.IsNullOrWhiteSpace(latestRelease.Name) ? latestRelease.Name : $"{displayName} {latestRelease.TagName}", + Description = latestRelease.Body ?? "SuperHackers release - details available after resolution", + Version = latestRelease.TagName ?? "latest", + AuthorName = owner, + ContentType = contentType, + TargetGame = resolvedTargetGame, + IsInferred = false, + ProviderName = SourceName, + RequiresResolution = true, + ResolverId = SuperHackersConstants.ResolverId, + SourceUrl = latestRelease.HtmlUrl, + LastUpdated = latestRelease.PublishedAt?.DateTime ?? latestRelease.CreatedAt.DateTime, + ResolverMetadata = + { + [GitHubConstants.OwnerMetadataKey] = owner, + [GitHubConstants.RepoMetadataKey] = repo, + [GitHubConstants.TagMetadataKey] = latestRelease.TagName ?? "latest", + }, + }; + + result.SetData(latestRelease); + results.Add(result); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to fetch SuperHackers release for {Owner}/{Repo}", owner, repo); + errors.Add($"{owner}/{repo}: {ex.Message}"); + } + } + + if (results.Count == 0 && errors.Count > 0) + { + return OperationResult>.CreateFailure( + $"Search failed for SuperHackers targets: {string.Join("; ", errors)}"); } return OperationResult>.CreateSuccess(results); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { Logger.LogError(ex, "Failed to search SuperHackers content"); diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index b62d5aeef..766532f76 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -84,11 +84,16 @@ public async Task> StartProcessAsync(GameLaunch process = startResult.Data; logger.LogDebug("[Process] Process {ProcessId} started successfully", process.Id); + // Read while the launcher is still alive: a Unix process that has exited can no longer + // report its start time, and that time is the only thing separating the child this + // launch spawned from an instance of the same game the user already had running. + var launcherStartTime = ReadStartTime(process); + var capturedErrors = SetupErrorRedirection(process); if (!string.IsNullOrWhiteSpace(configuration.ExpectedChildProcessName)) { - return await AdoptExpectedChildProcessAsync(process, configuration, workingDirectory, capturedErrors, cancellationToken); + return await AdoptExpectedChildProcessAsync(process, configuration, workingDirectory, launcherStartTime, capturedErrors, cancellationToken); } if (!isBatchFile) @@ -97,7 +102,7 @@ public async Task> StartProcessAsync(GameLaunch if (process.HasExited) { - return await HandleImmediateProcessExitAsync(process, configuration, capturedErrors); + return HandleImmediateProcessExit(process, configuration, launcherStartTime, capturedErrors); } } @@ -568,6 +573,24 @@ private static bool HasExecutePermission(string path) } } + /// + /// Reads a process's start time in UTC, or reports that it could not be read. + /// + /// The process to inspect. + /// The start time, or when the platform will not report it. + private DateTime? ReadStartTime(Process process) + { + try + { + return process.StartTime.ToUniversalTime(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "[Process] Unable to inspect start time for process {ProcessId}", process.Id); + return null; + } + } + private OperationResult ValidateLaunchConfiguration(GameLaunchConfiguration? configuration) { if (configuration == null) @@ -767,14 +790,19 @@ private ProcessStartInfo ConfigureProcessStartInfo(GameLaunchConfiguration confi return processStartInfo; } - private async Task> HandleImmediateProcessExitAsync( + private OperationResult HandleImmediateProcessExit( Process process, GameLaunchConfiguration configuration, + DateTime? launcherStartTime, BoundedErrorBuffer capturedErrors) { var exitCode = process.ExitCode; - if (exitCode == 0 && OperatingSystem.IsWindows()) + // Adoption is not gated on Windows: a Wine or Proton wrapper forks and exits the same way, + // and adoption only accepts a candidate that carries the name, started at or after this + // launcher, is inside the recency window, and runs from the workspace directory. If the + // engine really did exit, nothing satisfies that and the launch still fails loudly. + if (exitCode == ProcessConstants.ExitCodeSuccess) { logger.LogInformation( "[Process] Launcher process {ProcessId} exited with code 0 - attempting to find spawned game process", @@ -784,17 +812,7 @@ private async Task> HandleImmediateProcessExitA ? configuration.ExpectedChildProcessName : Path.GetFileNameWithoutExtension(configuration.ExecutablePath); - DateTime? launcherStartTime = null; - try - { - launcherStartTime = process.StartTime.ToUniversalTime(); - } - catch (Exception ex) - { - logger.LogDebug(ex, "[Process] Unable to inspect start time for exiting launcher {ProcessId}", process.Id); - } - - var spawnedProcess = FindSpawnedGameProcess( + var spawnedProcess = FindAdoptableGameProcess( executableName, configuration.WorkingDirectory ?? Path.GetDirectoryName(configuration.ExecutablePath)!, launcherStartTime); @@ -899,6 +917,7 @@ private void OnProcessExited(object? sender, EventArgs e) /// The process that was started. /// The launch configuration. /// The directory the game must run from. + /// The launcher's start time, read while it was still running. /// /// The launcher's captured stderr, quoted in the failure messages so a bootstrapper /// that refuses to start the game can say why. @@ -909,6 +928,7 @@ private async Task> AdoptExpectedChildProcessAs Process launcher, GameLaunchConfiguration configuration, string workingDirectory, + DateTime? launcherStartTime, BoundedErrorBuffer capturedErrors, CancellationToken cancellationToken) { @@ -919,27 +939,37 @@ private async Task> AdoptExpectedChildProcessAs var gracePeriod = TimeSpan.FromMilliseconds(ProcessConstants.LauncherExitGracePeriodMs); DateTime? launcherExitedAt = null; - logger.LogInformation( - "[Process] Waiting up to {TimeoutMs}ms for launcher {LauncherId} to start {ExpectedName}", - (int)timeout.TotalMilliseconds, - launcher.Id, - expectedName); - - DateTime? launcherStartTime = null; try { - launcherStartTime = launcher.StartTime.ToUniversalTime(); - } - catch (Exception ex) - { - logger.LogDebug(ex, "[Process] Unable to inspect start time for launcher {ProcessId}", launcher.Id); - } + // Adoption requires the launcher's start time to rule out an instance of the game the + // user already had running, so without it no candidate can ever qualify. Polling that + // out would repeat the refusal once per interval and then report a discovery timeout, + // which describes a launcher that was never given the chance to fail. + if (!launcherStartTime.HasValue) + { + logger.LogError( + "[Process] Not waiting for {ExpectedName}: the launcher's start time is unknown, so a process that predates this launch cannot be ruled out", + expectedName); + + await TerminateAbandonedLauncherAsync(launcher); + + // Terminated first, so the launcher has exited and its stderr drains in full. + return OperationResult.CreateFailure( + AppendLauncherErrors( + $"Launcher exited without starting {expectedName}: the launcher's start time could not be read.", + launcher, + capturedErrors)); + } + + logger.LogInformation( + "[Process] Waiting up to {TimeoutMs}ms for launcher {LauncherId} to start {ExpectedName}", + (int)timeout.TotalMilliseconds, + launcher.Id, + expectedName); - try - { while (true) { - var child = FindSpawnedGameProcess(expectedName, workingDirectory, launcherStartTime); + var child = FindAdoptableGameProcess(expectedName, workingDirectory, launcherStartTime); if (child != null) { _managedProcesses[child.Id] = child; @@ -1122,19 +1152,54 @@ private GameProcessInfo BuildProcessInfo(Process process, string fallbackExecuta } /// - /// Finds a spawned game process by executable name and working directory. - /// Used when a launcher executable spawns the actual game and exits. + /// Finds a game process by executable name and working directory, without a launcher to bound + /// the search. Used when discovering a game a storefront started on our behalf. + /// + /// The base executable name without extension. + /// The expected working directory. + /// The discovered process if found, null otherwise. + private Process? FindSpawnedGameProcess(string executableName, string workingDirectory) => + FindGameProcess( + executableName, + candidates => GameProcessSelector.SelectSpawnedGameProcess( + candidates, executableName, workingDirectory, DateTime.UtcNow)); + + /// + /// Finds the process a launcher spawned, to be tracked and terminated in the launcher's place. /// /// The base executable name without extension. /// The expected working directory. /// The start time of the launcher process, if known. - /// The spawned process if found, null otherwise. - private Process? FindSpawnedGameProcess(string executableName, string workingDirectory, DateTime? launcherStartTime = null) + /// The process to adopt if one qualifies, null otherwise. + private Process? FindAdoptableGameProcess(string executableName, string workingDirectory, DateTime? launcherStartTime) + { + if (!launcherStartTime.HasValue) + { + logger.LogWarning( + "[Process] Not adopting a running {ExecutableName}: the launcher's start time is unknown, so a process that predates this launch cannot be ruled out", + executableName); + return null; + } + + return FindGameProcess( + executableName, + candidates => GameProcessSelector.SelectAdoptableGameProcess( + candidates, executableName, workingDirectory, launcherStartTime.Value.ToUniversalTime())); + } + + /// + /// Enumerates the processes that could carry and hands them + /// to a selection policy. + /// + /// The base executable name without extension. + /// The policy deciding which candidate, if any, is ours. + /// The selected process if found, null otherwise. + private Process? FindGameProcess(string executableName, Func, GameProcessCandidate?> select) { Process[] processes = []; try { - processes = Process.GetProcessesByName(executableName); + processes = Process.GetProcessesByName(GameProcessSelector.GetDiscoveryName(executableName)); } catch (Exception ex) { @@ -1163,8 +1228,7 @@ private GameProcessInfo BuildProcessInfo(Process process, string fallbackExecuta } } - var selected = GameProcessSelector.SelectSpawnedGameProcess( - candidates, executableName, workingDirectory, DateTime.UtcNow, launcherStartTime?.ToUniversalTime()); + var selected = select(candidates); if (selected == null) { diff --git a/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs b/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs index 7cffd1500..57a8ddc43 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs @@ -23,6 +23,72 @@ public class DependencyResolver( private readonly IContentManifestPool _manifestPool = manifestPool ?? throw new ArgumentNullException(nameof(manifestPool)); private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + /// + /// Matches a declared catalog ID to an acquired manifest ID allowing version and variant differences. + /// + /// The declared catalog ID. + /// The acquired manifest ID. + /// if identities are compatible; otherwise, . + public static bool HasCompatibleCatalogIdentity(string? declaredId, string? acquiredId) + { + if (string.IsNullOrWhiteSpace(declaredId) || string.IsNullOrWhiteSpace(acquiredId)) + { + return false; + } + + if (string.Equals(declaredId, acquiredId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var declaredParts = declaredId.Split('.'); + var acquiredParts = acquiredId.Split('.'); + + return HasCompatibleCatalogIdentity(declaredParts, acquiredParts); + } + + /// + /// Matches a declared 5-segment catalog ID (schemaVersion.userVersion.publisher.contentType.contentName) + /// to an acquired manifest ID. Requires schemaVersion (segment 0), publisher (segment 2, or wildcard any), + /// and contentType (segment 3) to match, while allowing userVersion (segment 1) and trailing variant labels + /// (e.g. -720p on contentName segment 4) to differ. + /// + /// The 5 segments of the declared catalog ID. + /// The 5 segments of the acquired manifest ID. + /// if identities are compatible; otherwise, . + public static bool HasCompatibleCatalogIdentity(string[] declaredParts, string[] acquiredParts) + { + if (declaredParts.Length != ManifestConstants.MinManifestSegments || acquiredParts.Length != ManifestConstants.MinManifestSegments) + { + return false; + } + + if (!declaredParts[0].Equals(acquiredParts[0], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var isAnyPublisher = declaredParts[2].Equals(ManifestConstants.AnyPublisherToken, StringComparison.OrdinalIgnoreCase); + if (!isAnyPublisher && !declaredParts[2].Equals(acquiredParts[2], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!declaredParts[3].Equals(acquiredParts[3], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var declaredName = declaredParts[4]; + var acquiredName = acquiredParts[4]; + if (declaredName.Equals(acquiredName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return acquiredName.StartsWith(declaredName + ManifestConstants.VariantSeparator, StringComparison.OrdinalIgnoreCase); + } + /// public async Task> ResolveDependenciesAsync(IEnumerable contentIds, CancellationToken cancellationToken = default) { diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs index 2ffca7e4c..51e951e73 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs @@ -451,99 +451,25 @@ private async Task> LaunchToolProfileAsyn { logger.LogInformation("[Launch] Detected Tool profile, launching tool directly"); - // Get the tool manifest - if (string.IsNullOrWhiteSpace(profile.ToolContentId)) - { - return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId); - } - - if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId)) - { - return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}"); - } - - var toolManifestResult = await manifestPool.GetManifestAsync( - toolManifestId, - cancellationToken); - - if (toolManifestResult.Failed || toolManifestResult.Data == null) + var manifestResult = await ResolveToolManifestAsync(profile, cancellationToken); + if (manifestResult.Failed || manifestResult.Data == null) { return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}"); + manifestResult.FirstError ?? ProfileValidationConstants.FailedToLoadToolManifest); } - var toolManifest = toolManifestResult.Data; + var toolManifest = manifestResult.Data; logger.LogDebug("[Launch] Tool manifest loaded: {ManifestId}", toolManifest.Id); - var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken); - string toolWorkspacePath = string.Empty; - string? actualWorkspaceId = null; - - if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data)) - { - toolWorkspacePath = toolDirectory.Data; - logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolWorkspacePath); - } - else + var workspaceResult = await ResolveToolWorkspaceAsync(profile, toolManifest, cancellationToken); + if (workspaceResult.Failed) { - logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager"); - - var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient - { - Name = toolManifest.Name, - GameType = toolManifest.TargetGame, - }; - - var appDataBase = configurationProvider.GetApplicationDataPath(); - if (!Directory.Exists(appDataBase)) - { - Directory.CreateDirectory(appDataBase); - } - - var baseDetails = appDataBase; - - var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken); - var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest]; - - var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy(); - var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy); - - if (effectiveToolStrategy != requestedToolStrategy) - { - logger.LogInformation( - "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment", - requestedToolStrategy); - } - - actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}"; - var workspaceConfig = new WorkspaceConfiguration - { - Id = actualWorkspaceId, - Manifests = [.. allManifests], - GameClient = dummyGameClient, - Strategy = effectiveToolStrategy, - ForceRecreate = false, - ValidateAfterPreparation = true, - BaseInstallationPath = baseDetails, - WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces), - SkipCleanup = false, - }; - - var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken); - if (prepareResult.Failed) - { - return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}"); - } - - toolWorkspacePath = prepareResult.Data!.WorkspacePath; - logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath); + return ProfileOperationResult.CreateFailure( + workspaceResult.FirstError ?? ProfileValidationConstants.FailedToPrepareToolWorkspace); } - var toolDirectoryPath = toolWorkspacePath; - var toolExecutable = toolManifest.Files?.FirstOrDefault(f => f.IsExecutable) - ?? toolManifest.Files?.FirstOrDefault(f => f.RelativePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + var (toolDirectoryPath, actualWorkspaceId) = workspaceResult.Data; + var toolExecutable = ResolveToolExecutable(toolManifest); if (toolExecutable == null) { @@ -564,35 +490,7 @@ private async Task> LaunchToolProfileAsyn try { - var processStartInfo = new ProcessStartInfo - { - FileName = toolExecutablePath, - WorkingDirectory = toolDirectoryPath, - Arguments = profile.CommandLineArguments ?? string.Empty, - UseShellExecute = false, - }; - - if (profile.EnvironmentVariables != null) - { - foreach (var envVar in profile.EnvironmentVariables) - { - processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value; - } - } - - Process? process = null; - try - { - process = Process.Start(processStartInfo); - } - catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740) - { - logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored."); - processStartInfo.UseShellExecute = true; - processStartInfo.Verb = "runas"; - process = Process.Start(processStartInfo); - } - + var process = StartToolProcess(toolExecutablePath, toolDirectoryPath, profile); if (process == null) { return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProcessStartFailed); @@ -631,12 +529,181 @@ private async Task> LaunchToolProfileAsyn } catch (Exception ex) { - logger.LogError(ex, "[Launch] Tool launch failed"); + logger.LogError(ex, "[Launch] Unexpected error launching tool for profile {ProfileId}", profileId); notificationService.ShowError( ProfileValidationConstants.ToolLaunchFailedTitle, $"Failed to launch '{profile.Name}': {ex.Message}", NotificationDurations.VeryLong); - return ProfileOperationResult.CreateFailure($"Tool launch failed: {ex.Message}"); + return ProfileOperationResult.CreateFailure( + $"Tool launch failed: {ex.Message}"); + } + } + + private async Task> ResolveToolManifestAsync( + GameProfile profile, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(profile.ToolContentId)) + { + return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId); + } + + if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId)) + { + return ProfileOperationResult.CreateFailure( + $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}"); + } + + var toolManifestResult = await manifestPool.GetManifestAsync( + toolManifestId, + cancellationToken); + + if (toolManifestResult.Failed || toolManifestResult.Data == null) + { + return ProfileOperationResult.CreateFailure( + $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}"); + } + + return ProfileOperationResult.CreateSuccess(toolManifestResult.Data); + } + + private async Task> ResolveToolWorkspaceAsync( + GameProfile profile, + ContentManifest toolManifest, + CancellationToken cancellationToken) + { + var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken); + if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data)) + { + logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolDirectory.Data); + return ProfileOperationResult<(string, string?)>.CreateSuccess((toolDirectory.Data, null)); + } + + logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager"); + + var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient + { + Name = toolManifest.Name, + GameType = toolManifest.TargetGame, + }; + + var appDataBase = configurationProvider.GetApplicationDataPath(); + if (!Directory.Exists(appDataBase)) + { + Directory.CreateDirectory(appDataBase); + } + + var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken); + var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest]; + + var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy(); + var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy); + + if (effectiveToolStrategy != requestedToolStrategy) + { + logger.LogInformation( + "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment", + requestedToolStrategy); + } + + var actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}"; + var workspaceConfig = new WorkspaceConfiguration + { + Id = actualWorkspaceId, + Manifests = [.. allManifests], + GameClient = dummyGameClient, + Strategy = effectiveToolStrategy, + ForceRecreate = false, + ValidateAfterPreparation = true, + BaseInstallationPath = appDataBase, + WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces), + SkipCleanup = false, + }; + + var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken); + if (prepareResult.Failed) + { + return ProfileOperationResult<(string, string?)>.CreateFailure( + $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}"); + } + + var toolWorkspacePath = prepareResult.Data!.WorkspacePath; + logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath); + return ProfileOperationResult<(string, string?)>.CreateSuccess((toolWorkspacePath, actualWorkspaceId)); + } + + private ManifestFile? ResolveToolExecutable(ContentManifest toolManifest) + { + var resolvedFiles = ManifestVariantResolver.ResolveFiles(toolManifest); + var resolution = ManifestVariantResolver.ResolveEntryPoint(toolManifest); + + if (resolution.Success && resolution.RelativePath != null) + { + var toolExecutable = resolvedFiles?.FirstOrDefault(f => + ManifestVariantResolver.PathsMatch(f.RelativePath, resolution.RelativePath)); + + if (toolExecutable != null) + { + logger.LogInformation( + "[Launch] Tool executable resolved for manifest {ManifestId}: {RelativePath} ({Reason})", + toolManifest.Id, + toolExecutable.RelativePath, + resolution.Reason); + } + else + { + logger.LogWarning( + "[Launch] Entry point '{RelativePath}' resolved for tool manifest {ManifestId} ({Reason}) but not found in resolved files", + resolution.RelativePath, + toolManifest.Id, + resolution.Reason); + } + + return toolExecutable; + } + + logger.LogWarning( + "[Launch] Entry point resolution for tool manifest '{ManifestId}' did not succeed: {Resolution}", + toolManifest.Id, + resolution); + + return null; + } + + private Process? StartToolProcess(string toolExecutablePath, string toolDirectoryPath, GameProfile profile) + { + var processStartInfo = new ProcessStartInfo + { + FileName = toolExecutablePath, + WorkingDirectory = toolDirectoryPath, + Arguments = profile.CommandLineArguments ?? string.Empty, + UseShellExecute = false, + }; + + if (profile.EnvironmentVariables != null) + { + foreach (var envVar in profile.EnvironmentVariables) + { + processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value; + } + } + + try + { + return Process.Start(processStartInfo); + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740) + { + logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored."); + var elevatedStartInfo = new ProcessStartInfo + { + FileName = toolExecutablePath, + WorkingDirectory = toolDirectoryPath, + Arguments = profile.CommandLineArguments ?? string.Empty, + UseShellExecute = true, + Verb = "runas", + }; + return Process.Start(elevatedStartInfo); } } @@ -816,7 +883,7 @@ private async Task> ReconcilePublisherClient } else { - if (IsGeneralsOnlineProfile(profile)) + if (profile.IsGeneralsOnlineProfile()) { publisherType = PublisherTypeConstants.GeneralsOnline; reconciler = reconcilerRegistry.GetReconciler(publisherType); @@ -1169,36 +1236,6 @@ private string BuildVersionRequirementString(ContentDependency dependency) return parts.Count > 0 ? $"({string.Join(" and ", parts)})" : string.Empty; } - /// - /// Checks if a profile uses a GeneralsOnline game client. - /// - /// The profile to check. - /// True if the profile uses GeneralsOnline, false otherwise. - private bool IsGeneralsOnlineProfile(GameProfile profile) - { - // Check PublisherType first - if (profile.GameClient?.PublisherType?.Equals( - PublisherTypeConstants.GeneralsOnline, - StringComparison.OrdinalIgnoreCase) == true) - { - return true; - } - - // Check if Name contains "GeneralsOnline" (for legacy or incomplete profiles) - if (profile.GameClient?.Name?.Contains("GeneralsOnline", StringComparison.OrdinalIgnoreCase) == true) - { - return true; - } - - // Final fallback: Check enabled content for GeneralsOnline manifests - if (profile.EnabledContentIds?.Any(id => id.Contains("generalsonline", StringComparison.OrdinalIgnoreCase)) == true) - { - return true; - } - - return false; - } - /// /// Checks if a profile uses a SuperHackers game client. /// diff --git a/GenHub/GenHub/Features/GameProfiles/Services/SetupWizardService.cs b/GenHub/GenHub/Features/GameProfiles/Services/SetupWizardService.cs index 93d6a3ca7..5ad4510fc 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/SetupWizardService.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/SetupWizardService.cs @@ -74,7 +74,7 @@ public async Task RunSetupWizardAsync(IEnumerable x.Client != null && string.Equals((string)x.Client.Version, latestVersion, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(x => x.Client != null && string.Equals(CleanVersionString((string)x.Client.Version), latestVersion, StringComparison.OrdinalIgnoreCase)); if (upToDateManaged != null) { @@ -118,7 +118,7 @@ public async Task RunSetupWizardAsync(IEnumerable RunSetupWizardAsync(IEnumerable RunSetupWizardAsync(IEnumerable GetLatestVersionAsync(string publisher) { try diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs index 55a28b999..386f01d9b 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs @@ -12,6 +12,8 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.ViewModels; @@ -29,7 +31,7 @@ public partial class AddLocalContentViewModel( IContentStorageService? contentStorageService, IGenLauncherNormalizationService? genLauncherNormalizationService, IDialogService? dialogService, - ILogger? logger = null) : ObservableObject + ILogger? logger = null) : ObservableObject, IDisposable { /// /// Gets the list of available game types. @@ -56,6 +58,26 @@ public partial class AddLocalContentViewModel( ContentType.Mission, ]; + /// + /// Counts the total number of executables in the given file tree items recursively. + /// + /// The file tree items to inspect. + /// The total number of executable files found. + internal static int CountExecutables(IEnumerable items) + { + int count = 0; + foreach (var item in items) + { + if (item.IsExecutable) count++; + count += CountExecutables(item.Children); + } + + return count; + } + + private static bool RequiresExecutable(ContentType contentType) => + contentType is ContentType.GameClient or ContentType.ModdingTool or ContentType.Executable; + private static FileTreeItem? FindFirstExecutable(IEnumerable items) { foreach (var item in items) @@ -75,21 +97,10 @@ public partial class AddLocalContentViewModel( return null; } - private static int CountExecutables(IEnumerable items) - { - int count = 0; - foreach (var item in items) - { - if (item.IsExecutable) count++; - count += CountExecutables(item.Children); - } - - return count; - } - private readonly string _stagingPath = Path.Combine(Path.GetTempPath(), "GenHub_Staging_" + Guid.NewGuid()); private string? _originalManifestId; + private string? _pendingEntryPoint; /// /// Gets a value indicating whether we are editing existing content. @@ -177,7 +188,7 @@ private static int CountExecutables(IEnumerable items) private bool _isDemoMode; /// - /// Gets or sets the selected executable item (for Executable/ModdingTool content type). + /// Gets or sets the selected executable item (for GameClient/Executable/ModdingTool content type). /// [ObservableProperty] private FileTreeItem? _selectedExecutableItem; @@ -192,7 +203,7 @@ private static int CountExecutables(IEnumerable items) /// /// Gets a value indicating whether the executable selection should be shown. /// - public bool ShowExecutableSelection => (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) && ExecutableCount > 1; + public bool ShowExecutableSelection => RequiresExecutable(SelectedContentType) && ExecutableCount > 0; /// /// Gets the text to display in the preview area when no content is loaded. @@ -255,6 +266,7 @@ public async Task LoadFromManifestAsync(ContentDisplayItem item) StatusMessage = "Loading existing content..."; _originalManifestId = item.ManifestId.Value; + _pendingEntryPoint = item.Manifest?.EntryPoint; ContentName = item.DisplayName ?? string.Empty; SelectedContentType = item.ContentType; SelectedGameType = item.GameType; @@ -487,24 +499,81 @@ public async Task ImportContentAsync(string path) } } + /// + public void Dispose() + { + _cts?.Dispose(); + _cts = null; + CleanupStaging(); + GC.SuppressFinalize(this); + } + private static List BuildDirectoryTree(DirectoryInfo dir) + => BuildDirectoryTree(dir, CollectExecutableDirectories(dir)); + + private static HashSet CollectExecutableDirectories(DirectoryInfo root) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var file in root.EnumerateFiles("*", SearchOption.AllDirectories)) + { + if (!ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(file.Name) + && !file.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + for (var d = file.Directory; d != null; d = d.Parent) + { + if (!result.Add(d.FullName)) + { + break; + } + } + } + } + catch + { + // ignore inaccessible directories + } + + return result; + } + + private static List BuildDirectoryTree(DirectoryInfo dir, HashSet executableDirs) { var items = new List(); - if (!dir.Exists) return items; + if (!dir.Exists) + { + return items; + } + + var subDirs = dir.GetDirectories(); + var prioritizedDirs = subDirs + .OrderByDescending(d => executableDirs.Contains(d.FullName)) + .ThenBy(d => d.Name) + .Take(20); - foreach (var d in dir.GetDirectories().Take(20)) + foreach (var d in prioritizedDirs) { items.Add(new FileTreeItem { Name = d.Name, IsFile = false, FullPath = d.FullName, - Children = new ObservableCollection(BuildDirectoryTree(d)), + Children = new ObservableCollection(BuildDirectoryTree(d, executableDirs)), }); } - foreach (var f in dir.GetFiles().Take(50)) + var files = dir.GetFiles(); + var prioritizedFiles = files + .OrderByDescending(f => ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.Name) || f.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + .ThenBy(f => f.Name) + .Take(50); + + foreach (var f in prioritizedFiles) { items.Add(new FileTreeItem { Name = f.Name, IsFile = true, FullPath = f.FullName }); } @@ -640,6 +709,20 @@ private async Task AddContentAsync() _cts = new CancellationTokenSource(); + string? entryPoint = null; + if (RequiresExecutable(SelectedContentType) && SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + entryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to determine relative path for selected executable '{FullPath}'. Falling back to file name '{Name}'", SelectedExecutableItem.FullPath, SelectedExecutableItem.Name); + entryPoint = SelectedExecutableItem.Name; + } + } + // Preserve SourcePath metadata if available // Note: We no longer write to "source.path" file to avoid polluting the content. // Instead we pass the SourcePath directly to the service. @@ -652,7 +735,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token) + _cts.Token, + entryPoint) : await localContentService.CreateLocalContentManifestAsync( _stagingPath, ContentName, @@ -660,7 +744,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token); + _cts.Token, + entryPoint); if (result.Success) { @@ -770,6 +855,29 @@ private void CreateMapFoldersIfNeeded() } } + private FileTreeItem? FindFileItemByRelativePath(IEnumerable items, string relativePath) + { + var normalizedTarget = relativePath.Replace('\\', '/').TrimStart('/'); + foreach (var item in items) + { + if (item.IsFile) + { + var itemRel = Path.GetRelativePath(_stagingPath, item.FullPath).Replace('\\', '/').TrimStart('/'); + if (ManifestVariantResolver.PathsMatch(itemRel, normalizedTarget)) + { + return item; + } + } + else + { + var found = FindFileItemByRelativePath(item.Children, relativePath); + if (found != null) return found; + } + } + + return null; + } + private async Task RefreshStagingTreeAsync() { bool wasBusy = IsBusy; @@ -777,6 +885,23 @@ private async Task RefreshStagingTreeAsync() { if (!wasBusy) IsBusy = true; + string? previousRelativePath = null; + if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + previousRelativePath = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch + { + // Ignore path calculation error + } + } + else if (!string.IsNullOrWhiteSpace(_pendingEntryPoint)) + { + previousRelativePath = _pendingEntryPoint; + } + FileTree.Clear(); SelectedExecutableItem = null; // Clear previous selection on refresh if (Directory.Exists(_stagingPath)) @@ -791,10 +916,29 @@ private async Task RefreshStagingTreeAsync() ExecutableCount = CountExecutables(FileTree); - // Auto-select first executable if content type requires it - if (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) + // Reselect previously selected executable or auto-select first if content type requires it + if (RequiresExecutable(SelectedContentType)) { - AutoSelectFirstExecutable(); + FileTreeItem? matchedItem = null; + if (!string.IsNullOrWhiteSpace(previousRelativePath)) + { + matchedItem = FindFileItemByRelativePath(FileTree, previousRelativePath); + } + + if (matchedItem != null && matchedItem.IsExecutable) + { + SelectedExecutableItem = matchedItem; + _pendingEntryPoint = null; + } + else + { + _pendingEntryPoint = null; + AutoSelectFirstExecutable(); + } + } + else + { + SelectedExecutableItem = null; } Validate(); @@ -816,8 +960,8 @@ private void Validate() var stagingExists = Directory.Exists(_stagingPath); var stagingHasEntries = stagingExists && Directory.EnumerateFileSystemEntries(_stagingPath).Any(); - // For ModdingTool (Tool) and Executable, we also need an executable selected - var requiresExecutable = SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable; + // For GameClient, ModdingTool (Tool), and Executable, we also need an executable selected + var requiresExecutable = RequiresExecutable(SelectedContentType); var hasExecutableIfNeeded = !requiresExecutable || SelectedExecutableItem != null; CanAdd = hasName && (hasFiles || stagingHasEntries) && hasExecutableIfNeeded; @@ -842,10 +986,44 @@ partial void OnSelectedContentTypeChanged(ContentType value) OnPropertyChanged(nameof(ShowExecutableSelection)); OnPropertyChanged(nameof(PreviewIdleText)); - // Auto-select first executable if switching to ModdingTool or Executable - if ((value == ContentType.ModdingTool || value == ContentType.Executable) && SelectedExecutableItem == null) + // Auto-select first executable if switching to a content type that requires it, + // or clear selection when switching to a non-executable content type + if (RequiresExecutable(value)) + { + if (SelectedExecutableItem == null) + { + FileTreeItem? matchedItem = null; + if (!string.IsNullOrWhiteSpace(_pendingEntryPoint)) + { + matchedItem = FindFileItemByRelativePath(FileTree, _pendingEntryPoint); + } + + if (matchedItem != null && matchedItem.IsExecutable) + { + SelectedExecutableItem = matchedItem; + _pendingEntryPoint = null; + } + else + { + AutoSelectFirstExecutable(); + } + } + } + else { - AutoSelectFirstExecutable(); + if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + _pendingEntryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch + { + // Ignore path calculation error + } + } + + SelectedExecutableItem = null; } Validate(); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs index afbd8961b..a22b52d3d 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs @@ -147,6 +147,7 @@ private void InitializeDemoData() }; FileTree.Add(modFolder); + ExecutableCount = CountExecutables(FileTree); // Set status message StatusMessage = "Demo content ready. Click buttons to see what they do!"; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs index 20b12e965..81052c517 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs @@ -14,17 +14,23 @@ public partial class FileTreeItem : ObservableObject /// /// Gets or sets the name of the file or directory. /// - public string Name { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private string _name = string.Empty; /// /// Gets or sets a value indicating whether this item is a file. /// - public bool IsFile { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private bool _isFile; /// /// Gets or sets the full path of the file or directory. /// - public string FullPath { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private string _fullPath = string.Empty; /// /// Gets or sets the children of this item (for directories). diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs index 0931ffe2f..2dcd4b0d7 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs @@ -63,6 +63,8 @@ public partial class GameProfileLauncherViewModel( private readonly SemaphoreSlim _launchSemaphore = new(1, 1); private readonly System.Timers.Timer _headerCollapseTimer = new(TimeIntervals.HeaderCollapseDelayMs); private readonly System.Timers.Timer _headerExpansionTimer = new(TimeIntervals.HeaderExpansionDelayMs); + private bool _isHovering; + private bool _isTimersConfigured; private bool _lastOperationSuccess; private string? _expectedProfileIdForSuccess; private bool _isCreatingNewProfile; @@ -118,31 +120,39 @@ partial void OnSelectedProfileChanged(GameProfileItemViewModel? value) /// A representing the asynchronous operation. public virtual async Task InitializeAsync() { - // Reset header state on initialization/activation - ResetHeaderState(); + // On app launch, the header is expanded and persists without auto-collapsing + IsHeaderExpanded = true; + _isHovering = false; try { - // Set up timer - _headerCollapseTimer.AutoReset = false; - _headerCollapseTimer.Elapsed += (s, e) => - Avalonia.Threading.Dispatcher.UIThread.Invoke(() => IsHeaderExpanded = false); - - _headerCollapseTimer.Start(); - - // Set up expansion timer - _headerExpansionTimer.AutoReset = false; - _headerExpansionTimer.Elapsed += (s, e) => - Avalonia.Threading.Dispatcher.UIThread.Invoke(() => - { - IsHeaderExpanded = true; - _isHovering = true; + if (!_isTimersConfigured) + { + _isTimersConfigured = true; - // Stop collapse timer just in case - _headerCollapseTimer.Stop(); - }); + // Set up timer + _headerCollapseTimer.AutoReset = false; + _headerCollapseTimer.Elapsed += (s, e) => + Avalonia.Threading.Dispatcher.UIThread.Invoke(() => + { + if (!_isHovering && !IsScanning) + { + IsHeaderExpanded = false; + } + }); + + // Set up expansion timer + _headerExpansionTimer.AutoReset = false; + _headerExpansionTimer.Elapsed += (s, e) => + Avalonia.Threading.Dispatcher.UIThread.Invoke(() => + { + IsHeaderExpanded = true; + _isHovering = true; + _headerCollapseTimer.Stop(); + }); - gameProcessManager.ProcessExited += OnProcessExited; + gameProcessManager.ProcessExited += OnProcessExited; + } StatusMessage = "Loading profiles..."; ErrorMessage = string.Empty; @@ -312,10 +322,8 @@ public void OnTabActivated() ResetHeaderState(); } - private bool _isHovering; - /// - /// Resets the header state to expanded and restarts the auto-collapse timer. + /// Resets the header state to expanded and starts the auto-collapse timer. /// public void ResetHeaderState() { @@ -324,7 +332,7 @@ public void ResetHeaderState() _headerExpansionTimer.Stop(); // Only start the auto-collapse timer if the user is NOT currently hovering - if (!_isHovering) + if (!_isHovering && !IsScanning) { _headerCollapseTimer.Start(); } @@ -542,6 +550,12 @@ private async Task ApplyInstallationWizardDecisionsAsync( List installationsList, SetupWizardResult wizardResult) { + if (!wizardResult.Confirmed) + { + logger.LogInformation("Setup wizard was skipped by user, skipping profile creation"); + return 0; + } + var cpDecision = wizardResult.CommunityPatchAction; var goDecision = wizardResult.GeneralsOnlineAction; var shDecision = wizardResult.SuperHackersAction; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index ae01bb830..59a022f53 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -419,8 +419,6 @@ private async Task SaveAsync() StatusMessage = "Profile created successfully"; _logger?.LogInformation("Created new profile {ProfileName} with {ContentCount} enabled content items", Name, enabledContentIds.Count); - WeakReferenceMessenger.Default.Send(new ProfileCreatedMessage(result.Data)); - ExecuteCancel(); } else @@ -699,7 +697,7 @@ private async Task AddLocalContentAsync(Avalonia.Controls.Window? owner) if (dialogOwner == null) return; - var vm = new AddLocalContentViewModel( + using var vm = new AddLocalContentViewModel( _localContentService, _contentStorageService, _genLauncherNormalizationService, @@ -767,7 +765,7 @@ private async Task EditContentAsync(ContentDisplayItem? contentItem) if (owner == null) return; - var vm = new AddLocalContentViewModel( + using var vm = new AddLocalContentViewModel( _localContentService, _contentStorageService, _genLauncherNormalizationService, diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs index 3da6eb041..c1834d177 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs @@ -36,7 +36,7 @@ public virtual async Task InitializeForNewProfileAsync() } CurrentProfileId = null; - Name = "New Profile"; + Name = ProfileConstants.DefaultProfileName; Description = "A new game profile"; ColorValue = "#1976D2"; SelectedWorkspaceStrategy = GetDefaultWorkspaceStrategy(); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs index 38db92e52..a6c666191 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; using CommunityToolkit.Mvvm.Messaging; @@ -17,6 +18,7 @@ using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.Manifest; +using GenHub.Features.GameProfiles.Services; using GenHub.Features.Notifications.Services; using GenHub.Features.Notifications.ViewModels; using Microsoft.Extensions.Logging; @@ -168,19 +170,19 @@ private static void ValidateSingleDependencyWarning( if (dependency.Id.ToString() != ManifestConstants.DefaultContentDependencyId) { - bool found = manifestsById.ContainsKey(dependency.Id.ToString()); - if (!found && !dependency.StrictPublisher) + var declaredId = dependency.Id.ToString(); + bool found = manifestsById.ContainsKey(declaredId); + if (!found) { - var depIdSegments = dependency.Id.ToString().Split('.'); - if (depIdSegments.Length >= 5) + var depIdSegments = declaredId.Split('.'); + found = potentialMatches.Any(m => { - var (depType, depName) = (depIdSegments[3], depIdSegments[4]); - found = potentialMatches.Any(m => - { - var segments = m.Id.ToString().Split('.'); - return segments.Length >= 5 && segments[3].Equals(depType, StringComparison.OrdinalIgnoreCase) && segments[4].Equals(depName, StringComparison.OrdinalIgnoreCase); - }); - } + var segments = m.Id.ToString().Split('.'); + return HasCompatibleCatalogMatch(declaredId, m.Id.ToString()) || + (!dependency.StrictPublisher && segments.Length >= 5 && depIdSegments.Length >= 5 && + segments[3].Equals(depIdSegments[3], StringComparison.OrdinalIgnoreCase) && + segments[4].Equals(depIdSegments[4], StringComparison.OrdinalIgnoreCase)); + }); } if (!found && !dependency.IsOptional) warnings.Add($"'{manifest.Name}' requires '{dependency.Name}' which is not enabled."); @@ -193,6 +195,9 @@ private static void ValidateSingleDependencyWarning( } } + private static bool HasCompatibleCatalogMatch(string declaredId, string availableId) => + DependencyResolver.HasCompatibleCatalogIdentity(declaredId, availableId); + private readonly IGameProfileManager? _gameProfileManager; private readonly IGameSettingsService? _gameSettingsService; private readonly IConfigurationProviderService? _configurationProvider; @@ -399,67 +404,109 @@ partial void OnSelectedGameInstallationChanged(ContentDisplayItem? value) private async Task OnContentTypeChangedAsync() => await LoadAvailableContentAsync(); - private async Task EnableContentInternal(ContentDisplayItem? contentItem, bool bypassLoadingGuard = false) + private async Task EnableContentInternal( + ContentDisplayItem? contentItem, + bool bypassLoadingGuard = false, + bool isRootOperation = true, + List? autoEnabledNames = null, + CancellationToken cancellationToken = default) + { + if (!CanEnableContent(contentItem, bypassLoadingGuard)) + { + return; + } + + ReplaceConflictingEnabledContent(contentItem!); + ActivateContentItem(contentItem!); + + var autoResolved = autoEnabledNames ?? []; + await ResolveDependenciesAsync(contentItem!, autoResolved, cancellationToken); + + if (isRootOperation) + { + await HandleRootOperationCompletionAsync(contentItem!, autoResolved, cancellationToken); + } + } + + private bool CanEnableContent(ContentDisplayItem? contentItem, bool bypassLoadingGuard) { - if (contentItem == null) return; - if (IsLoadingContent && !bypassLoadingGuard) return; + if (contentItem == null || (IsLoadingContent && !bypassLoadingGuard)) + { + return false; + } + + if (contentItem.ContentType == ContentType.GameInstallation && SelectedGameInstallation == contentItem && contentItem.IsEnabled) + { + return false; + } + if (contentItem.IsLocked) { StatusMessage = "This content item is locked and cannot be modified"; - return; + return false; } if (!contentItem.CanToggle) { StatusMessage = "This content item cannot be toggled"; - return; + return false; } - if (contentItem.IsEnabled) return; + if (contentItem.IsEnabled || EnabledContent.Any(e => e.ManifestId.Value == contentItem.ManifestId.Value)) + { + return false; + } + + return true; + } - var alreadyEnabled = EnabledContent.FirstOrDefault(e => e.ManifestId.Value == contentItem.ManifestId.Value); - if (alreadyEnabled != null) return; + private void ReplaceConflictingEnabledContent(ContentDisplayItem contentItem) + { + if (contentItem.ContentType != ContentType.GameInstallation && contentItem.ContentType != ContentType.GameClient) + { + return; + } - if (contentItem.ContentType == ContentType.GameInstallation || contentItem.ContentType == ContentType.GameClient) + var existingItems = EnabledContent.Where(e => e.ContentType == contentItem.ContentType).ToList(); + foreach (var existing in existingItems) { - var existingItems = EnabledContent.Where(e => e.ContentType == contentItem.ContentType).ToList(); - foreach (var existing in existingItems) + if (existing.ContentType == ContentType.GameClient && Name == existing.DisplayName) { - if (existing.ContentType == ContentType.GameClient && Name == existing.DisplayName) - { - Name = "New Profile"; - } + Name = ProfileConstants.DefaultProfileName; + } - existing.IsEnabled = false; - EnabledContent.Remove(existing); + existing.IsEnabled = false; + EnabledContent.Remove(existing); - if (existing.ContentType == SelectedContentType && existing.GameType == GameTypeFilter) + if (existing.ContentType == SelectedContentType && existing.GameType == GameTypeFilter) + { + var alreadyInAvailable = AvailableContent.FirstOrDefault(a => a.ManifestId.Value == existing.ManifestId.Value); + if (alreadyInAvailable == null) { - var alreadyInAvailable = AvailableContent.FirstOrDefault(a => a.ManifestId.Value == existing.ManifestId.Value); - if (alreadyInAvailable == null) + AvailableContent.Add(new ContentDisplayItem { - AvailableContent.Add(new ContentDisplayItem - { - ManifestId = existing.ManifestId, - DisplayName = existing.DisplayName, - ContentType = existing.ContentType, - GameType = existing.GameType, - InstallationType = existing.InstallationType, - Publisher = existing.Publisher, - IsEnabled = false, - SourceId = existing.SourceId, - GameClientId = existing.GameClientId, - Version = existing.Version, - IsEditable = existing.IsEditable, - SourcePath = existing.SourcePath, - IsLocked = existing.IsLocked, - CanToggle = existing.CanToggle, - }); - } + ManifestId = existing.ManifestId, + DisplayName = existing.DisplayName, + ContentType = existing.ContentType, + GameType = existing.GameType, + InstallationType = existing.InstallationType, + Publisher = existing.Publisher, + IsEnabled = false, + SourceId = existing.SourceId, + GameClientId = existing.GameClientId, + Version = existing.Version, + IsEditable = existing.IsEditable, + SourcePath = existing.SourcePath, + IsLocked = existing.IsLocked, + CanToggle = existing.CanToggle, + }); } } } + } + private void ActivateContentItem(ContentDisplayItem contentItem) + { contentItem.IsEnabled = true; EnabledContent.Add(contentItem); @@ -477,28 +524,39 @@ private async Task EnableContentInternal(ContentDisplayItem? contentItem, bool b StatusMessage = $"Enabled {contentItem.DisplayName}"; _logger?.LogInformation("Enabled content {ContentName} for profile", contentItem.DisplayName); - _localNotificationService.ShowSuccess( - "Content Enabled", - $"Enabled '{contentItem.DisplayName}'"); - - if (contentItem.ContentType == ContentType.GameClient && Name == "New Profile") + if (contentItem.ContentType == ContentType.GameClient && Name == ProfileConstants.DefaultProfileName) { Name = contentItem.DisplayName; } + } + + private async Task HandleRootOperationCompletionAsync(ContentDisplayItem contentItem, List autoResolved, CancellationToken cancellationToken = default) + { + if (autoResolved.Count > 0) + { + _localNotificationService.ShowSuccess( + "Content Enabled", + $"Enabled '{contentItem.DisplayName}' and auto-resolved: {string.Join(", ", autoResolved)}"); + } + else + { + _localNotificationService.ShowSuccess( + "Content Enabled", + $"Enabled '{contentItem.DisplayName}'"); + } - await ResolveDependenciesAsync(contentItem); + await ValidateEnabledContentDependenciesAsync(contentItem.DisplayName, cancellationToken); } - private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem) + private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem, List autoEnabledNames, CancellationToken cancellationToken = default) { try { if (_manifestPool == null) return; - var manifest = await GetOrSynthesizeManifestForContentAsync(contentItem); + var manifest = await GetOrSynthesizeManifestForContentAsync(contentItem, cancellationToken); if (manifest?.Dependencies == null || manifest.Dependencies.Count == 0) { - _ = ValidateEnabledContentDependenciesAsync(contentItem.DisplayName); return; } @@ -506,31 +564,28 @@ private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem) { if (dependency.DependencyType == ContentType.GameInstallation) { - await ResolveGameInstallationDependencyAsync(contentItem, dependency); + await ResolveGameInstallationDependencyAsync(contentItem, dependency, autoEnabledNames, cancellationToken); } else { - await ResolveContentDependencyAsync(dependency); + await ResolveContentDependencyAsync(dependency, autoEnabledNames, cancellationToken); } } - - await ValidateEnabledContentDependenciesAsync(contentItem.DisplayName); } catch (Exception ex) { _logger?.LogError(ex, "Error resolving dependencies for {ContentName}", contentItem.DisplayName); - _ = ValidateEnabledContentDependenciesAsync(contentItem.DisplayName); } } - private async Task GetOrSynthesizeManifestForContentAsync(ContentDisplayItem contentItem) + private async Task GetOrSynthesizeManifestForContentAsync(ContentDisplayItem contentItem, CancellationToken cancellationToken = default) { if (_manifestPool == null) { return null; } - var manifestResult = await _manifestPool.GetManifestAsync(contentItem.ManifestId.Value); + var manifestResult = await _manifestPool.GetManifestAsync(ManifestId.Create(contentItem.ManifestId.Value), cancellationToken); if (manifestResult.Success && manifestResult.Data != null) { return manifestResult.Data; @@ -561,7 +616,11 @@ private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem) return null; } - private async Task ResolveGameInstallationDependencyAsync(ContentDisplayItem contentItem, ContentDependency dependency) + private async Task ResolveGameInstallationDependencyAsync( + ContentDisplayItem contentItem, + ContentDependency dependency, + List autoEnabledNames, + CancellationToken cancellationToken = default) { bool isSatisfied = false; var isDefaultDep = dependency.Id.ToString() == ManifestConstants.DefaultContentDependencyId; @@ -607,15 +666,25 @@ private async Task ResolveGameInstallationDependencyAsync(ContentDisplayItem con if (compatibleInstallation != null) { - _localNotificationService.ShowSuccess("Auto-Resolved", $"Switched Game Installation to '{compatibleInstallation.DisplayName}' as required by '{contentItem.DisplayName}'."); - await EnableContentInternal(compatibleInstallation, bypassLoadingGuard: true); + if (!autoEnabledNames.Contains(compatibleInstallation.DisplayName)) + { + autoEnabledNames.Add(compatibleInstallation.DisplayName); + } + + await EnableContentInternal(compatibleInstallation, bypassLoadingGuard: true, isRootOperation: false, autoEnabledNames, cancellationToken); } } - private async Task ResolveContentDependencyAsync(ContentDependency dependency) + private async Task ResolveContentDependencyAsync( + ContentDependency dependency, + List autoEnabledNames, + CancellationToken cancellationToken = default) { - bool alreadyEnabled = dependency.Id.ToString() != ManifestConstants.DefaultContentDependencyId - ? EnabledContent.Any(x => x.ManifestId.Value == dependency.Id.ToString()) + var declaredId = dependency.Id.ToString(); + bool alreadyEnabled = declaredId != ManifestConstants.DefaultContentDependencyId + ? EnabledContent.Any(x => x.ManifestId.Value == declaredId || + (x.ContentType == dependency.DependencyType && + HasCompatibleCatalogMatch(declaredId, x.ManifestId.Value))) : EnabledContent.Any(x => x.ContentType == dependency.DependencyType); if (alreadyEnabled || dependency.IsOptional || _profileContentLoader == null) return; @@ -632,24 +701,27 @@ private async Task ResolveContentDependencyAsync(ContentDependency dependency) })), EnabledContent.Select(x => x.ManifestId.Value)); - Core.Models.Content.ContentDisplayItem? match = null; - if (dependency.Id.ToString() != ManifestConstants.DefaultContentDependencyId) - { - match = availableOfTargetType.FirstOrDefault(x => x.ManifestId == dependency.Id.ToString()); - } + var match = declaredId != ManifestConstants.DefaultContentDependencyId + ? (availableOfTargetType.FirstOrDefault(x => x.ManifestId == declaredId) + ?? availableOfTargetType.FirstOrDefault(x => HasCompatibleCatalogMatch(declaredId, x.ManifestId))) + : availableOfTargetType.FirstOrDefault(x => x.ContentType == dependency.DependencyType); if (match != null) { var viewModelItem = ConvertToViewModelContentDisplayItem(match); if (!viewModelItem.IsEnabled) { - _localNotificationService.ShowSuccess("Auto-Resolved", $"Automatically enabled required content: '{viewModelItem.DisplayName}'"); - await EnableContentInternal(viewModelItem, bypassLoadingGuard: true); + if (!autoEnabledNames.Contains(viewModelItem.DisplayName)) + { + autoEnabledNames.Add(viewModelItem.DisplayName); + } + + await EnableContentInternal(viewModelItem, bypassLoadingGuard: true, isRootOperation: false, autoEnabledNames, cancellationToken); } } } - private async Task ValidateEnabledContentDependenciesAsync(string justEnabledContentName) + private async Task ValidateEnabledContentDependenciesAsync(string justEnabledContentName, CancellationToken cancellationToken = default) { try { @@ -660,7 +732,7 @@ private async Task ValidateEnabledContentDependenciesAsync(string justEnabledCon var manifests = new List(); foreach (var manifestId in enabledManifestIds) { - var manifestResult = await _manifestPool.GetManifestAsync(manifestId); + var manifestResult = await _manifestPool.GetManifestAsync(ManifestId.Create(manifestId), cancellationToken); if (manifestResult.Success && manifestResult.Data != null) manifests.Add(manifestResult.Data); } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs index a152638e7..fce0e13b7 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -11,6 +12,7 @@ using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameSettings; +using GenHub.Core.Models.Results; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.ViewModels; @@ -316,32 +318,32 @@ partial void OnStaticGameLODChanged(string value) private bool _tshScreenEdgeScrollEnabledInWindowedApp = GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInWindowedApp; [ObservableProperty] - private int _tshMoneyTransactionVolume = 50; + private int _tshMoneyTransactionVolume = GameSettingsTheSuperHackersConstants.DefaultMoneyTransactionVolume; // ===== GeneralsOnline Client Settings ===== [ObservableProperty] private bool _goShowFps; [ObservableProperty] - private bool _goShowPing; + private bool _goShowPing = GameSettingsGeneralsOnlineConstants.DefaultShowPing; [ObservableProperty] - private bool _goShowPlayerRanks; + private bool _goShowPlayerRanks = GameSettingsGeneralsOnlineConstants.DefaultShowPlayerRanks; [ObservableProperty] private bool _goAutoLogin; [ObservableProperty] - private bool _goRememberUsername; + private bool _goRememberUsername = GameSettingsGeneralsOnlineConstants.DefaultRememberUsername; [ObservableProperty] - private bool _goEnableNotifications; + private bool _goEnableNotifications = GameSettingsGeneralsOnlineConstants.DefaultEnableNotifications; [ObservableProperty] - private bool _goEnableSoundNotifications; + private bool _goEnableSoundNotifications = GameSettingsGeneralsOnlineConstants.DefaultEnableSoundNotifications; [ObservableProperty] - private int _goChatFontSize = 12; + private int _goChatFontSize = GameSettingsGeneralsOnlineConstants.DefaultChatFontSize; // Camera settings [ObservableProperty] @@ -423,6 +425,8 @@ public async Task InitializeForProfileAsync(string? profileId, Core.Models.GameP try { _currentProfileId = profileId; + _currentProfileIsGeneralsOnline = profile?.IsGeneralsOnlineProfile() == true; + _generalsOnlineSettingsSeeded = false; // Auto-select game type from profile if (profile != null) @@ -457,6 +461,10 @@ public async Task InitializeForProfileAsync(string? profileId, Core.Models.GameP // If profile has settings, load them if (profile?.HasCustomSettings() == true) { + // Seeded from settings.json first so that the options the profile does not declare + // show, and are saved back as, what the user configured inside the GeneralsOnline + // client rather than this view model's defaults. + await LoadGeneralsOnlineSettingsFromClientAsync(); LoadSettingsFromProfile(profile); } else @@ -631,6 +639,8 @@ private async Task TestPat() } private IniOptions? _currentOptions; + private bool _generalsOnlineSettingsSeeded; + private bool _currentProfileIsGeneralsOnline; private string? _currentProfileId; private int _initializationDepth; private bool _isLoadingFromOptions; @@ -689,10 +699,12 @@ private async Task LoadSettings() if (goResult?.Success == true && goResult.Data != null) { ApplyGeneralsOnlineSettings(goResult.Data); + _generalsOnlineSettingsSeeded = true; _logger.LogInformation("Loaded GeneralsOnline settings"); } else { + _generalsOnlineSettingsSeeded = false; var goErrors = goResult?.Errors ?? ["LoadGeneralsOnlineSettings result was null"]; _logger.LogWarning("Failed to load GeneralsOnline settings: {Errors}", string.Join(", ", goErrors)); } @@ -708,6 +720,37 @@ private async Task LoadSettings() } } + /// + /// Reads the GeneralsOnline client's own settings.json into this view model. + /// + /// + /// The view model's GeneralsOnline properties have no unset state, so every one of them is + /// written back on save. Seeding them from the client's file is what keeps that from replacing + /// options the profile says nothing about with defaults. A read that fails leaves the view + /// model unseeded, which is what stops the save from writing over the client's own values. + /// + /// A task representing the asynchronous operation. + private async Task LoadGeneralsOnlineSettingsFromClientAsync() + { + if (_gameSettingsService == null || !_currentProfileIsGeneralsOnline) + { + return; + } + + var goResult = await _gameSettingsService.LoadGeneralsOnlineSettingsAsync(); + if (goResult?.Success == true && goResult.Data != null) + { + ApplyGeneralsOnlineSettings(goResult.Data); + _generalsOnlineSettingsSeeded = true; + } + else + { + _generalsOnlineSettingsSeeded = false; + var goErrors = goResult?.Errors ?? ["LoadGeneralsOnlineSettings result was null"]; + _logger.LogWarning("Failed to load GeneralsOnline settings: {Errors}", string.Join(", ", goErrors)); + } + } + /// /// Loads settings from a game profile. /// @@ -853,8 +896,16 @@ private void LoadGeneralsOnlineSettingsFromProfile(Core.Models.GameProfile.GameP } /// - /// Saves the current settings to options.ini. + /// Saves the current settings to Options.ini and, for a GeneralsOnline profile, to the client's + /// settings.json. /// + /// + /// The two files are separate writes with no transaction between them, so either one can land + /// while the other does not: the settings.json rewrite can be refused after Options.ini is + /// written, and Options.ini can fail after settings.json has been rewritten. Reordering the + /// writes only moves which half is exposed, so the status message names the halves separately + /// instead of reporting a total failure over a file that was written. + /// [RelayCommand] private async Task SaveSettings() { @@ -872,27 +923,66 @@ private async Task SaveSettings() var options = CreateOptionsFromViewModel(); var result = await _gameSettingsService.SaveOptionsAsync(SelectedGameType, options); - // Save GeneralsOnline settings - var goSettings = CreateGeneralsOnlineSettings(); - var goResult = await _gameSettingsService.SaveGeneralsOnlineSettingsAsync(goSettings); + var writeGeneralsOnlineSettings = ShouldWriteGeneralsOnlineSettings(); + OperationResult? goResult = null; + string? goLoadError = null; - if (result?.Success == true && goResult?.Success == true) + if (writeGeneralsOnlineSettings) + { + var goLoadResult = await ReadGeneralsOnlineSettingsForRewriteAsync(); + if (goLoadResult.Success && goLoadResult.Data != null) + { + var goSettings = goLoadResult.Data; + MergeViewModelIntoGeneralsOnlineSettings(goSettings); + goResult = await _gameSettingsService.SaveGeneralsOnlineSettingsAsync(goSettings); + } + else + { + goLoadError = goLoadResult.FirstError; + } + } + + var optionsSaved = result?.Success == true; + var generalsOnlineWritten = goResult?.Success == true; + var generalsOnlineBlocked = writeGeneralsOnlineSettings && !generalsOnlineWritten; + + if (optionsSaved) { _currentOptions = options; OptionsFileExists = true; + } + + var optionsErrors = new List(); + if (result == null) optionsErrors.Add("SaveOptions result was null"); + if (result?.Success == false) optionsErrors.AddRange(result.Errors); + + var generalsOnlineErrors = new List(); + if (goLoadError != null) generalsOnlineErrors.Add(goLoadError); + if (goResult?.Success == false) generalsOnlineErrors.AddRange(goResult.Errors); + if (generalsOnlineBlocked && goLoadError == null && goResult == null) generalsOnlineErrors.Add("SaveGeneralsOnlineSettings result was null"); + + if (optionsSaved && !generalsOnlineBlocked) + { StatusMessage = $"{SelectedGameType} settings saved successfully"; _logger.LogInformation("Saved settings for {GameType}", SelectedGameType); } + else if (optionsSaved) + { + var goErrors = string.Join(", ", generalsOnlineErrors); + StatusMessage = $"Options.ini saved; GeneralsOnline settings not written: {goErrors}"; + _logger.LogWarning("Saved Options.ini for {GameType} but did not write GeneralsOnline settings: {Errors}", SelectedGameType, goErrors); + } + else if (generalsOnlineWritten) + { + var iniErrors = string.Join(", ", optionsErrors); + StatusMessage = $"GeneralsOnline settings saved; Options.ini not saved: {iniErrors}"; + _logger.LogWarning("Wrote GeneralsOnline settings but failed to save Options.ini for {GameType}: {Errors}", SelectedGameType, iniErrors); + } else { - var errors = new List(); - if (result?.Success == false) errors.AddRange(result.Errors); - if (goResult?.Success == false) errors.AddRange(goResult.Errors); - if (result == null) errors.Add("SaveOptions result was null"); - if (goResult == null) errors.Add("SaveGeneralsOnlineSettings result was null"); - - StatusMessage = $"Failed to save settings: {string.Join(", ", errors)}"; - _logger.LogWarning("Failed to save settings: {Errors}", string.Join(", ", errors)); + var errors = string.Join(", ", optionsErrors.Concat(generalsOnlineErrors)); + StatusMessage = $"Failed to save settings: {errors}"; + _logger.LogWarning("Failed to save settings: {Errors}", errors); } } catch (Exception ex) @@ -906,6 +996,43 @@ private async Task SaveSettings() } } + /// + /// Reads the GeneralsOnline client's settings.json so the save can be applied on top of it. + /// + /// + /// The file is read again for every save rather than kept as a snapshot: it is the + /// GeneralsOnline client's own global file, so anything it or another GenHub window wrote + /// since this editor opened would otherwise be reverted by the rewrite. Reading it is also + /// the only way to fail loudly, because a missing file reads as defaults and reports success: + /// a failure therefore means the client's file exists and could not be read, and rewriting it + /// from defaults would discard every key the client owns. + /// + /// The read alone is not enough. This view model has no unset state, so it writes all 24 + /// GeneralsOnline fields; unless they were seeded from a successful read, writing them would + /// replace what the user configured inside the client with this view model's defaults. + /// + /// + /// The settings this save must be applied on top of, or the error that aborts the rewrite. + private async Task> ReadGeneralsOnlineSettingsForRewriteAsync() + { + if (!_generalsOnlineSettingsSeeded) + { + const string error = "GeneralsOnline settings.json was never read, so its values cannot be rewritten"; + _logger.LogWarning("Not writing GeneralsOnline settings: {Error}", error); + return OperationResult.CreateFailure(error); + } + + var goLoadResult = await _gameSettingsService!.LoadGeneralsOnlineSettingsAsync(); + if (goLoadResult?.Success == true && goLoadResult.Data != null) + { + return goLoadResult; + } + + var loadError = goLoadResult?.FirstError ?? "LoadGeneralsOnlineSettings result was null"; + _logger.LogWarning("Not writing GeneralsOnline settings because settings.json could not be read: {Error}", loadError); + return OperationResult.CreateFailure(loadError); + } + /// /// Opens the Options.ini file location in Windows Explorer. /// @@ -1173,6 +1300,8 @@ private IniOptions CreateOptionsFromViewModel() private void ApplyGeneralsOnlineSettings(GeneralsOnlineSettings settings) { + settings.EnsureNestedSectionsInitialized(); + GoShowFps = settings.ShowFps; GoShowPing = settings.ShowPing; GoShowPlayerRanks = settings.ShowPlayerRanks; @@ -1199,20 +1328,34 @@ private void ApplyGeneralsOnlineSettings(GeneralsOnlineSettings settings) GoSocialNotificationPlayerSendsRequestMenus = settings.Social.NotificationPlayerSendsRequestMenus; } - private GeneralsOnlineSettings CreateGeneralsOnlineSettings() + /// + /// Decides whether this save may rewrite settings.json, which is a single global file owned by + /// the GeneralsOnline client rather than a per-profile one. Saving a retail, TheSuperHackers or + /// CommunityOutpost profile must leave it untouched. + /// + /// True when the profile being edited runs the GeneralsOnline client. + private bool ShouldWriteGeneralsOnlineSettings() { - var settings = new GeneralsOnlineSettings - { - ShowFps = GoShowFps, - ShowPing = GoShowPing, - ShowPlayerRanks = GoShowPlayerRanks, - AutoLogin = GoAutoLogin, - RememberUsername = GoRememberUsername, - EnableNotifications = GoEnableNotifications, - EnableSoundNotifications = GoEnableSoundNotifications, - ChatFontSize = GoChatFontSize, - }; + return SelectedGameType == GameType.ZeroHour && _currentProfileIsGeneralsOnline; + } + /// + /// Writes this view model's GeneralsOnline values into settings just read from the client's + /// settings.json, which is what carries the keys this model does not declare through a save. + /// + /// The settings read from settings.json, mutated in place. + private void MergeViewModelIntoGeneralsOnlineSettings(GeneralsOnlineSettings settings) + { + settings.EnsureNestedSectionsInitialized(); + + settings.ShowFps = GoShowFps; + settings.ShowPing = GoShowPing; + settings.ShowPlayerRanks = GoShowPlayerRanks; + settings.AutoLogin = GoAutoLogin; + settings.RememberUsername = GoRememberUsername; + settings.EnableNotifications = GoEnableNotifications; + settings.EnableSoundNotifications = GoEnableSoundNotifications; + settings.ChatFontSize = GoChatFontSize; settings.Camera.MaxHeightOnlyWhenLobbyHost = GoCameraMaxHeightOnlyWhenLobbyHost; settings.Camera.MinHeight = GoCameraMinHeight; settings.Camera.MoveSpeedRatio = GoCameraMoveSpeedRatio; @@ -1229,7 +1372,5 @@ private GeneralsOnlineSettings CreateGeneralsOnlineSettings() settings.Social.NotificationPlayerAcceptsRequestMenus = GoSocialNotificationPlayerAcceptsRequestMenus; settings.Social.NotificationPlayerSendsRequestGameplay = GoSocialNotificationPlayerSendsRequestGameplay; settings.Social.NotificationPlayerSendsRequestMenus = GoSocialNotificationPlayerSendsRequestMenus; - - return settings; } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardItemViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardItemViewModel.cs index a2cde11eb..37bc051cf 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardItemViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardItemViewModel.cs @@ -64,7 +64,12 @@ public string Version get => _version; set { - var displayVersion = GameVersionHelper.IsDefaultVersion(value) ? string.Empty : value; + var displayVersion = GameVersionHelper.IsDefaultVersion(value) ? string.Empty : value?.Trim(); + if (!string.IsNullOrEmpty(displayVersion) && (displayVersion.StartsWith('v') || displayVersion.StartsWith('V'))) + { + displayVersion = displayVersion[1..]; + } + SetProperty(ref _version, displayVersion ?? string.Empty); } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModel.cs index 90f6fe14e..87488271d 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModel.cs @@ -27,7 +27,7 @@ public sealed partial class SetupWizardViewModel(IEnumerable [ObservableProperty] - private string _cancelLabel = "Skip & Create Base Profiles"; + private string _cancelLabel = "Skip"; /// /// Gets or sets the label for the confirm/continue button. @@ -45,11 +45,16 @@ public sealed partial class SetupWizardViewModel(IEnumerable _confirmed; [RelayCommand] - private void ToggleSelection(SetupWizardItemViewModel item) + private void ToggleSelection(SetupWizardItemViewModel? item) { + if (item == null) + { + return; + } + if (!item.IsMandatory) { - // IsSelected is bound two-way, so we just need to update the summary labels + item.IsSelected = !item.IsSelected; UpdateLabels(); } } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml index 081789f27..d9256dfae 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml @@ -13,6 +13,7 @@ + @@ -115,7 +116,13 @@ + Classes="glass"> + + + + + + @@ -157,7 +164,7 @@ - + - + + + + + + - + - - + + @@ -73,72 +120,86 @@ - - - - - - - - - - + - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs index 07fe4ce37..78c4b9180 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs @@ -1,8 +1,11 @@ +using System; using Avalonia; using Avalonia.Controls; +#if DEBUG +using Avalonia.Diagnostics; +#endif using Avalonia.Markup.Xaml; using GenHub.Features.GameProfiles.ViewModels.Wizard; -using System; namespace GenHub.Features.GameProfiles.Views.Wizard; diff --git a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs index 2dc4a45d8..6370f2596 100644 --- a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs +++ b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security; using System.Text; using System.Text.Json; using System.Threading; @@ -33,6 +34,17 @@ public class GameSettingsService(ILogger logger, IGamePathP /// private static readonly SemaphoreSlim _optionsIniWriteSemaphore = new(1, 1); + /// + /// Static semaphore to serialize settings.json reads and writes across all game launches. + /// The launch lock is per profile, so two GeneralsOnline profiles launching at once both + /// reach this one global file. On Windows that is not a race one writer simply wins: two + /// overlapping replacements of the same destination, or a replacement overlapping a read, + /// fail outright with an access denial, and the launch loses the settings it meant to save. + /// The lock is released between a load and the save that follows it, so which launch writes + /// last is still whichever finishes last. + /// + private static readonly SemaphoreSlim _generalsOnlineSettingsSemaphore = new(1, 1); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // Required, not optional. This previously defaulted to WindowsGamePathProvider when @@ -59,99 +71,99 @@ public bool OptionsFileExists(GameType gameType) /// public async Task> LoadOptionsAsync(GameType gameType) { - using var scope = _logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "OptionsIni" }); - - // Acquire semaphore to prevent reading while writing - await _optionsIniWriteSemaphore.WaitAsync(); - try + using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "OptionsIni" })) { - var filePath = GetOptionsFilePath(gameType); - _logger.LogDebug("Loading from path: {FilePath}", filePath); - - if (!File.Exists(filePath)) + // Acquire semaphore to prevent reading while writing + await _optionsIniWriteSemaphore.WaitAsync(); + try { - _logger.LogWarning("File not found at {FilePath}, returning defaults", filePath); - return OperationResult.CreateSuccess(new IniOptions()); - } + var filePath = GetOptionsFilePath(gameType); + _logger.LogDebug("Loading from path: {FilePath}", filePath); - _logger.LogDebug("Reading file"); - var lines = await File.ReadAllLinesAsync(filePath); - _logger.LogDebug("Parsing {LineCount} lines", lines.Length); - var options = ParseOptionsIni(lines); + if (!File.Exists(filePath)) + { + _logger.LogWarning("File not found at {FilePath}, returning defaults", filePath); + return OperationResult.CreateSuccess(new IniOptions()); + } - _logger.LogInformation("Loaded successfully from {FilePath}", filePath); - return OperationResult.CreateSuccess(options); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load Options.ini for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to load options: {ex.Message}"); - } - finally - { - _optionsIniWriteSemaphore.Release(); + _logger.LogDebug("Reading file"); + var lines = await File.ReadAllLinesAsync(filePath); + _logger.LogDebug("Parsing {LineCount} lines", lines.Length); + var options = ParseOptionsIni(lines); + + _logger.LogInformation("Loaded successfully from {FilePath}", filePath); + return OperationResult.CreateSuccess(options); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException or InvalidOperationException) + { + _logger.LogError(ex, "Failed to load Options.ini for {GameType}", gameType); + return OperationResult.CreateFailure($"Failed to load options: {ex.Message}"); + } + finally + { + _optionsIniWriteSemaphore.Release(); + } } } /// public async Task> SaveOptionsAsync(GameType gameType, IniOptions options) { - using var scope = _logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "OptionsIni" }); - - // Acquire semaphore to serialize Options.ini writes - await _optionsIniWriteSemaphore.WaitAsync(); - try + using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "OptionsIni" })) { - var filePath = GetOptionsFilePath(gameType); - _logger.LogDebug("Saving to path: {FilePath}", filePath); + // Acquire semaphore to serialize Options.ini writes + await _optionsIniWriteSemaphore.WaitAsync(); + try + { + var filePath = GetOptionsFilePath(gameType); + _logger.LogDebug("Saving to path: {FilePath}", filePath); - var directory = Path.GetDirectoryName(filePath); + var directory = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - _logger.LogDebug("Creating directory: {Directory}", directory); - Directory.CreateDirectory(directory); - _logger.LogInformation("Created directory {Directory}", directory); - } + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + _logger.LogDebug("Creating directory: {Directory}", directory); + Directory.CreateDirectory(directory); + _logger.LogInformation("Created directory {Directory}", directory); + } - // Safety check: Don't overwrite existing non-empty file with empty options - // This prevents data loss if a load failed but Save was called with defaults - if (File.Exists(filePath) && new FileInfo(filePath).Length > 0) - { - bool isDefault = options.Video.ResolutionWidth == 0 && options.Video.ResolutionHeight == 0; - if (isDefault) + // Safety check: Don't overwrite existing non-empty file with empty options + // This prevents data loss if a load failed but Save was called with defaults + if (File.Exists(filePath) && new FileInfo(filePath).Length > 0) { - _logger.LogWarning("Attempted to overwrite existing Options.ini with default empty settings. Aborting save to prevent data loss."); - return OperationResult.CreateFailure("Prevented overwriting Options.ini with default settings."); + bool isDefault = options.Video.ResolutionWidth == 0 && options.Video.ResolutionHeight == 0; + if (isDefault) + { + _logger.LogWarning("Attempted to overwrite existing Options.ini with default empty settings. Aborting save to prevent data loss."); + return OperationResult.CreateFailure("Prevented overwriting Options.ini with default settings."); + } } - } - _logger.LogDebug("Serializing options"); - var lines = SerializeOptionsIni(options); - _logger.LogDebug("Writing {LineCount} lines to file", lines.Length); - await File.WriteAllLinesAsync(filePath, lines, Encoding.UTF8); + _logger.LogDebug("Serializing options"); + var lines = SerializeOptionsIni(options); + _logger.LogDebug("Writing {LineCount} lines to file", lines.Length); + await File.WriteAllLinesAsync(filePath, lines, Encoding.UTF8); - _logger.LogInformation("Saved successfully to {FilePath}", filePath); - return OperationResult.CreateSuccess(true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to save Options.ini for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to save options: {ex.Message}"); - } - finally - { - // Always release the semaphore - _optionsIniWriteSemaphore.Release(); + _logger.LogInformation("Saved successfully to {FilePath}", filePath); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException or InvalidOperationException) + { + _logger.LogError(ex, "Failed to save Options.ini for {GameType}", gameType); + return OperationResult.CreateFailure($"Failed to save options: {ex.Message}"); + } + finally + { + // Always release the semaphore + _optionsIniWriteSemaphore.Release(); + } } } /// public async Task> LoadTheSuperHackersSettingsAsync(GameType gameType) { - using var scope = _logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" }); - - try + using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" })) { var optionsResult = await LoadOptionsAsync(gameType); if (!optionsResult.Success || optionsResult.Data == null) @@ -170,19 +182,12 @@ public async Task> LoadTheSuperHackersS _logger.LogInformation("Loaded TheSuperHackers settings for {GameType}", gameType); return OperationResult.CreateSuccess(settings); } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load TheSuperHackers settings for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to load TheSuperHackers settings: {ex.Message}"); - } } /// public async Task> SaveTheSuperHackersSettingsAsync(GameType gameType, TheSuperHackersSettings settings) { - using var scope = _logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" }); - - try + using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" })) { var optionsResult = await LoadOptionsAsync(gameType); if (!optionsResult.Success || optionsResult.Data == null) @@ -197,74 +202,128 @@ public async Task> SaveTheSuperHackersSettingsAsync(GameTy var saveResult = await SaveOptionsAsync(gameType, options); return saveResult; } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to save TheSuperHackers settings for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to save TheSuperHackers settings: {ex.Message}"); - } } /// public async Task> LoadGeneralsOnlineSettingsAsync() { - using var scope = _logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" }); - - try + using (_logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" })) { - var settingsPath = GetGeneralsOnlineSettingsPath(); - _logger.LogDebug("Loading GeneralsOnline settings from: {SettingsPath}", settingsPath); - - if (!File.Exists(settingsPath)) + await _generalsOnlineSettingsSemaphore.WaitAsync(); + try { - _logger.LogWarning("GeneralsOnline settings file not found at {SettingsPath}, returning defaults", settingsPath); - return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); - } + var settingsPath = GetGeneralsOnlineSettingsPath(); + _logger.LogDebug("Loading GeneralsOnline settings from: {SettingsPath}", settingsPath); + + if (!File.Exists(settingsPath)) + { + _logger.LogWarning("GeneralsOnline settings file not found at {SettingsPath}, returning defaults", settingsPath); + return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); + } + + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + if (settings == null) + { + _logger.LogWarning("Failed to deserialize GeneralsOnline settings, returning defaults"); + return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); + } - var json = await File.ReadAllTextAsync(settingsPath); - var settings = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + settings.EnsureNestedSectionsInitialized(); - if (settings == null) + _logger.LogInformation("Loaded GeneralsOnline settings from {SettingsPath}", settingsPath); + return OperationResult.CreateSuccess(settings); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException or InvalidOperationException or JsonException) { - _logger.LogWarning("Failed to deserialize GeneralsOnline settings, returning defaults"); - return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); + _logger.LogError(ex, "Failed to load GeneralsOnline settings"); + return OperationResult.CreateFailure($"Failed to load GeneralsOnline settings: {ex.Message}"); + } + finally + { + _generalsOnlineSettingsSemaphore.Release(); } - - _logger.LogInformation("Loaded GeneralsOnline settings from {SettingsPath}", settingsPath); - return OperationResult.CreateSuccess(settings); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load GeneralsOnline settings"); - return OperationResult.CreateFailure($"Failed to load GeneralsOnline settings: {ex.Message}"); } } /// public async Task> SaveGeneralsOnlineSettingsAsync(GeneralsOnlineSettings settings) { - using var scope = _logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" }); - - try + using (_logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" })) { - var settingsPath = GetGeneralsOnlineSettingsPath(); - var directory = Path.GetDirectoryName(settingsPath); + string? temporaryPath = null; + await _generalsOnlineSettingsSemaphore.WaitAsync(); + try + { + var settingsPath = GetGeneralsOnlineSettingsPath(); + var directory = Path.GetDirectoryName(settingsPath); + + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + _logger.LogDebug("Creating directory: {Directory}", directory); + Directory.CreateDirectory(directory); + } - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + var json = JsonSerializer.Serialize(settings, _jsonSerializerOptions); + + // Written beside settings.json under a name of its own and then moved over it. This + // file belongs to the GeneralsOnline client and holds keys GenHub cannot reconstruct, + // so a truncating write that is interrupted, or that overlaps a second launch writing + // the same path, would leave the client with a settings.json it cannot read. + temporaryPath = $"{settingsPath}.{Guid.NewGuid():N}{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}"; + await File.WriteAllTextAsync(temporaryPath, json, Encoding.UTF8); + await ReplaceSettingsFileAsync(temporaryPath, settingsPath); + temporaryPath = null; + + _logger.LogInformation("Saved GeneralsOnline settings to {SettingsPath}", settingsPath); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException or InvalidOperationException or JsonException) { - _logger.LogDebug("Creating directory: {Directory}", directory); - Directory.CreateDirectory(directory); + _logger.LogError(ex, "Failed to save GeneralsOnline settings"); + return OperationResult.CreateFailure($"Failed to save GeneralsOnline settings: {ex.Message}"); } + finally + { + DiscardTemporarySettingsFile(temporaryPath); + _generalsOnlineSettingsSemaphore.Release(); + } + } + } + + /// + /// Gets the path of the GeneralsOnline client's global settings.json. + /// + /// The full path to settings.json. + protected virtual string GetGeneralsOnlineSettingsPath() + { + var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var zeroHourDataPath = Path.Combine(documentsPath, GameSettingsConstants.FolderNames.ZeroHour); + var generalsOnlineDataPath = Path.Combine(zeroHourDataPath, GameSettingsConstants.FolderNames.GeneralsOnlineData); + return Path.Combine(generalsOnlineDataPath, GameSettingsGeneralsOnlineConstants.SettingsFileName); + } - var json = JsonSerializer.Serialize(settings, _jsonSerializerOptions); - await File.WriteAllTextAsync(settingsPath, json, Encoding.UTF8); + private static void DiscardTemporarySettingsFile(string? temporaryPath) + { + if (temporaryPath == null || !File.Exists(temporaryPath)) + { + return; + } - _logger.LogInformation("Saved GeneralsOnline settings to {SettingsPath}", settingsPath); - return OperationResult.CreateSuccess(true); + try + { + File.Delete(temporaryPath); } - catch (Exception ex) + catch (IOException) { - _logger.LogError(ex, "Failed to save GeneralsOnline settings"); - return OperationResult.CreateFailure($"Failed to save GeneralsOnline settings: {ex.Message}"); + // Best effort; a leftover temporary file is not worth failing the save over, and + // this runs in a finally block where throwing would hide the error being reported. + } + catch (UnauthorizedAccessException) + { + // Best effort; a leftover temporary file is not worth failing the save over, and + // this runs in a finally block where throwing would hide the error being reported. } } @@ -488,6 +547,9 @@ private static void ParseAudioSection(AudioSettings audio, Dictionary SerializeTheSuperHackersSettings(TheSu }; } - private static string GetGeneralsOnlineSettingsPath() - { - var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - var zeroHourDataPath = Path.Combine(documentsPath, GameSettingsConstants.FolderNames.ZeroHour); - var generalsOnlineDataPath = Path.Combine(zeroHourDataPath, GameSettingsConstants.FolderNames.GeneralsOnlineData); - return Path.Combine(generalsOnlineDataPath, GameSettingsGeneralsOnlineConstants.SettingsFileName); - } - private static string SanitizeKey(string key) { if (string.IsNullOrEmpty(key)) return key; @@ -749,4 +806,43 @@ private static string SanitizeKey(string key) // Remove any other control characters or non-printable chars if needed return key.Trim(); } + + /// + /// Moves a completed settings file over settings.json, retrying the move a bounded number + /// of times before letting the failure reach the caller. + /// + /// + /// The semaphore keeps GenHub's own saves off each other, but settings.json belongs to the + /// GeneralsOnline client, and a running client, a virus scanner or the search indexer can + /// hold it open. Windows refuses a replacement of a file another handle has open instead of + /// waiting for it, and reports that as an access denial rather than as contention. Every + /// such holder lets go within milliseconds, so a few attempts separated by a short delay + /// tell an overlap apart from a file GenHub genuinely may not write. + /// + /// The completed file to move. + /// The settings.json path to replace. + /// A representing the asynchronous operation. + private async Task ReplaceSettingsFileAsync(string temporaryPath, string settingsPath) + { + for (var attempt = 1; attempt < GameSettingsGeneralsOnlineConstants.SettingsReplaceAttemptLimit; attempt++) + { + try + { + File.Move(temporaryPath, settingsPath, overwrite: true); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogDebug( + ex, + "Attempt {Attempt} of {AttemptLimit} to replace {SettingsPath} was refused, retrying", + attempt, + GameSettingsGeneralsOnlineConstants.SettingsReplaceAttemptLimit, + settingsPath); + await Task.Delay(GameSettingsGeneralsOnlineConstants.SettingsReplaceRetryDelayMilliseconds); + } + } + + File.Move(temporaryPath, settingsPath, overwrite: true); + } } diff --git a/GenHub/GenHub/Features/Info/Services/DefaultInfoContentProvider.cs b/GenHub/GenHub/Features/Info/Services/DefaultInfoContentProvider.cs index 0daa3e0d6..7e7a2fcf7 100644 --- a/GenHub/GenHub/Features/Info/Services/DefaultInfoContentProvider.cs +++ b/GenHub/GenHub/Features/Info/Services/DefaultInfoContentProvider.cs @@ -788,7 +788,7 @@ private static InfoSection CreateWorkspaceSection() { Id = "workspaces", Title = "Virtual Workspaces", - Description = "Technical details of NTFS Hardlink isolation.", + Description = "Workspace strategies, file linking techniques, and isolation mechanics.", Order = 8, Cards = [ @@ -800,36 +800,117 @@ private static InfoSection CreateWorkspaceSection() IsExpandable = true, DetailedContent = """ **The "Magic Mirror":** - When you hit Play, GenHub creates a "Virtual Copy" of your game installation instantly. + When you hit Play, GenHub creates an isolated virtual workspace for your profile (taking milliseconds in linked modes). **Why is this cool?** - 1. **Zero Space:** It looks like a full 5GB game, but it takes up 0MB of disk space on your drive. - 2. **Safety:** Any changes made by mods happen in this "Mirror". If a mod breaks the game, your actual installation is perfectly safe. + 1. **Zero Space:** In linked modes (HardLink and SymlinkOnly), it acts like a full multi-gigabyte game folder while consuming virtually 0 MB of extra disk space. + 2. **Profile Isolation:** Mods and configurations live in dedicated profile workspaces without manually shuffling files in your main game directory. (Note: In direct linked modes, file data is shared with the underlying source; choose Hybrid or Full Copy if mods modify game binaries in-place). + 3. **Instant Mod Switching:** Switch between massive total conversions like *Rise of the Reds* and *ShockWave* without reinstalling or moving files. """, }, new InfoCard { - Title = "Troubleshooting", - Content = "Resolving common build errors.", + Title = "Workspace Strategies Compared", + Content = "Comparing Hardlink, Symlink, Hybrid, and Full Copy strategies.", + Type = InfoCardType.Concept, + IsExpandable = true, + DetailedContent = """ + **Choosing the Right Strategy:** + GenHub supports four file deployment strategies under **Settings -> Game Configuration**: + + * **HardLink (Default & Recommended):** + * *How it works:* Creates direct filesystem pointers (hard links) on the same drive. If the workspace is on a different drive than the game installation, it automatically falls back to copying files. + * *Disk Space:* **0 bytes** extra storage when on the same drive (full file size if copying across drives). + * *Speed:* Instant (< 50ms) on the same volume. + * *Privileges:* No administrator privileges or developer mode needed. + * *Recommendation:* Place workspaces and game files on the **same drive/volume** (e.g. both on `C:` or both on `D:`) for optimal zero-space operation. + + * **SymlinkOnly:** + * *How it works:* Creates symbolic link pointers referencing target files and directories. + * *Disk Space:* **Negligible** (~few KB of pointer metadata). + * *Speed:* Instant (< 50ms). + * *Advantage:* Links seamlessly across **different drives and partitions**. + * *Limitation:* On Windows, requires **Administrator rights** or **Developer Mode** enabled in Windows Settings. + + * **HybridCopySymlink (Balanced Compatibility):** + * *How it works:* Copies essential engine files, scripts, and mod configurations into the workspace while symlinking non-essential media assets (such as textures, audio, and video). + * *Disk Space:* Balanced (copies essential assets, links media assets). + * *Speed:* Fast (1-2 seconds). + * *Advantage:* Protects essential configs from cross-profile conflicts while reducing overall workspace footprint. + + * **FullCopy (Universal Fallback):** + * *How it works:* Physically duplicates every game and mod file into the workspace directory. + * *Disk Space:* Uses full game size (**2-5+ GB** per profile). + * *Speed:* Slower (10-30+ seconds depending on drive speed). + * *Advantage:* Unconditional compatibility across external drives, network drives, and restricted environments. + """, + }, + new InfoCard + { + Title = "Hardlinks vs Symlinks vs Copies: Deep Dive", + Content = "How file linking differs under the hood.", + Type = InfoCardType.Feature, + IsExpandable = true, + DetailedContent = """ + **Under the Hood:** + + * **Hardlink:** + A hardlink is a directory entry that points directly to an existing file's data cluster on disk (the NTFS file record / inode). The file data is shared, so creating a hardlink takes zero disk space. Because it points directly to physical drive sectors, hardlinks cannot cross drive partitions. + + * **Symlink (Symbolic Link):** + A symlink is a special small file that contains a text path pointing to another file or folder (like a transparent shortcut at the operating system level). Because it stores a path, it can point across different drives, but Windows security policies require elevated privileges or Developer Mode to create symlinks. + + * **Full Copy:** + A physical byte-for-byte duplicate of the source file. It allocates new disk clusters and writes the entire file contents again. + + **Automatic Fallback:** + If you configure Symlink mode but run GenHub without administrator rights or Developer Mode, GenHub automatically falls back to hardlinks when files reside on the same drive, ensuring your game launches seamlessly without interruptions. + """, + }, + new InfoCard + { + Title = "Troubleshooting & Permissions", + Content = "Resolving common permissions and workspace build errors.", Type = InfoCardType.HowTo, IsExpandable = true, DetailedContent = """ - **Common Issues:** - - **"Access Denied":** GenHub requires Write permissions to `AppData`. Run as Admin if issues persist. - - **"File In Use":** Ensure the game process is fully terminated before rebuilding. + **Common Issues & Solutions:** + + * **"Access Denied" / Privilege Errors:** + * If using Symlink strategy on Windows, enable **Developer Mode** in *Windows Settings -> System -> For developers*, or run GenHub as Administrator. + * Alternatively, switch your Default Workspace Strategy to **HardLink** in GenHub Settings. + * **Cross-Drive Linking & Storage:** + * Hardlinks require the same drive/volume to achieve zero-space linking; across different drives, HardLink strategy falls back to copying files. + * To maintain instant, zero-space workspaces, keep your CAS pool and workspace directories on the same drive as your game installation in **Settings -> Data Directories**, or enable Symlink mode with Developer Mode turned on. + * **"File In Use" / Locked Files:** + * Ensure all instances of `generals.exe` or `game.dat` are completely closed before switching profiles or rebuilding workspaces. """, }, new InfoCard { Title = "Performance Specs", - Content = "Efficiency and integrity metrics.", + Content = "Efficiency, speed, and integrity metrics across strategies.", Type = InfoCardType.Feature, IsExpandable = true, DetailedContent = """ - **Hardlinks:** - - **Speed:** < 50ms creation time (Metadata only). - - **Space:** 0 bytes additional disk usage (Pointers). - - **Integrity:** Read-only source files. Modifications in workspace do not corrupt the installation. + **Strategy Metrics:** + + * **HardLink:** + * *Creation Time:* < 50ms on same volume (Metadata only) + * *Disk Overhead:* 0 MB on same volume (copies on cross-volume) + * *Integrity:* Shared data clusters (CAS objects remain immutable in CAS pool; direct writes affect linked file). + * **SymlinkOnly:** + * *Creation Time:* < 50ms (Pointer creation) + * *Disk Overhead:* < 1 MB + * *Integrity:* Transparent pointer redirection across volumes. + * **Hybrid:** + * *Creation Time:* 1-2 seconds + * *Disk Overhead:* Copies essential configs, links media assets + * *Integrity:* Physical copies for essential configs, shared links for media assets. + * **Full Copy:** + * *Creation Time:* 10-30 seconds + * *Disk Overhead:* Full size (2,000 - 5,000+ MB) + * *Integrity:* Total physical file isolation. """, }, ], diff --git a/GenHub/GenHub/Features/Info/Services/MockToolServices.cs b/GenHub/GenHub/Features/Info/Services/MockToolServices.cs index 0e1aaae7e..50ced9287 100644 --- a/GenHub/GenHub/Features/Info/Services/MockToolServices.cs +++ b/GenHub/GenHub/Features/Info/Services/MockToolServices.cs @@ -6,6 +6,7 @@ using System.Reactive.Subjects; 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.GameProfiles; @@ -43,6 +44,7 @@ public class MockNotificationService : INotificationService private readonly Subject _dismissRequests = new(); private readonly Subject _dismissAllRequests = new(); private readonly Subject _notificationHistory = new(); + private readonly Subject<(Guid Id, string? Title, string Message)> _updateRequests = new(); /// public IObservable Notifications => _notifications.AsObservable(); @@ -56,6 +58,9 @@ public class MockNotificationService : INotificationService /// public IObservable NotificationHistory => _notificationHistory.AsObservable(); + /// + public IObservable<(Guid Id, string? Title, string Message)> UpdateRequests => _updateRequests.AsObservable(); + /// public void Show(NotificationMessage notification) => _notifications.OnNext(notification); @@ -75,6 +80,10 @@ public void ShowWarning(string title, string message, int? autoDismissMs = null, public void ShowError(string title, string message, int? autoDismissMs = null, bool showInBadge = false) => Show(new NotificationMessage(NotificationType.Error, title, message, autoDismissMs, showInBadge: showInBadge)); + /// + public void Update(Guid notificationId, string message, string? title = null) + => _updateRequests.OnNext((notificationId, title, message)); + /// public void Dismiss(Guid id) => _dismissRequests.OnNext(id); @@ -457,7 +466,18 @@ public Task CreateMapPackAsync(string name, Guid? profileId, IEnumerabl public class MockLocalContentService : ILocalContentService { /// - public IReadOnlyList AllowedContentTypes => [ContentType.Mod, ContentType.Map, ContentType.GameClient]; + public IReadOnlyList AllowedContentTypes => + [ + ContentType.Mod, + ContentType.GameClient, + ContentType.Executable, + ContentType.ModdingTool, + ContentType.Patch, + ContentType.Addon, + ContentType.Map, + ContentType.MapPack, + ContentType.Mission, + ]; /// public Task> AddLocalContentAsync(string name, string directoryPath, ContentType contentType, GameType targetGame, CancellationToken cancellationToken = default) @@ -466,18 +486,26 @@ public Task> AddLocalContentAsync(string name, } /// - public Task> CreateLocalContentManifestAsync(string directoryPath, string name, ContentType contentType, GameType targetGame, string? sourcePath = null, IProgress? progress = null, CancellationToken cancellationToken = default) + public Task> CreateLocalContentManifestAsync(string directoryPath, string name, ContentType contentType, GameType targetGame, string? sourcePath = null, IProgress? progress = null, CancellationToken cancellationToken = default, string? entryPoint = null) { - return Task.FromResult(OperationResult.CreateSuccess(new ContentManifest { Name = name, ContentType = contentType, TargetGame = targetGame, SourcePath = sourcePath })); + var normalizedEntryPoint = !string.IsNullOrWhiteSpace(entryPoint) + ? entryPoint.Replace('\\', '/').TrimStart('/') + : null; + + return Task.FromResult(OperationResult.CreateSuccess(new ContentManifest { Name = name, ContentType = contentType, TargetGame = targetGame, SourcePath = sourcePath, EntryPoint = normalizedEntryPoint })); } /// public Task DeleteLocalContentAsync(string manifestId, CancellationToken cancellationToken = default) => Task.FromResult(OperationResult.CreateSuccess()); /// - public Task> UpdateLocalContentManifestAsync(string existingManifestId, string name, string directoryPath, ContentType contentType, GameType targetGame, string? sourcePath = null, IProgress? progress = null, CancellationToken cancellationToken = default) + public Task> UpdateLocalContentManifestAsync(string existingManifestId, string name, string directoryPath, ContentType contentType, GameType targetGame, string? sourcePath = null, IProgress? progress = null, CancellationToken cancellationToken = default, string? entryPoint = null) { - return Task.FromResult(OperationResult.CreateSuccess(new ContentManifest { Name = name, ContentType = contentType, TargetGame = targetGame, SourcePath = sourcePath })); + var normalizedEntryPoint = !string.IsNullOrWhiteSpace(entryPoint) + ? entryPoint.Replace('\\', '/').TrimStart('/') + : null; + + return Task.FromResult(OperationResult.CreateSuccess(new ContentManifest { Name = name, ContentType = contentType, TargetGame = targetGame, SourcePath = sourcePath, EntryPoint = normalizedEntryPoint })); } } @@ -573,6 +601,12 @@ public static void UseDefaultConfiguration() /// public bool GetAutoCheckForUpdatesOnStartup() => true; + /// + public bool GetAutoCheckForUpdatesPeriodically() => true; + + /// + public int GetPeriodicUpdateCheckIntervalMinutes() => AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + /// public bool GetEnableDetailedLogging() => false; diff --git a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml index 62f0318db..805271908 100644 --- a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml +++ b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml @@ -733,7 +733,7 @@ - + diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index 18e049652..5c7303bbd 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -1606,11 +1606,16 @@ private async Task ApplyProfileSettingsToIniOptionsAsync(GameProfile profile) /// /// Applies GeneralsOnline-specific settings to the settings.json file. /// + /// + /// settings.json is a single global file owned by the GeneralsOnline client, not a + /// per-profile one. Only a GeneralsOnline profile may rewrite it: a retail, TheSuperHackers + /// or CommunityOutpost Zero Hour profile has nothing to say about that client's settings, + /// and writing anyway replaced whatever the user had configured inside the client itself. + /// /// The game profile containing the settings. private async Task ApplyGeneralsOnlineSettingsAsync(GameProfile profile) { - // Only apply if it's Zero Hour (as GO settings only apply there currently) - if (profile.GameClient?.GameType != GameType.ZeroHour) + if (profile.GameClient?.GameType != GameType.ZeroHour || !profile.IsGeneralsOnlineProfile()) { return; } @@ -1619,10 +1624,22 @@ private async Task ApplyGeneralsOnlineSettingsAsync(GameProfile profile) { logger.LogInformation("[GameLauncher] Applying GeneralsOnline settings to settings.json for profile {ProfileId}", profile.Id); - // Clean Launch Strategy: Create fresh settings object to ensure isolation and prevent pollution - var settings = new GeneralsOnlineSettings(); + // Loaded first so the settings the client owns and the profile says nothing about + // survive the rewrite; the mapper then overwrites only what the profile declares. + var loadResult = await gameSettingsService.LoadGeneralsOnlineSettingsAsync(); + if (loadResult?.Success != true || loadResult.Data == null) + { + // A missing settings.json loads as defaults and reports success, so a failure here + // means the client's own file exists and could not be read. Rewriting it from + // defaults would discard every key the client owns. + logger.LogWarning( + "[GameLauncher] Not writing GeneralsOnline settings because settings.json could not be read: {Error}", + loadResult?.FirstError ?? "LoadGeneralsOnlineSettings result was null"); + return; + } + + var settings = loadResult.Data; - // Map GO settings from profile using the centralized mapper GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); var saveResult = await gameSettingsService.SaveGeneralsOnlineSettingsAsync(settings); diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index fcecce36f..828107c9c 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -255,6 +255,28 @@ public IContentManifestBuilder WithPublisher( return this; } + /// + public IContentManifestBuilder WithPublisher(PublisherInfo publisher) + { + ArgumentNullException.ThrowIfNull(publisher); + + _manifest.Publisher = new PublisherInfo + { + Name = publisher.Name, + PublisherType = publisher.PublisherType, + Website = publisher.Website, + SupportUrl = publisher.SupportUrl, + ContactEmail = publisher.ContactEmail, + UpdateApiEndpoint = publisher.UpdateApiEndpoint, + ContentIndexUrl = publisher.ContentIndexUrl, + UpdateCheckIntervalHours = publisher.UpdateCheckIntervalHours, + SupportsIncrementalUpdates = publisher.SupportsIncrementalUpdates, + AuthenticationMethod = publisher.AuthenticationMethod, + }; + logger.LogDebug("Set publisher: {PublisherName} (Type: {PublisherType})", publisher.Name, publisher.PublisherType); + return this; + } + /// /// Sets the metadata for the manifest. /// @@ -358,6 +380,16 @@ public IContentManifestBuilder AddContentReference( return this; } + /// + public IContentManifestBuilder WithContentReferences(IEnumerable contentReferences) + { + ArgumentNullException.ThrowIfNull(contentReferences); + + _manifest.ContentReferences = [.. contentReferences]; + logger.LogDebug("Set {Count} content references", _manifest.ContentReferences.Count); + return this; + } + /// /// Adds files from a directory to the manifest. /// @@ -512,6 +544,7 @@ public Task AddContentAddressableFileAsync( { RelativePath = relativePath, SourceType = ContentSourceType.ContentAddressable, + InstallTarget = DetermineInstallTarget(relativePath), IsExecutable = isExecutable, Hash = hash, Size = size, @@ -614,69 +647,86 @@ public IContentManifestBuilder AddRequiredDirectories(params string[] directorie public IContentManifestBuilder WithInstallationInstructions( WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy) { - _manifest.InstallationInstructions = new InstallationInstructions - { - WorkspaceStrategy = workspaceStrategy, - }; + _manifest.InstallationInstructions = _manifest.InstallationInstructions == null + ? new InstallationInstructions { WorkspaceStrategy = workspaceStrategy } + : new InstallationInstructions + { + WorkspaceStrategy = workspaceStrategy, + DownloadHash = _manifest.InstallationInstructions.DownloadHash, + PostInstallSteps = _manifest.InstallationInstructions.PostInstallSteps == null + ? [] + : [.. _manifest.InstallationInstructions.PostInstallSteps], + }; + logger.LogDebug("Set workspace strategy: {Strategy}", workspaceStrategy); return this; } - /// - /// Adds a pre-installation step to the manifest. - /// - /// Step name. - /// Command. - /// Arguments. - /// Working directory. - /// Requires elevation. - /// The builder instance. - public IContentManifestBuilder AddPreInstallStep( - string name, - string command, - List? arguments = null, - string workingDirectory = "", - bool requiresElevation = false) + /// + public IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions) { - var step = new InstallationStep + ArgumentNullException.ThrowIfNull(installationInstructions); + + _manifest.InstallationInstructions = new InstallationInstructions { - Name = name, - Command = command, - Arguments = arguments ?? [], - WorkingDirectory = workingDirectory, - RequiresElevation = requiresElevation, + WorkspaceStrategy = installationInstructions.WorkspaceStrategy, + DownloadHash = installationInstructions.DownloadHash, + PostInstallSteps = installationInstructions.PostInstallSteps == null + ? [] + : [.. installationInstructions.PostInstallSteps], }; - _manifest.InstallationInstructions.PreInstallSteps.Add(step); - logger.LogDebug("Added pre-install step: {StepName}", name); + + logger.LogDebug( + "Set installation instructions with strategy {Strategy}, {PostCount} post-install steps", + _manifest.InstallationInstructions.WorkspaceStrategy, + _manifest.InstallationInstructions.PostInstallSteps.Count); return this; } - /// - /// Adds a post-installation step to the manifest. - /// - /// Step name. - /// Command. - /// Arguments. - /// Working directory. - /// Requires elevation. - /// The builder instance. + /// public IContentManifestBuilder AddPostInstallStep( string name, - string command, + InstallationStepKind kind, + string? targetRelativePath = null, List? arguments = null, - string workingDirectory = "", - bool requiresElevation = false) + string? destinationRelativePath = null, + bool requiresElevation = false, + string? statusMessage = null, + bool runOnce = false, + string? stepKey = null) { var step = new InstallationStep { Name = name, - Command = command, - Arguments = arguments ?? [], - WorkingDirectory = workingDirectory, + Kind = kind, + TargetRelativePath = targetRelativePath, + Arguments = arguments, + DestinationRelativePath = destinationRelativePath, RequiresElevation = requiresElevation, + StatusMessage = statusMessage, + RunOnce = runOnce, + StepKey = stepKey, }; + return AddPostInstallStep(step); + } + + /// + public IContentManifestBuilder AddPostInstallStep(InstallationStep step) + { + ArgumentNullException.ThrowIfNull(step); + if (step.Kind == InstallationStepKind.Unknown) + { + throw new ArgumentException("Installation step kind cannot be Unknown.", nameof(step)); + } + + if (string.IsNullOrWhiteSpace(step.Name)) + { + throw new ArgumentException("Installation step name cannot be empty or whitespace.", nameof(step)); + } + + _manifest.InstallationInstructions ??= new InstallationInstructions(); _manifest.InstallationInstructions.PostInstallSteps.Add(step); - logger.LogDebug("Added post-install step: {StepName}", name); + logger.LogDebug("Added post-install step: {StepName} (Kind: {Kind}, RunOnce: {RunOnce})", step.Name, step.Kind, step.RunOnce); return this; } diff --git a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs index b65437505..e72a12c44 100644 --- a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs +++ b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs @@ -127,7 +127,7 @@ public async Task InitializeCacheAsync(CancellationToken cancellationToken = def // is honoured; a raw SpecialFolder lookup would keep reading the default tree. var applicationDataPath = configurationProvider.GetApplicationDataPath(); var localManifestDir = Path.Combine(applicationDataPath, FileTypes.ManifestsDirectory); - var customManifestDir = Path.Combine(applicationDataPath, "CustomManifests"); + var customManifestDir = Path.Combine(applicationDataPath, DirectoryNames.CustomManifests); await DiscoverFileSystemManifestsAsync([localManifestDir, customManifestDir], cancellationToken); diff --git a/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs b/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs index 92c8154e2..fac3c8e95 100644 --- a/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs +++ b/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs @@ -24,6 +24,7 @@ public class NotificationService : INotificationService, IDisposable private readonly Subject _dismissSubject = new(); private readonly Subject _dismissAllSubject = new(); private readonly Subject _historySubject = new(); + private readonly Subject<(Guid Id, string? Title, string Message)> _updateSubject = new(); private readonly List _notificationHistory = new(); private readonly object _historyLock = new(); private readonly object _muteLock = new(); @@ -74,6 +75,9 @@ public NotificationService( /// public IObservable NotificationHistory => _historySubject; + /// + public IObservable<(Guid Id, string? Title, string Message)> UpdateRequests => _updateSubject; + /// public NotificationMuteState MuteState { @@ -175,6 +179,35 @@ public void Show(NotificationMessage notification) } } + /// + public void Update(Guid notificationId, string message, string? title = null) + { + if (_disposed) + { + _logger.LogWarning("Attempted to update notification after service disposal"); + return; + } + + ArgumentNullException.ThrowIfNull(message); + + lock (_historyLock) + { + var index = _notificationHistory.FindIndex(n => n.Id == notificationId); + if (index >= 0) + { + var existing = _notificationHistory[index]; + _notificationHistory[index] = existing with + { + Title = title ?? existing.Title, + Message = message, + }; + } + } + + _logger.LogDebug("Updating notification {NotificationId}: {Message}", notificationId, message); + _updateSubject.OnNext((notificationId, title, message)); + } + /// public async Task MuteSession(CancellationToken cancellationToken = default) { @@ -317,6 +350,7 @@ public void Dispose() _dismissSubject?.Dispose(); _dismissAllSubject?.Dispose(); _historySubject?.Dispose(); + _updateSubject?.Dispose(); _disposed = true; GC.SuppressFinalize(this); } diff --git a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs index 235bacfea..a010e81bd 100644 --- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs +++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs @@ -37,15 +37,11 @@ public partial class NotificationItemViewModel : ViewModelBase, IDisposable /// public NotificationType Type { get; } - /// - /// Gets the notification title. - /// - public string Title { get; } + [ObservableProperty] + private string _title; - /// - /// Gets the notification message. - /// - public string Message { get; } + [ObservableProperty] + private string _message; /// /// Gets the timestamp when the notification was created. @@ -118,8 +114,8 @@ public NotificationItemViewModel( Id = notification.Id; Type = notification.Type; - Title = notification.Title; - Message = notification.Message; + _title = notification.Title; + _message = notification.Message; Timestamp = notification.Timestamp; IsActionable = notification.IsActionable; _isVisible = false; @@ -135,10 +131,7 @@ public NotificationItemViewModel( StartDismissTimer(notification.AutoDismissMilliseconds.Value); } - Dispatcher.UIThread.Post(() => - { - IsVisible = true; - }); + Dispatcher.UIThread.Post(() => IsVisible = true); } /// diff --git a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs index 2637c7758..8b174a82f 100644 --- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs +++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs @@ -20,6 +20,7 @@ public class NotificationManagerViewModel : ViewModelBase, IDisposable private readonly IDisposable _notificationSubscription; private readonly IDisposable _dismissSubscription; private readonly IDisposable _dismissAllSubscription; + private readonly IDisposable _updateSubscription; private readonly object _lock = new(); private bool _disposed; @@ -48,6 +49,7 @@ public NotificationManagerViewModel( _notificationSubscription = _notificationService.Notifications.Subscribe(HandleNotificationReceived); _dismissSubscription = _notificationService.DismissRequests.Subscribe(HandleDismissRequest); _dismissAllSubscription = _notificationService.DismissAllRequests.Subscribe(_ => HandleDismissAllRequest()); + _updateSubscription = _notificationService.UpdateRequests.Subscribe(HandleUpdateRequest); _logger.LogInformation("NotificationManagerViewModel initialized"); } @@ -133,6 +135,7 @@ public void Dispose() _notificationSubscription?.Dispose(); _dismissSubscription?.Dispose(); _dismissAllSubscription?.Dispose(); + _updateSubscription?.Dispose(); foreach (var notification in ActiveNotifications) { @@ -157,6 +160,37 @@ private void HandleDismissRequest(Guid notificationId) RemoveNotification(notificationId); } + private void HandleUpdateRequest((Guid Id, string? Title, string Message) update) + { + _logger.LogDebug("Update request received for notification {NotificationId}", update.Id); + Dispatcher.UIThread.InvokeAsync( + () => + { + try + { + lock (_lock) + { + var notification = ActiveNotifications.FirstOrDefault(n => n.Id == update.Id); + if (notification != null) + { + if (update.Title is not null) + { + notification.Title = update.Title; + } + + notification.Message = update.Message; + _logger.LogDebug("Updated notification {NotificationId} message: {Message}", update.Id, update.Message); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating notification {NotificationId}", update.Id); + } + }, + DispatcherPriority.Send); + } + private void HandleDismissAllRequest() { _logger.LogDebug("Dismiss all request received"); diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index 32cce78ad..9f97f5367 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -68,6 +68,7 @@ public partial class SettingsViewModel : ObservableObject, IDisposable private readonly IGameInstallationService _installationService; private readonly IStorageLocationService _storageLocationService; private readonly IUserDataTracker _userDataTracker; + private readonly IDialogService _dialogService; private bool _isViewVisible; private bool _disposed; @@ -128,6 +129,12 @@ public partial class SettingsViewModel : ObservableObject, IDisposable [ObservableProperty] private bool _autoCheckForUpdatesOnStartup = true; + [ObservableProperty] + private bool _autoCheckForUpdatesPeriodically = true; + + [ObservableProperty] + private int _periodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + [ObservableProperty] private bool _allowBackgroundDownloads = true; @@ -195,12 +202,6 @@ public partial class SettingsViewModel : ObservableObject, IDisposable [ObservableProperty] private string _patStatusMessage = string.Empty; - [ObservableProperty] - private bool _isLoadingArtifacts; - - [ObservableProperty] - private ObservableCollection _availableArtifacts = []; - /// /// Initializes a new instance of the class. /// @@ -216,6 +217,7 @@ public partial class SettingsViewModel : ObservableObject, IDisposable /// Game installation service. /// Storage location service. /// User data tracker service. + /// Dialog service used to confirm destructive actions. /// GitHub token storage. public SettingsViewModel( IUserSettingsService userSettingsService, @@ -230,6 +232,7 @@ public SettingsViewModel( IGameInstallationService installationService, IStorageLocationService storageLocationService, IUserDataTracker userDataTracker, + IDialogService dialogService, IGitHubTokenStorage? gitHubTokenStorage = null) { _userSettingsService = userSettingsService ?? throw new ArgumentNullException(nameof(userSettingsService)); @@ -244,6 +247,7 @@ public SettingsViewModel( _installationService = installationService ?? throw new ArgumentNullException(nameof(installationService)); _storageLocationService = storageLocationService ?? throw new ArgumentNullException(nameof(storageLocationService)); _userDataTracker = userDataTracker ?? throw new ArgumentNullException(nameof(userDataTracker)); + _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); _gitHubTokenStorage = gitHubTokenStorage; LoadSettings(); @@ -486,6 +490,8 @@ private void LoadSettings() WorkspacePath = settings.WorkspacePath; MaxConcurrentDownloads = settings.MaxConcurrentDownloads; AutoCheckForUpdatesOnStartup = settings.AutoCheckForUpdatesOnStartup; + AutoCheckForUpdatesPeriodically = settings.AutoCheckForUpdatesPeriodically; + PeriodicUpdateCheckIntervalMinutes = settings.PeriodicUpdateCheckIntervalMinutes; AllowBackgroundDownloads = settings.AllowBackgroundDownloads; EnableDetailedLogging = settings.EnableDetailedLogging; DefaultWorkspaceStrategy = settings.DefaultWorkspaceStrategy; @@ -540,6 +546,8 @@ private async Task SaveSettings() settings.WorkspacePath = WorkspacePath; settings.MaxConcurrentDownloads = MaxConcurrentDownloads; settings.AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup; + settings.AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically; + settings.PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes; settings.AllowBackgroundDownloads = AllowBackgroundDownloads; settings.EnableDetailedLogging = EnableDetailedLogging; settings.DefaultWorkspaceStrategy = DefaultWorkspaceStrategy; @@ -569,6 +577,12 @@ private async Task SaveSettings() await _userSettingsService.SaveAsync(); + // Notify components of updated update settings + WeakReferenceMessenger.Default.Send(new UpdateSettingsChangedMessage( + AutoCheckForUpdatesOnStartup, + AutoCheckForUpdatesPeriodically, + PeriodicUpdateCheckIntervalMinutes)); + // Apply log level change immediately without restart Infrastructure.DependencyInjection.LoggingModule.SetLogLevel(EnableDetailedLogging); @@ -583,6 +597,10 @@ private async Task SaveSettings() catch (Exception ex) { _logger.LogError(ex, "Failed to save settings"); + _notificationService.ShowError( + "Settings Not Saved", + ex.Message, + (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds); } finally { @@ -600,6 +618,8 @@ private async Task ResetToDefaults() WorkspacePath = string.Empty; MaxConcurrentDownloads = DownloadDefaults.MaxConcurrentDownloads; AutoCheckForUpdatesOnStartup = true; + AutoCheckForUpdatesPeriodically = true; + PeriodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; AllowBackgroundDownloads = true; EnableDetailedLogging = false; DefaultWorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy; @@ -744,6 +764,14 @@ private bool ValidateSettings() DownloadBufferSizeKB = DownloadDefaults.BufferSizeKB; } + // Validate periodic update check interval + if (PeriodicUpdateCheckIntervalMinutes < AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes || + PeriodicUpdateCheckIntervalMinutes > AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes) + { + _logger.LogWarning("Invalid PeriodicUpdateCheckIntervalMinutes value: {Value}. Resetting to default.", PeriodicUpdateCheckIntervalMinutes); + PeriodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + } + // Validate game install path if specified if (!string.IsNullOrEmpty(WorkspacePath) && !Directory.Exists(WorkspacePath)) { @@ -1007,7 +1035,6 @@ private async Task DeletePatAsync() HasGitHubPat = false; IsPatValid = false; PatStatusMessage = "GitHub PAT removed"; - AvailableArtifacts.Clear(); } catch (Exception ex) { @@ -1034,66 +1061,59 @@ private void OpenUpdateWindow() } } - /// - /// Loads available CI artifacts for selection. - /// [RelayCommand] - private async Task LoadArtifactsAsync() + private async Task DeleteAllData() { - if (_updateManager == null || !HasGitHubPat) + try { - PatStatusMessage = "Configure a GitHub PAT to load artifacts"; - return; - } + _logger.LogWarning("Deleting ALL application data requested"); - IsLoadingArtifacts = true; - AvailableArtifacts.Clear(); + var confirmed = await _dialogService.ShowConfirmationAsync( + AppConstants.DeleteAllDataConfirmationTitle, + AppConstants.DeleteAllDataConfirmationMessage, + confirmText: AppConstants.DeleteAllDataConfirmText); - try - { - var artifact = await _updateManager.CheckForArtifactUpdatesAsync(); - if (artifact != null) + if (!confirmed) + { + _logger.LogInformation("Deleting ALL application data was cancelled at the confirmation prompt"); + return; + } + + await DeleteProfiles(); + await DeleteWorkspaces(); + await DeleteManifests(); + await DeleteCasStorage(); + var userDataDeleted = await DeleteUserDataInternalAsync(); + + // Invalidate installation cache to force re-generation of manifests on next scan + _installationService.InvalidateCache(); + + await UpdateDangerZoneDataAsync(); + + // A success toast on top of the partial-failure toast the user data deletion just raised + // would tell the user their data is gone while their originals are still on disk. + if (userDataDeleted) { - AvailableArtifacts.Add(artifact); - PatStatusMessage = $"Found {AvailableArtifacts.Count} artifact(s)"; + _notificationService.ShowSuccess( + "Data Deleted", + $"Profiles, workspaces, manifests, and user data were deleted. {CasDefaults.GarbageCollectionDisabledMessage}", + 5000); } else { - PatStatusMessage = "No artifacts available"; + _notificationService.ShowWarning( + "Data Partially Deleted", + $"Profiles, workspaces, and manifests were deleted, but some user data was kept. {CasDefaults.GarbageCollectionDisabledMessage}", + 5000); } } catch (Exception ex) { - _logger.LogError(ex, "Failed to load artifacts"); - PatStatusMessage = $"Error loading artifacts: {ex.Message}"; - } - finally - { - IsLoadingArtifacts = false; + _logger.LogError(ex, "Failed to delete all application data"); + _notificationService.ShowError("Deletion Failed", $"Failed to delete all application data: {ex.Message}", 5000); } } - [RelayCommand] - private async Task DeleteAllData() - { - _logger.LogWarning("Deleting ALL application data requested"); - - await DeleteProfiles(); - await DeleteWorkspaces(); - await DeleteManifests(); - await DeleteCasStorage(); - await DeleteUserData(); - - // Invalidate installation cache to force re-generation of manifests on next scan - _installationService.InvalidateCache(); - - await UpdateDangerZoneDataAsync(); - _notificationService.ShowSuccess( - "Data Deleted", - $"Profiles, workspaces, manifests, and user data were deleted. {CasDefaults.GarbageCollectionDisabledMessage}", - 5000); - } - [RelayCommand] private async Task UninstallGenHub() { @@ -1308,19 +1328,42 @@ private async Task CleanupOrphanedWorkspaceDirectoriesAsync() [RelayCommand] private async Task DeleteUserData() + { + await DeleteUserDataInternalAsync(); + } + + /// + /// Deletes the tracked user data and reports whether everything was actually removed, so a + /// caller that follows it with a summary message cannot contradict the partial-failure it raised. + /// + /// true when all tracked user data was deleted; otherwise, false. + private async Task DeleteUserDataInternalAsync() { try { _logger.LogWarning("Deleting all user data"); - await _userDataTracker.DeleteAllUserDataAsync(); - _notificationService.ShowSuccess("User Data Deleted", "All user data deleted successfully.", 3000); + var result = await _userDataTracker.DeleteAllUserDataAsync(); + if (result.Success) + { + _notificationService.ShowSuccess("User Data Deleted", "All user data deleted successfully.", 3000); + } + else + { + _logger.LogWarning("User data deletion kept some data: {Error}", result.FirstError); + _notificationService.ShowError( + "User Data Partially Deleted", + result.FirstError ?? "Some tracked user data could not be deleted.", + 5000); + } await UpdateDangerZoneDataAsync(); + return result.Success; } catch (Exception ex) { _logger.LogError(ex, "Failed to delete user data"); _notificationService.ShowError("Deletion Failed", $"Failed to delete user data: {ex.Message}", 5000); + return false; } } diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml index 719a8ae05..7a022331b 100644 --- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml +++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml @@ -734,6 +734,28 @@ Classes="setting-description" Margin="24,0,0,0" /> + + + + + + + + + + + + @@ -770,10 +792,6 @@