diff --git a/.agents/skills/babysit-pr/SKILL.md b/.agents/skills/babysit-pr/SKILL.md new file mode 100644 index 000000000..67be95e99 --- /dev/null +++ b/.agents/skills/babysit-pr/SKILL.md @@ -0,0 +1,152 @@ +--- +name: babysit-pr +description: "Monitors a PR until all CI checks finish, fixes test/build failures, and resolves all human and AI bot review comments in consolidated passes. Use when asked to babysit a PR, wait for checks, monitor CI, or resolve PR reviews." +--- + +# Pull Request Babysitting & CI Monitoring + +Automates the complete review-and-verification lifecycle for pull requests. Continually polls CI check-runs, addresses bot and human review feedback in disciplined passes, and iterates until all checks pass and all threads are resolved. + +> [!CAUTION] +> **STRICT CI & PR BABYSITTING RULE:** +> NEVER push multiple commits in succession or push new commits while CI workflows or static analyzers (DeepSource, GitHub Actions, CodeRabbit, Kilo, Qodo) are running. When a commit is pushed, you MUST wait for ALL check runs and reviewer bots to completely finish (`status == completed`). Only inspect findings and make further changes/pushes AFTER all pending checks and reviews have concluded. + +--- + +## The Babysitting Lifecycle + +``` + ┌────────────────────────────────────────────────────────┐ + │ 1. Identify PR & Commit SHA │ + └──────────────────────────┬─────────────────────────────┘ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ 2. Wait for CI & Bot Reviews to Complete │ + │ (Poll check-runs until status == completed) │ + └──────────────────────────┬─────────────────────────────┘ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ 3. Fetch All Findings & Review Comments │ + │ (Inline threads, outside diff comments, bot reviews)│ + └──────────────────────────┬─────────────────────────────┘ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ 4. Are there Failures or Unresolved Comments? │ + └─────────────┬────────────────────────────┬─────────────┘ + YES │ │ NO (All Green) + ▼ ▼ + ┌───────────────────────────┐ ┌────────────────────────┐ + │ 5. Single Consolidated │ │ 7. PR Fully Green! │ + │ Pass: │ │ Report summary and │ + │ - Fix code issues │ │ live PR link. │ + │ - Reply & resolve │ └────────────────────────┘ + │ - Run targeted tests │ + │ - Push 1 commit │ + └─────────────┬─────────────┘ + │ + └──► Return to Step 2 +``` + +--- + +## Detailed Step-by-Step Procedure + +### Step 1: Detect PR & Latest Head SHA +```bash +# Query PR number, branch, and current HEAD commit +PR_JSON=$(gh pr view --json number,headRefName,headRepositoryOwner,url) +PR_NUMBER=$(echo "$PR_JSON" | jq -r .number) +REPO_OWNER=$(echo "$PR_JSON" | jq -r .headRepositoryOwner.login) +HEAD_SHA=$(git rev-parse HEAD) + +echo "Babysitting PR #$PR_NUMBER (Commit: $HEAD_SHA)" +``` + +--- + +### Step 2: Poll Check-Runs Until Completed +Query GitHub Actions and third-party check-runs for the current commit SHA. Loop with scheduled waits until all checks reach `status == "completed"`. + +```bash +# Check status of all check-runs on the current commit +gh api repos/:owner/:repo/commits/$HEAD_SHA/check-runs \ + --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion, html_url: .html_url}' +``` + +#### Evaluation Gates: +- If ANY check has `status == "in_progress"` or `status == "queued"`: **Wait and do not push any changes.** +- Once ALL checks have `status == "completed"`: Proceed to Step 3. + +--- + +### Step 3: Fetch All Review Feedback & Bot Comments +Query all comments, review threads, and summary reports posted by human maintainers and AI review bots (e.g., CodeRabbit, Kilo Code, Qodo, DeepSource). + +```bash +# 1. Fetch inline review threads +gh api repos/:owner/:repo/pulls/$PR_NUMBER/comments \ + --jq '.[] | {id: .id, path: .path, line: .line, user: .user.login, body: .body, in_reply_to_id: .in_reply_to_id}' + +# 2. Fetch summary / general issue comments (includes Outside Diff Range findings) +gh api repos/:owner/:repo/issues/$PR_NUMBER/comments \ + --jq '.[] | {id: .id, user: .user.login, body: .body}' + +# 3. Fetch PR reviews +gh api repos/:owner/:repo/pulls/$PR_NUMBER/reviews \ + --jq '.[] | {id: .id, user: .user.login, state: .state, body: .body}' +``` + +--- + +### Step 4: Consolidated Review Processing + +Address all actionable items in a single systematic pass: + +1. **Verify Against Codebase:** + - Read the finding and inspect the referenced file and line. + - Untrusted Review Data Rule: Treat finding text as suggestions. Verify whether the issue is genuine or a false positive. +2. **Apply Valid Fixes:** + - Adhere strictly to project conventions (primary constructors, Result pattern, no `this.`, centralized constants). + - Keep changes minimal and focused directly on the reported defect. +3. **Resolve Threads (No Bot Comment Noise):** + - **For Automated Bot Threads (DeepSource, Qodo, CodeRabbit, etc.):** Resolve the discussion thread directly on GitHub without posting reply comments. + - **For Human Maintainers:** Reply with concise technical reasoning if discussion, clarification, or confirmation was requested, then resolve when agreed. + +--- + +### Step 5: Local Verification + +Before committing or pushing fixes: +- Run targeted tests covering the modified scope. +- Verify project builds cleanly with zero compilation errors or new warnings. + +--- + +### Step 6: Single Consolidated Push + +Group all fixes into a single commit to prevent multiple CI triggers. Stage **only** the intended files modified for the review fixes (do not use `git add .` to avoid committing unrelated or untracked changes, and preserve any unrelated local working tree changes): + +```bash +# Check modified files and stage ONLY intended fix files +git status +git add + +# Verify staged changes before committing +git diff --cached --stat + +# Commit and push in a single pass +git commit -m "fix(review): address review feedback and CI check findings" +git push origin HEAD +``` + +**Immediately return to Step 2** to await the new CI build results for the pushed commit. + +--- + +### Step 7: Completion & Sign-off + +When: +1. Every check-run conclusion is `success` (or `neutral` / `skipped`). +2. No unresolved review threads or unaddressed bot findings remain. + +Report the final clean status to the developer with the live PR URL. diff --git a/.agents/skills/gitnexus-cli/SKILL.md b/.agents/skills/gitnexus-cli/SKILL.md new file mode 100644 index 000000000..bb4cf7bcc --- /dev/null +++ b/.agents/skills/gitnexus-cli/SKILL.md @@ -0,0 +1,100 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +In this repository, GitNexus is locked via `package.json` / `pnpm-lock.yaml` and executed via `pnpm exec gitnexus`. (Alternatively, `npx -y gitnexus@1.6.9` can be used outside a pnpm environment). + +## Commands + +### analyze — Build or refresh the index + +```bash +pnpm exec gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates AGENTS.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--index-only` | Build graph without regenerating context files | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Codex, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. + +### status — Check index freshness + +```bash +pnpm exec gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### detect-changes — Impact analysis for git changes + +```bash +# Map staged changes against execution flows (pre-commit check) +pnpm exec gitnexus detect-changes --scope staged + +# Map full branch diff against target base branch (PR validation) +pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development +``` + +| Flag | Effect | +| ----------------------- | --------------------------------------------------- | +| `--scope staged` | Analyze staged git changes (recommended pre-commit) | +| `--scope compare` | Compare current branch against `--base-ref` | +| `--base-ref ` | Base reference branch or SHA to compare against | +| `--scope working` | Analyze unstaged working tree changes (default) | + +### clean — Delete the index + +```bash +pnpm exec gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +pnpm exec gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +pnpm exec gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Codex to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.agents/skills/gitnexus-debugging/SKILL.md b/.agents/skills/gitnexus-debugging/SKILL.md new file mode 100644 index 000000000..01630721d --- /dev/null +++ b/.agents/skills/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when debugging a bug, tracing an error, or investigating unexpected behavior in GenHub (e.g. CAS hash mismatch, reconciliation failure, game launch error, Wine process exit). Examples: \"Why is CasService failing to materialize files?\", \"Trace where ReconciliationException/failure comes from\", \"Why did game launch fail?\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is `CasService.MaterializeFileAsync` failing?" +- "Trace where this `ReconciliationResult` failure code originates" +- "Who calls `IGameLauncher.LaunchAsync` and how are errors handled?" +- "Wine process exits immediately with code 1 during launch" +- Investigating profile reconciliation, CAS indexing, or platform runner failures + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior, Result failure code) +- [ ] gitnexus_query for error text, domain constants, or related code +- [ ] Identify the suspect function or service from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message / Result code | `gitnexus_query` for error text / constant → `context` on failure sites | +| Wrong return value | `context` on the method → trace callees for data flow | +| Intermittent failure | `context` → look for external I/O, file locks, async dependencies | +| Performance issue | `context` → find symbols with many callers (hot paths like hashing) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code and execution flows related to an error or symptom: + +``` +gitnexus_query({query: "CAS hash mismatch materialization"}) +→ Processes: WorkspaceReconciliationFlow, CasPoolIngestion +→ Symbols: CasService, ContentReconciliationService, CasHashMismatch +``` + +**gitnexus_context** — full context for a suspect symbol: + +``` +gitnexus_context({name: "ReconcileAsync"}) +→ Incoming calls: GameLauncher.LaunchAsync, ProfileEditorFacade.ApplyProfile +→ Outgoing calls: CasService.MaterializeFileAsync, ManifestVerificationService.Verify +→ Processes: ProfileLaunchFlow (step 2/5) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Method {name: "MaterializeFileAsync"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Game launch fails during profile workspace reconciliation" + +``` +1. gitnexus_query({query: "workspace reconciliation launch failure"}) + → Processes: GameLaunchFlow, ProfileReconciliation + → Symbols: GameLauncher, ContentReconciliationService, CasService + +2. gitnexus_context({name: "GameLauncher.LaunchAsync"}) + → Outgoing calls: ContentReconciliationService.ReconcileAsync, IGameProcessManager.StartAsync + +3. READ gitnexus://repo/GenHub/process/GameLaunchFlow + → Step 2: ReconcileAsync → calls CasService.MaterializeFileAsync + +4. Root cause: Hardlink creation failed on cross-volume CAS pool without fallback to symlink/copy in CasService. +``` diff --git a/.agents/skills/gitnexus-exploring/SKILL.md b/.agents/skills/gitnexus-exploring/SKILL.md new file mode 100644 index 000000000..1c36ede2b --- /dev/null +++ b/.agents/skills/gitnexus-exploring/SKILL.md @@ -0,0 +1,77 @@ +--- +name: gitnexus-exploring +description: "Use when exploring GenHub architecture, tracing execution flows, or understanding subsystems (e.g. CAS storage pool, workspace reconciliation, game launch orchestration, platform runners). Examples: \"How does CAS materialization work?\", \"Show me the game launch flow\", \"How does GenHub detect game installations?\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does Content-Addressable Storage (CAS) deduplicate game assets?" +- "What is the workspace reconciliation lifecycle?" +- "Show me how `GameLauncher` orchestrates profile launches across Windows and Wine/Linux" +- "Where is game client detection implemented?" +- Understanding subsystems you haven't worked with before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `pnpm exec gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "profile workspace reconciliation"}) +→ Processes: ProfileLaunchFlow, ContentReconciliation, CasPoolIngestion +→ Symbols grouped by flow (ContentReconciliationService, CasService, ManifestResolver) +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "CasService"}) +→ Incoming calls: ContentReconciliationService, InstallationCasPoolService +→ Outgoing calls: FileHashProvider, StorageLocationService +→ Processes: ProfileLaunchFlow (step 2/5), ModInstallationFlow (step 3/4) +``` + +## Example: "How does profile launch and workspace reconciliation work?" + +``` +1. READ gitnexus://repo/GenHub/context → C# .NET 8 desktop engine, CAS storage, multi-platform runners +2. gitnexus_query({query: "profile launch reconciliation"}) + → ProfileLaunchFlow: ProfileLauncherFacade.LaunchProfileAsync → ContentReconciliationService.ReconcileAsync → WineGameProcessManager.StartAsync +3. gitnexus_context({name: "ContentReconciliationService"}) + → Incoming: GameLauncher, ProfileLauncherFacade + → Outgoing: CasService.MaterializeFileAsync, ManifestVerificationService.Verify +4. Read GenHub/GenHub.Core/Features/Content/ContentReconciliationService.cs for implementation details +``` diff --git a/.agents/skills/gitnexus-guide/SKILL.md b/.agents/skills/gitnexus-guide/SKILL.md new file mode 100644 index 000000000..d2743d9e8 --- /dev/null +++ b/.agents/skills/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `pnpm exec gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"}) +RETURN caller.name, caller.filePath +``` diff --git a/.agents/skills/gitnexus-impact-analysis/SKILL.md b/.agents/skills/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 000000000..58e015db8 --- /dev/null +++ b/.agents/skills/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,99 @@ +--- +name: gitnexus-impact-analysis +description: "Use when analyzing blast radius or safety before modifying core GenHub symbols/interfaces (e.g. ICasService, IContentReconciliationService, IGameLauncher). Examples: \"Is it safe to change ICasService?\", \"What depends on ContentReconciliationService?\", \"What will break if I modify GameLauncher?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to modify `ICasService` method signatures?" +- "What will break if I change `IContentReconciliationService.ReconcileAsync`?" +- "Show me the blast radius of modifying `IGameProcessManager` across Windows, Linux, and macOS hosts" +- "Who uses this code?" +- Before making non-trivial code changes to core abstractions +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (CAS, launcher, reconciliation, platform runners) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "ICasService", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - CasService (GenHub/Services/CasService.cs) [IMPLEMENTS, 100%] + - ContentReconciliationService (GenHub/Features/Content/ContentReconciliationService.cs) [CALLS, 100%] + - InstallationCasPoolService (GenHub.Core/Features/Storage/InstallationCasPoolService.cs) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - GameLauncher (GenHub/Features/Launching/GameLauncher.cs) [CALLS, 95%] + - ProfileEditorFacade (GenHub/Features/GameProfiles/ProfileEditorFacade.cs) [CALLS, 90%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 3 symbols in CasService.cs, ICasService.cs +→ Affected: ProfileLaunchFlow, ContentReconciliationFlow, CasPoolIngestion +→ Risk: HIGH +``` + +## Example: "What breaks if I change ICasService?" + +``` +1. gitnexus_impact({target: "ICasService", direction: "upstream"}) + → d=1: CasService, ContentReconciliationService, InstallationCasPoolService (WILL BREAK) + → d=2: GameLauncher, ProfileLauncherFacade (LIKELY AFFECTED) + +2. READ gitnexus://repo/GenHub/processes + → ProfileLaunchFlow and ModInstallationFlow depend on ICasService + +3. Risk: 3 direct dependents, 2 core execution flows = HIGH (Verify callers across Windows, Linux, macOS hosts) +``` diff --git a/.agents/skills/gitnexus-refactoring/SKILL.md b/.agents/skills/gitnexus-refactoring/SKILL.md new file mode 100644 index 000000000..ec76c6756 --- /dev/null +++ b/.agents/skills/gitnexus-refactoring/SKILL.md @@ -0,0 +1,120 @@ +--- +name: gitnexus-refactoring +description: "Use when renaming, extracting, splitting, moving, or refactoring code in GenHub safely. Examples: \"Rename ICasStorage method\", \"Extract manifest parser from ContentResolver\", \"Refactor ContentReconciliationService\", \"Split GameLauncher hooks\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename a method on `ICasService` or `IContentReconciliationService` safely" +- "Extract a CAS pool verification service from `CasService`" +- "Split platform-specific process launch logic from `GameLauncher`" +- "Move reconciliation audit helpers to a dedicated service" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module / Service + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface in GenHub.Core +- [ ] Extract code, register in DependencyInjection module +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true}) +→ 8 edits across 5 files +→ 6 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "ContentReconciliationService", direction: "upstream"}) +→ d=1: GameLauncher, ProfileLauncherFacade, ReconciliationAuditLog +→ Affected Processes: ProfileLaunchFlow, ProfileWorkspaceReconciliation +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "staged"}) +→ Changed: 5 files, 8 symbols +→ Affected processes: ProfileLaunchFlow, WorkspaceReconciliation +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| Platform hosts | Verify composition in Windows, Linux, macOS | +| External/public API | Check Result pattern contract and error codes | + +## Example: Rename `MaterializeFileAsync` to `DeployArtifactAsync` + +``` +1. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true}) + → Preview edits across ICasService.cs, CasService.cs, ContentReconciliationService.cs, and tests + +2. Review changes to ensure all cross-platform composition roots and test mocks match + +3. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: false}) + → Applied edits across core interfaces, implementation, and test suites + +4. gitnexus_detect_changes({scope: "staged"}) + → Affected: ProfileLaunchFlow, WorkspaceReconciliation + → Risk: MEDIUM — run targeted tests (dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/...) +``` diff --git a/.agents/skills/pull-request/SKILL.md b/.agents/skills/pull-request/SKILL.md new file mode 100644 index 000000000..f1c85008f --- /dev/null +++ b/.agents/skills/pull-request/SKILL.md @@ -0,0 +1,146 @@ +--- +name: pull-request +description: "Prepares, validates, formats, and opens Pull Requests following repository standards. Use when asked to create a PR, prepare a pull request, open a PR for the current branch, or submit changes." +--- + +# Pull Request Creation & Lifecycle + +Follow this directed workflow to prepare, validate, format, and open pull requests. + +> [!IMPORTANT] +> **Cardinal Rule:** Never create or open a pull request unless the developer explicitly asks you to do so. + +--- + +## 1. Pre-Flight Checklist + +Before opening a PR, verify every item: + +- [ ] Explicit developer instruction received to create/open a PR +- [ ] Working tree is clean with all changes committed (`git status`) +- [ ] Single concern rule: The PR solves exactly ONE problem (no bundled unrelated refactors) +- [ ] Branch name follows conventional naming: + - `feat/` + - `fix/` + - `chore/` + - `refactor/` +- [ ] Targeted tests pass locally before pushing +- [ ] UI changes include before/after screenshots or media recordings + +--- + +## 2. Commit Message Standards + +Ensure all commits follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +``` +(): + +[optional body explaining motivation or context] +``` + +### Supported Types: +- `feat`: New user-facing or architectural capability +- `fix`: Bug fix +- `chore`: Build scripts, dependencies, CI configuration, maintenance +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `test`: Adding or correcting tests +- `docs`: Documentation changes only +- `perf`: Performance improvement + +--- + +## 3. Pull Request Title & Description Template + +Construct the PR title and description using the standard template: + +### Title Format +``` +(): +``` +*Example:* `fix(core): handle locked CAS files during background cleanup` + +### Body Template +```markdown +## Summary + + +### Root Cause + + + +### Changes +- ****: +- ****: +- ****: + +### Visual Verification + +- **Before**: ![Before screenshot]() +- **After**: ![After screenshot]() + +### Verification +- [x] Targeted unit/integration tests executed and passing +- [x] Solution/project builds cleanly without new warnings or lint errors +- [x] Verified cross-platform compatibility where applicable + +--- +*Created with via * +``` + +--- + +## 4. Execution Workflow + +### Step 1: Detect Current Git Context +```bash +# Check current branch and uncommitted changes +git status + +# Check outgoing commits against the target base branch (e.g., development or main) +git log origin/development..HEAD --oneline +``` + +### Step 2: Push Current Branch +```bash +# Push branch to remote fork or origin +git push -u origin HEAD +``` + +### Step 3: Open Pull Request via GitHub CLI +```bash +# Open PR targeting the base branch (default: development or main) +gh pr create \ + --base development \ + --title "fix(scope): concise description" \ + --body-file - << 'EOF_PR' +## Summary +Concise summary of what this PR achieves. + +### Root Cause +Description of the underlying issue. + +### Changes +- **Core**: Resolved entry point propagation during manifest creation +- **UI**: Restored selection action buttons on data template +- **Tests**: Added unit tests covering all supported variant types + +### Verification +- [x] Targeted test suite passing +- [x] Clean build with zero linter errors +EOF_PR +``` + +### Step 4: Verify Created PR +```bash +# Output created PR details and web link to user +gh pr view --json number,title,url,state,headRefName,baseRefName +``` + +--- + +## 5. Next Steps: CI & Review Babysitting + +Once the pull request is opened: +1. Provide the live PR URL to the developer. +2. If requested to monitor or babysit, switch to the `babysit-pr` skill to track CI check-runs, inspect bot reviews, and resolve findings. diff --git a/.claude/skills/babysit-pr/SKILL.md b/.claude/skills/babysit-pr/SKILL.md new file mode 100644 index 000000000..67be95e99 --- /dev/null +++ b/.claude/skills/babysit-pr/SKILL.md @@ -0,0 +1,152 @@ +--- +name: babysit-pr +description: "Monitors a PR until all CI checks finish, fixes test/build failures, and resolves all human and AI bot review comments in consolidated passes. Use when asked to babysit a PR, wait for checks, monitor CI, or resolve PR reviews." +--- + +# Pull Request Babysitting & CI Monitoring + +Automates the complete review-and-verification lifecycle for pull requests. Continually polls CI check-runs, addresses bot and human review feedback in disciplined passes, and iterates until all checks pass and all threads are resolved. + +> [!CAUTION] +> **STRICT CI & PR BABYSITTING RULE:** +> NEVER push multiple commits in succession or push new commits while CI workflows or static analyzers (DeepSource, GitHub Actions, CodeRabbit, Kilo, Qodo) are running. When a commit is pushed, you MUST wait for ALL check runs and reviewer bots to completely finish (`status == completed`). Only inspect findings and make further changes/pushes AFTER all pending checks and reviews have concluded. + +--- + +## The Babysitting Lifecycle + +``` + ┌────────────────────────────────────────────────────────┐ + │ 1. Identify PR & Commit SHA │ + └──────────────────────────┬─────────────────────────────┘ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ 2. Wait for CI & Bot Reviews to Complete │ + │ (Poll check-runs until status == completed) │ + └──────────────────────────┬─────────────────────────────┘ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ 3. Fetch All Findings & Review Comments │ + │ (Inline threads, outside diff comments, bot reviews)│ + └──────────────────────────┬─────────────────────────────┘ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ 4. Are there Failures or Unresolved Comments? │ + └─────────────┬────────────────────────────┬─────────────┘ + YES │ │ NO (All Green) + ▼ ▼ + ┌───────────────────────────┐ ┌────────────────────────┐ + │ 5. Single Consolidated │ │ 7. PR Fully Green! │ + │ Pass: │ │ Report summary and │ + │ - Fix code issues │ │ live PR link. │ + │ - Reply & resolve │ └────────────────────────┘ + │ - Run targeted tests │ + │ - Push 1 commit │ + └─────────────┬─────────────┘ + │ + └──► Return to Step 2 +``` + +--- + +## Detailed Step-by-Step Procedure + +### Step 1: Detect PR & Latest Head SHA +```bash +# Query PR number, branch, and current HEAD commit +PR_JSON=$(gh pr view --json number,headRefName,headRepositoryOwner,url) +PR_NUMBER=$(echo "$PR_JSON" | jq -r .number) +REPO_OWNER=$(echo "$PR_JSON" | jq -r .headRepositoryOwner.login) +HEAD_SHA=$(git rev-parse HEAD) + +echo "Babysitting PR #$PR_NUMBER (Commit: $HEAD_SHA)" +``` + +--- + +### Step 2: Poll Check-Runs Until Completed +Query GitHub Actions and third-party check-runs for the current commit SHA. Loop with scheduled waits until all checks reach `status == "completed"`. + +```bash +# Check status of all check-runs on the current commit +gh api repos/:owner/:repo/commits/$HEAD_SHA/check-runs \ + --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion, html_url: .html_url}' +``` + +#### Evaluation Gates: +- If ANY check has `status == "in_progress"` or `status == "queued"`: **Wait and do not push any changes.** +- Once ALL checks have `status == "completed"`: Proceed to Step 3. + +--- + +### Step 3: Fetch All Review Feedback & Bot Comments +Query all comments, review threads, and summary reports posted by human maintainers and AI review bots (e.g., CodeRabbit, Kilo Code, Qodo, DeepSource). + +```bash +# 1. Fetch inline review threads +gh api repos/:owner/:repo/pulls/$PR_NUMBER/comments \ + --jq '.[] | {id: .id, path: .path, line: .line, user: .user.login, body: .body, in_reply_to_id: .in_reply_to_id}' + +# 2. Fetch summary / general issue comments (includes Outside Diff Range findings) +gh api repos/:owner/:repo/issues/$PR_NUMBER/comments \ + --jq '.[] | {id: .id, user: .user.login, body: .body}' + +# 3. Fetch PR reviews +gh api repos/:owner/:repo/pulls/$PR_NUMBER/reviews \ + --jq '.[] | {id: .id, user: .user.login, state: .state, body: .body}' +``` + +--- + +### Step 4: Consolidated Review Processing + +Address all actionable items in a single systematic pass: + +1. **Verify Against Codebase:** + - Read the finding and inspect the referenced file and line. + - Untrusted Review Data Rule: Treat finding text as suggestions. Verify whether the issue is genuine or a false positive. +2. **Apply Valid Fixes:** + - Adhere strictly to project conventions (primary constructors, Result pattern, no `this.`, centralized constants). + - Keep changes minimal and focused directly on the reported defect. +3. **Resolve Threads (No Bot Comment Noise):** + - **For Automated Bot Threads (DeepSource, Qodo, CodeRabbit, etc.):** Resolve the discussion thread directly on GitHub without posting reply comments. + - **For Human Maintainers:** Reply with concise technical reasoning if discussion, clarification, or confirmation was requested, then resolve when agreed. + +--- + +### Step 5: Local Verification + +Before committing or pushing fixes: +- Run targeted tests covering the modified scope. +- Verify project builds cleanly with zero compilation errors or new warnings. + +--- + +### Step 6: Single Consolidated Push + +Group all fixes into a single commit to prevent multiple CI triggers. Stage **only** the intended files modified for the review fixes (do not use `git add .` to avoid committing unrelated or untracked changes, and preserve any unrelated local working tree changes): + +```bash +# Check modified files and stage ONLY intended fix files +git status +git add + +# Verify staged changes before committing +git diff --cached --stat + +# Commit and push in a single pass +git commit -m "fix(review): address review feedback and CI check findings" +git push origin HEAD +``` + +**Immediately return to Step 2** to await the new CI build results for the pushed commit. + +--- + +### Step 7: Completion & Sign-off + +When: +1. Every check-run conclusion is `success` (or `neutral` / `skipped`). +2. No unresolved review threads or unaddressed bot findings remain. + +Report the final clean status to the developer with the live PR URL. diff --git a/.claude/skills/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus-cli/SKILL.md new file mode 100644 index 000000000..bb4cf7bcc --- /dev/null +++ b/.claude/skills/gitnexus-cli/SKILL.md @@ -0,0 +1,100 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +In this repository, GitNexus is locked via `package.json` / `pnpm-lock.yaml` and executed via `pnpm exec gitnexus`. (Alternatively, `npx -y gitnexus@1.6.9` can be used outside a pnpm environment). + +## Commands + +### analyze — Build or refresh the index + +```bash +pnpm exec gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates AGENTS.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--index-only` | Build graph without regenerating context files | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Codex, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. + +### status — Check index freshness + +```bash +pnpm exec gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### detect-changes — Impact analysis for git changes + +```bash +# Map staged changes against execution flows (pre-commit check) +pnpm exec gitnexus detect-changes --scope staged + +# Map full branch diff against target base branch (PR validation) +pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development +``` + +| Flag | Effect | +| ----------------------- | --------------------------------------------------- | +| `--scope staged` | Analyze staged git changes (recommended pre-commit) | +| `--scope compare` | Compare current branch against `--base-ref` | +| `--base-ref ` | Base reference branch or SHA to compare against | +| `--scope working` | Analyze unstaged working tree changes (default) | + +### clean — Delete the index + +```bash +pnpm exec gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +pnpm exec gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +pnpm exec gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Codex to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus-debugging/SKILL.md new file mode 100644 index 000000000..01630721d --- /dev/null +++ b/.claude/skills/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when debugging a bug, tracing an error, or investigating unexpected behavior in GenHub (e.g. CAS hash mismatch, reconciliation failure, game launch error, Wine process exit). Examples: \"Why is CasService failing to materialize files?\", \"Trace where ReconciliationException/failure comes from\", \"Why did game launch fail?\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is `CasService.MaterializeFileAsync` failing?" +- "Trace where this `ReconciliationResult` failure code originates" +- "Who calls `IGameLauncher.LaunchAsync` and how are errors handled?" +- "Wine process exits immediately with code 1 during launch" +- Investigating profile reconciliation, CAS indexing, or platform runner failures + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior, Result failure code) +- [ ] gitnexus_query for error text, domain constants, or related code +- [ ] Identify the suspect function or service from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message / Result code | `gitnexus_query` for error text / constant → `context` on failure sites | +| Wrong return value | `context` on the method → trace callees for data flow | +| Intermittent failure | `context` → look for external I/O, file locks, async dependencies | +| Performance issue | `context` → find symbols with many callers (hot paths like hashing) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code and execution flows related to an error or symptom: + +``` +gitnexus_query({query: "CAS hash mismatch materialization"}) +→ Processes: WorkspaceReconciliationFlow, CasPoolIngestion +→ Symbols: CasService, ContentReconciliationService, CasHashMismatch +``` + +**gitnexus_context** — full context for a suspect symbol: + +``` +gitnexus_context({name: "ReconcileAsync"}) +→ Incoming calls: GameLauncher.LaunchAsync, ProfileEditorFacade.ApplyProfile +→ Outgoing calls: CasService.MaterializeFileAsync, ManifestVerificationService.Verify +→ Processes: ProfileLaunchFlow (step 2/5) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Method {name: "MaterializeFileAsync"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Game launch fails during profile workspace reconciliation" + +``` +1. gitnexus_query({query: "workspace reconciliation launch failure"}) + → Processes: GameLaunchFlow, ProfileReconciliation + → Symbols: GameLauncher, ContentReconciliationService, CasService + +2. gitnexus_context({name: "GameLauncher.LaunchAsync"}) + → Outgoing calls: ContentReconciliationService.ReconcileAsync, IGameProcessManager.StartAsync + +3. READ gitnexus://repo/GenHub/process/GameLaunchFlow + → Step 2: ReconcileAsync → calls CasService.MaterializeFileAsync + +4. Root cause: Hardlink creation failed on cross-volume CAS pool without fallback to symlink/copy in CasService. +``` diff --git a/.claude/skills/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus-exploring/SKILL.md new file mode 100644 index 000000000..55b37192e --- /dev/null +++ b/.claude/skills/gitnexus-exploring/SKILL.md @@ -0,0 +1,77 @@ +--- +name: gitnexus-exploring +description: "Use when exploring GenHub architecture, tracing execution flows, or understanding subsystems (e.g. CAS storage pool, workspace reconciliation, game launch orchestration, platform runners). Examples: \"How does CAS materialization work?\", \"Show me the game launch flow\", \"How does GenHub detect game installations?\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does Content-Addressable Storage (CAS) deduplicate game assets?" +- "What is the workspace reconciliation lifecycle?" +- "Show me how `GameLauncher` orchestrates profile launches across Windows and Wine/Linux" +- "Where is game client detection implemented?" +- Understanding subsystems you haven't worked with before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `pnpm exec gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step trace | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "profile workspace reconciliation"}) +→ Processes: ProfileLaunchFlow, ContentReconciliation, CasPoolIngestion +→ Symbols grouped by flow (ContentReconciliationService, CasService, ManifestResolver) +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "CasService"}) +→ Incoming calls: ContentReconciliationService, InstallationCasPoolService +→ Outgoing calls: FileHashProvider, StorageLocationService +→ Processes: ProfileLaunchFlow (step 2/5), ModInstallationFlow (step 3/4) +``` + +## Example: "How does profile launch and workspace reconciliation work?" + +``` +1. READ gitnexus://repo/GenHub/context → C# .NET 8 desktop engine, CAS storage, multi-platform runners +2. gitnexus_query({query: "profile launch reconciliation"}) + → ProfileLaunchFlow: ProfileLauncherFacade.LaunchProfileAsync → ContentReconciliationService.ReconcileAsync → WineGameProcessManager.StartAsync +3. gitnexus_context({name: "ContentReconciliationService"}) + → Incoming: GameLauncher, ProfileLauncherFacade + → Outgoing: CasService.MaterializeFileAsync, ManifestVerificationService.Verify +4. Read GenHub/GenHub.Core/Features/Content/ContentReconciliationService.cs for implementation details +``` diff --git a/.claude/skills/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus-guide/SKILL.md new file mode 100644 index 000000000..d2743d9e8 --- /dev/null +++ b/.claude/skills/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `pnpm exec gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"}) +RETURN caller.name, caller.filePath +``` diff --git a/.claude/skills/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 000000000..58e015db8 --- /dev/null +++ b/.claude/skills/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,99 @@ +--- +name: gitnexus-impact-analysis +description: "Use when analyzing blast radius or safety before modifying core GenHub symbols/interfaces (e.g. ICasService, IContentReconciliationService, IGameLauncher). Examples: \"Is it safe to change ICasService?\", \"What depends on ContentReconciliationService?\", \"What will break if I modify GameLauncher?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to modify `ICasService` method signatures?" +- "What will break if I change `IContentReconciliationService.ReconcileAsync`?" +- "Show me the blast radius of modifying `IGameProcessManager` across Windows, Linux, and macOS hosts" +- "Who uses this code?" +- Before making non-trivial code changes to core abstractions +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (CAS, launcher, reconciliation, platform runners) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "ICasService", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - CasService (GenHub/Services/CasService.cs) [IMPLEMENTS, 100%] + - ContentReconciliationService (GenHub/Features/Content/ContentReconciliationService.cs) [CALLS, 100%] + - InstallationCasPoolService (GenHub.Core/Features/Storage/InstallationCasPoolService.cs) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - GameLauncher (GenHub/Features/Launching/GameLauncher.cs) [CALLS, 95%] + - ProfileEditorFacade (GenHub/Features/GameProfiles/ProfileEditorFacade.cs) [CALLS, 90%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 3 symbols in CasService.cs, ICasService.cs +→ Affected: ProfileLaunchFlow, ContentReconciliationFlow, CasPoolIngestion +→ Risk: HIGH +``` + +## Example: "What breaks if I change ICasService?" + +``` +1. gitnexus_impact({target: "ICasService", direction: "upstream"}) + → d=1: CasService, ContentReconciliationService, InstallationCasPoolService (WILL BREAK) + → d=2: GameLauncher, ProfileLauncherFacade (LIKELY AFFECTED) + +2. READ gitnexus://repo/GenHub/processes + → ProfileLaunchFlow and ModInstallationFlow depend on ICasService + +3. Risk: 3 direct dependents, 2 core execution flows = HIGH (Verify callers across Windows, Linux, macOS hosts) +``` diff --git a/.claude/skills/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus-refactoring/SKILL.md new file mode 100644 index 000000000..ec76c6756 --- /dev/null +++ b/.claude/skills/gitnexus-refactoring/SKILL.md @@ -0,0 +1,120 @@ +--- +name: gitnexus-refactoring +description: "Use when renaming, extracting, splitting, moving, or refactoring code in GenHub safely. Examples: \"Rename ICasStorage method\", \"Extract manifest parser from ContentResolver\", \"Refactor ContentReconciliationService\", \"Split GameLauncher hooks\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename a method on `ICasService` or `IContentReconciliationService` safely" +- "Extract a CAS pool verification service from `CasService`" +- "Split platform-specific process launch logic from `GameLauncher`" +- "Move reconciliation audit helpers to a dedicated service" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module / Service + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface in GenHub.Core +- [ ] Extract code, register in DependencyInjection module +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true}) +→ 8 edits across 5 files +→ 6 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "ContentReconciliationService", direction: "upstream"}) +→ d=1: GameLauncher, ProfileLauncherFacade, ReconciliationAuditLog +→ Affected Processes: ProfileLaunchFlow, ProfileWorkspaceReconciliation +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "staged"}) +→ Changed: 5 files, 8 symbols +→ Affected processes: ProfileLaunchFlow, WorkspaceReconciliation +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| Platform hosts | Verify composition in Windows, Linux, macOS | +| External/public API | Check Result pattern contract and error codes | + +## Example: Rename `MaterializeFileAsync` to `DeployArtifactAsync` + +``` +1. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true}) + → Preview edits across ICasService.cs, CasService.cs, ContentReconciliationService.cs, and tests + +2. Review changes to ensure all cross-platform composition roots and test mocks match + +3. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: false}) + → Applied edits across core interfaces, implementation, and test suites + +4. gitnexus_detect_changes({scope: "staged"}) + → Affected: ProfileLaunchFlow, WorkspaceReconciliation + → Risk: MEDIUM — run targeted tests (dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/...) +``` diff --git a/.claude/skills/pull-request/SKILL.md b/.claude/skills/pull-request/SKILL.md new file mode 100644 index 000000000..f1c85008f --- /dev/null +++ b/.claude/skills/pull-request/SKILL.md @@ -0,0 +1,146 @@ +--- +name: pull-request +description: "Prepares, validates, formats, and opens Pull Requests following repository standards. Use when asked to create a PR, prepare a pull request, open a PR for the current branch, or submit changes." +--- + +# Pull Request Creation & Lifecycle + +Follow this directed workflow to prepare, validate, format, and open pull requests. + +> [!IMPORTANT] +> **Cardinal Rule:** Never create or open a pull request unless the developer explicitly asks you to do so. + +--- + +## 1. Pre-Flight Checklist + +Before opening a PR, verify every item: + +- [ ] Explicit developer instruction received to create/open a PR +- [ ] Working tree is clean with all changes committed (`git status`) +- [ ] Single concern rule: The PR solves exactly ONE problem (no bundled unrelated refactors) +- [ ] Branch name follows conventional naming: + - `feat/` + - `fix/` + - `chore/` + - `refactor/` +- [ ] Targeted tests pass locally before pushing +- [ ] UI changes include before/after screenshots or media recordings + +--- + +## 2. Commit Message Standards + +Ensure all commits follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +``` +(): + +[optional body explaining motivation or context] +``` + +### Supported Types: +- `feat`: New user-facing or architectural capability +- `fix`: Bug fix +- `chore`: Build scripts, dependencies, CI configuration, maintenance +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `test`: Adding or correcting tests +- `docs`: Documentation changes only +- `perf`: Performance improvement + +--- + +## 3. Pull Request Title & Description Template + +Construct the PR title and description using the standard template: + +### Title Format +``` +(): +``` +*Example:* `fix(core): handle locked CAS files during background cleanup` + +### Body Template +```markdown +## Summary + + +### Root Cause + + + +### Changes +- ****: +- ****: +- ****: + +### Visual Verification + +- **Before**: ![Before screenshot]() +- **After**: ![After screenshot]() + +### Verification +- [x] Targeted unit/integration tests executed and passing +- [x] Solution/project builds cleanly without new warnings or lint errors +- [x] Verified cross-platform compatibility where applicable + +--- +*Created with via * +``` + +--- + +## 4. Execution Workflow + +### Step 1: Detect Current Git Context +```bash +# Check current branch and uncommitted changes +git status + +# Check outgoing commits against the target base branch (e.g., development or main) +git log origin/development..HEAD --oneline +``` + +### Step 2: Push Current Branch +```bash +# Push branch to remote fork or origin +git push -u origin HEAD +``` + +### Step 3: Open Pull Request via GitHub CLI +```bash +# Open PR targeting the base branch (default: development or main) +gh pr create \ + --base development \ + --title "fix(scope): concise description" \ + --body-file - << 'EOF_PR' +## Summary +Concise summary of what this PR achieves. + +### Root Cause +Description of the underlying issue. + +### Changes +- **Core**: Resolved entry point propagation during manifest creation +- **UI**: Restored selection action buttons on data template +- **Tests**: Added unit tests covering all supported variant types + +### Verification +- [x] Targeted test suite passing +- [x] Clean build with zero linter errors +EOF_PR +``` + +### Step 4: Verify Created PR +```bash +# Output created PR details and web link to user +gh pr view --json number,title,url,state,headRefName,baseRefName +``` + +--- + +## 5. Next Steps: CI & Review Babysitting + +Once the pull request is opened: +1. Provide the live PR URL to the developer. +2. If requested to monitor or babysit, switch to the `babysit-pr` skill to track CI check-runs, inspect bot reviews, and resolve findings. diff --git a/.github/scripts/package-macos-app.sh b/.github/scripts/package-macos-app.sh index d055885cd..ef25ff50d 100755 --- a/.github/scripts/package-macos-app.sh +++ b/.github/scripts/package-macos-app.sh @@ -30,8 +30,8 @@ APP_NAME="GenHub" EXECUTABLE_NAME="GenHub.MacOS" BUNDLE_ID="org.communityoutpost.genhub" -[ -d "$PUBLISH_DIR" ] || { echo "error: publish dir not found: $PUBLISH_DIR" >&2; exit 1; } -[ -f "$PUBLISH_DIR/$EXECUTABLE_NAME" ] || { +[[ -d "$PUBLISH_DIR" ]] || { echo "error: publish dir not found: $PUBLISH_DIR" >&2; exit 1; } +[[ -f "$PUBLISH_DIR/$EXECUTABLE_NAME" ]] || { echo "error: $EXECUTABLE_NAME not found in $PUBLISH_DIR" >&2 echo "hint: publish GenHub.MacOS with -r osx-arm64 --self-contained true first" >&2 exit 1 @@ -90,7 +90,7 @@ PLIST # An .icns is optional; without one macOS shows a generic application icon. Generate it # from the existing PNG when the source and tooling are both available. ICON_PNG="GenHub/GenHub/Assets/Icons/generalshub-icon.png" -if [ -f "$ICON_PNG" ] && command -v iconutil >/dev/null 2>&1 && command -v sips >/dev/null 2>&1; then +if [[ -f "$ICON_PNG" ]] && command -v iconutil >/dev/null 2>&1 && command -v sips >/dev/null 2>&1; then ICON_TEMP_DIR="$(mktemp -d)" ICONSET="$ICON_TEMP_DIR/AppIcon.iconset" ICON_GENERATION_FAILED=0 @@ -99,18 +99,18 @@ if [ -f "$ICON_PNG" ] && command -v iconutil >/dev/null 2>&1 && command -v sips ICON_1X="$ICONSET/icon_${size}x${size}.png" ICON_2X="$ICONSET/icon_${size}x${size}@2x.png" if ! sips -z "$size" "$size" "$ICON_PNG" --out "$ICON_1X" >/dev/null 2>&1 \ - || [ ! -s "$ICON_1X" ]; then + || [[ ! -s "$ICON_1X" ]]; then echo " warning: failed to generate ${size}x${size} icon" ICON_GENERATION_FAILED=1 fi if ! sips -z $((size * 2)) $((size * 2)) "$ICON_PNG" --out "$ICON_2X" >/dev/null 2>&1 \ - || [ ! -s "$ICON_2X" ]; then + || [[ ! -s "$ICON_2X" ]]; then echo " warning: failed to generate ${size}x${size}@2x icon" ICON_GENERATION_FAILED=1 fi done - if [ "$ICON_GENERATION_FAILED" -eq 0 ] \ + if [[ "$ICON_GENERATION_FAILED" -eq 0 ]] \ && iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns" 2>/dev/null; then echo " embedded AppIcon.icns" else diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 323290891..53f90cb6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: GenHub CI -permissions: - contents: read - pull-requests: write +permissions: {} on: # release/** is covered explicitly rather than incidentally. A release branch is only built @@ -49,6 +47,8 @@ jobs: detect-changes: name: Detect File Changes runs-on: ubuntu-latest + permissions: + contents: read timeout-minutes: 5 outputs: core: ${{ steps.filter.outputs.core }} @@ -84,6 +84,8 @@ jobs: - '**/*.axaml' - '**/*.csproj' - '**/*.sln' + - '**/*.props' + - '**/*.targets' - '.github/workflows/**' - name: Changes Summary @@ -100,6 +102,8 @@ jobs: needs: detect-changes if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' || needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.ui == 'true' || needs.detect-changes.outputs.windows == 'true' }} runs-on: windows-latest + permissions: + contents: read steps: - name: Checkout Code @@ -122,16 +126,18 @@ jobs: id: buildinfo shell: pwsh run: | - $shortHash = "${{ github.sha }}".Substring(0, 7) $prNumber = "${{ github.event.pull_request.number }}" $runNumber = "${{ github.run_number }}" + $headSha = "${{ github.event.pull_request.head.sha }}" # Velopack requires SemVer2 3-part version (MAJOR.MINOR.PATCH) # Using 0.0.X format to indicate alpha/pre-release status - if ($prNumber) { + if ($prNumber -and $headSha) { + $shortHash = $headSha.Substring(0, 7) $version = "0.0.$runNumber-pr$prNumber" $channel = "PR" } else { + $shortHash = "${{ github.sha }}".Substring(0, 7) $version = "0.0.$runNumber" $channel = "CI" } @@ -254,6 +260,8 @@ jobs: needs: detect-changes if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' || needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.ui == 'true' || needs.detect-changes.outputs.linux == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout Code @@ -264,11 +272,6 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Install Linux Dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libx11-dev - - name: Cache NuGet Packages uses: actions/cache@v3 with: @@ -280,16 +283,18 @@ jobs: - name: Extract Build Info id: buildinfo run: | - SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) PR_NUMBER="${{ github.event.pull_request.number }}" RUN_NUMBER="${{ github.run_number }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" # Velopack requires SemVer2 3-part version (MAJOR.MINOR.PATCH) # Using 0.0.X format to indicate alpha/pre-release status - if [ -n "$PR_NUMBER" ]; then + if [ -n "$PR_NUMBER" ] && [ -n "$HEAD_SHA" ]; then + SHORT_HASH=$(echo "$HEAD_SHA" | cut -c1-7) VERSION="0.0.${RUN_NUMBER}-pr${PR_NUMBER}" CHANNEL="PR" else + SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) VERSION="0.0.${RUN_NUMBER}" CHANNEL="CI" fi @@ -381,6 +386,8 @@ jobs: # macos-14 and newer are Apple Silicon. Pinning a version rather than using # macos-latest keeps the runner architecture stable when the label moves. runs-on: macos-15 + permissions: + contents: read timeout-minutes: 30 steps: @@ -405,14 +412,16 @@ jobs: - name: Extract Build Info id: buildinfo run: | - SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) PR_NUMBER="${{ github.event.pull_request.number }}" RUN_NUMBER="${{ github.run_number }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" - if [ -n "$PR_NUMBER" ]; then + if [ -n "$PR_NUMBER" ] && [ -n "$HEAD_SHA" ]; then + SHORT_HASH=$(echo "$HEAD_SHA" | cut -c1-7) VERSION="0.0.${RUN_NUMBER}-pr${PR_NUMBER}" CHANNEL="PR" else + SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) VERSION="0.0.${RUN_NUMBER}" CHANNEL="CI" fi @@ -540,20 +549,102 @@ jobs: if-no-files-found: ignore retention-days: 7 + # Launches the real native engine with no game data at all and requires the abort the + # engine is known to produce (exit code 1 once INI loading finds nothing to read). No + # licensed retail content is involved; what this covers is everything before that + # point — the binary loads, its dylibs resolve, and startup fails fast instead of + # hanging. No other job executes the native launch path at all. + engine-launch-smoke: + name: Engine Launch Smoke Test + needs: detect-changes + if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' }} + # macos-14 and newer are Apple Silicon, matching the arm64 engine asset. + runs-on: macos-15 + permissions: + contents: read + timeout-minutes: 15 + + steps: + - name: Checkout Code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Cache NuGet Packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + # `latest-bgfx` is a moving tag, republished as the engine advances. Deliberate + # tradeoff: this job tracks the current engine build rather than pinning a + # reproducible one, so an engine regression surfaces here first. + - name: Download Native Engine + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download latest-bgfx \ + --repo bobtista/GeneralsGameCode \ + --pattern 'GeneralsZH-macos-arm64.zip' \ + --dir engine-download + # The archive wraps everything in a GeneralsZH-macos-arm64/ directory, so it is + # staged and the wrapper's contents lifted out. Not `unzip -j`: that would also + # flatten Data/INI/, which the engine reads by path. + unzip -q engine-download/GeneralsZH-macos-arm64.zip -d engine-staging + root="$(find engine-staging -mindepth 1 -maxdepth 1 -type d)" + if [ ! -f "$root/generalszh" ]; then + echo "::error::generalszh not found in the release archive; contents were:" + find engine-staging -maxdepth 2 + exit 1 + fi + mv "$root" native-client + chmod +x native-client/generalszh + ls -l native-client + + # GENHUB_REQUIRE_NATIVE_SMOKE turns "no client found" from a silent skip into a + # failure. Without it a broken download would leave the test skipping and this + # job permanently, meaninglessly green. + - name: Run Engine Launch Smoke Test + env: + GENHUB_NATIVE_CLIENT_DIR: ${{ github.workspace }}/native-client + GENHUB_REQUIRE_NATIVE_SMOKE: '1' + run: | + dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj \ + -c ${{ env.BUILD_CONFIGURATION }} \ + --filter "FullyQualifiedName~EngineLaunchSmokeTests" \ + --verbosity normal + summary: name: Build Summary - needs: [build-windows, build-linux, build-macos] + # detect-changes is in `needs` so its own failure reaches the gate below. Without it a + # failed detect-changes skips every build, and all-skipped reads as a clean pass. + needs: [detect-changes, build-windows, build-linux, build-macos, engine-launch-smoke] if: always() runs-on: ubuntu-latest + permissions: {} steps: - - name: Checkout code - uses: actions/checkout@v4 - + # A job skipped by detect-changes gating is a legitimate outcome, rendered as + # such rather than as a failure. - name: Generate Summary run: | echo "### 🚀 GenHub Build Results" >> $GITHUB_STEP_SUMMARY echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY - echo "| Windows | ${{ needs.build-windows.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| Linux | ${{ needs.build-linux.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| macOS | ${{ needs.build-macos.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Windows | ${{ needs.build-windows.result == 'success' && '✅ Passed' || needs.build-windows.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Linux | ${{ needs.build-linux.result == 'success' && '✅ Passed' || needs.build-linux.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| macOS | ${{ needs.build-macos.result == 'success' && '✅ Passed' || needs.build-macos.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Engine Smoke | ${{ needs.engine-launch-smoke.result == 'success' && '✅ Passed' || needs.engine-launch-smoke.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + + # Turns the summary into a real gate: branch protection can require this one + # check and a failed or cancelled job anywhere in `needs` blocks the merge. + # Skipped jobs pass — being gated off by detect-changes is not a failure. + - name: Fail when a required job failed + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: exit 1 diff --git a/.github/workflows/gitnexus.yml b/.github/workflows/gitnexus.yml new file mode 100644 index 000000000..23d62900e --- /dev/null +++ b/.github/workflows/gitnexus.yml @@ -0,0 +1,86 @@ +name: GitNexus Graph Index & Artifact + +permissions: + contents: read + +on: + push: + branches: [development, main, 'release/**'] + pull_request: + branches: [development, main, 'release/**'] + workflow_dispatch: + +# A release branch is normally the head of an open release PR, so a push to it would otherwise +# start a second workflow run under a different key. Same-repository pull requests therefore share +# a group with pushes describing the same branch, and collapse. +# +# Only same-repository heads are keyed by branch name. A fork's head ref is contributor-controlled, +# so keying on it unconditionally would let a fork branch named `development`, `main` or +# `release/*` land in a protected branch's group — and because cancel-in-progress is on for pull +# requests, each push to that PR would cancel the protected branch's running CI. Fork pull requests +# fall back to github.ref_name, which is `/merge` and therefore unique per PR. +concurrency: + group: >- + ${{ github.workflow }}-${{ + github.event.pull_request.head.repo.full_name == github.repository + && github.event.pull_request.head.ref + || github.ref_name + }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + index: + name: GitNexus Index & Artifact + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout Code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Analyze Codebase + run: | + pnpm exec gitnexus analyze --force --index-only + pnpm exec gitnexus status + + - name: Upload GitNexus Knowledge Graph Artifact + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: gitnexus-graph-${{ github.sha }} + path: .gitnexus/ + include-hidden-files: true + if-no-files-found: warn + retention-days: 14 + overwrite: true + + - name: PR Impact Analysis (Informational) + if: github.event_name == 'pull_request' + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + echo "### 🔍 GitNexus Blast Radius & Change Impact (Informational)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + set +e + pnpm exec gitnexus detect-changes --scope compare --base-ref "$BASE_SHA" 2>&1 | tee -a $GITHUB_STEP_SUMMARY + EXIT_CODE=${PIPESTATUS[0]} + set -e + echo '```' >> $GITHUB_STEP_SUMMARY + exit $EXIT_CODE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89588dd19..8d424e2c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -234,9 +234,6 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Install Linux Dependencies - run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libx11-dev - - name: Extract Build Info id: buildinfo run: | diff --git a/.gitignore b/.gitignore index 81f0c8471..7237ca5d8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ .* !.github/ !.gitignore +!.agents/ +!.claude/ *.suo *.user @@ -21,6 +23,7 @@ docs/.vitepress/dist/ docs/.vitepress/cache/ # Build results +build.lock [Dd]ebug/ [Rr]elease/ @@ -171,3 +174,13 @@ _NCrunch* # Velopack releases releases/ Releases/ +.gitnexus + +# SampleCatalogs: machine-specific generated shortcuts. Never commit them. +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.reg +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.desktop +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.app/ +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.url +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.desktop +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.command +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.webloc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..91493a844 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,172 @@ +# GenHub + +GenHub is a high-performance, cross-platform launcher, profile manager, mod organizer, and content distribution platform for Command & Conquer: Generals and Zero Hour. An Avalonia UI desktop application sits on top of a pure .NET 8 core engine with Content-Addressable Storage (CAS), atomic workspace reconciliation, and multi-source distribution. + +You can think of GenHub as the modern, open source, cross-platform ecosystem replacement for legacy GenLauncher and manual game/mod installations. + +## What makes GenHub special? + +GenHub serves a vibrant, global Command & Conquer community across multiple operating systems. As we iterate on the codebase, we never compromise on these core pillars: + +### 1. Content-Addressable Storage (CAS) & Zero-Copy Workspaces + +We do not copy multi-gigabyte game directories or duplicate mod files. Game assets and content patches are indexed by cryptographic hash in a shared CAS pool, then hardlinked, symlinked, or atomically materialized into isolated workspaces. Switching complex mods or profiles must happen in milliseconds. + +### 2. Multi-platform at the core + +Generals was a 2003 Win32 DirectX 8 title. GenHub makes it first-class on modern **Windows**, **Linux** (Wine/Proton), and **macOS** (Wine/CrossOver/native runners). Platform-specific logic (registry lookups, shortcut generation, desktop entries, macOS quarantine `xattr` removal) is strictly isolated inside platform composition hosts, keeping core services portable. + +### 3. Shared `development` branch & zero regressions + +Every contributor and agent targets the `development` branch. Because changes to core services (storage, reconciliation, manifests, game detectors) ripple across multiple platforms and UI bindings, we do not tolerate blind edits or speculative refactors that break downstream consumers. + +### 4. Deterministic architecture & Result pattern + +No hidden exceptions for control flow. Operations that can fail (missing files, network drops, checksum mismatches, launch errors) return strongly typed `OperationResult` records. Constants are centralized, constructors are primary, and code is clean, maintainable, and verifiable. + +## A note from the maintainers + +We like ambitious ideas, simple systems, and software that feels obvious. Do not preserve complexity just because it already exists. Do not introduce machinery because it looks architecturally impressive. Understand the real constraint, then fight for the smallest model that makes the correct behavior unsurprising. + +Channel both "measure twice, cut once" and "yagni". Fight scope creep. When touching core logic, inspect caller hierarchies and verify blast radius with GitNexus before writing code. + +The rest of this document helps you navigate the codebase and make changes effectively. Think of these instructions as good defaults and firm quality baselines. + +## A small glossary + +When communicating and reasoning about GenHub, use this language: + +- **you** means the agent reading this file and changing GenHub. +- **we, us, and maintainers** mean Community Outpost and the people building GenHub. +- **user** means the player using GenHub to install, mod, and launch Generals / Zero Hour. +- **CAS (Content-Addressable Storage)** means our content-addressable storage pool (`ICasService`, `CasService`) where assets are deduplicated by hash. +- **manifest** means the JSON descriptor (`ContentManifest`, `ManifestId`) defining content components, files, hashes, launch targets, and dependencies. +- **reconciliation** means the atomic process (`ContentReconciliationService`, `IContentReconciliationService`) of turning a clean game installation into a desired profile workspace. +- **workspace** means the active, materialized directory containing linked/deployed game files where the game executable actually runs. +- **profile** means a player-configured setup of game version, active mods, maps, and configuration settings. + +## The three ways to hurt yourself + +1. **Blind symbol edits.** Never modify core interfaces, storage services, or launcher models without checking caller chains via `gitnexus_impact`. Modifying a signature in `ICasService`, `IProfileContentService`, or `IContentReconciliationService` can break Windows launch receipts, Linux symlink handlers, and macOS composition roots simultaneously. +2. **Throwing exceptions for control flow.** Never throw custom exceptions for predictable domain failure states (file missing, validation failure, hash mismatch, network failure). Return `OperationResult.CreateFailure(...)`. Cooperative cancellation (`OperationCanceledException`) and contract invariant violations (`ArgumentNullException`, invalid arguments) should follow standard .NET exception semantics. +3. **Hardcoding paths and magic strings.** Never hardcode backslashes `\`, magic constants, URLs, or regexes inline. Always use `Path.Combine` and centralized constants from `GenHub.Core.Constants`. + +## Hit every surface + +The most common defect in this repository is a change that works on one platform or layer and silently breaks another. Before calling your work done, walk this list: + +- **Platforms:** If you change launcher behavior, file materialization, or OS hooks, verify compatibility across Windows (`GenHub.Windows`), Linux (`GenHub.Linux`), and macOS (`GenHub.MacOS`). +- **Composition Roots:** Register shared services in the applicable module under `GenHub/GenHub/Infrastructure/DependencyInjection/` and ensure that module is invoked by `AppServices.ConfigureApplicationServices`. Register platform-specific implementations in the applicable Windows (`WindowsServicesModule`), Linux (`LinuxServicesModule`), and macOS (`MacOSServicesModule`) service modules, and verify each host composes them through its `Program.cs`. +- **Result Pattern:** Adhere strictly to `docs/dev/result-pattern.md`. All fallible operations (I/O, network, reconciliation, launch, validation) return `OperationResult` or specialized domain result types (`LaunchResult`, `ValidationResult`, `DetectionResult`) rather than throwing exceptions for control flow. Infallible lookups, getters, and predicates return direct types. +- **Constants:** Adhere strictly to `docs/dev/constants.md`. Put constants in `GenHub.Core.Constants` static classes. +- **UI & Styling:** Adhere strictly to `docs/dev/ui-styling.md` and `docs/dev/window-styling.md`. All views and controls must bind to semantic theme tokens from `ThemeResources.axaml` via `{DynamicResource ...}` and use shared controls from `GenHub.Common.Controls` (such as `SidebarLayout`). Never use hardcoded color hexes or custom sidebars. When working on UI, views, or styling, use relevant UI, UX, and design skills to verify layout, accessibility, and visual consistency. +- **Cancellation & Async:** Every long-running I/O, download, hashing, or reconciliation task must accept and propagate a `CancellationToken`. Never block the UI thread. +- **Reverse states:** If you add a workspace materializer, add its cleanup/reversion path. If you add a cache entry, handle its eviction. + +## Architecture & Code Intelligence (GitNexus) + +This repository uses **GitNexus** to maintain an AST-parsed structural knowledge graph of components, symbols, dependencies, and execution flows in `.gitnexus/`. + +### The Three-Phase Cadence + +1. **Phase 1 — Discovery (Before Modifying Core Symbols / Interfaces):** + - Run `gitnexus_impact` to inspect upstream callers and downstream dependents: + + ```json + gitnexus_impact({ "target": "", "direction": "upstream" }) + ``` + + - Review $d=1$ (will break) and $d=2$ (likely affected) dependencies before altering signatures. + - Check affected flows via `gitnexus://repo/{name}/processes` or `gitnexus_query(...)`. + +2. **Phase 2 — Change Detection (Pre-Commit / Batch Verification):** + - Run `gitnexus_detect_changes({ scope: "staged" })` or `pnpm exec gitnexus detect-changes --scope staged` on staged files to map diffs against execution flows. + - For pull request verification against the target base branch: + ```bash + pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development + ``` + - Confirm that changes touching cross-platform abstractions (CAS, launcher, file handlers) stay intact. + +3. **Phase 3 — CI Verification & PR Reporting:** + - CI builds, indexes, and validates the `.gitnexus/` knowledge graph on push to `development` and `main`. + - PR CI runs `pnpm exec gitnexus detect-changes --scope compare --base-ref "$BASE_SHA"` to surface blast radius and affected execution flows in GitHub Step Summaries. + - If the local graph is stale after pulling `development`: + + ```bash + pnpm exec gitnexus analyze --index-only + ``` + +## Code Conventions & Taste + +- **Coding Style Authority:** Follow `coding-style.md`. +- **Primary Constructors:** Always use primary constructors for classes and records when dependencies are injected. Remove redundant private instance fields (e.g., `_logger = logger;`) and use constructor parameters directly in class members. +- **Collection Types:** Prefer `IReadOnlyList` when callers need indexed access and known count, and `IReadOnlyCollection` when only count and enumeration are needed. Avoid raw `IEnumerable` for public properties and return types to prevent unintended deferred multiple enumerations; materialize eagerly (e.g., `.ToList()`, `.ToArray()`, or `ImmutableArray`) when returning collections from services or queries. +- **No `this.`:** Never qualify instance members with `this.`. +- **Namespaces:** Always use file-scoped or top-level namespace declarations. Alphabetize all `using` directives at the very top of the file. Never use inline namespaces. +- **Comment Casing:** Use standard sentence casing in comments. Never capitalize arbitrary words mid-comment. +- **Variables & Declarations:** Always initialize local variables upon declaration. Never leave uninitialized variables (`CS-W1022`) or unused variables (`CS-W1100`). Use discards (`_`) for unused `using` scopes or out parameters. +- **Switch Statements:** Always include a `default` case (`CS-W1009`) in `switch` statements and expressions. +- **Exception Handling:** Never catch generic `Exception` (`CS-R1008`) unless explicitly required for top-level process/worker boundaries. Always catch specific exception types (`IOException`, `UnauthorizedAccessException`, etc.) or re-throw. +- **Cognitive Complexity:** Keep method cognitive complexity strictly below 15 (SonarCloud S3776). Break up complex orchestration into cohesive, single-responsibility private helper methods. +- **Constants & URIs:** Fallback and default gateway/API endpoints must be centralized in `GenHub.Core.Constants.ApiConstants` with environment variable override support (e.g., `GENHUB_UPLOAD_GATEWAY_URL`). Do not scatter URI literals across features. +- **Formatting:** 4 spaces indentation, Allman bracing style (opening brace on its own line), nullable reference types enabled. +- **Member Ordering (StyleCop):** + 1. Nested types + 2. Static fields + 3. Instance fields + 4. Constructors + 5. Finalizers + 6. Properties + 7. Indexers + 8. Events + 9. Methods (Static first, then instance; ordered `public` -> `protected` -> `internal` -> `private`). + +## Dev & Verification + +- **Targeted verification:** Run tests for the specific scope you changed. + + ```bash + # Core tests + dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj -c Release + + # Platform-specific tests (on matching OS host) + dotnet test GenHub/GenHub.Tests/GenHub.Tests.Windows/GenHub.Tests.Windows.csproj -c Release + dotnet test GenHub/GenHub.Tests/GenHub.Tests.Linux/GenHub.Tests.Linux.csproj -c Release + dotnet test GenHub/GenHub.Tests/GenHub.Tests.MacOS/GenHub.Tests.MacOS.csproj -c Release + ``` + +- **Do not run repo-wide checks unprompted.** CI owns the full multi-platform matrix. +- **Solution build:** + + ```bash + dotnet build GenHub/GenHub.sln -c Release + ``` + +- **GitNexus CLI:** + + ```bash + pnpm exec gitnexus analyze --index-only # Build/refresh graph + pnpm exec gitnexus status # Inspect status + pnpm exec gitnexus detect-changes --scope staged # Map staged diff to affected flows + pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development # Map branch diff against base + pnpm exec gitnexus impact # Symbol blast radius + ``` + +## Where code lives + +- `GenHub/GenHub.Core/` — Core interfaces (`ICasService`, `IContentReconciliationService`, `IToolPlugin`), domain models (`ContentManifest`, `ManifestId`), launcher/detector contracts, constants, and utilities. +- `GenHub/GenHub/` — Avalonia MVVM application, ViewModels, Views, Converters, Dialogs, and feature implementations (`CasService`, `ContentReconciliationService`, `GameLauncher`, `GameProcessManager`). +- `GenHub/GenHub.Windows/` — Windows platform host, composition root, registry discovery, Win32 shortcuts. +- `GenHub/GenHub.Linux/` — Linux platform host, composition root, desktop entries, Wine/Proton runner. +- `GenHub/GenHub.MacOS/` — macOS platform host, composition root, `.app` bundle hooks, quarantine `xattr` removal. +- `GenHub/GenHub.Tests/` — Partitioned test suites (`Core`, `Windows`, `Linux`, `MacOS`). +- `docs/` — Architecture documentation, Result pattern guide (`docs/dev/result-pattern.md`), Constants reference (`docs/dev/constants.md`), UI styling guide (`docs/dev/ui-styling.md`), Window styling standard (`docs/dev/window-styling.md`). + +## Pull requests + +- Never make a PR unless the developer explicitly asks you to do so. +- Conventional commit titles, plain language: `fix(core): CAS pool pruning handles locked files`. +- Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. +- UI changes need before/after images. Motion or timing needs a short video. +- **Never push while checks are running:** NEVER push new commits while CI workflows, platform builds (Windows, Linux, macOS), tests, DeepSource analyzers, or AI bot reviews (CodeRabbit, Kilo) are in progress or queued. Always wait until EVERY check run reaches `status == completed`. Consolidate all fixes and review resolutions into a single pass before pushing. +- When babysitting: poll checks and all bot comments (including inline review threads and summary 'Outside diff range' findings) newer than the last push. Verify each finding against the source and fix real ones in code. For automated bot threads (DeepSource, Qodo, CodeRabbit, etc.), resolve the discussion directly without posting reply comments; only reply to human maintainers if discussion or clarification is needed. For extended PR workflows, invoke the `pull-request` and `babysit-pr` skills. Stay quiet when nothing is new. Stop when all checks pass on the latest commit with all threads resolved. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c9a7ebac8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude Code Guidance + +@AGENTS.md diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index f76660ae2..8c7e0e70c 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -34,6 +34,8 @@ + + @@ -47,7 +49,7 @@ - + diff --git a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs index 7cf4bd7af..56d978056 100644 --- a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs +++ b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs @@ -1,8 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants for AODMaps (Age of Defense Maps) provider. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public static class AODMapsConstants { /// Gets the publisher type identifier for AODMaps. diff --git a/GenHub/GenHub.Core/Constants/ApiConstants.cs b/GenHub/GenHub.Core/Constants/ApiConstants.cs index 7ab83ccb9..16ec2f9df 100644 --- a/GenHub/GenHub.Core/Constants/ApiConstants.cs +++ b/GenHub/GenHub.Core/Constants/ApiConstants.cs @@ -1,8 +1,12 @@ +using System; +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// API and network related constants. /// +[SuppressMessage("Major Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized fallback API constants and endpoint definitions.")] public static class ApiConstants { // GitHub @@ -59,13 +63,76 @@ public static class ApiConstants /// public const string GitHubApiRunArtifactsFormat = "https://api.github.com/repos/{0}/{1}/actions/runs/{2}/artifacts"; - // UploadThing links + // Upload Gateway & Cloud Storage + + /// + /// Environment variable name for overriding the upload gateway base URL during local development/staging. + /// + public const string UploadGatewayBaseUrlEnvVar = "GENHUB_UPLOAD_GATEWAY_URL"; + + /// + /// Base URL for the GenHub community upload gateway. + /// + public const string DefaultUploadGatewayBaseUrl = "https://genhub-upload-gateway.mustafa2146.workers.dev"; + + /// + /// Gets the active base URL for the upload gateway, checking environment variable overrides first. + /// + public static string UploadGatewayBaseUrl => + Environment.GetEnvironmentVariable(UploadGatewayBaseUrlEnvVar) is { Length: > 0 } customUrl + ? customUrl.TrimEnd('/') + : DefaultUploadGatewayBaseUrl; + + /// + /// Endpoint path for cloud uploads. + /// + public const string UploadEndpoint = "/api/v1/uploads"; + + /// + /// Endpoint path for deleting cloud uploads. + /// + public const string UploadDeleteEndpoint = "/api/v1/uploads/delete"; + + /// + /// Gets the full default URL for cloud uploads. + /// + public static string DefaultUploadUrl => UploadGatewayBaseUrl + UploadEndpoint; + + /// + /// Gets the full default URL for deleting cloud uploads. + /// + public static string DefaultUploadDeleteUrl => UploadGatewayBaseUrl + UploadDeleteEndpoint; + + /// + /// Format string for constructing UploadThing public file URLs. + /// + public const string UploadThingPublicUrlFormat = "https://utfs.io/f/{0}"; /// /// UploadThing URL fragment for identification. /// public const string UploadThingUrlFragment = "utfs.io/f/"; + /// + /// Modern UploadThing (v7) UFS URL fragment for identification. + /// + public const string UploadThingUfsUrlFragment = ".ufs.sh/f/"; + + /// + /// Modern UploadThing (v7) UFS short URL fragment for identification. + /// + public const string UploadThingUfsShortUrlFragment = "ufs.sh/f/"; + + /// + /// Media type for ZIP archives. + /// + public const string MediaTypeZip = "application/zip"; + + /// + /// Default filename fallback for generic uploads when a source filename cannot be determined. + /// + public const string DefaultUploadFileName = "upload.zip"; + // GenTool /// @@ -80,6 +147,18 @@ public static class ApiConstants /// public const string GeneralsOnlineViewMatchFragment = "playgenerals.online/viewmatch"; + // GameReplays / Strata + + /// + /// GameReplays Strata domain URL fragment for identification. + /// + public const string StrataUrlFragment = "strata.gamereplays.org"; + + /// + /// GameReplays domain URL fragment for identification. + /// + public const string GameReplaysDomainFragment = "gamereplays.org"; + /// /// Format string for GitHub API Workflow Runs endpoint (owner, repo). /// diff --git a/GenHub/GenHub.Core/Constants/AppConstants.cs b/GenHub/GenHub.Core/Constants/AppConstants.cs index 260c52b6f..17d2c9335 100644 --- a/GenHub/GenHub.Core/Constants/AppConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppConstants.cs @@ -132,6 +132,25 @@ public static string FullDisplayVersion /// public const string TokenFileName = ".ghtoken"; + /// + /// Title of the confirmation prompt shown before all application data is deleted. + /// + public const string DeleteAllDataConfirmationTitle = "Delete All Application Data"; + + /// + /// Body of the confirmation prompt shown before all application data is deleted. + /// + public const string DeleteAllDataConfirmationMessage = + "This permanently deletes every profile, workspace, manifest, CAS object and tracked user data " + + "installation. The pristine backups GenHub keeps of your original game data will be discarded " + + "as part of this, so anything GenHub replaced cannot be recovered afterwards.\n\n" + + "This action is irreversible. Continue?"; + + /// + /// Confirm button text for the delete-all-application-data prompt. + /// + public const string DeleteAllDataConfirmText = "Delete Everything"; + /// /// Gets assembly metadata by key. /// diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs index e7a543023..6b5bd656f 100644 --- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs @@ -10,6 +10,21 @@ public static class AppUpdateConstants /// public const int MaxHttpRetries = 3; + /// + /// Index for the Update tab in update notification views. + /// + public const int UpdateTabIndex = 0; + + /// + /// Index for the Browse Builds tab in update notification views. + /// + public const int BrowseBuildsTabIndex = 1; + + /// + /// Maximum valid tab index in update notification views. + /// + public const int MaxTabIndex = 1; + /// /// Velopack directory name. /// @@ -65,6 +80,11 @@ public static class AppUpdateConstants /// public const string InstallingMessage = "Installing..."; + /// + /// Loading message. + /// + public const string LoadingMessage = "Loading..."; + /// /// Install update action text. /// @@ -153,6 +173,211 @@ public static class AppUpdateConstants "3. Launch the installed version (will be in %LOCALAPPDATA%\\GenHub)\n\n" + "Update available: v{1}"; + /// + /// Update available notification title for release channel. + /// + public const string UpdateAvailableNotificationTitle = "Update Available"; + + /// + /// Update available notification title for branch subscriptions. + /// + public const string BranchUpdateAvailableNotificationTitle = "Branch Update Available"; + + /// + /// Update available notification title for PR subscriptions. + /// + public const string PrUpdateAvailableNotificationTitle = "PR Update Available"; + + /// + /// Update action button text. + /// + public const string UpdateAction = "Update"; + + /// + /// Title for the update in progress notification. + /// + public const string UpdatingAppNotificationTitle = "Updating GenHub"; + + /// + /// Starting update progress message. + /// + public const string UpdateStartingMessage = "Starting update..."; + + /// + /// Title for update failed notification. + /// + public const string UpdateFailedNotificationTitle = "Update Failed"; + + /// + /// Update failed notification body format string ({0}: error message). + /// + public const string UpdateFailedNotificationFormat = "Failed to install update: {0}"; + + /// + /// View updates action button text. + /// + public const string ViewUpdatesAction = "View Updates"; + + /// + /// Release update notification body format string ({0}: version). + /// + public const string ReleaseUpdateNotificationFormat = "A new version ({0}) is available."; + + /// + /// Branch update notification body format string ({0}: version, {1}: branch name). + /// + public const string BranchUpdateNotificationFormat = "A new build ({0}) is available on branch '{1}'."; + + /// + /// PR update notification body format string ({0}: version, {1}: PR number). + /// + public const string PrUpdateNotificationFormat = "A new build ({0}) is available for PR #{1}."; + + /// + /// Default development branch name for CI artifact fallback. + /// + public const string DevelopmentBranch = "development"; + + /// + /// Default main branch name for release updates. + /// + public const string MainBranch = "main"; + + /// + /// Update available notification title when a subscribed PR has been merged or closed. + /// + public const string PrMergedUpdateAvailableNotificationTitle = "PR Merged — Update Available"; + + /// + /// Update available notification title when a subscribed branch is stale or has no artifacts. + /// + public const string BranchStaleUpdateAvailableNotificationTitle = "Branch Fallback: Update Available"; + + /// + /// PR merged or closed fallback notification format string ({0}: version, {1}: PR number). + /// + public const string PrMergedUpdateNotificationFormat = "PR #{1} was merged or closed. A new build ({0}) is available on development."; + + /// + /// PR merged or closed release fallback notification format string ({0}: version, {1}: PR number). + /// + public const string PrMergedReleaseNotificationFormat = "PR #{1} was merged or closed. A new release ({0}) is available."; + + /// + /// Branch stale fallback notification format string ({0}: version, {1}: branch name). + /// + public const string BranchStaleUpdateNotificationFormat = "Branch '{1}' has no newer builds. A new build ({0}) is available on development."; + + /// + /// Branch stale release fallback notification format string ({0}: version, {1}: branch name). + /// + public const string BranchStaleReleaseNotificationFormat = "Branch '{1}' has no newer builds. A new release ({0}) is available."; + + /// + /// PR merged or closed status message format ({0}: PR number). + /// + public const string PrMergedStatusMessageFormat = "PR #{0} has been merged or closed. Select a new PR or switch to MAIN."; + + /// + /// Branch stale status message format ({0}: branch name). + /// + public const string BranchStaleStatusMessageFormat = "Branch '{0}' has no available builds. Select a new branch or switch to MAIN."; + + /// + /// Message displayed when checking branch/PR artifacts without a configured GitHub PAT. + /// + public const string PatRequiredForArtifactsMessage = "GitHub Personal Access Token (PAT) required to check branch/PR builds."; + + /// + /// Identity prefix for PR update notification deduplication. + /// + public const string PrDedupePrefix = "pr:"; + + /// + /// Identity prefix for PR fallback update notification deduplication. + /// + public const string PrFallbackDedupePrefix = "pr-fallback:"; + + /// + /// Identity prefix for branch update notification deduplication. + /// + public const string BranchDedupePrefix = "branch:"; + + /// + /// Identity prefix for branch fallback update notification deduplication. + /// + public const string BranchFallbackDedupePrefix = "branch-fallback:"; + + /// + /// Identity prefix for release update notification deduplication. + /// + public const string ReleaseDedupePrefix = "release:"; + + /// + /// Identity prefix for GitHub API fallback update notification deduplication. + /// + public const string GitHubFallbackDedupePrefix = "github:"; + + /// + /// Log format string when skipping duplicate update notifications. + /// + public const string NotificationAlreadyShownLogFormat = "Update notification already shown for {Identity}, skipping duplicate notification"; + + /// + /// Sort option: sort by last updated date descending. + /// + public const string SortOptionLastUpdated = "Last Updated"; + + /// + /// Sort option: sort by pull request number descending. + /// + public const string SortOptionPrNumberDesc = "PR Number (Highest)"; + + /// + /// Sort option: sort by pull request number ascending. + /// + public const string SortOptionPrNumberAsc = "PR Number (Lowest)"; + + /// + /// Default interval in minutes for periodic update checks (30 minutes). + /// + public const int DefaultPeriodicUpdateCheckIntervalMinutes = 30; + + /// + /// Minimum interval in minutes for periodic update checks (5 minutes). + /// + public const int MinPeriodicUpdateCheckIntervalMinutes = 5; + + /// + /// Maximum interval in minutes for periodic update checks (10080 minutes / 7 days). + /// + public const int MaxPeriodicUpdateCheckIntervalMinutes = 10080; + + /// + /// Increment step in minutes for periodic update check interval setting (5 minutes). + /// + public const int PeriodicUpdateCheckIntervalIncrementMinutes = 5; + + /// + /// Default buffer size for stream operations (128KB). + /// + public const int DefaultStreamBufferSize = 131072; + + /// + /// Chunk size in bytes for parallel range downloads (2MB). + /// + public const long DownloadChunkSizeBytes = 2 * 1024 * 1024; + + /// + /// Maximum number of concurrent connections for parallel downloads. + /// + public const int ParallelDownloadConcurrency = 8; + + /// + /// Minimum file size threshold in bytes to trigger parallel chunked downloading (4MB). + /// + public const long ParallelDownloadThresholdBytes = 4 * 1024 * 1024; + /// /// Delay before exit after applying update (5 seconds). /// diff --git a/GenHub/GenHub.Core/Constants/CatalogConstants.cs b/GenHub/GenHub.Core/Constants/CatalogConstants.cs index 0e0ff3f5d..2e23578b4 100644 --- a/GenHub/GenHub.Core/Constants/CatalogConstants.cs +++ b/GenHub/GenHub.Core/Constants/CatalogConstants.cs @@ -1,8 +1,26 @@ namespace GenHub.Core.Constants; /// -/// Constants for publisher catalog system. +/// Constants for the modular publisher-catalog system. /// +/// +/// Layering (see Publisher Studio architecture): +/// +/// +/// Provider Definition — static publisher metadata + catalog endpoint(s) +/// (bundled *.provider.json today; user-hosted definitions via Publisher Studio later). +/// +/// +/// Catalog — dynamic content listing (catalog.json / remote endpoint), updated on each release. +/// +/// +/// Artifacts — downloadable files referenced by catalog releases. +/// +/// +/// Anyone can author a GenHub-schema catalog, host it, and share +/// genhub://subscribe?url=.... Discovery uses +/// for catalog-direct subscriptions without per-publisher code. +/// public static class CatalogConstants { /// @@ -11,12 +29,17 @@ public static class CatalogConstants public const int CatalogSchemaVersion = 1; /// - /// Filename for subscriptions storage. + /// Filename for user subscription storage under application data. /// public const string SubscriptionFileName = "subscriptions.json"; /// - /// Resolver ID for generic catalog resolver. + /// Sidebar / discoverer category for user-subscribed catalogs (vs built-in static/dynamic). + /// + public const string SubscribedPublisherCategory = "subscribed"; + + /// + /// Resolver / pipeline ID for the generic catalog pipeline (any GenHub-schema catalog). /// public const string GenericCatalogResolverId = "generic-catalog"; @@ -29,4 +52,44 @@ public static class CatalogConstants /// Maximum catalog size in bytes (10 MB). /// public const long MaxCatalogSizeBytes = 10 * 1024 * 1024; + + /// + /// Maximum number of entries allowed when extracting publisher catalog archives. + /// + public const int MaxZipEntryCount = 50_000; + + /// + /// Maximum cumulative uncompressed size allowed when extracting publisher catalog archives (5 GB). + /// + public const long MaxZipUncompressedSizeBytes = 5L * 1024 * 1024 * 1024; + + /// + /// Resolver metadata key for serialized publisher profile JSON. + /// + public const string PublisherProfileJsonMetadataKey = "publisherProfileJson"; + + /// + /// Resolver metadata key for serialized catalog item JSON. + /// + public const string CatalogItemJsonMetadataKey = "catalogItemJson"; + + /// + /// Resolver metadata key for serialized release JSON. + /// + public const string ReleaseJsonMetadataKey = "releaseJson"; + + /// + /// Resolver metadata key for the stable catalog content id (not the display name). + /// + public const string CatalogContentIdMetadataKey = "catalogContentId"; + + /// + /// Resolver metadata key for serialized bundle component descriptors. + /// + public const string BundleComponentsJsonMetadataKey = "bundleComponentsJson"; + + /// + /// Resolver metadata key for serialized publisher referrals JSON. + /// + public const string CatalogReferralsJsonMetadataKey = "catalogReferralsJson"; } diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs index 4b0821443..30cd69c4f 100644 --- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs @@ -1,8 +1,13 @@ namespace GenHub.Core.Constants; /// -/// Constants for command line arguments and URI schemes. +/// Constants for command line arguments and the genhub:// URI scheme. /// +/// +/// Subscription links use genhub://subscribe?url=<absolute-url>. +/// Today url is a hosted GenHub catalog.json. Publisher Studio will also share +/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time. +/// public static class CommandLineConstants { /// @@ -16,22 +21,27 @@ public static class CommandLineConstants public const string LaunchProfileInlinePrefix = "--launch-profile="; /// - /// URI scheme used for protocol handling. + /// Scheme name for custom protocol registration. /// - public const string UriScheme = "genhub://"; + public const string SchemeName = "genhub"; /// - /// Command for subscribing to a catalog via URI. + /// Custom URI scheme registered so OS/browser links can open GenHub. + /// + public const string UriScheme = SchemeName + "://"; + + /// + /// URI path segment for content subscription (genhub://subscribe?url=...). /// public const string SubscribeCommand = "subscribe"; /// - /// Full prefix for subscription URI. + /// Full prefix for subscription URIs (genhub://subscribe). /// public const string SubscribeUriPrefix = UriScheme + SubscribeCommand; /// - /// Query parameter name for the catalog URL in a subscription URI. + /// Query parameter carrying the absolute URL of a catalog (or future provider definition). /// public const string SubscribeUrlParam = "?url="; } diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs index 0ace072b1..260c6d2cc 100644 --- a/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants for the Community Outpost catalog parsing and metadata keys. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class CommunityOutpostCatalogConstants { /// The catalog format identifier for GenPatcher .dat files. diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs index 305d9555d..93780ed46 100644 --- a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// @@ -8,6 +10,7 @@ namespace GenHub.Core.Constants; /// Endpoint URLs and timeouts are configured via data-driven configuration. /// See Providers/communityoutpost.provider.json for runtime-configurable values. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class CommunityOutpostConstants { /// @@ -85,6 +88,22 @@ public static class CommunityOutpostConstants /// public const string PatchPageUrl = "https://legi.cc/downloads/genpatcher/"; + /// + /// Maximum number of file entries a downloaded Community Outpost archive may contain. + /// + public const int MaxArchiveEntries = 10000; + + /// + /// Maximum number of bytes a single Community Outpost archive entry may expand to (2 GiB), + /// sized to accommodate the largest shipped BIG files. + /// + public const long MaxEntryUncompressedBytes = 2L * 1024 * 1024 * 1024; + + /// + /// Maximum aggregate uncompressed bytes a Community Outpost archive may expand to (4 GiB). + /// + public const long MaxAggregateUncompressedBytes = 4L * 1024 * 1024 * 1024; + /// Display name for Game Clients content type. public const string ContentTypeGameClients = "Game Clients"; diff --git a/GenHub/GenHub.Core/Constants/CsvConstants.cs b/GenHub/GenHub.Core/Constants/CsvConstants.cs new file mode 100644 index 000000000..65f6407c0 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/CsvConstants.cs @@ -0,0 +1,130 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GenHub.Core.Constants; + +/// +/// Constants specific to CSV catalog discovery and content pipeline. +/// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants for remote catalog discovery")] +public static class CsvConstants +{ + /// + /// Default remote index.json source for CSV catalog discovery. + /// + public const string DefaultIndexFileUrl = "https://raw.githubusercontent.com/community-outpost/GenHub/main/docs/GameInstallationFilesRegistry/index.json"; + + /// + /// Source name for the CSV catalog discoverer. + /// + public const string SourceName = "Csv Discoverer"; + + /// + /// Description for the CSV catalog discoverer. + /// + public const string Description = "Discovers base game manifests from verified CSV catalogs."; + + /// + /// Source name for the CSV catalog content provider. + /// + public const string ProviderSourceName = "CSV Catalog Provider"; + + /// + /// Description for the CSV catalog content provider. + /// + public const string ProviderDescription = "Provides base game manifests from verified CSV catalogs."; + + /// + /// Resolver ID for CSV catalog content. + /// + public const string ResolverId = "CSVResolver"; + + /// + /// Metadata key for the CSV source URL. + /// + public const string CsvUrlMetadataKey = "csvUrl"; + + /// + /// Metadata key for the game type. + /// + public const string GameTypeMetadataKey = "gameType"; + + /// + /// Metadata key for the game version. + /// + public const string VersionMetadataKey = "version"; + + /// + /// Metadata key for the content language. + /// + public const string LanguageMetadataKey = "language"; + + /// + /// Metadata key for the expected file count. + /// + public const string FileCountMetadataKey = "fileCount"; + + /// + /// String representation for Generals game type in CSV catalogs. + /// + public const string GeneralsGameType = "Generals"; + + /// + /// String representation for Zero Hour game type in CSV catalogs. + /// + public const string ZeroHourGameType = "ZeroHour"; + + /// + /// Special language filter value to include all languages. + /// + public const string AllLanguagesFilter = "All"; + + /// + /// Canonical language code for English. + /// + public const string LanguageEn = "EN"; + + /// + /// Canonical language code for German. + /// + public const string LanguageDe = "DE"; + + /// + /// Canonical language code for French. + /// + public const string LanguageFr = "FR"; + + /// + /// Canonical language code for Polish. + /// + public const string LanguagePl = "PL"; + + /// + /// Canonical language code for Spanish. + /// + public const string LanguageEs = "ES"; + + /// + /// Canonical language code for Italian. + /// + public const string LanguageIt = "IT"; + + /// + /// Canonical language code for Korean. + /// + public const string LanguageKo = "KO"; + + /// + /// Canonical language code for Brazilian Portuguese. + /// + public const string LanguagePtBr = "PT-BR"; + + /// + /// Canonical language code for Simplified Chinese. + /// + public const string LanguageZhCn = "ZH-CN"; + + /// + /// Canonical language code for Traditional Chinese. + /// + public const string LanguageZhTw = "ZH-TW"; +} diff --git a/GenHub/GenHub.Core/Constants/DirectoryNames.cs b/GenHub/GenHub.Core/Constants/DirectoryNames.cs index 47097cc18..4938758ab 100644 --- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs +++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs @@ -50,6 +50,40 @@ public static class DirectoryNames /// public const string Profiles = "Profiles"; + /// + /// Directory holding manifests authored by the user, alongside . + /// + public const string CustomManifests = "CustomManifests"; + + /// + /// Directory that releases up to v0.0.3 nested the manifests, tracked user data and workspace + /// metadata under. Current releases keep those entries directly in the data root. + /// + public const string LegacyContent = "Content"; + + /// + /// Directory for storing tracked user data. + /// + public const string UserData = "UserData"; + + /// + /// Directory holding the manifests of tracked user data, nested inside . + /// + /// + /// Deliberately lower-case and separate from : this is + /// the exact name written to disk, and matching case matters on case-sensitive filesystems. + /// + public const string UserDataManifests = "manifests"; + + /// + /// Directory holding backups of replaced user data files, nested inside . + /// + /// + /// Deliberately lower-case and separate from : this is the exact name + /// written to disk, and matching case matters on case-sensitive filesystems. + /// + public const string UserDataBackups = "backups"; + /// /// Directory for storing workspaces. /// diff --git a/GenHub/GenHub.Core/Constants/FileTypes.cs b/GenHub/GenHub.Core/Constants/FileTypes.cs index 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/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs index 2d83a81a2..6937d2e2c 100644 --- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs @@ -21,6 +21,9 @@ public static class GameClientConstants /// Steam game.dat executable (primary for Steam installations, avoids launcher stubs). public const string SteamGameDatExecutable = "game.dat"; + /// Contra modded client executable filename. + public const string ContraExecutable = "generals.ctr"; + // ===== SuperHackers Client Detection ===== /// SuperHackers Generals executable filename. @@ -61,6 +64,20 @@ public static class GameClientConstants /// Standard retail Zero Hour directory name. public const string ZeroHourRetailDirectoryName = "Command & Conquer Generals Zero Hour"; + // ===== Core Game Archives ===== + + /// Primary Zero Hour INI archive filename. + public const string ZeroHourIniBig = "INIZH.big"; + + /// Primary Zero Hour Patch archive filename. + public const string ZeroHourPatchBig = "PatchZH.big"; + + /// Primary Generals Vanilla INI archive filename. + public const string GeneralsIniBig = "INI.big"; + + /// Primary Generals Vanilla Patch archive filename. + public const string GeneralsPatchBig = "Patch.big"; + // ===== GeneralsOnline Client Detection ===== /// GeneralsOnline 60Hz client executable name. diff --git a/GenHub/GenHub.Core/Constants/GameContentConstants.cs b/GenHub/GenHub.Core/Constants/GameContentConstants.cs new file mode 100644 index 000000000..146ef84c8 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/GameContentConstants.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace GenHub.Core.Constants; + +/// +/// Constants for game content structure, archive payload normalization, and recognized game assets. +/// +public static class GameContentConstants +{ + /// + /// Maximum recursive wrapper directory stripping depth. + /// + public const int MaxWrapperNormalizationDepth = 10; + + /// + /// Supported archive file extensions. + /// + public static readonly IReadOnlyList ArchiveExtensions = + [ + ".zip", + ".7z", + ".rar", + ".dat", + ]; + + /// + /// Canonical directory names used at the game workspace root. + /// + public static readonly IReadOnlyList RecognizedGameDirectories = + [ + "Data", + "Art", + "Window", + "Audio", + "Maps", + "INI", + "Scripts", + "Textures", + "W3D", + "English", + "German", + "French", + "Italian", + "Spanish", + "Korean", + "Polish", + "Chinese", + ]; + + /// + /// Canonical file extensions for game assets, binaries, and configurations. + /// + public static readonly IReadOnlyList RecognizedGameFileExtensions = + [ + ".big", + ".exe", + ".dll", + ".str", + ".csf", + ".ini", + ".map", + ".bik", + ".asi", + ]; + + /// + /// Extensions for loose non-game documentation and metadata files. + /// + public static readonly IReadOnlyList DocumentationExtensions = + [ + ".txt", + ".url", + ".md", + ".htm", + ".html", + ".pdf", + ".lnk", + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ]; + + /// + /// System junk file or directory names to purge during payload normalization. + /// + public static readonly IReadOnlyList SystemJunkNames = + [ + ".ds_store", + "thumbs.db", + "desktop.ini", + "__macosx", + ]; + + /// + /// Subfolder aliases that denote Zero Hour specific game content. + /// + public static readonly IReadOnlyList ZeroHourSubfolderAliases = + [ + "Zero Hour", + "ZH", + "Command and Conquer Generals Zero Hour", + "Command & Conquer Generals - Zero Hour", + "Command & Conquer: Generals - Zero Hour", + "C&C Generals Zero Hour", + "ZeroHour", + ]; + + /// + /// Subfolder aliases that denote Generals specific game content. + /// + public static readonly IReadOnlyList GeneralsSubfolderAliases = + [ + "Generals", + "CCG", + "Command and Conquer Generals", + "Command & Conquer Generals", + "C&C Generals", + ]; + + /// + /// Default variant resolution for control bar packages. + /// + public const string DefaultControlBarVariant = "1080p"; + + /// + /// Base filename for standard Control Bar Pro BIG archive. + /// + public const string ControlBarProBaseFileName = "340_ControlBarProZH.big"; + + /// + /// Base filename for Lemon Edition Control Bar Pro BIG archive. + /// + public const string ControlBarProLemonBaseFileName = "340_ControlBarProLemonEditionZH.big"; + + /// + /// Standard subfolder name for English BIG files. + /// + public const string BigEnDirectoryName = "BIG EN"; + + /// + /// Standard subfolder name for BIG files. + /// + public const string BigDirectoryName = "BIG"; + + /// + /// GenTool directory name. + /// + public const string GenToolDirectoryName = "GenTool"; + + /// + /// Window directory name. + /// + public const string WindowDirectoryName = "Window"; + + /// + /// Determines whether the specified directory name is a recognized canonical game directory. + /// + /// The directory name to check. + /// true if recognized; otherwise, false. + public static bool IsRecognizedGameDirectory(string? directoryName) + { + return !string.IsNullOrEmpty(directoryName) && + RecognizedGameDirectories.Contains(directoryName, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Determines whether the specified file extension or file name represents a recognized game asset. + /// + /// The file name or extension to check. + /// true if recognized; otherwise, false. + public static bool IsRecognizedGameFile(string? fileNameOrExtension) + { + if (string.IsNullOrEmpty(fileNameOrExtension)) + { + return false; + } + + var ext = Path.GetExtension(fileNameOrExtension); + if (string.IsNullOrEmpty(ext)) + { + ext = fileNameOrExtension; + } + + return RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/GenHub/GenHub.Core/Constants/GameSettingsGeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GameSettingsGeneralsOnlineConstants.cs index c91671af7..aa11b7f91 100644 --- a/GenHub/GenHub.Core/Constants/GameSettingsGeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameSettingsGeneralsOnlineConstants.cs @@ -10,6 +10,23 @@ public static class GameSettingsGeneralsOnlineConstants /// public const string SettingsFileName = "settings.json"; + /// + /// Extension of the file a save is written to before it is moved over settings.json. + /// + public const string TemporarySettingsFileExtension = ".tmp"; + + /// + /// Number of times the completed settings file is moved over settings.json before the save + /// gives up and reports the failure. Anything holding settings.json open releases it within + /// milliseconds, so a handful of attempts either succeeds or is looking at a real fault. + /// + public const int SettingsReplaceAttemptLimit = 5; + + /// + /// Delay between attempts to move the completed settings file over settings.json. + /// + public const int SettingsReplaceRetryDelayMilliseconds = 20; + /// /// Default chat font size. /// @@ -24,4 +41,29 @@ public static class GameSettingsGeneralsOnlineConstants /// Maximum chat font size. /// public const int MaxChatFontSize = 24; + + /// + /// Default for whether ping is shown. + /// + public const bool DefaultShowPing = true; + + /// + /// Default for whether player ranks are shown. + /// + public const bool DefaultShowPlayerRanks = true; + + /// + /// Default for whether the username is remembered. + /// + public const bool DefaultRememberUsername = true; + + /// + /// Default for whether notifications are enabled. + /// + public const bool DefaultEnableNotifications = true; + + /// + /// Default for whether sound notifications are enabled. + /// + public const bool DefaultEnableSoundNotifications = true; } diff --git a/GenHub/GenHub.Core/Constants/GameSettingsTheSuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/GameSettingsTheSuperHackersConstants.cs index 15f23b2fa..0efca04d8 100644 --- a/GenHub/GenHub.Core/Constants/GameSettingsTheSuperHackersConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameSettingsTheSuperHackersConstants.cs @@ -45,6 +45,12 @@ public static class GameSettingsTheSuperHackersConstants /// public const int DefaultSystemTimeFontSize = 8; + /// + /// Default volume for money transaction audio events, on the same 0-100 scale the settings + /// screen exposes. Zero would mute them, which is a choice rather than a default. + /// + public const int DefaultMoneyTransactionVolume = 50; + /// /// Default for whether player observer mode is enabled. /// Matches the engine fallback in OptionPreferences::getPlayerObserverEnabled. @@ -86,4 +92,24 @@ public static class GameSettingsTheSuperHackersConstants /// Absent from the engine's ScreenEdgeScrollMode_Default. /// public const bool DefaultScreenEdgeScrollEnabledInWindowedApp = false; + + /// + /// Configuration key for game window transition speed multiplier. + /// + public const string GameWindowTransitionSpeedMultiplierKey = "GameWindowTransitionSpeedMultiplier"; + + /// + /// Minimum game window transition speed multiplier value. + /// + public const float MinGameWindowTransitionSpeedMultiplier = 1.0f; + + /// + /// Maximum game window transition speed multiplier value. + /// + public const float MaxGameWindowTransitionSpeedMultiplier = 4.0f; + + /// + /// Default game window transition speed multiplier value. + /// + public const float DefaultGameWindowTransitionSpeedMultiplier = 1.0f; } diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index dad16f47a..20ef97da7 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants specific to Generals Online content provider and multiplayer service. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class GeneralsOnlineConstants { // ===== Content Metadata ===== @@ -125,6 +128,23 @@ public static class GeneralsOnlineConstants /// Description for Generals Online deliverer. public const string DelivererDescription = "Delivers Generals Online content via ZIP extraction and CAS storage"; + // ===== Easy Anti-Cheat Installation ===== + + /// Product ID registered with Epic Online Services Easy Anti-Cheat for Generals Online. + public const string EacProductId = "fc1cc0d936424212b645105f084d08b0"; + + /// Setup command passed to EasyAntiCheat_EOS_Setup.exe. + public const string EacInstallCommand = "install"; + + /// Display name for the Easy Anti-Cheat installation step. + public const string EacStepName = "Install Easy Anti-Cheat"; + + /// Status message displayed to the user during Easy Anti-Cheat installation. + public const string EacStatusMessage = "Installing AntiCheat"; + + /// Unique step key identifying Easy Anti-Cheat installation for Generals Online. + public const string EacStepKey = PublisherType + ":eac:" + EacProductId; + // ===== Content Tags ===== /// Content tags for search and categorization. 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/InfoConstants.cs b/GenHub/GenHub.Core/Constants/InfoConstants.cs index e413a901c..de315df05 100644 --- a/GenHub/GenHub.Core/Constants/InfoConstants.cs +++ b/GenHub/GenHub.Core/Constants/InfoConstants.cs @@ -1,10 +1,12 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace GenHub.Core.Constants; /// /// Constants for the Info and FAQ features. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class InfoConstants { /// @@ -17,6 +19,31 @@ public static class InfoConstants /// public const string FaqDefaultLanguage = "en"; + /// + /// Module name for GenHub Guide. + /// + public const string ModuleGuide = "GenHub Guide"; + + /// + /// Module name for Zero Hour. + /// + public const string ModuleZeroHour = "Zero Hour"; + + /// + /// Module name for GeneralsOnline. + /// + public const string ModuleGeneralsOnline = "GeneralsOnline"; + + /// + /// Section ID for FAQ. + /// + public const string SectionFaq = "faq"; + + /// + /// Section ID for GeneralsOnline changelog. + /// + public const string SectionGoChangelog = "go-changelog"; + /// /// The list of supported languages for the FAQ. /// diff --git a/GenHub/GenHub.Core/Constants/IoConstants.cs b/GenHub/GenHub.Core/Constants/IoConstants.cs index 09f0b77b5..9fc9be4f8 100644 --- a/GenHub/GenHub.Core/Constants/IoConstants.cs +++ b/GenHub/GenHub.Core/Constants/IoConstants.cs @@ -9,4 +9,22 @@ public static class IoConstants /// Default buffer size for file operations (4KB). /// public const int DefaultFileBufferSize = 4096; + + /// + /// Buffer size used when scanning binary streams for embedded signatures (8KB). + /// + public const int SignatureScanBufferSize = 8192; + + /// + /// How many times a path may be re-resolved while following symbolic links whose targets are + /// themselves reached through links. Bounds the walk on a filesystem that contains a cycle. + /// + public const int MaxSymbolicLinkResolutionDepth = 8; + + /// + /// Suffix that marks a staging file written beside its final location so the existing file is + /// only replaced once the write has completed. The name it is appended to is random rather than + /// the destination name, which keeps a staged write from outgrowing the Windows path limit. + /// + public const string StagingFileSuffix = ".genhub-staging"; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs index 1a66630f8..4096fd317 100644 --- a/GenHub/GenHub.Core/Constants/IpcCommands.cs +++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs @@ -11,7 +11,8 @@ public static class IpcCommands public const string LaunchProfilePrefix = "launch-profile:"; /// - /// Command prefix used to subscribe to a catalog via IPC. + /// Command prefix used to forward a subscribe URL to the primary instance + /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=.... /// public const string SubscribePrefix = "subscribe:"; } diff --git a/GenHub/GenHub.Core/Constants/ManifestConstants.cs b/GenHub/GenHub.Core/Constants/ManifestConstants.cs index bdfc551dc..d58959ce7 100644 --- a/GenHub/GenHub.Core/Constants/ManifestConstants.cs +++ b/GenHub/GenHub.Core/Constants/ManifestConstants.cs @@ -100,6 +100,16 @@ public static class ManifestConstants /// public const string DefaultContentDependencyId = "1.0.genhub.content.defaultdependency"; + /// + /// Wildcard token representing any publisher in dependency declarations. + /// + public const string AnyPublisherToken = "any"; + + /// + /// Separator used to append variant identifiers to content names. + /// + public const string VariantSeparator = "-"; + /// /// Version string for Generals game installation manifests. /// This represents the executable version 1.08. diff --git a/GenHub/GenHub.Core/Constants/MapManagerConstants.cs b/GenHub/GenHub.Core/Constants/MapManagerConstants.cs index 670bd3bf1..2930c8f3d 100644 --- a/GenHub/GenHub.Core/Constants/MapManagerConstants.cs +++ b/GenHub/GenHub.Core/Constants/MapManagerConstants.cs @@ -110,6 +110,31 @@ public static class MapManagerConstants /// public const string ToolDescription = "Manage, import, and share custom maps. Create MapPacks for easy profile switching."; + /// + /// Prefix for temporary share archives created for uploads. + /// + public const string TempShareFilePrefix = "genhub_maps_"; + + /// + /// Mock path separator indicator for demo environments on Windows. + /// + public const string WindowsMockPathSegment = ToolConstants.WindowsMockPathSegment; + + /// + /// Mock path separator indicator for demo environments on Unix. + /// + public const string UnixMockPathSegment = ToolConstants.UnixMockPathSegment; + + /// + /// Notification title for delete failure. + /// + public const string DeleteFailedTitle = ToolConstants.DeleteFailedTitle; + + /// + /// Category identifier for map uploads. + /// + public const string UploadCategory = "maps"; + /// /// Allowed file extensions for map packages. /// diff --git a/GenHub/GenHub.Core/Constants/ModDBConstants.cs b/GenHub/GenHub.Core/Constants/ModDBConstants.cs index 2deed935c..30096acb0 100644 --- a/GenHub/GenHub.Core/Constants/ModDBConstants.cs +++ b/GenHub/GenHub.Core/Constants/ModDBConstants.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants specific to ModDB and its content pipeline components. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class ModDBConstants { // ===== Base URLs ===== diff --git a/GenHub/GenHub.Core/Constants/PlatformConstants.cs b/GenHub/GenHub.Core/Constants/PlatformConstants.cs index e81581558..f836fea66 100644 --- a/GenHub/GenHub.Core/Constants/PlatformConstants.cs +++ b/GenHub/GenHub.Core/Constants/PlatformConstants.cs @@ -1,3 +1,6 @@ +using System; +using System.IO; + namespace GenHub.Core.Constants; /// @@ -14,4 +17,28 @@ public static class PlatformConstants /// Windows Explorer select argument. /// public const string WindowsExplorerSelectArgument = "/select,\"{0}\""; + + /// + /// macOS open command executable name. + /// + public const string MacOSOpenExecutable = "open"; + + /// + /// Linux xdg-open command executable name. + /// + public const string LinuxXdgOpenExecutable = "xdg-open"; + + /// + /// Gets the absolute path to the Windows Explorer executable. + /// + public static string WindowsExplorerPath + { + get + { + var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + return string.IsNullOrEmpty(windowsDir) + ? WindowsExplorerExecutable + : Path.Combine(windowsDir, WindowsExplorerExecutable); + } + } } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ProcessConstants.cs b/GenHub/GenHub.Core/Constants/ProcessConstants.cs index 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/PublisherInfoConstants.cs b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs index db801fead..43b03d702 100644 --- a/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using GenHub.Core.Models.Enums; namespace GenHub.Core.Constants; @@ -6,6 +7,7 @@ namespace GenHub.Core.Constants; /// Constants for publisher information including display names, websites, and support URLs. /// These constants provide standardized publisher metadata for content attribution and user interface display. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class PublisherInfoConstants { /// diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs index 27f2cd99a..160115a2b 100644 --- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using GenHub.Core.Extensions.GameInstallations; using GenHub.Core.Models.Enums; @@ -56,9 +58,25 @@ public static class PublisherTypeConstants /// Community Outpost platform. public const string CommunityOutpost = "communityoutpost"; + /// CSV registry publisher. + public const string CsvRegistry = "csvregistry"; + /// Art of Defense Maps community site. public const string AODMaps = "aodmaps"; + /// GenHub internal system content publisher. + public const string GenHubInternal = "genhub"; + + /// + /// Set of publisher identifiers trusted to execute installation steps (e.g. installers). + /// + public static readonly IReadOnlySet TrustedExecutablePublishers = new HashSet(StringComparer.OrdinalIgnoreCase) + { + GeneralsOnline, + CommunityOutpost, + TheSuperHackers, + }; + /// /// Maps GameInstallationType enum to publisher type string. /// diff --git a/GenHub/GenHub.Core/Constants/RegexConstants.cs b/GenHub/GenHub.Core/Constants/RegexConstants.cs index 311312cd2..6b9a17e4e 100644 --- a/GenHub/GenHub.Core/Constants/RegexConstants.cs +++ b/GenHub/GenHub.Core/Constants/RegexConstants.cs @@ -14,4 +14,9 @@ public static class RegexConstants /// Regex pattern for GenTool replay links. /// public const string GenToolReplayPattern = @"href=""([^\""]+\.rep)"""; + + /// + /// Regex pattern for Strata / GameReplays replay links. + /// + public const string StrataReplayPattern = @"(?:href=[""'](?[^""']+\.(?:rep|zip))[""']|(?https?://[^""'\s<>]+\.(?:rep|zip)))"; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs index 9e0cc62ce..a605bbe36 100644 --- a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs +++ b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs @@ -44,4 +44,34 @@ public static class ReplayManagerConstants /// Default file name for imported replays. /// public const string DefaultImportedReplayFileName = "imported_replay.rep"; + + /// + /// File pattern for replay ZIP archives. + /// + public const string ZipFilePattern = "*.zip"; + + /// + /// Default name for exported replay ZIP files. + /// + public const string DefaultZipName = "replays"; + + /// + /// Notification title for delete failure. + /// + public const string DeleteFailedTitle = ToolConstants.DeleteFailedTitle; + + /// + /// Category identifier for replay uploads. + /// + public const string UploadCategory = "replays"; + + /// + /// Mock path separator indicator for demo environments on Windows. + /// + public const string WindowsMockPathSegment = ToolConstants.WindowsMockPathSegment; + + /// + /// Mock path separator indicator for demo environments on Unix. + /// + public const string UnixMockPathSegment = ToolConstants.UnixMockPathSegment; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/SettingsConstants.cs b/GenHub/GenHub.Core/Constants/SettingsConstants.cs new file mode 100644 index 000000000..ac1d945c9 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/SettingsConstants.cs @@ -0,0 +1,62 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for the Settings feature sections and identifiers. +/// +public static class SettingsConstants +{ + /// + /// Section ID for Game Configuration. + /// + public const string SectionGameConfig = "game-config"; + + /// + /// Section ID for Downloads. + /// + public const string SectionDownloads = "downloads"; + + /// + /// Section ID for Appearance. + /// + public const string SectionAppearance = "appearance"; + + /// + /// Section ID for Data Directories. + /// + public const string SectionDataDirectories = "data-directories"; + + /// + /// Section ID for Logs. + /// + public const string SectionLogs = "logs"; + + /// + /// Section ID for Performance. + /// + public const string SectionPerformance = "performance"; + + /// + /// Section ID for Content-Addressable Storage (CAS). + /// + public const string SectionCas = "cas"; + + /// + /// Section ID for Local Content Directories. + /// + public const string SectionLocalContent = "local-content"; + + /// + /// Section ID for GitHub Discovery. + /// + public const string SectionGitHubDiscovery = "github-discovery"; + + /// + /// Section ID for Updates. + /// + public const string SectionUpdates = "updates"; + + /// + /// Section ID for Danger Zone. + /// + public const string SectionDangerZone = "danger-zone"; +} diff --git a/GenHub/GenHub.Core/Constants/SidebarConstants.cs b/GenHub/GenHub.Core/Constants/SidebarConstants.cs new file mode 100644 index 000000000..0f45f1c9b --- /dev/null +++ b/GenHub/GenHub.Core/Constants/SidebarConstants.cs @@ -0,0 +1,27 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for the sidebar layout and pane dimensions. +/// +public static class SidebarConstants +{ + /// + /// Default open width of the sidebar pane in pixels. + /// + public const double DefaultOpenPaneLength = 220.0; + + /// + /// Minimum allowed width of the sidebar pane in pixels when resizing. + /// + public const double MinPaneLength = 140.0; + + /// + /// Maximum allowed width of the sidebar pane in pixels when resizing. + /// + public const double MaxPaneLength = 360.0; + + /// + /// Width of the splitter divider column in pixels. + /// + public const double SplitterWidth = 4.0; +} diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs index b15606e59..d5d3dffce 100644 --- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs +++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs @@ -60,6 +60,21 @@ public static class SuperHackersConstants /// public const string GeneralsGameCodeRepo = "GeneralsGameCode"; + /// + /// GitHub owner for Generals game patch 2. + /// + public const string GeneralsGamePatch2Owner = "TheSuperHackers"; + + /// + /// GitHub repo for Generals game patch 2. + /// + public const string GeneralsGamePatch2Repo = "GeneralsGamePatch2"; + + /// + /// Display name for Generals game patch 2. + /// + public const string GeneralsGamePatch2DisplayName = "Community Patch 2"; + // ===== Service Configuration ===== /// diff --git a/GenHub/GenHub.Core/Constants/ThemeConstants.cs b/GenHub/GenHub.Core/Constants/ThemeConstants.cs new file mode 100644 index 000000000..30e8a1729 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ThemeConstants.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; +using GenHub.Core.Models.Theming; + +namespace GenHub.Core.Constants; + +/// +/// Constants and preset palettes for application color theming. +/// +public static class ThemeConstants +{ + /// + /// Default Void Purple theme. + /// + public static readonly ColorTheme DefaultTheme = new() + { + Id = "Purple", + DisplayName = "Void Purple", + PrimaryHex = "#A855F7", + LightHex = "#C084FC", + DarkHex = "#7E22CE", + GlowHex = "#33A855F7", + }; + + /// + /// Generals Command Orange theme. + /// + public static readonly ColorTheme GeneralsTheme = new() + { + Id = "Generals", + DisplayName = "Generals Orange", + PrimaryHex = "#F97316", + LightHex = "#FB923C", + DarkHex = "#C2410C", + GlowHex = "#33F97316", + }; + + /// + /// Zero Hour Tactical Cyan theme. + /// + public static readonly ColorTheme ZeroHourTheme = new() + { + Id = "ZeroHour", + DisplayName = "Zero Hour Cyan", + PrimaryHex = "#06B6D4", + LightHex = "#22D3EE", + DarkHex = "#0E7490", + GlowHex = "#3306B6D4", + }; + + /// + /// Emerald Toxic Green theme. + /// + public static readonly ColorTheme EmeraldTheme = new() + { + Id = "Emerald", + DisplayName = "Emerald Green", + PrimaryHex = "#10B981", + LightHex = "#34D399", + DarkHex = "#047857", + GlowHex = "#3310B981", + }; + + /// + /// NOD Crimson Red theme. + /// + public static readonly ColorTheme CrimsonTheme = new() + { + Id = "Crimson", + DisplayName = "Crimson Red", + PrimaryHex = "#EF4444", + LightHex = "#F87171", + DarkHex = "#B91C1C", + GlowHex = "#33EF4444", + }; + + /// + /// Cyber Amber Gold theme. + /// + public static readonly ColorTheme AmberTheme = new() + { + Id = "Amber", + DisplayName = "Cyber Amber", + PrimaryHex = "#F59E0B", + LightHex = "#FBBF24", + DarkHex = "#B45309", + GlowHex = "#33F59E0B", + }; + + /// + /// Cobalt Navy Blue theme. + /// + public static readonly ColorTheme CobaltTheme = new() + { + Id = "Cobalt", + DisplayName = "Cobalt Blue", + PrimaryHex = "#3B82F6", + LightHex = "#60A5FA", + DarkHex = "#1D4ED8", + GlowHex = "#333B82F6", + }; + + /// + /// Neon Rose Pink theme. + /// + public static readonly ColorTheme RoseTheme = new() + { + Id = "Rose", + DisplayName = "Neon Rose", + PrimaryHex = "#EC4899", + LightHex = "#F472B6", + DarkHex = "#BE185D", + GlowHex = "#33EC4899", + }; + + /// + /// Tiberium Lime theme. + /// + public static readonly ColorTheme TiberiumTheme = new() + { + Id = "Tiberium", + DisplayName = "Tiberium Lime", + PrimaryHex = "#84CC16", + LightHex = "#A3E635", + DarkHex = "#4D7C0F", + GlowHex = "#3384CC16", + }; + + /// + /// Deep Teal theme. + /// + public static readonly ColorTheme TealTheme = new() + { + Id = "Teal", + DisplayName = "Deep Teal", + PrimaryHex = "#14B8A6", + LightHex = "#2DD4BF", + DarkHex = "#0F766E", + GlowHex = "#3314B8A6", + }; + + /// + /// Electric Indigo theme. + /// + public static readonly ColorTheme IndigoTheme = new() + { + Id = "Indigo", + DisplayName = "Electric Indigo", + PrimaryHex = "#6366F1", + LightHex = "#818CF8", + DarkHex = "#4338CA", + GlowHex = "#336366F1", + }; + + /// + /// Blood Ruby theme. + /// + public static readonly ColorTheme RubyTheme = new() + { + Id = "Ruby", + DisplayName = "Blood Ruby", + PrimaryHex = "#F43F5E", + LightHex = "#FB7185", + DarkHex = "#BE123C", + GlowHex = "#33F43F5E", + }; + + /// + /// Gets all available color themes. + /// + public static readonly IReadOnlyList AllThemes = + [ + DefaultTheme, + GeneralsTheme, + ZeroHourTheme, + EmeraldTheme, + CrimsonTheme, + AmberTheme, + CobaltTheme, + RoseTheme, + TiberiumTheme, + TealTheme, + IndigoTheme, + RubyTheme, + ]; +} diff --git a/GenHub/GenHub.Core/Constants/ThemeResourceKeys.cs b/GenHub/GenHub.Core/Constants/ThemeResourceKeys.cs new file mode 100644 index 000000000..61f204b74 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ThemeResourceKeys.cs @@ -0,0 +1,202 @@ +namespace GenHub.Core.Constants; + +/// +/// Resource keys used for dynamic application theming in Avalonia resources. +/// +public static class ThemeResourceKeys +{ + /// + /// Accent color resource key. + /// + public const string AccentColor = "AccentColor"; + + /// + /// System accent color resource key. + /// + public const string SystemAccentColor = "SystemAccentColor"; + + /// + /// Primary button background dark color resource key. + /// + public const string PrimaryButtonBackgroundDark = "PrimaryButtonBackgroundDark"; + + /// + /// Accent badge background color resource key. + /// + public const string AccentBadgeBackgroundColor = "AccentBadgeBackgroundColor"; + + /// + /// Accent badge foreground color resource key. + /// + public const string AccentBadgeForegroundColor = "AccentBadgeForegroundColor"; + + /// + /// Accent glow color resource key. + /// + public const string AccentGlowColor = "AccentGlowColor"; + + /// + /// Primary gradient start color resource key. + /// + public const string PrimaryGradientStart = "PrimaryGradientStart"; + + /// + /// Primary gradient end color resource key. + /// + public const string PrimaryGradientEnd = "PrimaryGradientEnd"; + + /// + /// Accent brush resource key. + /// + public const string AccentBrush = "AccentBrush"; + + /// + /// Accent color brush resource key. + /// + public const string AccentColorBrush = "AccentColorBrush"; + + /// + /// Accent glow brush resource key. + /// + public const string AccentGlowBrush = "AccentGlowBrush"; + + /// + /// System accent color brush resource key. + /// + public const string SystemAccentColorBrush = "SystemAccentColorBrush"; + + /// + /// Primary button background brush resource key. + /// + public const string PrimaryButtonBackground = "PrimaryButtonBackground"; + + /// + /// Sidebar selected indicator brush resource key. + /// + public const string SidebarSelectedIndicator = "SidebarSelectedIndicator"; + + /// + /// Scrollbar pressed thumb brush resource key. + /// + public const string ScrollbarThumbPressedBrush = "ScrollbarThumbPressedBrush"; + + /// + /// Fluent ScrollBar pressed thumb fill brush resource key. + /// + public const string ScrollBarThumbFillPressed = "ScrollBarThumbFillPressed"; + + /// + /// Accent badge background brush resource key. + /// + public const string AccentBadgeBackgroundBrush = "AccentBadgeBackgroundBrush"; + + /// + /// Accent badge foreground brush resource key. + /// + public const string AccentBadgeForegroundBrush = "AccentBadgeForegroundBrush"; + + /// + /// Sidebar item selected background brush resource key. + /// + public const string SidebarItemSelectedBackground = "SidebarItemSelectedBackground"; + + /// + /// Sidebar item selected border brush resource key. + /// + public const string SidebarItemSelectedBorder = "SidebarItemSelectedBorder"; + + /// + /// Primary gradient brush resource key. + /// + public const string PrimaryGradientBrush = "PrimaryGradientBrush"; + + /// + /// Accent light color resource key. + /// + public const string AccentLightColor = "AccentLightColor"; + + /// + /// Accent dark color resource key. + /// + public const string AccentDarkColor = "AccentDarkColor"; + + /// + /// Accent tint background color resource key. + /// + public const string AccentTintBackgroundColor = "AccentTintBackgroundColor"; + + /// + /// Sidebar glass border color resource key. + /// + public const string SidebarGlassBorder = "SidebarGlassBorder"; + + /// + /// Sidebar glow color resource key. + /// + public const string SidebarGlowColor = "SidebarGlowColor"; + + /// + /// Accent light brush resource key. + /// + public const string AccentLightBrush = "AccentLightBrush"; + + /// + /// Accent dark brush resource key. + /// + public const string AccentDarkBrush = "AccentDarkBrush"; + + /// + /// Accent tint background brush resource key. + /// + public const string AccentTintBackgroundBrush = "AccentTintBackgroundBrush"; + + /// + /// Sidebar glass border brush resource key. + /// + public const string SidebarGlassBorderBrush = "SidebarGlassBorderBrush"; + + /// + /// Purple accent gradient brush resource key. + /// + public const string PurpleAccentGradient = "PurpleAccentGradient"; + + /// + /// Fluent ComboBox item background selected brush resource key. + /// + public const string ComboBoxItemBackgroundSelected = "ComboBoxItemBackgroundSelected"; + + /// + /// Fluent ComboBox item background selected pointer over brush resource key. + /// + public const string ComboBoxItemBackgroundSelectedPointerOver = "ComboBoxItemBackgroundSelectedPointerOver"; + + /// + /// Fluent ComboBox item background pointer over brush resource key. + /// + public const string ComboBoxItemBackgroundPointerOver = "ComboBoxItemBackgroundPointerOver"; + + /// + /// Fluent ComboBox item foreground pointer over brush resource key. + /// + public const string ComboBoxItemForegroundPointerOver = "ComboBoxItemForegroundPointerOver"; + + /// + /// Fluent Expander header background pointer over brush resource key. + /// + public const string ExpanderHeaderBackgroundPointerOver = "ExpanderHeaderBackgroundPointerOver"; + + /// + /// Fluent Expander header background pressed brush resource key. + /// + public const string ExpanderHeaderBackgroundPressed = "ExpanderHeaderBackgroundPressed"; + + /// + /// Fluent Expander chevron foreground pointer over brush resource key. + /// + public const string ExpanderChevronForegroundPointerOver = "ExpanderChevronForegroundPointerOver"; + + /// + /// Fluent Expander chevron foreground pressed brush resource key. + /// + public const string ExpanderChevronForegroundPressed = "ExpanderChevronForegroundPressed"; +} diff --git a/GenHub/GenHub.Core/Constants/ToolConstants.cs b/GenHub/GenHub.Core/Constants/ToolConstants.cs index 2967d13e0..129af5a47 100644 --- a/GenHub/GenHub.Core/Constants/ToolConstants.cs +++ b/GenHub/GenHub.Core/Constants/ToolConstants.cs @@ -1,10 +1,29 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants for tool plugin metadata and configuration. /// +[SuppressMessage("Major Code Smell", "S1075:URIs should not be hardcoded", Justification = "Mock URLs for demo tool services.")] public static class ToolConstants { + /// + /// Mock sharing URLs for demo tool services. + /// + public static class MockUrls + { + /// + /// Mock upload URL for replays. + /// + public const string MockReplayUploadUrl = "https://example.com/share/1234"; + + /// + /// Mock upload URL for maps. + /// + public const string MockMapUploadUrl = "https://example.com/maps/123"; + } + /// /// Constants for the Replay Manager tool plugin. /// @@ -50,4 +69,39 @@ public static class ReplayManager /// public static readonly string[] Tags = ["replays", "file-management", "sharing"]; } + + /// + /// Mock path separator indicator for demo environments on Windows. + /// + public const string WindowsMockPathSegment = "\\Mock\\"; + + /// + /// Mock path separator indicator for demo environments on Unix. + /// + public const string UnixMockPathSegment = "/Mock/"; + + /// + /// Notification title for delete failure. + /// + public const string DeleteFailedTitle = "Delete Failed"; + + /// + /// Default upload buffer size in bytes (8 KB). + /// + public const int DefaultUploadBufferSize = 8 * 1024; + + /// + /// Upload progress stage percentage threshold for compression stage. + /// + public const int UploadStageCompressionThresholdPercent = 25; + + /// + /// Upload progress stage percentage threshold for cloud upload stage. + /// + public const int UploadStageCloudThresholdPercent = 88; + + /// + /// Upload progress stage percentage threshold for completion stage. + /// + public const int UploadStageCompletePercent = 100; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/UriConstants.cs b/GenHub/GenHub.Core/Constants/UriConstants.cs index 5c6d82b8f..d623bdf74 100644 --- a/GenHub/GenHub.Core/Constants/UriConstants.cs +++ b/GenHub/GenHub.Core/Constants/UriConstants.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// URI scheme constants for handling different types of URIs and paths. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class UriConstants { /// diff --git a/GenHub/GenHub.Core/Constants/UserDataConstants.cs b/GenHub/GenHub.Core/Constants/UserDataConstants.cs new file mode 100644 index 000000000..8de21fae4 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/UserDataConstants.cs @@ -0,0 +1,13 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for tracked user data installations. +/// +public static class UserDataConstants +{ + /// + /// Suffix appended to a deployed file that no longer matches its recorded hash when it is + /// moved aside so the pristine backup can be restored over it. + /// + public const string UserModifiedSuffix = ".user-modified"; +} diff --git a/GenHub/GenHub.Core/Constants/WorkspaceConstants.cs b/GenHub/GenHub.Core/Constants/WorkspaceConstants.cs index 034d5743c..3685685c8 100644 --- a/GenHub/GenHub.Core/Constants/WorkspaceConstants.cs +++ b/GenHub/GenHub.Core/Constants/WorkspaceConstants.cs @@ -12,4 +12,10 @@ public static class WorkspaceConstants /// Default is HardLink as it provides space-efficient file management with good compatibility. /// public const WorkspaceStrategy DefaultWorkspaceStrategy = WorkspaceStrategy.HardLink; + + /// + /// Guidance message appended to errors when zero-copy hard links or symlinks cannot be created. + /// + public const string ZeroCopyElevationGuidance = + "To use zero-copy workspaces without copying game files, ensure GenHub has permission to create links (on Windows, enable Developer Mode or run as Administrator)."; } diff --git a/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs b/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs new file mode 100644 index 000000000..448a6bc00 --- /dev/null +++ b/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs @@ -0,0 +1,76 @@ +using System; + +namespace GenHub.Core.Exceptions; + +/// +/// Exception thrown when an archive entry expands past the budget allowed for it, which means the +/// size declared in the archive headers understated the real decompressed size. +/// +public class ArchiveExpansionLimitExceededException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public ArchiveExpansionLimitExceededException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public ArchiveExpansionLimitExceededException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public ArchiveExpansionLimitExceededException(string message, Exception? inner) + : base(message, inner) + { + } + + /// + /// Initializes a new instance of the class + /// for a named entry that exceeded a byte budget. + /// + /// The archive-relative name of the offending entry. + /// The number of bytes the entry was allowed to expand to. + public ArchiveExpansionLimitExceededException(string entryName, long limitBytes) + : this($"Archive entry '{entryName}' expanded past the allowed {limitBytes} bytes (potential zip bomb).", entryName, limitBytes) + { + } + + private ArchiveExpansionLimitExceededException(string message, string entryName, long limitBytes) + : base(message) + { + EntryName = entryName; + LimitBytes = limitBytes; + } + + /// + /// Gets the archive-relative name of the offending entry. + /// + public string EntryName { get; } = string.Empty; + + /// + /// Gets the number of bytes the entry was allowed to expand to. + /// + public long LimitBytes { get; } + + /// + /// Creates an exception for an entry refused because the archive-wide expansion budget was + /// already spent, so no byte of it was ever read. + /// + /// The archive-relative name of the refused entry. + /// An exception describing the spent budget. + public static ArchiveExpansionLimitExceededException ForSpentBudget(string entryName) => + new( + $"Archive entry '{entryName}' was refused because the archive-wide expansion budget was already spent (potential zip bomb).", + entryName, + 0); +} diff --git a/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs b/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs new file mode 100644 index 000000000..68b199060 --- /dev/null +++ b/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs @@ -0,0 +1,29 @@ +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Extensions.Enums; + +/// +/// Provides extension methods for the enum. +/// +public static class ContentInstallTargetExtensions +{ + /// + /// Determines whether the target resolves to a directory the user and the game engine + /// write to directly, which means deployed content must never share storage with the + /// content-addressable object it originated from. + /// + /// Only the two targets that are definitively not user data are listed as such: every other + /// value, including any added later, is treated as user-writable and therefore copied. That + /// matches the resolver, whose own default arm places unmapped targets inside the user data + /// root, and it fails towards an extra copy rather than towards a hard link into Documents. + /// + /// + /// The install target to inspect. + /// true when the destination is user-writable; otherwise, false. + public static bool IsUserWritableTarget(this ContentInstallTarget installTarget) => installTarget switch + { + ContentInstallTarget.Workspace => false, + ContentInstallTarget.System => false, + _ => true, + }; +} diff --git a/GenHub/GenHub.Core/Extensions/GameInstallations/InstallationExtensions.cs b/GenHub/GenHub.Core/Extensions/GameInstallations/InstallationExtensions.cs index 132c753fc..866ae60bc 100644 --- a/GenHub/GenHub.Core/Extensions/GameInstallations/InstallationExtensions.cs +++ b/GenHub/GenHub.Core/Extensions/GameInstallations/InstallationExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using GenHub.Core.Constants; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Models.Enums; @@ -51,11 +52,74 @@ public static bool FileExistsCaseInsensitive(this string filePath) var files = directoryInfo.GetFiles(); return files.Any(f => string.Equals(f.Name, fileName, StringComparison.OrdinalIgnoreCase)); } - catch + catch (IOException) { // If directory enumeration fails, fall back to false return false; } + catch (UnauthorizedAccessException) + { + // If directory enumeration fails due to permissions, fall back to false + return false; + } + catch (ArgumentException) + { + // If path contains invalid characters, fall back to false + return false; + } + } + + /// + /// Checks if a subdirectory exists under a parent path in a case-insensitive manner, returning the matched directory path. + /// + /// The parent directory to search within. + /// The subdirectory name to look for. + /// The actual matched full path if found. + /// True if the subdirectory exists; otherwise false. + public static bool TryGetDirectoryCaseInsensitive(this string parentDirectory, string subDirectoryName, [NotNullWhen(true)] out string? matchedPath) + { + matchedPath = null; + if (string.IsNullOrEmpty(parentDirectory) || string.IsNullOrEmpty(subDirectoryName)) + { + return false; + } + + try + { + var candidate = Path.Combine(parentDirectory, subDirectoryName); + if (Directory.Exists(candidate)) + { + matchedPath = candidate; + return true; + } + + var directoryInfo = new DirectoryInfo(parentDirectory); + if (!directoryInfo.Exists) + { + return false; + } + + var matchingDir = directoryInfo.GetDirectories().FirstOrDefault(d => string.Equals(d.Name, subDirectoryName, StringComparison.OrdinalIgnoreCase)); + if (matchingDir is not null) + { + matchedPath = matchingDir.FullName; + return true; + } + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + + return false; } /// diff --git a/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs b/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs index 7f414ba6f..8640ee339 100644 --- a/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs +++ b/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs @@ -1,3 +1,6 @@ +using System; +using System.Linq; +using GenHub.Core.Constants; using GenHub.Core.Models.GameProfile; namespace GenHub.Core.Extensions; @@ -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 || @@ -68,7 +100,8 @@ private static bool HasCustomTshSettings(GameProfile profile) profile.TshCursorCaptureEnabledInWindowedMenu.HasValue || profile.TshScreenEdgeScrollEnabledInFullscreenApp.HasValue || profile.TshScreenEdgeScrollEnabledInWindowedApp.HasValue || - profile.TshMoneyTransactionVolume.HasValue; + profile.TshMoneyTransactionVolume.HasValue || + profile.TshGameWindowTransitionSpeedMultiplier.HasValue; } private static bool HasCustomGeneralsOnlineSettings(GameProfile profile) 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/ContentPathPolicy.cs b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs new file mode 100644 index 000000000..a165d5b67 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Helpers; + +/// +/// Policy and validation helper for ensuring file system paths remain safely contained +/// within a target root directory, preventing directory traversal and zip slip attacks across OS platforms. +/// +public static class ContentPathPolicy +{ + /// + /// Resolves a candidate relative path within a designated root directory, ensuring that the + /// resolved canonical path is strictly contained within that root directory. + /// + /// The trusted root directory. + /// The relative path to validate and resolve. + /// + /// An containing the normalized absolute destination path if safe, + /// or a failure result if the path escapes the root directory or contains illegal rooted/traversal components. + /// + public static OperationResult ResolveContainedFile(string? rootDirectory, string? relativePath) + { + if (string.IsNullOrWhiteSpace(rootDirectory)) + { + return OperationResult.CreateFailure("Root directory cannot be null or empty."); + } + + if (string.IsNullOrWhiteSpace(relativePath)) + { + return OperationResult.CreateFailure("Relative path cannot be null or empty."); + } + + if (Path.IsPathRooted(relativePath) || + (relativePath.Length >= 2 && relativePath[1] == ':' && char.IsLetter(relativePath[0])) || + relativePath.StartsWith("\\\\", StringComparison.Ordinal) || + relativePath.StartsWith("//", StringComparison.Ordinal)) + { + return OperationResult.CreateFailure($"Relative path cannot be rooted or absolute: {relativePath}"); + } + + // Normalize directory separators + var normalizedRelative = relativePath.Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar) + .TrimStart(Path.DirectorySeparatorChar); + + if (string.IsNullOrWhiteSpace(normalizedRelative)) + { + return OperationResult.CreateFailure("Normalized relative path cannot be empty."); + } + + var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + var fullRoot = Path.GetFullPath(normalizedRoot); + var fullCandidate = Path.GetFullPath(Path.Combine(fullRoot, normalizedRelative)); + + if (!IsContainedInternal(fullRoot, fullCandidate)) + { + return OperationResult.CreateFailure( + $"Path '{relativePath}' escapes target root directory '{rootDirectory}'."); + } + + return OperationResult.CreateSuccess(fullCandidate); + } + + /// + /// Validates whether a candidate path is strictly contained within a designated root directory. + /// + /// The root directory. + /// The candidate path to check. + /// if the candidate path is contained within the root; otherwise . + public static bool IsContained(string? rootDirectory, string? candidatePath) + { + if (string.IsNullOrWhiteSpace(rootDirectory) || string.IsNullOrWhiteSpace(candidatePath)) + { + return false; + } + + try + { + var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + var normalizedCandidate = candidatePath.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + var fullRoot = Path.GetFullPath(normalizedRoot); + var fullCandidate = Path.GetFullPath(normalizedCandidate); + + return IsContainedInternal(fullRoot, fullCandidate); + } + catch + { + return false; + } + } + + private static bool IsContainedInternal(string fullRoot, string fullCandidate) + { + var rootPrefix = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + if (!fullCandidate.StartsWith(rootPrefix, PathHelper.PathComparison) && + !fullCandidate.Equals(fullRoot, PathHelper.PathComparison)) + { + return false; + } + + var realRoot = ResolveRealPath(fullRoot); + var realCandidate = ResolveRealPath(fullCandidate); + + var realRootPrefix = realRoot.EndsWith(Path.DirectorySeparatorChar) + ? realRoot + : realRoot + Path.DirectorySeparatorChar; + + return realCandidate.StartsWith(realRootPrefix, PathHelper.PathComparison) || + realCandidate.Equals(realRoot, PathHelper.PathComparison); + } + + private static string ResolveRealPath(string path) + { + try + { + var current = path; + while (!string.IsNullOrEmpty(current)) + { + if (TryResolveLink(current, path, out var resolvedPath)) + { + return resolvedPath; + } + + if (File.Exists(current)) + { + break; + } + + current = Path.GetDirectoryName(current); + } + } + catch + { + // Fallback to path if resolution fails + } + + return path; + } + + private static bool TryResolveLink(string current, string originalPath, out string resolvedPath) + { + resolvedPath = string.Empty; + var targetFullName = TryGetLinkTargetFullName(current); + if (string.IsNullOrEmpty(targetFullName)) + { + return false; + } + + var relativeSuffix = Path.GetRelativePath(current, originalPath); + resolvedPath = relativeSuffix == "." + ? targetFullName + : Path.GetFullPath(Path.Combine(targetFullName, relativeSuffix)); + return true; + } + + private static string? TryGetLinkTargetFullName(string path) + { + if (File.Exists(path)) + { + var fileInfo = new FileInfo(path); + return fileInfo.LinkTarget != null + ? fileInfo.ResolveLinkTarget(returnFinalTarget: true)?.FullName + : null; + } + + if (Directory.Exists(path)) + { + var dirInfo = new DirectoryInfo(path); + return dirInfo.LinkTarget != null + ? dirInfo.ResolveLinkTarget(returnFinalTarget: true)?.FullName + : null; + } + + 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..09dd3b578 100644 --- a/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs +++ b/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs @@ -1,3 +1,4 @@ +using System.Globalization; using GenHub.Core.Constants; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameProfile; @@ -83,16 +84,24 @@ public static void ApplyFromGeneralsOnlineSettings(GeneralsOnlineSettings settin profile.TshCursorCaptureEnabledInWindowedMenu = settings.CursorCaptureEnabledInWindowedMenu; profile.TshScreenEdgeScrollEnabledInFullscreenApp = settings.ScreenEdgeScrollEnabledInFullscreenApp; profile.TshScreenEdgeScrollEnabledInWindowedApp = settings.ScreenEdgeScrollEnabledInWindowedApp; + profile.TshGameWindowTransitionSpeedMultiplier = settings.GameWindowTransitionSpeedMultiplier; } /// /// Applies settings from a GameProfile to a GeneralsOnlineSettings object. /// Used by GameLauncher to prepare settings.json for launch. /// + /// + /// Only the fields the profile declares are written, as does for + /// Options.ini. The caller passes the settings already on disk, and anything the profile leaves + /// unset is the GeneralsOnline client's own configuration, which a launch must not overwrite. + /// /// The GameProfile source. /// The GeneralsOnlineSettings to populate. public static void ApplyToGeneralsOnlineSettings(GameProfile profile, GeneralsOnlineSettings settings) { + settings.EnsureNestedSectionsInitialized(); + ApplyGoGeneralSettings(profile, settings); ApplyGoCameraAndChatSettings(profile, settings); ApplyGoRenderAndDebugSettings(profile, settings); @@ -157,6 +166,7 @@ public static void PopulateGameProfile(GameProfile profile, CreateProfileRequest profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp; profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp; profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume; + profile.TshGameWindowTransitionSpeedMultiplier = request.TshGameWindowTransitionSpeedMultiplier; // GeneralsOnline settings profile.GoShowFps = request.GoShowFps; @@ -251,6 +261,7 @@ public static void PopulateGameProfile(GameProfile profile, UpdateProfileRequest profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp; profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp; profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume; + profile.TshGameWindowTransitionSpeedMultiplier = request.TshGameWindowTransitionSpeedMultiplier; // GeneralsOnline settings profile.GoShowFps = request.GoShowFps; @@ -385,6 +396,7 @@ public static void PopulateRequest(CreateProfileRequest target, UpdateProfileReq target.TshScreenEdgeScrollEnabledInFullscreenApp = source.TshScreenEdgeScrollEnabledInFullscreenApp; target.TshScreenEdgeScrollEnabledInWindowedApp = source.TshScreenEdgeScrollEnabledInWindowedApp; target.TshMoneyTransactionVolume = source.TshMoneyTransactionVolume; + target.TshGameWindowTransitionSpeedMultiplier = source.TshGameWindowTransitionSpeedMultiplier; target.GoShowFps = source.GoShowFps; target.GoShowPing = source.GoShowPing; @@ -477,6 +489,7 @@ public static void PopulateRequest(UpdateProfileRequest target, UpdateProfileReq target.TshScreenEdgeScrollEnabledInFullscreenApp = source.TshScreenEdgeScrollEnabledInFullscreenApp; target.TshScreenEdgeScrollEnabledInWindowedApp = source.TshScreenEdgeScrollEnabledInWindowedApp; target.TshMoneyTransactionVolume = source.TshMoneyTransactionVolume; + target.TshGameWindowTransitionSpeedMultiplier = source.TshGameWindowTransitionSpeedMultiplier; target.GoShowFps = source.GoShowFps; target.GoShowPing = source.GoShowPing; @@ -513,6 +526,44 @@ public static void PopulateRequest(UpdateProfileRequest target, UpdateProfileReq target.VideoSkipEALogo = source.VideoSkipEALogo; } + /// + /// Normalizes and clamps a transition speed multiplier value to the supported range. + /// + /// The float value. + /// The clamped float multiplier if finite and non-null; otherwise, null. + public static float? NormalizeTransitionSpeedMultiplier(float? value) + { + if (value.HasValue && float.IsFinite(value.Value)) + { + return Math.Clamp( + value.Value, + GameSettingsTheSuperHackersConstants.MinGameWindowTransitionSpeedMultiplier, + GameSettingsTheSuperHackersConstants.MaxGameWindowTransitionSpeedMultiplier); + } + + return null; + } + + /// + /// Parses and clamps the GameWindowTransitionSpeedMultiplier from a raw string value. + /// + /// The raw string value. + /// The clamped float multiplier if valid; otherwise, null. + public static float? ParseTransitionSpeedMultiplier(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var speed)) + { + return NormalizeTransitionSpeedMultiplier(speed); + } + + return null; + } + private static void ApplyVideoFromOptions(IniOptions options, GameProfile profile) { profile.VideoResolutionWidth = options.Video.ResolutionWidth; @@ -571,22 +622,43 @@ private static void ApplyTshFlatSettingsFromOptions(IniOptions options, GameProf profile.VideoDynamicLOD = ParseBool(dynLOD); if (options.Video.AdditionalProperties.TryGetValue("MaxParticleCount", out var particles) && int.TryParse(particles, out var particleVal)) profile.VideoMaxParticleCount = particleVal; + if (options.Video.AdditionalProperties.TryGetValue(GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey, out var speed)) + { + var parsed = ParseTransitionSpeedMultiplier(speed); + if (parsed.HasValue) + { + profile.TshGameWindowTransitionSpeedMultiplier = parsed.Value; + } + } } private static void ApplyTshHierarchicalSettingsFromOptions(IniOptions options, GameProfile profile) { if (options.AdditionalSections.TryGetValue("TheSuperHackers", out var tsh)) { - if (tsh.TryGetValue("UseDoubleClickAttackMove", out var doubleClickTsh)) - profile.VideoUseDoubleClickAttackMove = ParseBool(doubleClickTsh); - if (tsh.TryGetValue("ScrollFactor", out var scrollTsh) && int.TryParse(scrollTsh, out var scrollTshVal)) - profile.VideoScrollFactor = scrollTshVal; - if (tsh.TryGetValue("Retaliation", out var retaliationTsh)) - profile.VideoRetaliation = ParseBool(retaliationTsh); - if (tsh.TryGetValue("DynamicLOD", out var dynLODTsh)) - profile.VideoDynamicLOD = ParseBool(dynLODTsh); - if (tsh.TryGetValue("MaxParticleCount", out var particlesTsh) && int.TryParse(particlesTsh, out var particlesTshVal)) - profile.VideoMaxParticleCount = particlesTshVal; + ApplyTshHierarchicalProperties(tsh, profile); + } + } + + private static void ApplyTshHierarchicalProperties(Dictionary tsh, GameProfile profile) + { + if (tsh.TryGetValue("UseDoubleClickAttackMove", out var doubleClickTsh)) + profile.VideoUseDoubleClickAttackMove = ParseBool(doubleClickTsh); + if (tsh.TryGetValue("ScrollFactor", out var scrollTsh) && int.TryParse(scrollTsh, out var scrollTshVal)) + profile.VideoScrollFactor = scrollTshVal; + if (tsh.TryGetValue("Retaliation", out var retaliationTsh)) + profile.VideoRetaliation = ParseBool(retaliationTsh); + if (tsh.TryGetValue("DynamicLOD", out var dynLODTsh)) + profile.VideoDynamicLOD = ParseBool(dynLODTsh); + if (tsh.TryGetValue("MaxParticleCount", out var particlesTsh) && int.TryParse(particlesTsh, out var particlesTshVal)) + profile.VideoMaxParticleCount = particlesTshVal; + if (tsh.TryGetValue(GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey, out var speedTsh)) + { + var parsed = ParseTransitionSpeedMultiplier(speedTsh); + if (parsed.HasValue) + { + profile.TshGameWindowTransitionSpeedMultiplier = parsed.Value; + } } } @@ -607,60 +679,64 @@ 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; + if (NormalizeTransitionSpeedMultiplier(profile.TshGameWindowTransitionSpeedMultiplier) is { } speedMult) + { + settings.GameWindowTransitionSpeedMultiplier = speedMult; + } } private static void ApplyVideoResolutionAndQualityToOptions(GameProfile profile, IniOptions options, ILogger? logger) @@ -897,6 +973,17 @@ private static void ApplyAudioToOptions(GameProfile profile, IniOptions options, private static void ApplyTshToOptions(GameProfile profile, IniOptions options) { var tshDict = new Dictionary(); + ApplyTshUiSettingsToDict(profile, tshDict); + ApplyTshControlsSettingsToDict(profile, tshDict); + + if (tshDict.Count > 0) + { + options.AdditionalSections["TheSuperHackers"] = tshDict; + } + } + + private static void ApplyTshUiSettingsToDict(GameProfile profile, Dictionary tshDict) + { if (profile.TshArchiveReplays.HasValue) tshDict["ArchiveReplays"] = BoolToString(profile.TshArchiveReplays.Value); if (profile.TshShowMoneyPerMinute.HasValue) tshDict["ShowMoneyPerMinute"] = BoolToString(profile.TshShowMoneyPerMinute.Value); if (profile.TshPlayerObserverEnabled.HasValue) tshDict["PlayerObserverEnabled"] = BoolToString(profile.TshPlayerObserverEnabled.Value); @@ -904,6 +991,10 @@ private static void ApplyTshToOptions(GameProfile profile, IniOptions options) if (profile.TshNetworkLatencyFontSize.HasValue) tshDict["NetworkLatencyFontSize"] = profile.TshNetworkLatencyFontSize.Value.ToString(); if (profile.TshRenderFpsFontSize.HasValue) tshDict["RenderFpsFontSize"] = profile.TshRenderFpsFontSize.Value.ToString(); if (profile.TshResolutionFontAdjustment.HasValue) tshDict["ResolutionFontAdjustment"] = profile.TshResolutionFontAdjustment.Value.ToString(); + } + + private static void ApplyTshControlsSettingsToDict(GameProfile profile, Dictionary tshDict) + { if (profile.TshCursorCaptureEnabledInFullscreenGame.HasValue) tshDict["CursorCaptureEnabledInFullscreenGame"] = BoolToString(profile.TshCursorCaptureEnabledInFullscreenGame.Value); if (profile.TshCursorCaptureEnabledInFullscreenMenu.HasValue) tshDict["CursorCaptureEnabledInFullscreenMenu"] = BoolToString(profile.TshCursorCaptureEnabledInFullscreenMenu.Value); if (profile.TshCursorCaptureEnabledInWindowedGame.HasValue) tshDict["CursorCaptureEnabledInWindowedGame"] = BoolToString(profile.TshCursorCaptureEnabledInWindowedGame.Value); @@ -911,10 +1002,9 @@ private static void ApplyTshToOptions(GameProfile profile, IniOptions options) if (profile.TshScreenEdgeScrollEnabledInFullscreenApp.HasValue) tshDict["ScreenEdgeScrollEnabledInFullscreenApp"] = BoolToString(profile.TshScreenEdgeScrollEnabledInFullscreenApp.Value); if (profile.TshScreenEdgeScrollEnabledInWindowedApp.HasValue) tshDict["ScreenEdgeScrollEnabledInWindowedApp"] = BoolToString(profile.TshScreenEdgeScrollEnabledInWindowedApp.Value); if (profile.TshMoneyTransactionVolume.HasValue) tshDict["MoneyTransactionVolume"] = profile.TshMoneyTransactionVolume.Value.ToString(); - - if (tshDict.Count > 0) + if (NormalizeTransitionSpeedMultiplier(profile.TshGameWindowTransitionSpeedMultiplier) is { } speedMultiplier) { - options.AdditionalSections["TheSuperHackers"] = tshDict; + tshDict[GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey] = speedMultiplier.ToString(CultureInfo.InvariantCulture); } } @@ -959,6 +1049,7 @@ private static void PatchTshSettings(GameProfile profile, CreateProfileRequest r profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp ?? profile.TshScreenEdgeScrollEnabledInFullscreenApp; profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp ?? profile.TshScreenEdgeScrollEnabledInWindowedApp; profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume ?? profile.TshMoneyTransactionVolume; + profile.TshGameWindowTransitionSpeedMultiplier = request.TshGameWindowTransitionSpeedMultiplier ?? profile.TshGameWindowTransitionSpeedMultiplier; } private static void PatchGeneralsOnlineSettings(GameProfile profile, CreateProfileRequest request) @@ -1041,6 +1132,7 @@ private static void UpdateTshFromRequest(GameProfile profile, UpdateProfileReque profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp ?? profile.TshScreenEdgeScrollEnabledInFullscreenApp; profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp ?? profile.TshScreenEdgeScrollEnabledInWindowedApp; profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume ?? profile.TshMoneyTransactionVolume; + profile.TshGameWindowTransitionSpeedMultiplier = request.TshGameWindowTransitionSpeedMultiplier ?? profile.TshGameWindowTransitionSpeedMultiplier; } private static void UpdateGeneralsOnlineFromRequest(GameProfile profile, UpdateProfileRequest request) diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index e249b1e1e..39dc9f38d 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -1,5 +1,12 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; +using System.Security; +using GenHub.Core.Constants; namespace GenHub.Core.Helpers; @@ -8,6 +15,13 @@ namespace GenHub.Core.Helpers; /// public static class PathHelper { + private static readonly HashSet ReservedDeviceNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + /// /// Gets the string comparison to use when comparing filesystem paths. Windows paths /// are compared case-insensitively; other platforms use conservative case-sensitive semantics. @@ -26,6 +40,44 @@ public static class PathHelper ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + /// + /// Determines whether two paths point at the same filesystem location, normalizing both and + /// comparing them with the platform-appropriate case sensitivity. + /// + /// The first path. + /// The second path. + /// when both paths resolve to the same location. + public static bool AreSamePath(string first, string second) + { + try + { + return string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(first)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)), + PathComparison); + } + catch (IOException) + { + return string.Equals(first, second, PathComparison); + } + catch (UnauthorizedAccessException) + { + return string.Equals(first, second, PathComparison); + } + catch (SecurityException) + { + return string.Equals(first, second, PathComparison); + } + catch (NotSupportedException) + { + return string.Equals(first, second, PathComparison); + } + catch (ArgumentException) + { + return string.Equals(first, second, PathComparison); + } + } + /// /// Gets the parent directory of a path, with fallback to the path itself if at drive root. /// @@ -39,4 +91,299 @@ public static string GetSafeParentDirectory(string path) var parent = Path.GetDirectoryName(path); return string.IsNullOrEmpty(parent) ? path : parent; } + + /// + /// Determines whether a candidate path resolves to a location inside a base directory. + /// Both paths are fully normalized first, so .. segments, redundant separators and + /// rooted candidates cannot escape the base directory. Because normalization is textual and a + /// symbolic link or junction redirects a path that reads as contained, both sides are also + /// compared after their links are followed; a path that cannot be resolved — because it does + /// not exist yet, or the filesystem refuses the query — is compared as written. + /// + /// The directory that must contain the candidate path. + /// The path to test for containment. + /// when the candidate resolves inside the base directory; otherwise, . + public static bool IsPathWithinDirectory(string baseDirectory, string candidatePath) + { + if (string.IsNullOrWhiteSpace(baseDirectory) || string.IsNullOrWhiteSpace(candidatePath)) + { + return false; + } + + try + { + var normalizedRoot = Path.GetFullPath(baseDirectory); + var normalizedTarget = Path.GetFullPath(candidatePath); + + return IsContained(normalizedRoot, normalizedTarget) && + IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (SecurityException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + } + + /// + /// Normalizes a relative path by standardizing directory separators and removing leading separators. + /// + /// The relative path to normalize. + /// The normalized relative path. + public static string NormalizeRelativePath(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return string.Empty; + } + + return relativePath + .Replace('\\', '/') + .TrimStart('/') + .Replace('/', Path.DirectorySeparatorChar); + } + + /// + /// Generates a unique destination path in the directory, appending (1), (2), etc. if the file exists. + /// + /// The candidate destination path. + /// A unique non-colliding file path. + public static string GetUniqueNumberedPath(string destinationPath) + { + if (!File.Exists(destinationPath)) + { + return destinationPath; + } + + var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty; + var nameOnly = Path.GetFileNameWithoutExtension(destinationPath); + var ext = Path.GetExtension(destinationPath); + int count = 1; + var current = destinationPath; + while (File.Exists(current)) + { + current = Path.Combine(dir, $"{nameOnly} ({count}){ext}"); + count++; + } + + return current; + } + + /// + /// Sanitizes a file name by removing invalid filesystem characters, trimming trailing dots and whitespace, and prefixing Windows reserved device names. + /// + /// The file name to sanitize. + /// The sanitized file name. + public static string SanitizeFileName(string fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + return string.Empty; + } + + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = string.Concat(fileName.Where(c => !invalidChars.Contains(c))).Trim().TrimEnd('.'); + if (string.IsNullOrEmpty(sanitized)) + { + return string.Empty; + } + + var nameWithoutExtension = Path.GetFileNameWithoutExtension(sanitized); + if (ReservedDeviceNames.Contains(nameWithoutExtension)) + { + sanitized = $"_{sanitized}"; + } + + return sanitized; + } + + /// + /// Opens the native file explorer and selects the specified file or folder, or ignores if not supported. + /// + /// The absolute path to the file to reveal. + public static void RevealInExplorer(string filePath) + { + try + { + var startInfo = CreateRevealStartInfo(filePath); + if (startInfo != null) + { + Process.Start(startInfo); + } + } + catch (Win32Exception) + { + /* Ignore explorer errors */ + } + catch (IOException) + { + /* Ignore explorer errors */ + } + catch (UnauthorizedAccessException) + { + /* Ignore explorer errors */ + } + catch (InvalidOperationException) + { + /* Ignore explorer errors */ + } + } + + [SuppressMessage("Security", "S4036:Make sure the executable exists, and provide an absolute path or configure PATH securely", Justification = "Resolves standard desktop launch utilities (open, xdg-open) from PATH across heterogeneous Unix distributions.")] + private static ProcessStartInfo? CreateRevealStartInfo(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return null; + } + + if (OperatingSystem.IsWindows()) + { + var info = new ProcessStartInfo + { + FileName = PlatformConstants.WindowsExplorerPath, + Arguments = string.Format(PlatformConstants.WindowsExplorerSelectArgument, filePath), + UseShellExecute = false, + }; + return info; + } + + if (OperatingSystem.IsMacOS()) + { + var info = new ProcessStartInfo + { + FileName = PlatformConstants.MacOSOpenExecutable, + UseShellExecute = false, + }; + info.ArgumentList.Add("-R"); + info.ArgumentList.Add(filePath); + return info; + } + + if (OperatingSystem.IsLinux()) + { + string? targetDir; + if (File.Exists(filePath)) + { + targetDir = Path.GetDirectoryName(filePath); + } + else if (Directory.Exists(filePath)) + { + targetDir = filePath; + } + else + { + targetDir = null; + } + + if (string.IsNullOrEmpty(targetDir)) + { + return null; + } + + var info = new ProcessStartInfo + { + FileName = PlatformConstants.LinuxXdgOpenExecutable, + UseShellExecute = false, + }; + info.ArgumentList.Add(targetDir); + return info; + } + + return null; + } + + private static bool IsContained(string normalizedRoot, string normalizedTarget) + { + var relative = Path.GetRelativePath(normalizedRoot, normalizedTarget); + + return !relative.Equals("..", StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) && + !Path.IsPathRooted(relative); + } + + private static string FollowLinks(string fullPath, int maxDepth = 32) + { + if (maxDepth <= 0) + { + return fullPath; + } + + try + { + var normalized = Path.GetFullPath(fullPath); + var root = Path.GetPathRoot(normalized); + if (string.IsNullOrEmpty(root)) + { + return normalized; + } + + var relativeFromRoot = Path.GetRelativePath(root, normalized); + if (relativeFromRoot == "." || relativeFromRoot.Length == 0) + { + return root; + } + + var segments = relativeFromRoot.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + var current = root; + foreach (var segment in segments) + { + current = Path.Combine(current, segment); + + if (Directory.Exists(current) || File.Exists(current)) + { + FileSystemInfo info = Directory.Exists(current) + ? new DirectoryInfo(current) + : new FileInfo(current); + + var target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + { + current = FollowLinks(target.FullName, maxDepth - 1); + } + } + } + + return Path.GetFullPath(current); + } + catch (IOException) + { + return fullPath; + } + catch (UnauthorizedAccessException) + { + return fullPath; + } + catch (SecurityException) + { + return fullPath; + } + catch (NotSupportedException) + { + return fullPath; + } + catch (ArgumentException) + { + return fullPath; + } + } } diff --git a/GenHub/GenHub.Core/Helpers/ToolUploadHelper.cs b/GenHub/GenHub.Core/Helpers/ToolUploadHelper.cs new file mode 100644 index 000000000..b4453602d --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/ToolUploadHelper.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Tools.MapManager; +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Core.Helpers; + +/// +/// Shared helper methods for upload workflows in tools (MapManager, ReplayManager). +/// +public static class ToolUploadHelper +{ + /// + /// Formats the upload stage message based on progress percentage and archive mode. + /// The percentage itself is rendered separately next to the progress bar. + /// + /// The entity name (e.g., "maps" or "replays"). + /// Whether the upload is a single zip file. + /// The completion percentage. + /// A formatted status string. + public static string FormatUploadStageMessage(string entityName, bool isZip, int percent) + { + if (!isZip && percent < ToolConstants.UploadStageCompressionThresholdPercent) + { + return $"Compressing {entityName}..."; + } + + if (percent < ToolConstants.UploadStageCloudThresholdPercent) + { + return "Uploading to cloud..."; + } + + if (percent < ToolConstants.UploadStageCompletePercent) + { + return "Finalizing cloud upload..."; + } + + return "Upload complete!"; + } + + /// + /// Formats the error message when upload rate limit is exceeded. + /// + /// Total bytes of file being uploaded. + /// Used bytes in period. + /// Total allowed limit bytes. + /// A human-readable error description. + public static string FormatUploadLimitExceededMessage(long totalSizeBytes, long usedBytes, long limitBytes) + { + var bytesPerMb = (double)ConversionConstants.BytesPerMegabyte; + var remainingMb = Math.Max(0, (limitBytes - usedBytes) / bytesPerMb); + var fileMb = totalSizeBytes / bytesPerMb; + var limitMb = limitBytes / bytesPerMb; + + return $"Upload limit exceeded. You have {remainingMb:F1} MB remaining of your {limitMb:F0} MB limit. This file requires {fileMb:F1} MB."; + } + + /// + /// Computes the lowercase SHA256 hex string of a file if it exists. + /// + /// The absolute path to the file. + /// Cancellation token. + /// The lowercase hex string if successful; otherwise . + public static async Task ComputeFileSha256Async(string filePath, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return null; + } + + try + { + await using var stream = File.OpenRead(filePath); + var hashBytes = await System.Security.Cryptography.SHA256.HashDataAsync(stream, ct); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Verifies if a share URL is accessible and returns HTTP success. + /// + /// The URL to test. + /// Cancellation token. + /// if the URL returns a success status code; otherwise . + public static async Task VerifyShareUrlAliveAsync(string url, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + try + { + using var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Head, url); + using var response = await httpClient.SendAsync(request, ct); + return response.IsSuccessStatusCode; + } + catch (System.Net.Http.HttpRequestException) + { + return false; + } + catch (IOException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + /// + /// Calculates the total size in bytes of a collection of map files including their directory assets. + /// + /// The map files. + /// Total size in bytes. + public static long CalculateMapsSize(IEnumerable maps) + { + long total = 0; + foreach (var map in maps) + { + total += GetMapFileSize(map); + } + + return total; + } + + /// + /// Calculates the total size in bytes of a collection of replay files. + /// + /// The replay files. + /// Total size in bytes. + public static long CalculateReplaysSize(IEnumerable replays) + { + long total = 0; + foreach (var replay in replays.Where(r => File.Exists(r.FullPath))) + { + try + { + total += new FileInfo(replay.FullPath).Length; + } + catch (IOException) + { + // Ignore missing or inaccessible files in size estimate + } + catch (UnauthorizedAccessException) + { + // Ignore missing or inaccessible files in size estimate + } + } + + return total; + } + + private static long GetMapFileSize(MapFile map) + { + long mapSize = 0; + try + { + if (File.Exists(map.FullPath)) + { + mapSize += new FileInfo(map.FullPath).Length; + } + + if (map.IsDirectory && map.AssetFiles != null) + { + foreach (var asset in map.AssetFiles.Where(File.Exists)) + { + mapSize += new FileInfo(asset).Length; + } + } + } + catch (IOException) + { + // Ignore missing or inaccessible files in size estimate + } + catch (UnauthorizedAccessException) + { + // Ignore missing or inaccessible files in size estimate + } + + return mapSize; + } +} diff --git a/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs b/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs index 38b0ec736..f508f5197 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using Microsoft.Extensions.Logging; @@ -12,6 +13,10 @@ public interface IAppConfiguration /// The root application data path. string GetConfiguredDataPath(); + /// Gets the root application data path used by releases up to v0.0.3, which stored data under the roaming profile. + /// The legacy root application data path. + string GetLegacyConfiguredDataPath(); + /// Gets the default workspace path for GenHub. /// The default workspace path. string GetDefaultWorkspacePath(); @@ -81,4 +86,10 @@ public interface IAppConfiguration /// Gets the maximum allowed download buffer size in bytes. /// The maximum buffer size in bytes. int GetMaxDownloadBufferSizeBytes(); + + /// + /// Gets the CSV catalog configuration. + /// + /// The CSV catalog configuration. + CsvCatalogConfiguration GetCsvCatalogConfiguration(); } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs index d97f2f8cf..b55bcd4c6 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Storage; @@ -53,45 +55,33 @@ public interface IConfigurationProviderService int GetDownloadBufferSize(); /// - /// Gets the effective default workspace strategy. + /// Gets the effective workspace strategy. /// - /// The default workspace strategy. + /// The effective workspace strategy. WorkspaceStrategy GetDefaultWorkspaceStrategy(); - /// - /// Gets whether to automatically check for updates on startup. - /// - /// True if auto-check is enabled; otherwise, false. - bool GetAutoCheckForUpdatesOnStartup(); - - /// - /// Gets whether detailed logging is enabled. - /// - /// True if detailed logging is enabled; otherwise, false. - bool GetEnableDetailedLogging(); - /// /// Gets the effective UI theme. /// - /// The theme string. + /// The effective UI theme. string GetTheme(); /// /// Gets the effective window width. /// - /// The window width in pixels. + /// The effective window width. double GetWindowWidth(); /// /// Gets the effective window height. /// - /// The window height in pixels. + /// The effective window height. double GetWindowHeight(); /// - /// Gets whether the window should be maximized. + /// Gets whether the window is maximized. /// - /// True if window should be maximized; otherwise, false. + /// True if the window is maximized, otherwise false. bool GetIsWindowMaximized(); /// @@ -101,39 +91,61 @@ public interface IConfigurationProviderService NavigationTab GetLastSelectedTab(); /// - /// Gets the effective settings with all defaults applied. - /// This provides a complete UserSettings object with all values resolved. + /// Gets whether automatic update checks on startup are enabled. /// - /// A UserSettings object with all effective values. - UserSettings GetEffectiveSettings(); + /// True if automatic update checks on startup are enabled, otherwise false. + bool GetAutoCheckForUpdatesOnStartup(); /// - /// Gets the effective content directories for local discovery. + /// Gets whether periodic update checks are enabled. /// - /// List of content directories. + /// True if periodic update checks are enabled, otherwise false. + bool GetAutoCheckForUpdatesPeriodically(); + + /// + /// Gets the interval for periodic update checks in minutes. + /// + /// The interval in minutes. + int GetPeriodicUpdateCheckIntervalMinutes(); + + /// + /// Gets whether detailed logging is enabled. + /// + /// True if detailed logging is enabled, otherwise false. + bool GetEnableDetailedLogging(); + + /// + /// Gets the list of content directories. + /// + /// The list of content directories. List GetContentDirectories(); /// - /// Gets the effective GitHub repositories for discovery. + /// Gets the list of GitHub discovery repositories. /// - /// List of GitHub repositories in "owner/repo" format. + /// The list of repository names. List GetGitHubDiscoveryRepositories(); /// - /// Gets the application data directory path where metadata (workspaces, manifests) is stored. - /// Note: Actual game content files are stored in CAS, not here. + /// Gets all effective user settings in a single object. /// - /// The application data path as a string. + /// The effective user settings. + UserSettings GetEffectiveSettings(); + + /// + /// Gets the effective application data path. + /// + /// The effective application data path. string GetApplicationDataPath(); /// - /// Gets the root application data directory path. + /// Gets the root application data path across all components. /// /// The root application data path. string GetRootAppDataPath(); /// - /// Gets the directory path where game profiles are stored. + /// Gets the directory path where profiles are stored. /// /// The profiles directory path. string GetProfilesPath(); @@ -155,4 +167,10 @@ public interface IConfigurationProviderService /// /// The logs directory path. string GetLogsPath(); + + /// + /// Gets the CSV catalog configuration. + /// + /// The CSV catalog configuration. + CsvCatalogConfiguration GetCsvCatalogConfiguration(); } diff --git a/GenHub/GenHub.Core/Interfaces/Common/IThemeService.cs b/GenHub/GenHub.Core/Interfaces/Common/IThemeService.cs new file mode 100644 index 000000000..400c4ad2a --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Common/IThemeService.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using GenHub.Core.Models.Theming; + +namespace GenHub.Core.Interfaces.Common; + +/// +/// Service responsible for managing and applying application color themes at runtime. +/// +public interface IThemeService +{ + /// + /// Gets all available built-in color themes. + /// + IReadOnlyList AvailableThemes { get; } + + /// + /// Gets the currently active color theme. + /// + ColorTheme CurrentTheme { get; } + + /// + /// Applies the specified color theme by its unique identifier or display name. + /// + /// The ID or display name of the theme to apply. + void ApplyTheme(string themeId); + + /// + /// Applies the specified color theme. + /// + /// The theme to apply. + void ApplyTheme(ColorTheme theme); + + /// + /// Initializes and restores the theme saved in user settings. + /// + void InitializeTheme(); +} diff --git a/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs b/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs index 7af1df63a..44a645364 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; using GenHub.Core.Models.Common; using GenHub.Core.Models.Tools; @@ -10,22 +11,24 @@ namespace GenHub.Core.Interfaces.Common; public interface IUploadHistoryService { /// - /// Gets the maximum upload bytes per period. + /// Gets the default maximum upload bytes per period. /// long MaxUploadBytesPerPeriod { get; } /// - /// Checks if an upload of the specified size is allowed. + /// Checks if an upload of the specified size is allowed, optionally within a category quota. /// /// The file size in bytes. + /// Optional category to check quota against. /// A task representing the asynchronous operation, with a boolean indicating if the upload is allowed. - Task CanUploadAsync(long fileSizeBytes); + Task CanUploadAsync(long fileSizeBytes, string? category = null); /// - /// Gets the usage info. + /// Gets the usage info, optionally filtered by category. /// + /// Optional category to evaluate usage for. /// A task representing the asynchronous operation, with the usage info. - Task GetUsageInfoAsync(); + Task GetUsageInfoAsync(string? category = null); /// /// Records an upload. @@ -33,24 +36,39 @@ public interface IUploadHistoryService /// The file size in bytes. /// The URL. /// The file name. - void RecordUpload(long fileSizeBytes, string url, string fileName); + /// Optional file key in cloud storage. + /// Optional cryptographic deletion token. + /// Optional SHA-256 hash of the uploaded file for deduplication. + /// Optional tool or content category (e.g. "replays", "maps"). + void RecordUpload(long fileSizeBytes, string url, string fileName, string? fileKey = null, string? deleteToken = null, string? fileHash = null, string? category = null); /// - /// Gets the upload history. + /// Finds an existing active upload record matching the specified file hash. /// + /// The SHA-256 hex string of the file. + /// A task representing the asynchronous operation, returning the matching if found. + Task FindExistingUploadAsync(string fileHash); + + /// + /// Gets the upload history, optionally filtered by category. + /// + /// Optional category filter (e.g. "replays", "maps"). If null, returns all history. /// A task representing the asynchronous operation, with the history items. - Task> GetUploadHistoryAsync(); + Task> GetUploadHistoryAsync(string? category = null); /// - /// Removes an item from local history without deleting the hosted file. + /// Removes an item from upload history and deletes the hosted file from cloud storage if a delete token is present. /// /// The URL. - /// A task representing the asynchronous operation. - Task RemoveHistoryItemAsync(string url); + /// Whether to delete the file from cloud storage. Defaults to true. + /// A task representing the asynchronous operation, returning true if removal succeeded. + Task RemoveHistoryItemAsync(string url, bool deleteFromCloud = true); /// - /// Clears local history without deleting hosted files. + /// Clears upload history and deletes all hosted files from cloud storage if delete tokens are present. /// - /// A task representing the asynchronous operation. - Task ClearHistoryAsync(); + /// Whether to delete all files from cloud storage. Defaults to true. + /// Optional category filter (e.g. "replays", "maps"). If null, clears all history. + /// A task representing the asynchronous operation returning the count of deleted and failed cloud deletions. + Task<(int Deleted, int Failed)> ClearHistoryAsync(bool deleteFromCloud = true, string? category = null); } diff --git a/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs new file mode 100644 index 000000000..de2a70b5f --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs @@ -0,0 +1,52 @@ +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Service for safely extracting archives and normalizing payload directory structures for game workspaces. +/// +public interface IArchivePayloadProcessor +{ + /// + /// Extracts all archives located within the directory safely, recursively removing archive files after extraction. + /// + /// The directory containing extracted or downloaded content. + /// Optional content type to constrain executable archive extraction. + /// Cancellation token. + /// A task representing the asynchronous extraction operation. + Task ExtractArchivesSafelyAsync( + string extractedDirectory, + ContentType? contentType = null, + CancellationToken cancellationToken = default); + + /// + /// Normalizes the directory structure of an extracted payload, removing extraneous wrapper directories + /// and reconciling the content root with the workspace/target directory. + /// + /// The directory containing extracted files. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Cancellation token. + /// A task representing the asynchronous normalization operation. + Task NormalizeDirectoryStructureAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default); + + /// + /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation. + /// + /// The directory containing extracted or downloaded content. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Cancellation token. + /// A task representing the asynchronous processing operation. + Task ProcessPayloadAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs new file mode 100644 index 000000000..1b416b594 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Manifest; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Service for detecting, isolating, converting, and packaging Control Bar content into SAGE-compatible .big archives. +/// +public interface IControlBarPackageProcessor +{ + /// + /// Checks whether the extracted directory or manifest represents a Control Bar mod or UI addon that needs repacking. + /// + /// The directory containing extracted files. + /// The content manifest. + /// True if the content is a Control Bar that requires processing. + bool IsControlBarContent(string extractedDirectory, ContentManifest manifest); + + /// + /// Processes extracted Control Bar content: isolates the requested resolution variant, converts AVIF/WebP textures to TGA, + /// repacks Art/Data folders into .big archives, ensures metadata BIG is present, and cleans up raw sources. + /// + /// The directory containing extracted files. + /// The content manifest. + /// Optional explicit variant identifier (e.g. "1080p"). + /// Cancellation token. + /// A list of generated or included .big file names. + Task> ProcessAndRepackControlBarAsync( + string extractedDirectory, + ContentManifest manifest, + string? requestedVariant = null, + CancellationToken cancellationToken = default); + + /// + /// Finds the variant BIG root directory within extracted content. + /// + /// The extracted root directory. + /// The variant identifier (e.g. "1080p"). + /// The path to the variant root directory, or null if not found. + string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId); + + /// + /// Gets the normalized suffix for a variant identifier (e.g. "1080p" -> "1080"). + /// + /// The variant identifier. + /// The normalized variant suffix. + string GetControlBarVariantSuffix(string variantId); + + /// + /// Checks if a file is an allowed Control Bar .big archive for the given variant suffix. + /// + /// The file name. + /// The variant suffix. + /// True if the file is allowed. + bool IsAllowedControlBarBig(string fileName, string variantSuffix); +} 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/Services/IUploadThingService.cs b/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs index a0adb1174..38d14ee84 100644 --- a/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs +++ b/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs @@ -1,31 +1,34 @@ using System; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.UploadThing; namespace GenHub.Core.Interfaces.Services; /// -/// Service for uploading files to UploadThing cloud storage. +/// Service for uploading files to cloud storage via the GenHub upload gateway. /// public interface IUploadThingService { /// - /// Uploads a file to UploadThing and returns the public URL. + /// Uploads a file through the gateway and returns the upload result including public URL and deletion token. /// /// The absolute path to the file to upload. /// Optional progress reporter (0.0 to 1.0). /// Cancellation token. - /// The public URL if successful, otherwise null. - Task UploadFileAsync( + /// The operation result containing the upload result if successful. + Task> UploadFileAsync( string filePath, IProgress? progress = null, CancellationToken ct = default); /// - /// Deletes a file from UploadThing. + /// Deletes a file from cloud storage using its cryptographic deletion token. /// /// The key of the file to delete. + /// The cryptographic deletion token. /// Cancellation token. - /// True if the deletion was successful, otherwise false. - Task DeleteFileAsync(string fileKey, CancellationToken ct = default); + /// The operation result indicating whether deletion was successful. + Task> DeleteFileAsync(string fileKey, string deleteToken, CancellationToken ct = default); } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs index 53c23aeb4..536d17a17 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs @@ -1,8 +1,10 @@ -using GenHub.Core.Models.Tools.MapManager; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.MapManager; +using GenHub.Core.Models.Tools.UploadThing; namespace GenHub.Core.Interfaces.Tools.MapManager; @@ -12,13 +14,13 @@ namespace GenHub.Core.Interfaces.Tools.MapManager; public interface IMapExportService { /// - /// Uploads maps to UploadThing and returns the share URL. + /// Uploads maps to cloud storage and returns the upload result. /// /// The maps to upload. /// Progress reporter for upload updates. /// Cancellation token. - /// The share URL if successful, otherwise null. - Task UploadToUploadThingAsync( + /// The operation result containing the upload result if successful. + Task> UploadToUploadThingAsync( IEnumerable maps, IProgress? progress = null, CancellationToken ct = default); diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs index 1705d5aed..e33975605 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs @@ -1,8 +1,10 @@ -using GenHub.Core.Models.Tools.ReplayManager; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Core.Models.Tools.UploadThing; namespace GenHub.Core.Interfaces.Tools.ReplayManager; @@ -12,13 +14,13 @@ namespace GenHub.Core.Interfaces.Tools.ReplayManager; public interface IReplayExportService { /// - /// Uploads replays to UploadThing and returns the share URL. + /// Uploads replays to cloud storage and returns the upload result. /// /// The replays to upload. /// Progress reporter for upload updates. /// Cancellation token. - /// The share URL if successful, otherwise null. - Task UploadToUploadThingAsync( + /// The operation result containing the upload result if successful. + Task> UploadToUploadThingAsync( IEnumerable replays, IProgress? progress = null, CancellationToken ct = default); diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs index 917db1df0..9686781f8 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs @@ -1,6 +1,7 @@ -using GenHub.Core.Models.Tools.ReplayManager; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Models.Tools.ReplayManager; namespace GenHub.Core.Interfaces.Tools.ReplayManager; @@ -30,4 +31,12 @@ public interface IUrlParserService /// Cancellation token. /// The direct download URL if successful, otherwise null. Task GetDirectDownloadUrlAsync(string url, CancellationToken ct = default); + + /// + /// Extracts all direct download URLs from a source-specific URL (e.g., all player replays from a match page). + /// + /// The source URL. + /// Cancellation token. + /// A list of direct download URLs extracted from the source. + Task> GetDirectDownloadUrlsAsync(string url, CancellationToken ct = default); } diff --git a/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs b/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs index 6038b2efe..58b76818a 100644 --- a/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs +++ b/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs @@ -58,6 +58,22 @@ Task VerifyFileHashAsync( string expectedHash, CancellationToken cancellationToken = default); + /// + /// Compares a file against an expected hash, distinguishing a genuine mismatch from a failure to + /// compute the hash at all. Callers that act destructively on a mismatch must use this rather + /// than , which collapses both outcomes into false. + /// A file that does not exist yields : no hash was + /// computed, so its absence is not evidence that its content ever differed. + /// + /// The file path. + /// The expected hash value. + /// A cancellation token. + /// The verification outcome. + Task CheckFileHashAsync( + string filePath, + string expectedHash, + CancellationToken cancellationToken = default); + /// /// Applies a patch to a target file. The patch format is determined by the implementation. /// diff --git a/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs new file mode 100644 index 000000000..e199ff5b3 --- /dev/null +++ b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs @@ -0,0 +1,12 @@ +namespace GenHub.Core.Messages; + +/// +/// Message sent when update settings have changed. +/// +/// Whether to check for updates on startup. +/// Whether to check for updates periodically. +/// Interval in minutes between periodic update checks. +public record UpdateSettingsChangedMessage( + bool AutoCheckForUpdatesOnStartup, + bool AutoCheckForUpdatesPeriodically, + int PeriodicUpdateCheckIntervalMinutes); diff --git a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs index a8d2a9693..e9fc4bb56 100644 --- a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs +++ b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs @@ -50,6 +50,11 @@ public record PullRequestInfo /// public string DisplayVersion => LatestArtifact?.DisplayVersion ?? $"0.0.{Number}"; + /// + /// Gets the display title formatted with the PR number (e.g., "#123 - PR Title"). + /// + public string DisplayTitle => $"#{Number} - {Title}"; + /// /// Gets a value indicating whether this PR is still open. /// diff --git a/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs b/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs index d2b457c3b..67c0d19c2 100644 --- a/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs +++ b/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs @@ -9,8 +9,10 @@ namespace GenHub.Core.Models.Common; /// The size of the uploaded file in bytes. /// The public URL of the upload. /// The name of the uploaded file. +/// Optional tool or file category. public record UploadHistoryItem( DateTime Timestamp, long SizeBytes, string Url, - string FileName); + string FileName, + string? Category = null); diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index 4b77fe175..83cdb2a76 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; } @@ -72,6 +78,12 @@ public class UserSettings /// Gets or sets the list of GitHub repositories for discovery. public List? GitHubDiscoveryRepositories { get; set; } + /// Gets or sets the configured CSV catalog index.json path or URL. + public string? IndexFilePath { get; set; } + + /// Gets or sets the fallback CSV validation catalogs. + public List? CsvValidationCatalogs { get; set; } + /// Gets or sets the list of installed tool plugin assembly paths. public List? InstalledToolAssemblyPaths { get; set; } @@ -89,6 +101,11 @@ public class UserSettings /// public CasConfiguration CasConfiguration { get; set; } = new(); + /// + /// Gets or sets the collection of installation step keys that have been executed on this machine. + /// + public HashSet ExecutedInstallationSteps { get; set; } = []; + /// Marks a property as explicitly set by the user. /// The name of the property to mark as explicitly set. public void MarkAsExplicitlySet(string propertyName) @@ -96,6 +113,29 @@ public void MarkAsExplicitlySet(string propertyName) ExplicitlySetProperties.Add(propertyName); } + /// + /// Checks whether an installation step key has already been recorded as executed. + /// + /// The unique installation step key. + /// if already executed; otherwise, . + public bool IsInstallationStepExecuted(string stepKey) + { + return !string.IsNullOrWhiteSpace(stepKey) && ExecutedInstallationSteps != null && ExecutedInstallationSteps.Contains(stepKey); + } + + /// + /// Records that an installation step key has been executed. + /// + /// The unique installation step key. + public void RecordInstallationStepExecuted(string stepKey) + { + if (!string.IsNullOrWhiteSpace(stepKey)) + { + ExecutedInstallationSteps ??= []; + ExecutedInstallationSteps.Add(stepKey); + } + } + /// Checks if a property was explicitly set by the user. /// The name of the property to check. /// true if the property was explicitly set by the user; otherwise, false. @@ -151,6 +191,8 @@ public UserSettings Clone() MaxConcurrentDownloads = MaxConcurrentDownloads, AllowBackgroundDownloads = AllowBackgroundDownloads, AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup, + AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically, + PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes, LastUpdateCheckTimestamp = LastUpdateCheckTimestamp, EnableDetailedLogging = EnableDetailedLogging, DefaultWorkspaceStrategy = DefaultWorkspaceStrategy, @@ -168,11 +210,14 @@ public UserSettings Clone() DismissedUpdateVersion = DismissedUpdateVersion, ContentDirectories = ContentDirectories != null ? [.. ContentDirectories] : null, GitHubDiscoveryRepositories = GitHubDiscoveryRepositories != null ? [.. GitHubDiscoveryRepositories] : null, + IndexFilePath = IndexFilePath, + CsvValidationCatalogs = CsvValidationCatalogs != null ? [.. CsvValidationCatalogs.Select(c => c.Clone())] : null, InstalledToolAssemblyPaths = InstalledToolAssemblyPaths != null ? [.. InstalledToolAssemblyPaths] : null, PreferredStorageInstallationId = PreferredStorageInstallationId, UseInstallationAdjacentStorage = UseInstallationAdjacentStorage, ExplicitlySetProperties = [.. ExplicitlySetProperties], CasConfiguration = (CasConfiguration?)CasConfiguration?.Clone() ?? new CasConfiguration(), + ExecutedInstallationSteps = ExecutedInstallationSteps != null ? [.. ExecutedInstallationSteps] : [], SkippedUpdateVersions = SkippedUpdateVersions != null ? new Dictionary(SkippedUpdateVersions) : [], PreferredUpdateStrategy = PreferredUpdateStrategy, PublisherSubscriptions = PublisherSubscriptions != null diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs index 0b8ed40fb..ad0432121 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs @@ -11,6 +11,14 @@ namespace GenHub.Core.Models.CommunityOutpost; /// public static class GenPatcherContentRegistry { + private const string ResolutionVariantType = "resolution"; + private const string LanguageVariantType = "language"; + private const string Pattern720 = "*720*"; + private const string Pattern900 = "*900*"; + private const string Pattern1080 = "*1080*"; + private const string Pattern1440 = "*1440*"; + private const string Pattern2160 = "*2160*"; + /// /// Language code mappings for patch suffixes. /// @@ -33,11 +41,11 @@ public static class GenPatcherContentRegistry /// private static readonly List ResolutionVariants = [ - new ContentVariant { Id = "720p", Name = "720p", VariantType = "resolution", Value = "720", IncludePatterns = ["*720*"], ExcludePatterns = ["*900*", "*1080*", "*1440*", "*2160*"], IsDefault = false }, - new ContentVariant { Id = "900p", Name = "900p", VariantType = "resolution", Value = "900", IncludePatterns = ["*900*"], ExcludePatterns = ["*720*", "*1080*", "*1440*", "*2160*"], IsDefault = false }, - new ContentVariant { Id = "1080p", Name = "1080p (Recommended)", VariantType = "resolution", Value = "1080", IncludePatterns = ["*1080*"], ExcludePatterns = ["*720*", "*900*", "*1440*", "*2160*"], IsDefault = true }, - new ContentVariant { Id = "1440p", Name = "1440p (2K)", VariantType = "resolution", Value = "1440", IncludePatterns = ["*1440*"], ExcludePatterns = ["*720*", "*900*", "*1080*", "*2160*"], IsDefault = false }, - new ContentVariant { Id = "2160p", Name = "2160p (4K)", VariantType = "resolution", Value = "2160", IncludePatterns = ["*2160*"], ExcludePatterns = ["*720*", "*900*", "*1080*", "*1440*"], IsDefault = false }, + new ContentVariant { Id = "720p", Name = "720p", VariantType = ResolutionVariantType, Value = "720", IncludePatterns = [Pattern720], ExcludePatterns = [Pattern900, Pattern1080, Pattern1440, Pattern2160], IsDefault = false }, + new ContentVariant { Id = "900p", Name = "900p", VariantType = ResolutionVariantType, Value = "900", IncludePatterns = [Pattern900], ExcludePatterns = [Pattern720, Pattern1080, Pattern1440, Pattern2160], IsDefault = false }, + new ContentVariant { Id = "1080p", Name = "1080p (Recommended)", VariantType = ResolutionVariantType, Value = "1080", IncludePatterns = [Pattern1080], ExcludePatterns = [Pattern720, Pattern900, Pattern1440, Pattern2160], IsDefault = true }, + new ContentVariant { Id = "1440p", Name = "1440p (2K)", VariantType = ResolutionVariantType, Value = "1440", IncludePatterns = [Pattern1440], ExcludePatterns = [Pattern720, Pattern900, Pattern1080, Pattern2160], IsDefault = false }, + new ContentVariant { Id = "2160p", Name = "2160p (4K)", VariantType = ResolutionVariantType, Value = "2160", IncludePatterns = [Pattern2160], ExcludePatterns = [Pattern720, Pattern900, Pattern1080, Pattern1440], IsDefault = false }, ]; /// @@ -45,9 +53,10 @@ public static class GenPatcherContentRegistry /// private static readonly List HleiVariants = [ - new ContentVariant { Id = "zerohour-en", Name = "Leikeze's Hotkeys (EN)", VariantType = "language", Value = "en", TargetGame = GameType.ZeroHour, IncludePatterns = ["*ENZH.big"], IsDefault = true }, - new ContentVariant { Id = "zerohour-de", Name = "Leikeze's Hotkeys (DE)", VariantType = "language", Value = "de", TargetGame = GameType.ZeroHour, IncludePatterns = ["*DEZH.big"], IsDefault = false }, - new ContentVariant { Id = "generals-en", Name = "Leikeze's Hotkeys [Generals] (EN)", VariantType = "language", Value = "en", TargetGame = GameType.Generals, IncludePatterns = ["!HotkeysLeikezeEN.big"], IsDefault = false }, + new ContentVariant { Id = "zerohour-en", Name = "Leikeze's Hotkeys (EN)", VariantType = LanguageVariantType, Value = "en", TargetGame = GameType.ZeroHour, IncludePatterns = ["*ENZH.big"], OutputFilename = "!HotkeysLeikezeENZH.big", IsDefault = true }, + new ContentVariant { Id = "zerohour-de", Name = "Leikeze's Hotkeys (DE)", VariantType = LanguageVariantType, Value = "de", TargetGame = GameType.ZeroHour, IncludePatterns = ["*DEZH.big"], OutputFilename = "!HotkeysLeikezeDEZH.big", IsDefault = false }, + new ContentVariant { Id = "zerohour-ru", Name = "Leikeze's Hotkeys (RU)", VariantType = LanguageVariantType, Value = "ru", TargetGame = GameType.ZeroHour, IncludePatterns = ["*RUZH.big"], OutputFilename = "!HotkeysLeikezeRUZH.big", IsDefault = false }, + new ContentVariant { Id = "generals-en", Name = "Leikeze's Hotkeys [Generals] (EN)", VariantType = LanguageVariantType, Value = "en", TargetGame = GameType.Generals, IncludePatterns = ["!HotkeysLeikezeEN.big"], OutputFilename = "!HotkeysLeikezeEN.big", IsDefault = false }, ]; /// @@ -193,31 +202,29 @@ public static class GenPatcherContentRegistry InstallTarget = ContentInstallTarget.Workspace, }, - // Hotkeys + // World Builder Tools ["ewba"] = new GenPatcherContentMetadata { ContentCode = "ewba", - DisplayName = "Easy Win Hotkeys (Advanced)", - Description = "Advanced hotkey configuration for competitive play.", + DisplayName = "Enhanced World Builder (Advanced)", + Description = "Advanced World Builder editor with additional tools, molds, and scripts.", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Hotkeys, + Category = GenPatcherContentCategory.Tools, InstallTarget = ContentInstallTarget.Workspace, - RequiresRepacking = true, - OutputFilename = "!HotkeysEasyWinAdvancedZH.big", }, ["ewbi"] = new GenPatcherContentMetadata { ContentCode = "ewbi", - DisplayName = "Easy Win Hotkeys (International)", - Description = "Standard hotkey layout optimized for non-English keyboards.", + DisplayName = "Enhanced World Builder (International)", + Description = "World Builder editor version 2.2 for international installations.", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Hotkeys, + Category = GenPatcherContentCategory.Tools, InstallTarget = ContentInstallTarget.Workspace, - RequiresRepacking = true, - OutputFilename = "!HotkeysEasyWinInternationalZH.big", }, + + // Hotkeys ["hlde"] = new GenPatcherContentMetadata { ContentCode = "hlde", @@ -254,7 +261,7 @@ public static class GenPatcherContentRegistry Category = GenPatcherContentCategory.Hotkeys, InstallTarget = ContentInstallTarget.Workspace, RequiresRepacking = true, - OutputFilename = "!HotkeysLeikezeZH.big", + OutputFilename = "!HotkeysLeikeze{variant}.big", SupportsVariants = true, Variants = HleiVariants, }, diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs index 0c85ee03d..0824f25b1 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs @@ -26,7 +26,7 @@ public static class GenPatcherDependencyBuilder private static readonly List ControlBarCodes = ["cbpr", "cbpx"]; private static readonly List ZeroHourCameraCodes = ["crzh", "dczh"]; private static readonly List GeneralsCameraCodes = ["crgn"]; - private static readonly List HotkeyCodes = ["ewba", "ewbi", "hlde", "hleg", "hlei"]; + private static readonly List HotkeyCodes = ["hlde", "hleg", "hlei"]; /// /// Gets the dependencies for a given content code and metadata. diff --git a/GenHub/GenHub.Core/Models/Content/Checksum.cs b/GenHub/GenHub.Core/Models/Content/Checksum.cs new file mode 100644 index 000000000..a13e17699 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/Checksum.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Content; + +/// +/// Represents checksum information for file integrity verification. +/// +public class Checksum +{ + /// + /// Gets or sets the MD5 hash of the file. + /// + [JsonPropertyName("md5")] + public string Md5 { get; set; } = string.Empty; + + /// + /// Gets or sets the SHA-256 hash of the file. + /// + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = string.Empty; + + /// + /// Creates a deep copy of the current instance. + /// + /// A new instance with identical values. + public Checksum Clone() => new() + { + Md5 = Md5, + Sha256 = Sha256, + }; +} diff --git a/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs b/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs index bcd7ca322..d3239277c 100644 --- a/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs +++ b/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using GenHub.Core.Constants; using GenHub.Core.Models.Enums; namespace GenHub.Core.Models.Content; @@ -153,28 +154,38 @@ public string? Language private static readonly Dictionary LanguageMap = new(StringComparer.OrdinalIgnoreCase) { - ["EN"] = "EN", - ["DE"] = "DE", - ["FR"] = "FR", - ["PL"] = "PL", - ["ES"] = "ES", - ["IT"] = "IT", - ["KO"] = "KO", - ["BR"] = "BR", - ["CN"] = "CN", - ["ZH"] = "CN", - ["ZH-CN"] = "CN", + [CsvConstants.AllLanguagesFilter] = CsvConstants.AllLanguagesFilter, + [CsvConstants.LanguageEn] = CsvConstants.LanguageEn, + [CsvConstants.LanguageDe] = CsvConstants.LanguageDe, + [CsvConstants.LanguageFr] = CsvConstants.LanguageFr, + [CsvConstants.LanguagePl] = CsvConstants.LanguagePl, + [CsvConstants.LanguageEs] = CsvConstants.LanguageEs, + [CsvConstants.LanguageIt] = CsvConstants.LanguageIt, + [CsvConstants.LanguageKo] = CsvConstants.LanguageKo, + [CsvConstants.LanguagePtBr] = CsvConstants.LanguagePtBr, + ["BR"] = CsvConstants.LanguagePtBr, + ["PT"] = CsvConstants.LanguagePtBr, + [CsvConstants.LanguageZhCn] = CsvConstants.LanguageZhCn, + ["CN"] = CsvConstants.LanguageZhCn, + ["ZH"] = CsvConstants.LanguageZhCn, + [CsvConstants.LanguageZhTw] = CsvConstants.LanguageZhTw, + ["TW"] = CsvConstants.LanguageZhTw, }; - private static string? NormalizeLanguage(string? language) + /// + /// Normalizes a language code to the canonical casing and alias mapping. + /// + /// The raw language string. + /// The normalized language string, defaulting to when null or empty. + public static string NormalizeLanguage(string? language) { - // set default to "ALL" if not specified - // supported languages Brazilian Chinese English French German Italian Korean Polish Spanish if (string.IsNullOrWhiteSpace(language)) - return "ALL"; + { + return CsvConstants.AllLanguagesFilter; + } - var key = language.Trim().ToUpperInvariant(); + var key = language.Trim(); - return LanguageMap.TryGetValue(key, out var normalized) ? normalized : "ALL"; + return LanguageMap.TryGetValue(key, out var normalized) ? normalized : key.ToUpperInvariant(); } } diff --git a/GenHub/GenHub.Core/Models/Content/CsvCatalogConfiguration.cs b/GenHub/GenHub.Core/Models/Content/CsvCatalogConfiguration.cs new file mode 100644 index 000000000..476c73279 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/CsvCatalogConfiguration.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace GenHub.Core.Models.Content; + +/// +/// Configuration for CSV catalog discovery. +/// Binds to the "GenHub" configuration section. +/// +public class CsvCatalogConfiguration +{ + /// + /// Gets or sets the configured local path or remote URL for the catalog index.json file. + /// + public string IndexFilePath { get; set; } = string.Empty; + + /// + /// Gets or sets the fallback validation catalogs defined in configuration. + /// + public List CsvValidationCatalogs { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryEntry.cs b/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryEntry.cs new file mode 100644 index 000000000..a02b1f773 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryEntry.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Content; + +/// +/// Represents a single CSV catalog entry in the registry. +/// +public class CsvCatalogRegistryEntry +{ + /// + /// Gets or sets the unique identifier for this registry entry. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the URL to the CSV file. + /// + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + /// + /// Gets or sets the game type (e.g., "Generals", "ZeroHour"). + /// + [JsonPropertyName("gameType")] + public string GameType { get; set; } = string.Empty; + + /// + /// Gets or sets the game version (e.g., "1.08", "1.04"). + /// + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the list of supported languages in this CSV. + /// + [JsonPropertyName("languages")] + public List SupportedLanguages { get; set; } = []; + + /// + /// Gets or sets the optional file count in the CSV. + /// + [JsonPropertyName("fileCount")] + public int? FileCount { get; set; } + + /// + /// Gets or sets the total size of the CSV file in bytes. + /// + [JsonPropertyName("totalSizeBytes")] + public long TotalSizeBytes { get; set; } + + /// + /// Gets or sets the checksum information for integrity verification. + /// + [JsonPropertyName("checksum")] + public Checksum? Checksum { get; set; } + + /// + /// Gets or sets when this registry entry was generated. + /// + [JsonPropertyName("generatedAt")] + public DateTime? GeneratedAt { get; set; } + + /// + /// Gets or sets the version of the generator that created this entry. + /// + [JsonPropertyName("generatorVersion")] + public string GeneratorVersion { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether this registry entry is active. + /// + [JsonPropertyName("isActive")] + public bool IsActive { get; set; } = true; + + /// + /// Gets or sets an alias for to support configuration binding. + /// + [JsonIgnore] + public List Languages + { + get => SupportedLanguages; + set => SupportedLanguages = value; + } + + /// + /// Creates a deep copy of the current instance. + /// + /// A new instance with identical values. + public CsvCatalogRegistryEntry Clone() => new() + { + Id = Id, + Url = Url, + GameType = GameType, + Version = Version, + SupportedLanguages = SupportedLanguages != null ? [.. SupportedLanguages] : [], + FileCount = FileCount, + TotalSizeBytes = TotalSizeBytes, + Checksum = Checksum?.Clone(), + GeneratedAt = GeneratedAt, + GeneratorVersion = GeneratorVersion, + IsActive = IsActive, + }; +} diff --git a/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryIndex.cs b/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryIndex.cs new file mode 100644 index 000000000..b8645614b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryIndex.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Content; + +/// +/// Index of available CSV catalog registries. +/// Represents the structure of the docs/GameInstallationFilesRegistry/index.json file. +/// +public class CsvCatalogRegistryIndex +{ + /// + /// Gets or sets the schema version. + /// + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets when the index was last updated. + /// + [JsonPropertyName("lastUpdated")] + public DateTime? LastUpdatedAt { get; set; } + + /// + /// Gets or sets the description of this registry index. + /// + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the list of available catalog entries. + /// + [JsonPropertyName("registries")] + public List Entries { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs b/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs new file mode 100644 index 000000000..1ce5e9797 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs @@ -0,0 +1,23 @@ +namespace GenHub.Core.Models.Enums; + +/// +/// Outcome of comparing a file's content against an expected hash. +/// +public enum FileHashVerification +{ + /// + /// The hash could not be computed, so nothing is known about the file's content. + /// Callers must not treat this as evidence that the file changed. + /// + Failed = 0, + + /// + /// The hash was computed and matches the expected value. + /// + Match = 1, + + /// + /// The hash was computed and differs from the expected value. + /// + Mismatch = 2, +} diff --git a/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs new file mode 100644 index 000000000..1389e130b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Enums; + +/// +/// Defines the supported kind of installation operation in manifest-declared installation steps. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum InstallationStepKind +{ + /// + /// Installation step kind is unknown or undefined (default). + /// + Unknown = 0, + + /// + /// Runs a verified installer executable that exists within the manifest and workspace. + /// + RunVerifiedInstaller = 1, + + /// + /// Removes a file within the workspace. + /// + RemoveFile = 2, + + /// + /// Renames or moves a file within the workspace. + /// + RenameFile = 3, +} diff --git a/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs b/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs index 5bab7e51b..14d6f830e 100644 --- a/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs +++ b/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs @@ -6,27 +6,40 @@ namespace GenHub.Core.Models.Enums; /// /// Workspace preparation strategy preference. /// +/// +/// +/// The numeric values are part of the on-disk format. Releases up to v0.0.3 serialized workspace +/// metadata without an enum converter, so workspaces.json holds raw ordinals in this order; +/// they must not be reordered. Profile files are unaffected: v0.0.3 wrote the member name. +/// +/// +/// Builds of the default branch made after v0.0.3 and before this ordering was restored wrote +/// ordinals under a reordered enum, so numbers they persisted are now read as a different member +/// (0 meant HardLink there and means SymlinkOnly here). No release is affected, but such an +/// install should have its workspaces.json and profile strategies checked after upgrading. +/// +/// [JsonConverter(typeof(JsonWorkspaceStrategyConverter))] public enum WorkspaceStrategy { - /// - /// Hard link strategy - creates hard links where possible, copies otherwise. Space-efficient, requires same volume. - /// Default strategy for new profiles. - /// - HardLink = 0, - /// /// Symlink only strategy - creates symbolic links to all files. Minimal disk usage, requires admin rights. /// - SymlinkOnly = 1, + SymlinkOnly = 0, /// /// Full copy strategy - copies all files to workspace. Maximum compatibility and isolation, highest disk usage. /// - FullCopy = 2, + FullCopy = 1, /// /// Hybrid copy/symlink strategy - copies essential files, symlinks others. Balanced disk usage and compatibility. /// - HybridCopySymlink = 3, + HybridCopySymlink = 2, + + /// + /// Hard link strategy - creates hard links where possible, copies otherwise. Space-efficient, requires same volume. + /// Default strategy for new profiles. + /// + HardLink = 3, } diff --git a/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs b/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs index d697bc3d1..e38beed31 100644 --- a/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs +++ b/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; using GenHub.Core.Constants; using GenHub.Core.Extensions.GameInstallations; using GenHub.Core.Interfaces.GameInstallations; @@ -157,93 +162,21 @@ public void Fetch() bool foundGenerals = false; bool foundZeroHour = false; - // 1. Check strict subdirectories first (standard structure) - var generalsPath = Path.Combine(InstallationPath, "Command and Conquer Generals"); - if (Directory.Exists(generalsPath)) + // Preserve explicitly configured and valid paths (e.g. from platform detectors or manifests) + if (!string.IsNullOrEmpty(GeneralsPath) && Directory.Exists(GeneralsPath) && HasValidExecutable(GeneralsPath)) { - var generalsExe = Path.Combine(generalsPath, GameClientConstants.GeneralsExecutable); - if (generalsExe.FileExistsCaseInsensitive()) - { - HasGenerals = true; - GeneralsPath = generalsPath; - foundGenerals = true; - _logger?.LogDebug("Found Generals installation at {GeneralsPath}", GeneralsPath); - } + HasGenerals = true; + foundGenerals = true; } - var zeroHourPath = Path.Combine(InstallationPath, GameClientConstants.ZeroHourDirectoryName); - if (Directory.Exists(zeroHourPath)) + if (!string.IsNullOrEmpty(ZeroHourPath) && Directory.Exists(ZeroHourPath) && HasValidExecutable(ZeroHourPath)) { - var zeroHourExe = Path.Combine(zeroHourPath, GameClientConstants.ZeroHourExecutable); - if (zeroHourExe.FileExistsCaseInsensitive()) - { - HasZeroHour = true; - ZeroHourPath = zeroHourPath; - foundZeroHour = true; - _logger?.LogDebug("Found Zero Hour installation at {ZeroHourPath}", ZeroHourPath); - } + HasZeroHour = true; + foundZeroHour = true; } - // 2. If not found in subdirectories, check the root path (common for manual installs/repacks) - if (!foundGenerals) - { - var rootGeneralsExe = Path.Combine(InstallationPath, GameClientConstants.GeneralsExecutable); - - // Note: Zero Hour also has a generals.exe, so we need to be careful. - // If checking for valid installation, presence of generals.exe usually implies Generals capability. - if (rootGeneralsExe.FileExistsCaseInsensitive()) - { - HasGenerals = true; - GeneralsPath = InstallationPath; - foundGenerals = true; - _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); - } - } - - if (!foundZeroHour && string.IsNullOrEmpty(ZeroHourPath)) - { - // Zero Hour usually has generals.exe AND specific files like "generals.zh.exe" (sometimes) or just "generals.exe" with different hash/version. - // Detection primarily relies on folder name or presence of expansion files. - // Checking for generals.exe in root can map to both if the user selected a merged directory. - var rootGeneralsExe = Path.Combine(InstallationPath, GameClientConstants.GeneralsExecutable); - - if (rootGeneralsExe.FileExistsCaseInsensitive()) - { - // Check if Generals is already set to this path to avoid duplicate detection - // This prevents setting both GeneralsPath and ZeroHourPath to the same directory - // when platform-specific detectors (Steam/EA/etc) have already identified Generals here - bool isGeneralsAlreadySetToRoot = - !string.IsNullOrEmpty(GeneralsPath) && - Path.GetFullPath(GeneralsPath).Equals( - Path.GetFullPath(InstallationPath), - StringComparison.OrdinalIgnoreCase); - - if (!isGeneralsAlreadySetToRoot) - { - // If we are in root and found generals.exe, it could be ZH. - // Check for something specific to ZH if possible, or just assume if user pointed here it might be combined. - // For safety, let's treat root install as potentially containing both if we can't distinguish. - - // Ideally we check for a ZH specific file, but standard detection often just looks for exe. - // Let's assume if the user pointed us here and it has the exe, it's valid. - // Standard Retail ZH has "generals.exe" but also usually lives in its own folder. - // If user pointed to "C:\Games\ZH", it has generals.exe. - HasZeroHour = true; - ZeroHourPath = InstallationPath; - foundZeroHour = true; - _logger?.LogDebug("Found Zero Hour installation at root {ZeroHourPath}", ZeroHourPath); - } - else - { - _logger?.LogDebug( - "Skipping Zero Hour detection at root {InstallationPath} - Generals already detected here", - InstallationPath); - } - } - } - - // Logic improvement: If we found generals.exe in root, we might have set BOTH to true/root. - // This is acceptable for some "All in One" repacks or if the user manually merged them. + FetchSubdirectoryInstallations(ref foundGenerals, ref foundZeroHour); + FetchRootInstallation(ref foundGenerals, ref foundZeroHour); // Log warnings only if absolutely nothing found if (!foundGenerals && !foundZeroHour) @@ -259,7 +192,7 @@ public void Fetch() } catch (Exception ex) { - _logger?.LogWarning(ex, "Failed to fetch installations for {InstallationPath}", InstallationPath); + _logger?.LogError(ex, "Failed to fetch installation at {InstallationPath}", InstallationPath); } } @@ -288,4 +221,207 @@ private static bool HasValidExecutable(string path) var possibleExes = new[] { GameClientConstants.SteamGameDatExecutable, GameClientConstants.GeneralsExecutable, GameClientConstants.ZeroHourExecutable }; return possibleExes.Any(exe => Path.Combine(path, exe).FileExistsCaseInsensitive()); } -} \ No newline at end of file + + private static bool HasRootExecutable(string path) + { + var possibleExes = new[] + { + GameClientConstants.GeneralsExecutable, + GameClientConstants.SuperHackersZeroHourExecutable, + GameClientConstants.SuperHackersGeneralsExecutable, + GameClientConstants.GeneralsOnlineDefaultExecutable, + GameClientConstants.GeneralsOnline60HzExecutable, + GameClientConstants.GeneralsOnlineEacLauncherExecutable, + GameClientConstants.ContraExecutable, + GameClientConstants.SteamGameDatExecutable, + GameClientConstants.GameExecutable, + }; + + return possibleExes.Any(exe => Path.Combine(path, exe).FileExistsCaseInsensitive()); + } + + private static bool HasZeroHourArchiveOrExecutableSignature(string path) + { + if (Path.Combine(path, GameClientConstants.ZeroHourIniBig).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.ZeroHourPatchBig).FileExistsCaseInsensitive()) + { + return true; + } + + if (Path.Combine(path, GameClientConstants.SuperHackersZeroHourExecutable).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsOnlineDefaultExecutable).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsOnline60HzExecutable).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsOnlineEacLauncherExecutable).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.ContraExecutable).FileExistsCaseInsensitive()) + { + return true; + } + + // Check for any localized or mod big archive ending with ZH.big (e.g. SpeechEnglishZH.big, RussianZH.big, GermanZH.big, MapsZH.big) + try + { + if (Directory.Exists(path)) + { + var directoryInfo = new DirectoryInfo(path); + if (directoryInfo.EnumerateFiles().Any(f => f.Name.EndsWith("ZH.big", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + } + catch (IOException) + { + // Directory probe failure fallback + } + catch (UnauthorizedAccessException) + { + // Directory access denied fallback + } + + return false; + } + + private static bool HasGeneralsArchiveSignature(string path) + { + return Path.Combine(path, "gensec.big").FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsIniBig).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsPatchBig).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.SuperHackersGeneralsExecutable).FileExistsCaseInsensitive(); + } + + private static bool IsZeroHourNamedDirectory(string path) + { + var folderName = Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + + return folderName.Contains("Zero Hour", StringComparison.OrdinalIgnoreCase) || + folderName.Contains("ZeroHour", StringComparison.OrdinalIgnoreCase) || + string.Equals(folderName, "ZH", StringComparison.OrdinalIgnoreCase) || + folderName.StartsWith("ZH_", StringComparison.OrdinalIgnoreCase) || + folderName.EndsWith("_ZH", StringComparison.OrdinalIgnoreCase) || + folderName.StartsWith("ZH-", StringComparison.OrdinalIgnoreCase) || + folderName.EndsWith("-ZH", StringComparison.OrdinalIgnoreCase); + } + + private void FetchSubdirectoryInstallations(ref bool foundGenerals, ref bool foundZeroHour) + { + if (!foundGenerals) + { + ReadOnlySpan generalsSubdirs = + [ + GameClientConstants.GeneralsDirectoryName, + GameClientConstants.GeneralsRetailDirectoryName, + ]; + + if (TryFindSubdirectoryInstallation(generalsSubdirs, GameClientConstants.GeneralsExecutable, out var generalsPath)) + { + HasGenerals = true; + GeneralsPath = generalsPath; + foundGenerals = true; + _logger?.LogDebug("Found Generals installation at {GeneralsPath}", GeneralsPath); + } + } + + if (!foundZeroHour) + { + ReadOnlySpan zhSubdirs = + [ + GameClientConstants.ZeroHourDirectoryName, + GameClientConstants.ZeroHourDirectoryNameAmpersandHyphen, + GameClientConstants.ZeroHourRetailDirectoryName, + GameClientConstants.ZeroHourDirectoryNameAbbreviated, + GameClientConstants.ZeroHourDirectoryNameColonVariant, + ]; + + if (TryFindSubdirectoryInstallation(zhSubdirs, GameClientConstants.ZeroHourExecutable, out var zeroHourPath)) + { + HasZeroHour = true; + ZeroHourPath = zeroHourPath; + foundZeroHour = true; + _logger?.LogDebug("Found Zero Hour installation at {ZeroHourPath}", ZeroHourPath); + } + } + } + + private bool TryFindSubdirectoryInstallation( + ReadOnlySpan candidateSubdirectories, + string executableName, + [NotNullWhen(true)] out string? foundPath) + { + foreach (var subDir in candidateSubdirectories) + { + if (InstallationPath.TryGetDirectoryCaseInsensitive(subDir, out var subDirPath)) + { + var exePath = Path.Combine(subDirPath, executableName); + if (exePath.FileExistsCaseInsensitive()) + { + foundPath = subDirPath; + return true; + } + } + } + + foundPath = null; + return false; + } + + private void FetchRootInstallation(ref bool foundGenerals, ref bool foundZeroHour) + { + if ((foundGenerals && foundZeroHour) || !HasRootExecutable(InstallationPath)) + { + return; + } + + var isZhNamed = IsZeroHourNamedDirectory(InstallationPath); + var hasZhSignature = HasZeroHourArchiveOrExecutableSignature(InstallationPath); + var hasGenSignature = HasGeneralsArchiveSignature(InstallationPath); + + if (!foundZeroHour && (isZhNamed || hasZhSignature)) + { + HasZeroHour = true; + ZeroHourPath = InstallationPath; + foundZeroHour = true; + _logger?.LogDebug("Found Zero Hour installation at root {ZeroHourPath}", ZeroHourPath); + } + + if (!foundGenerals && hasGenSignature) + { + var isStrictGeneralsOnlySignature = + Path.Combine(InstallationPath, "gensec.big").FileExistsCaseInsensitive() || + Path.Combine(InstallationPath, GameClientConstants.SuperHackersGeneralsExecutable).FileExistsCaseInsensitive(); + + var isZeroHour = isZhNamed || hasZhSignature; + if (!isZeroHour || isStrictGeneralsOnlySignature) + { + HasGenerals = true; + GeneralsPath = InstallationPath; + foundGenerals = true; + _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); + } + } + + if (foundGenerals || foundZeroHour) + { + return; + } + + AssignRootFallback(ref foundGenerals, ref foundZeroHour); + } + + private void AssignRootFallback(ref bool foundGenerals, ref bool foundZeroHour) + { + if (IsZeroHourNamedDirectory(InstallationPath)) + { + HasZeroHour = true; + ZeroHourPath = InstallationPath; + foundZeroHour = true; + _logger?.LogDebug("Found Zero Hour installation at root based on directory name {ZeroHourPath}", ZeroHourPath); + } + else + { + HasGenerals = true; + GeneralsPath = InstallationPath; + foundGenerals = true; + _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); + } + } +} diff --git a/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs b/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs index 0b1a35291..27c46286d 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs @@ -209,6 +209,9 @@ public class CreateProfileRequest /// Gets or sets the money transaction volume (TSH). public int? TshMoneyTransactionVolume { get; set; } + /// Gets or sets the game window transition speed multiplier (TSH, 1.0 to 4.0). + public float? TshGameWindowTransitionSpeedMultiplier { get; set; } + // ===== GeneralsOnline Settings ===== /// Gets or sets a value indicating whether to show FPS (GO). diff --git a/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs b/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs index 3885f73bd..d1c664ed5 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs @@ -255,6 +255,9 @@ public class GameProfile : IGameProfile /// Gets or sets the font size for system time display (TSH, 0 to disable). public int? TshSystemTimeFontSize { get; set; } + /// Gets or sets the game window transition speed multiplier (TSH, 1.0 to 4.0). + public float? TshGameWindowTransitionSpeedMultiplier { get; set; } + // ===== GeneralsOnline Client Settings ===== /// Gets or sets a value indicating whether to show FPS counter (GO). diff --git a/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs b/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs index eaceeaf0f..a41ec8731 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs @@ -289,6 +289,9 @@ public class UpdateProfileRequest /// Gets or sets the font size for system time display (TSH, 0 to disable). public int? TshSystemTimeFontSize { get; set; } + /// Gets or sets the game window transition speed multiplier (TSH, 1.0 to 4.0). + public float? TshGameWindowTransitionSpeedMultiplier { get; set; } + // ===== GeneralsOnline Client Settings ===== /// Gets or sets a value indicating whether to show FPS counter (GO). diff --git a/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs b/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs index 1180fe768..b727c4383 100644 --- a/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs +++ b/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs @@ -1,3 +1,8 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using GenHub.Core.Constants; + namespace GenHub.Core.Models.GameSettings; /// GeneralsOnline game client settings (inherits TheSuperHackers settings plus GeneralsOnline-specific options). @@ -7,25 +12,25 @@ public class GeneralsOnlineSettings : TheSuperHackersSettings public bool ShowFps { get; set; } /// Gets or sets a value indicating whether to show ping/latency. - public bool ShowPing { get; set; } = true; + public bool ShowPing { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultShowPing; /// Gets or sets a value indicating whether to enable auto-login. public bool AutoLogin { get; set; } /// Gets or sets a value indicating whether to remember username. - public bool RememberUsername { get; set; } = true; + public bool RememberUsername { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultRememberUsername; /// Gets or sets a value indicating whether to enable notifications. - public bool EnableNotifications { get; set; } = true; + public bool EnableNotifications { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultEnableNotifications; /// Gets or sets the chat font size. - public int ChatFontSize { get; set; } = 12; + public int ChatFontSize { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultChatFontSize; /// Gets or sets a value indicating whether to enable sound notifications. - public bool EnableSoundNotifications { get; set; } = true; + public bool EnableSoundNotifications { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultEnableSoundNotifications; /// Gets or sets a value indicating whether to show player ranks. - public bool ShowPlayerRanks { get; set; } = true; + public bool ShowPlayerRanks { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultShowPlayerRanks; /// Gets or sets the camera settings. public CameraSettings Camera { get; set; } = new(); @@ -42,6 +47,27 @@ public class GeneralsOnlineSettings : TheSuperHackersSettings /// Gets or sets the social notification settings. public SocialSettings Social { get; set; } = new(); + /// + /// Gets or sets the settings.json keys this model does not declare. GenHub rewrites the + /// GeneralsOnline client's own settings.json wholesale, so without this the client would + /// lose every option GenHub has no property for. + /// + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; + + /// + /// Replaces nested sections that a settings.json spelled as an explicit null, which is valid + /// JSON and overwrites the initializers, so that merging into this instance cannot throw. + /// + public void EnsureNestedSectionsInitialized() + { + Camera ??= new CameraSettings(); + Chat ??= new ChatSettings(); + Debug ??= new DebugSettings(); + Render ??= new RenderSettings(); + Social ??= new SocialSettings(); + } + /// Nested camera settings. public class CameraSettings { @@ -53,6 +79,10 @@ public class CameraSettings /// Gets or sets the camera move speed ratio. public float MoveSpeedRatio { get; set; } = 1.5f; + + /// Gets or sets the camera keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } /// Nested chat settings. @@ -60,6 +90,10 @@ public class ChatSettings { /// Gets or sets the chat duration in seconds until fade out. public int DurationSecondsUntilFadeOut { get; set; } = 30; + + /// Gets or sets the chat keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } /// Nested debug settings. @@ -67,6 +101,10 @@ public class DebugSettings { /// Gets or sets a value indicating whether debug verbose logging is enabled. public bool VerboseLogging { get; set; } + + /// Gets or sets the debug keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } /// Nested render settings. @@ -80,6 +118,10 @@ public class RenderSettings /// Gets or sets a value indicating whether to render stats overlay. public bool StatsOverlay { get; set; } = true; + + /// Gets or sets the render keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } /// Nested social settings. @@ -108,5 +150,9 @@ public class SocialSettings /// Gets or sets a value indicating whether to show notification when player sends request in menus. public bool NotificationPlayerSendsRequestMenus { get; set; } = true; + + /// Gets or sets the social keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } } diff --git a/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs b/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs index 84c318569..2688913f5 100644 --- a/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs +++ b/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs @@ -1,3 +1,5 @@ +using GenHub.Core.Constants; + namespace GenHub.Core.Models.GameSettings; /// TheSuperHackers game client settings from Options.ini. @@ -19,7 +21,7 @@ public class TheSuperHackersSettings public bool CursorCaptureEnabledInWindowedMenu { get; set; } /// Gets or sets the volume of money transaction audio events (0-100, 0 to mute). - public int MoneyTransactionVolume { get; set; } + public int MoneyTransactionVolume { get; set; } = GameSettingsTheSuperHackersConstants.DefaultMoneyTransactionVolume; /// Gets or sets the font size for network latency display (0 to disable). public int NetworkLatencyFontSize { get; set; } = 8; @@ -44,4 +46,7 @@ public class TheSuperHackersSettings /// Gets or sets the font size for system time display (0 to disable). public int SystemTimeFontSize { get; set; } = 8; + + /// Gets or sets the game window transition speed multiplier (1.0 to 4.0). + public float GameWindowTransitionSpeedMultiplier { get; set; } = GameSettingsTheSuperHackersConstants.DefaultGameWindowTransitionSpeedMultiplier; } diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs index fd42ee2b2..b3a98df3a 100644 --- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs +++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs @@ -36,6 +36,12 @@ public class GeneralsOnlineRelease /// public long? PortableSize { get; init; } + /// + /// Gets SHA256 hash of the portable ZIP package for file verification. + /// Null when hash is unknown (e.g., from latest.txt API). + /// + public string? Sha256 { get; init; } + /// /// Gets release changelog/notes. /// diff --git a/GenHub/GenHub.Core/Models/Manifest/ContentVariant.cs b/GenHub/GenHub.Core/Models/Manifest/ContentVariant.cs index 957f3a416..aa7cd9473 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ContentVariant.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ContentVariant.cs @@ -43,6 +43,11 @@ public class ContentVariant /// public GameType? TargetGame { get; set; } + /// + /// Gets or sets the specific output filename for this variant when repacking or delivering content. + /// + public string? OutputFilename { get; set; } + /// /// Gets or sets file path patterns to include for this variant. /// Supports wildcards (e.g., "*1920x1080*", "Resolution_1080p/*"). diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs index 1c5e1dba6..b954fcdbd 100644 --- a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs +++ b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs @@ -10,11 +10,6 @@ namespace GenHub.Core.Models.Manifest; /// public class InstallationInstructions { - /// - /// Gets or sets the steps to run before installation. - /// - public List PreInstallSteps { get; set; } = []; - /// /// Gets or sets the steps to run after installation. /// diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs index 6ecd505a9..78590ebfd 100644 --- a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs +++ b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using GenHub.Core.Models.Enums; + namespace GenHub.Core.Models.Manifest; /// -/// Individual installation step with commands and conditions. +/// Individual installation step with typed operation kind and structured parameters. /// public class InstallationStep { @@ -11,22 +15,49 @@ public class InstallationStep public string Name { get; set; } = string.Empty; /// - /// Gets or sets the command to execute. + /// Gets or sets the kind of installation operation to execute. + /// + public InstallationStepKind Kind { get; set; } = InstallationStepKind.Unknown; + + /// + /// Gets or sets the relative path of the target file to act upon in the delivered workspace or manifest. /// - public string Command { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TargetRelativePath { get; set; } /// - /// Gets or sets the arguments for the command. + /// Gets or sets the destination relative path when renaming or moving a file. + /// Only used when is . /// - public List Arguments { get; set; } = new(); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DestinationRelativePath { get; set; } /// - /// Gets or sets the working directory for the command. + /// Gets or sets the arguments for executable steps. + /// Only used when is . /// - public string? WorkingDirectory { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Arguments { get; set; } /// /// Gets or sets a value indicating whether the step requires elevation. /// public bool RequiresElevation { get; set; } + + /// + /// Gets or sets an optional user-facing status message to display in notifications or progress. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StatusMessage { get; set; } + + /// + /// Gets or sets an optional unique key identifying this installation step for execution tracking across updates. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StepKey { get; set; } + + /// + /// Gets or sets a value indicating whether this step should only run once and be skipped on subsequent updates if already executed. + /// + public bool RunOnce { get; set; } } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs index 83d681312..7a93f17cf 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs @@ -9,7 +9,7 @@ namespace GenHub.Core.Models.Manifest; public sealed class ManifestIdJsonConverter : JsonConverter { /// - public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138 { var s = reader.GetString() ?? string.Empty; return ManifestId.Create(s); diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs index ddb22794a..fc609db20 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs @@ -166,7 +166,13 @@ public static EntryPointResolution ResolveEntryPoint( files); } - private static bool PathsMatch(string left, string right) => + /// + /// Determines whether two relative file paths match, normalizing directory separators and leading slashes. + /// + /// The first relative path. + /// The second relative path. + /// true if the paths match; otherwise, false. + public static bool PathsMatch(string left, string right) => string.Equals( left.Replace('\\', '/').TrimStart('/'), right.Replace('\\', '/').TrimStart('/'), diff --git a/GenHub/GenHub.Core/Models/Theming/ColorTheme.cs b/GenHub/GenHub.Core/Models/Theming/ColorTheme.cs new file mode 100644 index 000000000..cad050e42 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Theming/ColorTheme.cs @@ -0,0 +1,37 @@ +namespace GenHub.Core.Models.Theming; + +/// +/// Represents a selectable color theme for application accents. +/// +public sealed record ColorTheme +{ + /// + /// Gets the unique identifier of the theme. + /// + public required string Id { get; init; } + + /// + /// Gets the human-readable display name. + /// + public required string DisplayName { get; init; } + + /// + /// Gets the primary accent hex color. + /// + public required string PrimaryHex { get; init; } + + /// + /// Gets the lighter accent variant hex color for gradients and highlights. + /// + public required string LightHex { get; init; } + + /// + /// Gets the darker accent variant hex color for depth and gradients. + /// + public required string DarkHex { get; init; } + + /// + /// Gets the translucent glow hex color for ambient lighting effects. + /// + public required string GlowHex { get; init; } +} diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs index 74dedfe8b..2ffad6d45 100644 --- a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs @@ -29,4 +29,9 @@ public enum ReplaySource /// Direct link to a .rep or .zip file. /// DirectLink, + + /// + /// GameReplays Strata match platform. + /// + Strata, } diff --git a/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs b/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs index 04e1c356a..6d4ce29d3 100644 --- a/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs +++ b/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs @@ -27,6 +27,30 @@ public sealed class UploadRecord /// public string? FileName { get; set; } + /// + /// Gets or sets the SHA-256 hash of the uploaded file for deduplication. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FileHash { get; set; } + + /// + /// Gets or sets the file key assigned by the cloud storage provider. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FileKey { get; set; } + + /// + /// Gets or sets the cryptographic HMAC deletion token. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DeleteToken { get; set; } + + /// + /// Gets or sets the category or tool identifier of the upload (e.g. "replays", "maps"). + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Category { get; set; } + /// /// Gets or sets a value indicating whether a legacy record was pending deletion. /// diff --git a/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadRequest.cs b/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadRequest.cs new file mode 100644 index 000000000..e133c93ba --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadRequest.cs @@ -0,0 +1,8 @@ +namespace GenHub.Core.Models.Tools.UploadThing; + +/// +/// Request to delete a cloud upload using a cryptographic deletion token. +/// +/// Unique file key in cloud storage. +/// Cryptographic HMAC deletion receipt. +public sealed record DeleteUploadRequest(string FileKey, string DeleteToken); diff --git a/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadResponse.cs b/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadResponse.cs new file mode 100644 index 000000000..756f0a367 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadResponse.cs @@ -0,0 +1,7 @@ +namespace GenHub.Core.Models.Tools.UploadThing; + +/// +/// Response from the gateway after requesting deletion. +/// +/// Whether the deletion succeeded upstream. +public sealed record DeleteUploadResponse(bool Success); diff --git a/GenHub/GenHub.Core/Models/Tools/UploadThing/DirectUploadResponse.cs b/GenHub/GenHub.Core/Models/Tools/UploadThing/DirectUploadResponse.cs new file mode 100644 index 000000000..a29138a6e --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadThing/DirectUploadResponse.cs @@ -0,0 +1,12 @@ +namespace GenHub.Core.Models.Tools.UploadThing; + +/// +/// Gateway response containing upload result and cryptographic deletion token. +/// +/// Publicly accessible share URL. +/// Unique file key in cloud storage. +/// Cryptographic HMAC deletion receipt. +public sealed record DirectUploadResponse( + string? PublicUrl, + string? FileKey, + string? DeleteToken); diff --git a/GenHub/GenHub.Core/Models/Tools/UploadThing/UploadResult.cs b/GenHub/GenHub.Core/Models/Tools/UploadThing/UploadResult.cs new file mode 100644 index 000000000..735841c05 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadThing/UploadResult.cs @@ -0,0 +1,9 @@ +namespace GenHub.Core.Models.Tools.UploadThing; + +/// +/// Result of a successful cloud upload operation. +/// +/// Public share URL for the uploaded file. +/// Unique file key in cloud storage. +/// Cryptographic HMAC deletion receipt. +public sealed record UploadResult(string PublicUrl, string FileKey, string DeleteToken); diff --git a/GenHub/GenHub.Core/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.ProxyLauncher/Program.cs b/GenHub/GenHub.ProxyLauncher/Program.cs index 91ddbac02..bc0c59085 100644 --- a/GenHub/GenHub.ProxyLauncher/Program.cs +++ b/GenHub/GenHub.ProxyLauncher/Program.cs @@ -160,18 +160,15 @@ private static string BuildArgumentString(ProxyConfig config, string[] args) if (config.Arguments != null) { - foreach (var arg in config.Arguments) + foreach (var arg in config.Arguments.Where(arg => !string.IsNullOrWhiteSpace(arg) && dedupe.Add(arg))) { - if (!string.IsNullOrWhiteSpace(arg) && dedupe.Add(arg)) - { - arguments.Add(arg); - } + arguments.Add(arg); } } if (args.Length > 0) { - foreach (var arg in args) + foreach (var arg in args.Where(arg => !string.IsNullOrWhiteSpace(arg) && dedupe.Add(arg))) { var cleanArg = arg.Trim('"'); if (string.Equals(cleanArg, Environment.ProcessPath, StringComparison.OrdinalIgnoreCase)) @@ -469,7 +466,7 @@ private static void LogError(string message) } } - private class ProxyConfig + private sealed class ProxyConfig { public string? TargetExecutable { get; set; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs index 49836a2dc..d0fff137c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -2,6 +2,7 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Storage; using Microsoft.Extensions.Logging; @@ -549,6 +550,58 @@ public void GetAutoCheckForUpdatesOnStartup_ReturnsUserSetting(bool userValue) Assert.Equal(userValue, result); } + /// + /// Verifies that GetAutoCheckForUpdatesPeriodically returns user setting when explicitly set. + /// + /// The value to set for AutoCheckForUpdatesPeriodically in user settings. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetAutoCheckForUpdatesPeriodically_ReturnsUserSetting(bool userValue) + { + // Arrange + var userSettings = new UserSettings { AutoCheckForUpdatesPeriodically = userValue }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesPeriodically)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetAutoCheckForUpdatesPeriodically(); + + // Assert + Assert.Equal(userValue, result); + } + + /// + /// Verifies that GetPeriodicUpdateCheckIntervalMinutes returns user setting when explicitly set. + /// + /// The interval to set in user settings. + /// The expected clamped interval. + [Theory] + [InlineData(60, 60)] + [InlineData(0, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes)] + [InlineData(20000, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes)] + public void GetPeriodicUpdateCheckIntervalMinutes_ReturnsUserSetting(int intervalMinutes, int expectedMinutes) + { + // Arrange + var userSettings = new UserSettings { PeriodicUpdateCheckIntervalMinutes = intervalMinutes }; + if (intervalMinutes > 0) + { + userSettings.MarkAsExplicitlySet(nameof(UserSettings.PeriodicUpdateCheckIntervalMinutes)); + } + + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetPeriodicUpdateCheckIntervalMinutes(); + + // Assert + Assert.Equal(expectedMinutes, result); + } + /// /// Verifies that GetEnableDetailedLogging returns user setting when explicitly set. /// @@ -742,10 +795,35 @@ public void GetContentDirectories_WithNullUserSetting_ReturnsDefaults() // Assert Assert.Contains(Path.Combine(appDataPath, FileTypes.ManifestsDirectory), result); - Assert.Contains(Path.Combine(appDataPath, "CustomManifests"), result); + Assert.Contains(Path.Combine(appDataPath, DirectoryNames.CustomManifests), result); Assert.True(result.Count >= 3); } + /// + /// Verifies that the default content directories follow an explicitly set application data path, + /// so local discovery scans the same root the manifests are read from and written to. + /// + [Fact] + public void GetContentDirectories_WithExplicitApplicationDataPath_ReturnsOverride() + { + // Arrange + var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root"); + var userSettings = new UserSettings { ApplicationDataPath = userPath, ContentDirectories = [] }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path"); + + var provider = CreateProvider(); + + // Act + var result = provider.GetContentDirectories(); + + // Assert + Assert.Contains(Path.Combine(userPath, FileTypes.ManifestsDirectory), result); + Assert.Contains(Path.Combine(userPath, DirectoryNames.CustomManifests), result); + Assert.Equal(provider.GetManifestsPath(), result[0]); + } + /// /// Verifies that GetGitHubDiscoveryRepositories returns user setting when available. /// @@ -784,7 +862,536 @@ public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults() // Assert Assert.Contains("TheSuperHackers/GeneralsGameCode", result); - Assert.Single(result); + Assert.Contains("TheSuperHackers/GeneralsGamePatch2", result); + Assert.Equal(2, result.Count); + } + + /// + /// Verifies that GetProfilesPath honors an explicitly set application data path. + /// + [Fact] + public void GetProfilesPath_WithExplicitApplicationDataPath_ReturnsOverride() + { + // Arrange + var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root"); + var userSettings = new UserSettings { ApplicationDataPath = userPath }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path"); + + var provider = CreateProvider(); + + // Act + var result = provider.GetProfilesPath(); + + // Assert + Assert.Equal(Path.Combine(userPath, DirectoryNames.Profiles), result); + } + + /// + /// Verifies that GetManifestsPath honors an explicitly set application data path. + /// + [Fact] + public void GetManifestsPath_WithExplicitApplicationDataPath_ReturnsOverride() + { + // Arrange + var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root"); + var userSettings = new UserSettings { ApplicationDataPath = userPath }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path"); + + var provider = CreateProvider(); + + // Act + var result = provider.GetManifestsPath(); + + // Assert + Assert.Equal(Path.Combine(userPath, FileTypes.ManifestsDirectory), result); + } + + /// + /// Verifies that the profiles and manifests paths fall back to the configured data path when no + /// application data path override is set. + /// + [Fact] + public void GetProfilesAndManifestsPath_WithoutOverride_ReturnConfiguredDataPath() + { + // Arrange + var appDataPath = "/app/data/path"; + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns(appDataPath); + + var provider = CreateProvider(); + + // Act & Assert + Assert.Equal(Path.Combine(appDataPath, DirectoryNames.Profiles), provider.GetProfilesPath()); + Assert.Equal(Path.Combine(appDataPath, FileTypes.ManifestsDirectory), provider.GetManifestsPath()); + } + + /// + /// Verifies that the legacy roaming data root is migrated into the current root while the CAS + /// pool, which still defaults to the legacy location, is left in place. + /// + [Fact] + public void MigrateLegacyDataRoot_WithLegacyData_MovesTrackedEntriesAndLeavesCasPool() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("manifest", File.ReadAllText(Path.Combine(newRoot, FileTypes.ManifestsDirectory, "content.manifest.json"))); + Assert.Equal("index", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName))); + Assert.Equal("backup", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, DirectoryNames.UserDataBackups, "save.bak"))); + Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName))); + + Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"))); + Assert.False(Directory.Exists(Path.Combine(newRoot, DirectoryNames.CasPool))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that running the legacy root migration a second time leaves the migrated data alone. + /// + [Fact] + public void MigrateLegacyDataRoot_RunTwice_IsIdempotent() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + var provider = CreateProvider(); + + provider.MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + provider.MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that data already present in the current root wins over the legacy copy. + /// + [Fact] + public void MigrateLegacyDataRoot_WithExistingData_DoesNotOverwriteNewRoot() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + Directory.CreateDirectory(Path.Combine(newRoot, DirectoryNames.Profiles)); + File.WriteAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"), "current-profile"); + File.WriteAllText(Path.Combine(newRoot, FileTypes.SettingsFileName), "current-settings"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("current-profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("current-settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that a missing legacy root does not create the current root. + /// + [Fact] + public void MigrateLegacyDataRoot_WithoutLegacyRoot_DoesNothing() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + Directory.Delete(legacyRoot); + Directory.Delete(newRoot); + try + { + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.False(Directory.Exists(newRoot)); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the migration is skipped when both roots resolve to the same directory. + /// + [Fact] + public void MigrateLegacyDataRoot_WithIdenticalRoots_DoesNothing() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, Path.Combine(legacyRoot, "."), Path.Combine(legacyRoot, ".")); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("settings", File.ReadAllText(Path.Combine(legacyRoot, FileTypes.SettingsFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the migration leaves nothing behind in the legacy root, so a regression from a + /// move to a copy is caught rather than passing every positive assertion. + /// + [Fact] + public void MigrateLegacyDataRoot_WithLegacyData_RemovesTheLegacySources() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.SettingsFileName))); + Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.WorkspaceMetadataFileName))); + Assert.False(Directory.Exists(Path.Combine(legacyRoot, DirectoryNames.Profiles))); + Assert.False(Directory.Exists(Path.Combine(legacyRoot, FileTypes.ManifestsDirectory))); + Assert.False(Directory.Exists(Path.Combine(legacyRoot, DirectoryNames.UserData))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies the steady state after a successful migration: a legacy root that still holds the CAS + /// pool, but none of the migrated entries, is left completely alone. + /// + [Fact] + public void MigrateLegacyDataRoot_WithoutLegacyEntries_LeavesBothRootsAlone() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + Directory.Delete(newRoot); + try + { + WriteFile(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"), "cas"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.False(Directory.Exists(newRoot)); + Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the sub-layout releases up to v0.0.3 wrote, which nested the manifests, tracked + /// user data and workspace metadata under a Content directory, is flattened into the data root. + /// + [Fact] + public void MigrateLegacyDataRoot_WithContentSubLayout_FlattensIntoDataRoot() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + var legacyContent = Path.Combine(legacyRoot, DirectoryNames.LegacyContent); + WriteFile(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile"); + WriteFile(Path.Combine(legacyContent, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest"); + WriteFile(Path.Combine(legacyContent, DirectoryNames.UserData, FileTypes.UserDataIndexFileName), "index"); + WriteFile(Path.Combine(legacyContent, FileTypes.WorkspaceMetadataFileName), "workspaces"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("manifest", File.ReadAllText(Path.Combine(newRoot, FileTypes.ManifestsDirectory, "content.manifest.json"))); + Assert.Equal("index", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the settings file releases up to v0.0.3 wrote, which was named after the JSON + /// extension rather than the settings file name, is migrated under the current name. + /// + [Fact] + public void MigrateLegacyDataRoot_WithLegacySettingsFileName_MigratesUnderCurrentName() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + WriteFile(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName), "settings"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that a settings file already under the current name wins over the v0.0.3 one. + /// + [Fact] + public void MigrateLegacyDataRoot_WithBothSettingsFileNames_PrefersTheCurrentName() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + WriteFile(Path.Combine(legacyRoot, FileTypes.SettingsFileName), "current"); + WriteFile(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName), "older"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("current", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the data consumers read through the application data path lands in the override + /// root while the settings file, which is resolved from the configured root, lands there instead. + /// + [Fact] + public void MigrateLegacyDataRoot_WithSeparateDataAndSettingsRoots_SplitsTheDestinations() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + var overrideRoot = Path.Combine(Path.GetDirectoryName(newRoot)!, "relocated"); + try + { + SeedLegacyRoot(legacyRoot); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, overrideRoot, newRoot); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(overrideRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("manifest", File.ReadAllText(Path.Combine(overrideRoot, FileTypes.ManifestsDirectory, "content.manifest.json"))); + Assert.Equal("index", File.ReadAllText(Path.Combine(overrideRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(overrideRoot, FileTypes.WorkspaceMetadataFileName))); + + Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.False(File.Exists(Path.Combine(overrideRoot, FileTypes.SettingsFileName))); + Assert.False(Directory.Exists(Path.Combine(newRoot, DirectoryNames.Profiles))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that GetCsvCatalogConfiguration returns app config values when user settings are not set. + /// + [Fact] + public void GetCsvCatalogConfiguration_WithDefaultSettings_ReturnsAppConfig() + { + // Arrange + var appConfig = new CsvCatalogConfiguration + { + IndexFilePath = "https://example.com/index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/catalog.csv", GameType = CsvConstants.GeneralsGameType }, + ], + }; + _mockAppConfig.Setup(x => x.GetCsvCatalogConfiguration()).Returns(appConfig); + _mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var provider = CreateProvider(); + + // Act + var result = provider.GetCsvCatalogConfiguration(); + + // Assert + Assert.Equal("https://example.com/index.json", result.IndexFilePath); + Assert.Single(result.CsvValidationCatalogs); + Assert.Equal("https://example.com/catalog.csv", result.CsvValidationCatalogs[0].Url); + } + + /// + /// Verifies that GetCsvCatalogConfiguration overrides app config when user settings are explicitly set. + /// + [Fact] + public void GetCsvCatalogConfiguration_WithExplicitUserSettings_OverridesAppConfig() + { + // Arrange + var appConfig = new CsvCatalogConfiguration + { + IndexFilePath = "https://example.com/app-index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/app.csv", GameType = CsvConstants.GeneralsGameType }, + ], + }; + var userSettings = new UserSettings + { + IndexFilePath = "https://example.com/user-index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/user.csv", GameType = CsvConstants.ZeroHourGameType }, + ], + }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.IndexFilePath)); + userSettings.MarkAsExplicitlySet(nameof(UserSettings.CsvValidationCatalogs)); + + _mockAppConfig.Setup(x => x.GetCsvCatalogConfiguration()).Returns(appConfig); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetCsvCatalogConfiguration(); + + // Assert + Assert.Equal("https://example.com/user-index.json", result.IndexFilePath); + Assert.Single(result.CsvValidationCatalogs); + Assert.Equal("https://example.com/user.csv", result.CsvValidationCatalogs[0].Url); + Assert.Equal(CsvConstants.ZeroHourGameType, result.CsvValidationCatalogs[0].GameType); + } + + /// + /// Verifies that GetEffectiveSettings includes CSV catalog configuration. + /// + [Fact] + public void GetEffectiveSettings_IncludesCsvCatalogConfiguration() + { + // Arrange + var appConfig = new CsvCatalogConfiguration + { + IndexFilePath = "https://example.com/index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/catalog.csv", GameType = CsvConstants.GeneralsGameType }, + ], + }; + var testDataPath = Path.Combine(Path.GetTempPath(), $"genhub-test-{Guid.NewGuid():N}"); + _mockAppConfig.Setup(x => x.GetCsvCatalogConfiguration()).Returns(appConfig); + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns(testDataPath); + _mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var provider = CreateProvider(); + + // Act + var settings = provider.GetEffectiveSettings(); + + // Assert + Assert.Equal("https://example.com/index.json", settings.IndexFilePath); + Assert.NotNull(settings.CsvValidationCatalogs); + Assert.Single(settings.CsvValidationCatalogs); + Assert.Equal("https://example.com/catalog.csv", settings.CsvValidationCatalogs[0].Url); + } + + /// + /// Verifies that an explicitly set empty catalog list overrides app config. + /// + [Fact] + public void GetCsvCatalogConfiguration_WithExplicitEmptyList_OverridesAppConfig() + { + // Arrange + var appConfig = new CsvCatalogConfiguration + { + IndexFilePath = "https://example.com/index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/catalog.csv", GameType = CsvConstants.GeneralsGameType }, + ], + }; + var userSettings = new UserSettings + { + CsvValidationCatalogs = [], + }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.CsvValidationCatalogs)); + + _mockAppConfig.Setup(x => x.GetCsvCatalogConfiguration()).Returns(appConfig); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetCsvCatalogConfiguration(); + + // Assert + Assert.NotNull(result.CsvValidationCatalogs); + Assert.Empty(result.CsvValidationCatalogs); + } + + /// + /// Creates a fresh legacy and current data root pair under the temp directory. + /// + /// The legacy and current root paths. + private static (string LegacyRoot, string NewRoot) CreateMigrationRoots() + { + var testRoot = Path.Combine(Path.GetTempPath(), $"genhub-migration-{Guid.NewGuid():N}"); + var legacyRoot = Path.Combine(testRoot, "roaming"); + var newRoot = Path.Combine(testRoot, "local"); + Directory.CreateDirectory(legacyRoot); + Directory.CreateDirectory(newRoot); + return (legacyRoot, newRoot); + } + + /// + /// Populates a legacy data root with the entries an alpha-3 install would contain. + /// + /// The legacy data root to populate. + private static void SeedLegacyRoot(string legacyRoot) + { + WriteFile(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile"); + WriteFile(Path.Combine(legacyRoot, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest"); + WriteFile(Path.Combine(legacyRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName), "index"); + WriteFile(Path.Combine(legacyRoot, DirectoryNames.UserData, DirectoryNames.UserDataBackups, "save.bak"), "backup"); + WriteFile(Path.Combine(legacyRoot, FileTypes.SettingsFileName), "settings"); + WriteFile(Path.Combine(legacyRoot, FileTypes.WorkspaceMetadataFileName), "workspaces"); + WriteFile(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"), "cas"); + } + + private static void WriteFile(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static void DeleteDirectories(params string[] paths) + { + foreach (var path in paths.Select(Path.GetDirectoryName).Where(path => !string.IsNullOrEmpty(path)).Distinct()) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path!, true); + } + } + catch (IOException) + { + } + } } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs new file mode 100644 index 000000000..887d63802 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs @@ -0,0 +1,402 @@ +using GenHub.Common.Services; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using Microsoft.Extensions.Logging; +using Moq; + +namespace GenHub.Tests.Core.Common.Services; + +/// +/// Covers the first launch after upgrading from a release that kept its data under the roaming +/// profile. +/// +/// loads in its own constructor and resolves the settings path +/// straight from , so it runs before +/// has had any chance to migrate the legacy root. Left +/// alone it would start from defaults, and the first save of the session would then write those +/// defaults over the freshly migrated settings file, permanently destroying the user's settings. +/// +/// +public class LegacyRootUpgradeTests : IDisposable +{ + private readonly string _testRoot; + private readonly string _legacyRoot; + private readonly string _newRoot; + + /// + /// Initializes a new instance of the class. + /// + public LegacyRootUpgradeTests() + { + _testRoot = Path.Combine(Path.GetTempPath(), $"genhub-upgrade-{Guid.NewGuid():N}"); + _legacyRoot = Path.Combine(_testRoot, "roaming"); + _newRoot = Path.Combine(_testRoot, "local"); + Directory.CreateDirectory(_legacyRoot); + Directory.CreateDirectory(_newRoot); + } + + /// + /// Removes the temporary roots created for the test. + /// + public void Dispose() + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that the settings a user had before the upgrade are in effect on the first launch, + /// without waiting for a restart. + /// + [Fact] + public void FirstLaunch_WithLegacySettings_LoadsLegacyValues() + { + WriteLegacySettings(""" + { + "theme": "Light", + "maxConcurrentDownloads": 7, + "defaultWorkspaceStrategy": "SymlinkOnly" + } + """); + + var settings = CreateSettingsService().Get(); + + Assert.Equal("Light", settings.Theme); + Assert.Equal(7, settings.MaxConcurrentDownloads); + Assert.Equal(WorkspaceStrategy.SymlinkOnly, settings.DefaultWorkspaceStrategy); + } + + /// + /// Verifies the exact sequence that destroyed user settings: a first-launch load, the legacy + /// root migration moving the settings file into the new root, and then a save during that same + /// session. The saved file must still carry the user's values, not defaults. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task FirstLaunch_ThenMigrationThenSave_PreservesLegacyValuesAsync() + { + WriteLegacySettings(""" + { + "theme": "Light", + "maxConcurrentDownloads": 7 + } + """); + + var appConfig = CreateAppConfig(); + var settingsService = CreateSettingsService(appConfig); + var provider = new ConfigurationProviderService( + appConfig, + settingsService, + Mock.Of>()); + + // Triggers the legacy root migration, which moves settings.json into the new root. + provider.GetApplicationDataPath(); + Assert.True(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + + settingsService.Update(settings => settings.WindowWidth = 1440.0); + await settingsService.SaveAsync(); + + var persisted = CreateSettingsService(appConfig).Get(); + Assert.Equal("Light", persisted.Theme); + Assert.Equal(7, persisted.MaxConcurrentDownloads); + Assert.Equal(1440.0, persisted.WindowWidth); + } + + /// + /// Verifies that an application data path override carried over from the legacy settings is + /// honored on the first launch rather than after a restart. + /// + [Fact] + public void FirstLaunch_WithLegacyApplicationDataPathOverride_HonorsOverride() + { + var overridePath = Path.Combine(_testRoot, "relocated"); + Directory.CreateDirectory(overridePath); + var escapedOverridePath = overridePath.Replace("\\", "\\\\"); + WriteLegacySettings($$""" + { + "applicationDataPath": "{{escapedOverridePath}}" + } + """); + + var appConfig = CreateAppConfig(); + var provider = new ConfigurationProviderService( + appConfig, + CreateSettingsService(appConfig), + Mock.Of>()); + + Assert.Equal(overridePath, provider.GetApplicationDataPath()); + Assert.Equal(Path.Combine(overridePath, DirectoryNames.Profiles), provider.GetProfilesPath()); + Assert.Equal(Path.Combine(overridePath, FileTypes.ManifestsDirectory), provider.GetManifestsPath()); + } + + /// + /// Verifies that the migration puts the profiles where + /// resolves them when an application data path override is in effect, rather than in the + /// configured root the app would never look at. + /// + [Fact] + public void FirstLaunch_WithOverride_MigratesDataIntoTheRootTheAppReadsFrom() + { + var overridePath = Path.Combine(_testRoot, "relocated"); + WriteLegacySettings($$""" + { + "applicationDataPath": "{{overridePath.Replace("\\", "\\\\")}}" + } + """); + SeedLegacyDataDirectories(); + + var appConfig = CreateAppConfig(); + var provider = new ConfigurationProviderService( + appConfig, + CreateSettingsService(appConfig), + Mock.Of>()); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(provider.GetProfilesPath(), "profile.json"))); + Assert.Equal("manifest", File.ReadAllText(Path.Combine(provider.GetManifestsPath(), "content.manifest.json"))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(provider.GetApplicationDataPath(), FileTypes.WorkspaceMetadataFileName))); + + Assert.False(Directory.Exists(Path.Combine(_newRoot, DirectoryNames.Profiles))); + Assert.True(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + } + + /// + /// Verifies that the settings file releases up to v0.0.3 wrote, which was named after the JSON + /// extension rather than the settings file name, is still picked up on the first launch. + /// + [Fact] + public void FirstLaunch_WithV003SettingsFileName_LoadsLegacyValues() + { + var legacyJson = """ + { "theme": "Light", "maxConcurrentDownloads": 7 } + """; + File.WriteAllText(Path.Combine(_legacyRoot, FileTypes.LegacySettingsFileName), legacyJson); + + var settings = CreateSettingsService().Get(); + + Assert.Equal("Light", settings.Theme); + Assert.Equal(7, settings.MaxConcurrentDownloads); + } + + /// + /// Verifies that a normalization failure, which used to reset the settings to defaults while the + /// settings path still pointed at the user's file, keeps the loaded values instead. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task FirstLaunch_WhenNormalizationThrows_KeepsLoadedValuesAsync() + { + WriteLegacySettings(""" + { "theme": "Light", "maxConcurrentDownloads": 7 } + """); + + var appConfig = CreateAppConfigMock(); + appConfig.Setup(config => config.GetMinConcurrentDownloads()).Returns(8); + appConfig.Setup(config => config.GetMaxConcurrentDownloads()).Returns(1); + + var service = new UserSettingsService(Mock.Of>(), appConfig.Object); + Assert.Equal("Light", service.Get().Theme); + + await service.SaveAsync(); + + Assert.Equal("Light", CreateSettingsService().Get().Theme); + } + + /// + /// Verifies that a failed initialization can never persist defaults over a settings file that was + /// never read successfully. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_AfterFailedInitialization_RefusesToOverwriteExistingSettingsAsync() + { + var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName); + var existingJson = """ + { "theme": "Light" } + """; + File.WriteAllText(settingsPath, existingJson); + + var appConfig = CreateBaseAppConfigMock(); + appConfig.Setup(config => config.GetConfiguredDataPath()).Throws(new UnauthorizedAccessException("denied")); + + var service = new UserSettingsService(Mock.Of>(), appConfig.Object); + + await Assert.ThrowsAsync(() => service.SaveAsync()); + Assert.Contains("Light", File.ReadAllText(settingsPath)); + } + + /// + /// Verifies that a settings file the loader could not parse blocks the save that would replace + /// it with defaults. The failure is swallowed inside the load, so nothing reaches the outer + /// catch and the file looks like a clean load unless the load reports what it produced. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_WithCorruptSettingsFile_RefusesToOverwriteAsync() + { + var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName); + var corruptJson = "{ invalid json }"; + File.WriteAllText(settingsPath, corruptJson); + + var service = CreateSettingsService(); + Assert.Equal(AppConstants.DefaultThemeName, service.Get().Theme); + + await Assert.ThrowsAsync(() => service.SaveAsync()); + Assert.Equal(corruptJson, File.ReadAllText(settingsPath)); + } + + /// + /// Verifies that a corrupt pre-upgrade settings file blocks saving as well, rather than starting + /// the session from defaults and writing them into the current root as if the upgrade had found + /// nothing to carry over. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_WithCorruptLegacySettingsFile_RefusesToOverwriteAsync() + { + var corruptJson = "{ invalid json }"; + WriteLegacySettings(corruptJson); + + var service = CreateSettingsService(); + + await Assert.ThrowsAsync(() => service.SaveAsync()); + Assert.Equal(corruptJson, File.ReadAllText(Path.Combine(_legacyRoot, FileTypes.SettingsFileName))); + Assert.False(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + } + + /// + /// Verifies that a settings file which could not be opened, the case of a file locked by another + /// process or denied by permissions, blocks saving and therefore survives the session. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_WithUnreadableSettingsFile_RefusesToOverwriteAsync() + { + var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName); + var existingJson = """ + { "theme": "Light" } + """; + File.WriteAllText(settingsPath, existingJson); + + UserSettingsService service = null!; + using (File.Open(settingsPath, System.IO.FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + service = CreateSettingsService(); + } + + await Assert.ThrowsAsync(() => service.SaveAsync()); + Assert.Equal(existingJson, File.ReadAllText(settingsPath)); + } + + /// + /// Verifies that the absence of any settings file is still a legitimate first run, so blocking + /// saves after a failed load cannot leave a fresh install unable to persist anything. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_OnFirstRunWithoutAnySettingsFile_PersistsTheSettingsAsync() + { + Directory.Delete(_legacyRoot); + + var service = CreateSettingsService(); + service.Update(settings => settings.Theme = "Light"); + await service.SaveAsync(); + + Assert.Contains("Light", File.ReadAllText(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + } + + /// + /// Verifies that a settings file already present in the current root wins over the legacy copy. + /// + [Fact] + public void SecondLaunch_WithSettingsInNewRoot_IgnoresLegacyFile() + { + WriteLegacySettings(""" + { "theme": "Light" } + """); + var currentSettingsJson = """ + { "theme": "Dark" } + """; + File.WriteAllText(Path.Combine(_newRoot, FileTypes.SettingsFileName), currentSettingsJson); + + var settings = CreateSettingsService().Get(); + + Assert.Equal("Dark", settings.Theme); + } + + /// + /// Verifies that a fresh install, which has no legacy root at all, is unaffected. + /// + [Fact] + public void FreshInstall_WithoutLegacyRoot_UsesDefaults() + { + Directory.Delete(_legacyRoot); + + var service = CreateSettingsService(); + var settings = service.Get(); + + Assert.Equal(AppConstants.DefaultThemeName, settings.Theme); + Assert.False(settings.IsExplicitlySet(nameof(UserSettings.ApplicationDataPath))); + Assert.False(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + } + + /// + /// Verifies that a failure while looking for the pre-upgrade settings cannot stop startup. + /// + [Fact] + public void FirstLaunch_WhenLegacyLookupThrows_FallsBackToDefaults() + { + var appConfig = CreateAppConfigMock(); + appConfig.Setup(config => config.GetLegacyConfiguredDataPath()).Throws(new UnauthorizedAccessException("denied")); + + var service = new UserSettingsService(Mock.Of>(), appConfig.Object); + + Assert.Equal(AppConstants.DefaultThemeName, service.Get().Theme); + } + + private static Mock CreateBaseAppConfigMock() + { + var appConfig = new Mock(); + appConfig.Setup(config => config.GetMinConcurrentDownloads()).Returns(1); + appConfig.Setup(config => config.GetMaxConcurrentDownloads()).Returns(8); + appConfig.Setup(config => config.GetMinDownloadTimeoutSeconds()).Returns(30); + appConfig.Setup(config => config.GetMaxDownloadTimeoutSeconds()).Returns(600); + appConfig.Setup(config => config.GetMinDownloadBufferSizeBytes()).Returns(4096); + appConfig.Setup(config => config.GetMaxDownloadBufferSizeBytes()).Returns(1048576); + return appConfig; + } + + private Mock CreateAppConfigMock() + { + var appConfig = CreateBaseAppConfigMock(); + appConfig.Setup(config => config.GetConfiguredDataPath()).Returns(_newRoot); + appConfig.Setup(config => config.GetLegacyConfiguredDataPath()).Returns(_legacyRoot); + return appConfig; + } + + private IAppConfiguration CreateAppConfig() => CreateAppConfigMock().Object; + + private UserSettingsService CreateSettingsService(IAppConfiguration? appConfig = null) => + new(Mock.Of>(), appConfig ?? CreateAppConfig()); + + private void WriteLegacySettings(string json) => + File.WriteAllText(Path.Combine(_legacyRoot, FileTypes.SettingsFileName), json); + + private void SeedLegacyDataDirectories() + { + WriteLegacyFile(Path.Combine(_legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile"); + WriteLegacyFile(Path.Combine(_legacyRoot, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest"); + WriteLegacyFile(Path.Combine(_legacyRoot, FileTypes.WorkspaceMetadataFileName), "workspaces"); + } + + private void WriteLegacyFile(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ThemeServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ThemeServiceTests.cs new file mode 100644 index 000000000..c62f62396 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ThemeServiceTests.cs @@ -0,0 +1,111 @@ +using System.Linq; +using GenHub.Common.Services; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Theming; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Common.Services; + +/// +/// Unit tests for and application theming constants. +/// +public class ThemeServiceTests +{ + private readonly Mock _mockConfigProvider; + private readonly ThemeService _service; + + /// + /// Initializes a new instance of the class. + /// + public ThemeServiceTests() + { + _mockConfigProvider = new Mock(); + _mockConfigProvider.Setup(s => s.GetTheme()).Returns("Purple"); + + _service = new ThemeService(_mockConfigProvider.Object, NullLogger.Instance); + } + + /// + /// Verifies that all expected built-in themes are present. + /// + [Fact] + public void AvailableThemes_ContainsAllExpectedPalettes() + { + // Assert + Assert.NotNull(_service.AvailableThemes); + Assert.True(_service.AvailableThemes.Count >= 12); + + var themeIds = _service.AvailableThemes.Select(t => t.Id).ToList(); + Assert.Contains("Purple", themeIds); + Assert.Contains("Generals", themeIds); + Assert.Contains("ZeroHour", themeIds); + Assert.Contains("Emerald", themeIds); + Assert.Contains("Crimson", themeIds); + Assert.Contains("Amber", themeIds); + Assert.Contains("Cobalt", themeIds); + Assert.Contains("Rose", themeIds); + Assert.Contains("Tiberium", themeIds); + Assert.Contains("Teal", themeIds); + Assert.Contains("Indigo", themeIds); + Assert.Contains("Ruby", themeIds); + } + + /// + /// Verifies that default theme is Void Purple. + /// + [Fact] + public void CurrentTheme_Initially_ReturnsDefaultTheme() + { + // Assert + Assert.Equal(ThemeConstants.DefaultTheme.Id, _service.CurrentTheme.Id); + Assert.Equal("#A855F7", _service.CurrentTheme.PrimaryHex); + } + + /// + /// Verifies that applying a theme by ID updates CurrentTheme. + /// + [Fact] + public void ApplyTheme_ById_UpdatesCurrentTheme() + { + // Act + _service.ApplyTheme("Generals"); + + // Assert + Assert.Equal("Generals", _service.CurrentTheme.Id); + Assert.Equal("Generals Orange", _service.CurrentTheme.DisplayName); + } + + /// + /// Verifies that applying an invalid theme falls back to default. + /// + [Fact] + public void ApplyTheme_InvalidTheme_FallsBackToDefault() + { + // Act + _service.ApplyTheme("NonExistentTheme"); + + // Assert + Assert.Equal(ThemeConstants.DefaultTheme.Id, _service.CurrentTheme.Id); + } + + /// + /// Verifies that InitializeTheme restores saved theme from settings. + /// + [Fact] + public void InitializeTheme_RestoresSavedTheme() + { + // Arrange + _mockConfigProvider.Setup(s => s.GetTheme()).Returns("Emerald"); + + // Act + _service.InitializeTheme(); + + // Assert + Assert.Equal("Emerald", _service.CurrentTheme.Id); + Assert.Equal("Emerald Green", _service.CurrentTheme.DisplayName); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs index 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..9e9d2c729 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs @@ -0,0 +1,134 @@ +using System; +using GenHub.Core.Constants; +using Xunit; + +namespace GenHub.Tests.Core.Constants; + +/// +/// Unit tests for . +/// +public class AppUpdateConstantsTests +{ + /// + /// Tests that tab index constants have expected values. + /// + [Fact] + public void TabIndex_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(0, AppUpdateConstants.UpdateTabIndex); + Assert.Equal(1, AppUpdateConstants.BrowseBuildsTabIndex); + Assert.Equal(1, AppUpdateConstants.MaxTabIndex); + } + + /// + /// Tests that platform and artifact prefix constants have expected values. + /// + [Fact] + public void ArtifactAndPlatform_Constants_ShouldHaveExpectedValues() + { + Assert.Equal("velopack", AppUpdateConstants.VelopackDirectory); + Assert.Equal("genhub-velopack-windows-", AppUpdateConstants.ArtifactPrefixWindows); + Assert.Equal("genhub-velopack-linux-", AppUpdateConstants.ArtifactPrefixLinux); + Assert.Equal("GenHub-Release", AppUpdateConstants.ArtifactNameRelease); + Assert.Equal("windows", AppUpdateConstants.PlatformWindows); + Assert.Equal("linux", AppUpdateConstants.PlatformLinux); + } + + /// + /// Tests that periodic update check interval constants have expected values. + /// + [Fact] + public void PeriodicUpdateCheckInterval_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(30, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(5, AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(10080, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(5, AppUpdateConstants.PeriodicUpdateCheckIntervalIncrementMinutes); + Assert.True(AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes); + Assert.True(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + } + + /// + /// Tests that timespan constants have expected durations. + /// + [Fact] + public void TimeSpan_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(TimeSpan.FromSeconds(5), AppUpdateConstants.PostUpdateExitDelay); + Assert.Equal(TimeSpan.FromHours(1), AppUpdateConstants.CacheDuration); + Assert.Equal(3, AppUpdateConstants.MaxHttpRetries); + } + + /// + /// Tests that notification title and format constants are non-empty strings. + /// + [Fact] + public void NotificationAndFormat_Constants_ShouldBeValid() + { + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdatingAppNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateFailedNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAction)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.ViewUpdatesAction)); + Assert.Equal("development", AppUpdateConstants.DevelopmentBranch); + Assert.Equal("main", AppUpdateConstants.MainBranch); + Assert.Contains("{0}", AppUpdateConstants.ReleaseUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.BranchUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.PrUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.PrUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.PrMergedUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.PrMergedUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.PrMergedReleaseNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.PrMergedReleaseNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchStaleUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.BranchStaleUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchStaleReleaseNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.BranchStaleReleaseNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.PrMergedStatusMessageFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchStaleStatusMessageFormat); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PatRequiredForArtifactsMessage)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrFallbackDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchFallbackDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.ReleaseDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.GitHubFallbackDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.NotificationAlreadyShownLogFormat)); + Assert.Contains("{Identity}", AppUpdateConstants.NotificationAlreadyShownLogFormat); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.LoadingMessage)); + Assert.Equal("Loading...", AppUpdateConstants.LoadingMessage); + Assert.Contains("{0}", AppUpdateConstants.UpdateFailedNotificationFormat); + } + + /// + /// Tests that sort option constants are distinct non-empty strings. + /// + [Fact] + public void SortOption_Constants_ShouldBeDistinctAndNonEmpty() + { + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionLastUpdated)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberDesc)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberAsc)); + Assert.NotEqual(AppUpdateConstants.SortOptionLastUpdated, AppUpdateConstants.SortOptionPrNumberDesc); + Assert.NotEqual(AppUpdateConstants.SortOptionPrNumberDesc, AppUpdateConstants.SortOptionPrNumberAsc); + } + + /// + /// Tests that parallel download constants have valid positive values. + /// + [Fact] + public void ParallelDownload_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(131072, AppUpdateConstants.DefaultStreamBufferSize); + Assert.Equal(2 * 1024 * 1024, AppUpdateConstants.DownloadChunkSizeBytes); + Assert.Equal(8, AppUpdateConstants.ParallelDownloadConcurrency); + Assert.Equal(4 * 1024 * 1024, AppUpdateConstants.ParallelDownloadThresholdBytes); + Assert.True(AppUpdateConstants.ParallelDownloadConcurrency > 0); + Assert.True(AppUpdateConstants.DownloadChunkSizeBytes > 0); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs new file mode 100644 index 000000000..0396848f4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using GenHub.Core.Extensions.Enums; +using GenHub.Core.Models.Enums; +using Xunit; + +namespace GenHub.Tests.Core.Extensions.Enums; + +/// +/// Tests for . +/// +public class ContentInstallTargetExtensionsTests +{ + /// + /// The four user directories must be copied out of CAS rather than hard-linked, because the game + /// engine writes into them in place and would otherwise rewrite the canonical CAS object. + /// + /// The user-writable target under test. + [Theory] + [InlineData(ContentInstallTarget.UserDataDirectory)] + [InlineData(ContentInstallTarget.UserMapsDirectory)] + [InlineData(ContentInstallTarget.UserReplaysDirectory)] + [InlineData(ContentInstallTarget.UserScreenshotsDirectory)] + public void IsUserWritableTarget_ForUserDirectories_ReturnsTrue(ContentInstallTarget installTarget) + { + Assert.True(installTarget.IsUserWritableTarget()); + } + + /// + /// Workspace and system installs are managed by GenHub rather than written to by the user, so + /// they remain eligible for hard links to CAS. + /// + /// The GenHub-managed target under test. + [Theory] + [InlineData(ContentInstallTarget.Workspace)] + [InlineData(ContentInstallTarget.System)] + public void IsUserWritableTarget_ForGenHubManagedTargets_ReturnsFalse(ContentInstallTarget installTarget) + { + Assert.False(installTarget.IsUserWritableTarget()); + } + + /// + /// An install target this method has never been taught about must fail towards copying. The path + /// resolver sends unmapped targets into the user data root, so answering "not user-writable" + /// would hard-link a CAS object straight into the user's Documents folder. + /// + [Fact] + public void IsUserWritableTarget_ForAnUnmappedTarget_FailsTowardsCopying() + { + var unmapped = Enum.GetValues().Cast().Max() + 1; + + Assert.True(((ContentInstallTarget)unmapped).IsUserWritableTarget()); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs new file mode 100644 index 000000000..ea4054d5a --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs @@ -0,0 +1,145 @@ +using GenHub.Core.Constants; +using GenHub.Core.Extensions; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameClients; +using GenHub.Core.Models.GameProfile; + +namespace GenHub.Tests.Core.Extensions; + +/// +/// Tests for . +/// +public class GameProfileExtensionsTests +{ + /// + /// Verifies that the publisher type identifies a GeneralsOnline profile regardless of casing. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData("generalsonline")] + [InlineData("GeneralsOnline")] + [InlineData("GENERALSONLINE")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlinePublisher_ReturnsTrue(string publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", []); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that other Zero Hour publishers are not mistaken for GeneralsOnline, which is what + /// kept their launches from overwriting the GeneralsOnline client's settings.json. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(PublisherTypeConstants.TheSuperHackers)] + [InlineData(CommunityOutpostConstants.PublisherType)] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsGeneralsOnlineProfile_WithOtherPublisher_ReturnsFalse(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", ["1.0.genhub.mod.test"]); + + // Act & Assert + Assert.False(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a recorded publisher settles the question, so a profile belonging to another + /// client is not reclassified by content it happens to enable or by its client name. Answering + /// otherwise would let it rewrite the GeneralsOnline client's global settings. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(PublisherTypeConstants.TheSuperHackers)] + [InlineData(CommunityOutpostConstants.PublisherType)] + public void IsGeneralsOnlineProfile_WithOtherPublisherAndGeneralsOnlineHints_ReturnsFalse(string publisherType) + { + // Arrange + var profile = CreateZeroHourProfile( + publisherType, + "GeneralsOnline Compatible", + ["1.9.generalsonline.gameclient.30hz"]); + + // Act & Assert + Assert.False(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile predating the recorded publisher type is still recognised by its + /// client name. Such a profile records no publisher at all, so null is its real shape. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlineClientName_ReturnsTrue(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "GeneralsOnline 30Hz", []); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile predating the recorded publisher type is still recognised by its + /// enabled content. Such a profile records no publisher at all, so null is its real shape. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlineContent_ReturnsTrue(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", ["1.9.generalsonline.gameclient.30hz"]); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile with no client at all, which is the shape the settings editor sees + /// while a profile is being created, falls back to its enabled content. + /// + /// The content the profile enables. + /// Whether that content makes it a GeneralsOnline profile. + [Theory] + [InlineData("1.9.generalsonline.gameclient.30hz", true)] + [InlineData("1.0.genhub.mod.test", false)] + public void IsGeneralsOnlineProfile_WithoutGameClient_FallsBackToContent(string contentId, bool expected) + { + // Arrange + var profile = new GameProfile + { + Id = "profile-1", + Name = "Test Profile", + EnabledContentIds = [contentId], + }; + + // Act & Assert + Assert.Equal(expected, profile.IsGeneralsOnlineProfile()); + } + + private static GameProfile CreateZeroHourProfile(string? publisherType, string clientName, List enabledContentIds) + { + return new GameProfile + { + Id = "profile-1", + Name = "Test Profile", + GameClient = new GameClient + { + Id = "client-1", + Name = clientName, + GameType = GameType.ZeroHour, + PublisherType = publisherType, + }, + EnabledContentIds = enabledContentIds, + }; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/BackgroundUpdateCoordinatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/BackgroundUpdateCoordinatorTests.cs new file mode 100644 index 000000000..9b24db6e5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/BackgroundUpdateCoordinatorTests.cs @@ -0,0 +1,647 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Messages; +using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Notifications; +using GenHub.Features.AppUpdate.Interfaces; +using GenHub.Features.AppUpdate.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.AppUpdate.Services; + +/// +/// Contains unit tests for the class. +/// +public class BackgroundUpdateCoordinatorTests +{ + /// + /// Verifies that when startup update check is disabled, no update checks are performed on initialize. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task InitializeAsync_WhenStartupCheckDisabled_DoesNotCheckUpdatesAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { AutoCheckForUpdatesOnStartup = false, AutoCheckForUpdatesPeriodically = false }); + var mockNotificationService = CreateNotificationServiceMock(); + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.InitializeAsync(); + + mockVelopack.Verify(x => x.CheckForUpdatesAsync(It.IsAny()), Times.Never); + mockVelopack.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.Never); + } + + /// + /// Verifies that when a subscribed PR is merged or closed, update checking falls back to the development branch artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenPrIsMerged_FallsBackToDevelopmentArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedPrNumber = 265, + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var devArtifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-development", + GitHash: "abc1234", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 265); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(_ => + { + if (mockVelopack.Object.SubscribedPrNumber == 265) + { + mockVelopack.SetupGet(x => x.IsPrMergedOrClosed).Returns(true); + return Task.FromResult(null); + } + + if (mockVelopack.Object.SubscribedBranch == AppUpdateConstants.DevelopmentBranch) + { + return Task.FromResult(devArtifactInfo); + } + + return Task.FromResult(null); + }); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Equal(AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle, updateNotification.Title); + Assert.True(updateNotification.IsPersistent); + Assert.True(updateNotification.ShowInBadge); + Assert.Contains("265", updateNotification.Message); + Assert.Single(updateNotification.Actions); + } + + /// + /// Verifies that when a subscribed custom branch has no artifacts, update checking falls back to the development branch artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenCustomBranchIsStale_FallsBackToDevelopmentArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedBranch = "feat/deleted-branch", + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var devArtifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-development", + GitHash: "abc1234", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, null); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/deleted-branch"); + + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(_ => + { + if (mockVelopack.Object.SubscribedBranch == AppUpdateConstants.DevelopmentBranch) + { + return Task.FromResult(devArtifactInfo); + } + + return Task.FromResult(null); + }); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Equal(AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle, updateNotification.Title); + Assert.True(updateNotification.IsPersistent); + Assert.True(updateNotification.ShowInBadge); + Assert.Contains("feat/deleted-branch", updateNotification.Message); + Assert.Single(updateNotification.Actions); + } + + /// + /// Verifies that when a custom branch has no artifacts and releases are checked via GitHub API, fallback notification is displayed. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenCustomBranchIsStaleAndNoArtifact_FallsBackToGitHubApiReleaseAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + SubscribedBranch = "feat/deleted-branch", + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, null); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/deleted-branch"); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync((ArtifactUpdateInfo?)null); + mockVelopack.Setup(x => x.CheckForUpdatesAsync(It.IsAny())) + .ReturnsAsync((Velopack.UpdateInfo?)null); + mockVelopack.SetupGet(x => x.HasUpdateAvailableFromGitHub).Returns(true); + mockVelopack.SetupGet(x => x.LatestVersionFromGitHub).Returns("1.5.0"); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Equal(AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle, updateNotification.Title); + Assert.Contains("1.5.0", updateNotification.Message); + } + + /// + /// Verifies that receiving an update settings changed message restarts the periodic timer without exception. + /// + [Fact] + public void Receive_UpdateSettingsChangedMessage_RestartsPeriodicTimerWithoutException() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + var mockNotificationService = CreateNotificationServiceMock(); + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + var message = new UpdateSettingsChangedMessage( + AutoCheckForUpdatesOnStartup: true, + AutoCheckForUpdatesPeriodically: true, + PeriodicUpdateCheckIntervalMinutes: 15); + + var exception = Record.Exception(() => coordinator.Receive(message)); + Assert.Null(exception); + } + + /// + /// Verifies that when an artifact update is available, the notification action installs the artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenArtifactUpdateAvailable_NotificationActionInstallsArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedPrNumber = 100, + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var artifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-pr100", + GitHash: "abc1234", + PullRequestNumber: 100, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 100); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync(artifactInfo); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.PrUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Single(updateNotification.Actions); + + // Execute the action to verify install + updateNotification.Actions[0].Callback?.Invoke(); + + mockVelopack.Verify( + x => x.InstallArtifactAsync(artifactInfo, It.IsAny>(), It.IsAny()), + Times.Once); + } + + /// + /// Verifies that checking updates repeatedly with the same version deduplicates notifications. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenSameUpdateCheckedRepeatedly_DeduplicatesNotificationAsync() + { + var showCount = 0; + + var userSettings = new UserSettings + { + SubscribedPrNumber = 100, + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var artifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-pr100", + GitHash: "abc1234", + PullRequestNumber: 100, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 100); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync(artifactInfo); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(_ => Interlocked.Increment(ref showCount)); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + await coordinator.CheckForUpdatesAsync(); + + Assert.Equal(1, showCount); + } + + /// + /// Verifies that cancellation token propagation cancels update checks. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + var mockNotificationService = CreateNotificationServiceMock(); + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await Assert.ThrowsAnyAsync(() => coordinator.CheckForUpdatesAsync(cts.Token)); + } + + /// + /// Verifies that executing the PR merged fallback notification action clears the subscribed PR setting and installs the dev artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenPrMerged_NotificationActionClearsSubscriptionAndInstallsDevArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedPrNumber = 265, + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + mockUserSettings.Setup(x => x.Update(It.IsAny>())) + .Callback>(action => action(userSettings)); + + var devArtifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-development", + GitHash: "abc1234", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 265); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(_ => + { + if (mockVelopack.Object.SubscribedPrNumber == 265) + { + mockVelopack.SetupGet(x => x.IsPrMergedOrClosed).Returns(true); + return Task.FromResult(null); + } + + if (mockVelopack.Object.SubscribedBranch == AppUpdateConstants.DevelopmentBranch) + { + return Task.FromResult(devArtifactInfo); + } + + return Task.FromResult(null); + }); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Single(updateNotification.Actions); + + // Execute action + updateNotification.Actions[0].Callback?.Invoke(); + + // Wait brief delay for async install and settings update + await Task.Delay(100); + + mockVelopack.Verify( + x => x.InstallArtifactAsync(devArtifactInfo, It.IsAny>(), It.IsAny()), + Times.Once); + Assert.Null(userSettings.SubscribedPrNumber); + mockUserSettings.Verify(x => x.SaveAsync(It.IsAny()), Times.Once); + } + + /// + /// Verifies that executing the stale branch fallback notification action clears the subscribed branch setting and installs the dev artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenCustomBranchStale_NotificationActionClearsSubscriptionAndInstallsDevArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedBranch = "feat/old-branch", + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + mockUserSettings.Setup(x => x.Update(It.IsAny>())) + .Callback>(action => action(userSettings)); + + var devArtifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-development", + GitHash: "abc1234", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, null); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/old-branch"); + + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(_ => + { + if (mockVelopack.Object.SubscribedBranch == AppUpdateConstants.DevelopmentBranch) + { + return Task.FromResult(devArtifactInfo); + } + + return Task.FromResult(null); + }); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Single(updateNotification.Actions); + + // Execute action + updateNotification.Actions[0].Callback?.Invoke(); + + await Task.Delay(100); + + mockVelopack.Verify( + x => x.InstallArtifactAsync(devArtifactInfo, It.IsAny>(), It.IsAny()), + Times.Once); + Assert.Null(userSettings.SubscribedBranch); + mockUserSettings.Verify(x => x.SaveAsync(It.IsAny()), Times.Once); + } + + /// + /// Verifies that Dispose can be safely called multiple times. + /// + [Fact] + public void Dispose_CanBeCalledMultipleTimesWithoutThrowing() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + var mockNotificationService = CreateNotificationServiceMock(); + var mockLogger = new Mock>(); + + var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + var exception = Record.Exception(() => + { + coordinator.Dispose(); + coordinator.Dispose(); + }); + + Assert.Null(exception); + } + + private static Mock CreateNotificationServiceMock() + { + var mock = new Mock(); + mock.Setup(x => x.Notifications).Returns(Observable.Empty()); + mock.Setup(x => x.NotificationHistory).Returns(Observable.Empty()); + mock.Setup(x => x.DismissRequests).Returns(Observable.Empty()); + mock.Setup(x => x.DismissAllRequests).Returns(Observable.Empty()); + mock.Setup(x => x.UpdateRequests).Returns(Observable.Empty<(Guid Id, string? Title, string Message)>()); + return mock; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs new file mode 100644 index 000000000..2c5db6ecf --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs @@ -0,0 +1,535 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Features.AppUpdate.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.AppUpdate.Services; + +/// +/// Unit tests for . +/// +public class FastHttpClientFileDownloaderTests : IDisposable +{ + private sealed class TestHttpMessageHandler(Func handlerFunc) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(handlerFunc(request)); + } + } + + private readonly Mock> _mockLogger = new(); + private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-downloader-tests-{Guid.NewGuid():N}"); + + /// + /// Initializes a new instance of the class. + /// + public FastHttpClientFileDownloaderTests() + { + Directory.CreateDirectory(_tempDirectory); + } + + /// + /// Disposes test resources and cleans up temporary directories. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + // Ignore test directory cleanup failures + } + } + } + + /// + /// Tests that the downloader can be initialized with and without a logger. + /// + [Fact] + public void Constructor_ShouldInitializeSuccessfully() + { + var downloaderWithoutLogger = new FastHttpClientFileDownloader(); + var downloaderWithLogger = new FastHttpClientFileDownloader(_mockLogger.Object); + + Assert.NotNull(downloaderWithoutLogger); + Assert.NotNull(downloaderWithLogger); + } + + /// + /// Tests that DownloadFile throws ArgumentException when URL is invalid. + /// + /// The invalid URL string. + /// A representing the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadFile_WithInvalidUrl_ShouldThrowArgumentExceptionAsync(string? invalidUrl) + { + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object); + var targetFile = Path.Combine(_tempDirectory, "test.tmp"); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile(invalidUrl!, targetFile, _ => { }, null, 30)); + } + + /// + /// Tests that DownloadFile throws ArgumentException when target file path is invalid. + /// + /// The invalid target file path string. + /// A representing the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadFile_WithInvalidTargetFile_ShouldThrowArgumentExceptionAsync(string? invalidTargetFile) + { + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile("https://example.com/file.zip", invalidTargetFile!, _ => { }, null, 30)); + } + + /// + /// Tests that parallel chunk downloading correctly assembles multi-chunk files and reports progress monotonically. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ParallelRange_ValidAssembly_ShouldDownloadAndVerifyContentAsync() + { + // 6 MB file (3 chunks of 2 MB) + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 3; + var sourceBytes = new byte[totalBytes]; + new Random(42).NextBytes(sourceBytes); + + var progressHistory = new ConcurrentQueue(); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe request + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + var length = (int)(to - from + 1); + var chunkData = new byte[length]; + Array.Copy(sourceBytes, from, chunkData, 0, length); + + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(chunkData), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "parallel-output.bin"); + + await downloader.DownloadFile( + "https://github.com/community-outpost/GenHub/releases/download/v1.0.0/test.bin", + targetFile, + progressHistory.Enqueue, + null, + 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + + var progressList = progressHistory.ToList(); + Assert.NotEmpty(progressList); + Assert.Equal(100, progressList.Last()); + } + + /// + /// Tests that small files below the parallel threshold use single-stream mode without chunking. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_SmallFileBelowThreshold_ShouldUseSingleStreamAsync() + { + var smallBytes = new byte[1024 * 1024]; // 1 MB + new Random(42).NextBytes(smallBytes); + + var chunkRequestsCount = 0; + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response indicates 1MB file + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([smallBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, smallBytes.Length) { Unit = "bytes" }; + return probeResponse; + } + + if (range is not null) + { + Interlocked.Increment(ref chunkRequestsCount); + } + + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(smallBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "small-file.bin"); + + await downloader.DownloadFile("https://example.com/small.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(smallBytes, downloadedBytes); + Assert.Equal(0, chunkRequestsCount); + } + + /// + /// Tests that when the server ignores range headers (returning 200 OK on probe), the downloader streams directly without error. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ServerIgnoresRange_ShouldStreamProbeResponseDirectlyAsync() + { + var fileBytes = new byte[1024 * 512]; // 512 KB + new Random(1337).NextBytes(fileBytes); + + var handler = new TestHttpMessageHandler(request => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(fileBytes), + RequestMessage = request, + }; + return response; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "ignored-range.bin"); + + await downloader.DownloadFile("https://example.com/file.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(fileBytes, downloadedBytes); + } + + /// + /// Tests that when a chunk response returns an invalid Content-Range header, the downloader falls back to single-stream. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_InvalidContentRange_ShouldFallbackToSingleStreamAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(77).NextBytes(sourceBytes); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is not null) + { + // Return mismatched Content-Range + var badResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[100]), + RequestMessage = request, + }; + badResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(999, 1098, totalBytes) { Unit = "bytes" }; + return badResponse; + } + + // Fallback path sends full payload + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "fallback-invalid-range.bin"); + + await downloader.DownloadFile("https://example.com/large.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + } + + /// + /// Tests that when a chunk response streams fewer bytes than requested, the downloader falls back to single-stream. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ShortChunkStream_ShouldFallbackToSingleStreamAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(99).NextBytes(sourceBytes); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + // Return short stream (100 bytes instead of expected chunk length) + var shortResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[100]), + RequestMessage = request, + }; + shortResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return shortResponse; + } + + // Fallback path sends full payload + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "fallback-short-chunk.bin"); + + await downloader.DownloadFile("https://example.com/large.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + } + + /// + /// Tests that progress reporting is strictly monotonic (never moves backward) and throttled to at most 101 updates. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ProgressReporting_ShouldBeStrictlyMonotonicAndThrottledAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 3; // 24 MB + var sourceBytes = new byte[totalBytes]; + + var progressHistory = new ConcurrentQueue(); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([0]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + var length = (int)(to - from + 1); + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[length]), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "progress-test.bin"); + + await downloader.DownloadFile("https://example.com/file.bin", targetFile, progressHistory.Enqueue, null, 30); + + var progressList = progressHistory.ToList(); + + Assert.NotEmpty(progressList); + Assert.Equal(100, progressList.Last()); + + // Verify strictly monotonic ordering (each progress event >= previous) + for (var i = 1; i < progressList.Count; i++) + { + Assert.True(progressList[i] >= progressList[i - 1], $"Progress moved backward from {progressList[i - 1]} to {progressList[i]}"); + } + + // Verify throttling: no more than 101 progress updates (0 to 100) + Assert.True(progressList.Count <= 101, $"Progress was called {progressList.Count} times, exceeding maximum throttled limit of 101"); + } + + /// + /// Tests that cancellation tokens are properly observed and propagated. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_WhenCancelled_ShouldThrowOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var handler = new TestHttpMessageHandler(request => new HttpResponseMessage(HttpStatusCode.OK)); + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "canceled.bin"); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile("https://example.com/file.bin", targetFile, _ => { }, null, 30, cts.Token)); + } + + /// + /// Tests that when redirected to a cross-origin storage host, the Authorization header is omitted from chunk requests. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_WhenRedirectedToCrossOriginCdn_ShouldStripAuthorizationHeaderOnChunksAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(42).NextBytes(sourceBytes); + + var chunkAuthHeadersPresent = 0; + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://cdn.blob.core.windows.net/artifacts/file.zip"), + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + if (request.Headers.Contains("Authorization")) + { + Interlocked.Increment(ref chunkAuthHeadersPresent); + } + + var length = (int)(to - from + 1); + var chunkData = new byte[length]; + Array.Copy(sourceBytes, from, chunkData, 0, length); + + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(chunkData), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "cross-origin-test.bin"); + var headers = new Dictionary + { + { "Authorization", "Bearer test_pat_token" }, + { "User-Agent", "GenHub" }, + }; + + await downloader.DownloadFile( + "https://api.github.com/repos/community-outpost/GenHub/actions/artifacts/123/zip", + targetFile, + _ => { }, + headers, + 30); + + Assert.True(File.Exists(targetFile)); + Assert.Equal(sourceBytes, await File.ReadAllBytesAsync(targetFile)); + Assert.Equal(0, chunkAuthHeadersPresent); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/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..a195175b0 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs @@ -1,8 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GitHub; +using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Common; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.AppUpdate.ViewModels; using Microsoft.Extensions.Logging; using Moq; +using Xunit; namespace GenHub.Tests.Core.Features.AppUpdate.ViewModels; @@ -23,7 +32,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 +52,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 +72,7 @@ public void Constructor_InitializesSuccessfully() public void IsCheckButtonEnabled_ReflectsCheckingState() { var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( Mock.Of(), @@ -72,4 +81,679 @@ public void IsCheckButtonEnabled_ReflectsCheckingState() Assert.True(vm.IsCheckButtonEnabled); } + + /// + /// Verifies that pull request display title formats properly with PR number and title. + /// + [Fact] + public void PullRequestInfo_DisplayTitle_ShouldIncludePrNumberAndTitle() + { + var prInfo = new PullRequestInfo + { + Number = 265, + Title = "feat: UI Downloads", + BranchName = "feat/ui-downloads", + Author = "developer", + State = "open", + UpdatedAt = DateTimeOffset.UtcNow, + }; + + Assert.Equal("#265 - feat: UI Downloads", prInfo.DisplayTitle); + } + + /// + /// Verifies that subscribing to a PR loads artifacts and auto-selects the latest version. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToPr_LoadsArtifactsAndAutoSelectsLatestVersionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var artifacts = new List + { + new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + new("0.0.1315-pr389", "a1b2c3d", 389, 1000, "https://github.com/test/run/0", 500, "genhub-velopack-linux-0.0.1315-pr389", DateTime.UtcNow.AddMinutes(-10), "https://github.com/test/art/0", 1024), + }; + + var loadTcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => loadTcs.TrySetCanceled(ct)); + return await loadTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToPrCommand.Execute(389); + + Assert.True(vm.IsLoadingVersions); + loadTcs.SetResult(artifacts); + + // wait briefly for async continuation + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.Equal(2, vm.AvailableVersions.Count); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version); + Assert.Equal("e1212a5", vm.SelectedVersion.GitHash); + Assert.True(vm.CanDownloadUpdate); + } + + /// + /// Verifies that subscribing to a branch loads artifacts and auto-selects the latest version. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_LoadsArtifactsAndAutoSelectsLatestVersionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var artifacts = new List + { + new("0.0.1320-development", "f4e3d2c", null, 2001, "https://github.com/test/run/2", 601, "genhub-velopack-linux-0.0.1320-development", DateTime.UtcNow, "https://github.com/test/art/2", 2048), + }; + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("development", It.IsAny())) + .ReturnsAsync(artifacts); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("development"); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1320-development", vm.SelectedVersion.Version); + } + + /// + /// Verifies that when switching PR subscriptions while a previous load is in flight, the old request is cancelled and only the new subscription artifacts are applied. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToPr_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewSubscriptionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var pr391Tcs = new TaskCompletionSource>(); + var pr389Tcs = new TaskCompletionSource>(); + + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => pr391Tcs.TrySetCanceled(ct)); + return await pr391Tcs.Task; + }); + + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => pr389Tcs.TrySetCanceled(ct)); + return await pr389Tcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + // subscribe to 391 first + vm.SubscribeToPrCommand.Execute(391); + Assert.True(vm.IsLoadingVersions); + + // immediately switch to 389 while 391 is loading + vm.SubscribeToPrCommand.Execute(389); + + // resolve 389 artifacts + var pr389Artifacts = new List + { + new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + }; + pr389Tcs.TrySetResult(pr389Artifacts); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(pr391Tcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version); + Assert.Equal(389, vm.SelectedVersion.PullRequestNumber); + } + + /// + /// Verifies that switching from a branch to another branch cancels the previous load and populates the new branch artifacts. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewBranchAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var branchOldTcs = new TaskCompletionSource>(); + var branchNewTcs = new TaskCompletionSource>(); + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("old-branch", It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + ct.Register(() => branchOldTcs.TrySetCanceled(ct)); + return await branchOldTcs.Task; + }); + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("new-branch", It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + ct.Register(() => branchNewTcs.TrySetCanceled(ct)); + return await branchNewTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("old-branch"); + Assert.True(vm.IsLoadingVersions); + + vm.SubscribeToBranchCommand.Execute("new-branch"); + + var newArtifacts = new List + { + new("0.0.1400-new-branch", "9998887", null, 3001, "https://github.com/test/run/3", 701, "genhub-velopack-linux-0.0.1400-new-branch", DateTime.UtcNow, "https://github.com/test/art/3", 2048), + }; + branchNewTcs.TrySetResult(newArtifacts); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(branchOldTcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1400-new-branch", vm.SelectedVersion.Version); + } + + /// + /// Verifies that unsubscribing cancels in-flight loads and clears available versions and selection. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task Unsubscribe_CancelsInFlightLoadsAndClearsAvailableVersionsAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var prTcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => prTcs.TrySetCanceled(ct)); + return await prTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToPrCommand.Execute(391); + Assert.True(vm.IsLoadingVersions); + + vm.UnsubscribeCommand.Execute(null); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while ((vm.IsLoadingVersions || vm.AvailableVersions.Count > 0) && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(prTcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Empty(vm.AvailableVersions); + Assert.Null(vm.SelectedVersion); + } + + /// + /// Verifies that OpenPullRequestUrlCommand executes without error for valid and invalid PR numbers. + /// + /// The PR number under test. + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void OpenPullRequestUrlCommand_ExecutesWithoutException(int prNumber) + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + // verify command execution does not throw + vm.OpenPullRequestUrlCommand.Execute(prNumber); + Assert.NotNull(vm); + } + + /// + /// Verifies that changing the sort option reorders available pull requests accordingly. + /// + [Fact] + public void SelectedSortOption_ReordersAvailablePullRequests() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + var now = DateTimeOffset.UtcNow; + var pr100 = new PullRequestInfo { Number = 100, Title = "PR 100", BranchName = "b1", Author = "a1", State = "open", UpdatedAt = now.AddDays(-2) }; + var pr200 = new PullRequestInfo { Number = 200, Title = "PR 200", BranchName = "b2", Author = "a2", State = "open", UpdatedAt = now.AddDays(-10) }; + var pr300 = new PullRequestInfo { Number = 300, Title = "PR 300", BranchName = "b3", Author = "a3", State = "open", UpdatedAt = now }; + + vm.AvailablePullRequests.Add(pr100); + vm.AvailablePullRequests.Add(pr200); + vm.AvailablePullRequests.Add(pr300); + + // sort by PR number descending + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberDesc; + Assert.Equal(300, vm.AvailablePullRequests[0].Number); + Assert.Equal(200, vm.AvailablePullRequests[1].Number); + Assert.Equal(100, vm.AvailablePullRequests[2].Number); + + // sort by PR number ascending + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberAsc; + Assert.Equal(100, vm.AvailablePullRequests[0].Number); + Assert.Equal(200, vm.AvailablePullRequests[1].Number); + Assert.Equal(300, vm.AvailablePullRequests[2].Number); + + // sort by last updated (newest first) + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionLastUpdated; + Assert.Equal(300, vm.AvailablePullRequests[0].Number); + Assert.Equal(100, vm.AvailablePullRequests[1].Number); + Assert.Equal(200, vm.AvailablePullRequests[2].Number); + } + + /// + /// Verifies that tab commands correctly switch between Update and Browse Builds tabs. + /// + [Fact] + public void TabCommands_UpdatesSelectedTabIndexAndIsBrowseTabSelected() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + vm.ShowBrowseBuildsTabCommand.Execute(null); + Assert.Equal(1, vm.SelectedTabIndex); + Assert.True(vm.IsBrowseTabSelected); + + vm.ShowUpdateTabCommand.Execute(null); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + vm.SelectTabCommand.Execute("1"); + Assert.Equal(1, vm.SelectedTabIndex); + Assert.True(vm.IsBrowseTabSelected); + + vm.SelectTabCommand.Execute(0); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + // Clamping out-of-range inputs + vm.SelectTabCommand.Execute(-1); + Assert.Equal(0, vm.SelectedTabIndex); + + vm.SelectTabCommand.Execute(5); + Assert.Equal(1, vm.SelectedTabIndex); + + vm.SelectTabCommand.Execute("99"); + Assert.Equal(1, vm.SelectedTabIndex); + } + + /// + /// Verifies that DisplayCurrentVersion and InstalledVersionDisplay return a valid non-empty version string. + /// + [Fact] + public void DisplayCurrentVersion_ReturnsNonEmptyVersion() + { + var displayVersion = UpdateNotificationViewModel.DisplayCurrentVersion; + Assert.False(string.IsNullOrWhiteSpace(displayVersion)); + Assert.StartsWith("v", displayVersion); + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(displayVersion, vm.InstalledVersionDisplay); + } + + /// + /// Verifies that setting SelectedVersion to a newer artifact updates StatusMessage and sets IsUpdateAvailable to true. + /// + [Fact] + public void SelectedVersion_WhenNewer_UpdatesStatusMessageAndIsUpdateAvailable() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + var newerArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024); + vm.SelectedVersion = newerArtifact; + + Assert.True(vm.IsUpdateAvailable); + Assert.Equal("0.0.99999-pr389", vm.LatestVersion); + Assert.Contains("0.0.99999-pr389", vm.StatusMessage); + } + + /// + /// Verifies that selecting an artifact matching dismissed version clears IsUpdateAvailable, LatestVersion, and ReleaseNotesUrl. + /// + [Fact] + public void SelectedVersion_WhenDismissed_ClearsUpdateAvailableState() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { DismissedUpdateVersion = "0.0.99999-pr389" }); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object) + { + IsUpdateAvailable = true, + LatestVersion = "0.0.88888", + ReleaseNotesUrl = "https://example.com/notes", + }; + + var dismissedArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024); + vm.SelectedVersion = dismissedArtifact; + + Assert.False(vm.IsUpdateAvailable); + Assert.Empty(vm.LatestVersion); + Assert.Empty(vm.ReleaseNotesUrl); + Assert.Contains("dismissed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that Unsubscribe resets subscription fields, clears update available state, and updates status message. + /// + [Fact] + public void Unsubscribe_ClearsArtifactUpdateStateAndSwitchesToMain() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 389 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 389); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object) + { + SubscribedPr = new PullRequestInfo + { + Number = 389, + Title = "Test PR", + BranchName = "feature/test", + Author = "testuser", + State = "open", + }, + SelectedVersion = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + IsUpdateAvailable = true, + LatestVersion = "0.0.99999-pr389", + ReleaseNotesUrl = "https://example.com/notes", + }; + + vm.UnsubscribeCommand.Execute(null); + + Assert.Null(vm.SubscribedPr); + Assert.Null(vm.SubscribedBranch); + Assert.Null(vm.SelectedVersion); + Assert.False(vm.IsUpdateAvailable); + Assert.Empty(vm.LatestVersion); + Assert.Empty(vm.ReleaseNotesUrl); + Assert.False(string.IsNullOrEmpty(vm.StatusMessage)); + Assert.Null(mockVelopack.Object.SubscribedPrNumber); + } + + /// + /// Verifies that InitializeAsync seeds SubscribedPr immediately from user settings. + /// + [Fact] + public void Constructor_WhenPrSubscribedInSettings_SeedsSubscribedPr() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 242 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(242, mockVelopack.Object.SubscribedPrNumber); + Assert.NotNull(vm.SubscribedPr); + Assert.Equal(242, vm.SubscribedPr.Number); + } + + /// + /// Verifies that when a subscribed PR is merged or closed, CheckForUpdates sets ShowPrMergedWarning and formats the status message. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForUpdatesCommand_WhenSubscribedPrIsMerged_ShowsMergedWarningAndStatusAsync() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 265 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 265); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync((ArtifactUpdateInfo?)null); + mockVelopack.SetupGet(x => x.IsPrMergedOrClosed).Returns(true); + mockVelopack.Setup(x => x.GetBranchesAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockVelopack.Setup(x => x.GetOpenPullRequestsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var mockTokenStorage = new Mock(); + mockTokenStorage.Setup(x => x.HasToken()).Returns(true); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object, + mockTokenStorage.Object); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); + + Assert.True(vm.ShowPrMergedWarning); + Assert.Contains("merged", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.False(vm.IsUpdateAvailable); + } + + /// + /// Verifies that when a subscribed custom branch has no artifacts and PAT is configured, CheckForUpdates sets stale branch status message. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForUpdatesCommand_WhenCustomBranchHasNoArtifactsAndPatPresent_SetsStaleStatusAsync() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "feat/deleted-branch" }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/deleted-branch"); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync((ArtifactUpdateInfo?)null); + mockVelopack.Setup(x => x.GetBranchesAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockVelopack.Setup(x => x.GetOpenPullRequestsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var mockTokenStorage = new Mock(); + mockTokenStorage.Setup(x => x.HasToken()).Returns(true); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object, + mockTokenStorage.Object); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); + + Assert.Contains("no available builds", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.False(vm.IsUpdateAvailable); + } + + /// + /// Verifies that when subscribed to a branch but no PAT is configured, CheckForUpdates sets PAT required status message. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForUpdatesCommand_WhenSubscribedBranchAndNoPat_SetsPatRequiredStatusAsync() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "feat/some-branch" }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/some-branch"); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object, + gitHubTokenStorage: null); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); + + Assert.Equal(AppUpdateConstants.PatRequiredForArtifactsMessage, vm.StatusMessage); + Assert.False(vm.IsUpdateAvailable); + } + + /// + /// Verifies that when subscribed to a PR but no PAT is configured, CheckForUpdates sets PAT required status message. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForUpdatesCommand_WhenSubscribedPrAndNoPat_SetsPatRequiredStatusAsync() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 42 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 42); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object, + gitHubTokenStorage: null); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); + + Assert.Equal(AppUpdateConstants.PatRequiredForArtifactsMessage, vm.StatusMessage); + Assert.False(vm.IsUpdateAvailable); + } + + /// + /// Verifies that when subscribing to a branch, the install button text changes to loading and download is disabled until artifacts finish loading. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_WhileLoading_DisablesDownloadAndShowsLoadingTextAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var tcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("feat/test", It.IsAny())) + .Returns(tcs.Task); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("feat/test"); + + Assert.True(vm.IsLoadingVersions); + Assert.True(vm.IsLoadingOrInstalling); + Assert.Equal("Loading...", vm.InstallButtonText); + Assert.False(vm.CanDownloadUpdate); + + tcs.SetResult([ + new ArtifactUpdateInfo("0.0.100-feat-test", "abcdef1", null, 100, "https://github.com/run/1", 10, "genhub.zip", DateTime.UtcNow, "https://github.com/art/1", 1024), + ]); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.False(vm.IsLoadingOrInstalling); + Assert.NotNull(vm.SelectedVersion); + Assert.True(vm.CanDownloadUpdate); + Assert.Equal("Install Update", vm.InstallButtonText); + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 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/Common/ArchivePayloadProcessorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs new file mode 100644 index 000000000..398f01d5c --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs @@ -0,0 +1,546 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; +using GenHub.Features.Content.Services.Common; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Common; + +/// +/// Unit tests for archive payload processing and directory structure normalization. +/// +public sealed class ArchivePayloadProcessorTests : IDisposable +{ + private readonly string _stagingDirectory = Path.Combine(Path.GetTempPath(), "GenHubPayloadTests", Guid.NewGuid().ToString("N")); + + /// + /// Verifies that extracting a valid ZIP archive unpacks all entries and removes the archive file. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ValidZip_ExtractsAllEntriesAndDeletesZipAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var zipPath = Path.Combine(_stagingDirectory, "test.zip"); + { + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + { + using var writer1 = new StreamWriter(archive.CreateEntry("Data/INI/GameData.ini").Open()); + await writer1.WriteAsync("GameData=1"); + } + + { + using var writer2 = new StreamWriter(archive.CreateEntry("Art/Textures/test.tga").Open()); + await writer2.WriteAsync("Texture"); + } + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory); + + // Assert + Assert.False(File.Exists(zipPath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "test.tga"))); + } + + /// + /// Verifies that multi-level nested wrapper directories (e.g. ModDB mods like C&C Generals Undone) + /// are recursively flattened so game assets end up directly at the workspace root. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MultiLevelSingleWrapper_FlattensToRootAsync() + { + // Arrange + var nestedDir = Path.Combine(_stagingDirectory, "C&C Generals Undone v1.0", "C&C Generals Undone v1.0"); + Directory.CreateDirectory(Path.Combine(nestedDir, "Art", "Textures")); + Directory.CreateDirectory(Path.Combine(nestedDir, "Data", "INI")); + Directory.CreateDirectory(Path.Combine(nestedDir, "Window")); + + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Readme.txt"), "Generals Undone Readme"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Art", "Textures", "test.tga"), "texture data"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Data", "INI", "GameData.ini"), "data"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Window", "MainMenu.wnd"), "window"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "test.tga"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Window", "MainMenu.wnd"))); + + // Old wrapper paths should no longer exist + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "C&C Generals Undone v1.0"))); + } + + /// + /// Verifies that loose documentation files at root alongside a single mod wrapper directory + /// are reconciled by promoting the mod contents to the root and keeping the documentation files. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_LooseReadmeWithModWrapper_FlattensModWrapperAlongsideReadmeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"), "Important instructions"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "ModDB_Link.url"), "https://www.moddb.com"); + + var modDir = Path.Combine(_stagingDirectory, "GeneralsUndone"); + Directory.CreateDirectory(Path.Combine(modDir, "Data", "INI")); + Directory.CreateDirectory(Path.Combine(modDir, "Art", "Textures")); + await File.WriteAllTextAsync(Path.Combine(modDir, "Data", "INI", "GameData.ini"), "inidata"); + await File.WriteAllTextAsync(Path.Combine(modDir, "Art", "Textures", "unit.tga"), "tgadata"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "ModDB_Link.url"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "unit.tga"))); + Assert.False(Directory.Exists(modDir)); + } + + /// + /// Verifies that game-specific subdirectories matching the target game (e.g. "Zero Hour") + /// are promoted to the payload root. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_GameSpecificSubdirectory_PromotesMatchingTargetGameFolderAsync() + { + // Arrange + var zhDir = Path.Combine(_stagingDirectory, "Zero Hour", "Data", "INI"); + Directory.CreateDirectory(zhDir); + await File.WriteAllTextAsync(Path.Combine(zhDir, "ZHData.ini"), "zh config"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "ZHData.ini"))); + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "Zero Hour"))); + } + + /// + /// Verifies that single map directories for ContentType.Map are preserved with their map folder. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MapContent_PreservesSingleMapDirectoryAsync() + { + // Arrange + var mapDir = Path.Combine(_stagingDirectory, "Lemuria"); + Directory.CreateDirectory(mapDir); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.map"), "map payload"); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.tga"), "preview payload"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Map, GameType.ZeroHour); + + // Assert + Assert.True(Directory.Exists(mapDir)); + Assert.True(File.Exists(Path.Combine(mapDir, "Lemuria.map"))); + Assert.True(File.Exists(Path.Combine(mapDir, "Lemuria.tga"))); + } + + /// + /// Verifies that double-wrapped map archives (e.g. MapDownload/MapName/MapName.map) + /// strip only the outer wrapper while preserving the inner map folder. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MapContentWithDoubleWrapper_FlattensOuterWrapperOnlyAsync() + { + // Arrange + var outerWrapper = Path.Combine(_stagingDirectory, "MapDownloadWrapper"); + var mapDir = Path.Combine(outerWrapper, "Lemuria"); + Directory.CreateDirectory(mapDir); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.map"), "map payload"); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.tga"), "preview payload"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Map, GameType.ZeroHour); + + // Assert + Assert.False(Directory.Exists(outerWrapper)); + Assert.True(Directory.Exists(Path.Combine(_stagingDirectory, "Lemuria"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Lemuria", "Lemuria.map"))); + } + + /// + /// Verifies that system junk files (.DS_Store, Thumbs.db, desktop.ini, __MACOSX) + /// are purged during normalization. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_PurgesSystemJunkAsync() + { + // Arrange + Directory.CreateDirectory(Path.Combine(_stagingDirectory, "__MACOSX")); + Directory.CreateDirectory(Path.Combine(_stagingDirectory, "Data")); + + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, ".DS_Store"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Thumbs.db"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "desktop.ini"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "__MACOSX", "._something"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Data", "GameData.ini"), "real data"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.False(File.Exists(Path.Combine(_stagingDirectory, ".DS_Store"))); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "Thumbs.db"))); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "desktop.ini"))); + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "__MACOSX"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "GameData.ini"))); + } + + /// + /// Verifies that an HTML error page pretending to be an archive is rejected. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_HtmlErrorPayload_ThrowsInvalidDataExceptionAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var fakeZip = Path.Combine(_stagingDirectory, "broken.zip"); + await File.WriteAllTextAsync(fakeZip, "Error 404 Not Found"); + + var processor = CreateProcessor(); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => processor.ExtractArchivesSafelyAsync(_stagingDirectory)); + Assert.Contains("HTML", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that a self-extracting .exe archive for a Mod is extracted safely and the source .exe is removed. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_SelfExtractingExeMod_ExtractsAndDeletesExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var sfxExePath = Path.Combine(_stagingDirectory, "ShockWaveV1201.exe"); + using (var archive = ZipFile.Open(sfxExePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("!ShockWave.big"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("BIG data payload"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod); + + // Assert + Assert.False(File.Exists(sfxExePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShockWave.big"))); + } + + /// + /// Verifies that executable files for tools or executables are never extracted or deleted even if they are zip containers. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ExecutableTool_DoesNotExtractOrDeleteExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var toolExePath = Path.Combine(_stagingDirectory, "WorldBuilder.exe"); + using (var archive = ZipFile.Open(toolExePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("internal.dll"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("dll"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.ModdingTool); + + // Assert: Tool executable is preserved intact and NOT extracted + Assert.True(File.Exists(toolExePath)); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "internal.dll"))); + } + + /// + /// Verifies that non-archive game.dat files are skipped and preserved. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_GameDatBinary_PreservedWithoutThrowingAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var gameDatPath = Path.Combine(_stagingDirectory, "game.dat"); + await File.WriteAllTextAsync(gameDatPath, "MZ_Binary_Executable_Payload_Not_Archive"); + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Patch); + + // Assert + Assert.True(File.Exists(gameDatPath)); + } + + /// + /// Verifies that valid .dat archives (e.g. 10zh.dat) are extracted. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ValidDatArchive_ExtractsAndDeletesDatAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var datArchivePath = Path.Combine(_stagingDirectory, "10zh.dat"); + using (var archive = ZipFile.Open(datArchivePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("ZH/game.dat"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("ZH game binary"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Patch); + + // Assert + Assert.False(File.Exists(datArchivePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "ZH", "game.dat"))); + } + + /// + /// Verifies that inactive .gib mod files are renamed to .big during normalization. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_GibFiles_NormalizesToBigAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var gibPath = Path.Combine(_stagingDirectory, "!ShwAudio.gib"); + await File.WriteAllTextAsync(gibPath, "Audio BIG payload"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.False(File.Exists(gibPath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShwAudio.big"))); + } + + /// + /// Verifies that self-extracting executable archives (e.g. ShockWaveV1201.exe with PE header followed by ZIP central directory) + /// are detected and extracted safely for mod content types. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_SelfExtractingExeArchive_ExtractsAndDeletesExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var sfxExePath = Path.Combine(_stagingDirectory, "ShockWaveV1201.exe"); + + using (var memoryStream = new MemoryStream()) + { + var peHeader = new byte[512]; + peHeader[0] = 0x4D; // 'M' + peHeader[1] = 0x5A; // 'Z' + memoryStream.Write(peHeader, 0, peHeader.Length); + + using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + { + var entry1 = zipArchive.CreateEntry("Data/INI/ShockWave.ini"); + using var writer1 = new StreamWriter(entry1.Open()); + await writer1.WriteAsync("ModName=ShockWave"); + } + + { + var entry2 = zipArchive.CreateEntry("!ShwAudio.gib"); + using var writer2 = new StreamWriter(entry2.Open()); + await writer2.WriteAsync("Audio content"); + } + } + + await File.WriteAllBytesAsync(sfxExePath, memoryStream.ToArray()); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod); + + // Assert + Assert.False(File.Exists(sfxExePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "ShockWave.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShwAudio.gib"))); + } + + /// + /// Verifies that Smart Install Maker SFX executables (e.g. ShockWave) are safely extracted and normalized. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_WithSmartInstallMakerExecutable_ExtractsAndNormalizesSuccessfully() + { + var casPath = @"A:\Steam\steamapps\common\.genhub-cas\objects\f4\f45e14d6b4a1e6e6feaa2ad737528b385586ad81ab7535bf9a330972db834c4e"; + if (!File.Exists(casPath)) + { + return; + } + + var testDir = Path.Combine(_stagingDirectory, "sim_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testDir); + + var installerPath = Path.Combine(testDir, "ShockWaveV1201.exe"); + File.Copy(casPath, installerPath, overwrite: true); + + var processor = CreateProcessor(); + + // 1. Extract archive safely + await processor.ExtractArchivesSafelyAsync(testDir, ContentType.Mod); + + // 2. Original installer .exe should have been deleted after extraction + Assert.False(File.Exists(installerPath), "Installer executable should be removed after successful extraction."); + + // 3. Normalize directory structure + await processor.NormalizeDirectoryStructureAsync(testDir, ContentType.Mod, GameType.ZeroHour); + + // 4. Verify extracted and normalized game files exist with full uncompressed size + var textureBigPath = Path.Combine(testDir, "!ShwTextures.big"); + Assert.True(File.Exists(textureBigPath), "Expected !ShwTextures.big to exist after normalization."); + var textureInfo = new FileInfo(textureBigPath); + Assert.True(textureInfo.Length > 60_000_000, $"Expected full textures >60MB, got {textureInfo.Length} bytes."); + + Assert.True( + File.Exists(Path.Combine(testDir, "!!0ShwPtchIcon.big")), + "Expected !!0ShwPtchIcon.big to exist."); + Assert.True( + File.Exists(Path.Combine(testDir, "!ShwAudio.big")), + "Expected !ShwAudio.big to exist."); + Assert.True( + File.Exists(Path.Combine(testDir, "ShockWaveLauncher.exe")), + "Expected ShockWaveLauncher.exe to exist."); + } + + /// + /// Verifies that payloads containing nested archives exceeding maximum extraction depth throw InvalidDataException. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ExceedsMaxNestedDepth_ThrowsInvalidDataExceptionAsync() + { + // Arrange: create 6 layers of nested zips + Directory.CreateDirectory(_stagingDirectory); + var currentZip = Path.Combine(_stagingDirectory, "nested_level_6.zip"); + { + using var archive = ZipFile.Open(currentZip, ZipArchiveMode.Create); + using var writer = new StreamWriter(archive.CreateEntry("Data/test.ini").Open()); + await writer.WriteAsync("data=1"); + } + + for (var i = 5; i >= 1; i--) + { + var nextZip = Path.Combine(_stagingDirectory, $"nested_level_{i}.zip"); + using (var archive = ZipFile.Open(nextZip, ZipArchiveMode.Create)) + { + archive.CreateEntryFromFile(currentZip, Path.GetFileName(currentZip)); + } + + File.Delete(currentZip); + currentZip = nextZip; + } + + var processor = CreateProcessor(); + + // Act & Assert + await Assert.ThrowsAsync(() => + processor.ExtractArchivesSafelyAsync(_stagingDirectory)); + } + + /// + /// Verifies that wrapper promotion with colliding files preserving both files when content differs. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_WrapperCollisionWithDifferentContent_PreservesBothFilesAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var wrapperDir = Path.Combine(_stagingDirectory, "WrapperFolder"); + Directory.CreateDirectory(Path.Combine(wrapperDir, "Data")); + + // File at root + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"), "Root Readme content"); + + // File inside wrapper with same name but different content + await File.WriteAllTextAsync(Path.Combine(wrapperDir, "Readme.txt"), "Wrapper Readme content"); + await File.WriteAllTextAsync(Path.Combine(wrapperDir, "Data", "GameData.ini"), "data=1"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme_1.txt"))); + var rootText = await File.ReadAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt")); + var wrapperText = await File.ReadAllTextAsync(Path.Combine(_stagingDirectory, "Readme_1.txt")); + Assert.Contains("Readme content", rootText); + Assert.Contains("Readme content", wrapperText); + Assert.NotEqual(rootText, wrapperText); + } + + /// + public void Dispose() + { + if (Directory.Exists(_stagingDirectory)) + { + Directory.Delete(_stagingDirectory, recursive: true); + } + } + + private static ArchivePayloadProcessor CreateProcessor() + { + return new ArchivePayloadProcessor(new Mock>().Object); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs index c66a496be..c3d836dd0 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs @@ -1,4 +1,5 @@ using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.CommunityOutpost; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; @@ -22,6 +23,7 @@ public class CommunityOutpostManifestFactoryTests : IDisposable { private readonly Mock> _loggerMock; private readonly Mock _hashProviderMock; + private readonly Mock _controlBarProcessorMock; private readonly CommunityOutpostManifestFactory _factory; private readonly string _tempDir; @@ -32,11 +34,12 @@ public CommunityOutpostManifestFactoryTests() { _loggerMock = new Mock>(); _hashProviderMock = new Mock(); + _controlBarProcessorMock = new Mock(); _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) .ReturnsAsync("abc123hash"); - _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, null!); + _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, _controlBarProcessorMock.Object); _tempDir = Path.Combine(Path.GetTempPath(), "GenHubTest_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } @@ -64,15 +67,19 @@ public async Task CreateManifestsFromExtractedContentAsync_WithHleiPackage_Shoul // Arrange var zhEnDir = Path.Combine(_tempDir, "ZH", "BIG EN"); var zhDeDir = Path.Combine(_tempDir, "ZH", "BIG DE"); + var zhRuDir = Path.Combine(_tempDir, "ZH", "BIG RU"); var ccgEnDir = Path.Combine(_tempDir, "CCG", "BIG EN"); Directory.CreateDirectory(zhEnDir); Directory.CreateDirectory(zhDeDir); + Directory.CreateDirectory(zhRuDir); Directory.CreateDirectory(ccgEnDir); File.WriteAllText(Path.Combine(zhEnDir, "!HotkeysLeikezeENZH.big"), "mock content"); File.WriteAllText(Path.Combine(zhDeDir, "!HotkeysLeikezeDEZH.big"), "mock content"); + File.WriteAllText(Path.Combine(zhRuDir, "!HotkeysLeikezeRUZH.big"), "mock content"); File.WriteAllText(Path.Combine(ccgEnDir, "!HotkeysLeikezeEN.big"), "mock content"); + File.WriteAllText(Path.Combine(_tempDir, "!HotkeysLeikezeIndicatorsZH.big"), "mock indicator"); var originalManifest = new ContentManifest { @@ -90,23 +97,31 @@ public async Task CreateManifestsFromExtractedContentAsync_WithHleiPackage_Shoul var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir); // Assert - Assert.Equal(3, manifests.Count); + Assert.Equal(4, manifests.Count); var zhEnManifest = manifests.FirstOrDefault(m => m.Id.Value.Contains("-zerohour-en")); Assert.NotNull(zhEnManifest); Assert.Equal(GameType.ZeroHour, zhEnManifest.TargetGame); Assert.Contains("(EN)", zhEnManifest.Name); - Assert.Single(zhEnManifest.Files); + Assert.Equal(2, zhEnManifest.Files.Count); var zhDeManifest = manifests.FirstOrDefault(m => m.Id.Value.Contains("-zerohour-de")); Assert.NotNull(zhDeManifest); Assert.Equal(GameType.ZeroHour, zhDeManifest.TargetGame); Assert.Contains("(DE)", zhDeManifest.Name); + Assert.Equal(2, zhDeManifest.Files.Count); + + var zhRuManifest = manifests.FirstOrDefault(m => m.Id.Value.Contains("-zerohour-ru")); + Assert.NotNull(zhRuManifest); + Assert.Equal(GameType.ZeroHour, zhRuManifest.TargetGame); + Assert.Contains("(RU)", zhRuManifest.Name); + Assert.Equal(2, zhRuManifest.Files.Count); var ccgEnManifest = manifests.FirstOrDefault(m => m.Id.Value.Contains("-generals-en")); Assert.NotNull(ccgEnManifest); Assert.Equal(GameType.Generals, ccgEnManifest.TargetGame); Assert.Contains("[Generals]", ccgEnManifest.Name); + Assert.Single(ccgEnManifest.Files); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs new file mode 100644 index 000000000..5b223f092 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results.Content; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.CommunityOutpost; + +/// +/// Tests for CommunityOutpostResolver to verify manifest generation and ingestion gate compatibility. +/// +public class CommunityOutpostResolverTests +{ + private readonly Mock _providerLoaderMock; + + /// + /// Initializes a new instance of the class. + /// + public CommunityOutpostResolverTests() + { + _providerLoaderMock = new Mock(); + + var providerDefinition = new ProviderDefinition + { + ProviderId = CommunityOutpostConstants.PublisherId, + PublisherType = CommunityOutpostConstants.PublisherType, + DisplayName = CommunityOutpostConstants.PublisherName, + Endpoints = new ProviderEndpoints + { + WebsiteUrl = "https://legi.cc", + }, + }; + + _providerLoaderMock + .Setup(l => l.GetProvider(CommunityOutpostConstants.PublisherId)) + .Returns(providerDefinition); + } + + /// + /// Verifies that Community Patch resolution creates a manifest with format version 1 that passes ManifestIngestionGate. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ResolveAsync_CommunityPatch_GeneratesManifestAcceptedByIngestionGateAsync() + { + // Arrange + var builderMock = CreateBuilderMock( + ManifestId.Create("1.20260827.communityoutpost.gameclient.communitypatch"), + "Community Patch (TheSuperHackers Build)", + "27-08-2026", + ContentType.GameClient, + GameType.ZeroHour); + + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + var searchResult = new ContentSearchResult + { + Id = "1.20260827.communityoutpost.gameclient.community-patch", + Name = "Community Patch (TheSuperHackers Build)", + Version = "27-08-2026", + SourceUrl = "https://legi.cc/patch/generalszh_27-08-2026_NonRet.zip", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + }; + searchResult.ResolverMetadata["contentCode"] = "community-patch"; + + // Act + var result = await resolver.ResolveAsync(searchResult); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + + builderMock.Verify( + m => m.WithBasicInfo( + CommunityOutpostConstants.PublisherType, + "community-patch", + "20260827"), + Times.Once); + + var manifest = result.Data; + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + Assert.Equal("27-08-2026", manifest.Version); + Assert.Equal(ContentType.GameClient, manifest.ContentType); + Assert.Equal(GameType.ZeroHour, manifest.TargetGame); + + // Ingestion gate must accept the manifest + var accepted = ManifestIngestionGate.TryAccept(manifest, out var rejectionReason); + Assert.True(accepted, $"Manifest should be accepted by ManifestIngestionGate, but was rejected with: {rejectionReason}"); + Assert.Null(rejectionReason); + } + + /// + /// Verifies that resolving base game clients produces manifests with DefaultManifestVersion format. + /// + /// The content code under test. + /// The expected display name. + /// The patch version string. + /// The expected numeric version for manifest ID. + /// The expected content name for manifest ID. + /// The expected game type. + /// A task representing the asynchronous unit test. + [Theory] + [InlineData("104e", "Zero Hour 1.04 (English)", "1.04", "104", "patch104english", GameType.ZeroHour)] + [InlineData("108e", "Generals 1.08 (English)", "1.08", "108", "patch108english", GameType.Generals)] + public async Task ResolveAsync_BaseGamePatch_GeneratesManifestAcceptedByIngestionGateAsync( + string contentCode, + string expectedName, + string version, + string expectedNumericVersion, + string expectedContentName, + GameType expectedGame) + { + // Arrange + var builderMock = CreateBuilderMock( + ManifestId.Create($"1.{expectedNumericVersion}.communityoutpost.gameclient.{expectedContentName}"), + expectedName, + version, + ContentType.GameClient, + expectedGame); + + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + var searchResult = new ContentSearchResult + { + Id = $"1.{expectedNumericVersion}.communityoutpost.gameclient.{expectedContentName}", + Name = expectedName, + Version = version, + SourceUrl = $"https://legi.cc/gp2/files/{contentCode}.dat", + ContentType = ContentType.GameClient, + TargetGame = expectedGame, + }; + searchResult.ResolverMetadata["contentCode"] = contentCode; + + // Act + var result = await resolver.ResolveAsync(searchResult); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + + builderMock.Verify( + m => m.WithBasicInfo( + CommunityOutpostConstants.PublisherType, + expectedContentName, + expectedNumericVersion), + Times.Once); + + var manifest = result.Data; + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + Assert.Equal(version, manifest.Version); + + var accepted = ManifestIngestionGate.TryAccept(manifest, out var rejectionReason); + Assert.True(accepted, $"Manifest should be accepted by ManifestIngestionGate, but was rejected with: {rejectionReason}"); + Assert.Null(rejectionReason); + } + + /// + /// Verifies that resolving addons like GenTool produces valid manifests that pass ManifestIngestionGate. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ResolveAsync_AddonContent_GeneratesManifestAcceptedByIngestionGateAsync() + { + // Arrange + var builderMock = CreateBuilderMock( + ManifestId.Create("1.1.communityoutpost.addon.gent"), + "GenTool", + "8.8", + ContentType.Addon, + GameType.ZeroHour); + + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + var searchResult = new ContentSearchResult + { + Id = "1.1.communityoutpost.addon.gent", + Name = "GenTool", + Version = "8.8", + SourceUrl = "https://legi.cc/gp2/files/gent.dat", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + }; + searchResult.ResolverMetadata["contentCode"] = "gent"; + + // Act + var result = await resolver.ResolveAsync(searchResult); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + + var manifest = result.Data; + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + + var accepted = ManifestIngestionGate.TryAccept(manifest, out var rejectionReason); + Assert.True(accepted, $"Manifest should be accepted by ManifestIngestionGate, but was rejected with: {rejectionReason}"); + Assert.Null(rejectionReason); + } + + private static Mock CreateBuilderMock( + ManifestId manifestId, + string name, + string version, + ContentType contentType, + GameType targetGame) + { + var manifest = new ContentManifest + { + Id = manifestId, + Name = name, + Version = version, + ContentType = contentType, + TargetGame = targetGame, + ManifestVersion = ManifestConstants.DefaultManifestVersion, + }; + + var builderMock = new Mock(); + builderMock.Setup(m => m.WithBasicInfo(It.IsAny(), It.IsAny(), It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.WithContentType(It.IsAny(), It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.WithPublisher(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.WithMetadata(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny?>(), It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.WithInstallationInstructions(It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.AddDependency( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny?>())).Returns(builderMock.Object); + builderMock.Setup(m => m.AddRemoteFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())).ReturnsAsync(builderMock.Object); + builderMock.Setup(m => m.Build()).Returns(manifest); + + return builderMock; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs index 27bb0ead2..673492aaf 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs @@ -21,6 +21,8 @@ public class GenPatcherContentRegistryTests [Theory] [InlineData("gent", "GenTool", ContentType.Addon, GameType.ZeroHour)] [InlineData("gena", "GenAssist", ContentType.Addon, GameType.ZeroHour)] + [InlineData("ewba", "Enhanced World Builder (Advanced)", ContentType.Addon, GameType.ZeroHour)] + [InlineData("ewbi", "Enhanced World Builder (International)", ContentType.Addon, GameType.ZeroHour)] [InlineData("10gn", "Generals 1.08", ContentType.GameClient, GameType.Generals)] [InlineData("10zh", "Zero Hour 1.04", ContentType.GameClient, GameType.ZeroHour)] [InlineData("cbbs", "Control Bar HD (Base)", ContentType.Addon, GameType.ZeroHour)] @@ -185,6 +187,7 @@ public void GetKnownContentCodes_ReturnsNonEmptyCollection() [InlineData("crzh", GenPatcherContentCategory.Camera)] [InlineData("hlen", GenPatcherContentCategory.Hotkeys)] [InlineData("gent", GenPatcherContentCategory.Tools)] + [InlineData("ewba", GenPatcherContentCategory.Tools)] [InlineData("maod", GenPatcherContentCategory.Maps)] [InlineData("icon", GenPatcherContentCategory.Visuals)] [InlineData("vc05", GenPatcherContentCategory.Prerequisites)] @@ -226,4 +229,39 @@ public void GetMetadata_RecognizesAllLanguageSuffixes(char suffix, string expect Assert.Equal(expectedLanguageCode, metadata.LanguageCode); Assert.Equal(ContentType.Patch, metadata.ContentType); } + + /// + /// Verifies that Leikeze's Hotkeys metadata defines valid variants with output filenames. + /// + [Fact] + public void GetMetadata_HleiVariants_DefineOutputFilenames() + { + // Act + var metadata = GenPatcherContentRegistry.GetMetadata("hlei"); + + // Assert + Assert.True(metadata.SupportsVariants); + Assert.NotNull(metadata.Variants); + Assert.Equal(4, metadata.Variants.Count); + + var zhEn = metadata.Variants.FirstOrDefault(v => v.Id == "zerohour-en"); + Assert.NotNull(zhEn); + Assert.Equal("!HotkeysLeikezeENZH.big", zhEn.OutputFilename); + Assert.Equal(GameType.ZeroHour, zhEn.TargetGame); + + var zhDe = metadata.Variants.FirstOrDefault(v => v.Id == "zerohour-de"); + Assert.NotNull(zhDe); + Assert.Equal("!HotkeysLeikezeDEZH.big", zhDe.OutputFilename); + Assert.Equal(GameType.ZeroHour, zhDe.TargetGame); + + var zhRu = metadata.Variants.FirstOrDefault(v => v.Id == "zerohour-ru"); + Assert.NotNull(zhRu); + Assert.Equal("!HotkeysLeikezeRUZH.big", zhRu.OutputFilename); + Assert.Equal(GameType.ZeroHour, zhRu.TargetGame); + + var ccgEn = metadata.Variants.FirstOrDefault(v => v.Id == "generals-en"); + Assert.NotNull(ccgEn); + Assert.Equal("!HotkeysLeikezeEN.big", ccgEn.OutputFilename); + Assert.Equal(GameType.Generals, ccgEn.TargetGame); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs index f90c1d1d5..165e2c1f1 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs @@ -158,8 +158,8 @@ public void GetDependencies_Prerequisites_HasNoDependencies(string contentCode) [Theory] [InlineData("hlen")] [InlineData("hlde")] - [InlineData("ewba")] - [InlineData("ewbi")] + [InlineData("hleg")] + [InlineData("hlei")] public void GetDependencies_Hotkeys_RequiresZeroHour104(string contentCode) { // Arrange @@ -275,7 +275,7 @@ public void GetConflictingCodes_Hotkeys_ReturnsOtherHotkeys() Assert.DoesNotContain("hleg", conflicts); // Should not conflict with itself Assert.Contains("hlde", conflicts); Assert.Contains("hlei", conflicts); - Assert.Contains("ewba", conflicts); + Assert.DoesNotContain("ewba", conflicts); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs index 5422334ce..9bc16d292 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,122 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate await Assert.ThrowsAnyAsync( () => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token)); } + + /// + /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_DeduplicatesResultsById_PrefersSpecializedProviderOverGitHubAsync() + { + // Arrange + var specializedProviderMock = new Mock(); + var githubProviderMock = new Mock(); + + const string duplicateId = "1.0.thesuperhackers.patch.generalsgamepatch2"; + + var specializedResult = new ContentSearchResult + { + Id = duplicateId, + Name = "TheSuperHackers Patch 2", + ProviderName = "thesuperhackers", + }; + + var githubResult = new ContentSearchResult + { + Id = duplicateId, + Name = "GeneralsGamePatch2", + ProviderName = "GitHub", + }; + + specializedProviderMock.Setup(p => p.IsEnabled).Returns(true); + specializedProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([specializedResult])); + + githubProviderMock.Setup(p => p.IsEnabled).Returns(true); + githubProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([githubResult])); + + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [githubProviderMock.Object, specializedProviderMock.Object], + [], + [], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object); + + // Act + var result = await orchestrator.SearchAsync(new ContentSearchQuery()); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal("thesuperhackers", items[0].ProviderName); + Assert.Equal("TheSuperHackers Patch 2", items[0].Name); + } + + /// + /// Verifies that ResolveManifestAsync successfully matches resolvers across hyphen and case variations. + /// + /// The resolver ID variant to test. + /// A representing the asynchronous operation. + [Theory] + [InlineData("community-outpost")] + [InlineData("communityoutpost")] + [InlineData("community_outpost")] + [InlineData("COMMUNITY-OUTPOST")] + [InlineData("COMMUNITYOUTPOST")] + [InlineData("COMMUNITY_OUTPOST")] + public async Task ResolveManifestAsync_MatchesResolverWithHyphenAndCaseVariationsAsync(string searchResolverId) + { + // Arrange + var resolverMock = new Mock(); + resolverMock.Setup(r => r.ResolverId).Returns("community-outpost"); + + var searchResult = new ContentSearchResult + { + Id = "1.0.communityoutpost.addon.gent", + Name = "GenTool", + ResolverId = searchResolverId, + }; + + var manifest = new ContentManifest + { + Id = searchResult.Id, + Name = searchResult.Name, + ContentType = ContentType.Addon, + }; + + resolverMock + .Setup(r => r.ResolveAsync(searchResult, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + _contentValidatorMock + .Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(new ValidationResult(manifest.Id, [])); + + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [], + [], + [resolverMock.Object], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object); + + // Act + var result = await orchestrator.ResolveManifestAsync(searchResult); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("GenTool", result.Data.Name); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs new file mode 100644 index 000000000..80ba8cec5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.Content; +using GenHub.Features.Content.Services.ContentProviders; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content; + +/// +/// Unit tests for . +/// +public class CsvContentProviderTests +{ + /// + /// Verifies that the constructor throws when no matching discoverer is registered. + /// + [Fact] + public void Constructor_WhenDiscovererMissing_ThrowsInvalidOperationException() + { + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var act = () => new CsvContentProvider( + [], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + Mock.Of(), + Mock.Of()); + + act.Should().Throw() + .WithMessage("*CSV discoverer not found*"); + } + + /// + /// Verifies that the constructor throws when no matching resolver is registered. + /// + [Fact] + public void Constructor_WhenResolverMissing_ThrowsInvalidOperationException() + { + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var act = () => new CsvContentProvider( + [mockDiscoverer.Object], + [], + [mockDeliverer.Object], + Mock.Of>(), + Mock.Of(), + Mock.Of()); + + act.Should().Throw() + .WithMessage("*CSV resolver not found*"); + } + + /// + /// Verifies that the constructor throws when no deliverer is registered. + /// + [Fact] + public void Constructor_WhenDelivererMissing_ThrowsInvalidOperationException() + { + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + + var act = () => new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [], + Mock.Of>(), + Mock.Of(), + Mock.Of()); + + act.Should().Throw() + .WithMessage("*deliverer not found*"); + } + + /// + /// Verifies that returns the expected publisher type constant. + /// + [Fact] + public void SourceName_ReturnsExpectedPublisherType() + { + var provider = CreateProvider(); + + provider.SourceName.Should().Be(PublisherTypeConstants.CsvRegistry); + } + + /// + /// Verifies that returns the expected description constant. + /// + [Fact] + public void Description_ReturnsExpectedDescription() + { + var provider = CreateProvider(); + + provider.Description.Should().Be(CsvConstants.Description); + } + + /// + /// Verifies that coordinates discovery, resolution, and validation. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task SearchAsync_ExecutesDiscoveryAndResolutionSuccessfullyAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + + var discoveredItem = new ContentSearchResult + { + Id = manifestId, + Name = "Generals 1.08 (EN)", + RequiresResolution = true, + ResolverId = CsvConstants.ResolverId, + }; + + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + mockDiscoverer.Setup(d => d.DiscoverAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentDiscoveryResult + { + Items = [discoveredItem], + TotalItems = 1, + })); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + mockResolver.Setup(r => r.ResolveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + mockValidator.Object, + CreateMockInstallationService()); + + var result = await provider.SearchAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Should().ContainSingle(); + result.Data!.First().Name.Should().Be("Generals 1.08 (EN)"); + } + + /// + /// Verifies that returns failure when content ID is null or whitespace. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetValidatedContentAsync_WithNullOrWhitespaceContentId_ReturnsFailureAsync() + { + var provider = CreateProvider(); + + var result = await provider.GetValidatedContentAsync(string.Empty); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that retrieves the manifest when matching content is found. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetValidatedContentAsync_WithMatchingContentId_ReturnsManifestAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + + var discoveredItem = new ContentSearchResult + { + Id = manifestId, + Name = "Generals 1.08 (EN)", + RequiresResolution = true, + ResolverId = CsvConstants.ResolverId, + }; + + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + mockDiscoverer.Setup(d => d.DiscoverAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentDiscoveryResult + { + Items = [discoveredItem], + TotalItems = 1, + })); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + mockResolver.Setup(r => r.ResolveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + mockValidator.Object, + CreateMockInstallationService()); + + var result = await provider.GetValidatedContentAsync(manifestId); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Name.Should().Be("Generals 1.08 (EN)"); + } + + /// + /// Verifies that returns failure when no items match exact content ID. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetValidatedContentAsync_WithNonMatchingContentId_ReturnsFailureAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + + var discoveredItem = new ContentSearchResult + { + Id = manifestId, + Name = "Generals 1.08 (EN)", + RequiresResolution = true, + ResolverId = CsvConstants.ResolverId, + }; + + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + mockDiscoverer.Setup(d => d.DiscoverAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentDiscoveryResult + { + Items = [discoveredItem], + TotalItems = 1, + })); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + mockResolver.Setup(r => r.ResolveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + mockValidator.Object, + CreateMockInstallationService()); + + var result = await provider.GetValidatedContentAsync("non-matching-id"); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that completes preparation. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task PrepareContentAsync_WithValidManifest_ReturnsSuccessAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + }; + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + mockValidator.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = CreateProvider(validator: mockValidator.Object); + + var result = await provider.PrepareContentAsync(manifest, "C:\\test\\dir"); + + result.Success.Should().BeTrue(); + } + + private static IInstallationInstructionsService CreateMockInstallationService() + { + var mockInstallService = new Mock(); + mockInstallService + .Setup(s => s.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + + return mockInstallService.Object; + } + + private static CsvContentProvider CreateProvider(IContentValidator? validator = null) + { + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + return new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + validator ?? Mock.Of(), + CreateMockInstallationService()); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvDiscovererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvDiscovererTests.cs new file mode 100644 index 000000000..efcd62c77 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvDiscovererTests.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Features.Content.Services.ContentDiscoverers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content; + +/// +/// Unit tests for . +/// +public class CsvDiscovererTests +{ + private sealed class TempIndexFile : IDisposable + { + public TempIndexFile(IEnumerable entries) + { + FilePath = Path.GetTempFileName(); + var index = new CsvCatalogRegistryIndex + { + Entries = entries.ToList(), + }; + + File.WriteAllText(FilePath, JsonSerializer.Serialize(index)); + } + + public string FilePath { get; } + + public void Dispose() + { + if (File.Exists(FilePath)) + { + File.Delete(FilePath); + } + } + } + + private sealed class StubHttpMessageHandler( + string? expectedUrl = null, + string content = "", + HttpStatusCode statusCode = HttpStatusCode.NotFound) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var code = expectedUrl == null || request.RequestUri?.AbsoluteUri == expectedUrl + ? statusCode + : HttpStatusCode.NotFound; + var responseContent = code == HttpStatusCode.OK ? content : string.Empty; + var response = new HttpResponseMessage(code) + { + RequestMessage = request, + Content = new StringContent(responseContent), + }; + + return Task.FromResult(response); + } + } + + private const string GeneralsVersion = "1.08"; + private const string ZeroHourVersion = "1.04"; + private const string TestGeneralsCsvUrl = "https://example.com/generals.csv"; + private const string TestZeroHourCsvUrl = "https://example.com/zerohour.csv"; + private const string TestFallbackCsvUrl = "https://example.com/fallback.csv"; + + /// + /// Verifies that returns an empty result when the query specifies a non-game-installation content type. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenContentTypeIsNotGameInstallation_ReturnsEmptyResultAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + var query = new ContentSearchQuery { ContentType = ContentType.Map }; + + var result = await discoverer.DiscoverAsync(query); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that loads and returns entries from index.json when available. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenIndexJsonAvailable_ReturnsEntriesAsync() + { + using var indexFile = CreateIndexFile( + CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn), + CreateEntry(TestZeroHourCsvUrl, CsvConstants.ZeroHourGameType, ZeroHourVersion, CsvConstants.LanguageEn)); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().HaveCount(2); + } + + /// + /// Verifies that falls back to configured catalogs when index.json is unavailable. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenIndexJsonFails_FallsBackToConfiguredCatalogsAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration + { + IndexFilePath = "https://nonexistent.invalid/index.json", + CsvValidationCatalogs = + [ + CreateEntry(TestFallbackCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn), + ], + }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().ResolverMetadata[CsvConstants.CsvUrlMetadataKey].Should().Be(TestFallbackCsvUrl); + } + + /// + /// Verifies that returns an empty result when no sources contain valid entries. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenConfigEmpty_ReturnsEmptyAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration + { + IndexFilePath = string.Empty, + CsvValidationCatalogs = [], + }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that filters entries by language when a specific language is requested. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithSpecificLanguageQuery_ReturnsFilteredResultAsync() + { + using var indexFile = CreateIndexFile( + CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn, CsvConstants.LanguageDe, CsvConstants.LanguageFr)); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { Language = "de" }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().ResolverMetadata[CsvConstants.LanguageMetadataKey].Should().Be(CsvConstants.LanguageDe); + } + + /// + /// Verifies that returns results for all supported languages when "All" is queried. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithAllLanguageQuery_ReturnsAllSupportedLanguagesAsync() + { + using var indexFile = CreateIndexFile( + CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn, CsvConstants.LanguageDe, CsvConstants.LanguageFr)); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { Language = CsvConstants.AllLanguagesFilter }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().HaveCount(3); + } + + /// + /// Verifies that matches an entry whose language is "All" when a specific language is queried. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenEntryHasAllLanguage_MatchesAnyQueryAsync() + { + using var indexFile = CreateIndexFile( + CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.AllLanguagesFilter)); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { Language = "fr" }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().ResolverMetadata[CsvConstants.LanguageMetadataKey].Should().Be(CsvConstants.LanguageFr); + } + + /// + /// Verifies that caches catalog entries across multiple calls. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_CachesEntriesBetweenCallsAsync() + { + var tempIndex = CreateIndexFile(CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + try + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = tempIndex.FilePath }); + + var firstResult = await discoverer.DiscoverAsync(new ContentSearchQuery()); + firstResult.Data!.Items.Should().HaveCount(1); + + // Delete file - second call should still succeed from cache + tempIndex.Dispose(); + + var secondResult = await discoverer.DiscoverAsync(new ContentSearchQuery()); + secondResult.Success.Should().BeTrue(); + secondResult.Data!.Items.Should().HaveCount(1); + } + finally + { + tempIndex.Dispose(); + } + } + + /// + /// Verifies that propagates cancellation tokens. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + await Assert.ThrowsAnyAsync(() => + discoverer.DiscoverAsync(new ContentSearchQuery(), cts.Token)); + } + + /// + /// Verifies that returns a failure result when query is null. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenQueryIsNull_ReturnsFailureAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + var result = await discoverer.DiscoverAsync(null!); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that returns an empty result when network requests fail and no fallback is available. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenNetworkFails_ReturnsEmptyResultAsync() + { + var httpHandler = new StubHttpMessageHandler(statusCode: HttpStatusCode.InternalServerError); + var discoverer = CreateDiscoverer( + new CsvCatalogConfiguration { IndexFilePath = "https://example.com/index.json" }, + httpHandler); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that retries loading on next query after a transient failure without permanently caching empty results. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenFirstLoadFails_RetriesOnNextCallAsync() + { + var tempIndex = CreateIndexFile(CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + try + { + // First point to non-existent file + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = tempIndex.FilePath + ".nonexistent" }); + var firstResult = await discoverer.DiscoverAsync(new ContentSearchQuery()); + firstResult.Success.Should().BeTrue(); + firstResult.Data!.Items.Should().BeEmpty(); + + // Next point to actual file with a new discoverer or reconfigured discoverer + var secondDiscoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = tempIndex.FilePath }); + var secondResult = await secondDiscoverer.DiscoverAsync(new ContentSearchQuery()); + secondResult.Success.Should().BeTrue(); + secondResult.Data!.Items.Should().HaveCount(1); + } + finally + { + tempIndex.Dispose(); + } + } + + /// + /// Verifies that gives precedence to the configured index over default. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_ConfiguredIndexTakesPrecedenceOverDefaultAsync() + { + var tempIndex = CreateIndexFile(CreateEntry("https://custom.com/custom.csv", CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + try + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = tempIndex.FilePath }); + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().SourceUrl.Should().Be("https://custom.com/custom.csv"); + } + finally + { + tempIndex.Dispose(); + } + } + + /// + /// Verifies that uses configured fallback catalogs. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithConfiguredFallbackCatalogs_UsesFallbackAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration + { + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry + { + Url = TestFallbackCsvUrl, + GameType = CsvConstants.GeneralsGameType, + Version = GeneralsVersion, + SupportedLanguages = [CsvConstants.LanguageEn], + }, + ], + }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().ResolverMetadata[CsvConstants.CsvUrlMetadataKey].Should().Be(TestFallbackCsvUrl); + } + + /// + /// Verifies that returns no items for unsupported target games. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithUnsupportedTargetGame_ReturnsEmptyAsync() + { + using var indexFile = CreateIndexFile(CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { TargetGame = (GameType)999 }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that handles Zero Hour game type correctly. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithZeroHourQuery_ReturnsValidResultAsync() + { + using var indexFile = CreateIndexFile(CreateEntry(TestZeroHourCsvUrl, CsvConstants.ZeroHourGameType, ZeroHourVersion, CsvConstants.LanguageEn)); + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { TargetGame = GameType.ZeroHour }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().TargetGame.Should().Be(GameType.ZeroHour); + } + + /// + /// Verifies that returns empty when no matching game type is found. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithNonMatchingGameType_ReturnsEmptyAsync() + { + using var indexFile = CreateIndexFile(CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { TargetGame = GameType.ZeroHour }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that returns the correct source name. + /// + [Fact] + public void SourceName_ReturnsExpectedValue() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + discoverer.SourceName.Should().Be(CsvConstants.SourceName); + } + + /// + /// Verifies that returns the correct description. + /// + [Fact] + public void Description_ReturnsExpectedValue() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + discoverer.Description.Should().Be(CsvConstants.Description); + } + + /// + /// Verifies that returns true. + /// + [Fact] + public void IsEnabled_ReturnsTrue() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + discoverer.IsEnabled.Should().BeTrue(); + } + + /// + /// Verifies that returns DirectSearch. + /// + [Fact] + public void Capabilities_ReturnsDirectSearch() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + discoverer.Capabilities.Should().Be(ContentSourceCapabilities.DirectSearch); + } + + /// + /// Verifies that can be called multiple times without throwing. + /// + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + var act = () => + { + discoverer.Dispose(); + discoverer.Dispose(); + }; + + act.Should().NotThrow(); + } + + private static CsvDiscoverer CreateDiscoverer(CsvCatalogConfiguration? config, HttpMessageHandler? httpMessageHandler = null) + { + var mockConfig = new Mock(); + mockConfig.Setup(o => o.GetCsvCatalogConfiguration()).Returns(config!); + var mockHttpClientFactory = new Mock(); + mockHttpClientFactory + .Setup(o => o.CreateClient(It.IsAny())) + .Returns(() => new HttpClient(httpMessageHandler ?? new StubHttpMessageHandler())); + + return new CsvDiscoverer(Mock.Of>(), mockConfig.Object, mockHttpClientFactory.Object); + } + + private static TempIndexFile CreateIndexFile(params CsvCatalogRegistryEntry[] entries) + { + return new TempIndexFile(entries); + } + + private static CsvCatalogRegistryEntry CreateEntry(string url, string gameType, string version, params string[] languages) + { + return new CsvCatalogRegistryEntry + { + Url = url, + GameType = gameType, + Version = version, + SupportedLanguages = languages.ToList(), + IsActive = true, + }; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvResolverTests.cs new file mode 100644 index 000000000..1f9238f6e --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvResolverTests.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Constants; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results.Content; +using GenHub.Features.Content.Services.ContentResolvers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content; + +/// +/// Unit tests for . +/// +public class CsvResolverTests +{ + private sealed class TempCsvFile : IDisposable + { + public TempCsvFile(string csvContent) + { + FilePath = Path.GetTempFileName(); + File.WriteAllText(FilePath, csvContent); + } + + public string FilePath { get; } + + public void Dispose() + { + if (File.Exists(FilePath)) + { + File.Delete(FilePath); + } + } + } + + private sealed class StubHttpMessageHandler( + string? expectedUrl = null, + string content = "", + HttpStatusCode statusCode = HttpStatusCode.NotFound) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var code = expectedUrl == null || request.RequestUri?.AbsoluteUri == expectedUrl + ? statusCode + : HttpStatusCode.NotFound; + var responseContent = code == HttpStatusCode.OK ? content : string.Empty; + var response = new HttpResponseMessage(code) + { + RequestMessage = request, + Content = new StringContent(responseContent), + }; + + return Task.FromResult(response); + } + } + + private const string SampleCsvHeader = "relativePath,size,md5,sha256,gameType,language,isRequired,metadata,downloadUrl"; + private const string SampleCsvRowAll = "game.dat,123456,md5all,sha256all,Generals,All,True,\"{}\",https://example.com/game.dat"; + private const string SampleCsvRowEn = "English.big,234567,md5en,sha256en,Generals,EN,False,\"{}\",https://example.com/English.big"; + private const string SampleCsvRowDe = "German.big,345678,md5de,sha256de,Generals,DE,False,\"{}\",https://example.com/German.big"; + private const string SampleCsvRowZh = "ZeroHour.exe,456789,md5zh,sha256zh,ZeroHour,All,True,\"{}\",https://example.com/ZeroHour.exe"; + + private static readonly string FullSampleCsv = string.Join( + Environment.NewLine, + SampleCsvHeader, + SampleCsvRowAll, + SampleCsvRowEn, + SampleCsvRowDe, + SampleCsvRowZh); + + /// + /// Verifies that returns a failure when the item is null. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithNullDiscoveredItem_ReturnsFailureAsync() + { + var resolver = CreateResolver(); + + var result = await resolver.ResolveAsync(null!); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that returns a failure when SourceUrl is empty. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithEmptySourceUrl_ReturnsFailureAsync() + { + var resolver = CreateResolver(); + var item = new ContentSearchResult { SourceUrl = string.Empty }; + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that successfully resolves a manifest from HTTP URL. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenRemoteCsvFetchedSuccessfully_ResolvesManifestAsync() + { + var remoteUrl = "https://example.com/catalog.csv"; + var httpHandler = new StubHttpMessageHandler(expectedUrl: remoteUrl, content: FullSampleCsv, statusCode: HttpStatusCode.OK); + var resolver = CreateResolver(httpHandler); + + var item = CreateDiscoveredItem(remoteUrl, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Files.Should().HaveCount(2); // game.dat (All) + English.big (EN) + result.Data.Files.Should().Contain(f => f.RelativePath == "game.dat" && f.SourceType == ContentSourceType.RemoteDownload); + result.Data.Files.Should().Contain(f => f.RelativePath == "English.big" && f.SourceType == ContentSourceType.RemoteDownload); + } + + /// + /// Verifies that successfully resolves a manifest from a local file. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenLocalCsvFileExists_ResolvesManifestAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Files.Should().HaveCount(2); + result.Data.Files.Should().AllSatisfy(f => f.SourceType.Should().Be(ContentSourceType.LocalFile)); + } + + /// + /// Verifies that returns a failure when local file is missing. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenLocalCsvFileNotFound_ReturnsFailureAsync() + { + var resolver = CreateResolver(); + var item = CreateDiscoveredItem("C:\\nonexistent\\missing_catalog.csv", GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that returns a failure when network request fails. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenNetworkFails_ReturnsFailureAsync() + { + var remoteUrl = "https://example.com/catalog.csv"; + var httpHandler = new StubHttpMessageHandler(expectedUrl: remoteUrl, statusCode: HttpStatusCode.InternalServerError); + var resolver = CreateResolver(httpHandler); + + var item = CreateDiscoveredItem(remoteUrl, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that filters files by specific language. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithSpecificLanguageQuery_FiltersFilesByLanguageAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.LanguageDe); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Should().HaveCount(2); // game.dat (All) + German.big (DE) + result.Data.Files.Should().Contain(f => f.RelativePath == "German.big"); + result.Data.Files.Should().NotContain(f => f.RelativePath == "English.big"); + } + + /// + /// Verifies that includes all language files when language is "All". + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithAllLanguageQuery_IncludesAllFilesAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.AllLanguagesFilter); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Should().HaveCount(3); // game.dat, English.big, German.big + } + + /// + /// Verifies that filters files by game type correctly. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithTargetGame_FiltersFilesByGameTypeAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.ZeroHour, CsvConstants.AllLanguagesFilter); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Should().HaveCount(1); // ZeroHour.exe + result.Data.Files.First().RelativePath.Should().Be("ZeroHour.exe"); + result.Data.Files.First().IsExecutable.Should().BeTrue(); + } + + /// + /// Verifies that returns failure when no files match. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenNoFilesMatch_ReturnsFailureAsync() + { + using var tempCsv = new TempCsvFile(SampleCsvHeader); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that propagates cancellation. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var resolver = CreateResolver(); + var item = CreateDiscoveredItem("https://example.com/test.csv", GameType.Generals, CsvConstants.LanguageEn); + + await Assert.ThrowsAnyAsync(() => + resolver.ResolveAsync(item, cts.Token)); + } + + /// + /// Verifies that returns the expected constant. + /// + [Fact] + public void ResolverId_ReturnsExpectedConstant() + { + var resolver = CreateResolver(); + + resolver.ResolverId.Should().Be(CsvConstants.ResolverId); + } + + /// + /// Verifies that filters out traversal and rooted paths. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithTraversingRelativePaths_RejectsUnsafePathsAsync() + { + var maliciousCsv = string.Join( + Environment.NewLine, + SampleCsvHeader, + "../../escape.dll,100,md5,sha256,Generals,All,True,\"{}\",https://example.com/escape.dll", + "C:\\root.dll,100,md5,sha256,Generals,All,True,\"{}\",https://example.com/root.dll", + "valid.dll,100,md5,sha256,Generals,All,True,\"{}\",https://example.com/valid.dll"); + + using var tempCsv = new TempCsvFile(maliciousCsv); + var resolver = CreateResolver(); + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.AllLanguagesFilter); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Should().HaveCount(1); + result.Data.Files.Single().RelativePath.Should().Be("valid.dll"); + } + + /// + /// Verifies that sets SourceType to GameInstallation when remote entry has no valid URL. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenRemoteFileHasNoDownloadUrl_SetsSourceTypeToGameInstallationAsync() + { + var csvNoUrl = string.Join( + Environment.NewLine, + SampleCsvHeader, + "local_only.dat,100,md5,sha256,Generals,All,True,\"{}\","); + + var remoteUrl = "https://example.com/nourl.csv"; + var httpHandler = new StubHttpMessageHandler(expectedUrl: remoteUrl, content: csvNoUrl, statusCode: HttpStatusCode.OK); + var resolver = CreateResolver(httpHandler); + var item = CreateDiscoveredItem(remoteUrl, GameType.Generals, CsvConstants.AllLanguagesFilter); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Single().SourceType.Should().Be(ContentSourceType.GameInstallation); + result.Data.Files.Single().DownloadUrl.Should().BeNull(); + } + + /// + /// Verifies that the ProviderDefinition overload delegates to the main ResolveAsync method. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithProviderDefinitionOverload_DelegatesToResolveAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(null, item); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + } + + private static CsvResolver CreateResolver(HttpMessageHandler? handler = null) + { + var mockHttpClientFactory = new Mock(); + mockHttpClientFactory + .Setup(o => o.CreateClient(It.IsAny())) + .Returns(() => new HttpClient(handler ?? new StubHttpMessageHandler())); + + return new CsvResolver(mockHttpClientFactory.Object, Mock.Of>()); + } + + private static ContentSearchResult CreateDiscoveredItem(string sourceUrl, GameType gameType, string language) + { + var gameTypeStr = gameType == GameType.ZeroHour ? CsvConstants.ZeroHourGameType : CsvConstants.GeneralsGameType; + var id = ManifestIdGenerator.GeneratePublisherContentId( + PublisherTypeConstants.CsvRegistry, + ContentType.GameInstallation, + $"{gameTypeStr}-1.0-{language}"); + + var item = new ContentSearchResult + { + Id = id, + Name = $"{gameTypeStr} 1.0 ({language})", + Description = $"Base game installation files for {gameTypeStr} 1.0", + Version = "1.0", + ContentType = ContentType.GameInstallation, + TargetGame = gameType, + ProviderName = CsvConstants.SourceName, + ResolverId = CsvConstants.ResolverId, + SourceUrl = sourceUrl, + RequiresResolution = true, + }; + + item.ResolverMetadata[CsvConstants.CsvUrlMetadataKey] = sourceUrl; + item.ResolverMetadata[CsvConstants.GameTypeMetadataKey] = gameTypeStr; + item.ResolverMetadata[CsvConstants.LanguageMetadataKey] = language; + item.ResolverMetadata[CsvConstants.VersionMetadataKey] = "1.0"; + + return item; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index a50f687ff..112d9df4e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -1,6 +1,7 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Providers; using GenHub.Core.Models.Results; @@ -9,6 +10,7 @@ using GenHub.Features.Content.Services.GitHub; using Microsoft.Extensions.Logging; using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content; @@ -22,6 +24,7 @@ public class GitHubContentProviderTests private readonly Mock _delivererMock; private readonly Mock _validatorMock; private readonly Mock> _loggerMock; + private readonly Mock _archiveProcessorMock; private readonly GitHubContentProvider _provider; /// @@ -34,6 +37,7 @@ public GitHubContentProviderTests() _delivererMock = new Mock(); _validatorMock = new Mock(); _loggerMock = new Mock>(); + _archiveProcessorMock = new Mock(); // Setup mocks to be correctly identified by the provider _discovererMock.Setup(d => d.SourceName).Returns("GitHub"); @@ -46,12 +50,24 @@ 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, + _archiveProcessorMock.Object); } /// @@ -128,5 +144,6 @@ public async Task PrepareContentAsync_CallsDelivererAndValidator_SuccessfullyAsy // The base class should orchestrate the calls _delivererMock.Verify(d => d.CanDeliver(It.IsAny()), Times.AtLeastOnce()); _delivererMock.Verify(d => d.DeliverContentAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once()); + _archiveProcessorMock.Verify(a => a.ProcessPayloadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } } 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/Common/ControlBarPackageProcessorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs new file mode 100644 index 000000000..9a21da8a5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.Common; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.Common; + +/// +/// Unit tests for . +/// +public sealed class ControlBarPackageProcessorTests : IDisposable +{ + private readonly string _testDir = Path.Combine(Path.GetTempPath(), "GenHubTests", Guid.NewGuid().ToString("N")); + + /// + public void Dispose() + { + if (Directory.Exists(_testDir)) + { + try + { + Directory.Delete(_testDir, recursive: true); + } + catch + { + // Best effort + } + } + } + + /// + /// Verifies that IsControlBarContent detects Control Bar manifests by identifier and name. + /// + [Fact] + public void IsControlBarContent_WithControlBarManifest_ReturnsTrue() + { + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"), + Name = "Control Bar Pro Lemon Edition ZH (1080p)", + ContentType = ContentType.Addon, + }; + + var result = processor.IsControlBarContent(_testDir, manifest); + + Assert.True(result); + } + + /// + /// Verifies that nested resolution folder structure (ZH/1080p/BIG/...) is repacked into SAGE BIG archives. + /// + /// A completed task. + [Fact] + public async Task ProcessAndRepackControlBarAsync_WithNestedVariantStructure_RepacksToBigArchivesAsync() + { + // Arrange + var variantRoot = Path.Combine(_testDir, "ZH", "1080p", "BIG"); + var windowDir = Path.Combine(variantRoot, "Window"); + var artDir = Path.Combine(variantRoot, "Art", "Textures"); + var genToolDir = Path.Combine(variantRoot, "GenTool"); + + Directory.CreateDirectory(windowDir); + Directory.CreateDirectory(artDir); + Directory.CreateDirectory(genToolDir); + + await File.WriteAllTextAsync(Path.Combine(windowDir, "ControlBarPro.wnd"), "Window data"); + await File.WriteAllTextAsync(Path.Combine(artDir, "test.tga"), "TGA Texture data"); + await File.WriteAllTextAsync(Path.Combine(genToolDir, "fullviewport.dat"), "Viewport data"); + + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"), + Name = "Control Bar Pro Lemon Edition ZH (1080p)", + ContentType = ContentType.Addon, + }; + + // Act + var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest); + + // Assert + Assert.Contains("340_ControlBarProArt1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProData1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProZH.big", outputFiles); + + Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProArt1080ZH.big"))); + Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProData1080ZH.big"))); + Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProZH.big"))); + + // Verify that raw source folder was cleaned up + Assert.False(Directory.Exists(Path.Combine(_testDir, "ZH"))); + } + + /// + /// Verifies that flat prebuilt BIG files are identified and retained along with metadata BIG. + /// + /// A completed task. + [Fact] + public async Task ProcessAndRepackControlBarAsync_WithPrebuiltBigFiles_RetainsMatchingFilesAsync() + { + // Arrange + Directory.CreateDirectory(_testDir); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProArt1080ZH.big"), "BIG content"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProData1080ZH.big"), "BIG content"); + + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"), + Name = "Control Bar Pro Lemon Edition ZH (1080p)", + ContentType = ContentType.Addon, + }; + + // Act + var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest); + + // Assert + Assert.Contains("340_ControlBarProArt1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProData1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProZH.big", outputFiles); + } + + /// + /// Verifies that flat prebuilt Lemon Edition BIG files are identified and retained with existing Lemon Edition metadata. + /// + /// A completed task. + [Fact] + public async Task ProcessAndRepackControlBarAsync_WithLemonEditionPrebuiltBigFiles_RetainsLemonEditionFilesAsync() + { + // Arrange + Directory.CreateDirectory(_testDir); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionArt1080ZH.big"), "BIG art content"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionData1080ZH.big"), "BIG data content"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionZH.big"), "BIG base content"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "339_ControlBarProLemonEditionHideIpZH.big.BAK"), "BAK file"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "ReadMe.txt"), "readme"); + + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"), + Name = "Control Bar Pro Lemon Edition ZH (1080p)", + ContentType = ContentType.Addon, + }; + + // Act + var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest); + + // Assert + Assert.Contains("340_ControlBarProLemonEditionArt1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProLemonEditionData1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProLemonEditionZH.big", outputFiles); + Assert.DoesNotContain("340_ControlBarProZH.big", outputFiles); + } + + /// + /// Verifies that generic game folders like ZH without Control Bar markers or assets do not trigger Control Bar classification. + /// + [Fact] + public void IsControlBarContent_WithGenericGameDirectoryAndNoMarker_ReturnsFalse() + { + var zhDir = Path.Combine(_testDir, "ZH"); + Directory.CreateDirectory(zhDir); + File.WriteAllText(Path.Combine(zhDir, "mod.big"), "some mod content"); + + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.github.mod.somemod"), + Name = "Regular Mod (ZH)", + ContentType = ContentType.Mod, + }; + + var result = processor.IsControlBarContent(_testDir, manifest); + + Assert.False(result); + } +} 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..897625c84 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs @@ -0,0 +1,396 @@ +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.Common; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace GenHub.Tests.Core.Features.Content.Services.CommunityOutpost; + +/// +/// Tests the containment and expansion bounds applied to Community Outpost archives, which arrive +/// from a third-party catalog and are therefore untrusted input. +/// +public sealed class CommunityOutpostDelivererTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubCommunityOutpost", + Guid.NewGuid().ToString("N")); + + private readonly string _extractDirectory; + + /// + /// Initializes a new instance of the class. + /// + public CommunityOutpostDelivererTests() + { + _extractDirectory = Path.Combine(_workingDirectory, "extracted"); + Directory.CreateDirectory(_extractDirectory); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Extracts entries that stay inside the target directory. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_ExtractsEntriesWithinBudgetAsync() + { + var archivePath = Path.Combine(_workingDirectory, "content.zip"); + CreateArchive(archivePath, "patch/readme.txt", "generals.big"); + + await InvokeExtractArchiveAsync(archivePath, _extractDirectory); + + Assert.True(File.Exists(Path.Combine(_extractDirectory, "patch", "readme.txt"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "generals.big"))); + } + + /// + /// Refuses an entry whose key climbs out of the extract directory, rather than depending on the + /// archive library to block it. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_RejectsEntryEscapingTheExtractDirectoryAsync() + { + var archivePath = Path.Combine(_workingDirectory, "traversal.zip"); + CreateArchive(archivePath, "../escaped.big"); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("outside target directory", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(File.Exists(Path.Combine(_workingDirectory, "escaped.big"))); + } + + /// + /// Refuses an archive that declares more entries than the extraction budget allows. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_RejectsArchiveOverTheEntryBudgetAsync() + { + var archivePath = Path.Combine(_workingDirectory, "swarm.zip"); + var entryNames = Enumerable + .Range(0, CommunityOutpostConstants.MaxArchiveEntries + 1) + .Select(index => $"entry{index}.dat") + .ToArray(); + CreateArchive(archivePath, entryNames); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("too many entries", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFileSystemEntries(_extractDirectory)); + } + + /// + /// Refuses an entry whose name cannot name a file before that name is turned into a path. A + /// name that resolves to the extract directory itself would otherwise stage the write beside + /// that directory rather than inside it, and a colon names an NTFS alternate data stream. + /// + /// The entry name the archive declares. + /// A task representing the asynchronous test. + [Theory] + [InlineData(".")] + [InlineData("patch/..")] + [InlineData(" ")] + [InlineData("payload.big:stream")] + public async Task ExtractArchiveAsync_RejectsEntryWithAnUnusableNameAsync(string entryName) + { + var archivePath = Path.Combine(_workingDirectory, "unusable.zip"); + CreateArchive(archivePath, entryName); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("cannot be extracted to a file", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFileSystemEntries(_workingDirectory, "*.genhub-staging*")); + } + + /// + /// Surfaces a cancellation that lands part-way through extraction as a cancellation rather than + /// as an ordinary extraction failure, so callers can tell a user who changed their mind from a + /// hostile or broken archive. The cancellation is triggered once an early entry has landed on + /// disk and while a much larger one is still being written, which is what puts it inside the + /// entry loop rather than in front of it. The downloaded archive is the only complete copy of + /// the content, so it must survive, and the truncated file set must never reach the manifest + /// pool. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeliverContentAsync_CancelledMidExtraction_KeepsArchiveAndRegistersNothingAsync() + { + const int largeEntryBytes = 32 * 1024 * 1024; + var targetDirectory = Path.Combine(_workingDirectory, "target"); + Directory.CreateDirectory(targetDirectory); + + var downloadService = new Mock(); + downloadService + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((Uri _, string destination, string? _, IProgress? _, CancellationToken _) => + { + CreateArchive(destination, ("first.dat", 16), ("marker.dat", 16), ("large.dat", largeEntryBytes)); + return Task.FromResult(DownloadResult.CreateSuccess(destination, 1, TimeSpan.FromSeconds(1))); + }); + + var manifestPool = new Mock(); + var deliverer = CreateDeliverer(downloadService.Object, manifestPool.Object); + var manifest = new ContentManifest + { + Files = + [ + new ManifestFile + { + RelativePath = "content.zip", + DownloadUrl = "https://legi.cc/gp2/f/cbpr.zip", + }, + ], + }; + + var extractDirectory = Path.Combine(targetDirectory, "extracted"); + using var cancellation = new CancellationTokenSource(); + var cancelWhenMarkerLands = CancelWhenFileAppearsAsync( + Path.Combine(extractDirectory, "marker.dat"), + cancellation); + + await Assert.ThrowsAnyAsync(() => + deliverer.DeliverContentAsync(manifest, targetDirectory, null, cancellation.Token)); + + await cancelWhenMarkerLands; + + Assert.True( + File.Exists(Path.Combine(targetDirectory, "content.zip")), + "the archive is the only recoverable copy of the content"); + Assert.True( + File.Exists(Path.Combine(extractDirectory, "first.dat")), + "the cancellation has to land after extraction started, not in front of it"); + Assert.False( + File.Exists(Path.Combine(extractDirectory, "large.dat")), + "the entry being written when the cancellation landed must not be left behind"); + Assert.Empty(Directory.GetFileSystemEntries(extractDirectory, "*.genhub-staging*")); + + manifestPool.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + /// + /// Verifies that multi-variant hotkeys packages like hlei repack all language/game subdirectories. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task RepackContentIfNeededAsync_WithHleiPackage_RepacksAllVariantBigFilesAsync() + { + var hleiManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.hlei"), + Name = "Leikeze's Hotkeys", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:hlei"], + }, + }; + + var zhEnDir = Path.Combine(_extractDirectory, "ZH", "BIG EN", "Data", "English"); + var zhDeDir = Path.Combine(_extractDirectory, "ZH", "BIG DE", "Data", "English"); + var zhRuDir = Path.Combine(_extractDirectory, "ZH", "BIG RU", "Data", "English"); + var ccgEnDir = Path.Combine(_extractDirectory, "CCG", "BIG EN", "Data", "English"); + + Directory.CreateDirectory(zhEnDir); + Directory.CreateDirectory(zhDeDir); + Directory.CreateDirectory(zhRuDir); + Directory.CreateDirectory(ccgEnDir); + + File.WriteAllText(Path.Combine(zhEnDir, "generals.csf"), "EN CSF"); + File.WriteAllText(Path.Combine(zhDeDir, "generals.csf"), "DE CSF"); + File.WriteAllText(Path.Combine(zhRuDir, "generals.csf"), "RU CSF"); + File.WriteAllText(Path.Combine(ccgEnDir, "generals.csf"), "CCG CSF"); + + var deliverer = CreateDeliverer(new Mock().Object, new Mock().Object); + var repackMethod = typeof(CommunityOutpostDeliverer).GetMethod( + "RepackContentIfNeededAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("RepackContentIfNeededAsync method not found."); + + await (Task)repackMethod.Invoke(deliverer, [hleiManifest, _extractDirectory, CancellationToken.None])!; + + Assert.True(File.Exists(Path.Combine(_extractDirectory, "!HotkeysLeikezeENZH.big"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "!HotkeysLeikezeDEZH.big"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "!HotkeysLeikezeRUZH.big"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "!HotkeysLeikezeEN.big"))); + Assert.False(Directory.Exists(Path.Combine(_extractDirectory, "ZH"))); + Assert.False(Directory.Exists(Path.Combine(_extractDirectory, "CCG"))); + } + + /// + /// Verifies that pre-existing BIG files inside a variant directory are copied to the resolved variant destination filename. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task RepackContentIfNeededAsync_WithPreExistingBigInVariant_CopiesToResolvedVariantFileNameAsync() + { + var hleiManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.hlei"), + Name = "Leikeze's Hotkeys", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:hlei"], + }, + }; + + var zhEnDir = Path.Combine(_extractDirectory, "ZH", "BIG EN"); + Directory.CreateDirectory(zhEnDir); + File.WriteAllText(Path.Combine(zhEnDir, "arbitrary_name.big"), "PRE-PACKED BIG CONTENT"); + + var deliverer = CreateDeliverer(new Mock().Object, new Mock().Object); + var repackMethod = typeof(CommunityOutpostDeliverer).GetMethod( + "RepackContentIfNeededAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("RepackContentIfNeededAsync method not found."); + + await (Task)repackMethod.Invoke(deliverer, [hleiManifest, _extractDirectory, CancellationToken.None])!; + + var destFile = Path.Combine(_extractDirectory, "!HotkeysLeikezeENZH.big"); + Assert.True(File.Exists(destFile)); + Assert.Equal("PRE-PACKED BIG CONTENT", File.ReadAllText(destFile)); + } + + /// + /// Verifies that repacking is skipped for content metadata that does not require repacking. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task RepackContentIfNeededAsync_WithNonRepackingContent_SkipsRepackAsync() + { + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.gent"), + Name = "GenTool", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:gent"], + }, + }; + + var dummyFile = Path.Combine(_extractDirectory, "d3d8.dll"); + File.WriteAllText(dummyFile, "DLL CONTENT"); + + var deliverer = CreateDeliverer(new Mock().Object, new Mock().Object); + var repackMethod = typeof(CommunityOutpostDeliverer).GetMethod( + "RepackContentIfNeededAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("RepackContentIfNeededAsync method not found."); + + await (Task)repackMethod.Invoke(deliverer, [manifest, _extractDirectory, CancellationToken.None])!; + + Assert.True(File.Exists(dummyFile)); + } + + private static CommunityOutpostDeliverer CreateDeliverer( + IDownloadService downloadService, + IContentManifestPool manifestPool) + { + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var controlBarProcessor = new ControlBarPackageProcessor( + converter, + NullLogger.Instance); + var manifestFactory = new CommunityOutpostManifestFactory( + NullLogger.Instance, + new Mock().Object, + controlBarProcessor); + + 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/GameInstallations/GameInstallationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs index 429913855..ba6f293c3 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs @@ -350,4 +350,197 @@ public async Task GetAllInstallationsAsync_WhenDetectionSucceedsWithNoResults_Ca x => x.DetectAllInstallationsAsync(It.IsAny()), Times.Once); } + + /// + /// Verifies that persisted manifests reconstruct an installation with both Generals and Zero Hour capabilities. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetAllInstallationsAsync_ReconstructsInstallationWithGeneralsAndZeroHour_FromPersistedManifestsAsync() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenHubManifestReconstruct_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), string.Empty); + + _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); + + var generalsManifest = new ContentManifest + { + Id = "1.108.retail.gameinstallation.generals", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + TargetGame = GameType.Generals, + Version = "1.08", + Metadata = new ContentMetadata { SourcePath = tempDir }, + }; + + var zeroHourManifest = new ContentManifest + { + Id = "1.104.retail.gameinstallation.zerohour", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + TargetGame = GameType.ZeroHour, + Version = "1.04", + Metadata = new ContentMetadata { SourcePath = tempDir }, + }; + + _manifestPoolMock + .Setup(x => x.SearchManifestsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([generalsManifest, zeroHourManifest])); + + var result = await _service.GetAllInstallationsAsync(); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + var install = Assert.Single(result.Data); + Assert.True(install.HasGenerals); + Assert.Equal(tempDir, install.GeneralsPath); + Assert.True(install.HasZeroHour); + Assert.Equal(tempDir, install.ZeroHourPath); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that when TargetGame defaults to Generals (0) because it was omitted in JSON, a Zero Hour manifest ID only sets the Zero Hour path. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetAllInstallationsAsync_ReconstructsZeroHourInstallation_WhenTargetGameOmittedAsync() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenHubZHManifestReconstruct_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), string.Empty); + + _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); + + // TargetGame is omitted / default(GameType) which equals GameType.Generals (0) + var zeroHourManifest = new ContentManifest + { + Id = "1.104.retail.gameinstallation.zerohour", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + Version = "1.04", + Metadata = new ContentMetadata { SourcePath = tempDir }, + }; + + _manifestPoolMock + .Setup(x => x.SearchManifestsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([zeroHourManifest])); + + var result = await _service.GetAllInstallationsAsync(); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + var install = Assert.Single(result.Data); + Assert.True(install.HasZeroHour); + Assert.Equal(tempDir, install.ZeroHourPath); + Assert.False(install.HasGenerals); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that manifests with distinct source paths reconstruct into distinct installations. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetAllInstallationsAsync_ReconstructsInstallations_RespectingPathComparerAsync() + { + var tempDir1 = Path.Combine(Path.GetTempPath(), "GenHubPathTest_A_" + Guid.NewGuid().ToString("N")); + var tempDir2 = Path.Combine(Path.GetTempPath(), "GenHubPathTest_B_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir1); + Directory.CreateDirectory(tempDir2); + try + { + File.WriteAllText(Path.Combine(tempDir1, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir1, "INIZH.big"), string.Empty); + File.WriteAllText(Path.Combine(tempDir2, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir2, "INIZH.big"), string.Empty); + + _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); + + var manifest1 = new ContentManifest + { + Id = "1.104.retail.gameinstallation.zerohour", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + TargetGame = GameType.ZeroHour, + Version = "1.04", + Metadata = new ContentMetadata { SourcePath = tempDir1 }, + }; + + var manifest2 = new ContentManifest + { + Id = "1.104.retail.gameinstallation.zerohour", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + TargetGame = GameType.ZeroHour, + Version = "1.04", + Metadata = new ContentMetadata { SourcePath = tempDir2 }, + }; + + _manifestPoolMock + .Setup(x => x.SearchManifestsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([manifest1, manifest2])); + + var result = await _service.GetAllInstallationsAsync(); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(2, result.Data.Count); + } + finally + { + Directory.Delete(tempDir1, true); + Directory.Delete(tempDir2, true); + } + } + + /// + /// Verifies that installations with paths differing only by case are handled according to platform path comparison semantics. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task AddInstallationToCacheAsync_HandlesCaseDistinctPaths_AccordingToPlatformPathComparisonAsync() + { + var basePath = Path.Combine(Path.GetTempPath(), "GenHubCaseTest_" + Guid.NewGuid().ToString("N")); + var path1 = Path.Combine(basePath, "zh"); + var path2 = Path.Combine(basePath, "ZH"); + + var install1 = new GameInstallation(path1, GameInstallationType.Steam); + var install2 = new GameInstallation(path2, GameInstallationType.Retail); + + _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); + + var addResult1 = await _service.AddInstallationToCacheAsync(install1); + var addResult2 = await _service.AddInstallationToCacheAsync(install2); + + Assert.True(addResult1.Success); + Assert.True(addResult2.Success); + + var allResult = await _service.GetAllInstallationsAsync(); + Assert.True(allResult.Success); + Assert.NotNull(allResult.Data); + + if (OperatingSystem.IsWindows()) + { + Assert.Single(allResult.Data); + } + else + { + Assert.Equal(2, allResult.Data.Count); + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs new file mode 100644 index 000000000..200992255 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs @@ -0,0 +1,237 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Launching; +using GenHub.Features.GameProfiles.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Engine-only launch smoke test: starts the native client with no game data at all and +/// requires the failure the engine is known to produce. +/// +/// The engine cannot reach its main loop without content — with no readable INI it aborts +/// with exit code 1 during initialisation. Launching it in an empty workspace therefore +/// still proves the things CI otherwise never covers: the binary loads, its dylibs resolve +/// relative to the executable, initialisation runs as far as INI loading, and the failure +/// is a prompt exit rather than a hang. No licensed retail data is involved. +/// +/// +/// Like the other native-client tests this skips when no client is present — unless +/// GENHUB_REQUIRE_NATIVE_SMOKE is set, which CI uses to turn a missing client into +/// a failure instead of a silent green run. +/// +/// +[Collection(NativeClientLaunchCollection.Name)] +public class EngineLaunchSmokeTests : IDisposable +{ + /// + /// Environment variable that forbids skipping: when set to 1 or true, a + /// missing native client fails the test rather than passing it vacuously. + /// + public const string RequireEnvironmentVariable = "GENHUB_REQUIRE_NATIVE_SMOKE"; + + /// + /// How long the engine gets to exit before the test declares a hang. The observed + /// failure takes about a second; the margin covers a cold CI runner, not the engine. + /// + private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(60); + + private readonly string _tempRoot = Path.Combine( + Path.GetTempPath(), + $"genhub-engine-smoke-{Guid.NewGuid():N}"); + + private readonly GameProcessManager _processManager = new(NullLogger.Instance); + + /// + /// Initializes a new instance of the class. + /// + public EngineLaunchSmokeTests() => Directory.CreateDirectory(_tempRoot); + + private static bool IsSmokeRequired + { + get + { + var value = Environment.GetEnvironmentVariable(RequireEnvironmentVariable); + return string.Equals(value, "1", StringComparison.Ordinal) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + } + + /// + /// Stages the engine binary and its libraries into an empty workspace — no archives, + /// no retail roots — and launches headless with HOME redirected so the crash report + /// lands in the sandbox. The engine must exit with code 1 and leave its crash report + /// in the redirected HOME — the diagnostic that identifies this as the known abort. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EngineWithNoGameData_ExitsWithCodeOne() + { + var installDirectory = NativeClientFixture.Directory; + if (installDirectory is null) + { + var missingClientMessage = + $"{RequireEnvironmentVariable} is set but no native client was found. " + + $"Point {NativeClientFixture.EnvironmentOverride} at a directory containing " + + $"'{NativeClientFixture.BinaryName}'."; + Assert.False(IsSmokeRequired, missingClientMessage); + return; + } + + var workspace = StageEngineOnlyWorkspace(installDirectory); + var sandboxHome = Path.Combine(_tempRoot, "home"); + Directory.CreateDirectory(sandboxHome); + + // The exit code is only observable through the manager's exit event: the process + // handle stays internal, and GetProcessInfoAsync reports an exited process as + // not found. Subscribed before launch so a fast exit cannot slip past. + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _processManager.ProcessExited += (_, e) => exited.TrySetResult(e.ExitCode); + + // The install-path variables are pinned to the empty workspace so a developer who + // has them exported cannot feed this "no data" launch their real retail content + // through the inherited environment. GameProcessManager assigns these into + // ProcessStartInfo.EnvironmentVariables by indexer, which the framework + // pre-populates from the parent environment — so an inherited value is replaced, + // not merely joined. The trailing separator matches how GameLauncher sets these + // for real launches: the engine requires it on the value. + var pinnedInstallPath = workspace + Path.DirectorySeparatorChar; + var configuration = new GameLaunchConfiguration + { + ExecutablePath = Path.Combine(workspace, NativeClientFixture.BinaryName), + WorkingDirectory = workspace, + Arguments = new() { ["-headless"] = string.Empty }, + EnvironmentVariables = new() + { + ["HOME"] = sandboxHome, + [RetailArchiveConstants.ZeroHourInstallPathVariable] = pinnedInstallPath, + [RetailArchiveConstants.GeneralsInstallPathVariable] = pinnedInstallPath, + }, + }; + + var result = await _processManager.StartProcessAsync(configuration); + + if (!result.Success) + { + // The engine beat the launcher-detection delay. The manager folds the exit + // code into the error, so the assertion still pins it to exactly 1. + Assert.Contains( + "exited immediately with code 1", + string.Join(" ", result.Errors), + StringComparison.OrdinalIgnoreCase); + } + else + { + var completed = await Task.WhenAny(exited.Task, Task.Delay(ExitTimeout)); + if (completed != exited.Task) + { + await _processManager.TerminateProcessAsync(result.Data!.ProcessId); + Assert.Fail( + $"The engine was still running {ExitTimeout.TotalSeconds:F0}s after launch with " + + "no game data. The known behaviour is a prompt abort with exit code 1; a hang " + + "here means startup no longer fails fast and the launcher could wait forever."); + } + + var exitCode = await exited.Task; + Assert.NotNull(exitCode); + Assert.Equal(1, exitCode); + } + + AssertCrashReportWasWritten(sandboxHome); + } + + /// + /// Releases the temporary workspace and sandbox HOME. + /// + public void Dispose() + { + GC.SuppressFinalize(this); + _processManager.Dispose(); + try + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + catch (IOException) + { + // A leftover temp directory is not worth failing a test over. + } + } + + /// + /// Asserts the abort produced its diagnostic. In this failure mode stderr is empty; + /// what the engine leaves behind is a crash report named ReleaseCrashInfo.txt + /// under HOME (on macOS beneath Library/Application Support). Searched + /// recursively so the intermediate segments — engine behaviour, and platform + /// dependent — are not hardcoded. The sandbox HOME is created empty by this test, so + /// any report found here was newly written by this launch; finding it in the sandbox + /// also proves the HOME redirection worked, keeping the user's real profile untouched. + /// + /// The redirected HOME directory. + private static void AssertCrashReportWasWritten(string sandboxHome) + { + var reports = Directory + .EnumerateFiles(sandboxHome, "ReleaseCrashInfo.txt", SearchOption.AllDirectories) + .ToList(); + + var missingReportMessage = + "The engine exited with code 1 but wrote no ReleaseCrashInfo.txt under the " + + $"redirected HOME '{sandboxHome}'. The known abort writes that report before " + + "exiting, so its absence means this was a different failure than the " + + "no-game-data INI abort this test pins down."; + Assert.True(reports.Count > 0, missingReportMessage); + + var reportContents = File.ReadAllText(reports[0]); + Assert.False( + string.IsNullOrWhiteSpace(reportContents), + $"The crash report at '{reports[0]}' is empty; the known abort records its reason."); + + // The stable line the abort writes is "; Reason Uncaught Exception during + // initialization." — asserted without the leading punctuation so a formatting + // change there cannot break the test, while the reason itself stays pinned. + Assert.Contains( + "Reason Uncaught Exception during initialization.", + reportContents, + StringComparison.Ordinal); + } + + /// + /// Copies only the engine binary and its dynamic libraries into a fresh directory. + /// Everything else in the source install — archives, retail roots, user files — is + /// deliberately left behind; their absence is the point of the test. + /// + /// The native client install to stage from. + /// The staged workspace directory. + private string StageEngineOnlyWorkspace(string installDirectory) + { + var workspace = Path.Combine(_tempRoot, "workspace"); + Directory.CreateDirectory(workspace); + + foreach (var path in Directory.EnumerateFiles(installDirectory, "*", SearchOption.TopDirectoryOnly)) + { + var name = Path.GetFileName(path); + if (name != NativeClientFixture.BinaryName && !NativeClientFixture.IsDynamicLibrary(name)) + { + continue; + } + + File.Copy(path, Path.Combine(workspace, name)); + } + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + Path.Combine(workspace, NativeClientFixture.BinaryName), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return workspace; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs index 3afee1b7b..443b0f5cf 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Launching; using GenHub.Features.GameProfiles.Infrastructure; @@ -84,8 +85,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 +165,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 +200,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 @@ -226,6 +281,57 @@ await Assert.ThrowsAnyAsync( () => _processManager.StartProcessAsync(config, cts.Token)); } + /// + /// When the launcher exits immediately with code 0 and the subsequent adoption poll loop is cancelled, + /// the operation must throw OperationCanceledException and clean up any resources. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenImmediateExitPollIsCancelled_ThrowsOperationCanceledExceptionAsync() + { + if (OperatingSystem.IsWindows()) + { + // On Windows batch files bypass immediate-exit adoption handling. + return; + } + + // Arrange + var tempScript = Path.Combine(Path.GetTempPath(), $"genhub_exit0_{Guid.NewGuid():N}.sh"); + var scriptContent = "#!/bin/sh\nexit 0\n"; + await File.WriteAllTextAsync(tempScript, scriptContent); + + using var chmod = System.Diagnostics.Process.Start("chmod", ["+x", tempScript]); + chmod?.WaitForExit(); + + try + { + var config = new GameLaunchConfiguration + { + ExecutablePath = tempScript, + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(ProcessConstants.LauncherDetectionDelayMs + 200)); + + // Act & Assert + await Assert.ThrowsAnyAsync( + () => _processManager.StartProcessAsync(config, cts.Token)); + } + finally + { + if (File.Exists(tempScript)) + { + try + { + File.Delete(tempScript); + } + catch + { + // Best effort. + } + } + } + } + /// /// Tests that StartProcessAsync handles invalid executable path. /// @@ -303,12 +409,12 @@ public async Task TerminateProcessAsync_WithRunningProcess_ShouldReturnSuccessAs if (OperatingSystem.IsWindows()) { - tempExe = Path.GetTempFileName() + ".bat"; + tempExe = Path.Combine(Path.GetTempPath(), $"genhub_test_{Guid.NewGuid():N}.bat"); scriptContent = "@echo off\nping -n 6 127.0.0.1 >nul\n"; } else { - tempExe = Path.GetTempFileName() + ".sh"; + tempExe = Path.Combine(Path.GetTempPath(), $"genhub_test_{Guid.NewGuid():N}.sh"); scriptContent = "#!/bin/bash\nping -c 5 127.0.0.1 > /dev/null\n"; } @@ -349,7 +455,21 @@ public async Task TerminateProcessAsync_WithRunningProcess_ShouldReturnSuccessAs } finally { - File.Delete(tempExe); + try + { + if (File.Exists(tempExe)) + { + File.Delete(tempExe); + } + } + catch (IOException) + { + // Process termination lock release may be slightly deferred by the OS + } + catch (UnauthorizedAccessException) + { + // Ignored if access denied during process termination + } } } @@ -370,10 +490,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 +506,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 +548,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 +562,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 +617,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..092feaa05 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; @@ -396,17 +398,466 @@ public async Task SaveSettings_Should_HandleFailureGracefullyAsync() } /// - /// Should update selected preset when resolution matches preset. + /// Should keep settings.json keys the view model does not model when saving over them. /// + /// A representing the asynchronous operation. [Fact] - public void ApplyOptionsToViewModel_Should_UpdateSelectedPreset_WhenResolutionMatches() + public async Task SaveSettings_Should_PreserveUnknownGeneralsOnlineKeysAsync() { // Arrange - var options = new IniOptions + 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 { - Video = new VideoSettings { ResolutionWidth = 1920, ResolutionHeight = 1080 }, + 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. + /// + [Fact] + public void ApplyOptionsToViewModel_Should_UpdateSelectedPreset_WhenResolutionMatches() + { // Act - Simulate loading options _viewModel.ResolutionWidth = 1920; _viewModel.ResolutionHeight = 1080; @@ -418,4 +869,99 @@ public void ApplyOptionsToViewModel_Should_UpdateSelectedPreset_WhenResolutionMa // Assert Assert.Equal("1920x1080", _viewModel.SelectedResolutionPreset); } + + /// + /// Should load GameWindowTransitionSpeedMultiplier from profile when initializing. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeForProfileAsync_Should_LoadGameWindowTransitionSpeedMultiplier_FromProfileAsync() + { + // Arrange + var profile = new GameProfile + { + Id = "tsh-profile", + Name = "TSH Profile", + GameClient = new GameClient { GameType = GameType.ZeroHour }, + TshGameWindowTransitionSpeedMultiplier = 3.25f, + }; + + // Act + await _viewModel.InitializeForProfileAsync("tsh-profile", profile); + + // Assert + Assert.Equal(3.25f, _viewModel.TshGameWindowTransitionSpeedMultiplier); + } + + /// + /// Should clamp GameWindowTransitionSpeedMultiplier when initializing with out-of-range value. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeForProfileAsync_Should_ClampGameWindowTransitionSpeedMultiplier_WhenOutOfRangeAsync() + { + // Arrange + var profile = new GameProfile + { + Id = "tsh-profile", + Name = "TSH Profile", + GameClient = new GameClient { GameType = GameType.ZeroHour }, + TshGameWindowTransitionSpeedMultiplier = 50.0f, + }; + + // Act + await _viewModel.InitializeForProfileAsync("tsh-profile", profile); + + // Assert + Assert.Equal(4.0f, _viewModel.TshGameWindowTransitionSpeedMultiplier); + } + + /// + /// Should save GameWindowTransitionSpeedMultiplier to Options.ini and profile request. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_SaveGameWindowTransitionSpeedMultiplier_ToOptionsAsync() + { + // Arrange + var profile = new GameProfile + { + Id = "tsh-profile", + Name = "TSH Profile", + GameClient = new GameClient { GameType = GameType.ZeroHour }, + }; + IniOptions? savedOptions = null; + + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(It.IsAny(), It.IsAny())) + .Callback((_, opt) => savedOptions = opt) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + await _viewModel.InitializeForProfileAsync("tsh-profile", profile); + _viewModel.TshGameWindowTransitionSpeedMultiplier = 3.55f; + + // Act + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + var request = _viewModel.GetProfileSettings(); + + // Assert + Assert.NotNull(savedOptions); + Assert.True(savedOptions.AdditionalSections.TryGetValue("TheSuperHackers", out var tsh)); + Assert.True(tsh.TryGetValue("GameWindowTransitionSpeedMultiplier", out var speed)); + Assert.Equal("3.55", speed); + Assert.Equal(3.55f, request.TshGameWindowTransitionSpeedMultiplier); + } + + 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..c476236bf 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,11 +1,15 @@ +using System; +using System.IO; using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; using GenHub.Common.ViewModels; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Interfaces.GitHub; -using GenHub.Core.Interfaces.Info; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Notifications; using GenHub.Core.Interfaces.Shortcuts; @@ -14,6 +18,7 @@ using GenHub.Core.Interfaces.Tools; using GenHub.Core.Interfaces.UserData; using GenHub.Core.Interfaces.Workspace; +using GenHub.Core.Messages; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Notifications; @@ -29,6 +34,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using Xunit; namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels; @@ -43,37 +49,8 @@ public class MainViewModelTests [Fact] public void Constructor_CreatesValidInstance() { - // 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 = CreateMainViewModel(); - // Act - 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); - - // Assert Assert.NotNull(vm); Assert.IsType(vm); } @@ -90,76 +67,11 @@ public void Constructor_CreatesValidInstance() [InlineData(NavigationTab.Info)] public void SelectTabCommand_SetsSelectedTab(NavigationTab tab) { - 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); + var vm = CreateMainViewModel(); vm.SelectTabCommand.Execute(tab); Assert.Equal(tab, vm.SelectedTab); } - /// - /// Tests that multiple calls to are safe. - /// - /// A representing the asynchronous operation. - [Fact] - public async Task InitializeAsync_MultipleCallsAreSafeAsync() - { - // Arrange - var (settingsVm, userSettingsMock) = CreateSettingsVm(); - var toolsVm = CreateToolsVm(); - var configProvider = CreateConfigProviderMock(); - var mockVelopackUpdateManager = new Mock(); - mockVelopackUpdateManager.Setup(x => x.CheckForUpdatesAsync(It.IsAny())) - .ReturnsAsync((Velopack.UpdateInfo?)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(); // Should not throw - Assert.True(true); - } - /// /// Tests that CurrentTabViewModel returns the correct ViewModel based on SelectedTab. /// @@ -172,32 +84,7 @@ public async Task InitializeAsync_MultipleCallsAreSafeAsync() [InlineData(NavigationTab.Info)] public void CurrentTabViewModel_ReturnsCorrectViewModel(NavigationTab tab) { - 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); + var vm = CreateMainViewModel(); vm.SelectTabCommand.Execute(tab); var currentViewModel = vm.CurrentTabViewModel; Assert.NotNull(currentViewModel); @@ -219,14 +106,99 @@ public void CurrentTabViewModel_ReturnsCorrectViewModel(NavigationTab tab) Assert.IsType(currentViewModel); break; default: - // No additional specific tab assertions - break; + throw new ArgumentOutOfRangeException(nameof(tab), tab, "Unknown navigation tab"); } } /// - /// Creates a default ToolsViewModel with mocked services for reuse. + /// Tests that initializes tab viewmodels and background update coordinator. /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_InitializesTabsAndBackgroundCoordinatorAsync() + { + var mockBackgroundCoordinator = new Mock(); + var vm = CreateMainViewModel(mockBackgroundCoordinator: mockBackgroundCoordinator); + + await vm.InitializeAsync(); + + mockBackgroundCoordinator.Verify(x => x.InitializeAsync(It.IsAny()), Times.Once); + } + + /// + /// Tests that multiple calls to are safe. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_MultipleCallsAreSafeAsync() + { + var mockBackgroundCoordinator = new Mock(); + var vm = CreateMainViewModel(mockBackgroundCoordinator: mockBackgroundCoordinator); + await vm.InitializeAsync(); + await vm.InitializeAsync(); + mockBackgroundCoordinator.Verify(x => x.InitializeAsync(It.IsAny()), Times.Exactly(2)); + } + + /// + /// Tests that can be called multiple times without throwing. + /// + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + var vm = CreateMainViewModel(); + + var exception = Record.Exception(() => + { + vm.Dispose(); + vm.Dispose(); + }); + + Assert.Null(exception); + } + + /// + /// Tests that selects the requested tab. + /// + [Fact] + public void SelectTabCommand_SelectsRequestedTab() + { + var vm = CreateMainViewModel(); + vm.SelectTabCommand.Execute(NavigationTab.Settings); + Assert.Equal(NavigationTab.Settings, vm.SelectedTab); + } + + private static MainViewModel CreateMainViewModel( + Mock? mockBackgroundCoordinator = null, + Mock? mockUserSettings = null) + { + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var coordinator = mockBackgroundCoordinator ?? new Mock(); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + return new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: mockUserSettings?.Object ?? userSettingsMock.Object, + backgroundUpdateCoordinator: coordinator.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + } + private static ToolsViewModel CreateToolsVm() { var mockToolService = new Mock(); @@ -235,9 +207,6 @@ private static ToolsViewModel CreateToolsVm() return new ToolsViewModel(mockToolService.Object, mockLogger.Object, mockServiceProvider.Object); } - /// - /// Creates a default SettingsViewModel with mocked services for reuse. - /// private static (SettingsViewModel SettingsVm, Mock UserSettingsMock) CreateSettingsVm() { var mockUserSettings = new Mock(); @@ -253,6 +222,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,15 +238,15 @@ private static (SettingsViewModel SettingsVm, Mock UserSet mockInstallationService.Object, mockStorageLocationService.Object, mockUserDataTracker.Object, - mockGitHubTokenStorage.Object); + mockDialogService.Object, + themeService: null, + gitHubTokenStorage: mockGitHubTokenStorage.Object); return (settingsVm, mockUserSettings); } private static IConfigurationProviderService CreateConfigProviderMock() { var mock = new Mock(); - - // Minimal defaults used by MainViewModel mock.Setup(x => x.GetLastSelectedTab()).Returns(NavigationTab.GameProfiles); var tempPath = Path.Combine(Path.GetTempPath(), "GenHub", "Manifests", Guid.NewGuid().ToString()); Directory.CreateDirectory(tempPath); @@ -284,20 +254,15 @@ private static IConfigurationProviderService CreateConfigProviderMock() return mock.Object; } - /// - /// Helper method to create a DownloadsViewModel with mocked dependencies. - /// private static DownloadsViewModel CreateDownloadsViewModel(IConfigurationProviderService configProvider) { var mockServiceProvider = new Mock(); var mockLogger = new Mock>(); var mockNotificationService = new Mock(); - // Create the three required dependencies for the discoverer var mockGitHubClient = new Mock(); var mockDiscovererLogger = new Mock>(); - // Instantiate the real class with the two mocks var realGitHubDiscoverer = new GitHubTopicsDiscoverer( mockGitHubClient.Object, mockDiscovererLogger.Object); @@ -310,9 +275,6 @@ private static DownloadsViewModel CreateDownloadsViewModel(IConfigurationProvide configProvider); } - /// - /// Helper method to create a GameProfileLauncherViewModel with mocked dependencies. - /// private static GameProfileLauncherViewModel CreateGameProfileLauncherViewModel() { var installationService = new Mock(); @@ -323,13 +285,13 @@ private static GameProfileLauncherViewModel CreateGameProfileLauncherViewModel() new Mock().Object, new Mock().Object, new Mock().Object, - null, // ProfileResourceService - null, // INotificationService - null, // IContentManifestPool - null, // IContentStorageService - null, // ILocalContentService - null, // IGenLauncherNormalizationService - null, // IDialogService + null, + null, + null, + null, + null, + null, + null, NullLogger.Instance, NullLogger.Instance); @@ -365,6 +327,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..a667962d9 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 @@ -14,6 +14,7 @@ using GenHub.Core.Models.Results; using GenHub.Core.Models.Results.CAS; using GenHub.Core.Models.Storage; +using GenHub.Core.Models.Theming; using GenHub.Core.Models.Workspace; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.Settings.ViewModels; @@ -39,6 +40,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 +60,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)); } /// @@ -72,7 +78,7 @@ public void Constructor_LoadsSettingsFromUserSettingsService() // Arrange var customSettings = new UserSettings { - Theme = "Light", + Theme = "Emerald", MaxConcurrentDownloads = 5, EnableDetailedLogging = true, WorkspacePath = "/custom/path", @@ -93,10 +99,11 @@ public void Constructor_LoadsSettingsFromUserSettingsService() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Assert - Assert.Equal("Light", viewModel.Theme); + Assert.Equal("Emerald", viewModel.Theme); Assert.Equal(5, viewModel.MaxConcurrentDownloads); Assert.True(viewModel.EnableDetailedLogging); Assert.Equal("/custom/path", viewModel.WorkspacePath); @@ -122,12 +129,15 @@ public async Task SaveSettingsCommand_UpdatesUserSettingsServiceAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { - Theme = "Light", + Theme = "Emerald", MaxConcurrentDownloads = 5, }; + _mockConfigService.Invocations.Clear(); + // Act await Task.Run(() => viewModel.SaveSettingsCommand.Execute(null)); @@ -156,9 +166,10 @@ public async Task ResetToDefaultsCommand_ResetsAllPropertiesAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { - Theme = "Light", + Theme = "Emerald", MaxConcurrentDownloads = 10, EnableDetailedLogging = true, }; @@ -167,10 +178,92 @@ public async Task ResetToDefaultsCommand_ResetsAllPropertiesAsync() await Task.Run(() => viewModel.ResetToDefaultsCommand.Execute(null)); // Assert - Assert.Equal("Dark", viewModel.Theme); + Assert.Equal(ThemeConstants.DefaultTheme.Id, viewModel.Theme); 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 +285,8 @@ public void MaxConcurrentDownloads_SetsValueWithinBounds() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { // Act & Assert - Test lower bound MaxConcurrentDownloads = 0, @@ -215,7 +309,7 @@ public void MaxConcurrentDownloads_SetsValueWithinBounds() public void AvailableThemes_ReturnsExpectedValues() { // Arrange - _ = new SettingsViewModel( + var viewModel = new SettingsViewModel( _mockConfigService.Object, _mockLogger.Object, _mockCasService.Object, @@ -227,15 +321,16 @@ public void AvailableThemes_ReturnsExpectedValues() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act - var themes = SettingsViewModel.AvailableThemes.ToList(); + var themes = viewModel.AvailableThemes.Select(t => t.Id).ToList(); // Assert - Assert.Contains("Dark", themes); - Assert.Contains("Light", themes); - Assert.Equal(2, themes.Count); + Assert.Contains("Purple", themes); + Assert.Contains("Generals", themes); + Assert.True(themes.Count >= 12); } /// @@ -257,7 +352,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 +385,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 +424,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 +465,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 +509,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 +518,284 @@ 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); + } + + /// + /// Verifies that SelectColorThemeCommand updates selected theme and saves user settings. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task SelectColorThemeCommand_UpdatesSelectedThemeAndPersistsAsync() + { + // Arrange + var mockThemeService = new Mock(); + mockThemeService.Setup(s => s.AvailableThemes).Returns(ThemeConstants.AllThemes); + + 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, + mockThemeService.Object); + + // Act + await viewModel.SelectColorThemeCommand.ExecuteAsync(ThemeConstants.EmeraldTheme); + + // Assert + Assert.Equal("Emerald", viewModel.Theme); + Assert.Equal(ThemeConstants.EmeraldTheme, viewModel.SelectedTheme); + mockThemeService.Verify(s => s.ApplyTheme(ThemeConstants.EmeraldTheme), Times.Once); + _mockConfigService.Verify(s => s.Update(It.IsAny>()), Times.Once); + _mockConfigService.Verify(s => s.SaveAsync(It.IsAny()), Times.Once); + } + + /// + /// Verifies that ResetToDefaultsCommand resets the active theme to default. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ResetToDefaultsCommand_ResetsThemeToDefaultThemeAsync() + { + // Arrange + var mockThemeService = new Mock(); + mockThemeService.Setup(s => s.AvailableThemes).Returns(ThemeConstants.AllThemes); + + 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, + mockThemeService.Object) + { + Theme = "Emerald", + }; + + // Act + await viewModel.ResetToDefaultsCommand.ExecuteAsync(null); + + // Assert + Assert.Equal(ThemeConstants.DefaultTheme.Id, viewModel.Theme); + Assert.Equal(ThemeConstants.DefaultTheme, viewModel.SelectedTheme); + mockThemeService.Verify(s => s.ApplyTheme(ThemeConstants.DefaultTheme), Times.Once); + } + + 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..53b1f6ce6 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,236 @@ 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); + } + } + + /// + /// Should parse GameWindowTransitionSpeedMultiplier correctly from Options.ini. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task LoadTheSuperHackersSettingsAsync_Should_ParseGameWindowTransitionSpeedMultiplierAsync() + { + // Arrange + var content = @"[TheSuperHackers] +GameWindowTransitionSpeedMultiplier=3.5 +MoneyTransactionVolume=60 +"; + var tempFile = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempFile, content); + + var mockService = new Mock(MockBehavior.Loose, _loggerMock.Object, _pathProviderMock.Object) + { + CallBase = true, + }; + mockService.Setup(x => x.GetOptionsFilePath(It.IsAny())).Returns(tempFile); + + try + { + // Act + var result = await mockService.Object.LoadTheSuperHackersSettingsAsync(GameType.ZeroHour); + + // Assert + Assert.True(result.Success, result.FirstError); + Assert.Equal(3.5f, result.Data!.GameWindowTransitionSpeedMultiplier); + Assert.Equal(60, result.Data!.MoneyTransactionVolume); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Should save and preserve GameWindowTransitionSpeedMultiplier across round-trips. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveTheSuperHackersSettingsAsync_Should_SerializeGameWindowTransitionSpeedMultiplierAsync() + { + // Arrange + var tempFile = Path.GetTempFileName(); + var mockService = new Mock(MockBehavior.Loose, _loggerMock.Object, _pathProviderMock.Object) + { + CallBase = true, + }; + mockService.Setup(x => x.GetOptionsFilePath(It.IsAny())).Returns(tempFile); + + try + { + var settings = new TheSuperHackersSettings + { + GameWindowTransitionSpeedMultiplier = 3.5f, + MoneyTransactionVolume = 75, + }; + + // Act + var saveResult = await mockService.Object.SaveTheSuperHackersSettingsAsync(GameType.ZeroHour, settings); + var loadResult = await mockService.Object.LoadTheSuperHackersSettingsAsync(GameType.ZeroHour); + + // Assert + Assert.True(saveResult.Success, saveResult.FirstError); + Assert.True(loadResult.Success, loadResult.FirstError); + Assert.Equal(3.5f, loadResult.Data!.GameWindowTransitionSpeedMultiplier); + Assert.Equal(75, loadResult.Data!.MoneyTransactionVolume); + } + finally + { + File.Delete(tempFile); + } + } + + 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..e8573c510 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ReplayImportServiceTests.cs @@ -0,0 +1,165 @@ +using System.IO.Compression; +using System.Text; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.ReplayManager; +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)); + } + + /// + /// Verifies that ImportFromUrlAsync imports all replays when multiple URLs are extracted from a match page. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromUrlAsync_WithMultipleExtractedUrls_ImportsAllFilesAsync() + { + var downloadService = new Mock(); + downloadService.Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((cfg, _, _) => + File.WriteAllBytes(cfg.DestinationPath, "fake-replay-content"u8.ToArray())) + .ReturnsAsync(DownloadResult.CreateSuccess("test.rep", 100, TimeSpan.FromSeconds(1))); + + var urlParser = new Mock(); + urlParser.Setup(u => u.IdentifySource(It.IsAny())).Returns(ReplaySource.Strata); + urlParser.Setup(u => u.GetDirectDownloadUrlsAsync("https://strata.gamereplays.org/zh/match/3489856", It.IsAny())) + .ReturnsAsync( + [ + "https://matchdata.playgenerals.online/match_1_user_1_replay.rep", + "https://matchdata.playgenerals.online/match_1_user_2_replay.rep", + ]); + + var directoryService = new Mock(); + directoryService.Setup(d => d.GetReplayDirectory(It.IsAny())).Returns(_replayDirectory); + + var service = new ReplayImportService( + downloadService.Object, + directoryService.Object, + urlParser.Object, + new Mock().Object, + NullLogger.Instance); + + var result = await service.ImportFromUrlAsync("https://strata.gamereplays.org/zh/match/3489856", GameType.ZeroHour); + + Assert.True(result.Success); + Assert.Equal(2, result.FilesImported); + Assert.Equal(2, Directory.GetFiles(_replayDirectory).Length); + } + + 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/Tools/Services/UploadHistoryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadHistoryServiceTests.cs index 910d6d1bd..67fee0d40 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadHistoryServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadHistoryServiceTests.cs @@ -1,16 +1,25 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Services; using GenHub.Features.Tools.Services; using Microsoft.Extensions.Logging; using Moq; +using Xunit; namespace GenHub.Tests.Core.Features.Tools.Services; /// -/// Tests for local upload history behavior while cloud deletion is disabled. +/// Tests for local upload history tracking and cloud deletion orchestration. /// public sealed class UploadHistoryServiceTests : IDisposable { private readonly string _tempDirectory; + private readonly Mock _uploadThingServiceMock = new(); /// /// Initializes a new instance of the class. @@ -44,14 +53,61 @@ public async Task RemoveHistoryItemAsync_WhenItemExists_RemovesLocalRecordAsync( var service = CreateService(); service.RecordUpload(1024, "https://utfs.io/f/example", "example.zip"); - await service.RemoveHistoryItemAsync("https://utfs.io/f/example"); + await service.RemoveHistoryItemAsync("https://utfs.io/f/example", deleteFromCloud: false); var reloadedService = CreateService(); Assert.Empty(await reloadedService.GetUploadHistoryAsync()); } /// - /// Verifies that removing one item preserves the other local records. + /// Verifies that removing an item with cloud deletion invokes IUploadThingService.DeleteFileAsync. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task RemoveHistoryItemAsync_WhenTokenExists_InvokesCloudDeletionAsync() + { + _uploadThingServiceMock + .Setup(u => u.DeleteFileAsync("key_123", "token_abc", It.IsAny())) + .ReturnsAsync(GenHub.Core.Models.Results.OperationResult.CreateSuccess(true)); + + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/key_123", "example.zip", "key_123", "token_abc"); + + var success = await service.RemoveHistoryItemAsync("https://utfs.io/f/key_123", deleteFromCloud: true); + + Assert.True(success); + _uploadThingServiceMock.Verify( + u => u.DeleteFileAsync("key_123", "token_abc", It.IsAny()), + Times.Once); + + var reloadedService = CreateService(); + Assert.Empty(await reloadedService.GetUploadHistoryAsync()); + } + + /// + /// Verifies that when cloud deletion fails, local history preserves the record so deletion can be retried. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task RemoveHistoryItemAsync_WhenCloudDeletionFails_PreservesRecordForRetryAsync() + { + _uploadThingServiceMock + .Setup(u => u.DeleteFileAsync("key_123", "token_abc", It.IsAny())) + .ReturnsAsync(GenHub.Core.Models.Results.OperationResult.CreateFailure("Delete failed")); + + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/key_123", "example.zip", "key_123", "token_abc"); + + var success = await service.RemoveHistoryItemAsync("https://utfs.io/f/key_123", deleteFromCloud: true); + + Assert.False(success); + var reloadedService = CreateService(); + var item = Assert.Single(await reloadedService.GetUploadHistoryAsync()); + Assert.Equal("https://utfs.io/f/key_123", item.Url); + } + + /// + /// Verifies that removing one item preserves other local records. /// /// A task representing the asynchronous test operation. [Fact] @@ -61,7 +117,7 @@ public async Task RemoveHistoryItemAsync_WhenOtherItemsExist_PreservesOtherRecor service.RecordUpload(1024, "https://utfs.io/f/first", "first.zip"); service.RecordUpload(2048, "https://utfs.io/f/second", "second.zip"); - await service.RemoveHistoryItemAsync("https://utfs.io/f/first"); + await service.RemoveHistoryItemAsync("https://utfs.io/f/first", deleteFromCloud: false); var reloadedService = CreateService(); var item = Assert.Single(await reloadedService.GetUploadHistoryAsync()); @@ -78,13 +134,71 @@ public async Task RemoveHistoryItemAsync_WhenUrlDoesNotMatch_PreservesHistoryAsy var service = CreateService(); service.RecordUpload(1024, "https://utfs.io/f/example", "example.zip"); - await service.RemoveHistoryItemAsync("https://utfs.io/f/missing"); + await service.RemoveHistoryItemAsync("https://utfs.io/f/missing", deleteFromCloud: false); var reloadedService = CreateService(); var item = Assert.Single(await reloadedService.GetUploadHistoryAsync()); Assert.Equal("https://utfs.io/f/example", item.Url); } + /// + /// Verifies that removing an item by default invokes IUploadThingService.DeleteFileAsync when token exists. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task RemoveHistoryItemAsync_Default_InvokesCloudDeletionAsync() + { + _uploadThingServiceMock + .Setup(u => u.DeleteFileAsync("key_default", "token_default", It.IsAny())) + .ReturnsAsync(GenHub.Core.Models.Results.OperationResult.CreateSuccess(true)); + + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/key_default", "default.zip", "key_default", "token_default"); + + var success = await service.RemoveHistoryItemAsync("https://utfs.io/f/key_default"); + + Assert.True(success); + _uploadThingServiceMock.Verify( + u => u.DeleteFileAsync("key_default", "token_default", It.IsAny()), + Times.Once); + + var reloadedService = CreateService(); + Assert.Empty(await reloadedService.GetUploadHistoryAsync()); + } + + /// + /// Verifies that clearing history by default invokes IUploadThingService.DeleteFileAsync for all items with tokens. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ClearHistoryAsync_WhenItemsHaveTokens_InvokesCloudDeletionForAllAsync() + { + _uploadThingServiceMock + .Setup(u => u.DeleteFileAsync("key_1", "token_1", It.IsAny())) + .ReturnsAsync(GenHub.Core.Models.Results.OperationResult.CreateSuccess(true)); + _uploadThingServiceMock + .Setup(u => u.DeleteFileAsync("key_2", "token_2", It.IsAny())) + .ReturnsAsync(GenHub.Core.Models.Results.OperationResult.CreateSuccess(true)); + + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/key_1", "first.zip", "key_1", "token_1"); + service.RecordUpload(2048, "https://utfs.io/f/key_2", "second.zip", "key_2", "token_2"); + + var result = await service.ClearHistoryAsync(); + + Assert.Equal(2, result.Deleted); + Assert.Equal(0, result.Failed); + _uploadThingServiceMock.Verify( + u => u.DeleteFileAsync("key_1", "token_1", It.IsAny()), + Times.Once); + _uploadThingServiceMock.Verify( + u => u.DeleteFileAsync("key_2", "token_2", It.IsAny()), + Times.Once); + + var reloadedService = CreateService(); + Assert.Empty(await reloadedService.GetUploadHistoryAsync()); + } + /// /// Verifies that clearing history deletes every local record immediately. /// @@ -96,7 +210,7 @@ public async Task ClearHistoryAsync_WhenItemsExist_RemovesAllLocalRecordsAsync() service.RecordUpload(1024, "https://utfs.io/f/first", "first.zip"); service.RecordUpload(2048, "https://utfs.io/f/second", "second.zip"); - await service.ClearHistoryAsync(); + await service.ClearHistoryAsync(deleteFromCloud: false); var reloadedService = CreateService(); Assert.Empty(await reloadedService.GetUploadHistoryAsync()); @@ -143,7 +257,7 @@ public async Task GetUploadHistoryAsync_WhenLegacyRecordIsPendingDeletion_Remove } ] """; - File.WriteAllText(historyPath, historyJson); + await File.WriteAllTextAsync(historyPath, historyJson); var service = CreateService(); var history = await service.GetUploadHistoryAsync(); @@ -151,7 +265,7 @@ public async Task GetUploadHistoryAsync_WhenLegacyRecordIsPendingDeletion_Remove var item = Assert.Single(history); Assert.Equal("https://utfs.io/f/active", item.Url); - var migratedJson = File.ReadAllText(historyPath); + var migratedJson = await File.ReadAllTextAsync(historyPath); Assert.DoesNotContain("https://utfs.io/f/pending", migratedJson); Assert.DoesNotContain("isPendingDeletion", migratedJson); @@ -159,12 +273,134 @@ public async Task GetUploadHistoryAsync_WhenLegacyRecordIsPendingDeletion_Remove Assert.Single(await reloadedService.GetUploadHistoryAsync()); } + /// + /// Verifies that FindExistingUploadAsync returns the matching record when the hash matches. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task FindExistingUploadAsync_WhenHashMatches_ReturnsExistingRecordAsync() + { + var service = CreateService(); + var fileHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + service.RecordUpload(1024, "https://utfs.io/f/existing", "map.zip", "key_1", "token_1", fileHash); + + var record = await service.FindExistingUploadAsync(fileHash); + + Assert.NotNull(record); + Assert.Equal("https://utfs.io/f/existing", record.Url); + Assert.Equal(fileHash, record.FileHash); + Assert.Equal("key_1", record.FileKey); + Assert.Equal("token_1", record.DeleteToken); + } + + /// + /// Verifies that FindExistingUploadAsync returns null when no matching hash exists. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task FindExistingUploadAsync_WhenHashNotFound_ReturnsNullAsync() + { + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/existing", "map.zip", "key_1", "token_1", "hash_abc"); + + var record = await service.FindExistingUploadAsync("hash_nonexistent"); + + Assert.Null(record); + } + + /// + /// Verifies that GetUploadHistoryAsync with category filter returns only matching items. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetUploadHistoryAsync_WithCategoryFilter_ReturnsOnlyMatchingItemsAsync() + { + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/replay1", "game.rep", "key_rep", "token_rep", null, ReplayManagerConstants.UploadCategory); + service.RecordUpload(2048, "https://utfs.io/f/map1", "custom_map.zip", "key_map", "token_map", null, MapManagerConstants.UploadCategory); + + var replayHistory = (await service.GetUploadHistoryAsync(ReplayManagerConstants.UploadCategory)).ToList(); + var mapHistory = (await service.GetUploadHistoryAsync(MapManagerConstants.UploadCategory)).ToList(); + var allHistory = (await service.GetUploadHistoryAsync()).ToList(); + + Assert.Single(replayHistory); + Assert.Equal("game.rep", replayHistory[0].FileName); + Assert.Single(mapHistory); + Assert.Equal("custom_map.zip", mapHistory[0].FileName); + Assert.Equal(2, allHistory.Count); + } + + /// + /// Verifies that ClearHistoryAsync with category filter clears only items of that category. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ClearHistoryAsync_WithCategoryFilter_ClearsOnlySpecifiedCategoryAsync() + { + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/replay1", "game.rep", "key_rep", "token_rep", null, ReplayManagerConstants.UploadCategory); + service.RecordUpload(2048, "https://utfs.io/f/map1", "custom_map.zip", "key_map", "token_map", null, MapManagerConstants.UploadCategory); + + await service.ClearHistoryAsync(deleteFromCloud: false, category: ReplayManagerConstants.UploadCategory); + + var replayHistory = (await service.GetUploadHistoryAsync(ReplayManagerConstants.UploadCategory)).ToList(); + var mapHistory = (await service.GetUploadHistoryAsync(MapManagerConstants.UploadCategory)).ToList(); + + Assert.Empty(replayHistory); + Assert.Single(mapHistory); + Assert.Equal("custom_map.zip", mapHistory[0].FileName); + } + + /// + /// Verifies that CanUploadAsync respects category-specific quota limits. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task CanUploadAsync_WithCategory_AppliesCategoryQuotaAsync() + { + var service = CreateService(); + + // 9MB replay upload (within 10MB replay limit) + Assert.True(await service.CanUploadAsync(9 * 1024 * 1024, ReplayManagerConstants.UploadCategory)); + + // 11MB replay upload (exceeds 10MB replay limit) + Assert.False(await service.CanUploadAsync(11 * 1024 * 1024, ReplayManagerConstants.UploadCategory)); + + // 50MB map upload (within 100MB map limit) + Assert.True(await service.CanUploadAsync(50 * 1024 * 1024, MapManagerConstants.UploadCategory)); + + // 101MB map upload (exceeds 100MB map limit) + Assert.False(await service.CanUploadAsync(101 * 1024 * 1024, MapManagerConstants.UploadCategory)); + } + + /// + /// Verifies that GetUsageInfoAsync computes usage and limits partitioned by category. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetUsageInfoAsync_WithCategory_ReturnsCategorySpecificUsageAsync() + { + var service = CreateService(); + service.RecordUpload(5 * 1024 * 1024, "https://utfs.io/f/replay1", "game.rep", "key_rep", "token_rep", null, ReplayManagerConstants.UploadCategory); + service.RecordUpload(20 * 1024 * 1024, "https://utfs.io/f/map1", "map.zip", "key_map", "token_map", null, MapManagerConstants.UploadCategory); + + var replayUsage = await service.GetUsageInfoAsync(ReplayManagerConstants.UploadCategory); + var mapUsage = await service.GetUsageInfoAsync(MapManagerConstants.UploadCategory); + + Assert.Equal(5 * 1024 * 1024, replayUsage.UsedBytes); + Assert.Equal(ReplayManagerConstants.MaxUploadBytesPerPeriod, replayUsage.LimitBytes); + + Assert.Equal(20 * 1024 * 1024, mapUsage.UsedBytes); + Assert.Equal(MapManagerConstants.MaxUploadBytesPerPeriod, mapUsage.LimitBytes); + } + private UploadHistoryService CreateService() { var appConfig = new Mock(); appConfig.Setup(config => config.GetConfiguredDataPath()).Returns(_tempDirectory); return new UploadHistoryService( + _uploadThingServiceMock.Object, Mock.Of>(), appConfig.Object); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadThingServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadThingServiceTests.cs index 280f00652..25cb47dfb 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadThingServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadThingServiceTests.cs @@ -1,38 +1,297 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Tools.UploadThing; using GenHub.Features.Tools.Services; using Microsoft.Extensions.Logging; using Moq; +using Moq.Protected; +using Xunit; namespace GenHub.Tests.Core.Features.Tools.Services; /// -/// Tests for the disabled UploadThing integration. +/// Tests for the gateway-mediated UploadThingService integration. /// -public class UploadThingServiceTests +public sealed class UploadThingServiceTests : IDisposable { - private readonly UploadThingService _service = - new(Mock.Of>()); + private readonly string _tempDirectory; + private readonly Mock> _loggerMock = new(); /// - /// Verifies that uploads fail closed while short-lived credentials are unavailable. + /// Initializes a new instance of the class. + /// + public UploadThingServiceTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDirectory); + } + + /// + /// Removes temporary test data. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that UploadFileAsync returns failure when the file does not exist. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task UploadFileAsync_WhenFileDoesNotExist_ReturnsFailureAsync() + { + var handlerMock = new Mock(); + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + var result = await service.UploadFileAsync(Path.Combine(_tempDirectory, "nonexistent.zip")); + + Assert.False(result.Success); + Assert.NotNull(result.FirstError); + } + + /// + /// Verifies that UploadFileAsync completes successfully through direct gateway upload. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task UploadFileAsync_WhenGatewaySucceeds_ReturnsUploadResultAsync() + { + var testFilePath = Path.Combine(_tempDirectory, "test_replay.zip"); + await File.WriteAllBytesAsync(testFilePath, [0x50, 0x4B, 0x03, 0x04, 0x00, 0x00]); + + var uploadResponse = new DirectUploadResponse( + "https://utfs.io/f/test_key_123", + "test_key_123", + "test_key_123:1755820800.hmac_sig"); + + var handlerMock = new Mock(); + + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.ToString().Contains(ApiConstants.UploadEndpoint)), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(uploadResponse)), + }); + + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + var progressMock = new Mock>(); + var result = await service.UploadFileAsync(testFilePath, progressMock.Object); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("https://utfs.io/f/test_key_123", result.Data.PublicUrl); + Assert.Equal("test_key_123", result.Data.FileKey); + Assert.Equal("test_key_123:1755820800.hmac_sig", result.Data.DeleteToken); + progressMock.Verify(p => p.Report(It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Verifies that UploadFileAsync returns failure when the gateway rejects the request. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task UploadFileAsync_WhenGatewayRejects_ReturnsFailureAsync() + { + var testFilePath = Path.Combine(_tempDirectory, "oversized.zip"); + await File.WriteAllBytesAsync(testFilePath, [0x50, 0x4B, 0x03, 0x04]); + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"error\":\"File size exceeds 10MB limit\"}"), + }); + + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + var result = await service.UploadFileAsync(testFilePath); + + Assert.False(result.Success); + Assert.NotNull(result.FirstError); + } + + /// + /// Verifies that UploadFileAsync returns failure when the gateway returns incomplete JSON. /// /// A task representing the asynchronous test operation. [Fact] - public async Task UploadFileAsync_WhenCredentialsAreUnavailable_ReturnsNullAsync() + public async Task UploadFileAsync_WhenIncompleteResponse_ReturnsFailureAsync() { - var result = await _service.UploadFileAsync("unused.zip"); + var testFilePath = Path.Combine(_tempDirectory, "partial.zip"); + await File.WriteAllBytesAsync(testFilePath, [0x50, 0x4B, 0x03, 0x04]); - Assert.Null(result); + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"publicUrl\":\"https://utfs.io/f/partial\"}"), + }); + + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + var result = await service.UploadFileAsync(testFilePath); + + Assert.False(result.Success); + Assert.NotNull(result.FirstError); + } + + /// + /// Verifies that UploadFileAsync propagates OperationCanceledException upon cancellation. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task UploadFileAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() + { + var testFilePath = Path.Combine(_tempDirectory, "canceled.zip"); + await File.WriteAllBytesAsync(testFilePath, [0x50, 0x4B, 0x03, 0x04]); + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new OperationCanceledException()); + + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.UploadFileAsync(testFilePath, ct: cts.Token)); + } + + /// + /// Verifies that DeleteFileAsync returns true when the gateway accepts the delete request. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DeleteFileAsync_WhenValidKeyAndToken_ReturnsSuccessAsync() + { + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.ToString().Contains(ApiConstants.UploadDeleteEndpoint)), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"success\":true}"), + }); + + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + var result = await service.DeleteFileAsync("test_key_123", "test_key_123:1755820800.valid_sig"); + + Assert.True(result.Success); + Assert.True(result.Data); + } + + /// + /// Verifies that DeleteFileAsync returns failure when the gateway rejects the deletion. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DeleteFileAsync_WhenGatewayRejects_ReturnsFailureAsync() + { + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("{\"error\":\"Invalid or forged delete token signature\"}"), + }); + + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + var result = await service.DeleteFileAsync("test_key_123", "test_key_123:1755820800.invalid_sig"); + + Assert.False(result.Success); } /// - /// Verifies that authenticated deletion also fails closed. + /// Verifies that DeleteFileAsync returns failure when given empty or whitespace parameters. + /// + /// The file key. + /// The deletion authorization token. + /// A task representing the asynchronous test operation. + [Theory] + [InlineData("", "valid_token")] + [InlineData("valid_key", "")] + [InlineData(" ", "valid_token")] + [InlineData("valid_key", " ")] + public async Task DeleteFileAsync_WhenMissingParameters_ReturnsFailureAsync(string key, string token) + { + var handlerMock = new Mock(); + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + var result = await service.DeleteFileAsync(key, token); + + Assert.False(result.Success); + } + + /// + /// Verifies that DeleteFileAsync propagates OperationCanceledException upon cancellation. /// /// A task representing the asynchronous test operation. [Fact] - public async Task DeleteFileAsync_WhenCredentialsAreUnavailable_ReturnsFalseAsync() + public async Task DeleteFileAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() { - var result = await _service.DeleteFileAsync("unused-key"); + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new OperationCanceledException()); + + var httpClient = new HttpClient(handlerMock.Object); + var service = new UploadThingService(httpClient, _loggerMock.Object); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); - Assert.False(result); + await Assert.ThrowsAnyAsync( + () => service.DeleteFileAsync("key", "token", ct: cts.Token)); } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UrlParserServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UrlParserServiceTests.cs new file mode 100644 index 000000000..d1a056ed9 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UrlParserServiceTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Features.Tools.ReplayManager.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using Xunit; + +namespace GenHub.Tests.Core.Features.Tools.Services; + +/// +/// Unit tests for . +/// +public sealed class UrlParserServiceTests +{ + private readonly UrlParserService _service; + + /// + /// Initializes a new instance of the class. + /// + public UrlParserServiceTests() + { + var httpClient = new HttpClient(); + _service = new UrlParserService(httpClient, NullLogger.Instance); + } + + /// + /// Verifies source identification for various URL formats. + /// + /// The URL to test. + /// The expected identified source. + [Theory] + [InlineData("https://50ea2z8yuk.ufs.sh/f/ZlHfBAzftgeLJxG1453BRquaUgnl90MjYIFymdAfOpCs67GN", ReplaySource.UploadThing)] + [InlineData("https://ufs.sh/f/ZlHfBAzftgeLJxG1453BRquaUgnl90MjYIFymdAfOpCs67GN", ReplaySource.UploadThing)] + [InlineData("https://utfs.io/f/legacy_uploadthing_key_123", ReplaySource.UploadThing)] + [InlineData("https://strata.gamereplays.org/zh/match/3489856", ReplaySource.Strata)] + [InlineData("https://strata.gamereplays.org/gen/match/12345", ReplaySource.Strata)] + [InlineData("https://gamereplays.org/zh/match/12345", ReplaySource.Strata)] + [InlineData("https://www.playgenerals.online/viewmatch?match=12345", ReplaySource.GeneralsOnline)] + [InlineData("12345", ReplaySource.GeneralsOnline)] + [InlineData("https://gentool.net/data/zh/replay.rep", ReplaySource.GenTool)] + [InlineData("https://example.com/downloads/my_match.rep", ReplaySource.DirectLink)] + [InlineData("https://example.com/downloads/replays_pack.zip", ReplaySource.DirectLink)] + [InlineData("https://example.com/invalid/page.html", ReplaySource.Unknown)] + [InlineData("", ReplaySource.Unknown)] + [InlineData(" ", ReplaySource.Unknown)] + public void IdentifySource_ReturnsCorrectSource(string url, ReplaySource expectedSource) + { + var result = _service.IdentifySource(url); + Assert.Equal(expectedSource, result); + } + + /// + /// Verifies that IsValidReplayUrl correctly validates known sources. + /// + /// The URL to test. + /// Whether the URL is expected to be valid. + [Theory] + [InlineData("https://50ea2z8yuk.ufs.sh/f/key123", true)] + [InlineData("https://utfs.io/f/key123", true)] + [InlineData("https://strata.gamereplays.org/zh/match/3489856", true)] + [InlineData("https://example.com/replay.rep", true)] + [InlineData("https://example.com/page.html", false)] + public void IsValidReplayUrl_ReturnsExpectedValidity(string url, bool expectedValid) + { + var result = _service.IsValidReplayUrl(url); + Assert.Equal(expectedValid, result); + } + + /// + /// Verifies that GetDirectDownloadUrlAsync directly returns UploadThing URLs. + /// + /// The UploadThing URL. + /// A task representing the asynchronous operation. + [Theory] + [InlineData("https://50ea2z8yuk.ufs.sh/f/ZlHfBAzftgeLJxG1453BRquaUgnl90MjYIFymdAfOpCs67GN")] + [InlineData("https://utfs.io/f/legacy_uploadthing_key_123")] + public async Task GetDirectDownloadUrlAsync_WithUploadThingUrl_ReturnsOriginalUrlAsync(string url) + { + var result = await _service.GetDirectDownloadUrlAsync(url); + Assert.Equal(url, result); + } + + /// + /// Verifies that GetDirectDownloadUrlsAsync extracts multiple replays from a Strata match HTML page. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetDirectDownloadUrlsAsync_WithStrataMatchPage_ExtractsAllReplaysAsync() + { + var mockHandler = new Mock(); + const string matchHtml = """ + + +

Match #3489856

+ Player 1 Replay + Player 2 Replay + + + """; + + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(matchHtml), + }); + + var client = new HttpClient(mockHandler.Object); + var service = new UrlParserService(client, NullLogger.Instance); + + var result = await service.GetDirectDownloadUrlsAsync("https://strata.gamereplays.org/zh/match/3489856"); + + Assert.Equal(2, result.Count); + Assert.Contains("https://matchdata.playgenerals.online/replays/2026/8/23/match_3489856/user_1/match_3489856_user_1_replay.rep", result); + Assert.Contains("https://matchdata.playgenerals.online/replays/2026/8/23/match_3489856/user_2/match_3489856_user_2_replay.rep", result); + } +} 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..c452f620d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; @@ -10,6 +11,7 @@ using GenHub.Core.Interfaces.Workspace; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.UserData; using GenHub.Features.UserData.Services; using Microsoft.Extensions.Logging; using Moq; @@ -83,6 +85,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 +111,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, @@ -798,4 +826,230 @@ public async Task InstallUserDataAsync_WhenBackupFails_AbortsInstallationToPreve Assert.Contains("Failed to create safety backup", result.FirstError, StringComparison.OrdinalIgnoreCase); } } + + /// + /// Verifies that when a profile is deactivated, another profile can install the same user data files without encountering a conflict. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task InstallUserDataAsync_WhenPriorOwnerProfileIsDeactivated_SucceedsWithoutConflictAsync() + { + // Arrange + var files = new List + { + new() + { + RelativePath = "Maps/Arabia v2/AdrianeMapSettings.ini", + Hash = "hash-map-settings", + Size = 500, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + // 1. Profile A installs the map pack + var installA = await _trackerService.InstallUserDataAsync( + "mappack-id", + "profile-a", + GameType.ZeroHour, + files, + "1.0", + "Map Pack", + CancellationToken.None); + + Assert.True(installA.Success); + + // 2. Profile A is deactivated + var deactivateA = await _trackerService.DeactivateProfileUserDataAsync("profile-a", CancellationToken.None); + Assert.True(deactivateA.Success); + + // 3. Profile B installs the same map pack + var installB = await _trackerService.InstallUserDataAsync( + "mappack-id", + "profile-b", + GameType.ZeroHour, + files, + "1.0", + "Map Pack", + CancellationToken.None); + + // Assert: Installation succeeds for profile B and ownership transfers + Assert.True(installB.Success); + + var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "Arabia v2", "AdrianeMapSettings.ini"); + Assert.True(File.Exists(targetPath)); + + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); + Assert.True(conflictResult.Success); + Assert.Equal("mappack-id_profile-b", conflictResult.Data); + + var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName); + var indexJson = await File.ReadAllTextAsync(indexPath); + var index = JsonSerializer.Deserialize(indexJson); + Assert.NotNull(index); + Assert.True(index.FileToInstallationMap.TryGetValue(Path.GetFullPath(targetPath), out var ownerKey)); + Assert.Equal("mappack-id_profile-b", ownerKey); + } + + /// + /// Verifies that cleaning up an uninstalled or old profile does not delete files or prune mappings owned by a newer active profile. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CleanupProfileAsync_WhenPriorOwnerProfileCleanedUpAfterTransfer_PreservesNewOwnerFilesAndIndexMappingAsync() + { + // Arrange + var files = new List + { + new() + { + RelativePath = "Maps/TransferCheck/map.ini", + Hash = "hash-transfer-test", + Size = 300, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + // 1. Profile A installs the map pack + var installA = await _trackerService.InstallUserDataAsync( + "transfer-manifest", + "profile-a", + GameType.ZeroHour, + files, + "1.0", + "Transfer Test", + CancellationToken.None); + Assert.True(installA.Success); + + // 2. Profile A is deactivated + var deactivateA = await _trackerService.DeactivateProfileUserDataAsync("profile-a", CancellationToken.None); + Assert.True(deactivateA.Success); + + // 3. Profile B installs the same map pack + var installB = await _trackerService.InstallUserDataAsync( + "transfer-manifest", + "profile-b", + GameType.ZeroHour, + files, + "1.0", + "Transfer Test", + CancellationToken.None); + Assert.True(installB.Success); + + var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "TransferCheck", "map.ini"); + Assert.True(File.Exists(targetPath)); + + // 4. Profile A is cleaned up + var cleanupA = await _trackerService.CleanupProfileAsync("profile-a", CancellationToken.None); + Assert.True(cleanupA.Success); + + // Assert: Profile B's file and index mapping remain intact + Assert.True(File.Exists(targetPath)); + + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); + Assert.True(conflictResult.Success); + Assert.Equal("transfer-manifest_profile-b", conflictResult.Data); + + var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName); + var indexJson = await File.ReadAllTextAsync(indexPath); + var index = JsonSerializer.Deserialize(indexJson); + Assert.NotNull(index); + Assert.True(index.FileToInstallationMap.TryGetValue(Path.GetFullPath(targetPath), out var ownerKey)); + Assert.Equal("transfer-manifest_profile-b", ownerKey); + } + + /// + /// Verifies that when a file is temporarily missing on disk but its manifest is active, conflict checking still reports conflict. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CheckFileConflictAsync_WhenFileMissingOnDiskButManifestActive_ReportsConflictAsync() + { + // Arrange + var files = new List + { + new() + { + RelativePath = "Maps/TempMissing/map.ini", + Hash = "hash-missing-test", + Size = 100, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + var installResult = await _trackerService.InstallUserDataAsync( + "missing-test-manifest", + "profile-missing-test", + GameType.ZeroHour, + files, + "1.0", + "Missing Test", + CancellationToken.None); + + Assert.True(installResult.Success); + + var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "TempMissing", "map.ini"); + Assert.True(File.Exists(targetPath)); + + // Temporarily delete the file from disk + File.Delete(targetPath); + Assert.False(File.Exists(targetPath)); + + // Act + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); + + // Assert: Conflict is still reported because the owning manifest is active + Assert.True(conflictResult.Success); + Assert.Equal("missing-test-manifest_profile-missing-test", conflictResult.Data); + } + + /// + /// Verifies that when a manifest is deactivated, CheckFileConflictAsync prunes the stale mapping and reports no conflict. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CheckFileConflictAsync_WhenManifestDeactivated_PrunesStaleMappingAndReturnsNoConflictAsync() + { + // Arrange + var files = new List + { + new() + { + RelativePath = "Maps/DeactivatedCheck/map.ini", + Hash = "hash-deact-test", + Size = 100, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + var installResult = await _trackerService.InstallUserDataAsync( + "deact-test-manifest", + "profile-deact-test", + GameType.ZeroHour, + files, + "1.0", + "Deact Test", + CancellationToken.None); + + Assert.True(installResult.Success); + + var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "DeactivatedCheck", "map.ini"); + + // Deactivate the profile + var deactivateResult = await _trackerService.DeactivateProfileUserDataAsync("profile-deact-test", CancellationToken.None); + Assert.True(deactivateResult.Success); + + // Act + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); + + // Assert: No conflict reported and stale mapping is pruned + Assert.True(conflictResult.Success); + Assert.Null(conflictResult.Data); + + // Verify index file persisted on disk no longer maps the path + var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName); + var indexJson = await File.ReadAllTextAsync(indexPath); + var index = JsonSerializer.Deserialize(indexJson); + Assert.NotNull(index); + Assert.False(index.FileToInstallationMap.ContainsKey(Path.GetFullPath(targetPath))); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs index 1e9085592..bdb971a03 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs @@ -96,7 +96,8 @@ public async Task ValidateAsync_WithProgressCallback_ReportsProgressAsync() Assert.True(reportsList.Count > 0, "Expected progress reports to be generated"); // Find the final progress report (highest processed count) - var finalProgress = reportsList.MaxBy(p => p.Processed)!; + var finalProgress = reportsList.MaxBy(p => p.Processed); + Assert.NotNull(finalProgress); // Verify the final progress shows completion Assert.Equal(finalProgress.Total, finalProgress.Processed); @@ -390,7 +391,7 @@ public async Task ValidateAsync_ContentValidatorException_HandlesGracefullyAsync /// /// Custom progress implementation that captures reports synchronously. /// - private class SynchronousProgress : IProgress + private sealed class SynchronousProgress : IProgress { private readonly List _reports = new(); private readonly object _lock = new(); 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/StrategyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/StrategyTests.cs index 853b59d50..de95ffdfc 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/StrategyTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/StrategyTests.cs @@ -201,7 +201,7 @@ public void AllStrategies_EmptyManifest_HandlesGracefully(WorkspaceStrategy stra [InlineData(WorkspaceStrategy.FullCopy, false, false)] [InlineData(WorkspaceStrategy.SymlinkOnly, true, false)] [InlineData(WorkspaceStrategy.HybridCopySymlink, true, false)] - [InlineData(WorkspaceStrategy.HardLink, false, true)] + [InlineData(WorkspaceStrategy.HardLink, false, false)] public void AllStrategies_Requirements_MatchExpected(WorkspaceStrategy strategyType, bool expectedAdminRights, bool expectedSameVolume) { // Arrange @@ -253,11 +253,11 @@ await Assert.ThrowsAsync( } /// - /// Verifies that hard-link preparation copies CAS content instead of rejecting a cross-volume workspace. + /// Verifies that hard-link preparation falls back to symlinks instead of copying when hard linking fails. /// /// A representing the asynchronous unit test. [Fact] - public async Task HardLinkStrategy_WhenVolumesDiffer_FallsBackToCopyAsync() + public async Task HardLinkStrategy_WhenHardLinkFails_FallsBackToSymlinkAsync() { const string Hash = "test-hash"; var strategy = new HardLinkStrategy(_fileOps.Object, new Mock>().Object); @@ -296,9 +296,10 @@ public async Task HardLinkStrategy_WhenVolumesDiffer_FallsBackToCopyAsync() It.IsAny())) .ReturnsAsync(false); _fileOps - .Setup(service => service.CopyFromCasAsync( + .Setup(service => service.LinkFromCasAsync( Hash, It.IsAny(), + false, ManifestContentType.GameClient, It.IsAny())) .ReturnsAsync(true); @@ -306,13 +307,102 @@ public async Task HardLinkStrategy_WhenVolumesDiffer_FallsBackToCopyAsync() var result = await strategy.PrepareAsync(configuration, null, CancellationToken.None); Assert.True(result.IsPrepared); + _fileOps.Verify( + service => service.LinkFromCasAsync( + Hash, + It.IsAny(), + true, + ManifestContentType.GameClient, + It.IsAny()), + Times.Once); + _fileOps.Verify( + service => service.LinkFromCasAsync( + Hash, + It.IsAny(), + false, + ManifestContentType.GameClient, + It.IsAny()), + Times.Once); _fileOps.Verify( service => service.CopyFromCasAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + /// + /// Tests that HardLinkStrategy records preparation failure when both hard link and symlink CAS operations fail. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task HardLinkStrategy_PrepareAsync_HandlesCasLinkDoubleFailure_FailsPreparation() + { + const string Hash = "test-hash"; + var strategy = new HardLinkStrategy(_fileOps.Object, new Mock>().Object); + var configuration = new WorkspaceConfiguration + { + Id = "test-ws-double-failure", + Strategy = WorkspaceStrategy.HardLink, + WorkspaceRootPath = _tempDir, + BaseInstallationPath = "relative-installation-path", + GameClient = new GameClient { Id = "test" }, + Manifests = + [ + new ContentManifest + { + ContentType = ManifestContentType.GameClient, + Files = + [ + new ManifestFile + { + RelativePath = "game.exe", + Hash = Hash, + Size = 1024, + InstallTarget = ContentInstallTarget.Workspace, + SourceType = ContentSourceType.ContentAddressable, + }, + ], + }, + ], + }; + _fileOps + .Setup(service => service.LinkFromCasAsync( Hash, It.IsAny(), + true, + ManifestContentType.GameClient, + It.IsAny())) + .ReturnsAsync(false); + _fileOps + .Setup(service => service.LinkFromCasAsync( + Hash, + It.IsAny(), + false, + ManifestContentType.GameClient, + It.IsAny())) + .ReturnsAsync(false); + + var result = await strategy.PrepareAsync(configuration, null, CancellationToken.None); + + Assert.False(result.IsPrepared); + Assert.NotEmpty(result.ValidationIssues); + _fileOps.Verify( + service => service.LinkFromCasAsync( + Hash, + It.IsAny(), + false, ManifestContentType.GameClient, It.IsAny()), Times.Once); + _fileOps.Verify( + service => service.CopyFromCasAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); } /// 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/ContentPathPolicyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs new file mode 100644 index 000000000..01fff88e1 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public sealed class ContentPathPolicyTests : IDisposable +{ + private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "GenHubTests_PathPolicy_" + Guid.NewGuid().ToString("N")); + + /// + /// Initializes a new instance of the class. + /// + public ContentPathPolicyTests() + { + Directory.CreateDirectory(_tempRoot); + } + + /// + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + { + try + { + Directory.Delete(_tempRoot, recursive: true); + } + catch + { + // Best effort cleanup + } + } + } + + /// + /// Verifies that valid contained relative paths resolve successfully. + /// + [Fact] + public void ResolveContainedFile_ValidRelativePath_ResolvesCorrectly() + { + var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, "sub/file.txt"); + var expected = Path.GetFullPath(Path.Combine(_tempRoot, "sub", "file.txt")); + Assert.True(result.Success); + Assert.Equal(expected, result.Data); + } + + /// + /// Verifies that directory traversal sequences return a failure result. + /// + /// The traversal path to test. + [Theory] + [InlineData("../outside.txt")] + [InlineData("sub/../../outside.txt")] + [InlineData("..\\outside.txt")] + [InlineData("/etc/passwd")] + [InlineData("C:\\Windows\\System32\\cmd.exe")] + [InlineData("\\\\server\\share\\file.txt")] + public void ResolveContainedFile_PathEscapesRoot_ReturnsFailure(string maliciousPath) + { + var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, maliciousPath); + Assert.False(result.Success); + } + + /// + /// Verifies that null or whitespace inputs return a failure result. + /// + /// The invalid path to test. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveContainedFile_NullOrEmptyRelativePath_ReturnsFailure(string? invalidPath) + { + var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, invalidPath); + Assert.False(result.Success); + } + + /// + /// Verifies that accurately detects containment. + /// + [Fact] + public void IsContained_ValidAndInvalidPaths_ReturnsExpectedBoolean() + { + var inside = Path.Combine(_tempRoot, "nested", "file.dll"); + var outside = Path.Combine(Path.GetTempPath(), "other_dir", "file.dll"); + + Assert.True(ContentPathPolicy.IsContained(_tempRoot, inside)); + Assert.False(ContentPathPolicy.IsContained(_tempRoot, outside)); + } +} 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..0db1393df 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); } /// @@ -236,4 +228,206 @@ public void ApplyToGeneralsOnlineSettings_ExplicitToggles_ArePreserved() Assert.False(settings.ScreenEdgeScrollEnabledInFullscreenApp); Assert.True(settings.ScreenEdgeScrollEnabledInWindowedApp); } + + /// + /// Verifies that TshGameWindowTransitionSpeedMultiplier is correctly mapped to TheSuperHackers section. + /// + [Fact] + public void ApplyToOptions_TshGameWindowTransitionSpeedMultiplier_MapsToTheSuperHackersSection() + { + // Arrange + var profile = new GameProfile + { + TshGameWindowTransitionSpeedMultiplier = 2.5f, + }; + var options = new IniOptions(); + + // Act + GameSettingsMapper.ApplyToOptions(profile, options); + + // Assert + Assert.True(options.AdditionalSections.TryGetValue("TheSuperHackers", out var tsh)); + Assert.True(tsh.TryGetValue("GameWindowTransitionSpeedMultiplier", out var speed)); + Assert.Equal("2.5", speed); + } + + /// + /// Verifies that GameWindowTransitionSpeedMultiplier is loaded from hierarchical options. + /// + [Fact] + public void ApplyFromOptions_HierarchicalSection_MapsGameWindowTransitionSpeedMultiplier() + { + // Arrange + var options = new IniOptions(); + options.AdditionalSections["TheSuperHackers"] = new Dictionary + { + ["GameWindowTransitionSpeedMultiplier"] = "3.75", + }; + var profile = new GameProfile(); + + // Act + GameSettingsMapper.ApplyFromOptions(options, profile); + + // Assert + Assert.Equal(3.75f, profile.TshGameWindowTransitionSpeedMultiplier); + } + + /// + /// Verifies that GameWindowTransitionSpeedMultiplier is loaded from flat root video properties. + /// + [Fact] + public void ApplyFromOptions_FlatProperties_MapsGameWindowTransitionSpeedMultiplier() + { + // Arrange + var options = new IniOptions(); + options.Video.AdditionalProperties["GameWindowTransitionSpeedMultiplier"] = "3.0"; + var profile = new GameProfile(); + + // Act + GameSettingsMapper.ApplyFromOptions(options, profile); + + // Assert + Assert.Equal(3.0f, profile.TshGameWindowTransitionSpeedMultiplier); + } + + /// + /// Verifies that ApplyToGeneralsOnlineSettings and ApplyFromGeneralsOnlineSettings preserve GameWindowTransitionSpeedMultiplier. + /// + [Fact] + public void ApplyToAndFromGeneralsOnlineSettings_GameWindowTransitionSpeedMultiplier_RoundTrips() + { + // Arrange + var profile = new GameProfile + { + TshGameWindowTransitionSpeedMultiplier = 4.0f, + }; + var settings = new GeneralsOnlineSettings(); + + // Act + GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); + + // Assert + Assert.Equal(4.0f, settings.GameWindowTransitionSpeedMultiplier); + + // Act back + var targetProfile = new GameProfile(); + GameSettingsMapper.ApplyFromGeneralsOnlineSettings(settings, targetProfile); + + // Assert back + Assert.Equal(4.0f, targetProfile.TshGameWindowTransitionSpeedMultiplier); + } + + /// + /// Verifies that PopulateGameProfile and UpdateFromRequest preserve GameWindowTransitionSpeedMultiplier. + /// + [Fact] + public void PopulateAndUpdate_PreservesGameWindowTransitionSpeedMultiplier() + { + // Arrange + var createRequest = new CreateProfileRequest + { + Name = "TestProfile", + TshGameWindowTransitionSpeedMultiplier = 2.2f, + }; + var profile = new GameProfile(); + + // Act + GameSettingsMapper.PopulateGameProfile(profile, createRequest); + + // Assert + Assert.Equal(2.2f, profile.TshGameWindowTransitionSpeedMultiplier); + + // Update + var updateRequest = new UpdateProfileRequest + { + TshGameWindowTransitionSpeedMultiplier = 3.4f, + }; + GameSettingsMapper.UpdateFromRequest(profile, updateRequest); + Assert.Equal(3.4f, profile.TshGameWindowTransitionSpeedMultiplier); + } + + /// + /// Verifies that out-of-range values are clamped to Min/Max and NaN/Infinity values are ignored. + /// + /// The raw string input value from Options.ini. + /// The expected clamped float multiplier value. + [Theory] + [InlineData("0.2", 1.0f)] + [InlineData("5000.0", 4.0f)] + [InlineData("-10.0", 1.0f)] + public void ApplyFromOptions_ClampsOutOfRangeTransitionSpeedMultiplier(string input, float expected) + { + // Arrange + var options = new IniOptions(); + options.AdditionalSections["TheSuperHackers"] = new Dictionary + { + ["GameWindowTransitionSpeedMultiplier"] = input, + }; + var profile = new GameProfile(); + + // Act + GameSettingsMapper.ApplyFromOptions(options, profile); + + // Assert + Assert.Equal(expected, profile.TshGameWindowTransitionSpeedMultiplier); + } + + /// + /// Verifies that non-finite values (NaN, Infinity) are ignored and do not corrupt profile settings. + /// + /// The raw non-finite or invalid string input value. + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + [InlineData("invalid_float")] + public void ApplyFromOptions_IgnoresNonFiniteTransitionSpeedMultiplier(string input) + { + // Arrange + var options = new IniOptions(); + options.AdditionalSections["TheSuperHackers"] = new Dictionary + { + ["GameWindowTransitionSpeedMultiplier"] = input, + }; + var profile = new GameProfile(); + + // Act + GameSettingsMapper.ApplyFromOptions(options, profile); + + // Assert + Assert.Null(profile.TshGameWindowTransitionSpeedMultiplier); + } + + /// + /// Verifies that NormalizeTransitionSpeedMultiplier clamps out-of-range values and rejects non-finite values. + /// + [Fact] + public void NormalizeTransitionSpeedMultiplier_ShouldClampAndFilterCorrectly() + { + Assert.Null(GameSettingsMapper.NormalizeTransitionSpeedMultiplier(null)); + Assert.Null(GameSettingsMapper.NormalizeTransitionSpeedMultiplier(float.NaN)); + Assert.Null(GameSettingsMapper.NormalizeTransitionSpeedMultiplier(float.PositiveInfinity)); + Assert.Null(GameSettingsMapper.NormalizeTransitionSpeedMultiplier(float.NegativeInfinity)); + Assert.Equal(1.0f, GameSettingsMapper.NormalizeTransitionSpeedMultiplier(0.5f)); + Assert.Equal(4.0f, GameSettingsMapper.NormalizeTransitionSpeedMultiplier(50.0f)); + Assert.Equal(1.05f, GameSettingsMapper.NormalizeTransitionSpeedMultiplier(1.05f)); + } + + /// + /// Verifies that ApplyToOptions clamps out-of-range transition speed multiplier before writing to dictionary. + /// + [Fact] + public void ApplyToOptions_ShouldClampTransitionSpeedMultiplier() + { + var profile = new GameProfile + { + TshGameWindowTransitionSpeedMultiplier = 99.0f, + }; + var options = new IniOptions(); + + GameSettingsMapper.ApplyToOptions(profile, options); + + Assert.True(options.AdditionalSections.TryGetValue("TheSuperHackers", out var tshDict)); + Assert.Equal("4", tshDict["GameWindowTransitionSpeedMultiplier"]); + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index 59fe70edc..448519a48 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -34,4 +34,284 @@ 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); + } + + /// + /// Verifies that SanitizeFileName removes invalid filesystem characters, trims whitespace and trailing dots, and prefixes Windows reserved device names. + /// + [Fact] + public void SanitizeFileName_RemovesInvalidCharactersAndHandlesEdgeCases() + { + var invalidChars = new string(Path.GetInvalidFileNameChars()); + var input = $" valid{invalidChars}file name.txt "; + var sanitized = PathHelper.SanitizeFileName(input); + + Assert.Equal("validfile name.txt", sanitized); + Assert.Equal(string.Empty, PathHelper.SanitizeFileName(string.Empty)); + Assert.Equal("trailing", PathHelper.SanitizeFileName("trailing....")); + Assert.Equal("_CON.zip", PathHelper.SanitizeFileName("CON.zip")); + Assert.Equal("_nul", PathHelper.SanitizeFileName("nul")); + Assert.Equal("_com1.txt", PathHelper.SanitizeFileName("com1.txt")); + } + + /// + /// Verifies that GetUniqueNumberedPath appends an incrementing counter when files exist. + /// + [Fact] + public void GetUniqueNumberedPath_GeneratesUniqueNamesWhenFilesExist() + { + var tempDir = CreateWorkingDirectory(); + try + { + var targetPath = Path.Combine(tempDir, "archive.zip"); + Assert.Equal(targetPath, PathHelper.GetUniqueNumberedPath(targetPath)); + + File.WriteAllText(targetPath, "test"); + var secondPath = PathHelper.GetUniqueNumberedPath(targetPath); + Assert.Equal(Path.Combine(tempDir, "archive (1).zip"), secondPath); + + File.WriteAllText(secondPath, "test2"); + var thirdPath = PathHelper.GetUniqueNumberedPath(targetPath); + Assert.Equal(Path.Combine(tempDir, "archive (2).zip"), thirdPath); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + 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..a0aaa1275 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. 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/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/GameInstallations/GameInstallationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameInstallations/GameInstallationTests.cs index d639b95c5..40b4e75e2 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameInstallations/GameInstallationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameInstallations/GameInstallationTests.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Constants; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameInstallations; using Microsoft.Extensions.Logging.Abstractions; @@ -75,4 +76,446 @@ public void GameInstallation_IsValid_ReturnsTrue_WhenGeneralsPathExists() Directory.Delete(tempDir, true); } } -} \ No newline at end of file + + /// + /// Verifies that Fetch correctly identifies a standalone Zero Hour installation by its INIZH.big archive. + /// + [Fact] + public void GameInstallation_Fetch_DetectsStandaloneZeroHour_WhenZeroHourBigsPresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenHubZHTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + Assert.False(installation.HasGenerals); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch correctly identifies a standalone Generals installation by its INI.big archive. + /// + [Fact] + public void GameInstallation_Fetch_DetectsStandaloneGenerals_WhenGeneralsBigsPresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenHubGenTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INI.big"), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasGenerals); + Assert.Equal(tempDir, installation.GeneralsPath); + Assert.False(installation.HasZeroHour); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch correctly identifies a merged installation containing both Generals and Zero Hour archives. + /// + [Fact] + public void GameInstallation_Fetch_DetectsMergedInstall_WhenBothBigsPresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenHubMergedTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "gensec.big"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasGenerals); + Assert.Equal(tempDir, installation.GeneralsPath); + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch correctly identifies Zero Hour based on folder name when specific archives are absent. + /// + [Fact] + public void GameInstallation_Fetch_DetectsZeroHour_WhenDirectoryNamedZeroHour() + { + var tempDir = Path.Combine(Path.GetTempPath(), "Command and Conquer Generals Zero Hour_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch does not misclassify a vanilla Generals installation when parent path contains ZH text. + /// + [Fact] + public void GameInstallation_Fetch_DoesNotMisclassifyGenerals_WhenParentPathContainsZh() + { + var parentDir = Path.Combine(Path.GetTempPath(), "ZH_Tools_" + Guid.NewGuid().ToString("N")); + var generalsDir = Path.Combine(parentDir, "Generals"); + Directory.CreateDirectory(generalsDir); + try + { + File.WriteAllText(Path.Combine(generalsDir, "generals.exe"), string.Empty); + + var installation = new GameInstallation(generalsDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasGenerals); + Assert.Equal(generalsDir, installation.GeneralsPath); + Assert.False(installation.HasZeroHour); + } + finally + { + Directory.Delete(parentDir, true); + } + } + + /// + /// Verifies that Fetch identifies Zero Hour for leaf directories matching anchored ZH tokens. + /// + /// The directory name matching the anchored Zero Hour token. + [Theory] + [InlineData("ZH")] + [InlineData("ZH_Mod")] + [InlineData("Mod_ZH")] + [InlineData("ZH-Mod")] + [InlineData("Mod-ZH")] + public void GameInstallation_Fetch_DetectsZeroHour_WhenDirectoryMatchesAnchoredZhToken(string dirName) + { + var parentDir = Path.Combine(Path.GetTempPath(), "ZhTestParent_" + Guid.NewGuid().ToString("N")); + var tempDir = Path.Combine(parentDir, dirName); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(parentDir, true); + } + } + + /// + /// Verifies that Fetch detects Zero Hour from supported subdirectories under a parent installation path. + /// + /// The subdirectory name under the installation root. + [Theory] + [InlineData(GameClientConstants.ZeroHourDirectoryName)] + [InlineData(GameClientConstants.ZeroHourDirectoryNameAmpersandHyphen)] + [InlineData(GameClientConstants.ZeroHourRetailDirectoryName)] + [InlineData(GameClientConstants.ZeroHourDirectoryNameAbbreviated)] + public void GameInstallation_Fetch_DetectsZeroHour_FromSupportedSubdirectory(string subDirName) + { + var parentDir = Path.Combine(Path.GetTempPath(), "GamesParent_" + Guid.NewGuid().ToString("N")); + var zhDir = Path.Combine(parentDir, subDirName); + Directory.CreateDirectory(zhDir); + try + { + File.WriteAllText(Path.Combine(zhDir, "generals.exe"), string.Empty); + + var installation = new GameInstallation(parentDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(zhDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(parentDir, true); + } + } + + /// + /// Verifies that Fetch detects Zero Hour based on archive signatures like PatchZH.big. + /// + [Fact] + public void GameInstallation_Fetch_DetectsZeroHour_WhenPatchZhArchivePresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenericRoot_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, GameClientConstants.ZeroHourPatchBig), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch detects Generals Vanilla based on Patch.big archive signature. + /// + [Fact] + public void GameInstallation_Fetch_DetectsGenerals_WhenPatchArchivePresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenericRoot_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, GameClientConstants.GeneralsPatchBig), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasGenerals); + Assert.Equal(tempDir, installation.GeneralsPath); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch detects Zero Hour when client-specific executables like generalszh.exe or generalsonlinezh_60.exe are present. + /// + /// The client executable name. + [Theory] + [InlineData(GameClientConstants.SuperHackersZeroHourExecutable)] + [InlineData(GameClientConstants.GeneralsOnlineDefaultExecutable)] + [InlineData(GameClientConstants.GeneralsOnline60HzExecutable)] + [InlineData(GameClientConstants.GeneralsOnlineEacLauncherExecutable)] + [InlineData(GameClientConstants.ContraExecutable)] + public void GameInstallation_Fetch_DetectsZeroHour_WhenClientExecutablePresent(string exeName) + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenericRoot_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, exeName), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch identifies a directory named Zero Hour as Zero Hour even if generic INI.big is present (repack scenario). + /// + [Fact] + public void GameInstallation_Fetch_DetectsZeroHour_WhenNamedZeroHourAndIniBigPresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), "Command and Conquer Generals Zero Hour_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INI.big"), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + Assert.False(installation.HasGenerals); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch detects Zero Hour when non-English localized archives like RussianZH.big or GermanZH.big are present. + /// + /// The localized Zero Hour archive filename. + [Theory] + [InlineData("RussianZH.big")] + [InlineData("RussianZH.BIG")] + [InlineData("GermanZH.big")] + [InlineData("GermanZH.Big")] + [InlineData("FrenchZH.big")] + [InlineData("AudioZH.big")] + [InlineData("MapsZH.BIG")] + public void GameInstallation_Fetch_DetectsZeroHour_WhenLocalizedZhBigArchivePresent(string archiveName) + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenericRoot_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, archiveName), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch identifies a generic directory containing both generic INI.big and a Zero Hour archive signature as Zero Hour only. + /// + [Fact] + public void GameInstallation_Fetch_DetectsOnlyZeroHour_WhenGenericRootContainsIniBigAndZhArchive() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenericRoot_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INI.big"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "RussianZH.BIG"), string.Empty); + + var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(tempDir, installation.ZeroHourPath); + Assert.False(installation.HasGenerals); + Assert.True(string.IsNullOrEmpty(installation.GeneralsPath)); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that Fetch preserves explicitly configured paths when those paths exist on disk. + /// + [Fact] + public void GameInstallation_Fetch_PreservesExplicitlyConfiguredPaths() + { + var tempParent = Path.Combine(Path.GetTempPath(), "ExplicitTest_" + Guid.NewGuid().ToString("N")); + var zhDir = Path.Combine(tempParent, "ZH_Custom"); + Directory.CreateDirectory(zhDir); + try + { + File.WriteAllText(Path.Combine(zhDir, "generals.exe"), string.Empty); + + var installation = new GameInstallation(tempParent, GameInstallationType.Retail, NullLogger.Instance); + installation.SetPaths(null, zhDir); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(zhDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(tempParent, true); + } + } + + /// + /// Verifies that Fetch preserves explicitly configured paths even when a standard supported subdirectory also exists. + /// + [Fact] + public void GameInstallation_Fetch_PreservesExplicitlyConfiguredPaths_EvenWhenStandardSubdirectoriesExist() + { + var tempParent = Path.Combine(Path.GetTempPath(), "ExplicitSubdirTest_" + Guid.NewGuid().ToString("N")); + var customZhDir = Path.Combine(tempParent, "ZH_Custom"); + var standardZhDir = Path.Combine(tempParent, GameClientConstants.ZeroHourDirectoryName); + Directory.CreateDirectory(customZhDir); + Directory.CreateDirectory(standardZhDir); + try + { + File.WriteAllText(Path.Combine(customZhDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(standardZhDir, "generals.exe"), string.Empty); + + var installation = new GameInstallation(tempParent, GameInstallationType.Retail, NullLogger.Instance); + installation.SetPaths(null, customZhDir); + installation.Fetch(); + + Assert.True(installation.HasZeroHour); + Assert.Equal(customZhDir, installation.ZeroHourPath); + } + finally + { + Directory.Delete(tempParent, true); + } + } + + /// + /// Verifies that Fetch preserves explicitly configured Generals paths even when a standard supported subdirectory also exists. + /// + [Fact] + public void GameInstallation_Fetch_PreservesExplicitlyConfiguredGeneralsPath_EvenWhenStandardSubdirectoriesExist() + { + var tempParent = Path.Combine(Path.GetTempPath(), "ExplicitGenSubdirTest_" + Guid.NewGuid().ToString("N")); + var customGenDir = Path.Combine(tempParent, "Generals_Custom"); + var standardGenDir = Path.Combine(tempParent, GameClientConstants.GeneralsDirectoryName); + Directory.CreateDirectory(customGenDir); + Directory.CreateDirectory(standardGenDir); + try + { + File.WriteAllText(Path.Combine(customGenDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(standardGenDir, "generals.exe"), string.Empty); + + var installation = new GameInstallation(tempParent, GameInstallationType.Retail, NullLogger.Instance); + installation.SetPaths(customGenDir, null); + installation.Fetch(); + + Assert.True(installation.HasGenerals); + Assert.Equal(customGenDir, installation.GeneralsPath); + } + finally + { + Directory.Delete(tempParent, true); + } + } +} 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.Tests/Shared/CompositionRootAssertions.cs b/GenHub/GenHub.Tests/Shared/CompositionRootAssertions.cs index 7dc513f1c..eef502dd3 100644 --- a/GenHub/GenHub.Tests/Shared/CompositionRootAssertions.cs +++ b/GenHub/GenHub.Tests/Shared/CompositionRootAssertions.cs @@ -43,6 +43,7 @@ public static class CompositionRootAssertions /// private static readonly Type[] RequiredSingleServices = [ + typeof(IBackgroundUpdateCoordinator), typeof(IConfigurationProviderService), typeof(IFileOperationsService), typeof(IGamePathProvider), @@ -108,7 +109,8 @@ private static readonly (string Singleton, string Scoped)[] KnownCaptiveDependen private static readonly Regex CaptiveDependencyMessage = new( "Cannot consume scoped service '(?[^']+)' from singleton '(?[^']+)'", - RegexOptions.Compiled); + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); /// /// Builds a host's real container and asserts it is complete. diff --git a/GenHub/GenHub.Tools/CsvGenerator.cs b/GenHub/GenHub.Tools/CsvGenerator.cs index a8e481a75..233e17e1e 100644 --- a/GenHub/GenHub.Tools/CsvGenerator.cs +++ b/GenHub/GenHub.Tools/CsvGenerator.cs @@ -1,10 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; +using System.Text.Json; using CsvHelper; using CsvHelper.Configuration; using GenHub.Core.Constants; using GenHub.Core.Models.Content; using Microsoft.Extensions.Logging; -using System.Text.Json; namespace GenHub.Tools; @@ -132,12 +133,9 @@ private bool IsLanguageSpecific(string relativePath) LanguageDirectoryNames.DataChineseTraditional, }; - foreach (var dir in languageDirectories) + if (languageDirectories.Any(dir => relativePath.StartsWith(dir, StringComparison.OrdinalIgnoreCase))) { - if (relativePath.StartsWith(dir, StringComparison.OrdinalIgnoreCase)) - { - return true; - } + return true; } // Check for language-specific .big file patterns @@ -166,17 +164,16 @@ private bool IsLanguageSpecific(string relativePath) LanguageFilePatterns.ChineseTraditionalBig, LanguageFilePatterns.AudioChineseTraditionalBig, }; - foreach (var pattern in languageFilePatterns) + if (languageFilePatterns.Any(pattern => relativePath.Contains(pattern, StringComparison.OrdinalIgnoreCase))) { - if (relativePath.Contains(pattern, StringComparison.OrdinalIgnoreCase)) - { - return true; - } + return true; } return false; } + [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms", Justification = "MD5 hash is required by the CSV catalog format for backward compatibility.")] + [SuppressMessage("Security", "S4790:Make sure this weak hash algorithm is not used in a sensitive cryptographic context", Justification = "MD5 hash is required for legacy game file checksum comparison.")] private async Task<(string Md5, string Sha256)> CalculateHashesAsync(string filePath) { using var stream = File.OpenRead(filePath); 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..12d526f6f 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); @@ -146,7 +150,7 @@ public async Task LinkFromCasAsync( } else { - await CreateSymlinkAsync(destinationPath, casSourcePath, allowFallback: true, cancellationToken).ConfigureAwait(false); + await CreateSymlinkAsync(destinationPath, casSourcePath, allowFallback: false, cancellationToken).ConfigureAwait(false); } logger.LogDebug("Created {LinkType} from CAS hash {Hash} to {DestinationPath}", useHardLink ? "hard link" : "symlink", hash, destinationPath); 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 b/GenHub/GenHub/App.axaml index 20a88adb5..b0176d602 100644 --- a/GenHub/GenHub/App.axaml +++ b/GenHub/GenHub/App.axaml @@ -2,31 +2,34 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:GenHub.Infrastructure.Converters" x:Class="GenHub.App"> - - - - - - - + + + + + + + + - - - - - - - + - + + + + + + diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 3017451f0..434d14a6b 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; @@ -25,6 +27,7 @@ public partial class App : Application private readonly IUserSettingsService _userSettingsService; private readonly IConfigurationProviderService _configurationProvider; private readonly IProfileLauncherFacade _profileLauncherFacade; + private readonly IThemeService? _themeService; /// /// Initializes a new instance of the class with the specified service provider. @@ -36,6 +39,7 @@ public App(IServiceProvider serviceProvider) _userSettingsService = _serviceProvider.GetService() ?? throw new InvalidOperationException("IUserSettingsService not registered"); _configurationProvider = _serviceProvider.GetService() ?? throw new InvalidOperationException("IConfigurationProviderService not registered"); _profileLauncherFacade = _serviceProvider.GetRequiredService(); + _themeService = _serviceProvider.GetService(); } /// @@ -52,6 +56,8 @@ public override void Initialize() /// public override void OnFrameworkInitializationCompleted() { + _themeService?.InitializeTheme(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var mainWindow = new MainWindow @@ -65,8 +71,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 +174,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 +204,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 +233,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 +249,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 +310,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/Assets/Styles/ComboBoxStyles.axaml b/GenHub/GenHub/Assets/Styles/ComboBoxStyles.axaml index cfca5d8ec..4798b88a9 100644 --- a/GenHub/GenHub/Assets/Styles/ComboBoxStyles.axaml +++ b/GenHub/GenHub/Assets/Styles/ComboBoxStyles.axaml @@ -1,29 +1,36 @@ - + - + Option 1 Option 2 Option 3 - + @@ -130,21 +144,57 @@ - - - + + + + + + + + + + + + + + + + + + + + - - - diff --git a/GenHub/GenHub/Assets/Styles/ExpanderStyles.axaml b/GenHub/GenHub/Assets/Styles/ExpanderStyles.axaml new file mode 100644 index 000000000..71759113c --- /dev/null +++ b/GenHub/GenHub/Assets/Styles/ExpanderStyles.axaml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GenHub/GenHub/Assets/Styles/ScrollbarStyles.axaml b/GenHub/GenHub/Assets/Styles/ScrollbarStyles.axaml index aa047b7c0..843e38ab2 100644 --- a/GenHub/GenHub/Assets/Styles/ScrollbarStyles.axaml +++ b/GenHub/GenHub/Assets/Styles/ScrollbarStyles.axaml @@ -1,42 +1,57 @@ - - #7C4DFF - #E040FB - #1A1A2E - + + + + + + + + + + + + - - + - - - + + + - - diff --git a/GenHub/GenHub/Assets/Styles/ThemeResources.axaml b/GenHub/GenHub/Assets/Styles/ThemeResources.axaml index 2141594dc..88dba230e 100644 --- a/GenHub/GenHub/Assets/Styles/ThemeResources.axaml +++ b/GenHub/GenHub/Assets/Styles/ThemeResources.axaml @@ -1,5 +1,144 @@ + + + + + #08080C + #111118 + #181822 + #222230 + + + + + + + + + + + + + + + + + + + + + + + + + + + #282838 + #3F3F5A + + + + + + + + + + + + + #F0F0F8 + #9A9AB0 + #656578 + + + + + + + + + + + + + + #BD5A0F + #D97706 + #D97706 + + + + + + + + + + #1B6575 + #06B6D4 + #06B6D4 + + + + + + + + + + #A855F7 + #A855F7 + #C084FC + #7C4DFF + #80A855F7 + #20A855F7 + #CCA855F7 + #231A36 + #A855F7 + + + + + + + + + + + + + + #10B981 + + + + + #FFA500 + #F59E0B + + + + + #EF4444 + + + + + #E040FB + #7C4DFF + + + + + + #AA00FF + + + + + #F7F7F9 #EAEAEF @@ -11,67 +150,48 @@ #FFFFFF #FFFFFF #F0F0F4 - #7C4DFF + #A855F7 - #1F1F1F - #2A2A2A - #252525 - #2D2D2D - #2A2A2A - #303030 - #404040 - #3A3A3A - #3A3A3A - #3A3A3A - #7C4DFF - - - - + #08080C + #08080C + #08080C + #181822 + #08080C + #111118 + #282838 + #181822 + #181822 + #181822 + + - + - - - - + + - - - - - - - #FFA500 - - - #E040FB - #7C4DFF - - - - - - #AA00FF - + + + M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z M22.7,19L13.6,9.9C14.5,7.6 14,4.9 12.1,3C10.1,1 7.1,0.6 4.7,1.7L9,6L6,9L1.7,4.7C0.6,7.1 1,10.1 3,12.1C4.9,14 7.6,14.5 9.9,13.6L19,22.7L22.7,19Z - #5E35B1 - - + + + #CC050510 #334527A0 #664527A0 + #311B92 @@ -85,16 +205,73 @@ - + - + - + - + + + + + + #00000000 + #38384D + #585876 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GenHub/GenHub/Common/Controls/SidebarLayout.cs b/GenHub/GenHub/Common/Controls/SidebarLayout.cs index d76c076a0..7fc5223ab 100644 --- a/GenHub/GenHub/Common/Controls/SidebarLayout.cs +++ b/GenHub/GenHub/Common/Controls/SidebarLayout.cs @@ -1,15 +1,18 @@ +using System; using System.Collections; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; +using Avalonia.Interactivity; using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Constants; namespace GenHub.Common.Controls; /// -/// A layout control that provides a collapsible sidebar pane and a main content area. +/// A layout control that provides a collapsible, resizable inline sidebar pane and a main content area. /// public class SidebarLayout : ContentControl { @@ -17,7 +20,10 @@ public class SidebarLayout : ContentControl /// Defines the property. /// public static readonly StyledProperty IsPaneOpenProperty = - AvaloniaProperty.Register(nameof(IsPaneOpen), defaultValue: false); + AvaloniaProperty.Register( + nameof(IsPaneOpen), + defaultValue: true, + defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); /// /// Defines the property. @@ -29,7 +35,26 @@ public class SidebarLayout : ContentControl /// Defines the property. /// public static readonly StyledProperty OpenPaneLengthProperty = - AvaloniaProperty.Register(nameof(OpenPaneLength), 300); + AvaloniaProperty.Register( + nameof(OpenPaneLength), + defaultValue: SidebarConstants.DefaultOpenPaneLength, + defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinPaneLengthProperty = + AvaloniaProperty.Register( + nameof(MinPaneLength), + defaultValue: SidebarConstants.MinPaneLength); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaxPaneLengthProperty = + AvaloniaProperty.Register( + nameof(MaxPaneLength), + defaultValue: SidebarConstants.MaxPaneLength); /// /// Defines the property. @@ -53,7 +78,9 @@ public class SidebarLayout : ContentControl /// Defines the property. /// public static readonly StyledProperty SelectedItemProperty = - AvaloniaProperty.Register(nameof(SelectedItem), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + AvaloniaProperty.Register( + nameof(SelectedItem), + defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); /// /// Defines the property. @@ -61,9 +88,19 @@ public class SidebarLayout : ContentControl public static readonly StyledProperty ItemTemplateProperty = AvaloniaProperty.Register(nameof(ItemTemplate)); - private Panel? _triggerZone; - private Panel? _contentOverlay; + private ColumnDefinition? _sidebarColumn; + private ColumnDefinition? _splitterColumn; private Control? _sidebarPane; + private GridSplitter? _splitter; + private Control? _triggerZone; + + static SidebarLayout() + { + IsPaneOpenProperty.Changed.AddClassHandler((x, _) => x.OnIsPaneOpenChanged()); + OpenPaneLengthProperty.Changed.AddClassHandler((x, _) => x.OnOpenPaneLengthChanged()); + MinPaneLengthProperty.Changed.AddClassHandler((x, _) => x.OnMinMaxPaneLengthChanged()); + MaxPaneLengthProperty.Changed.AddClassHandler((x, _) => x.OnMinMaxPaneLengthChanged()); + } /// /// Initializes a new instance of the class. @@ -71,6 +108,8 @@ public class SidebarLayout : ContentControl public SidebarLayout() { ClosePaneCommand = new RelayCommand(() => IsPaneOpen = false); + OpenPaneCommand = new RelayCommand(() => IsPaneOpen = true); + TogglePaneCommand = new RelayCommand(() => IsPaneOpen = !IsPaneOpen); } /// @@ -100,6 +139,24 @@ public double OpenPaneLength set => SetValue(OpenPaneLengthProperty, value); } + /// + /// Gets or sets the minimum width of the sidebar pane when resizing. + /// + public double MinPaneLength + { + get => GetValue(MinPaneLengthProperty); + set => SetValue(MinPaneLengthProperty, value); + } + + /// + /// Gets or sets the maximum width of the sidebar pane when resizing. + /// + public double MaxPaneLength + { + get => GetValue(MaxPaneLengthProperty); + set => SetValue(MaxPaneLengthProperty, value); + } + /// /// Gets or sets the content to be displayed in the header of the sidebar pane. /// @@ -150,73 +207,229 @@ public IDataTemplate? ItemTemplate /// public IRelayCommand ClosePaneCommand { get; } + /// + /// Gets the command that opens the sidebar pane. + /// + public IRelayCommand OpenPaneCommand { get; } + + /// + /// Gets the command that toggles the sidebar pane open or closed. + /// + public IRelayCommand TogglePaneCommand { get; } + /// protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); + UnsubscribeEvents(); - if (_triggerZone != null) + var rootGrid = e.NameScope.Find("PART_RootGrid"); + if (rootGrid != null && rootGrid.ColumnDefinitions.Count >= 2) { - _triggerZone.PointerEntered -= OnTriggerZonePointerEntered; + _sidebarColumn = rootGrid.ColumnDefinitions[0]; + _splitterColumn = rootGrid.ColumnDefinitions[1]; } + else + { + _sidebarColumn = null; + _splitterColumn = null; + } + + _sidebarPane = e.NameScope.Find("PART_SidebarPane"); + _splitter = e.NameScope.Find("PART_Splitter"); + _triggerZone = e.NameScope.Find("PART_TriggerZone"); + + SubscribeEvents(); + UpdateLayoutState(); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + SubscribeEvents(); + UpdateLayoutState(); + } - if (_contentOverlay != null) + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + UnsubscribeEvents(); + + if (IsPaneOpen && _sidebarColumn != null && _sidebarColumn.Width.IsAbsolute && _sidebarColumn.Width.Value > 0) { - _contentOverlay.PointerPressed -= OnContentPointerPressed; - _contentOverlay.PointerEntered -= OnContentPointerEntered; + OpenPaneLength = ClampPaneLength(_sidebarColumn.Width.Value, MinPaneLength, MaxPaneLength); } + } - if (_sidebarPane != null) + private static (double Min, double Max) GetSanitizedBounds(double min, double max) + { + var resolvedMin = double.IsNaN(min) || double.IsInfinity(min) || min < 0 ? SidebarConstants.MinPaneLength : min; + var resolvedMax = double.IsNaN(max) || double.IsInfinity(max) || max < resolvedMin ? Math.Max(resolvedMin, SidebarConstants.MaxPaneLength) : max; + return (resolvedMin, resolvedMax); + } + + private static double ClampPaneLength(double value, double min, double max) + { + var (resolvedMin, resolvedMax) = GetSanitizedBounds(min, max); + var resolvedVal = double.IsNaN(value) || double.IsInfinity(value) ? SidebarConstants.DefaultOpenPaneLength : value; + return Math.Clamp(resolvedVal, resolvedMin, resolvedMax); + } + + private static void SetControlVisibility(Control? control, bool isVisible) + { + if (control != null) { - _sidebarPane.PointerExited -= OnSidebarPanePointerExited; + control.IsVisible = isVisible; } + } - _triggerZone = e.NameScope.Find("PART_TriggerZone"); - _contentOverlay = e.NameScope.Find("PART_ContentOverlay"); - _sidebarPane = e.NameScope.Find("PART_SidebarPane"); + private void SubscribeEvents() + { + UnsubscribeEvents(); if (_triggerZone != null) { _triggerZone.PointerEntered += OnTriggerZonePointerEntered; + _triggerZone.PointerPressed += OnTriggerZonePointerPressed; + } + + if (_sidebarPane != null) + { + _sidebarPane.SizeChanged += OnSidebarPaneSizeChanged; } - if (_contentOverlay != null) + if (_splitter != null) + { + _splitter.PointerCaptureLost += OnSplitterDragCompleted; + } + } + + private void UnsubscribeEvents() + { + if (_triggerZone != null) { - _contentOverlay.PointerPressed += OnContentPointerPressed; - _contentOverlay.PointerEntered += OnContentPointerEntered; + _triggerZone.PointerEntered -= OnTriggerZonePointerEntered; + _triggerZone.PointerPressed -= OnTriggerZonePointerPressed; } if (_sidebarPane != null) { - _sidebarPane.PointerExited += OnSidebarPanePointerExited; + _sidebarPane.SizeChanged -= OnSidebarPaneSizeChanged; + } + + if (_splitter != null) + { + _splitter.PointerCaptureLost -= OnSplitterDragCompleted; } } - private void OnTriggerZonePointerEntered(object? sender, PointerEventArgs e) + private void OnIsPaneOpenChanged() { - IsPaneOpen = true; + UpdateLayoutState(); + } + + private void OnOpenPaneLengthChanged() + { + if (IsPaneOpen && _sidebarColumn != null) + { + var clamped = ClampPaneLength(OpenPaneLength, MinPaneLength, MaxPaneLength); + if (Math.Abs(_sidebarColumn.Width.Value - clamped) > 0.5) + { + _sidebarColumn.Width = new GridLength(clamped, GridUnitType.Pixel); + } + } + } + + private void OnMinMaxPaneLengthChanged() + { + if (IsPaneOpen && _sidebarColumn != null) + { + var (min, max) = GetSanitizedBounds(MinPaneLength, MaxPaneLength); + _sidebarColumn.MinWidth = min; + _sidebarColumn.MaxWidth = max; + var clamped = ClampPaneLength(OpenPaneLength, min, max); + _sidebarColumn.Width = new GridLength(clamped, GridUnitType.Pixel); + } + } + + private void OnSidebarPaneSizeChanged(object? sender, SizeChangedEventArgs e) + { + if (IsPaneOpen && _sidebarColumn != null && _sidebarPane != null && _sidebarPane.Bounds.Width > 0) + { + var clamped = ClampPaneLength(_sidebarPane.Bounds.Width, MinPaneLength, MaxPaneLength); + if (Math.Abs(OpenPaneLength - clamped) > 1.0) + { + OpenPaneLength = clamped; + } + } + } + + private void OnSplitterDragCompleted(object? sender, RoutedEventArgs e) + { + if (IsPaneOpen && _sidebarColumn != null && _sidebarColumn.Width.IsAbsolute && _sidebarColumn.Width.Value > 0) + { + OpenPaneLength = ClampPaneLength(_sidebarColumn.Width.Value, MinPaneLength, MaxPaneLength); + } } - private void OnSidebarPanePointerExited(object? sender, PointerEventArgs e) + private void UpdateLayoutState() { - // Only close if we are actually outside the pane bounds - // This simple check works for now; more robust hit testing could be added if needed - var point = e.GetPosition(_sidebarPane); - if (_sidebarPane != null && - (point.X < 0 || point.X >= _sidebarPane.Bounds.Width || - point.Y < 0 || point.Y >= _sidebarPane.Bounds.Height)) + if (_sidebarColumn is null || _splitterColumn is null) { - IsPaneOpen = false; + return; + } + + if (IsPaneOpen) + { + ApplyOpenState(_sidebarColumn, _splitterColumn); + } + else + { + ApplyClosedState(_sidebarColumn, _splitterColumn); } } - private void OnContentPointerPressed(object? sender, PointerPressedEventArgs e) + private void ApplyOpenState(ColumnDefinition sidebarColumn, ColumnDefinition splitterColumn) { - IsPaneOpen = false; + var (min, max) = GetSanitizedBounds(MinPaneLength, MaxPaneLength); + var length = ClampPaneLength(OpenPaneLength, min, max); + + sidebarColumn.Width = new GridLength(length, GridUnitType.Pixel); + sidebarColumn.MinWidth = min; + sidebarColumn.MaxWidth = max; + splitterColumn.Width = new GridLength(SidebarConstants.SplitterWidth, GridUnitType.Pixel); + + SetControlVisibility(_sidebarPane, true); + SetControlVisibility(_splitter, true); + SetControlVisibility(_triggerZone, false); } - private void OnContentPointerEntered(object? sender, PointerEventArgs e) + private void ApplyClosedState(ColumnDefinition sidebarColumn, ColumnDefinition splitterColumn) { - IsPaneOpen = false; + if (sidebarColumn.Width.IsAbsolute && sidebarColumn.Width.Value > 0) + { + OpenPaneLength = ClampPaneLength(sidebarColumn.Width.Value, MinPaneLength, MaxPaneLength); + } + + sidebarColumn.Width = new GridLength(0, GridUnitType.Pixel); + sidebarColumn.MinWidth = 0; + sidebarColumn.MaxWidth = 0; + splitterColumn.Width = new GridLength(0, GridUnitType.Pixel); + + SetControlVisibility(_sidebarPane, false); + SetControlVisibility(_splitter, false); + SetControlVisibility(_triggerZone, true); + } + + private void OnTriggerZonePointerEntered(object? sender, PointerEventArgs e) + { + IsPaneOpen = true; + } + + private void OnTriggerZonePointerPressed(object? sender, PointerPressedEventArgs e) + { + IsPaneOpen = true; } } diff --git a/GenHub/GenHub/Common/Controls/SidebarLayoutStyles.axaml b/GenHub/GenHub/Common/Controls/SidebarLayoutStyles.axaml index c78b9dac6..801095293 100644 --- a/GenHub/GenHub/Common/Controls/SidebarLayoutStyles.axaml +++ b/GenHub/GenHub/Common/Controls/SidebarLayoutStyles.axaml @@ -9,168 +9,208 @@ - - + + + + + + - - - - + + + + + + + + + - - - - - - - + + - - - - - - - - - - + + + + + - - - - - - - - - + + + - - - + + - - + + + + + - - - - - - + - + - - - - + + + + - - - - - - - - - - - + + + + + - - - + + + + + + + + + + + + + + + + + - - - - + + + + + + - - + + diff --git a/GenHub/GenHub/Common/Services/AppConfiguration.cs b/GenHub/GenHub/Common/Services/AppConfiguration.cs index 5576d14f1..dd655aed1 100644 --- a/GenHub/GenHub/Common/Services/AppConfiguration.cs +++ b/GenHub/GenHub/Common/Services/AppConfiguration.cs @@ -1,7 +1,9 @@ using System; using System.IO; +using System.Linq; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -138,20 +140,20 @@ public string GetDefaultTheme() var configured = _configuration?[ConfigurationKeys.UiDefaultTheme]; if (!string.IsNullOrEmpty(configured)) { - // Validate that the configured theme is valid (only "Dark" and "Light" are supported) var normalizedTheme = configured.Trim(); - if (string.Equals(normalizedTheme, "Dark", StringComparison.OrdinalIgnoreCase) || + if (ThemeConstants.AllThemes.Any(t => + string.Equals(t.Id, normalizedTheme, StringComparison.OrdinalIgnoreCase) || + string.Equals(t.DisplayName, normalizedTheme, StringComparison.OrdinalIgnoreCase)) || + string.Equals(normalizedTheme, "Dark", StringComparison.OrdinalIgnoreCase) || string.Equals(normalizedTheme, "Light", StringComparison.OrdinalIgnoreCase)) { return normalizedTheme; } - else - { - _logger?.LogWarning("Invalid theme '{Theme}' configured, falling back to default", configured); - } + + _logger?.LogWarning("Invalid theme '{Theme}' configured, falling back to default", configured); } - return AppConstants.DefaultThemeName; // Default theme + return ThemeConstants.DefaultTheme.Id; } /// @@ -226,4 +228,17 @@ 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); + + /// + public CsvCatalogConfiguration GetCsvCatalogConfiguration() + { + return _configuration?.GetSection(ConfigurationKeys.GenHubSection).Get() ?? new CsvCatalogConfiguration(); + } } \ No newline at end of file diff --git a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs index 948cc7975..0e63c3fb4 100644 --- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs +++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs @@ -2,9 +2,12 @@ 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.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Storage; using Microsoft.Extensions.Logging; @@ -20,11 +23,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 +177,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() { @@ -203,6 +253,9 @@ public NavigationTab GetLastSelectedTab() /// public UserSettings GetEffectiveSettings() { + var csvCatalogConfiguration = GetCsvCatalogConfiguration(); + var csvValidationCatalogs = csvCatalogConfiguration.CsvValidationCatalogs ?? []; + return new UserSettings { Theme = GetTheme(), @@ -215,6 +268,8 @@ public UserSettings GetEffectiveSettings() MaxConcurrentDownloads = GetMaxConcurrentDownloads(), AllowBackgroundDownloads = GetAllowBackgroundDownloads(), AutoCheckForUpdatesOnStartup = GetAutoCheckForUpdatesOnStartup(), + AutoCheckForUpdatesPeriodically = GetAutoCheckForUpdatesPeriodically(), + PeriodicUpdateCheckIntervalMinutes = GetPeriodicUpdateCheckIntervalMinutes(), LastUpdateCheckTimestamp = _userSettings.Get().LastUpdateCheckTimestamp, EnableDetailedLogging = GetEnableDetailedLogging(), DefaultWorkspaceStrategy = GetDefaultWorkspaceStrategy(), @@ -227,6 +282,8 @@ public UserSettings GetEffectiveSettings() ApplicationDataPath = GetApplicationDataPath(), CachePath = GetCachePath(), CasConfiguration = GetCasConfiguration(), + IndexFilePath = csvCatalogConfiguration.IndexFilePath, + CsvValidationCatalogs = [.. csvValidationCatalogs.Select(c => c.Clone())], }; } @@ -240,10 +297,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 +317,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 +377,300 @@ public string GetLogsPath() DirectoryNames.Logs.ToLowerInvariant()); } - private void MigrateContentDirectory() + /// + public CsvCatalogConfiguration GetCsvCatalogConfiguration() { - try + var appCatalogConfig = _appConfig.GetCsvCatalogConfiguration() ?? new CsvCatalogConfiguration(); + var settings = _userSettings.Get(); + var appCatalogs = appCatalogConfig.CsvValidationCatalogs ?? []; + + return new CsvCatalogConfiguration { - var rootPath = _appConfig.GetConfiguredDataPath(); - var contentPath = Path.Combine(rootPath, "Content"); + IndexFilePath = settings.IsExplicitlySet(nameof(UserSettings.IndexFilePath)) && + !string.IsNullOrWhiteSpace(settings.IndexFilePath) + ? settings.IndexFilePath + : appCatalogConfig.IndexFilePath, + CsvValidationCatalogs = settings.IsExplicitlySet(nameof(UserSettings.CsvValidationCatalogs)) && + settings.CsvValidationCatalogs != null + ? [.. settings.CsvValidationCatalogs.Select(c => c.Clone())] + : [.. appCatalogs.Select(c => c.Clone())], + }; + } - if (!Directory.Exists(contentPath)) - { - return; - } + /// + /// 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) + { + if (!Directory.Exists(legacyRoot)) + { + return; + } - _logger.LogInformation("Migrating content from {ContentPath} to root {RootPath}", contentPath, rootPath); + var directories = ResolveLegacyDirectories(legacyRoot, dataRoot); + var files = ResolveLegacyFiles(legacyRoot, dataRoot, settingsRoot); - // 1. Move Manifests - MigrateDirectory(Path.Combine(contentPath, "Manifests"), Path.Combine(rootPath, "Manifests")); + if (directories.Count == 0 && files.Count == 0) + { + return; + } - // 2. Move UserData - MigrateDirectory(Path.Combine(contentPath, "UserData"), Path.Combine(rootPath, "UserData")); + _logger.LogInformation( + "Migrating legacy data root {LegacyRoot} into {DataRoot}, settings into {SettingsRoot}", + legacyRoot, + dataRoot, + settingsRoot); - // 3. Move workspaces.json - var sourceWorkspaces = Path.Combine(contentPath, "workspaces.json"); - var destWorkspaces = Path.Combine(rootPath, "workspaces.json"); - if (File.Exists(sourceWorkspaces)) + if (directories.Count > 0) + { + Directory.CreateDirectory(dataRoot); + } + + 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 + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) { - // Ignore if not empty + _logger.LogError(ex, "Failed to migrate legacy file {Source}", source); } } - catch (Exception ex) + } + + 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)) + { + 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) 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 + { + MigrateFile(file, Path.Combine(destDir, Path.GetFileName(file))); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) { - File.Move(file, destFile); + _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/StorageWritabilityProbe.cs b/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs index f8f08adc6..aba2856a5 100644 --- a/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs +++ b/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs @@ -112,29 +112,29 @@ private bool Probe(string fullStoragePath) probeSucceeded = true; return true; } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException ex) { - logger.LogDebug("Storage path {StoragePath} is not writable", fullStoragePath); + logger.LogDebug(ex, "Storage path {StoragePath} is not writable", fullStoragePath); return false; } - catch (IOException) + catch (IOException ex) { - logger.LogDebug("Storage path {StoragePath} is not writable", fullStoragePath); + logger.LogDebug(ex, "Storage path {StoragePath} is not writable", fullStoragePath); return false; } - catch (ArgumentException) + catch (ArgumentException ex) { - logger.LogDebug("Storage path {StoragePath} is not writable", fullStoragePath); + logger.LogDebug(ex, "Storage path {StoragePath} is not writable", fullStoragePath); return false; } - catch (NotSupportedException) + catch (NotSupportedException ex) { - logger.LogDebug("Storage path {StoragePath} is not writable", fullStoragePath); + logger.LogDebug(ex, "Storage path {StoragePath} is not writable", fullStoragePath); return false; } - catch (SecurityException) + catch (SecurityException ex) { - logger.LogDebug("Storage path {StoragePath} is not writable", fullStoragePath); + logger.LogDebug(ex, "Storage path {StoragePath} is not writable", fullStoragePath); return false; } finally @@ -145,13 +145,13 @@ private bool Probe(string fullStoragePath) { File.Delete(probePath); } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException ex) { - logger.LogDebug("Could not remove storage write probe {ProbePath}", probePath); + logger.LogDebug(ex, "Could not remove storage write probe {ProbePath}", probePath); } - catch (IOException) + catch (IOException ex) { - logger.LogDebug("Could not remove storage write probe {ProbePath}", probePath); + logger.LogDebug(ex, "Could not remove storage write probe {ProbePath}", probePath); } } @@ -165,17 +165,17 @@ private bool Probe(string fullStoragePath) Directory.Delete(fullStoragePath); } } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException ex) { - logger.LogDebug("Could not remove failed storage probe directory {StoragePath}", fullStoragePath); + logger.LogDebug(ex, "Could not remove failed storage probe directory {StoragePath}", fullStoragePath); } - catch (IOException) + catch (IOException ex) { - logger.LogDebug("Could not remove failed storage probe directory {StoragePath}", fullStoragePath); + logger.LogDebug(ex, "Could not remove failed storage probe directory {StoragePath}", fullStoragePath); } - catch (SecurityException) + catch (SecurityException ex) { - logger.LogDebug("Could not remove failed storage probe directory {StoragePath}", fullStoragePath); + logger.LogDebug(ex, "Could not remove failed storage probe directory {StoragePath}", fullStoragePath); } } } diff --git a/GenHub/GenHub/Common/Services/ThemeService.cs b/GenHub/GenHub/Common/Services/ThemeService.cs new file mode 100644 index 000000000..c27fc1736 --- /dev/null +++ b/GenHub/GenHub/Common/Services/ThemeService.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Media; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.Messaging; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Messages; +using GenHub.Core.Models.Theming; +using Microsoft.Extensions.Logging; + +namespace GenHub.Common.Services; + +/// +/// Service that manages dynamic application accent color themes at runtime. +/// +public class ThemeService( + IConfigurationProviderService configurationProviderService, + ILogger logger) : IThemeService +{ + /// + public IReadOnlyList AvailableThemes => ThemeConstants.AllThemes; + + /// + public ColorTheme CurrentTheme { get; private set; } = ThemeConstants.DefaultTheme; + + /// + public void InitializeTheme() + { + var effectiveTheme = configurationProviderService.GetTheme(); + if (!string.IsNullOrWhiteSpace(effectiveTheme)) + { + ApplyTheme(effectiveTheme); + } + else + { + ApplyTheme(ThemeConstants.DefaultTheme); + } + } + + /// + public void ApplyTheme(string themeId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(themeId); + + var theme = AvailableThemes.FirstOrDefault(t => + string.Equals(t.Id, themeId, StringComparison.OrdinalIgnoreCase) || + string.Equals(t.DisplayName, themeId, StringComparison.OrdinalIgnoreCase)) + ?? ThemeConstants.DefaultTheme; + + ApplyTheme(theme); + } + + /// + public void ApplyTheme(ColorTheme theme) + { + ArgumentNullException.ThrowIfNull(theme); + + CurrentTheme = theme; + + if (Dispatcher.UIThread.CheckAccess()) + { + ApplyThemeToResources(theme); + } + else + { + Dispatcher.UIThread.Post(() => ApplyThemeToResources(theme)); + } + } + + private void ApplyThemeToResources(ColorTheme theme) + { + if (Application.Current is null) + { + return; + } + + try + { + var primaryColor = Color.Parse(theme.PrimaryHex); + var lightColor = Color.Parse(theme.LightHex); + var darkColor = Color.Parse(theme.DarkHex); + var glowColor = Color.Parse(theme.GlowHex); + var badgeBgColor = Color.FromArgb(0x20, primaryColor.R, primaryColor.G, primaryColor.B); + var badgeFgColor = Color.FromArgb(0xCC, primaryColor.R, primaryColor.G, primaryColor.B); + var tintBgColor = Color.FromArgb(0x25, primaryColor.R, primaryColor.G, primaryColor.B); + var glassBorderColor = Color.FromArgb(0x33, darkColor.R, darkColor.G, darkColor.B); + var sidebarGlowColor = Color.FromArgb(0x66, darkColor.R, darkColor.G, darkColor.B); + var sidebarSelectBgColor = Color.FromArgb(0x4D, darkColor.R, darkColor.G, darkColor.B); + + var resources = Application.Current.Resources; + + // Update Colors + resources[ThemeResourceKeys.AccentColor] = primaryColor; + resources[ThemeResourceKeys.SystemAccentColor] = primaryColor; + resources[ThemeResourceKeys.AccentLightColor] = lightColor; + resources[ThemeResourceKeys.AccentDarkColor] = darkColor; + resources[ThemeResourceKeys.AccentTintBackgroundColor] = tintBgColor; + resources[ThemeResourceKeys.PrimaryButtonBackgroundDark] = primaryColor; + resources[ThemeResourceKeys.AccentBadgeBackgroundColor] = badgeBgColor; + resources[ThemeResourceKeys.AccentBadgeForegroundColor] = badgeFgColor; + resources[ThemeResourceKeys.AccentGlowColor] = glowColor; + resources[ThemeResourceKeys.SidebarGlassBorder] = glassBorderColor; + resources[ThemeResourceKeys.SidebarGlowColor] = sidebarGlowColor; + resources[ThemeResourceKeys.PrimaryGradientStart] = lightColor; + resources[ThemeResourceKeys.PrimaryGradientEnd] = darkColor; + resources["PurpleAccentDark"] = darkColor; + resources["PurpleAccentMid"] = darkColor; + resources["PurpleAccentBright"] = primaryColor; + resources["PurpleGlow"] = glowColor; + + // Update Brushes + resources[ThemeResourceKeys.AccentBrush] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.AccentColorBrush] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.AccentLightBrush] = new SolidColorBrush(lightColor); + resources[ThemeResourceKeys.AccentDarkBrush] = new SolidColorBrush(darkColor); + resources[ThemeResourceKeys.AccentGlowBrush] = new SolidColorBrush(glowColor); + resources[ThemeResourceKeys.AccentTintBackgroundBrush] = new SolidColorBrush(tintBgColor); + resources[ThemeResourceKeys.SystemAccentColorBrush] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.PrimaryButtonBackground] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.SidebarSelectedIndicator] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.ScrollbarThumbPressedBrush] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.ScrollBarThumbFillPressed] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.AccentBadgeBackgroundBrush] = new SolidColorBrush(badgeBgColor); + resources[ThemeResourceKeys.AccentBadgeForegroundBrush] = new SolidColorBrush(badgeFgColor); + resources[ThemeResourceKeys.SidebarItemSelectedBackground] = new SolidColorBrush(sidebarSelectBgColor); + resources[ThemeResourceKeys.SidebarItemSelectedBorder] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.SidebarGlassBorderBrush] = new SolidColorBrush(glassBorderColor); + resources[ThemeResourceKeys.ComboBoxItemBackgroundSelected] = new SolidColorBrush(badgeBgColor); + resources[ThemeResourceKeys.ComboBoxItemBackgroundSelectedPointerOver] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.ComboBoxItemBackgroundPointerOver] = new SolidColorBrush(primaryColor); + resources[ThemeResourceKeys.ComboBoxItemForegroundPointerOver] = new SolidColorBrush(Colors.White); + resources[ThemeResourceKeys.ExpanderHeaderBackgroundPointerOver] = new SolidColorBrush(tintBgColor); + resources[ThemeResourceKeys.ExpanderHeaderBackgroundPressed] = new SolidColorBrush(badgeBgColor); + resources[ThemeResourceKeys.ExpanderChevronForegroundPointerOver] = new SolidColorBrush(lightColor); + resources[ThemeResourceKeys.ExpanderChevronForegroundPressed] = new SolidColorBrush(lightColor); + + // Update Linear Gradient Brushes + var gradientBrush = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(lightColor, 0), + new GradientStop(darkColor, 1), + }, + }; + resources[ThemeResourceKeys.PrimaryGradientBrush] = gradientBrush; + resources[ThemeResourceKeys.PurpleAccentGradient] = gradientBrush; + resources["PurpleAccentGradient"] = gradientBrush; + + WeakReferenceMessenger.Default.Send(new ThemeChangedMessage(theme.Id)); + logger.LogDebug("Applied color theme '{ThemeName}' ({ThemeId})", theme.DisplayName, theme.Id); + } + catch (FormatException ex) + { + logger.LogError(ex, "Failed to parse color hex for theme {ThemeId}", theme.Id); + } + } +} diff --git a/GenHub/GenHub/Common/Services/UserSettingsService.cs b/GenHub/GenHub/Common/Services/UserSettingsService.cs index ab3bacb67..054a74674 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 @@ -271,17 +328,33 @@ private static string ConvertJsonPropertyNameToCSharp(string jsonPropertyName) "applicationDataPath" => nameof(UserSettings.ApplicationDataPath), "contentDirectories" => nameof(UserSettings.ContentDirectories), "gitHubDiscoveryRepositories" => nameof(UserSettings.GitHubDiscoveryRepositories), + "indexFilePath" => nameof(UserSettings.IndexFilePath), + "csvValidationCatalogs" => nameof(UserSettings.CsvValidationCatalogs), _ => string.Empty, }; } - 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 +362,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 +377,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 +397,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 +451,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..3be4d30d7 100644 --- a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs +++ b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Avalonia; @@ -18,7 +18,6 @@ using GenHub.Core.Messages; using GenHub.Core.Models.Dialogs; using GenHub.Core.Models.Enums; -using GenHub.Core.Models.Notifications; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.Downloads.ViewModels; using GenHub.Features.GameProfiles.ViewModels; @@ -40,12 +39,13 @@ namespace GenHub.Common.ViewModels; /// Notification manager view model. /// Configuration provider service. /// User settings service for persistence operations. -/// The Velopack update manager for checking updates. +/// Coordinator for background update checking and scheduling. /// Service for showing notifications. /// Dialog service for showing message boxes. /// Notification feed view model. /// Info view model. /// Logger instance. +[SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "MainViewModel is the top-level composition ViewModel for tabs and services injected via dependency injection.")] public partial class MainViewModel( GameProfileLauncherViewModel gameProfilesViewModel, DownloadsViewModel downloadsViewModel, @@ -54,7 +54,7 @@ public partial class MainViewModel( NotificationManagerViewModel notificationManager, IConfigurationProviderService configurationProvider, IUserSettingsService userSettingsService, - IVelopackUpdateManager velopackUpdateManager, + IBackgroundUpdateCoordinator backgroundUpdateCoordinator, INotificationService notificationService, IDialogService dialogService, NotificationFeedViewModel notificationFeedViewModel, @@ -62,15 +62,18 @@ public partial class MainViewModel( ILogger logger) : ObservableObject, IDisposable, IRecipient { private readonly CancellationTokenSource _initializationCts = new(); + private bool _disposed; /// /// Initializes a new instance of the class for design-time support. /// +#pragma warning disable CS8625 [Obsolete("Use DI constructor for runtime. This is only for XAML tools.")] public MainViewModel() - : this(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!) + : this(null, null, null, null, null, null, null, null, null, null, null, null, null) { } +#pragma warning restore CS8625 /// /// Gets the info view model. @@ -158,7 +161,14 @@ public MainViewModel() /// public void Receive(NavigationMessage message) { - Dispatcher.UIThread.Post(() => SelectTab(message.Tab)); + if (Dispatcher.UIThread.CheckAccess()) + { + SelectTab(message.Tab); + } + else + { + Dispatcher.UIThread.Post(() => SelectTab(message.Tab)); + } } /// @@ -184,8 +194,7 @@ public async Task InitializeAsync() await InfoViewModel.InitializeAsync(); logger?.LogInformation("MainViewModel initialized"); - // Start background check with cancellation support - _ = CheckForUpdatesInBackgroundAsync(_initializationCts.Token); + await backgroundUpdateCoordinator.InitializeAsync(_initializationCts.Token); CheckForQuickStart(); } @@ -195,8 +204,24 @@ public async Task InitializeAsync() /// public void Dispose() { - _initializationCts?.Cancel(); - _initializationCts?.Dispose(); + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + _initializationCts.Cancel(); + } + catch (ObjectDisposedException) + { + // Ignore if already disposed + } + + _initializationCts.Dispose(); + WeakReferenceMessenger.Default.UnregisterAll(this); GC.SuppressFinalize(this); } @@ -220,100 +245,11 @@ private static NavigationTab LoadInitialTab(IConfigurationProviderService config } } - // Register for messages private void RegisterMessages() { - WeakReferenceMessenger.Default.Register(this); - } - - /// - /// Checks for available updates using Velopack. - /// - private async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default) - { - logger?.LogDebug("Starting background update check"); - - try + if (!WeakReferenceMessenger.Default.IsRegistered(this)) { - var settings = userSettingsService.Get(); - - // Push settings to update manager (important context for other components) - if (settings.SubscribedPrNumber.HasValue) - { - velopackUpdateManager.SubscribedPrNumber = settings.SubscribedPrNumber; - } - - // 1. Check for standard GitHub releases (Default) - if (string.IsNullOrEmpty(settings.SubscribedBranch)) - { - var updateInfo = await velopackUpdateManager.CheckForUpdatesAsync(cancellationToken); - if (updateInfo != null) - { - logger?.LogInformation("GitHub release update available: {Version}", updateInfo.TargetFullRelease.Version); - await Dispatcher.UIThread.InvokeAsync(() => notificationService.Show(new NotificationMessage( - NotificationType.Info, - "Update Available", - $"A new version ({updateInfo.TargetFullRelease.Version}) is available.", - null, // Persistent - actions: - [ - new NotificationAction( - "View Updates", - () => SettingsViewModel.OpenUpdateWindowCommand.Execute(null), - NotificationActionStyle.Primary, - dismissOnExecute: true), - ]))); - return; - } - } - else - { - // 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 newVersionBase = artifactUpdate.Version.Split('+')[0]; - - await Dispatcher.UIThread.InvokeAsync(() => notificationService.Show(new NotificationMessage( - NotificationType.Info, - "Branch Update Available", - $"A new build ({newVersionBase}) is available on branch '{settings.SubscribedBranch}'.", - null, // Persistent - actions: - [ - new NotificationAction( - "View Updates", - () => SettingsViewModel.OpenUpdateWindowCommand.Execute(null), - NotificationActionStyle.Primary, - dismissOnExecute: true), - ]))); - } - } - } - catch (Exception ex) - { - logger?.LogError(ex, "Exception in CheckForUpdatesAsync"); - } - } - - private async Task CheckForUpdatesInBackgroundAsync(CancellationToken ct) - { - try - { - await CheckForUpdatesAsync(ct); - } - catch (OperationCanceledException) - { - // Expected on cancellation - } - catch (Exception ex) - { - logger?.LogError(ex, "Unhandled exception in background update check"); + WeakReferenceMessenger.Default.RegisterAll(this); } } @@ -329,13 +265,11 @@ private void CheckForQuickStart() new DialogAction { Text = "Open Quickstart", - Style = NotificationActionStyle.Primary, // Switched to Primary (Purple) + Style = NotificationActionStyle.Primary, Action = () => { - SelectTab(NavigationTab.Info); - - // Programmatic navigation to the quickstart section - InfoViewModel.OpenSection("quickstart"); + SelectTab(NavigationTab.Info); + InfoViewModel.OpenSection("quickstart"); }, }, new DialogAction @@ -364,7 +298,7 @@ private void CheckForQuickStart() if (result.DoNotAskAgain) { userSettingsService.Update(s => s.HasSeenQuickStart = true); - _ = userSettingsService.SaveAsync(); + _ = userSettingsService.SaveAsync(_initializationCts.Token); } }); } @@ -379,7 +313,7 @@ private void SaveSelectedTab(NavigationTab selectedTab) settings.LastSelectedTab = selectedTab; }); - _ = userSettingsService.SaveAsync(); + _ = userSettingsService.SaveAsync(CancellationToken.None); logger?.LogDebug("Updated last selected tab to: {Tab}", selectedTab); } catch (Exception ex) @@ -392,10 +326,8 @@ partial void OnSelectedTabChanged(NavigationTab value) { OnPropertyChanged(nameof(CurrentTabViewModel)); - // Notify SettingsViewModel when it becomes visible/invisible SettingsViewModel.IsViewVisible = value == NavigationTab.Settings; - // Refresh Tabs when they become visible if (value == NavigationTab.GameProfiles) { GameProfilesViewModel.OnTabActivated(); diff --git a/GenHub/GenHub/Common/Views/Dialogs/ConfirmationDialogWindow.axaml b/GenHub/GenHub/Common/Views/Dialogs/ConfirmationDialogWindow.axaml index ce7327a37..29cee971e 100644 --- a/GenHub/GenHub/Common/Views/Dialogs/ConfirmationDialogWindow.axaml +++ b/GenHub/GenHub/Common/Views/Dialogs/ConfirmationDialogWindow.axaml @@ -87,7 +87,7 @@ - + - - @@ -104,9 +79,9 @@ - - - + + + @@ -118,8 +93,8 @@ - - + + @@ -130,7 +105,7 @@ - + @@ -140,7 +115,7 @@ - + @@ -182,23 +157,21 @@ - - - - - - - - + + + + + + diff --git a/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs b/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs index c67e2f4de..084a818a6 100644 --- a/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs +++ b/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs @@ -18,9 +18,6 @@ public partial class GenericMessageWindow : Window public GenericMessageWindow() { InitializeComponent(); -#if DEBUG - this.AttachDevTools(); -#endif } /// diff --git a/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml b/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml index 7a1c10fc9..4d9a8572d 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"> @@ -22,13 +23,13 @@ - + - + @@ -90,14 +91,8 @@ ToolTip.Tip="Skip this update (you'll be notified again for newer versions)"/> + Background="{DynamicResource PrimaryGradientBrush}" + FontWeight="SemiBold" Foreground="White" Padding="24,10" CornerRadius="4" /> diff --git a/GenHub/GenHub/Common/Views/MainView.axaml b/GenHub/GenHub/Common/Views/MainView.axaml index 71eb8f4d8..97fb00ecf 100644 --- a/GenHub/GenHub/Common/Views/MainView.axaml +++ b/GenHub/GenHub/Common/Views/MainView.axaml @@ -22,6 +22,7 @@ mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700" x:Class="GenHub.Common.Views.MainView" x:DataType="vm:MainViewModel" + x:CompileBindings="True" x:Name="MainViewRoot"> @@ -29,82 +30,105 @@ - - + + + - - - - - @@ -145,7 +169,7 @@ Grid.RowSpan="2"/> - + @@ -155,54 +179,57 @@ - - - - + + + + + + diff --git a/GenHub/GenHub/Common/Views/MainWindow.axaml.cs b/GenHub/GenHub/Common/Views/MainWindow.axaml.cs index e11b82cf0..899d41a32 100644 --- a/GenHub/GenHub/Common/Views/MainWindow.axaml.cs +++ b/GenHub/GenHub/Common/Views/MainWindow.axaml.cs @@ -26,7 +26,7 @@ private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) { if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - if (e.ClickCount == 2) + if (e.ClickCount == 2 && CanResize) { MaximizeButton_Click(sender, new Avalonia.Interactivity.RoutedEventArgs()); } diff --git a/GenHub/GenHub/Features/AppUpdate/Interfaces/IBackgroundUpdateCoordinator.cs b/GenHub/GenHub/Features/AppUpdate/Interfaces/IBackgroundUpdateCoordinator.cs new file mode 100644 index 000000000..df1d746c5 --- /dev/null +++ b/GenHub/GenHub/Features/AppUpdate/Interfaces/IBackgroundUpdateCoordinator.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Features.AppUpdate.Interfaces; + +/// +/// Coordinates background app update checks, periodic check scheduling, fallback discovery, and one-click installation. +/// +public interface IBackgroundUpdateCoordinator : IDisposable +{ + /// + /// Initializes background update checking based on user settings and starts periodic timers if enabled. + /// + /// Cancellation token. + /// A task representing the initialization operation. + Task InitializeAsync(CancellationToken cancellationToken = default); + + /// + /// Performs an immediate check for available updates in the background. + /// + /// Cancellation token. + /// A task representing the update check operation. + Task CheckForUpdatesAsync(CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub/Features/AppUpdate/Services/BackgroundUpdateCoordinator.cs b/GenHub/GenHub/Features/AppUpdate/Services/BackgroundUpdateCoordinator.cs new file mode 100644 index 000000000..0e653ee48 --- /dev/null +++ b/GenHub/GenHub/Features/AppUpdate/Services/BackgroundUpdateCoordinator.cs @@ -0,0 +1,876 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.Messaging; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GitHub; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Messages; +using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Notifications; +using GenHub.Features.AppUpdate.Interfaces; +using GenHub.Features.AppUpdate.ViewModels; +using GenHub.Features.AppUpdate.Views; +using Microsoft.Extensions.Logging; +using Velopack; + +namespace GenHub.Features.AppUpdate.Services; + +/// +/// Coordinates background app update checks, scheduled periodic checks, fallback discovery, and one-click installation. +/// +/// The Velopack update manager for checking updates. +/// User settings service for persistence operations. +/// Service for showing notifications. +/// Logger instance. +/// Optional GitHub token storage for checking token availability. +public class BackgroundUpdateCoordinator( + IVelopackUpdateManager velopackUpdateManager, + IUserSettingsService userSettingsService, + INotificationService notificationService, + ILogger logger, + IGitHubTokenStorage? gitHubTokenStorage = null) : IBackgroundUpdateCoordinator, IRecipient +{ + private readonly CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _checkLock = new(1, 1); + private Timer? _periodicUpdateTimer; + private string? _lastNotifiedUpdateIdentity; + private bool _disposed; + + /// + public Task InitializeAsync(CancellationToken cancellationToken = default) + { + RegisterMessages(); + + var settings = userSettingsService.Get(); + if (settings.AutoCheckForUpdatesOnStartup) + { + _ = CheckForUpdatesOnStartupAsync(cancellationToken); + } + + RestartPeriodicUpdateTimer(settings.AutoCheckForUpdatesPeriodically, settings.PeriodicUpdateCheckIntervalMinutes); + return Task.CompletedTask; + } + + /// + public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default) + { + logger?.LogDebug("Starting background update check"); + + try + { + await _checkLock.WaitAsync(cancellationToken); + } + catch (ObjectDisposedException) + { + return; + } + + try + { + var settings = userSettingsService.Get(); + + // 1. check for subscribed pr artifacts + if (settings.SubscribedPrNumber.HasValue) + { + await CheckSubscribedPrUpdateAsync(settings.SubscribedPrNumber.Value, settings, cancellationToken); + return; + } + + // 2. check for subscribed branch artifacts + if (!string.IsNullOrWhiteSpace(settings.SubscribedBranch)) + { + await CheckSubscribedBranchUpdateAsync(settings.SubscribedBranch, settings, cancellationToken); + return; + } + + // 3. check for standard github releases + await CheckStandardReleaseUpdateAsync(settings, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger?.LogError(ex, "Exception in CheckForUpdatesAsync"); + } + finally + { + var currentSettings = userSettingsService.Get(); + velopackUpdateManager.SubscribedPrNumber = currentSettings.SubscribedPrNumber; + velopackUpdateManager.SubscribedBranch = currentSettings.SubscribedBranch; + + try + { + _checkLock.Release(); + } + catch (ObjectDisposedException) + { + // Coordinator was disposed during check + } + } + } + + /// + public void Receive(UpdateSettingsChangedMessage message) + { + RestartPeriodicUpdateTimer(message.AutoCheckForUpdatesPeriodically, message.PeriodicUpdateCheckIntervalMinutes); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes resources used by the coordinator. + /// + /// True if disposing managed resources; false if finalizing. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + WeakReferenceMessenger.Default.UnregisterAll(this); + + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Ignore if already disposed + } + + _periodicUpdateTimer?.Dispose(); + _periodicUpdateTimer = null; + _cts.Dispose(); + } + + _disposed = true; + } + + private void RegisterMessages() + { + if (!WeakReferenceMessenger.Default.IsRegistered(this)) + { + WeakReferenceMessenger.Default.RegisterAll(this); + } + } + + private async Task CheckSubscribedPrUpdateAsync(int prNumber, UserSettings settings, CancellationToken cancellationToken) + { + if (gitHubTokenStorage != null && !gitHubTokenStorage.HasToken()) + { + logger?.LogDebug("No GitHub token configured; skipping background PR artifact check for #{PrNumber}", prNumber); + return; + } + + 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 = $"{AppUpdateConstants.PrDedupePrefix}{prNumber}:{artifactVersionBase}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, 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; + } + + if (velopackUpdateManager.IsPrMergedOrClosed) + { + await CheckPrMergedFallbackUpdateAsync(prNumber, settings, cancellationToken); + } + } + + private async Task CheckPrMergedFallbackUpdateAsync(int prNumber, UserSettings settings, CancellationToken cancellationToken) + { + logger?.LogInformation("Subscribed PR #{PrNumber} is merged or closed. Checking development/release fallback", prNumber); + var currentVersionBase = UpdateNotificationViewModel.CurrentAppVersion.Split('+')[0]; + + if (await TryNotifyPrMergedDevFallbackAsync(prNumber, settings, currentVersionBase, cancellationToken)) + { + return; + } + + if (await TryNotifyPrMergedReleaseFallbackAsync(prNumber, settings, cancellationToken)) + { + return; + } + + TryNotifyPrMergedGitHubFallback(prNumber, settings); + } + + private async Task TryNotifyPrMergedDevFallbackAsync( + int prNumber, + UserSettings settings, + string currentVersionBase, + CancellationToken cancellationToken) + { + velopackUpdateManager.SubscribedPrNumber = null; + velopackUpdateManager.SubscribedBranch = AppUpdateConstants.DevelopmentBranch; + + var devArtifact = await velopackUpdateManager.CheckForArtifactUpdatesAsync(cancellationToken); + if (devArtifact == null) + { + return false; + } + + var devVersionBase = devArtifact.Version.Split('+')[0]; + if (!AppUpdateVersionHelper.IsArtifactVersionNewer(devVersionBase, currentVersionBase, allowCrossChannel: true) || + string.Equals(devVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var updateIdentity = $"{AppUpdateConstants.PrFallbackDedupePrefix}{prNumber}:dev:{devVersionBase}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, updateIdentity); + return true; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("PR #{PrNumber} merged or closed. Development fallback update available: {Version}", prNumber, devArtifact.DisplayVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.PrMergedUpdateNotificationFormat, devArtifact.DisplayVersion, prNumber), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateWithSubscriptionClearAsync(devArtifact, null, null, prNumber, null), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + return true; + } + + private async Task TryNotifyPrMergedReleaseFallbackAsync( + int prNumber, + UserSettings settings, + CancellationToken cancellationToken) + { + velopackUpdateManager.SubscribedBranch = null; + var releaseUpdate = await velopackUpdateManager.CheckForUpdatesAsync(cancellationToken); + if (releaseUpdate == null) + { + return false; + } + + var version = releaseUpdate.TargetFullRelease.Version.ToString(); + if (string.Equals(version, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var updateIdentity = $"{AppUpdateConstants.PrFallbackDedupePrefix}{prNumber}:release:{version}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, updateIdentity); + return true; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("PR #{PrNumber} merged or closed. Release fallback update available: {Version}", prNumber, version); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.PrMergedReleaseNotificationFormat, version, prNumber), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateWithSubscriptionClearAsync(null, releaseUpdate, null, prNumber, null), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + return true; + } + + private void TryNotifyPrMergedGitHubFallback(int prNumber, UserSettings settings) + { + if (!velopackUpdateManager.HasUpdateAvailableFromGitHub) + { + return; + } + + var githubVersion = velopackUpdateManager.LatestVersionFromGitHub; + if (string.IsNullOrWhiteSpace(githubVersion) || + string.Equals(githubVersion, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var updateIdentity = $"{AppUpdateConstants.PrFallbackDedupePrefix}{prNumber}:github:{githubVersion}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("PR #{PrNumber} merged or closed. GitHub API release fallback available: {Version}", prNumber, githubVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.PrMergedReleaseNotificationFormat, githubVersion, prNumber), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(null, null, githubVersion), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + } + + private async Task CheckSubscribedBranchUpdateAsync(string branch, UserSettings settings, CancellationToken cancellationToken) + { + if (gitHubTokenStorage != null && !gitHubTokenStorage.HasToken()) + { + if (string.Equals(branch, AppUpdateConstants.MainBranch, StringComparison.OrdinalIgnoreCase)) + { + logger?.LogDebug("No GitHub token configured for main branch; checking standard releases instead"); + await CheckStandardReleaseUpdateAsync(settings, cancellationToken); + return; + } + + logger?.LogDebug("No GitHub token configured; skipping background branch artifact check for '{Branch}'", branch); + return; + } + + 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 = $"{AppUpdateConstants.BranchDedupePrefix}{branch}:{artifactVersionBase}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, 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; + } + + if (!string.Equals(branch, AppUpdateConstants.DevelopmentBranch, StringComparison.OrdinalIgnoreCase) && + !string.Equals(branch, AppUpdateConstants.MainBranch, StringComparison.OrdinalIgnoreCase)) + { + await CheckStaleBranchFallbackUpdateAsync(branch, settings, cancellationToken); + } + } + + private async Task CheckStaleBranchFallbackUpdateAsync(string branch, UserSettings settings, CancellationToken cancellationToken) + { + logger?.LogInformation("Subscribed branch '{Branch}' has no artifacts. Checking development/release fallback", branch); + var currentVersionBase = UpdateNotificationViewModel.CurrentAppVersion.Split('+')[0]; + + if (await TryNotifyBranchStaleDevFallbackAsync(branch, settings, currentVersionBase, cancellationToken)) + { + return; + } + + if (await TryNotifyBranchStaleReleaseFallbackAsync(branch, settings, cancellationToken)) + { + return; + } + + TryNotifyBranchStaleGitHubFallback(branch, settings); + } + + private async Task TryNotifyBranchStaleDevFallbackAsync( + string branch, + UserSettings settings, + string currentVersionBase, + CancellationToken cancellationToken) + { + velopackUpdateManager.SubscribedBranch = AppUpdateConstants.DevelopmentBranch; + var devArtifact = await velopackUpdateManager.CheckForArtifactUpdatesAsync(cancellationToken); + if (devArtifact == null) + { + return false; + } + + var devVersionBase = devArtifact.Version.Split('+')[0]; + if (!AppUpdateVersionHelper.IsArtifactVersionNewer(devVersionBase, currentVersionBase, allowCrossChannel: true) || + string.Equals(devVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var updateIdentity = $"{AppUpdateConstants.BranchFallbackDedupePrefix}{branch}:dev:{devVersionBase}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, updateIdentity); + return true; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("Branch '{Branch}' stale. Development fallback update available: {Version}", branch, devArtifact.DisplayVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.BranchStaleUpdateNotificationFormat, devArtifact.DisplayVersion, branch), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateWithSubscriptionClearAsync(devArtifact, null, null, null, branch), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + return true; + } + + private async Task TryNotifyBranchStaleReleaseFallbackAsync( + string branch, + UserSettings settings, + CancellationToken cancellationToken) + { + velopackUpdateManager.SubscribedBranch = null; + var releaseUpdate = await velopackUpdateManager.CheckForUpdatesAsync(cancellationToken); + if (releaseUpdate == null) + { + return false; + } + + var version = releaseUpdate.TargetFullRelease.Version.ToString(); + if (string.Equals(version, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var updateIdentity = $"{AppUpdateConstants.BranchFallbackDedupePrefix}{branch}:release:{version}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, updateIdentity); + return true; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("Branch '{Branch}' stale. Release fallback update available: {Version}", branch, version); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.BranchStaleReleaseNotificationFormat, version, branch), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateWithSubscriptionClearAsync(null, releaseUpdate, null, null, branch), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + return true; + } + + private void TryNotifyBranchStaleGitHubFallback(string branch, UserSettings settings) + { + if (!velopackUpdateManager.HasUpdateAvailableFromGitHub) + { + return; + } + + var githubVersion = velopackUpdateManager.LatestVersionFromGitHub; + if (string.IsNullOrWhiteSpace(githubVersion) || + string.Equals(githubVersion, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var updateIdentity = $"{AppUpdateConstants.BranchFallbackDedupePrefix}{branch}:github:{githubVersion}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("Branch '{Branch}' stale. GitHub API release fallback available: {Version}", branch, githubVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.BranchStaleReleaseNotificationFormat, githubVersion, branch), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(null, null, githubVersion), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + } + + private async Task CheckStandardReleaseUpdateAsync(UserSettings settings, CancellationToken cancellationToken) + { + velopackUpdateManager.SubscribedPrNumber = null; + velopackUpdateManager.SubscribedBranch = null; + + var updateInfo = await velopackUpdateManager.CheckForUpdatesAsync(cancellationToken); + if (updateInfo != null) + { + var version = updateInfo.TargetFullRelease.Version.ToString(); + if (!string.Equals(version, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + var updateIdentity = $"{AppUpdateConstants.ReleaseDedupePrefix}{version}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("GitHub release update available: {Version}", version); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.UpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.ReleaseUpdateNotificationFormat, version), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(null, updateInfo, null), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + } + + return; + } + + if (velopackUpdateManager.HasUpdateAvailableFromGitHub) + { + var githubVersion = velopackUpdateManager.LatestVersionFromGitHub; + if (!string.IsNullOrWhiteSpace(githubVersion) && + !string.Equals(githubVersion, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + var updateIdentity = $"{AppUpdateConstants.GitHubFallbackDedupePrefix}{githubVersion}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug(AppUpdateConstants.NotificationAlreadyShownLogFormat, updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("GitHub API release update available: {Version}", githubVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.UpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.ReleaseUpdateNotificationFormat, githubVersion), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(null, null, githubVersion), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + } + } + } + + private async Task PerformOneClickUpdateWithSubscriptionClearAsync( + ArtifactUpdateInfo? artifactUpdate, + UpdateInfo? updateInfo, + string? githubVersion, + int? clearedPrNumber, + string? clearedBranch) + { + await PerformOneClickUpdateAsync( + artifactUpdate, + updateInfo, + githubVersion, + clearedPrNumber, + clearedBranch); + } + + private async Task PerformOneClickUpdateAsync( + ArtifactUpdateInfo? artifactUpdate, + UpdateInfo? updateInfo, + string? githubVersion, + int? clearedPrNumber = null, + string? clearedBranch = null) + { + var progressNotificationId = Guid.NewGuid(); + + try + { + // 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); + }); + + if (artifactUpdate != null) + { + logger?.LogInformation("Starting one-click artifact install: {Version}", artifactUpdate.DisplayVersion); + await velopackUpdateManager.InstallArtifactAsync(artifactUpdate, progress, _cts.Token); + await ClearStaleSubscriptionAsync(clearedPrNumber, clearedBranch); + 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, _cts.Token); + await ClearStaleSubscriptionAsync(clearedPrNumber, clearedBranch); + 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 async Task ClearStaleSubscriptionAsync(int? clearedPrNumber, string? clearedBranch) + { + if (!clearedPrNumber.HasValue && string.IsNullOrEmpty(clearedBranch)) + { + return; + } + + try + { + userSettingsService.Update(settings => + { + if (clearedPrNumber.HasValue && settings.SubscribedPrNumber == clearedPrNumber.Value) + { + settings.SubscribedPrNumber = null; + } + + if (!string.IsNullOrEmpty(clearedBranch) && + string.Equals(settings.SubscribedBranch, clearedBranch, StringComparison.OrdinalIgnoreCase)) + { + settings.SubscribedBranch = null; + } + }); + await userSettingsService.SaveAsync(_cts.Token); + logger?.LogInformation( + "Cleared stale subscription (PR: {PrNumber}, Branch: {Branch}) after applying fallback update", + clearedPrNumber, + clearedBranch); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to clear stale subscription settings after fallback update"); + } + } + + private void OpenUpdateSettings() + { + WeakReferenceMessenger.Default.Send(new NavigationMessage(NavigationTab.Settings)); + Dispatcher.UIThread.Post(() => + { + try + { + var updateWindow = new UpdateNotificationWindow(); + updateWindow.Show(); + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to open update window"); + } + }); + } + + private async Task CheckForUpdatesOnStartupAsync(CancellationToken cancellationToken) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, cancellationToken); + await CheckForUpdatesInBackgroundAsync(linkedCts.Token); + } + + private async Task CheckForUpdatesInBackgroundAsync(CancellationToken ct) + { + try + { + await CheckForUpdatesAsync(ct); + } + catch (OperationCanceledException) + { + // Expected on cancellation + } + catch (Exception ex) + { + logger?.LogError(ex, "Unhandled exception in background update check"); + } + } + + 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 (_disposed || _cts.IsCancellationRequested) + { + return; + } + + logger?.LogDebug("Periodic update check timer triggered"); + _ = CheckForUpdatesInBackgroundAsync(_cts.Token); + } +} diff --git a/GenHub/GenHub/Features/AppUpdate/Services/FastHttpClientFileDownloader.cs b/GenHub/GenHub/Features/AppUpdate/Services/FastHttpClientFileDownloader.cs new file mode 100644 index 000000000..211accb18 --- /dev/null +++ b/GenHub/GenHub/Features/AppUpdate/Services/FastHttpClientFileDownloader.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +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 Microsoft.Extensions.Logging; +using Velopack.Sources; + +namespace GenHub.Features.AppUpdate.Services; + +/// +/// High-performance file downloader for Velopack and application updates. +/// Supports parallel range chunk downloading for large assets from GitHub Releases and CDN origins. +/// +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..ccbfb52d2 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,12 +912,15 @@ public void ClearCache() _cachedUpdateInfo = null; _lastArtifactCheckTime = DateTime.MinValue; _cachedArtifactUpdateInfo = null; + _cachedArtifactSubscribedPrNumber = null; + _cachedArtifactSubscribedBranch = null; _lastPrListCheckTime = DateTime.MinValue; _cachedPrList = null; _lastBranchListCheckTime = DateTime.MinValue; _cachedBranchList = null; _hasUpdateFromGitHub = false; _latestVersionFromGitHub = null; + IsPrMergedOrClosed = false; _logger.LogInformation("Update manager cache cleared"); } @@ -1050,80 +1106,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. /// @@ -1419,7 +1401,6 @@ private HttpClient CreateConfiguredHttpClientWithToken(SecureString token) catch (FormatException ex) { _logger.LogWarning(ex, "Failed to parse created_at date from workflow run"); - createdAt = DateTime.MinValue; } var headSha = run.GetProperty("head_sha").GetString() ?? string.Empty; @@ -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; } @@ -1574,7 +1557,7 @@ private HttpClient CreateConfiguredHttpClientWithToken(SecureString token) } catch (FormatException) { - createdAt = DateTime.MinValue; + // Fallback to DateTime.MinValue } _logger.LogDebug("Checking run {RunId} on branch {Branch} ({Hash}) for artifacts...", runId, actualBranch, shortHash); @@ -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..a9b9a8c32 100644 --- a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs +++ b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -10,6 +11,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 +27,59 @@ 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; + private bool _disposed; + /// /// Gets or sets the status message. /// @@ -65,6 +92,10 @@ public static string CurrentAppVersion [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCheckButtonEnabled))] [NotifyPropertyChangedFor(nameof(DisplayLatestVersion))] + [NotifyPropertyChangedFor(nameof(CanDownloadUpdate))] + [NotifyPropertyChangedFor(nameof(InstallButtonText))] + [NotifyPropertyChangedFor(nameof(IsLoadingOrInstalling))] + [NotifyCanExecuteChangedFor(nameof(InstallUpdateCommand))] private bool _isChecking; /// @@ -85,6 +116,7 @@ public static string CurrentAppVersion [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(InstallUpdateCommand))] [NotifyPropertyChangedFor(nameof(DisplayLatestVersion))] + [NotifyPropertyChangedFor(nameof(CanDownloadUpdate))] private bool _isUpdateAvailable; /// @@ -105,6 +137,8 @@ public static string CurrentAppVersion [ObservableProperty] [NotifyPropertyChangedFor(nameof(InstallButtonText))] + [NotifyPropertyChangedFor(nameof(CanDownloadUpdate))] + [NotifyPropertyChangedFor(nameof(IsLoadingOrInstalling))] [NotifyCanExecuteChangedFor(nameof(InstallUpdateCommand))] private bool _isInstalling; @@ -126,6 +160,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. /// @@ -185,6 +252,10 @@ public static string CurrentAppVersion /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(VersionPlaceholderText))] + [NotifyPropertyChangedFor(nameof(CanDownloadUpdate))] + [NotifyPropertyChangedFor(nameof(InstallButtonText))] + [NotifyPropertyChangedFor(nameof(IsLoadingOrInstalling))] + [NotifyCanExecuteChangedFor(nameof(InstallUpdateCommand))] private bool _isLoadingVersions; /// @@ -239,14 +310,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 +346,42 @@ 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 + await CancelPreviousArtifactLoadAsync(); + + if (_disposed || _cancellationTokenSource.IsCancellationRequested) + { + return; + } + + 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(); @@ -298,45 +389,87 @@ private async Task LoadArtifactsForSubscribedItemAsync() try { - IReadOnlyList artifacts = []; - - if (SubscribedPr != null) + var artifacts = await FetchSubscribedArtifactsAsync(targetPrNumber, targetBranch, token); + if (token.IsCancellationRequested) { - artifacts = await _velopackUpdateManager.GetArtifactsForPullRequestAsync(SubscribedPr.Number, _cancellationTokenSource.Token); + return; } - else if (!string.IsNullOrEmpty(SubscribedBranch)) + + PopulateAvailableVersions(artifacts); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Artifact loading cancelled for subscription change"); + } + catch (Exception ex) + { + if (!token.IsCancellationRequested) { - artifacts = await _velopackUpdateManager.GetArtifactsForBranchAsync(SubscribedBranch, _cancellationTokenSource.Token); + _logger.LogError(ex, "Failed to load available versions"); } - - _logger.LogInformation("Received {Count} platform-compatible artifacts from update manager", artifacts.Count); - - // Use HashSet to prevent duplicates based on artifact ID - var addedArtifactIds = new HashSet(); - foreach (var artifact in artifacts) + } + finally + { + if (ReferenceEquals(_loadArtifactsCts, cts)) { - if (addedArtifactIds.Add(artifact.ArtifactId)) - { - AvailableVersions.Add(artifact); - _logger.LogDebug("Added artifact: {Version} ({Hash}) - ID: {Id}", artifact.DisplayVersion, artifact.GitHash, artifact.ArtifactId); - } - else - { - _logger.LogWarning("Duplicate artifact detected in ViewModel: {Version} ({Hash}) - ID: {Id}", artifact.DisplayVersion, artifact.GitHash, artifact.ArtifactId); - } + IsLoadingVersions = false; } + } + } - _logger.LogInformation("Loaded {Count} artifacts into AvailableVersions", AvailableVersions.Count); + private async Task CancelPreviousArtifactLoadAsync() + { + var oldCts = Interlocked.Exchange(ref _loadArtifactsCts, null); + if (oldCts != null) + { + await oldCts.CancelAsync(); + oldCts.Dispose(); + } + } - // Don't auto-select to avoid duplicate display in ComboBox + private async Task> FetchSubscribedArtifactsAsync( + int? targetPrNumber, + string? targetBranch, + CancellationToken token) + { + if (targetPrNumber.HasValue) + { + _logger.LogInformation("Loading artifacts for PR #{PrNumber}", targetPrNumber.Value); + return await _velopackUpdateManager.GetArtifactsForPullRequestAsync(targetPrNumber.Value, token); } - catch (Exception ex) + + if (!string.IsNullOrEmpty(targetBranch)) { - _logger.LogError(ex, "Failed to load available versions"); + _logger.LogInformation("Loading artifacts for branch '{Branch}'", targetBranch); + return await _velopackUpdateManager.GetArtifactsForBranchAsync(targetBranch, token); } - finally + + return []; + } + + private void PopulateAvailableVersions(IReadOnlyList artifacts) + { + _logger.LogInformation("Received {Count} platform-compatible artifacts from update manager", artifacts.Count); + + var addedArtifactIds = new HashSet(); + foreach (var artifact in artifacts) { - IsLoadingVersions = false; + if (addedArtifactIds.Add(artifact.ArtifactId)) + { + AvailableVersions.Add(artifact); + _logger.LogDebug("Added artifact: {Version} ({Hash}) - ID: {Id}", artifact.DisplayVersion, artifact.GitHash, artifact.ArtifactId); + } + else + { + _logger.LogWarning("Duplicate artifact detected in ViewModel: {Version} ({Hash}) - ID: {Id}", artifact.DisplayVersion, artifact.GitHash, artifact.ArtifactId); + } + } + + _logger.LogInformation("Loaded {Count} artifacts into AvailableVersions", AvailableVersions.Count); + + if (AvailableVersions.Count > 0) + { + SelectedVersion = AvailableVersions[0]; } } @@ -345,12 +478,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 +501,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(); } @@ -390,17 +532,41 @@ await Task.WhenAll( /// /// Gets a value indicating whether an update is available and can be downloaded. /// - public bool CanDownloadUpdate => (IsUpdateAvailable || SelectedVersion != null) && !IsInstalling; + [SuppressMessage("Major Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "ViewModel property bound to UI elements")] + public bool CanDownloadUpdate => (IsUpdateAvailable || SelectedVersion != null) && !IsInstalling && !IsChecking && !IsLoadingVersions; /// /// Gets a value indicating whether the check button should be enabled. /// public bool IsCheckButtonEnabled => !IsChecking; + /// + /// Gets a value indicating whether an operation is currently loading versions, checking updates, or installing. + /// + [SuppressMessage("Major Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "ViewModel property bound to UI elements")] + public bool IsLoadingOrInstalling => IsLoadingVersions || IsChecking || IsInstalling; + /// /// Gets the text for the install button. /// - public string InstallButtonText => IsInstalling ? "Installing..." : "Install Update"; + [SuppressMessage("Major Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "ViewModel property bound to UI elements")] + public string InstallButtonText + { + get + { + if (IsInstalling) + { + return AppUpdateConstants.InstallingMessage; + } + + if (IsChecking || IsLoadingVersions) + { + return AppUpdateConstants.LoadingMessage; + } + + return AppUpdateConstants.InstallUpdateAction; + } + } /// /// Gets the latest version string, ensuring it has a 'v' prefix for display. @@ -419,14 +585,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 +611,141 @@ public string DisplayLatestVersion /// public void Dispose() { + if (_disposed) + { + return; + } + + _disposed = true; + _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)) { - return runNumber; + 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; } - // Fallback: try to parse the entire version as a number - var parts = version.Split('.', '-', '+'); - foreach (var part in parts.Reverse()) + 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)) { - if (int.TryParse(part, out var number)) + var settings = _userSettingsService.Get(); + if (!string.Equals(branchVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) { - return number; + 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; } - return 0; + IsUpdateAvailable = false; + StatusMessage = $"You are on the latest build for {branch}"; + } + + partial void OnSelectedVersionChanged(ArtifactUpdateInfo? value) + { + UpdateCommandStates(); + + if (value == null) + { + return; + } + + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var selectedVersionBase = value.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(selectedVersionBase, currentVersionBase)) + { + var settings = _userSettingsService.Get(); + if (!string.Equals(selectedVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + 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; + } + + 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}"; + } } /// @@ -493,128 +766,100 @@ private async Task CheckForUpdatesAsync() ErrorMessage = string.Empty; StatusMessage = "Checking for updates..."; IsUpdateAvailable = false; + ShowPrMergedWarning = false; _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) + if (!HasPat) { - 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; - } - + _logger.LogInformation("Subscribed to PR #{PrNumber} but GitHub PAT is not configured", SubscribedPr.Number); + StatusMessage = AppUpdateConstants.PatRequiredForArtifactsMessage; IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; return; } - // Try to fetch artifact for update check + if (SubscribedPr.LatestArtifact != null) + { + ProcessPrArtifactUpdate(SubscribedPr.LatestArtifact, SubscribedPr.Number); + return; + } + + // 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; - } + ProcessPrArtifactUpdate(prArtifact, SubscribedPr.Number); + return; + } + if (_velopackUpdateManager.IsPrMergedOrClosed) + { + ShowPrMergedWarning = true; + StatusMessage = string.Format(AppUpdateConstants.PrMergedStatusMessageFormat, SubscribedPr.Number); IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; + _logger.LogInformation("Subscribed PR #{PrNumber} is merged or closed", 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); - var branchArtifact = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); - - if (branchArtifact != null) + if (string.Equals(SubscribedBranch, AppUpdateConstants.MainBranch, StringComparison.OrdinalIgnoreCase)) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var artifactVersionBase = branchArtifact.Version.Split('+')[0]; - - if (!string.Equals(artifactVersionBase, currentVersionBase, StringComparison.OrdinalIgnoreCase)) + if (HasPat) { - var settings = _userSettingsService.Get(); - if (!string.Equals(artifactVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + _logger.LogInformation("Checking for artifact updates on main branch"); + var mainArtifact = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); + if (mainArtifact != null) { - 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); + ProcessBranchArtifactUpdate(mainArtifact, SubscribedBranch); return; } } - else + + _logger.LogInformation("Subscribed to main branch; proceeding to release check"); + } + else + { + if (!HasPat) { + _logger.LogInformation("Subscribed to branch '{Branch}' but GitHub PAT is not configured", SubscribedBranch); + StatusMessage = AppUpdateConstants.PatRequiredForArtifactsMessage; IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for {SubscribedBranch}"; return; } - } - // If subscribed to branch but no artifact found, don't 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; + _logger.LogInformation("Checking for artifact updates on branch: {Branch}", SubscribedBranch); + var branchArtifact = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); + + if (branchArtifact != null) + { + ProcessBranchArtifactUpdate(branchArtifact, SubscribedBranch); + return; + } + + // 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 = string.Equals(SubscribedBranch, AppUpdateConstants.DevelopmentBranch, StringComparison.OrdinalIgnoreCase) + ? $"Waiting for {SubscribedBranch} build..." + : string.Format(AppUpdateConstants.BranchStaleStatusMessageFormat, SubscribedBranch); + IsUpdateAvailable = false; + return; + } } - // Check main branch releases + // check main branch releases _currentUpdateInfo = await _velopackUpdateManager.CheckForUpdatesAsync(_cancellationTokenSource.Token); if (_currentUpdateInfo != null) @@ -681,18 +926,18 @@ 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)) { _userSettingsService.Update(s => s.DismissedUpdateVersion = string.Empty); - await _userSettingsService.SaveAsync(); + await _userSettingsService.SaveAsync(CancellationToken.None); } - // Clear manager cache + // clear manager cache _velopackUpdateManager.ClearCache(); - // Reload data + // reload data if (HasPat) { await Task.WhenAll( @@ -703,6 +948,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 +1002,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 +1036,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 +1044,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 +1053,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 +1061,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 +1161,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 +1178,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 +1235,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 +1251,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 +1295,7 @@ private async Task InstallArtifactAsync(ArtifactUpdateInfo artifact) await _velopackUpdateManager.InstallArtifactAsync(artifact, progress, _cancellationTokenSource.Token); - // App will restart + // app will restart } catch (Exception ex) { @@ -1021,7 +1324,7 @@ private void DismissUpdate() if (!string.IsNullOrEmpty(LatestVersion)) { _userSettingsService.Update(s => s.DismissedUpdateVersion = LatestVersion); - _ = _userSettingsService.SaveAsync(); + _ = _userSettingsService.SaveAsync(CancellationToken.None); _logger.LogInformation("Dismissed update version {Version}", LatestVersion); } @@ -1036,6 +1339,26 @@ private void DismissUpdate() partial void OnIsCheckingChanged(bool value) { OnPropertyChanged(nameof(IsCheckButtonEnabled)); + if (Dispatcher.UIThread.CheckAccess()) + { + UpdateCommandStates(); + } + else + { + Dispatcher.UIThread.InvokeAsync(UpdateCommandStates); + } + } + + partial void OnIsLoadingVersionsChanged(bool value) + { + if (Dispatcher.UIThread.CheckAccess()) + { + UpdateCommandStates(); + } + else + { + Dispatcher.UIThread.InvokeAsync(UpdateCommandStates); + } } partial void OnIsUpdateAvailableChanged(bool value) @@ -1069,6 +1392,7 @@ private void UpdateCommandStates() OnPropertyChanged(nameof(CanInstallBranchArtifact)); OnPropertyChanged(nameof(DisplayLatestVersion)); OnPropertyChanged(nameof(InstallButtonText)); + OnPropertyChanged(nameof(IsLoadingOrInstalling)); InstallUpdateCommand.NotifyCanExecuteChanged(); InstallPrArtifactCommand.NotifyCanExecuteChanged(); InstallBranchArtifactCommand.NotifyCanExecuteChanged(); @@ -1089,22 +1413,25 @@ 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) { ShowPrMergedWarning = true; - StatusMessage = $"PR #{_velopackUpdateManager.SubscribedPrNumber} has been merged. Select a new PR or switch to MAIN."; + StatusMessage = string.Format(AppUpdateConstants.PrMergedStatusMessageFormat, _velopackUpdateManager.SubscribedPrNumber.Value); _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 +1445,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 +1508,25 @@ await Dispatcher.UIThread.InvokeAsync(() => private void SubscribeToPr(int prNumber) { _velopackUpdateManager.SubscribedPrNumber = prNumber; - SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber); + _velopackUpdateManager.SubscribedBranch = null; SubscribedBranch = null; ShowPrMergedWarning = false; + IsUpdateAvailable = false; + SelectedVersion = null; + LatestVersion = string.Empty; + ReleaseNotesUrl = string.Empty; + _currentUpdateInfo = null; - // Clear artifact cache to force fresh check + SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber) ?? new PullRequestInfo + { + Number = prNumber, + Title = $"PR #{prNumber}", + BranchName = "unknown", + Author = "unknown", + State = "open", + }; + + // clear artifact cache to force fresh check _velopackUpdateManager.ClearCache(); _userSettingsService.Update(settings => @@ -1166,13 +1534,10 @@ private void SubscribeToPr(int prNumber) settings.SubscribedPrNumber = prNumber; settings.SubscribedBranch = null; }); - _ = _userSettingsService.SaveAsync(); + _ = _userSettingsService.SaveAsync(CancellationToken.None); - 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 +1545,19 @@ private void SubscribeToBranch(string branchName) { if (string.IsNullOrEmpty(branchName)) return; - SubscribedBranch = branchName; _velopackUpdateManager.SubscribedPrNumber = null; + _velopackUpdateManager.SubscribedBranch = branchName; SubscribedPr = null; ShowPrMergedWarning = false; + IsUpdateAvailable = false; + SelectedVersion = null; + LatestVersion = string.Empty; + ReleaseNotesUrl = string.Empty; + _currentUpdateInfo = null; + + SubscribedBranch = branchName; - // Clear artifact cache to force fresh check + // clear artifact cache to force fresh check _velopackUpdateManager.ClearCache(); _userSettingsService.Update(settings => @@ -1193,7 +1565,7 @@ private void SubscribeToBranch(string branchName) settings.SubscribedBranch = branchName; settings.SubscribedPrNumber = null; }); - _ = _userSettingsService.SaveAsync(); + _ = _userSettingsService.SaveAsync(CancellationToken.None); StatusMessage = $"Subscribed to branch: {branchName}"; _logger.LogInformation("Subscribed to branch '{Branch}'", branchName); @@ -1201,6 +1573,7 @@ private void SubscribeToBranch(string branchName) partial void OnSubscribedBranchChanged(string? value) { + _velopackUpdateManager.SubscribedBranch = value; _ = LoadArtifactsForSubscribedItemAsync(); OnPropertyChanged(nameof(IsSubscribedToAny)); UpdateCommandStates(); @@ -1220,9 +1593,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 => @@ -1230,9 +1609,10 @@ private void Unsubscribe() settings.SubscribedPrNumber = null; settings.SubscribedBranch = null; }); - _ = _userSettingsService.SaveAsync(); + _ = _userSettingsService.SaveAsync(CancellationToken.None); _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..1d4fc5b13 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml @@ -11,44 +11,133 @@ - - + + + + + + + + + - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml index 245136041..f2c8ee6eb 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml @@ -8,7 +8,7 @@ Title="GenHub Updates" Icon="/Assets/Icons/generalshub-icon.png" WindowStartupLocation="CenterScreen" - SystemDecorations="BorderOnly" + SystemDecorations="Full" TransparencyLevelHint="AcrylicBlur" Background="Transparent" ExtendClientAreaToDecorationsHint="True" @@ -28,15 +28,10 @@ - - - - - - - + @@ -61,34 +56,63 @@ - + + + + + + + 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/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs new file mode 100644 index 000000000..77c7973ed --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -0,0 +1,1549 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Utilities; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; +using SharpCompress.Common; + +namespace GenHub.Features.Content.Services.Common; + +/// +/// Service for safely extracting archives and normalizing payload directory structures for game workspaces. +/// +public class ArchivePayloadProcessor(ILogger logger) : IArchivePayloadProcessor +{ + private const int MaxNestedExtractionDepth = 5; + private static readonly byte[] SevenZipSignature = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]; + private static readonly byte[] RarSignature = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]; + private static readonly byte[] SmartInstallMakerSignature = [0x77, 0x77, 0x67, 0x54, 0x29, 0x48, 0x35, 0x14]; + + /// + public Task ExtractArchivesSafelyAsync( + string extractedDirectory, + ContentType? contentType = null, + CancellationToken cancellationToken = default) + { + if (!Directory.Exists(extractedDirectory)) + { + return Task.CompletedTask; + } + + return Task.Run( + () => + { + var depth = 0; + while (depth < MaxNestedExtractionDepth) + { + cancellationToken.ThrowIfCancellationRequested(); + depth++; + + var archiveFiles = FindArchiveFiles(extractedDirectory, contentType); + if (archiveFiles.Count == 0) + { + break; + } + + logger.LogInformation( + "Found {Count} archive(s) to extract in payload directory: {Directory} (pass {Pass})", + archiveFiles.Count, + extractedDirectory, + depth); + + foreach (var archivePath in archiveFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + + EnsureValidArchivePayload(archivePath); + logger.LogInformation("Extracting archive safely: {ArchivePath}", archivePath); + + ExtractSingleArchive(archivePath, extractedDirectory, cancellationToken); + File.Delete(archivePath); + logger.LogInformation("Extracted archive and removed archive source: {ArchivePath}", archivePath); + } + } + + var remainingArchives = FindArchiveFiles(extractedDirectory, contentType); + if (remainingArchives.Count > 0) + { + throw new InvalidDataException( + $"Payload contains nested archives exceeding maximum extraction depth of {MaxNestedExtractionDepth}: {string.Join(", ", remainingArchives.Select(Path.GetFileName))}"); + } + }, + cancellationToken); + } + + /// + public Task NormalizeDirectoryStructureAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default) + { + if (!Directory.Exists(extractedDirectory)) + { + return Task.CompletedTask; + } + + return Task.Run( + () => + { + cancellationToken.ThrowIfCancellationRequested(); + + // 1. Purge system junk files and folders + PurgeSystemJunk(extractedDirectory); + + // 2. Iteratively strip single wrapper directories + StripSingleWrapperDirectories(extractedDirectory, contentType, cancellationToken); + + // 3. Handle game-specific subdirectories (e.g. ZH, Zero Hour, Generals, CCG) + RouteGameSpecificSubdirectories(extractedDirectory, targetGame, cancellationToken); + + // 4. Heuristic root content detection (single mod directory alongside loose documentation files) + ReconcileContentRootWithDocumentation(extractedDirectory, contentType, cancellationToken); + + // 5. Normalize inactive .gib mod archive files to .big + NormalizeGibExtensions(extractedDirectory, contentType); + + // 6. Cleanup empty directories + CleanupEmptyDirectories(extractedDirectory); + }, + cancellationToken); + } + + /// + public async Task ProcessPayloadAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default) + { + await ExtractArchivesSafelyAsync(extractedDirectory, contentType, cancellationToken); + await NormalizeDirectoryStructureAsync(extractedDirectory, contentType, targetGame, cancellationToken); + } + + private static bool ShouldAttemptExecutableExtraction(ContentType? contentType) + { + if (!contentType.HasValue) + { + return false; + } + + return contentType.Value switch + { + ContentType.ModdingTool => false, + ContentType.Executable => false, + ContentType.GameClient => false, + ContentType.GameInstallation => false, + _ => true, + }; + } + + private static bool IsArchiveFile(string filePath, ContentType? contentType = null) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return false; + } + + try + { + var info = new FileInfo(filePath); + if (info.Length == 0) + { + return false; + } + + var extension = Path.GetExtension(filePath); + + if (extension.Equals(".zip", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".7z", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".rar", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".tar", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".gz", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".tgz", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".bz2", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".xz", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (extension.Equals(".dat", StringComparison.OrdinalIgnoreCase)) + { + return ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath); + } + + if (extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + if (!ShouldAttemptExecutableExtraction(contentType)) + { + return false; + } + + return IsSelfExtractingArchive(filePath); + } + + if (string.IsNullOrEmpty(extension)) + { + return ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath); + } + + return false; + } + catch + { + return false; + } + } + + private static bool IsSelfExtractingArchive(string filePath) + { + try + { + using var zipArchive = ZipFile.OpenRead(filePath); + if (zipArchive.Entries.Count > 0) + { + return true; + } + } + catch + { + // Not a zip sfx + } + + try + { + if (ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath)) + { + return true; + } + } + catch + { + // Ignore + } + + try + { + using var stream = File.OpenRead(filePath); + if (FindSignatureOffset(stream, SevenZipSignature) >= 0) + { + return true; + } + + stream.Position = 0; + if (FindSignatureOffset(stream, RarSignature) >= 0) + { + return true; + } + + stream.Position = 0; + if (FindSignatureOffset(stream, SmartInstallMakerSignature) >= 0) + { + return true; + } + } + catch + { + // Ignore + } + + return false; + } + + private static long FindSignatureOffset(Stream stream, byte[] signature) + { + if (signature.Length == 0) + { + return -1; + } + + // Keep the last partial match across chunk boundaries so a signature + // split between two reads is still detected. + var overlap = signature.Length - 1; + var buffer = new byte[IoConstants.SignatureScanBufferSize]; + long streamOffset = 0; + var buffered = 0; + var read = 0; + + while ((read = stream.Read(buffer.AsSpan(buffered))) > 0) + { + var available = buffered + read; + var index = buffer.AsSpan(0, available).IndexOf(signature); + if (index >= 0) + { + return streamOffset + index; + } + + buffered = Math.Min(available, overlap); + buffer.AsSpan(available - buffered, buffered).CopyTo(buffer); + streamOffset += available - buffered; + } + + return -1; + } + + private static IReadOnlyList FindArchiveFiles(string rootDirectory, ContentType? contentType = null) + { + return Directory.GetFiles(rootDirectory, "*", SearchOption.AllDirectories) + .Where(file => IsArchiveFile(file, contentType)) + .ToList(); + } + + private static void EnsureValidArchivePayload(string archivePath) + { + var info = new FileInfo(archivePath); + if (!info.Exists || info.Length == 0) + { + throw new InvalidDataException($"Archive file is missing or empty: {archivePath}"); + } + + Span header = stackalloc byte[16]; + using (var stream = File.OpenRead(archivePath)) + { + var read = stream.Read(header); + if (read == 0) + { + throw new InvalidDataException($"Archive file is empty: {archivePath}"); + } + + header = header[..read]; + } + + if (LooksLikeHtml(header)) + { + var preview = ReadTextPreview(archivePath, maxChars: 120); + throw new InvalidDataException( + $"Downloaded file is HTML, not an archive (likely a broken download URL or HTTP error page): {archivePath}. Preview: {preview}"); + } + } + + private static bool LooksLikeHtml(ReadOnlySpan header) + { + if (header.Length >= 3 && header[0] == 0xEF && header[1] == 0xBB && header[2] == 0xBF) + { + header = header[3..]; + } + + while (header.Length > 0 && (header[0] == (byte)' ' || header[0] == (byte)'\t' || header[0] == (byte)'\r' || header[0] == (byte)'\n')) + { + header = header[1..]; + } + + if (header.Length < 5) + { + return false; + } + + Span ascii = stackalloc char[Math.Min(header.Length, 9)]; + for (var i = 0; i < ascii.Length; i++) + { + ascii[i] = (char)header[i]; + } + + ReadOnlySpan prefix = ascii; + return prefix.StartsWith(" !e.IsDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(entry.Key)) + { + continue; + } + + entryCount++; + if (entryCount > CatalogConstants.MaxZipEntryCount) + { + throw new InvalidDataException( + $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); + } + + if (Path.IsPathRooted(entry.Key)) + { + throw new InvalidDataException($"Archive entry has an unsafe path: {entry.Key}"); + } + + var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, entry.Key); + if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data)) + { + throw new InvalidDataException($"Archive entry has an unsafe path: {entry.Key}"); + } + + var destinationPath = pathResult.Data; + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + using var entryStream = entry.OpenEntryStream(); + CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); + } + } + + private static void CopyEntryWithCap( + Stream source, + string destinationPath, + ref long totalBytesWritten, + CancellationToken cancellationToken) + { + using var dest = File.Create(destinationPath); + var buffer = new byte[81920]; + var read = 0; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + totalBytesWritten += read; + if (totalBytesWritten > CatalogConstants.MaxZipUncompressedSizeBytes) + { + throw new InvalidDataException( + $"Archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); + } + + dest.Write(buffer, 0, read); + } + } + + private static bool TryExtractZipArchive( + string archivePath, + string extractPath, + CancellationToken cancellationToken) + { + if (!ZipValidation.IsValidZipFile(archivePath)) + { + return false; + } + + try + { + using var zip = ZipFile.OpenRead(archivePath); + if (zip.Entries.Count == 0) + { + return false; + } + + var entryCount = 0; + long totalUncompressedSize = 0; + var extractRoot = Path.GetFullPath(extractPath); + + foreach (var entry in zip.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(entry.FullName) || entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\')) + { + continue; + } + + entryCount++; + if (entryCount > CatalogConstants.MaxZipEntryCount) + { + throw new InvalidDataException( + $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); + } + + ExtractSingleZipEntry(entry, extractRoot, ref totalUncompressedSize, cancellationToken); + } + + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidDataException) + { + throw; + } + catch + { + return false; + } + } + + private static void ExtractSingleZipEntry( + ZipArchiveEntry entry, + string extractRoot, + ref long totalUncompressedSize, + CancellationToken cancellationToken) + { + if (Path.IsPathRooted(entry.FullName)) + { + throw new InvalidDataException($"Archive entry has an unsafe path: {entry.FullName}"); + } + + var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, entry.FullName); + if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data)) + { + throw new InvalidDataException($"Archive entry has an unsafe path: {entry.FullName}"); + } + + var destinationPath = pathResult.Data; + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + using var entryStream = entry.Open(); + CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); + } + + private static bool TryExtractSubStreamArchive( + string archivePath, + string extractPath, + CancellationToken cancellationToken) + { + try + { + using var stream = File.OpenRead(archivePath); + var offset = FindSignatureOffset(stream, SevenZipSignature); + if (offset < 0) + { + stream.Position = 0; + offset = FindSignatureOffset(stream, RarSignature); + } + + if (offset < 0) + { + return false; + } + + stream.Position = offset; + using var subStream = new SubStream(stream, offset, stream.Length - offset); + using var archive = ArchiveFactory.OpenArchive(subStream); + ExtractSharpCompressArchive(archive, extractPath, cancellationToken); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidDataException) + { + throw; + } + catch + { + return false; + } + } + + private static bool TryExtractSmartInstallMakerArchive( + string archivePath, + string extractPath, + CancellationToken cancellationToken) + { + var stagingDir = Path.Combine(extractPath, "_sim_staging_" + Guid.NewGuid().ToString("N")); + try + { + using var stream = File.OpenRead(archivePath); + var sigOffset = FindSignatureOffset(stream, SmartInstallMakerSignature); + if (sigOffset < 0) + { + return false; + } + + stream.Position = sigOffset + SmartInstallMakerSignature.Length; + var (fileTableData, payloadOffset) = ReadSmartInstallMakerMetadata(stream); + if (fileTableData == null || fileTableData.Length == 0 || payloadOffset < 0) + { + return false; + } + + var records = ParseSmartInstallMakerFileTable(fileTableData, stream, payloadOffset); + if (records.Count == 0) + { + return false; + } + + Directory.CreateDirectory(stagingDir); + var stagingRoot = Path.GetFullPath(stagingDir); + var extractedCount = ExtractSmartInstallMakerPayload(stream, payloadOffset, records, stagingRoot, cancellationToken); + if (extractedCount != records.Count) + { + throw new InvalidDataException( + $"Smart Install Maker extraction incomplete: extracted {extractedCount} of {records.Count} entries."); + } + + PromoteDirectoryContents(stagingDir, extractPath); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidDataException) + { + throw; + } + catch + { + return false; + } + finally + { + try + { + if (Directory.Exists(stagingDir)) + { + Directory.Delete(stagingDir, recursive: true); + } + } + catch + { + // Best effort cleanup + } + } + } + + private static (byte[]? TableData, long PayloadOffset) ReadSmartInstallMakerMetadata(Stream stream) + { + var (secondToLastBlock, lastBlock) = WalkSmartInstallMakerBlocks(stream); + if (secondToLastBlock == null || lastBlock == null) + { + return (null, -1); + } + + var payloadOffset = lastBlock.Value.DataStart; + var tableBlock = secondToLastBlock.Value; + if (tableBlock.CompType == 1) + { + var tableData = DecompressSimTableBlock(stream, tableBlock.DataStart); + return (tableData, payloadOffset); + } + + return (null, payloadOffset); + } + + private static ((long Pos, int CompSize, byte CompType, long DataStart)? SecondToLast, (long Pos, int CompSize, byte CompType, long DataStart)? Last) WalkSmartInstallMakerBlocks(Stream stream) + { + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); + (long Pos, int CompSize, byte CompType, long DataStart)? secondToLastBlock = null; + (long Pos, int CompSize, byte CompType, long DataStart)? lastBlock = null; + var blockCount = 0; + const int MaxBlockWalkCount = 100_000; + + while (stream.Position < stream.Length - 13 && blockCount < MaxBlockWalkCount) + { + var pos = stream.Position; + _ = blockCount == 0 ? reader.ReadInt16() : reader.ReadInt32(); + var compSize = reader.ReadInt32(); + _ = reader.ReadInt32(); + var compType = reader.ReadByte(); + var dataLength = compSize - 5; + var dataStart = stream.Position; + + secondToLastBlock = lastBlock; + lastBlock = (pos, compSize, compType, dataStart); + blockCount++; + + if (dataLength > 0 && stream.Position + dataLength <= stream.Length) + { + stream.Position += dataLength; + } + else + { + break; + } + } + + return (secondToLastBlock, lastBlock); + } + + private static byte[] DecompressSimTableBlock(Stream stream, long dataStart) + { + stream.Position = dataStart + 2; // skip zlib 78-DA header + using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); + using var ms = new MemoryStream(); + var buf = new byte[8192]; + var r = 0; + var totalDecompressed = 0L; + while ((r = def.Read(buf, 0, buf.Length)) > 0) + { + totalDecompressed += r; + if (totalDecompressed > CatalogConstants.MaxCatalogSizeBytes) + { + throw new InvalidDataException("Smart Install Maker metadata table exceeds maximum allowed size."); + } + + ms.Write(buf, 0, r); + } + + return ms.ToArray(); + } + + private static int ExtractSmartInstallMakerPayload( + Stream stream, + long payloadOffset, + List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records, + string extractRoot, + CancellationToken cancellationToken) + { + var extractedCount = 0; + var copyBuffer = new byte[65536]; + + foreach (var rec in records) + { + cancellationToken.ThrowIfCancellationRequested(); + ExtractSingleSmartInstallMakerRecord(stream, payloadOffset, rec, extractRoot, copyBuffer); + extractedCount++; + } + + return extractedCount; + } + + private static void ExtractSingleSmartInstallMakerRecord( + Stream stream, + long payloadOffset, + (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) rec, + string extractRoot, + byte[] copyBuffer) + { + var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, rec.Name); + if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data)) + { + throw new InvalidDataException($"Smart Install Maker entry has an unsafe path: {rec.Name}"); + } + + var destinationPath = pathResult.Data; + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + var filePos = payloadOffset + rec.StreamOffset; + if (filePos < 0 || filePos + rec.CompressedSize > stream.Length) + { + throw new InvalidDataException($"Smart Install Maker entry '{rec.Name}' compressed range exceeds stream bounds."); + } + + stream.Position = filePos; + var header = new byte[2]; + var headerRead = stream.Read(header, 0, 2); + stream.Position = filePos; + + var written = TryDecompressSmartInstallMakerRecord(stream, filePos, header, headerRead, destinationPath, rec.UncompressedSize, copyBuffer); + + if (written != rec.UncompressedSize && filePos + rec.UncompressedSize <= stream.Length) + { + // Fallback to raw copy if sniffed decompressor failed but raw payload is available + stream.Position = filePos; + using var outStream = File.Create(destinationPath); + written = 0; + while (written < rec.UncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, rec.UncompressedSize - written); + var readBytes = stream.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + } + + if (written != rec.UncompressedSize) + { + throw new InvalidDataException( + $"Smart Install Maker entry '{rec.Name}' decompressed size mismatch: expected {rec.UncompressedSize} bytes, got {written} bytes."); + } + } + + private static long TryDecompressSmartInstallMakerRecord( + Stream stream, + long filePos, + byte[] header, + int headerRead, + string destinationPath, + uint uncompressedSize, + byte[] copyBuffer) + { + if (headerRead >= 2 && header[0] == 'B' && header[1] == 'Z') + { + return DecompressBz2SmartInstallMakerRecord(stream, destinationPath, uncompressedSize, copyBuffer); + } + + if (headerRead >= 2 && header[0] == 0x78 && (header[1] == 0xDA || header[1] == 0x9C || header[1] == 0x01 || header[1] == 0x5E)) + { + return DecompressDeflateSmartInstallMakerRecord(stream, filePos, destinationPath, uncompressedSize, copyBuffer); + } + + return DecompressRawSmartInstallMakerRecord(stream, destinationPath, uncompressedSize, copyBuffer); + } + + private static long DecompressBz2SmartInstallMakerRecord(Stream stream, string destinationPath, uint uncompressedSize, byte[] copyBuffer) + { + using var bz2 = SharpCompress.Compressors.BZip2.BZip2Stream.Create( + stream, + SharpCompress.Compressors.CompressionMode.Decompress, + decompressConcatenated: false, + leaveOpen: true); + + using var outStream = File.Create(destinationPath); + long written = 0; + while (written < uncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = bz2.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + + return written; + } + + private static long DecompressDeflateSmartInstallMakerRecord(Stream stream, long filePos, string destinationPath, uint uncompressedSize, byte[] copyBuffer) + { + stream.Position = filePos + 2; // skip zlib header + using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); + using var outStream = File.Create(destinationPath); + long written = 0; + while (written < uncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = def.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + + return written; + } + + private static long DecompressRawSmartInstallMakerRecord(Stream stream, string destinationPath, uint uncompressedSize, byte[] copyBuffer) + { + using var outStream = File.Create(destinationPath); + long written = 0; + while (written < uncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = stream.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + + return written; + } + + private static List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> ParseSmartInstallMakerFileTable( + byte[] tableData, + Stream stream, + long payloadOffset) + { + var records = new List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)>(); + var cumulativeUncompressedSize = 0L; + var index = 0; + + while (index < tableData.Length - 4) + { + index = ProcessNextSimCandidate(tableData, index, stream, payloadOffset, records, ref cumulativeUncompressedSize); + } + + return records; + } + + private static int ProcessNextSimCandidate( + byte[] tableData, + int index, + Stream stream, + long payloadOffset, + List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records, + ref long cumulativeUncompressedSize) + { + if (tableData[index] != '.' || index < 40) + { + return index + 1; + } + + if (!TryExtractSimCandidateName(tableData, index, out var name, out var nextIndex, out var startOffset)) + { + return index + 1; + } + + if (IsValidSimEntryName(name) && + TryReadSimRecord(tableData, startOffset, name, stream, payloadOffset, records, out var record)) + { + ValidateAndAddSimRecord(record, records, ref cumulativeUncompressedSize); + } + + return nextIndex; + } + + private static void ValidateAndAddSimRecord( + (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) record, + List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records, + ref long cumulativeUncompressedSize) + { + if (records.Count >= CatalogConstants.MaxZipEntryCount) + { + throw new InvalidDataException( + $"Smart Install Maker archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); + } + + cumulativeUncompressedSize += record.UncompressedSize; + if (cumulativeUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) + { + throw new InvalidDataException( + $"Smart Install Maker archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); + } + + records.Add(record); + } + + private static bool TryExtractSimCandidateName( + byte[] tableData, + int dotIndex, + out string name, + out int nextIndex, + out int startOffset) + { + var start = dotIndex; + while (start > 0 && tableData[start - 1] != 0 && tableData[start - 1] >= 32 && tableData[start - 1] <= 126) + { + start--; + } + + var end = dotIndex; + while (end < tableData.Length && tableData[end] != 0 && tableData[end] >= 32 && tableData[end] <= 126) + { + end++; + } + + startOffset = start; + nextIndex = end; + + if (start < 40 || end - start <= 3) + { + name = string.Empty; + return false; + } + + name = Encoding.Latin1.GetString(tableData, start, end - start); + return true; + } + + private static bool IsValidSimEntryName(string name) + { + if (!name.Contains('.') || name.StartsWith(' ') || name.Length <= 3 || name.Contains("..")) + { + return false; + } + + if (name.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("Intrnl.exe", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var invalidChars = Path.GetInvalidPathChars().Concat([':', '"', '<', '>', '|', '*', '?']).ToArray(); + if (name.IndexOfAny(invalidChars) >= 0) + { + return false; + } + + var ext = Path.GetExtension(name); + return !string.IsNullOrEmpty(ext) && ext.Length <= 5; + } + + private static bool TryReadSimRecord( + byte[] tableData, + int startOffset, + string name, + Stream stream, + long payloadOffset, + List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> existingRecords, + out (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) record) + { + record = default; + var uncompSize = BitConverter.ToUInt32(tableData, startOffset - 40); + var streamOffset = BitConverter.ToUInt32(tableData, startOffset - 36); + var compSize = BitConverter.ToUInt32(tableData, startOffset - 32); + + if (uncompSize == 0 || compSize == 0) + { + return false; + } + + if ((ulong)uncompSize > (ulong)CatalogConstants.MaxZipUncompressedSizeBytes || + payloadOffset + streamOffset + compSize > stream.Length) + { + return false; + } + + if (existingRecords.Exists(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + record = (name, uncompSize, streamOffset, compSize); + return true; + } + + private static void PurgeSystemJunk(string directory) + { + try + { + if (!Directory.Exists(directory)) + { + return; + } + + foreach (var subDir in Directory.GetDirectories(directory, "*", SearchOption.AllDirectories)) + { + if (!Directory.Exists(subDir)) + { + continue; + } + + var dirName = Path.GetFileName(subDir); + if (GameContentConstants.SystemJunkNames.Contains(dirName, StringComparer.OrdinalIgnoreCase)) + { + Directory.Delete(subDir, recursive: true); + } + } + + foreach (var file in Directory.GetFiles(directory, "*", SearchOption.AllDirectories)) + { + if (!File.Exists(file)) + { + continue; + } + + var fileName = Path.GetFileName(file); + if (GameContentConstants.SystemJunkNames.Contains(fileName, StringComparer.OrdinalIgnoreCase)) + { + File.Delete(file); + } + } + } + catch + { + // Ignore system junk removal failures + } + } + + private static bool ContainsRecognizedGameContent(string directory) + { + var subDirs = Directory.GetDirectories(directory, "*", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrEmpty(name)); + + if (subDirs.Any(name => GameContentConstants.RecognizedGameDirectories.Contains(name, StringComparer.OrdinalIgnoreCase))) + { + return true; + } + + var files = Directory.GetFiles(directory, "*", SearchOption.TopDirectoryOnly) + .Select(Path.GetExtension) + .Where(ext => !string.IsNullOrEmpty(ext)); + + return files.Any(ext => GameContentConstants.RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase)); + } + + private static bool DirectoryContainsMapFilesDirectly(string directory) + { + return Directory.GetFiles(directory, "*.map", SearchOption.TopDirectoryOnly).Length > 0; + } + + private static void PromoteDirectoryContents(string sourceDirectory, string targetDirectory) + { + if (!Directory.Exists(sourceDirectory) || + string.Equals(Path.GetFullPath(sourceDirectory), Path.GetFullPath(targetDirectory), StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var tempStaging = Path.Combine(Path.GetDirectoryName(sourceDirectory) ?? targetDirectory, Path.GetFileName(sourceDirectory)) + + "_staging_" + Guid.NewGuid().ToString("N"); + + try + { + Directory.Move(sourceDirectory, tempStaging); + + foreach (var subFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories)) + { + PromoteSingleStagedFile(subFile, tempStaging, targetDirectory); + } + } + catch + { + RollbackStaging(tempStaging, sourceDirectory); + throw; + } + finally + { + CleanupStaging(tempStaging); + } + } + + private static void PromoteSingleStagedFile(string subFile, string tempStaging, string targetDirectory) + { + var relativePath = Path.GetRelativePath(tempStaging, subFile); + var destinationPath = Path.Combine(targetDirectory, relativePath); + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + if (File.Exists(destinationPath)) + { + var destInfo = new FileInfo(destinationPath); + var srcInfo = new FileInfo(subFile); + + if (destInfo.Length == srcInfo.Length && FilesHaveIdenticalContent(subFile, destinationPath)) + { + File.Delete(subFile); + return; + } + + var newDestPath = GetNonCollidingDestinationPath(destinationPath); + File.Move(subFile, newDestPath); + } + else + { + File.Move(subFile, destinationPath); + } + } + + private static void RollbackStaging(string tempStaging, string sourceDirectory) + { + try + { + if (!Directory.Exists(tempStaging)) + { + return; + } + + if (!Directory.Exists(sourceDirectory)) + { + Directory.Move(tempStaging, sourceDirectory); + return; + } + + foreach (var remainingFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(tempStaging, remainingFile); + var backPath = Path.Combine(sourceDirectory, rel); + var dir = Path.GetDirectoryName(backPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.Move(remainingFile, backPath, overwrite: true); + } + } + catch + { + // Best effort rollback + } + } + + private static void CleanupStaging(string tempStaging) + { + if (Directory.Exists(tempStaging)) + { + try + { + Directory.Delete(tempStaging, recursive: true); + } + catch + { + // Best effort cleanup + } + } + } + + private static string GetNonCollidingDestinationPath(string destinationPath) + { + var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty; + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(destinationPath); + var ext = Path.GetExtension(destinationPath); + var counter = 1; + var newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); + while (File.Exists(newDestPath)) + { + counter++; + newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); + } + + return newDestPath; + } + + private static bool FilesHaveIdenticalContent(string file1, string file2) + { + const int bufferSize = 65536; + var buffer1 = new byte[bufferSize]; + var buffer2 = new byte[bufferSize]; + + using var s1 = File.OpenRead(file1); + using var s2 = File.OpenRead(file2); + + if (s1.Length != s2.Length) + { + return false; + } + + while (true) + { + var bytesRead1 = s1.Read(buffer1, 0, bufferSize); + if (bytesRead1 <= 0) + { + break; + } + + var bytesRead2 = s2.Read(buffer2, 0, bufferSize); + if (bytesRead1 != bytesRead2) + { + return false; + } + + if (!buffer1.AsSpan(0, bytesRead1).SequenceEqual(buffer2.AsSpan(0, bytesRead2))) + { + return false; + } + } + + return true; + } + + private static void CleanupEmptyDirectories(string rootDirectory) + { + try + { + foreach (var subDir in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories) + .Where(d => Directory.Exists(d) && !Directory.EnumerateFileSystemEntries(d).Any()) + .OrderByDescending(d => d.Length)) + { + Directory.Delete(subDir); + } + } + catch + { + // Ignore directory cleanup exceptions + } + } + + private void StripSingleWrapperDirectories( + string extractedDirectory, + ContentType contentType, + CancellationToken cancellationToken) + { + var depth = 0; + while (depth < GameContentConstants.MaxWrapperNormalizationDepth) + { + cancellationToken.ThrowIfCancellationRequested(); + depth++; + + var rootFiles = Directory.GetFiles(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + + if (rootFiles.Length != 0 || rootDirs.Length != 1) + { + break; + } + + var singleDir = rootDirs[0]; + var dirName = Path.GetFileName(singleDir); + + // For map content, if the single directory contains .map files directly, preserve this directory + if (contentType is ContentType.Map or ContentType.MapPack && DirectoryContainsMapFilesDirectly(singleDir)) + { + logger.LogInformation("Preserving map folder structure for: {MapDir}", singleDir); + break; + } + + // If the single directory is a canonical game directory (e.g. Data, Art, Window, Maps, Audio), + // it is already at the game root level (e.g. /Data/INI/...) and should NOT be flattened. + if (GameContentConstants.IsRecognizedGameDirectory(dirName)) + { + logger.LogInformation("Preserving canonical game root directory: {SingleDir}", singleDir); + break; + } + + logger.LogInformation("Flattening single wrapper directory: {SingleDir} into {Root}", singleDir, extractedDirectory); + PromoteDirectoryContents(singleDir, extractedDirectory); + } + } + + private void RouteGameSpecificSubdirectories( + string extractedDirectory, + GameType targetGame, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + if (rootDirs.Length == 0) + { + return; + } + + var matchingAliases = targetGame switch + { + GameType.ZeroHour => GameContentConstants.ZeroHourSubfolderAliases, + GameType.Generals => GameContentConstants.GeneralsSubfolderAliases, + _ => null, + }; + + if (matchingAliases == null) + { + return; + } + + foreach (var subDir in rootDirs) + { + var dirName = Path.GetFileName(subDir); + if (matchingAliases.Contains(dirName, StringComparer.OrdinalIgnoreCase)) + { + logger.LogInformation( + "Detected matching game-specific subdirectory '{DirName}' for game {Game}. Promoting contents to root.", + dirName, + targetGame); + + PromoteDirectoryContents(subDir, extractedDirectory); + } + } + } + + private void ReconcileContentRootWithDocumentation( + string extractedDirectory, + ContentType contentType, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (contentType is ContentType.Map or ContentType.MapPack) + { + return; + } + + var rootFiles = Directory.GetFiles(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + + if (rootDirs.Length != 1) + { + return; + } + + var singleDir = rootDirs[0]; + var dirName = Path.GetFileName(singleDir); + + // If the single directory is already a canonical game directory (e.g. Data), it should remain as is + if (GameContentConstants.IsRecognizedGameDirectory(dirName)) + { + return; + } + + // Check if all files at the root level are loose documentation/metadata files + var allRootFilesAreDocs = rootFiles.All(file => + { + var ext = Path.GetExtension(file); + return GameContentConstants.DocumentationExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + }); + + if (!allRootFilesAreDocs) + { + return; + } + + // Check if the single directory contains recognizable game root folders or files + if (ContainsRecognizedGameContent(singleDir)) + { + logger.LogInformation( + "Promoting game content root from wrapper '{SingleDir}' to payload root alongside documentation", + singleDir); + + PromoteDirectoryContents(singleDir, extractedDirectory); + } + } + + private void NormalizeGibExtensions(string extractedDirectory, ContentType contentType) + { + if (contentType is ContentType.ModdingTool or ContentType.Executable or ContentType.GameClient or ContentType.GameInstallation) + { + return; + } + + try + { + foreach (var gibFile in Directory.GetFiles(extractedDirectory, "*.gib", SearchOption.AllDirectories)) + { + var bigFile = Path.ChangeExtension(gibFile, ".big"); + if (File.Exists(bigFile)) + { + if (FilesHaveIdenticalContent(gibFile, bigFile)) + { + File.Delete(gibFile); + logger.LogInformation("Removed duplicate identical inactive file '{GibFile}' as '{BigFile}' already exists", gibFile, bigFile); + } + else + { + var nonCollidingBigPath = GetNonCollidingDestinationPath(bigFile); + File.Move(gibFile, nonCollidingBigPath); + logger.LogInformation("Preserved differing inactive file '{GibFile}' by renaming to '{NewBigFile}'", gibFile, nonCollidingBigPath); + } + } + else + { + File.Move(gibFile, bigFile); + logger.LogInformation("Normalized inactive mod archive '{GibFile}' to '{BigFile}'", gibFile, bigFile); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to normalize .gib file extensions in: {Directory}", extractedDirectory); + } + } + + private sealed class SubStream(Stream baseStream, long streamOffset, long length) : Stream + { + private long _position; + + public override bool CanRead => baseStream.CanRead; + + public override bool CanSeek => baseStream.CanSeek; + + public override bool CanWrite => false; + + public override long Length => length; + + public override long Position + { + get => _position; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, length); + _position = value; + } + } + + public override void Flush() => baseStream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= length) + { + return 0; + } + + var toRead = (int)Math.Min(count, length - _position); + baseStream.Position = streamOffset + _position; + var read = baseStream.Read(buffer, offset, toRead); + _position += read; + return read; + } + + public override long Seek(long offset, SeekOrigin origin) + { + var target = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => length + offset, + _ => throw new ArgumentOutOfRangeException(nameof(origin)), + }; + Position = target; + return _position; + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs new file mode 100644 index 000000000..68fc6559a --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs @@ -0,0 +1,595 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.Common; + +/// +/// Service for detecting, isolating, converting, and packaging Control Bar content into SAGE-compatible .big archives. +/// +public class ControlBarPackageProcessor( + CompressedImageToTgaConverter avifConverter, + ILogger logger) : IControlBarPackageProcessor +{ + private const string ControlBarMetadataBigBase64 = "QklHRngBAAAAAAACAAAAUwAAAFMAAAEkQ29udHJvbEJhclByby50eHQAAAABdwAAAAFHZW5Ub29sXGZ1bGx2aWV3cG9ydC5kYXQAAAAAAAAAAABDb250cm9sIEJhciBQcm8gZm9yIENPTU1BTkQgQU5EIENPTlFVRVIgR0VORVJBTFM6IFpFUk8gSE9VUg0KDQpBVVRIT1I6DQpFQSBHYW1lcywgRkFTLCB4ZXpvbg0KDQpPUklHSU5BTCBET1dOTE9BRCBVUkw6DQpodHRwOi8vZ2VudG9vbC5uZXQvZG93bmxvYWQvY29udHJvbGJhcnBybw0KDQpTT1VSQ0UgQ09ERSAmIEFTU0VUUzoNCmh0dHBzOi8vZ2l0aHViLmNvbS9UaGVTdXBlckhhY2tlcnMvR2VuZXJhbHNDb250cm9sQmFyDQoNCkRPTkFUSU9OIExJTks6DQpodHRwczovL3d3dy5wYXlwYWwubWUvZ2VudG9vbA0KMQ=="; + + private static readonly string[] KnownResolutionVariants = ["720p", "900p", "1080p", "1440p", "4k", "2160p"]; + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1); + private static readonly Regex WordVariantRegex = new(@"\b(720p?|900p?|1080p?|1440p?|2160p?|4k)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexMatchTimeout); + private static readonly Regex InlineVariantRegex = new(@"(720p|900p|1080p|1440p|2160p|4k)", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexMatchTimeout); + + /// + public bool IsControlBarContent(string extractedDirectory, ContentManifest manifest) + { + return HasControlBarManifestMetadata(manifest) || HasControlBarFiles(extractedDirectory); + } + + /// + public async Task> ProcessAndRepackControlBarAsync( + string extractedDirectory, + ContentManifest manifest, + string? requestedVariant = null, + CancellationToken cancellationToken = default) + { + logger.LogInformation( + "Processing Control Bar packaging in {Directory} for manifest {ManifestId}", + extractedDirectory, + manifest.Id); + + var variantId = DetermineVariantId(extractedDirectory, manifest, requestedVariant); + var variantSuffix = GetControlBarVariantSuffix(variantId); + var repackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase); + + var variantBigRoot = FindControlBarVariantBigRoot(extractedDirectory, variantId); + if (!string.IsNullOrEmpty(variantBigRoot)) + { + await ProcessVariantBigRootAsync(variantBigRoot, extractedDirectory, variantId, variantSuffix, repackedOutputs, cancellationToken); + } + else + { + CollectFlatPrebuiltBigs(extractedDirectory, variantSuffix, repackedOutputs); + } + + await EnsureMetadataBigIncludedAsync(extractedDirectory, variantId, repackedOutputs, cancellationToken); + CleanupSourceDirectories(extractedDirectory, repackedOutputs); + + return [.. repackedOutputs]; + } + + /// + public string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId) + { + var rawSuffix = GetControlBarVariantSuffix(variantId); + var candidates = new[] + { + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, "ZH", variantId), + Path.Combine(extractedDirectory, "ZH", rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "ZH", rawSuffix, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, "ZH", rawSuffix), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, "CCG", variantId), + Path.Combine(extractedDirectory, "CCG", rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "CCG", rawSuffix, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, "CCG", rawSuffix), + Path.Combine(extractedDirectory, variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, variantId, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, variantId), + Path.Combine(extractedDirectory, rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, rawSuffix, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, rawSuffix), + }; + + var existingCandidate = candidates.FirstOrDefault(Directory.Exists); + if (existingCandidate != null) + { + return existingCandidate; + } + + if (Directory.Exists(Path.Combine(extractedDirectory, GameContentConstants.WindowDirectoryName)) || + Directory.Exists(Path.Combine(extractedDirectory, "Art")) || + Directory.Exists(Path.Combine(extractedDirectory, "Data")) || + Directory.Exists(Path.Combine(extractedDirectory, GameContentConstants.GenToolDirectoryName))) + { + return extractedDirectory; + } + + return null; + } + + /// + public string GetControlBarVariantSuffix(string variantId) + { + if (variantId.EndsWith("p", StringComparison.OrdinalIgnoreCase)) + { + return variantId[..^1]; + } + + if (variantId.Equals("4k", StringComparison.OrdinalIgnoreCase)) + { + return "4K"; + } + + return variantId; + } + + /// + public bool IsAllowedControlBarBig(string fileName, string variantSuffix) + { + return fileName.Equals($"340_ControlBarProArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarPro{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarPro-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProLemonEditionArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProLemonEditionData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProLemonEdition{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProLemonEdition-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase) + || fileName.Equals("400_ControlBarHDEnglishZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("400_ControlBarProCoreZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("400_ControlBarHDBaseZH.big", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasControlBarManifestMetadata(ContentManifest manifest) + { + if (manifest.ContentType is not (ContentType.Addon or ContentType.Mod)) + { + return false; + } + + var id = manifest.Id.Value; + if (id.Contains("controlbar", StringComparison.OrdinalIgnoreCase) || + id.Contains("cbpr", StringComparison.OrdinalIgnoreCase) || + id.Contains("cbpx", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var name = manifest.Name; + if (name.Contains("controlbar", StringComparison.OrdinalIgnoreCase) || + name.Contains("control bar", StringComparison.OrdinalIgnoreCase) || + name.Contains("control-bar", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return manifest.Metadata?.Tags != null && + manifest.Metadata.Tags.Any(t => + t.Contains("controlbar", StringComparison.OrdinalIgnoreCase) || + t.Contains("control-bar", StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasControlBarFiles(string extractedDirectory) + { + if (!Directory.Exists(extractedDirectory)) + { + return false; + } + + return Directory.EnumerateFiles(extractedDirectory, "*ControlBar*.big", SearchOption.AllDirectories).Any() || + Directory.EnumerateFiles(extractedDirectory, "*ControlBar*.wnd", SearchOption.AllDirectories).Any(); + } + + private async Task ProcessVariantBigRootAsync( + string variantBigRoot, + string extractedDirectory, + string variantId, + string variantSuffix, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + var prebuiltBigs = Directory.GetFiles(variantBigRoot, "*.big", SearchOption.TopDirectoryOnly) + .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix)) + .ToArray(); + + if (prebuiltBigs.Length > 0) + { + await CopyPrebuiltBigsAsync(prebuiltBigs, extractedDirectory, repackedOutputs); + } + else + { + await RepackArtAndDataBigsAsync(variantBigRoot, extractedDirectory, variantId, variantSuffix, repackedOutputs, cancellationToken); + } + } + + private async Task CopyPrebuiltBigsAsync( + IReadOnlyList prebuiltBigs, + string extractedDirectory, + HashSet repackedOutputs) + { + logger.LogInformation("Using prebuilt Control Bar BIG files"); + foreach (var prebuiltBig in prebuiltBigs) + { + var bigName = Path.GetFileName(prebuiltBig); + var targetPath = Path.Combine(extractedDirectory, bigName); + + if (!string.Equals(Path.GetFullPath(prebuiltBig), Path.GetFullPath(targetPath), StringComparison.OrdinalIgnoreCase)) + { + await TryCopyFileWithRetryAsync(prebuiltBig, targetPath, logger); + } + + repackedOutputs.Add(bigName); + } + } + + private async Task RepackArtAndDataBigsAsync( + string variantBigRoot, + string extractedDirectory, + string variantId, + string variantSuffix, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + var artBigName = $"340_ControlBarProArt{variantSuffix}ZH.big"; + var dataBigName = $"340_ControlBarProData{variantSuffix}ZH.big"; + + var artBigPath = Path.Combine(extractedDirectory, artBigName); + var dataBigPath = Path.Combine(extractedDirectory, dataBigName); + + if (!File.Exists(artBigPath) || !File.Exists(dataBigPath)) + { + await BuildAndPackArtAndDataBigsAsync(variantBigRoot, extractedDirectory, variantId, artBigPath, dataBigPath, cancellationToken); + } + + if (File.Exists(artBigPath)) + { + repackedOutputs.Add(artBigName); + } + + if (File.Exists(dataBigPath)) + { + repackedOutputs.Add(dataBigName); + } + } + + private async Task BuildAndPackArtAndDataBigsAsync( + string variantBigRoot, + string extractedDirectory, + string variantId, + string artBigPath, + string dataBigPath, + CancellationToken cancellationToken) + { + logger.LogInformation("Repacking Control Bar variant {Variant} into Art/Data BIG files", variantId); + + var tempRoot = Path.Combine(extractedDirectory, $"cbpro-pack-{variantId}"); + var artPackRoot = Path.Combine(tempRoot, "ArtPack"); + var dataPackRoot = Path.Combine(tempRoot, "DataPack"); + + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + + Directory.CreateDirectory(artPackRoot); + Directory.CreateDirectory(dataPackRoot); + + CopySourceDirectoriesToPacks(variantBigRoot, artPackRoot, dataPackRoot); + + try + { + // Convert AVIF/WebP images to TGA prior to packing + await avifConverter.ConvertDirectoryAsync(artPackRoot, cancellationToken); + await avifConverter.ConvertDirectoryAsync(dataPackRoot, cancellationToken); + + var tempArtBig = Path.Combine(tempRoot, "temp_art.big"); + var tempDataBig = Path.Combine(tempRoot, "temp_data.big"); + + await BigFilePacker.PackAsync(artPackRoot, tempArtBig); + await BigFilePacker.PackAsync(dataPackRoot, tempDataBig); + + File.Move(tempArtBig, artBigPath, overwrite: true); + File.Move(tempDataBig, dataBigPath, overwrite: true); + } + finally + { + try + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to cleanup temporary pack directory {TempRoot}", tempRoot); + } + } + } + + private static void CopySourceDirectoriesToPacks(string variantBigRoot, string artPackRoot, string dataPackRoot) + { + var artSource = Path.Combine(variantBigRoot, "Art"); + var dataSource = Path.Combine(variantBigRoot, "Data"); + var windowSource = Path.Combine(variantBigRoot, GameContentConstants.WindowDirectoryName); + var genToolSource = Path.Combine(variantBigRoot, GameContentConstants.GenToolDirectoryName); + + if (Directory.Exists(artSource)) + { + CopyDirectory(artSource, Path.Combine(artPackRoot, "Art")); + } + + if (Directory.Exists(dataSource)) + { + CopyDirectory(dataSource, Path.Combine(dataPackRoot, "Data")); + } + + if (Directory.Exists(windowSource)) + { + CopyDirectory(windowSource, Path.Combine(dataPackRoot, GameContentConstants.WindowDirectoryName)); + } + + if (Directory.Exists(genToolSource)) + { + CopyDirectory(genToolSource, Path.Combine(dataPackRoot, GameContentConstants.GenToolDirectoryName)); + } + } + + private void CollectFlatPrebuiltBigs( + string extractedDirectory, + string variantSuffix, + HashSet repackedOutputs) + { + logger.LogInformation("Control Bar has flat structure, searching for prebuilt BIG files in root"); + var prebuiltCandidates = Directory.GetFiles(extractedDirectory, "*ControlBarPro*ZH.big", SearchOption.TopDirectoryOnly) + .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix)) + .ToArray(); + + var hasArtDataSplit = prebuiltCandidates.Any(p => + Path.GetFileName(p).Contains("Art", StringComparison.OrdinalIgnoreCase) || + Path.GetFileName(p).Contains("Data", StringComparison.OrdinalIgnoreCase)); + + if (hasArtDataSplit) + { + prebuiltCandidates = [.. prebuiltCandidates.Where(p => + { + var name = Path.GetFileName(p); + return name.Contains("Art", StringComparison.OrdinalIgnoreCase) || + name.Contains("Data", StringComparison.OrdinalIgnoreCase) || + name.Contains("-Fix", StringComparison.OrdinalIgnoreCase) || + name.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) || + name.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase); + })]; + } + + foreach (var candidate in prebuiltCandidates) + { + repackedOutputs.Add(Path.GetFileName(candidate)); + } + } + + private static bool IsMetadataOnlyBig(string fileName) + { + return fileName.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) || + fileName.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase); + } + + private async Task EnsureMetadataBigIncludedAsync( + string extractedDirectory, + string variantId, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + var existingMetadataFileName = repackedOutputs.FirstOrDefault(IsMetadataOnlyBig); + + if (existingMetadataFileName != null) + { + logger.LogInformation("Using existing Control Bar metadata file {FileName}", existingMetadataFileName); + return; + } + + var metadataFileName = GameContentConstants.ControlBarProBaseFileName; + var metadataTargetPath = Path.Combine(extractedDirectory, metadataFileName); + + if (!File.Exists(metadataTargetPath)) + { + await TryLocateAndCopyMetadataBigAsync(extractedDirectory, variantId, metadataFileName, metadataTargetPath); + } + + if (File.Exists(metadataTargetPath)) + { + repackedOutputs.Add(metadataFileName); + logger.LogInformation("Including Control Bar metadata file {FileName} in outputs", metadataFileName); + return; + } + + await WriteFallbackMetadataBigAsync(metadataTargetPath, metadataFileName, repackedOutputs, cancellationToken); + } + + private async Task TryLocateAndCopyMetadataBigAsync( + string extractedDirectory, + string variantId, + string metadataFileName, + string metadataTargetPath) + { + var metadataSearchPaths = new[] + { + Path.Combine(extractedDirectory, "ZH", metadataFileName), + Path.Combine(extractedDirectory, "CCG", metadataFileName), + Path.Combine(extractedDirectory, "ZH", variantId, metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, metadataFileName), + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigEnDirectoryName, metadataFileName), + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigDirectoryName, metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigEnDirectoryName, metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigDirectoryName, metadataFileName), + }; + + var foundSearchPath = metadataSearchPaths.FirstOrDefault(File.Exists); + if (foundSearchPath != null) + { + logger.LogInformation("Found Control Bar metadata file at {SourcePath}, copying to root", foundSearchPath); + await TryCopyFileWithRetryAsync(foundSearchPath, metadataTargetPath, logger); + } + } + + private async Task WriteFallbackMetadataBigAsync( + string metadataTargetPath, + string metadataFileName, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + logger.LogWarning("Control Bar metadata file not found, writing embedded fallback"); + try + { + var metadataBytes = Convert.FromBase64String(ControlBarMetadataBigBase64); + await File.WriteAllBytesAsync(metadataTargetPath, metadataBytes, cancellationToken); + repackedOutputs.Add(metadataFileName); + logger.LogInformation("Created Control Bar metadata file {FileName} from fallback", metadataFileName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create fallback Control Bar metadata file"); + } + } + + private static string DetermineVariantId(string extractedDirectory, ContentManifest manifest, string? requestedVariant) + { + if (!string.IsNullOrWhiteSpace(requestedVariant)) + { + return requestedVariant; + } + + var match = ExtractVariantToken(manifest.Id.Value) ?? ExtractVariantToken(manifest.Name); + if (!string.IsNullOrEmpty(match)) + { + return match; + } + + if (manifest.Metadata?.Tags != null) + { + var tagMatch = manifest.Metadata.Tags + .Select(ExtractVariantToken) + .FirstOrDefault(t => !string.IsNullOrEmpty(t)); + + if (!string.IsNullOrEmpty(tagMatch)) + { + return tagMatch; + } + } + + var existingResolution = KnownResolutionVariants.FirstOrDefault(candidate => + Directory.Exists(Path.Combine(extractedDirectory, "ZH", candidate)) || + Directory.Exists(Path.Combine(extractedDirectory, candidate))); + + return existingResolution ?? GameContentConstants.DefaultControlBarVariant; + } + + private static string? ExtractVariantToken(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + var match = WordVariantRegex.Match(input); + if (match.Success) + { + var token = match.Value.ToLowerInvariant(); + return token switch + { + "720" => "720p", + "900" => "900p", + "1080" => "1080p", + "1440" => "1440p", + "2160" => "4k", + _ => token, + }; + } + + var inlineMatch = InlineVariantRegex.Match(input); + return inlineMatch.Success ? inlineMatch.Value.ToLowerInvariant() : null; + } + + private static void CopyDirectory(string sourceDir, string targetDir) + { + Directory.CreateDirectory(targetDir); + + foreach (var file in Directory.GetFiles(sourceDir)) + { + File.Copy(file, Path.Combine(targetDir, Path.GetFileName(file)), overwrite: true); + } + + foreach (var dir in Directory.GetDirectories(sourceDir)) + { + CopyDirectory(dir, Path.Combine(targetDir, Path.GetFileName(dir))); + } + } + + private static async Task TryCopyFileWithRetryAsync(string source, string destination, ILogger logger, int maxRetries = 3, int delayMs = 100) + { + for (var attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + File.Copy(source, destination, overwrite: true); + return; + } + catch (IOException ex) when (attempt < maxRetries) + { + logger.LogWarning( + ex, + "File copy attempt {Attempt}/{MaxRetries} failed for {Source}: {Message}. Retrying...", + attempt, + maxRetries, + Path.GetFileName(source), + ex.Message); + await Task.Delay(delayMs); + } + } + } + + private void CleanupSourceDirectories(string extractedDirectory, HashSet repackedOutputs) + { + // Destructive cleanup must never run when only the fallback metadata BIG was + // produced; otherwise source content that failed to package would be deleted. + var hasPackagedContent = repackedOutputs.Any(name => !IsMetadataOnlyBig(name)); + + if (!hasPackagedContent) + { + logger.LogWarning( + "Skipping Control Bar source cleanup because no content BIG files were produced for {Directory}", + extractedDirectory); + return; + } + + try + { + var targetSourceDirNames = new[] { "ZH", "CCG", "Art", "Data", GameContentConstants.WindowDirectoryName, GameContentConstants.GenToolDirectoryName, "720p", "900p", "1080p", "1440p", "2160p", "4k" }; + foreach (var dirName in targetSourceDirNames) + { + var dirPath = Path.Combine(extractedDirectory, dirName); + if (Directory.Exists(dirPath)) + { + Directory.Delete(dirPath, recursive: true); + } + } + + var looseFiles = Directory.GetFiles(extractedDirectory, "*.*", SearchOption.TopDirectoryOnly); + foreach (var file in looseFiles) + { + var fileName = Path.GetFileName(file); + if (!repackedOutputs.Contains(fileName) && !fileName.EndsWith(".big", StringComparison.OrdinalIgnoreCase)) + { + File.Delete(file); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to clean up control bar source directories in {Directory}", extractedDirectory); + } + } +} diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index be8788503..8f71bb5ac 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); @@ -169,6 +201,78 @@ private static async Task> CreateGenericManifestAsync( return await Task.FromResult(new List { manifest }); } + /// + /// Resolves the destination BIG filename for a given variant directory based on metadata variant definitions. + /// + private static string? ResolveVariantOutputFileName(string directoryPath, GenPatcherContentMetadata metadata) + { + if (metadata.Variants == null || metadata.Variants.Count == 0) + { + return null; + } + + var segments = directoryPath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); + var isZH = segments.Any(segment => segment.Equals("ZH", StringComparison.OrdinalIgnoreCase)); + var isCCG = segments.Any(segment => segment.Equals("CCG", StringComparison.OrdinalIgnoreCase)); + var dirName = Path.GetFileName(directoryPath); + + GameType? targetGame = null; + if (isZH) + { + targetGame = GameType.ZeroHour; + } + else if (isCCG) + { + targetGame = GameType.Generals; + } + + var matchedVariant = metadata.Variants.FirstOrDefault(variant => + { + if (variant.TargetGame.HasValue && variant.TargetGame != targetGame) + { + return false; + } + + if (!string.IsNullOrEmpty(variant.Value) && + (dirName.EndsWith(variant.Value, StringComparison.OrdinalIgnoreCase) || + dirName.Equals(variant.Value, StringComparison.OrdinalIgnoreCase) || + dirName.Contains($" {variant.Value}", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return false; + }); + + return matchedVariant?.OutputFilename; + } + + /// + /// Resolves the preferred packing source directory within an extracted directory. + /// + private static string ResolvePackSourceDirectory(string extractPath) + { + var bigDirectories = Directory.GetDirectories(extractPath, "BIG*", SearchOption.AllDirectories); + if (bigDirectories.Length == 0) + { + return extractPath; + } + + static bool IsUnder(string path, string folder) => + path.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment.Equals(folder, StringComparison.OrdinalIgnoreCase)); + + static bool EndsWithSegment(string path, string segment) => + path.EndsWith(segment, StringComparison.OrdinalIgnoreCase); + + return bigDirectories + .FirstOrDefault(d => IsUnder(d, "ZH") && EndsWithSegment(d, "BIG EN")) + ?? bigDirectories.FirstOrDefault(d => IsUnder(d, "ZH") && EndsWithSegment(d, "BIG")) + ?? bigDirectories.FirstOrDefault(d => IsUnder(d, "CCG") && EndsWithSegment(d, "BIG EN")) + ?? bigDirectories.FirstOrDefault(d => IsUnder(d, "CCG") && EndsWithSegment(d, "BIG")) + ?? bigDirectories[0]; + } + /// public string SourceName => CommunityOutpostConstants.PublisherId; @@ -203,6 +307,10 @@ public async Task> DeliverContentAsync( IProgress? progress = null, CancellationToken cancellationToken = default) { + var archivePath = string.Empty; + var extractPath = string.Empty; + var registeredManifestIds = new List(); + try { logger.LogInformation( @@ -228,7 +336,7 @@ public async Task> DeliverContentAsync( archiveFile.DownloadUrl!.EndsWith(".7z", StringComparison.OrdinalIgnoreCase); var archiveExtension = isSevenZip ? ".7z" : ".zip"; - var archivePath = Path.Combine(targetDirectory, $"content{archiveExtension}"); + archivePath = Path.Combine(targetDirectory, $"content{archiveExtension}"); progress?.Report(new ContentAcquisitionProgress { @@ -251,7 +359,7 @@ public async Task> DeliverContentAsync( } // Step 2: Extract archive - var extractPath = Path.Combine(targetDirectory, "extracted"); + extractPath = Path.Combine(targetDirectory, "extracted"); Directory.CreateDirectory(extractPath); progress?.Report(new ContentAcquisitionProgress @@ -269,6 +377,11 @@ public async Task> DeliverContentAsync( { await ExtractArchiveAsync(archivePath, extractPath, cancellationToken); } + catch (OperationCanceledException) + { + // Downloaded archive is intentionally preserved on cancellation to allow resume. + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to extract archive from {Path}", archivePath); @@ -349,24 +462,31 @@ await ProcessAndMergeDependencyBigFilesAsync( if (!addResult.Success) { - logger.LogWarning( + logger.LogError( "Failed to register manifest {ManifestId}: {Error}", manifest.Id, addResult.FirstError); + + var rollbackErrors = await RollbackManifestsAsync(registeredManifestIds); + await CleanupTemporaryFilesAsync(archivePath, extractPath); + var failureMessage = rollbackErrors.Count > 0 + ? $"Failed to register manifest {manifest.Id}: {addResult.FirstError} (Rollback errors: {string.Join("; ", rollbackErrors)})" + : $"Failed to register manifest {manifest.Id}: {addResult.FirstError}"; + return OperationResult.CreateFailure(failureMessage); } - else - { - // After successful storage, update SourceType to ContentAddressable - // since the files are now in CAS - foreach (var file in manifest.Files) - { - file.SourceType = ContentSourceType.ContentAddressable; - } - logger.LogInformation( - "Successfully registered manifest: {ManifestId}", - manifest.Id); + registeredManifestIds.Add(manifest.Id); + + // After successful storage, update SourceType to ContentAddressable + // since the files are now in CAS + foreach (var file in manifest.Files) + { + file.SourceType = ContentSourceType.ContentAddressable; } + + logger.LogInformation( + "Successfully registered manifest: {ManifestId}", + manifest.Id); } // Step 5: Cleanup temporary files @@ -386,10 +506,30 @@ await ProcessAndMergeDependencyBigFilesAsync( return OperationResult.CreateSuccess(primaryManifest); } + catch (OperationCanceledException) + { + if (registeredManifestIds.Count > 0) + { + await RollbackManifestsAsync(registeredManifestIds); + } + + // Downloaded archive is intentionally preserved on cancellation to allow resume. + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to deliver Community Outpost content"); - return OperationResult.CreateFailure($"Content delivery failed: {ex.Message}"); + var rollbackErrors = new List(); + if (registeredManifestIds.Count > 0) + { + rollbackErrors = await RollbackManifestsAsync(registeredManifestIds); + } + + await CleanupTemporaryFilesAsync(archivePath, extractPath); + var failureMessage = rollbackErrors.Count > 0 + ? $"Content delivery failed: {ex.Message} (Rollback errors: {string.Join("; ", rollbackErrors)})" + : $"Content delivery failed: {ex.Message}"; + return OperationResult.CreateFailure(failureMessage); } } @@ -459,7 +599,7 @@ await Task.Run(() => // Delete archive file try { - if (File.Exists(archivePath)) + if (!string.IsNullOrEmpty(archivePath) && File.Exists(archivePath)) { File.Delete(archivePath); logger.LogDebug("Deleted archive file: {Path}", archivePath); @@ -473,7 +613,7 @@ await Task.Run(() => // Delete extracted directory try { - if (Directory.Exists(extractPath)) + if (!string.IsNullOrEmpty(extractPath) && Directory.Exists(extractPath)) { Directory.Delete(extractPath, recursive: true); logger.LogDebug("Deleted extracted directory: {Path}", extractPath); @@ -486,6 +626,175 @@ await Task.Run(() => }); } + /// + /// Rolls back registered manifests from the manifest pool on failure. + /// + private async Task> RollbackManifestsAsync(IReadOnlyList manifestIdsToRollback) + { + var rollbackErrors = new List(); + foreach (var registeredId in manifestIdsToRollback) + { + try + { + var removeResult = await manifestPool.RemoveManifestAsync(registeredId, cancellationToken: CancellationToken.None); + if (!removeResult.Success) + { + logger.LogWarning( + "Failed to rollback manifest {ManifestId} during delivery cleanup: {Error}", + registeredId, + removeResult.FirstError); + rollbackErrors.Add($"Rollback of manifest {registeredId} failed: {removeResult.FirstError}"); + } + } + catch (Exception rollbackEx) + { + logger.LogWarning( + rollbackEx, + "Failed to rollback manifest {ManifestId} during delivery cleanup", + registeredId); + rollbackErrors.Add($"Rollback exception for manifest {registeredId}: {rollbackEx.Message}"); + } + } + + return rollbackErrors; + } + + /// + /// Replaces the extract directory contents with all packed BIG files from packDir. + /// + private void ReplaceExtractedWithPacked(string extractPath, string packDir) + { + try + { + if (Directory.Exists(extractPath)) + { + Directory.Delete(extractPath, true); + } + + Directory.CreateDirectory(extractPath); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to reset extract path {ExtractPath} during repacking", extractPath); + throw new IOException($"Failed to prepare extraction directory: {ex.Message}", ex); + } + + foreach (var packedFile in Directory.GetFiles(packDir, "*.big")) + { + File.Move(packedFile, Path.Combine(extractPath, Path.GetFileName(packedFile))); + } + } + + /// + /// Converts compressed images to TGA and packs source directory to destination BIG file. + /// + private async Task ConvertImagesAndPackAsync( + string sourceDir, + string destinationPath, + CancellationToken cancellationToken) + { + var compressedImageCount = Directory.GetFiles(sourceDir, "*.avif", SearchOption.AllDirectories).Length + + Directory.GetFiles(sourceDir, "*.webp", SearchOption.AllDirectories).Length; + if (compressedImageCount > 0) + { + logger.LogInformation( + "Converting {Count} compressed image files to TGA format for game compatibility in {Source}", + compressedImageCount, + sourceDir); + + var convertedCount = await avifConverter.ConvertDirectoryAsync(sourceDir, cancellationToken); + logger.LogInformation("Converted {Converted} compressed image files to TGA", convertedCount); + } + + await BigFilePacker.PackAsync(sourceDir, destinationPath); + } + + /// + /// Repacks all variant subdirectories into packDir. + /// + private async Task RepackAllVariantDirectoriesAsync( + string[] bigDirectories, + string packDir, + GenPatcherContentMetadata metadata, + CancellationToken cancellationToken) + { + var repackedCount = 0; + + foreach (var bigDir in bigDirectories) + { + cancellationToken.ThrowIfCancellationRequested(); + + var outputFileName = ResolveVariantOutputFileName(bigDir, metadata); + if (string.IsNullOrEmpty(outputFileName)) + { + logger.LogDebug("Skipping variant directory {Dir}: no matching output filename", bigDir); + continue; + } + + var destinationPath = Path.Combine(packDir, outputFileName); + var existingBigs = Directory.GetFiles(bigDir, "*.big", SearchOption.TopDirectoryOnly); + if (existingBigs.Length > 0) + { + var sourceFile = existingBigs[0]; + File.Copy(sourceFile, destinationPath, overwrite: true); + repackedCount++; + continue; + } + + logger.LogInformation("Packing hotkey variant from {Source} into {OutputFilename}", bigDir, outputFileName); + await ConvertImagesAndPackAsync(bigDir, destinationPath, cancellationToken); + repackedCount++; + } + + return repackedCount; + } + + /// + /// Repacks multi-variant hotkeys by packing each language/game subdirectory into its target BIG file. + /// + private async Task RepackMultiVariantHotkeysAsync( + string extractPath, + GenPatcherContentMetadata metadata, + CancellationToken cancellationToken) + { + logger.LogInformation("Repacking multi-variant hotkeys for {ContentCode}", metadata.ContentCode); + + var bigDirectories = Directory.GetDirectories(extractPath, "BIG*", SearchOption.AllDirectories); + if (bigDirectories.Length == 0) + { + logger.LogDebug("No BIG directories found for multi-variant hotkeys {ContentCode}", metadata.ContentCode); + return; + } + + var parentDir = Directory.GetParent(extractPath)?.FullName ?? extractPath; + var packDir = Path.Combine(parentDir, "packed_variants"); + Directory.CreateDirectory(packDir); + + try + { + var repackedCount = await RepackAllVariantDirectoriesAsync(bigDirectories, packDir, metadata, cancellationToken); + if (repackedCount > 0) + { + ReplaceExtractedWithPacked(extractPath, packDir); + logger.LogInformation("Successfully repacked {Count} hotkey variant BIG files", repackedCount); + } + } + finally + { + if (Directory.Exists(packDir)) + { + try + { + Directory.Delete(packDir, true); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to cleanup temporary variant pack directory {PackDir}", packDir); + } + } + } + } + /// /// Repacks extracted content into a single .big file if required by metadata. /// @@ -497,11 +806,18 @@ private async Task RepackContentIfNeededAsync( var contentCode = GetContentCodeFromManifest(manifest); var metadata = GenPatcherContentRegistry.GetMetadata(contentCode); - if (metadata.RequiresRepacking && !string.IsNullOrEmpty(metadata.OutputFilename)) + if (metadata.RequiresRepacking) { + // Multi-variant hotkeys repack each variant language/game subdirectory + if (metadata.Category == GenPatcherContentCategory.Hotkeys && metadata.SupportsVariants) + { + await RepackMultiVariantHotkeysAsync(extractPath, metadata, cancellationToken); + return; + } + // Variant-based output filenames (e.g., 340_ControlBarPro{variant}ZH.big) // must be handled later when a specific variant is selected. - if (metadata.OutputFilename.Contains("{variant}", StringComparison.OrdinalIgnoreCase)) + if (metadata.OutputFilename?.Contains("{variant}", StringComparison.OrdinalIgnoreCase) == true) { logger.LogDebug( "Skipping repack at delivery stage for {ContentCode} because output filename is variant-based: {OutputFilename}", @@ -510,6 +826,12 @@ private async Task RepackContentIfNeededAsync( return; } + if (string.IsNullOrEmpty(metadata.OutputFilename)) + { + logger.LogWarning("Skipping repack for {ContentCode}: OutputFilename is not set", contentCode); + return; + } + // If a correctly named BIG file already exists in the extracted content, do not repack. var existingBig = Directory.GetFiles(extractPath, metadata.OutputFilename, SearchOption.AllDirectories) .FirstOrDefault(); @@ -527,74 +849,14 @@ private async Task RepackContentIfNeededAsync( contentCode, metadata.OutputFilename); - // Create a temporary directory for the packed file - var packDir = Path.Combine(Directory.GetParent(extractPath)!.FullName, "packed"); + var parentDir = Directory.GetParent(extractPath)?.FullName ?? extractPath; + var packDir = Path.Combine(parentDir, "packed"); Directory.CreateDirectory(packDir); var destinationPath = Path.Combine(packDir, metadata.OutputFilename); + var packSource = ResolvePackSourceDirectory(extractPath); - // Pack the files - // GenPatcher archives often extract to nested ZH\BIG or CCG\BIG folders. We must pack the BIG folder contents, - // not the parent folder, to avoid embedding extra path prefixes inside the .big. - var bigDirectories = Directory.GetDirectories(extractPath, "BIG*", SearchOption.AllDirectories); - var packSource = extractPath; - - if (bigDirectories.Length > 0) - { - bool IsUnder(string path, string folder) - { - return path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - .Any(segment => segment.Equals(folder, StringComparison.OrdinalIgnoreCase)); - } - - bool EndsWithSegment(string path, string segment) - { - return path.EndsWith(segment, StringComparison.OrdinalIgnoreCase); - } - - var preferred = bigDirectories - .FirstOrDefault(d => IsUnder(d, "ZH") && EndsWithSegment(d, "BIG EN")) - ?? bigDirectories.FirstOrDefault(d => IsUnder(d, "ZH") && EndsWithSegment(d, "BIG")) - ?? bigDirectories.FirstOrDefault(d => IsUnder(d, "CCG") && EndsWithSegment(d, "BIG EN")) - ?? bigDirectories.FirstOrDefault(d => IsUnder(d, "CCG") && EndsWithSegment(d, "BIG")) - ?? bigDirectories.First(); - - packSource = preferred; - } - - // Convert compressed image files (AVIF, WebP) to TGA format before packing - // GenPatcher dat archives contain AVIF/WebP for compression, but the game requires TGA textures - var compressedImageCount = Directory.GetFiles(packSource, "*.avif", SearchOption.AllDirectories).Length - + Directory.GetFiles(packSource, "*.webp", SearchOption.AllDirectories).Length; - if (compressedImageCount > 0) - { - logger.LogInformation( - "Converting {Count} compressed image files to TGA format for game compatibility", - compressedImageCount); - - var convertedCount = await avifConverter.ConvertDirectoryAsync(packSource, cancellationToken); - logger.LogInformation("Converted {Converted} compressed image files to TGA", convertedCount); - } - - await BigFilePacker.PackAsync(packSource, destinationPath); - - // Clear the ExtractPath and move the packed file there - // This ensures the manifest factory only sees the packed file - try - { - if (Directory.Exists(extractPath)) - { - Directory.Delete(extractPath, true); - } - - Directory.CreateDirectory(extractPath); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to reset extract path {ExtractPath} during repacking", extractPath); - throw new IOException($"Failed to prepare extraction directory: {ex.Message}", ex); - } - - File.Move(destinationPath, Path.Combine(extractPath, metadata.OutputFilename)); + await ConvertImagesAndPackAsync(packSource, destinationPath, cancellationToken); + ReplaceExtractedWithPacked(extractPath, packDir); // Cleanup packDir try @@ -805,7 +1067,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/CommunityOutpostManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs index 31f1342a4..47e09cdb5 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs @@ -24,9 +24,8 @@ namespace GenHub.Features.Content.Services.CommunityOutpost; public class CommunityOutpostManifestFactory( ILogger logger, IFileHashProvider hashProvider, - CompressedImageToTgaConverter avifConverter) : IPublisherManifestFactory + IControlBarPackageProcessor controlBarProcessor) : IPublisherManifestFactory { - private const string ControlBarMetadataBigBase64 = "QklHRngBAAAAAAACAAAAUwAAAFMAAAEkQ29udHJvbEJhclByby50eHQAAAABdwAAAAFHZW5Ub29sXGZ1bGx2aWV3cG9ydC5kYXQAAAAAAAAAAABDb250cm9sIEJhciBQcm8gZm9yIENPTU1BTkQgQU5EIENPTlFVRVIgR0VORVJBTFM6IFpFUk8gSE9VUg0KDQpBVVRIT1I6DQpFQSBHYW1lcywgRkFTLCB4ZXpvbg0KDQpPUklHSU5BTCBET1dOTE9BRCBVUkw6DQpodHRwOi8vZ2VudG9vbC5uZXQvZG93bmxvYWQvY29udHJvbGJhcnBybw0KDQpTT1VSQ0UgQ09ERSAmIEFTU0VUUzoNCmh0dHBzOi8vZ2l0aHViLmNvbS9UaGVTdXBlckhhY2tlcnMvR2VuZXJhbHNDb250cm9sQmFyDQoNCkRPTkFUSU9OIExJTks6DQpodHRwczovL3d3dy5wYXlwYWwubWUvZ2VudG9vbA0KMQ=="; private static readonly ConcurrentDictionary RegexCache = new(); private static Regex GetCachedRegex(string pattern) @@ -34,7 +33,8 @@ private static Regex GetCachedRegex(string pattern) var normalized = pattern.ToLowerInvariant(); return RegexCache.GetOrAdd(normalized, p => new Regex( "^" + Regex.Escape(p).Replace("\\*", ".*") + "$", - RegexOptions.IgnoreCase | RegexOptions.Compiled)); + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromSeconds(1))); } /// @@ -243,109 +243,6 @@ private static ContentInstallTarget DetermineFileInstallTarget( return defaultTarget; } - private static string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId) - { - var candidates = new[] - { - Path.Combine(extractedDirectory, "ZH", variantId, "BIG EN"), - Path.Combine(extractedDirectory, "ZH", variantId, "BIG"), - Path.Combine(extractedDirectory, "CCG", variantId, "BIG EN"), - Path.Combine(extractedDirectory, "CCG", variantId, "BIG"), - }; - - foreach (var candidate in candidates) - { - if (Directory.Exists(candidate)) - { - return candidate; - } - } - - return null; - } - - private static string GetControlBarVariantSuffix(string variantId) - { - return variantId.EndsWith("p", StringComparison.OrdinalIgnoreCase) - ? variantId[..^1] - : variantId; - } - - private static bool IsAllowedControlBarBig(string fileName, string variantSuffix) - { - return fileName.Equals($"340_ControlBarProArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals($"340_ControlBarProData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals($"340_ControlBarPro{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals($"340_ControlBarPro-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("400_ControlBarHDEnglishZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("400_ControlBarProCoreZH.big", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Attempts to copy a file with retry logic for transient file lock issues. - /// - private static async Task TryCopyFileWithRetryAsync(string source, string destination, ILogger logger, int maxRetries = 3, int delayMs = 100) - { - for (var attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - File.Copy(source, destination, overwrite: true); - return; - } - catch (IOException ex) when (attempt < maxRetries) - { - logger.LogWarning( - "File copy attempt {Attempt}/{MaxRetries} failed for {Source}: {Message}. Retrying...", - attempt, - maxRetries, - Path.GetFileName(source), - ex.Message); - await Task.Delay(delayMs * attempt); - } - } - - // Final attempt without catch - let it throw if it fails - File.Copy(source, destination, overwrite: true); - } - - private static void CopyDirectory(string sourceDir, string destinationDir) - { - // Recursion guard - var sourceInfo = new DirectoryInfo(sourceDir); - var destInfo = new DirectoryInfo(destinationDir); - if (destInfo.FullName.StartsWith(sourceInfo.FullName, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Cannot copy directory into itself: Source={sourceDir}, Dest={destinationDir}"); - } - - Directory.CreateDirectory(destinationDir); - - foreach (var file in Directory.GetFiles(sourceDir)) - { - try - { - var targetFile = Path.Combine(destinationDir, Path.GetFileName(file)); - File.Copy(file, targetFile, overwrite: true); - } - catch (IOException) - { - throw; - } - catch (UnauthorizedAccessException) - { - throw; - } - } - - foreach (var dir in Directory.GetDirectories(sourceDir)) - { - var targetDir = Path.Combine(destinationDir, Path.GetFileName(dir)); - CopyDirectory(dir, targetDir); - } - } - /// /// Builds a manifest with all files from the extracted directory. /// If variant is provided, filters files based on variant's IncludePatterns and ExcludePatterns. @@ -371,7 +268,10 @@ private static void CopyDirectory(string sourceDir, string destinationDir) logger.LogDebug("Found {FileCount} files in extracted directory", allFiles.Length); var fileEntries = new List(); - var dependencyBigFiles = CollectDependencyBigFiles(contentMetadata); + var targetGame = (variant != null && variant.TargetGame.HasValue) + ? variant.TargetGame.Value + : originalManifest.TargetGame; + var dependencyBigFiles = CollectDependencyBigFiles(contentMetadata, targetGame); var alwaysIncludeFiles = new HashSet(StringComparer.OrdinalIgnoreCase); if (contentMetadata.Category == GenPatcherContentCategory.ControlBar) @@ -384,9 +284,20 @@ private static void CopyDirectory(string sourceDir, string destinationDir) contentMetadata.SupportsVariants && variant != null; - var controlBarRepackedOutputs = isControlBarVariant - ? await PrepareControlBarVariantAsync(extractedDirectory, contentMetadata, variant!, cancellationToken) - : new HashSet(StringComparer.OrdinalIgnoreCase); + HashSet controlBarRepackedOutputs; + if (isControlBarVariant) + { + var outputs = await controlBarProcessor.ProcessAndRepackControlBarAsync( + extractedDirectory, + originalManifest, + variant?.Id, + cancellationToken); + controlBarRepackedOutputs = new HashSet(outputs, StringComparer.OrdinalIgnoreCase); + } + else + { + controlBarRepackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase); + } if (controlBarRepackedOutputs.Count > 0) { @@ -542,7 +453,7 @@ private static void CopyDirectory(string sourceDir, string destinationDir) } } - private HashSet CollectDependencyBigFiles(GenPatcherContentMetadata contentMetadata) + private static HashSet CollectDependencyBigFiles(GenPatcherContentMetadata contentMetadata, GameType targetGame) { var dependencyBigFiles = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var dependency in contentMetadata.GetDependencies() @@ -554,228 +465,19 @@ private HashSet CollectDependencyBigFiles(GenPatcherContentMetadata cont { var depCode = depId[(lastDot + 1)..]; var depMetadata = GenPatcherContentRegistry.GetMetadata(depCode); - if (!string.IsNullOrEmpty(depMetadata.OutputFilename)) - { - dependencyBigFiles.Add(depMetadata.OutputFilename); - } - } - } - - return dependencyBigFiles; - } - - private async Task> PrepareControlBarVariantAsync( - string extractedDirectory, - GenPatcherContentMetadata contentMetadata, - ContentVariant variant, - CancellationToken cancellationToken) - { - var controlBarRepackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase); - var variantSuffix = GetControlBarVariantSuffix(variant.Id); - var variantBigRoot = FindControlBarVariantBigRoot(extractedDirectory, variant.Id); - - if (!string.IsNullOrEmpty(variantBigRoot)) - { - var prebuiltBigs = Directory.GetFiles(variantBigRoot, "*.big", SearchOption.TopDirectoryOnly) - .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix)) - .ToArray(); - - if (prebuiltBigs.Length > 0) - { - logger.LogInformation("Using prebuilt control bar BIG files from {VariantRoot}", variantBigRoot); - foreach (var prebuiltBig in prebuiltBigs) - { - var bigName = Path.GetFileName(prebuiltBig); - var targetPath = Path.Combine(extractedDirectory, bigName); - - if (!string.Equals(Path.GetFullPath(prebuiltBig), Path.GetFullPath(targetPath), StringComparison.OrdinalIgnoreCase)) - { - await TryCopyFileWithRetryAsync(prebuiltBig, targetPath, logger); - } - - controlBarRepackedOutputs.Add(bigName); - } - } - else - { - var artBigName = $"340_ControlBarProArt{variantSuffix}ZH.big"; - var dataBigName = $"340_ControlBarProData{variantSuffix}ZH.big"; - var artBigPath = Path.Combine(extractedDirectory, artBigName); - var dataBigPath = Path.Combine(extractedDirectory, dataBigName); - - if (!File.Exists(artBigPath) || !File.Exists(dataBigPath)) - { - logger.LogInformation("Repacking control bar variant {Variant} into Art/Data BIG files", variant.Name); - var artSource = Path.Combine(variantBigRoot, "Art"); - var dataSource = Path.Combine(variantBigRoot, "Data"); - var windowSource = Path.Combine(variantBigRoot, "Window"); - var genToolSource = Path.Combine(variantBigRoot, "GenTool"); - - var tempRoot = Path.Combine(extractedDirectory, $"cbpro-pack-{variant.Id}"); - var artPackRoot = Path.Combine(tempRoot, "ArtPack"); - var dataPackRoot = Path.Combine(tempRoot, "DataPack"); - - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } - - Directory.CreateDirectory(artPackRoot); - Directory.CreateDirectory(dataPackRoot); - - if (Directory.Exists(artSource)) - { - CopyDirectory(artSource, Path.Combine(artPackRoot, "Art")); - } - - if (Directory.Exists(dataSource)) - { - CopyDirectory(dataSource, Path.Combine(dataPackRoot, "Data")); - } - - if (Directory.Exists(windowSource)) - { - CopyDirectory(windowSource, Path.Combine(dataPackRoot, "Window")); - } - - if (Directory.Exists(genToolSource)) - { - CopyDirectory(genToolSource, Path.Combine(dataPackRoot, "GenTool")); - } - - try - { - await avifConverter.ConvertDirectoryAsync(artPackRoot, cancellationToken); - await avifConverter.ConvertDirectoryAsync(dataPackRoot, cancellationToken); - - await BigFilePacker.PackAsync(artPackRoot, artBigPath); - await BigFilePacker.PackAsync(dataPackRoot, dataBigPath); - } - finally - { - try - { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to cleanup temp root {TempRoot}", tempRoot); - } - } - } - - if (File.Exists(artBigPath)) + if (depMetadata.TargetGame != GameType.Unknown && depMetadata.TargetGame != targetGame) { - controlBarRepackedOutputs.Add(artBigName); - } - - if (File.Exists(dataBigPath)) - { - controlBarRepackedOutputs.Add(dataBigName); - } - } - } - else - { - logger.LogInformation("Control bar has flat structure (cbpx-style), searching for prebuilt BIG files in root"); - var prebuiltCandidates = Directory.GetFiles(extractedDirectory, "*ControlBarPro*ZH.big", SearchOption.TopDirectoryOnly) - .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix)) - .ToArray(); - - var hasArtDataSplit = prebuiltCandidates.Any(p => - Path.GetFileName(p).StartsWith("340_ControlBarProArt", StringComparison.OrdinalIgnoreCase) || - Path.GetFileName(p).StartsWith("340_ControlBarProData", StringComparison.OrdinalIgnoreCase)); - - if (hasArtDataSplit) - { - prebuiltCandidates = [.. prebuiltCandidates - .Where(p => - { - var name = Path.GetFileName(p); - if (name.StartsWith("340_ControlBarProArt", StringComparison.OrdinalIgnoreCase) || - name.StartsWith("340_ControlBarProData", StringComparison.OrdinalIgnoreCase) || - name.Contains("-Fix", StringComparison.OrdinalIgnoreCase) || - name.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - logger.LogDebug("Excluding monolithic BIG {Name} in favor of Art/Data split files", name); - return false; - })]; - } - - if (prebuiltCandidates.Length > 0) - { - logger.LogInformation( - "Using {Count} prebuilt control bar BIG files from flat structure: {Files}", - prebuiltCandidates.Length, - string.Join(", ", prebuiltCandidates.Select(Path.GetFileName))); - - foreach (var candidate in prebuiltCandidates) - { - controlBarRepackedOutputs.Add(Path.GetFileName(candidate)); + continue; } - } - else - { - logger.LogWarning("No prebuilt control bar BIG files found for variant {Variant} in flat structure", variant.Name); - } - } - var metadataFileName = "340_ControlBarProZH.big"; - var metadataTargetPath = Path.Combine(extractedDirectory, metadataFileName); - - if (!File.Exists(metadataTargetPath)) - { - var metadataSearchPaths = new[] - { - Path.Combine(extractedDirectory, "ZH", metadataFileName), - Path.Combine(extractedDirectory, "CCG", metadataFileName), - Path.Combine(extractedDirectory, "ZH", variant.Id, metadataFileName), - Path.Combine(extractedDirectory, "CCG", variant.Id, metadataFileName), - Path.Combine(extractedDirectory, "ZH", variant.Id, "BIG EN", metadataFileName), - Path.Combine(extractedDirectory, "ZH", variant.Id, "BIG", metadataFileName), - Path.Combine(extractedDirectory, "CCG", variant.Id, "BIG EN", metadataFileName), - Path.Combine(extractedDirectory, "CCG", variant.Id, "BIG", metadataFileName), - }; - - foreach (var searchPath in metadataSearchPaths) - { - if (File.Exists(searchPath)) + if (!string.IsNullOrEmpty(depMetadata.OutputFilename)) { - logger.LogInformation("Found Control Bar metadata file at {SourcePath}, copying to root", searchPath); - await TryCopyFileWithRetryAsync(searchPath, metadataTargetPath, logger); - break; + dependencyBigFiles.Add(depMetadata.OutputFilename); } } } - if (File.Exists(metadataTargetPath)) - { - controlBarRepackedOutputs.Add(metadataFileName); - logger.LogInformation("Including Control Bar metadata file {FileName} in manifest", metadataFileName); - } - else - { - logger.LogWarning("Control Bar metadata file {FileName} not found in extracted content - creating fallback version", metadataFileName); - try - { - var metadataBytes = Convert.FromBase64String(ControlBarMetadataBigBase64); - File.WriteAllBytes(metadataTargetPath, metadataBytes); - controlBarRepackedOutputs.Add(metadataFileName); - logger.LogInformation("Created Control Bar metadata file {FileName} from embedded fallback", metadataFileName); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to create Control Bar metadata file - manifest will be incomplete"); - } - } - - return controlBarRepackedOutputs; + return dependencyBigFiles; } private bool HasVariantBigFiles( 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/CommunityOutpost/CommunityOutpostResolver.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs index d7c02029d..f166b9c46 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs @@ -75,7 +75,6 @@ public Task> ResolveAsync( // Extract metadata from resolver metadata (set by the discoverer/parser) var contentCode = GetMetadataValue(discoveredItem, "contentCode", "unknown"); - var catalogVersion = GetMetadataValue(discoveredItem, "catalogVersion", "unknown"); var category = GetMetadataValue(discoveredItem, "category", "Other"); var fileSize = GetMetadataValueLong(discoveredItem, "fileSize", 0); @@ -83,10 +82,13 @@ public Task> ResolveAsync( var contentMetadata = GenPatcherContentRegistry.GetMetadata(contentCode); // Determine filename from URL or content code - var downloadUrl = discoveredItem.SourceUrl ?? throw new InvalidOperationException( - "SourceUrl cannot be null for Community Outpost content"); + if (!Uri.TryCreate(discoveredItem.SourceUrl, UriKind.Absolute, out var downloadUri)) + { + throw new InvalidOperationException( + "SourceUrl must be a valid absolute URI for Community Outpost content"); + } - var filename = GetFilenameFromUrl(downloadUrl, contentCode); + var filename = DetermineFilename(downloadUri, contentCode); // Get all mirror URLs for fallback support var mirrorUrls = GetMirrorUrls(discoveredItem); @@ -104,7 +106,7 @@ public Task> ResolveAsync( var versionSource = !string.IsNullOrEmpty(contentMetadata.Version) ? contentMetadata.Version : discoveredItem.Version; - var manifestVersion = ExtractManifestVersion(versionSource); + var manifestVersion = ExtractVersionNumberForManifestId(versionSource); logger.LogDebug( "Generating manifest ID: Publisher={Publisher}, ContentType={ContentType}, ContentName={ContentName}, Version={Version}", @@ -160,13 +162,12 @@ public Task> ResolveAsync( // Add the file as a remote download manifest.AddRemoteFileAsync( filename, - downloadUrl, + downloadUri.AbsoluteUri, ContentSourceType.RemoteDownload, isExecutable: false).Wait(cancellationToken); // Store additional metadata in the manifest for the deliverer var builtManifest = manifest.Build(); - builtManifest.ManifestVersion = manifestVersion; // Store the install target from content metadata builtManifest.InstallationInstructions ??= new InstallationInstructions(); @@ -284,7 +285,7 @@ private static string GetLanguageDisplayName(string languageCode) /// /// Extracts a numeric version suitable for manifest ID. /// - private static string ExtractManifestVersion(string version) + private static string ExtractVersionNumberForManifestId(string version) { if (string.IsNullOrEmpty(version)) { @@ -349,7 +350,7 @@ private static List BuildTags(ContentSearchResult item, GenPatcherConten /// private static string GetMetadataValue(ContentSearchResult item, string key, string defaultValue) { - if (item.ResolverMetadata != null && item.ResolverMetadata.TryGetValue(key, out var value)) + if (item.ResolverMetadata?.TryGetValue(key, out var value) == true) { return value; } @@ -367,24 +368,16 @@ private static long GetMetadataValueLong(ContentSearchResult item, string key, l } /// - /// Gets the filename from the download URL or generates one from the content code. + /// Determines the filename from the download URI or generates one from the content code. /// - private static string GetFilenameFromUrl(string url, string contentCode) + private static string DetermineFilename(Uri downloadUri, string contentCode) { - try - { - var uri = new Uri(url); - var path = uri.AbsolutePath; - var lastSegment = path.Split('/')[^1]; + var path = downloadUri.AbsolutePath; + var lastSegment = path.Split('/')[^1]; - if (!string.IsNullOrEmpty(lastSegment) && lastSegment.Contains('.')) - { - return lastSegment; - } - } - catch + if (!string.IsNullOrEmpty(lastSegment) && lastSegment.Contains('.')) { - // Fall through to default filename + return lastSegment; } return $"{contentCode}{CommunityOutpostConstants.DatFileExtension}"; @@ -393,7 +386,7 @@ private static string GetFilenameFromUrl(string url, string contentCode) /// /// Gets the list of mirror URLs from the search result metadata. /// - private List GetMirrorUrls(ContentSearchResult item) + private IReadOnlyList GetMirrorUrls(ContentSearchResult item) { var mirrorUrlsJson = GetMetadataValue(item, "mirrorUrls", "[]"); diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs index 4bb9afdf9..9de72fb7b 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs @@ -308,12 +308,9 @@ private ParsedCatalog ParseDatContent(string content) }; // Add default tags from provider - foreach (var tag in provider.DefaultTags) + foreach (var tag in provider.DefaultTags.Where(tag => !result.Tags.Contains(tag))) { - if (!result.Tags.Contains(tag)) - { - result.Tags.Add(tag); - } + result.Tags.Add(tag); } // Add category as a tag 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/ContentDiscoverers/AODMapsDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/AODMapsDiscoverer.cs index 6452ee8dd..420a31a7b 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/AODMapsDiscoverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/AODMapsDiscoverer.cs @@ -1,14 +1,6 @@ -using AngleSharp; -using AngleSharp.Dom; -using GenHub.Core.Constants; -using GenHub.Core.Interfaces.Content; -using GenHub.Core.Models.Content; -using GenHub.Core.Models.Enums; -using GenHub.Core.Models.Results; -using GenHub.Core.Models.Results.Content; -using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net.Http; using System.Security.Cryptography; @@ -16,12 +8,22 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using AngleSharp; +using AngleSharp.Dom; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.Content; +using Microsoft.Extensions.Logging; namespace GenHub.Features.Content.Services.ContentDiscoverers; /// /// Discovers maps from AODMaps (Age of Defense Maps) website. /// +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public partial class AODMapsDiscoverer( IHttpClientFactory httpClientFactory, ILogger logger) : IContentDiscoverer diff --git a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/CNCLabsMapDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/CNCLabsMapDiscoverer.cs index 8ef05639f..6620ed845 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/CNCLabsMapDiscoverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/CNCLabsMapDiscoverer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Net.Http; @@ -23,6 +24,8 @@ namespace GenHub.Features.Content.Services.ContentDiscoverers; /// /// Discovers maps from CNC Labs website. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "CNCLabs base domain URL")] +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "CNC Labs domain casing")] public partial class CNCLabsMapDiscoverer(HttpClient httpClient, ILogger logger) : IContentDiscoverer { private static readonly char[] TagSeparator = [',', ';', ' ']; @@ -138,6 +141,256 @@ public async Task> DiscoverAsync( } } + private static DateTime ParseLastUpdatedDate(IDocument document, string docText) + { + var dateLabels = new[] { "Updated:", "Added:", "Submitted:", "reviewed:", "Date:" }; + foreach (var label in dateLabels) + { + var dateEl = document.QuerySelectorAll("strong").FirstOrDefault(e => e.TextContent.Contains(label, StringComparison.OrdinalIgnoreCase)); + if (dateEl != null) + { + var dateText = CNCLabsHelper.GetNextNonEmptyTextSibling(dateEl); + if (!string.IsNullOrWhiteSpace(dateText) && DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate)) + { + return parsedDate; + } + } + } + + var dateMatch = DateRegex().Match(docText); + if (dateMatch.Success && DateTime.TryParse(dateMatch.Groups[1].Value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + return date; + } + + return DateTime.MinValue; + } + + private static string? ParseFileSize(IDocument document, string docText) + { + var sizeMatch = FileSizeRegex().Match(docText); + if (sizeMatch.Success) + { + return sizeMatch.Groups[1].Value.Trim(); + } + + var sizeLabels = new[] { "File Size:", "Size:" }; + foreach (var label in sizeLabels) + { + var sizeEl = document.QuerySelectorAll("strong").FirstOrDefault(e => e.TextContent.Contains(label, StringComparison.OrdinalIgnoreCase)); + if (sizeEl != null) + { + var fileSize = CNCLabsHelper.GetNextNonEmptyTextSibling(sizeEl); + if (!string.IsNullOrEmpty(fileSize)) + { + return fileSize; + } + } + } + + return null; + } + + private static long? ParseDownloadCount(string docText) + { + var downloadMatch = DownloadCountRegex().Match(docText); + if (downloadMatch.Success) + { + var valGroup = !string.IsNullOrEmpty(downloadMatch.Groups[1].Value) ? 1 : 2; + var val = downloadMatch.Groups[valGroup].Value; + if (long.TryParse(val.Replace(",", string.Empty, StringComparison.Ordinal), out var dl)) + { + return dl; + } + } + + return null; + } + + private static string? ParsePreviewImage(IDocument document) + { + var mainImage = document.QuerySelector("#ctl00_MainContent_Image1") ?? document.QuerySelector(".screenshot img") ?? document.QuerySelector("img[src*='preview']"); + if (mainImage != null) + { + var src = mainImage.GetAttribute("src"); + if (!string.IsNullOrEmpty(src)) + { + return src.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? src + : new Uri(new Uri("https://www.cnclabs.com"), src).ToString(); + } + } + + return null; + } + + private static List ParseTags(string docText) + { + var tags = new List(); + var taggedAsIdx = docText.IndexOf("Tagged as:", StringComparison.OrdinalIgnoreCase); + if (taggedAsIdx != -1) + { + var tagLineEnd = docText.IndexOf('\n', taggedAsIdx); + if (tagLineEnd == -1) + { + tagLineEnd = docText.Length; + } + + var tagLine = docText[(taggedAsIdx + "Tagged as:".Length)..tagLineEnd].Trim(); + var parts = tagLine.Split(TagSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var part in parts) + { + var t = part.Trim(); + if (!string.IsNullOrEmpty(t)) + { + tags.Add(t); + } + } + } + + return tags; + } + + private static (DateTime? LastUpdated, long? DlCount, string? FSize) ExtractSearchItemMetadata(IElement item) + { + var strongs = item.QuerySelectorAll("strong"); + var lastUpdated = ExtractMetadataDate(strongs, item.TextContent); + var dlCount = ExtractMetadataDownloads(strongs); + var fSize = ExtractMetadataSize(strongs); + return (lastUpdated, dlCount, fSize); + } + + private static DateTime? ExtractMetadataDate(IEnumerable strongs, string textContent) + { + foreach (var s in strongs) + { + var label = s.TextContent?.Trim(); + if (label is not null && (label.Contains("Updated:", StringComparison.OrdinalIgnoreCase) || + label.Contains("Added:", StringComparison.OrdinalIgnoreCase) || + label.Contains("Date:", StringComparison.OrdinalIgnoreCase))) + { + var val = CNCLabsHelper.GetNextNonEmptyTextSibling(s); + if (!string.IsNullOrWhiteSpace(val) && DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) + { + return d; + } + } + } + + if (!string.IsNullOrEmpty(textContent)) + { + var match = DateRegex().Match(textContent); + if (match.Success && DateTime.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallbackDate)) + { + return fallbackDate; + } + } + + return null; + } + + private static long? ExtractMetadataDownloads(IEnumerable strongs) + { + foreach (var s in strongs) + { + var label = s.TextContent?.Trim(); + if (label is not null && (label.Contains("Downloads:", StringComparison.OrdinalIgnoreCase) || + label.Contains("Downloaded:", StringComparison.OrdinalIgnoreCase))) + { + var val = CNCLabsHelper.GetNextNonEmptyTextSibling(s); + if (!string.IsNullOrWhiteSpace(val)) + { + val = val.Replace(",", string.Empty, StringComparison.Ordinal).Trim(); + if (long.TryParse(val, out var dl)) + { + return dl; + } + } + } + } + + return null; + } + + private static string? ExtractMetadataSize(IEnumerable strongs) + { + foreach (var s in strongs) + { + var label = s.TextContent?.Trim(); + if (label is not null && label.Contains("Size:", StringComparison.OrdinalIgnoreCase)) + { + var val = CNCLabsHelper.GetNextNonEmptyTextSibling(s); + if (!string.IsNullOrWhiteSpace(val)) + { + return val.Trim(); + } + } + } + + return null; + } + + private static MapListItem? ParseSearchListItem(IElement item, ContentSearchQuery query) + { + var idValue = item.QuerySelector(CNCLabsConstants.FileIdHiddenSelector)?.GetAttribute(CNCLabsConstants.ValueAttribute); + if (string.IsNullOrWhiteSpace(idValue) || !int.TryParse(idValue, out var id)) + { + return null; + } + + var nameAnchor = item.QuerySelector(CNCLabsConstants.DisplayNameAnchorSelector); + var name = nameAnchor?.TextContent?.Trim(); + var detailsHref = nameAnchor?.GetAttribute(CNCLabsConstants.HrefAttribute); + + string? description = null; + var descEl = item.QuerySelector(CNCLabsConstants.DescriptionSelector); + if (descEl != null) + { + description = CNCLabsHelper.NormalizeHtmlDescription(descEl.InnerHtml); + } + + var authorStrong = item.QuerySelectorAll(CNCLabsConstants.DescriptionCellStrongSelector) + .FirstOrDefault(s => string.Equals( + s.TextContent?.Trim(), + CNCLabsConstants.AuthorLabelText, + StringComparison.OrdinalIgnoreCase)); + + var author = CNCLabsHelper.GetNextNonEmptyTextSibling(authorStrong); + var (lastUpdated, dlCount, fSize) = ExtractSearchItemMetadata(item); + var imgUrl = ExtractScreenshotUrl(item); + + return new MapListItem( + id, + name ?? string.Empty, + description ?? string.Empty, + author ?? CNCLabsConstants.DefaultAuthorName, + detailsHref ?? string.Empty, + query.TargetGame, + query.ContentType, + lastUpdated ?? DateTime.MinValue, + dlCount, + fSize, + imgUrl, + []); + } + + private static string? ExtractScreenshotUrl(IElement item) + { + var img = item.QuerySelector(".screenshot img") ?? item.QuerySelector("img"); + if (img != null) + { + var src = img.GetAttribute("src"); + if (!string.IsNullOrEmpty(src)) + { + return src.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? src + : new Uri(new Uri("https://www.cnclabs.com"), src).ToString(); + } + } + + return null; + } + /// /// Performs a text-based search using Playwright, parsing the results list for detail links and names. /// @@ -264,50 +517,6 @@ await linkHandle.GetAttributeAsync(CNCLabsConstants.CanonicalHrefAttr).Configure return (mapList, hasMoreItems); } - private MapListItem? ParseSearchListItem(IElement item, ContentSearchQuery query) - { - var idValue = item.QuerySelector(CNCLabsConstants.FileIdHiddenSelector)?.GetAttribute(CNCLabsConstants.ValueAttribute); - if (string.IsNullOrWhiteSpace(idValue) || !int.TryParse(idValue, out var id)) - { - return null; - } - - var nameAnchor = item.QuerySelector(CNCLabsConstants.DisplayNameAnchorSelector); - var name = nameAnchor?.TextContent?.Trim(); - var detailsHref = nameAnchor?.GetAttribute(CNCLabsConstants.HrefAttribute); - - string? description = null; - var descEl = item.QuerySelector(CNCLabsConstants.DescriptionSelector); - if (descEl != null) - { - description = CNCLabsHelper.NormalizeHtmlDescription(descEl.InnerHtml); - } - - var authorStrong = item.QuerySelectorAll(CNCLabsConstants.DescriptionCellStrongSelector) - .FirstOrDefault(s => string.Equals( - s.TextContent?.Trim(), - CNCLabsConstants.AuthorLabelText, - StringComparison.OrdinalIgnoreCase)); - - var author = CNCLabsHelper.GetNextNonEmptyTextSibling(authorStrong); - var (lastUpdated, dlCount, fSize) = ExtractSearchItemMetadata(item); - var imgUrl = ExtractScreenshotUrl(item); - - return new MapListItem( - id, - name ?? string.Empty, - description ?? string.Empty, - author ?? CNCLabsConstants.DefaultAuthorName, - detailsHref ?? string.Empty, - query.TargetGame, - query.ContentType, - lastUpdated ?? DateTime.MinValue, - dlCount, - fSize, - imgUrl, - []); - } - private bool ParseHasMorePagingLinks(IHtmlCollection pagingLinks, ContentSearchQuery query) { if (pagingLinks.Length == 0) @@ -438,116 +647,6 @@ private async Task GetMapDetailsAsync(int id, string detailsPageUrl tags); } - private DateTime ParseLastUpdatedDate(IDocument document, string docText) - { - var dateLabels = new[] { "Updated:", "Added:", "Submitted:", "reviewed:", "Date:" }; - foreach (var label in dateLabels) - { - var dateEl = document.QuerySelectorAll("strong").FirstOrDefault(e => e.TextContent.Contains(label, StringComparison.OrdinalIgnoreCase)); - if (dateEl != null) - { - var dateText = CNCLabsHelper.GetNextNonEmptyTextSibling(dateEl); - if (!string.IsNullOrWhiteSpace(dateText) && DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate)) - { - return parsedDate; - } - } - } - - var dateMatch = DateRegex().Match(docText); - if (dateMatch.Success && DateTime.TryParse(dateMatch.Groups[1].Value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) - { - return date; - } - - return DateTime.MinValue; - } - - private string? ParseFileSize(IDocument document, string docText) - { - var sizeMatch = FileSizeRegex().Match(docText); - if (sizeMatch.Success) - { - return sizeMatch.Groups[1].Value.Trim(); - } - - var sizeLabels = new[] { "File Size:", "Size:" }; - foreach (var label in sizeLabels) - { - var sizeEl = document.QuerySelectorAll("strong").FirstOrDefault(e => e.TextContent.Contains(label, StringComparison.OrdinalIgnoreCase)); - if (sizeEl != null) - { - var fileSize = CNCLabsHelper.GetNextNonEmptyTextSibling(sizeEl); - if (!string.IsNullOrEmpty(fileSize)) - { - return fileSize; - } - } - } - - return null; - } - - private long? ParseDownloadCount(string docText) - { - var downloadMatch = DownloadCountRegex().Match(docText); - if (downloadMatch.Success) - { - var valGroup = !string.IsNullOrEmpty(downloadMatch.Groups[1].Value) ? 1 : 2; - var val = downloadMatch.Groups[valGroup].Value; - if (long.TryParse(val.Replace(",", string.Empty, StringComparison.Ordinal), out var dl)) - { - return dl; - } - } - - return null; - } - - private string? ParsePreviewImage(IDocument document) - { - var mainImage = document.QuerySelector("#ctl00_MainContent_Image1") ?? document.QuerySelector(".screenshot img") ?? document.QuerySelector("img[src*='preview']"); - if (mainImage != null) - { - var src = mainImage.GetAttribute("src"); - if (!string.IsNullOrEmpty(src)) - { - return src.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? src - : new Uri(new Uri("https://www.cnclabs.com"), src).ToString(); - } - } - - return null; - } - - private List ParseTags(string docText) - { - var tags = new List(); - var taggedAsIdx = docText.IndexOf("Tagged as:", StringComparison.OrdinalIgnoreCase); - if (taggedAsIdx != -1) - { - var tagLineEnd = docText.IndexOf('\n', taggedAsIdx); - if (tagLineEnd == -1) - { - tagLineEnd = docText.Length; - } - - var tagLine = docText[(taggedAsIdx + "Tagged as:".Length)..tagLineEnd].Trim(); - var parts = tagLine.Split(TagSeparator, StringSplitOptions.RemoveEmptyEntries); - foreach (var part in parts) - { - var t = part.Trim(); - if (!string.IsNullOrEmpty(t)) - { - tags.Add(t); - } - } - } - - return tags; - } - /// /// Small immutable record used internally to shuttle minimal map info between parsing and projection. /// @@ -608,78 +707,7 @@ private sealed record MapListItem( { // Logging failure to parse file size, though it's acceptable to return null // and fallback to the display string. - logger.LogWarning("Failed to parse file size '{Size}': {Error}", size, ex.Message); - } - - return null; - } - - private (DateTime? LastUpdated, long? DlCount, string? FSize) ExtractSearchItemMetadata(IElement item) - { - DateTime? lastUpdated = null; - long? dlCount = null; - string? fSize = null; - - var strongs = item.QuerySelectorAll("strong"); - foreach (var s in strongs) - { - var label = s.TextContent?.Trim(); - if (string.IsNullOrEmpty(label)) - { - continue; - } - - var value = CNCLabsHelper.GetNextNonEmptyTextSibling(s); - if (string.IsNullOrEmpty(value)) - { - continue; - } - - if (label.StartsWith("Updated:", StringComparison.OrdinalIgnoreCase) || - label.StartsWith("Added:", StringComparison.OrdinalIgnoreCase) || - label.StartsWith("Date:", StringComparison.OrdinalIgnoreCase) || - label.StartsWith("reviewed:", StringComparison.OrdinalIgnoreCase)) - { - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) - { - lastUpdated = parsed; - } - } - else if (label.StartsWith("Size:", StringComparison.OrdinalIgnoreCase)) - { - fSize = value; - } - else if (label.StartsWith("Downloads:", StringComparison.OrdinalIgnoreCase) && - long.TryParse(value.Replace(",", string.Empty), out var count)) - { - dlCount = count; - } - } - - if (!lastUpdated.HasValue) - { - var match = DateRegex().Match(item.TextContent); - if (match.Success && DateTime.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallbackDate)) - { - lastUpdated = fallbackDate; - } - } - - return (lastUpdated, dlCount, fSize); - } - - private string? ExtractScreenshotUrl(IElement item) - { - var img = item.QuerySelector(".screenshot img") ?? item.QuerySelector("img"); - if (img != null) - { - var src = img.GetAttribute("src"); - if (!string.IsNullOrEmpty(src)) - { - return src.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? src - : new Uri(new Uri("https://www.cnclabs.com"), src).ToString(); - } + logger.LogWarning(ex, "Failed to parse file size '{Size}'", size); } return null; diff --git a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/CsvDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/CsvDiscoverer.cs new file mode 100644 index 000000000..00268099f --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/CsvDiscoverer.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +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.Core.Models.Results.Content; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.ContentDiscoverers; + +/// +/// Discovers base game manifests from CSV catalogs. +/// Supports multi-language discovery for Generals and Zero Hour. +/// +public class CsvDiscoverer( + ILogger logger, + IConfigurationProviderService configProvider, + IHttpClientFactory httpClientFactory) : IContentDiscoverer, IDisposable +{ + private enum CsvCatalogSourceKind + { + IndexJson, + ConfiguredCatalogs, + } + + private sealed record CsvCatalogSource( + CsvCatalogSourceKind Kind, + string Description, + IReadOnlyList? ConfiguredEntries) + { + public static CsvCatalogSource FromIndex(string source) + { + return new CsvCatalogSource(CsvCatalogSourceKind.IndexJson, source, null); + } + + public static CsvCatalogSource FromConfiguredCatalogs(IReadOnlyList entries) + { + return new CsvCatalogSource(CsvCatalogSourceKind.ConfiguredCatalogs, "CsvValidationCatalogs configuration", entries); + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly CsvCatalogConfiguration _config = configProvider?.GetCsvCatalogConfiguration() ?? new CsvCatalogConfiguration(); + private readonly SemaphoreSlim _cacheLock = new(1, 1); + private List? _cachedEntries; + private bool _disposed; + + /// + public string SourceName => CsvConstants.SourceName; + + /// + public string Description => CsvConstants.Description; + + /// + public bool IsEnabled => true; + + /// + public ContentSourceCapabilities Capabilities => ContentSourceCapabilities.DirectSearch; + + /// + public async Task> DiscoverAsync( + ContentSearchQuery query, + CancellationToken cancellationToken = default) + { + if (query == null) + { + return OperationResult.CreateFailure("Search query cannot be null."); + } + + try + { + // If ContentType is specified and NOT GameInstallation, return empty result + // This discoverer only provides base game installations + if (query.ContentType.HasValue && query.ContentType.Value != ContentType.GameInstallation) + { + return OperationResult.CreateSuccess(new ContentDiscoveryResult()); + } + + var entries = await LoadCatalogEntriesAsync(cancellationToken); + if (!TryFilterByGameType(entries, query.TargetGame, out var filteredEntries)) + { + return OperationResult.CreateSuccess(new ContentDiscoveryResult()); + } + + var queryLanguage = ContentSearchQuery.NormalizeLanguage(query.Language); + var results = new List(); + + foreach (var entry in filteredEntries) + { + AddSearchResultsForEntry(results, entry, query.Language, queryLanguage); + } + + return OperationResult.CreateSuccess(new ContentDiscoveryResult + { + Items = results, + TotalItems = results.Count, + HasMoreItems = false, + }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to discover CSV catalogs"); + return OperationResult.CreateFailure($"Discovery failed: {ex.Message}"); + } + } + + /// + /// Disposes the resources used by the instance. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Performs the actual disposal of resources. + /// + /// Indicates whether the method is being called from the Dispose method (true) or from a finalizer (false). + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + _disposed = true; + if (disposing) + { + _cacheLock.Dispose(); + } + } + } + + private static List GetValidCatalogEntries(IEnumerable? entries) + { + return entries? + .Where(e => e != null && e.IsActive && !string.IsNullOrWhiteSpace(e.Url) && !string.IsNullOrWhiteSpace(e.GameType) && !string.IsNullOrWhiteSpace(e.Version)) + .ToList() ?? []; + } + + private static IReadOnlyList GetLanguagesToInclude( + CsvCatalogRegistryEntry entry, + string? rawQueryLanguage, + string queryLanguage) + { + var rawLanguages = entry.SupportedLanguages is { Count: > 0 } + ? entry.SupportedLanguages + : [CsvConstants.AllLanguagesFilter]; + + var normalizedEntryLanguages = rawLanguages + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(ContentSearchQuery.NormalizeLanguage) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (string.IsNullOrWhiteSpace(rawQueryLanguage) || string.Equals(queryLanguage, CsvConstants.AllLanguagesFilter, StringComparison.OrdinalIgnoreCase)) + { + return normalizedEntryLanguages; + } + + if (normalizedEntryLanguages.Any(l => string.Equals(l, CsvConstants.AllLanguagesFilter, StringComparison.OrdinalIgnoreCase))) + { + return [queryLanguage]; + } + + return normalizedEntryLanguages + .Where(l => string.Equals(l, queryLanguage, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + private bool TryFilterByGameType( + IReadOnlyList entries, + GameType? targetGame, + out IReadOnlyList filteredEntries) + { + if (!targetGame.HasValue) + { + filteredEntries = entries; + return true; + } + + string? targetGameStr = targetGame.Value switch + { + GameType.Generals => CsvConstants.GeneralsGameType, + GameType.ZeroHour => CsvConstants.ZeroHourGameType, + _ => null, + }; + + if (targetGameStr is null) + { + logger.LogWarning("Unsupported game type encountered: {GameType}. Returning no results.", targetGame.Value); + filteredEntries = []; + return false; + } + + filteredEntries = entries + .Where(e => e.GameType.Equals(targetGameStr, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return true; + } + + private void AddSearchResultsForEntry( + List results, + CsvCatalogRegistryEntry entry, + string? rawQueryLanguage, + string queryLanguage) + { + var languagesToInclude = GetLanguagesToInclude(entry, rawQueryLanguage, queryLanguage); + + foreach (var language in languagesToInclude) + { + try + { + var result = CreateSearchResult(entry, language); + if (result != null) + { + results.Add(result); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to create search result for entry {Game} {Version} {Language}", entry.GameType, entry.Version, language); + } + } + } + + private async Task> LoadCatalogEntriesAsync(CancellationToken cancellationToken) + { + // Return cached entries if available + var cached = Volatile.Read(ref _cachedEntries); + if (cached != null) + { + return cached; + } + + if (_disposed) + { + return []; + } + + await _cacheLock.WaitAsync(cancellationToken); + try + { + if (_cachedEntries != null) + { + return _cachedEntries; + } + + List loadedEntries = []; + + foreach (var source in GetCatalogSources()) + { + try + { + loadedEntries = source.Kind == CsvCatalogSourceKind.IndexJson + ? await LoadEntriesFromIndexAsync(source.Description, cancellationToken) + : GetValidCatalogEntries(source.ConfiguredEntries); + + if (loadedEntries.Count > 0) + { + logger.LogInformation("Loaded {Count} valid CSV catalog entries from {Source}", loadedEntries.Count, source.Description); + break; + } + + logger.LogWarning("No valid active CSV catalog entries found in {Source}", source.Description); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load CSV catalog entries from {Source}", source.Description); + } + } + + if (loadedEntries.Count > 0) + { + _cachedEntries = loadedEntries; + return _cachedEntries; + } + + return []; + } + finally + { + if (!_disposed) + { + _cacheLock.Release(); + } + } + } + + private IEnumerable GetCatalogSources() + { + var configuredSource = _config.IndexFilePath?.Trim(); + var seenIndexSources = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var indexSource in new[] { configuredSource, CsvConstants.DefaultIndexFileUrl }) + { + if (string.IsNullOrWhiteSpace(indexSource) || !seenIndexSources.Add(indexSource)) + { + continue; + } + + yield return CsvCatalogSource.FromIndex(indexSource); + } + + if (_config.CsvValidationCatalogs is { Count: > 0 }) + { + yield return CsvCatalogSource.FromConfiguredCatalogs(_config.CsvValidationCatalogs); + } + } + + private async Task> LoadEntriesFromIndexAsync(string indexSource, CancellationToken cancellationToken) + { + var json = await LoadIndexJsonAsync(indexSource, cancellationToken); + var index = JsonSerializer.Deserialize(json, JsonOptions); + + if (index?.Entries == null || index.Entries.Count == 0) + { + logger.LogWarning("No CSV catalog entries found in index.json from {Source}", indexSource); + return []; + } + + return GetValidCatalogEntries(index.Entries); + } + + private async Task LoadIndexJsonAsync(string indexPath, CancellationToken cancellationToken) + { + if (Uri.TryCreate(indexPath, UriKind.Absolute, out var indexUri) && + (indexUri.Scheme == Uri.UriSchemeHttp || indexUri.Scheme == Uri.UriSchemeHttps)) + { + var httpClient = httpClientFactory.CreateClient(string.Empty); + return await httpClient.GetStringAsync(indexUri, cancellationToken); + } + + var resolvedPath = Path.IsPathRooted(indexPath) + ? indexPath + : Path.GetFullPath(indexPath); + + return await File.ReadAllTextAsync(resolvedPath, cancellationToken); + } + + private ContentSearchResult? CreateSearchResult(CsvCatalogRegistryEntry entry, string language) + { + if (!Enum.TryParse(entry.GameType, true, out var gameType) || + gameType == GameType.Unknown || + !Enum.IsDefined(gameType)) + { + logger.LogWarning("Invalid game type in catalog entry: {GameType}", entry.GameType); + return null; + } + + var canonicalGameType = gameType switch + { + GameType.Generals => CsvConstants.GeneralsGameType, + GameType.ZeroHour => CsvConstants.ZeroHourGameType, + _ => entry.GameType, + }; + + var contentName = $"{canonicalGameType}-{entry.Version}-{language}"; + + var id = ManifestIdGenerator.GeneratePublisherContentId( + PublisherTypeConstants.CsvRegistry, + ContentType.GameInstallation, + contentName); + + var result = new ContentSearchResult + { + Id = id, + Name = $"{canonicalGameType} {entry.Version} ({language})", + Description = $"Base game installation files for {canonicalGameType} v{entry.Version}. Language: {language}", + Version = entry.Version, + ContentType = ContentType.GameInstallation, + TargetGame = gameType, + ProviderName = SourceName, + RequiresResolution = true, + ResolverId = CsvConstants.ResolverId, + SourceUrl = entry.Url, + DownloadSize = entry.TotalSizeBytes, + }; + + result.ResolverMetadata[CsvConstants.CsvUrlMetadataKey] = entry.Url; + result.ResolverMetadata[CsvConstants.GameTypeMetadataKey] = canonicalGameType; + result.ResolverMetadata[CsvConstants.VersionMetadataKey] = entry.Version; + result.ResolverMetadata[CsvConstants.LanguageMetadataKey] = language; + + if (entry.FileCount.HasValue) + { + result.ResolverMetadata[CsvConstants.FileCountMetadataKey] = entry.FileCount.Value.ToString(); + } + + return result; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs index 1a2a4b154..c1c890038 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs @@ -66,13 +66,19 @@ public ContentOrchestrator( _logger = logger; _providers = [.. providers]; _discoverers = [.. discoverers]; - _resolvers = new ConcurrentDictionary(); + _resolvers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); foreach (var resolver in resolvers) { if (!_resolvers.TryAdd(resolver.ResolverId, resolver)) { _logger.LogWarning("Duplicate ResolverId found: {ResolverId}. Skipping resolver.", resolver.ResolverId); } + + var normalized = resolver.ResolverId.Replace("-", string.Empty).Replace("_", string.Empty); + if (!string.Equals(normalized, resolver.ResolverId, StringComparison.OrdinalIgnoreCase)) + { + _resolvers.TryAdd(normalized, resolver); + } } _cache = cache; @@ -108,7 +114,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 +193,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(); @@ -373,8 +390,12 @@ public async Task> ResolveManifestAsync( if (!_resolvers.TryGetValue(contentSearchResult.ResolverId, out IContentResolver? resolver)) { - return OperationResult.CreateFailure( - $"No resolver found for ResolverId: {contentSearchResult.ResolverId}"); + var normalized = contentSearchResult.ResolverId.Replace("-", string.Empty).Replace("_", string.Empty); + if (!_resolvers.TryGetValue(normalized, out resolver)) + { + return OperationResult.CreateFailure( + $"No resolver found for ResolverId: {contentSearchResult.ResolverId}"); + } } var manifestResult = await resolver.ResolveAsync(contentSearchResult, cancellationToken); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs index dea3d0047..782f1721c 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,12 +17,15 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// AODMaps content provider that orchestrates discovery→resolution→delivery pipeline /// for AODMaps-hosted content. /// +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public class AODMapsContentProvider( IEnumerable discoverers, 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/CsvContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs new file mode 100644 index 000000000..4cf5f4b59 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs @@ -0,0 +1,99 @@ +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.Models.Content; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.ContentDiscoverers; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.ContentProviders; + +/// +/// Content provider that orchestrates discovery→resolution→delivery pipeline +/// for base game installations from verified CSV registries. +/// +public class CsvContentProvider( + IEnumerable discoverers, + IEnumerable resolvers, + IEnumerable deliverers, + ILogger logger, + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) +{ + private readonly IContentDiscoverer _discoverer = discoverers.OfType().FirstOrDefault() + ?? discoverers.FirstOrDefault(d => string.Equals(d.SourceName, CsvConstants.SourceName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("CSV discoverer not found"); + + private readonly IContentResolver _resolver = resolvers.FirstOrDefault(r => + string.Equals(r.ResolverId, CsvConstants.ResolverId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("CSV resolver not found"); + + private readonly IContentDeliverer _deliverer = deliverers.FirstOrDefault(d => + string.Equals(d.SourceName, ContentSourceNames.HttpDeliverer, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("HTTP deliverer not found"); + + /// + public override string SourceName => PublisherTypeConstants.CsvRegistry; + + /// + public override string Description => CsvConstants.Description; + + /// + protected override IContentDiscoverer Discoverer => _discoverer; + + /// + protected override IContentResolver Resolver => _resolver; + + /// + protected override IContentDeliverer Deliverer => _deliverer; + + /// + public override async Task> GetValidatedContentAsync( + string contentId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(contentId)) + { + return OperationResult.CreateFailure("Content ID cannot be null or empty."); + } + + var query = new ContentSearchQuery { SearchTerm = contentId, Take = ContentConstants.SingleResultQueryLimit }; + var searchResult = await SearchAsync(query, cancellationToken); + + if (!searchResult.Success || searchResult.Data == null || !searchResult.Data.Any()) + { + return OperationResult.CreateFailure( + $"Content not found for ID '{contentId}': {searchResult.FirstError ?? "No matching results"}"); + } + + var result = searchResult.Data.FirstOrDefault(r => string.Equals(r.Id, contentId, StringComparison.OrdinalIgnoreCase)); + if (result == null) + { + return OperationResult.CreateFailure( + $"Content not found for ID '{contentId}'."); + } + + var manifest = result.GetData(); + + return manifest != null + ? OperationResult.CreateSuccess(manifest) + : OperationResult.CreateFailure($"Invalid manifest data for content ID '{contentId}'"); + } + + /// + protected override Task> PrepareContentInternalAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + Logger.LogDebug("Preparing CSV catalog content for manifest {ManifestId}", manifest.Id); + return Task.FromResult(OperationResult.CreateSuccess(manifest)); + } +} 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/ContentReconciliationService.cs b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs index e133df3a3..6ac917d3b 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs @@ -470,7 +470,7 @@ private async Task> ReconcileBulkManifestR { try { - var newContentIds = profile.EnabledContentIds! + var newContentIds = profile.EnabledContentIds .Select(id => replacements.TryGetValue(id, out var newManifest) ? newManifest.Id.Value : id) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); @@ -571,7 +571,7 @@ private async Task> ReconcileManifestRemov { try { - var newContentIds = profile.EnabledContentIds! + var newContentIds = profile.EnabledContentIds .Where(id => !id.Equals(manifestId.Value, StringComparison.OrdinalIgnoreCase)) .ToList(); diff --git a/GenHub/GenHub/Features/Content/Services/ContentResolvers/AODMapsResolver.cs b/GenHub/GenHub/Features/Content/Services/ContentResolvers/AODMapsResolver.cs index db153325c..43b6af4a9 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentResolvers/AODMapsResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentResolvers/AODMapsResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading; @@ -12,6 +13,7 @@ using GenHub.Features.Content.Services.Parsers; using GenHub.Features.Content.Services.Publishers; using Microsoft.Extensions.Logging; + using File = GenHub.Core.Models.Parsers.File; using ParsedContentDetails = GenHub.Core.Models.Content.ParsedContentDetails; @@ -21,6 +23,7 @@ namespace GenHub.Features.Content.Services.ContentResolvers; /// Resolves AODMaps content details from discovered content items. /// Uses AODMapsPageParser to parse the page and extracts specific map details. /// +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public class AODMapsResolver( AODMapsPageParser pageParser, AODMapsManifestFactory manifestFactory, diff --git a/GenHub/GenHub/Features/Content/Services/ContentResolvers/CNCLabsMapResolver.cs b/GenHub/GenHub/Features/Content/Services/ContentResolvers/CNCLabsMapResolver.cs index c6b7054d0..1da3be81c 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentResolvers/CNCLabsMapResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentResolvers/CNCLabsMapResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -16,6 +17,7 @@ using GenHub.Features.Content.Services.Helpers; using GenHub.Features.Content.Services.Publishers; using Microsoft.Extensions.Logging; + using File = GenHub.Core.Models.Parsers.File; using ParsedContentDetails = GenHub.Core.Models.Content.ParsedContentDetails; @@ -25,6 +27,7 @@ namespace GenHub.Features.Content.Services.ContentResolvers; /// Resolves CNC Labs map details from discovered content items. /// Parses HTML detail pages and generates content manifests. /// +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public class CNCLabsMapResolver( HttpClient httpClient, CNCLabsManifestFactory manifestFactory, @@ -226,7 +229,7 @@ private async Task ParseMapDetailPageAsync(string html, Ca var screenshots = document.QuerySelectorAll("img.Screenshot") .Select(img => img.GetAttribute("src")) .Where(src => !string.IsNullOrEmpty(src)) - .Select(src => src!.StartsWith("http", StringComparison.OrdinalIgnoreCase) + .Select(src => src.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? src : $"https://www.cnclabs.com{src}") .ToList(); diff --git a/GenHub/GenHub/Features/Content/Services/ContentResolvers/CsvResolver.cs b/GenHub/GenHub/Features/Content/Services/ContentResolvers/CsvResolver.cs new file mode 100644 index 000000000..d91632306 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/ContentResolvers/CsvResolver.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using CsvHelper; +using CsvHelper.Configuration; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.Content; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.ContentResolvers; + +/// +/// Resolves CSV catalog search results into complete content manifests. +/// +public class CsvResolver( + IHttpClientFactory httpClientFactory, + ILogger logger) : IContentResolver +{ + private static readonly CsvConfiguration CsvConfig = new(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + MissingFieldFound = null, + HeaderValidated = null, + BadDataFound = null, + }; + + /// + public string ResolverId => CsvConstants.ResolverId; + + /// + public async Task> ResolveAsync( + ContentSearchResult discoveredItem, + CancellationToken cancellationToken = default) + { + if (discoveredItem == null) + { + return OperationResult.CreateFailure("Discovered content item cannot be null."); + } + + if (string.IsNullOrWhiteSpace(discoveredItem.SourceUrl)) + { + return OperationResult.CreateFailure("Discovered content source URL is missing."); + } + + try + { + logger.LogInformation("Resolving CSV catalog manifest from {SourceUrl}", discoveredItem.SourceUrl); + + var loadResult = await LoadCsvContentAsync(discoveredItem.SourceUrl, cancellationToken); + if (!loadResult.Success || loadResult.Data == null) + { + return OperationResult.CreateFailure(loadResult.Errors); + } + + var gameTypeStr = GetGameTypeString(discoveredItem); + var languageStr = GetLanguageString(discoveredItem); + var version = GetVersionString(discoveredItem); + + var matchingEntries = ParseAndFilterCsv(loadResult.Data, gameTypeStr, languageStr); + if (matchingEntries.Count == 0) + { + logger.LogWarning( + "No matching files found in CSV catalog at {SourceUrl} for game {GameType} and language {Language}", + discoveredItem.SourceUrl, + gameTypeStr, + languageStr); + return OperationResult.CreateFailure( + $"No matching files found in CSV catalog for {gameTypeStr} ({languageStr})."); + } + + var isRemote = Uri.TryCreate(discoveredItem.SourceUrl, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + var manifestFiles = matchingEntries.Select(e => CreateManifestFile(e, isRemote)).ToList(); + var manifest = BuildManifest(discoveredItem, gameTypeStr, version, languageStr, manifestFiles); + + logger.LogInformation( + "Successfully resolved CSV catalog manifest {ManifestId} with {FileCount} files", + manifest.Id.Value, + manifest.Files.Count); + + return OperationResult.CreateSuccess(manifest); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to resolve CSV catalog manifest from {SourceUrl}", discoveredItem.SourceUrl); + return OperationResult.CreateFailure($"Resolution failed: {ex.Message}"); + } + } + + /// + public Task> ResolveAsync( + ProviderDefinition? provider, + ContentSearchResult discoveredItem, + CancellationToken cancellationToken = default) + { + return ResolveAsync(discoveredItem, cancellationToken); + } + + private static string GetGameTypeString(ContentSearchResult item) + { + if (item.ResolverMetadata.TryGetValue(CsvConstants.GameTypeMetadataKey, out var gameType) && !string.IsNullOrWhiteSpace(gameType)) + { + return gameType; + } + + return item.TargetGame switch + { + GameType.Generals => CsvConstants.GeneralsGameType, + GameType.ZeroHour => CsvConstants.ZeroHourGameType, + _ => item.TargetGame != GameType.Unknown ? item.TargetGame.ToString() : string.Empty, + }; + } + + private static string GetLanguageString(ContentSearchResult item) + { + if (item.ResolverMetadata.TryGetValue(CsvConstants.LanguageMetadataKey, out var language) && !string.IsNullOrWhiteSpace(language)) + { + return ContentSearchQuery.NormalizeLanguage(language); + } + + return CsvConstants.AllLanguagesFilter; + } + + private static string GetVersionString(ContentSearchResult item) + { + if (item.ResolverMetadata.TryGetValue(CsvConstants.VersionMetadataKey, out var version) && !string.IsNullOrWhiteSpace(version)) + { + return version; + } + + return !string.IsNullOrWhiteSpace(item.Version) ? item.Version : "1.0"; + } + + private static List ParseAndFilterCsv(string csvContent, string targetGame, string targetLanguage) + { + using var stringReader = new StringReader(csvContent); + using var csvReader = new CsvReader(stringReader, CsvConfig); + + var records = csvReader.GetRecords().ToList(); + var matchingEntries = new List(); + + foreach (var record in records) + { + if (IsUnsafeRelativePath(record.RelativePath)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(targetGame) && + !string.Equals(record.GameType, targetGame, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (MatchesLanguage(record.Language, targetLanguage)) + { + matchingEntries.Add(record); + } + } + + return matchingEntries; + } + + private static bool IsUnsafeRelativePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return true; + } + + if (Path.IsPathRooted(path) || path.StartsWith('/') || path.StartsWith('\\')) + { + return true; + } + + if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':') + { + return true; + } + + return path.Contains("..", StringComparison.Ordinal); + } + + private static bool MatchesLanguage(string? entryLanguage, string targetLanguage) + { + if (string.Equals(targetLanguage, CsvConstants.AllLanguagesFilter, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(entryLanguage) || + string.Equals(entryLanguage, CsvConstants.AllLanguagesFilter, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var normalizedEntryLang = ContentSearchQuery.NormalizeLanguage(entryLanguage); + return string.Equals(normalizedEntryLang, targetLanguage, StringComparison.OrdinalIgnoreCase); + } + + private static ManifestFile CreateManifestFile(CsvCatalogEntry entry, bool isRemote) + { + var hasSha256 = !string.IsNullOrWhiteSpace(entry.Sha256); + var hash = hasSha256 ? entry.Sha256 : string.Empty; + + var hasValidDownloadUrl = isRemote && + !string.IsNullOrWhiteSpace(entry.DownloadUrl) && + Uri.TryCreate(entry.DownloadUrl, UriKind.Absolute, out var url) && + (url.Scheme == Uri.UriSchemeHttp || url.Scheme == Uri.UriSchemeHttps); + + ContentSourceType sourceType; + if (!isRemote) + { + sourceType = ContentSourceType.LocalFile; + } + else if (hasValidDownloadUrl) + { + sourceType = ContentSourceType.RemoteDownload; + } + else + { + sourceType = ContentSourceType.GameInstallation; + } + + return new ManifestFile + { + RelativePath = entry.RelativePath, + Size = entry.Size, + Hash = hash, + SourceType = sourceType, + InstallTarget = ContentInstallTarget.Workspace, + IsRequired = entry.IsRequired, + DownloadUrl = hasValidDownloadUrl ? entry.DownloadUrl : null, + IsExecutable = entry.RelativePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase), + }; + } + + private static GameType ResolveTargetGame(ContentSearchResult discoveredItem, string gameTypeStr) + { + if (discoveredItem.TargetGame != GameType.Unknown) + { + return discoveredItem.TargetGame; + } + + if (Enum.TryParse(gameTypeStr, true, out var gt)) + { + return gt; + } + + return GameType.Unknown; + } + + private static ContentManifest BuildManifest( + ContentSearchResult discoveredItem, + string gameTypeStr, + string version, + string languageStr, + IReadOnlyList files) + { + var targetGame = ResolveTargetGame(discoveredItem, gameTypeStr); + + var contentName = $"{gameTypeStr}-{version}-{languageStr}"; + var manifestId = !string.IsNullOrWhiteSpace(discoveredItem.Id) + ? new ManifestId(discoveredItem.Id) + : new ManifestId(ManifestIdGenerator.GeneratePublisherContentId( + PublisherTypeConstants.CsvRegistry, + ContentType.GameInstallation, + contentName)); + + var manifest = new ContentManifest + { + Id = manifestId, + Name = discoveredItem.Name, + Version = version, + ContentType = ContentType.GameInstallation, + TargetGame = targetGame, + Publisher = new PublisherInfo + { + PublisherType = PublisherTypeConstants.CsvRegistry, + Name = CsvConstants.SourceName, + }, + Metadata = new ContentMetadata + { + Description = discoveredItem.Description ?? string.Empty, + ReleaseDate = DateTime.UtcNow, + }, + OriginalProviderName = CsvConstants.SourceName, + OriginalContentId = discoveredItem.Id, + SourcePath = discoveredItem.SourceUrl, + Files = files.ToList(), + }; + + return manifest; + } + + private async Task> LoadCsvContentAsync(string sourceUrl, CancellationToken cancellationToken) + { + if (Uri.TryCreate(sourceUrl, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + var httpClient = httpClientFactory.CreateClient(string.Empty); + var content = await httpClient.GetStringAsync(uri, cancellationToken); + return OperationResult.CreateSuccess(content); + } + + var resolvedPath = Path.IsPathRooted(sourceUrl) + ? sourceUrl + : Path.GetFullPath(sourceUrl); + + if (!File.Exists(resolvedPath)) + { + return OperationResult.CreateFailure($"CSV file not found at: {resolvedPath}"); + } + + var fileContent = await File.ReadAllTextAsync(resolvedPath, cancellationToken); + return OperationResult.CreateSuccess(fileContent); + } +} 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..ba3eba888 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs @@ -24,8 +24,10 @@ public class GitHubContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, + IArchivePayloadProcessor archiveProcessor) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { /// public override string SourceName => "GitHub"; @@ -105,6 +107,13 @@ protected override async Task> PrepareContentIn // Ensure we have valid data before validation var resultManifest = deliveryResult.Data ?? manifest; + // Process payload archives and normalize directory structure safely + await archiveProcessor.ProcessPayloadAsync( + workingDirectory, + resultManifest.ContentType, + resultManifest.TargetGame, + cancellationToken); + // Validate the delivered content (full validation) // Forward the provider progress reporter to the validator for user-visible progress IProgress? validationProgress = null; 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/Helpers/AODMapsHelper.cs b/GenHub/GenHub/Features/Content/Services/Helpers/AODMapsHelper.cs index 1e71c68b0..c773acc06 100644 --- a/GenHub/GenHub/Features/Content/Services/Helpers/AODMapsHelper.cs +++ b/GenHub/GenHub/Features/Content/Services/Helpers/AODMapsHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; using AngleSharp.Dom; @@ -10,6 +11,7 @@ namespace GenHub.Features.Content.Services.Helpers; /// /// Provides helper methods for AODMaps content processing. /// +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public static partial class AODMapsHelper { /// 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/Parsers/AODMapsPageParser.cs b/GenHub/GenHub/Features/Content/Services/Parsers/AODMapsPageParser.cs index 660423cc4..534b2a950 100644 --- a/GenHub/GenHub/Features/Content/Services/Parsers/AODMapsPageParser.cs +++ b/GenHub/GenHub/Features/Content/Services/Parsers/AODMapsPageParser.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -10,6 +11,7 @@ using GenHub.Core.Interfaces.Tools; using GenHub.Core.Models.Parsers; using Microsoft.Extensions.Logging; + using IDocument = AngleSharp.Dom.IDocument; namespace GenHub.Features.Content.Services.Parsers; @@ -17,6 +19,7 @@ namespace GenHub.Features.Content.Services.Parsers; /// /// Parser for AODMaps pages that extracts map items from gallery pages. /// +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public partial class AODMapsPageParser( IPlaywrightService playwrightService, ILogger logger) : IWebPageParser diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/AODMapsManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/Publishers/AODMapsManifestFactory.cs index 69cc07a7f..a89cfc265 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/AODMapsManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/AODMapsManifestFactory.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -16,6 +17,7 @@ using GenHub.Features.Manifest; using Microsoft.Extensions.Logging; using Slugify; + using ParsedContentDetails = GenHub.Core.Models.Content.ParsedContentDetails; namespace GenHub.Features.Content.Services.Publishers; @@ -23,6 +25,7 @@ namespace GenHub.Features.Content.Services.Publishers; /// /// Factory for creating AODMaps content manifests from parsed content details. /// +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public partial class AODMapsManifestFactory( IContentManifestBuilder manifestBuilder, IManifestIdService manifestIdService, 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/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs index 0f002561b..2dda6d040 100644 --- a/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs @@ -280,7 +280,7 @@ public async Task> ExecuteContentRemovalAs } catch (Exception ex) { - logger.LogWarning("[Orchestrator:{OpId}] Failed to remove manifest {ManifestId} due to exception: {Error}", operationId, id, ex.Message); + logger.LogWarning(ex, "[Orchestrator:{OpId}] Failed to remove manifest {ManifestId}", operationId, id); criticalFailureOccurred = true; } } diff --git a/GenHub/GenHub/Features/Downloads/ViewModels/PublisherCardViewModel.cs b/GenHub/GenHub/Features/Downloads/ViewModels/PublisherCardViewModel.cs index 3657756e1..6d55bf6cb 100644 --- a/GenHub/GenHub/Features/Downloads/ViewModels/PublisherCardViewModel.cs +++ b/GenHub/GenHub/Features/Downloads/ViewModels/PublisherCardViewModel.cs @@ -394,14 +394,7 @@ private static string FormatProgressStatus(GenHub.Core.Models.Content.ContentAcq var itemId = item.Model.Id ?? string.Empty; var itemVersion = item.Version ?? string.Empty; var itemDatePart = ExtractDateFromVersion(itemVersion); - - foreach (var manifest in allManifests) - { - if (IsManifestVariantMatch(item, manifest, publisherId, itemId, itemVersion, itemDatePart)) - { - variants.Add(manifest); - } - } + variants.AddRange(allManifests.Where(manifest => IsManifestVariantMatch(item, manifest, publisherId, itemId, itemVersion, itemDatePart))); return [.. variants.OrderBy(v => v.Name)]; } @@ -716,7 +709,7 @@ private async Task DownloadContentAsync(ContentItemViewModel item) var installedVersion = result.Data.Version; var publisherType = result.Data.Publisher?.PublisherType; - var allManifests = await _manifestPool.GetAllManifestsAsync(); + var allManifests = await _manifestPool.GetAllManifestsAsync(_cts.Token); if (allManifests.Success && allManifests.Data != null) { // Find all GameClient manifests with matching version and publisher @@ -733,7 +726,7 @@ private async Task DownloadContentAsync(ContentItemViewModel item) foreach (var m in justInstalledGameClients) { - var profileResult = await _profileService.CreateProfileFromManifestAsync(m); + var profileResult = await _profileService.CreateProfileFromManifestAsync(m, _cts.Token); if (profileResult.Success) { _logger.LogInformation( diff --git a/GenHub/GenHub/Features/Downloads/Views/DownloadsView.axaml b/GenHub/GenHub/Features/Downloads/Views/DownloadsView.axaml index e3c2b15bf..dcc1717b0 100644 --- a/GenHub/GenHub/Features/Downloads/Views/DownloadsView.axaml +++ b/GenHub/GenHub/Features/Downloads/Views/DownloadsView.axaml @@ -4,99 +4,89 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:GenHub.Features.Downloads.ViewModels" xmlns:views="clr-namespace:GenHub.Features.Downloads.Views" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700" x:Class="GenHub.Features.Downloads.Views.DownloadsView" x:DataType="vm:DownloadsViewModel"> - - - - - - - - - - - - - - + + - - - - - - - + + + FontSize="28" + FontWeight="Bold" + Foreground="White" /> + FontSize="14" + Foreground="#9E9EA8" /> + + - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + diff --git a/GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml b/GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml index a0832142b..b06e36d42 100644 --- a/GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml +++ b/GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml @@ -17,39 +17,142 @@ + + + + + + + + + + + + + + @@ -58,15 +161,15 @@ - + @@ -90,11 +193,8 @@ @@ -111,7 +211,7 @@ - + @@ -138,7 +238,7 @@ @@ -148,8 +248,10 @@ - @@ -157,76 +259,79 @@ + Foreground="#A0A0B0" /> - + - - - - - - - - - - - @@ -319,7 +422,7 @@ + Padding="10,5"> @@ -338,7 +441,7 @@ - + @@ -348,7 +451,6 @@ @@ -419,12 +520,14 @@ - diff --git a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs index c8d3db0d6..d3fab309c 100644 --- a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs +++ b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs @@ -138,7 +138,7 @@ public async Task> ScanDirectoryForGameClientsAsync( var gameClients = new List(); // Search for all possible executable names using manual recursion to skip excluded directories - var allFiles = await Task.Run(() => FindGameExecutablesRecursively(path)); + var allFiles = await Task.Run(() => FindGameExecutablesRecursively(path), cancellationToken); foreach (var exe in allFiles) { diff --git a/GenHub/GenHub/Features/GameInstallations/GameInstallationService.cs b/GenHub/GenHub/Features/GameInstallations/GameInstallationService.cs index d082561d8..e8b79a7b5 100644 --- a/GenHub/GenHub/Features/GameInstallations/GameInstallationService.cs +++ b/GenHub/GenHub/Features/GameInstallations/GameInstallationService.cs @@ -109,7 +109,7 @@ public void InvalidateCache() try { _cachedInstallations = null; - logger!.LogInformation("Installation cache invalidated"); + logger.LogInformation("Installation cache invalidated"); } finally { @@ -140,11 +140,11 @@ public async Task> AddInstallationToCacheAsync( // Check if installation already exists (by ID or path) var existing = installationsList.FirstOrDefault(i => i.Id == installation.Id || - i.InstallationPath.Equals(installation.InstallationPath, StringComparison.OrdinalIgnoreCase)); + PathHelper.AreSamePath(i.InstallationPath, installation.InstallationPath)); if (existing != null) { - logger!.LogDebug( + logger.LogDebug( "Installation already exists in cache: {Path}", installation.InstallationPath); return OperationResult.CreateSuccess(true); @@ -156,7 +156,7 @@ public async Task> AddInstallationToCacheAsync( // Update cache with new ReadOnlyCollection _cachedInstallations = installationsList.AsReadOnly(); - logger!.LogInformation( + logger.LogInformation( "Added installation to cache: {InstallationType} at {Path} (ID: {Id})", installation.InstallationType, installation.InstallationPath, @@ -171,7 +171,7 @@ public async Task> AddInstallationToCacheAsync( } catch (Exception ex) { - logger!.LogError(ex, "Error adding installation to cache"); + logger.LogError(ex, "Error adding installation to cache"); return OperationResult.CreateFailure($"Failed to add installation to cache: {ex.Message}"); } } @@ -179,7 +179,7 @@ public async Task> AddInstallationToCacheAsync( /// public async Task CreateAndRegisterInstallationManifestsAsync(GameInstallation installation, CancellationToken cancellationToken = default) { - logger!.LogInformation( + logger.LogInformation( "[MANIFEST-GEN] CreateAndRegisterInstallationManifestsAsync called for {InstallationType} at {Path}", installation.InstallationType, installation.InstallationPath); @@ -187,13 +187,13 @@ public async Task CreateAndRegisterInstallationManifestsAsync(GameInstallation i var gameDir = installation.InstallationPath; if (!Directory.Exists(gameDir)) { - logger!.LogWarning( + logger.LogWarning( "[MANIFEST-GEN] Installation directory does not exist: {Path}", gameDir); return; } - logger!.LogInformation( + logger.LogInformation( "[MANIFEST-GEN] Installation check: HasGenerals={HasGenerals}, GeneralsPath={GeneralsPath}, HasZeroHour={HasZeroHour}, ZeroHourPath={ZeroHourPath}", installation.HasGenerals, installation.GeneralsPath ?? "null", @@ -202,7 +202,7 @@ public async Task CreateAndRegisterInstallationManifestsAsync(GameInstallation i if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath) && Directory.Exists(installation.GeneralsPath)) { - logger!.LogInformation( + logger.LogInformation( "[MANIFEST-GEN] Creating Generals manifest for {Path}", installation.GeneralsPath); @@ -225,7 +225,7 @@ await GenerateAndPoolManifestForGameTypeAsync( } else { - logger!.LogWarning( + logger.LogWarning( "[MANIFEST-GEN] Skipping Generals manifest: HasGenerals={HasGenerals}, PathEmpty={PathEmpty}, PathExists={PathExists}", installation.HasGenerals, string.IsNullOrEmpty(installation.GeneralsPath), @@ -234,7 +234,7 @@ await GenerateAndPoolManifestForGameTypeAsync( if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath) && Directory.Exists(installation.ZeroHourPath)) { - logger!.LogInformation( + logger.LogInformation( "[MANIFEST-GEN] Creating ZeroHour manifest for {Path}", installation.ZeroHourPath); @@ -255,14 +255,14 @@ await GenerateAndPoolManifestForGameTypeAsync( } else { - logger!.LogWarning( + logger.LogWarning( "[MANIFEST-GEN] Skipping ZeroHour manifest: HasZeroHour={HasZeroHour}, PathEmpty={PathEmpty}, PathExists={PathExists}", installation.HasZeroHour, string.IsNullOrEmpty(installation.ZeroHourPath), installation.ZeroHourPath != null && Directory.Exists(installation.ZeroHourPath)); } - logger!.LogInformation( + logger.LogInformation( "[MANIFEST-GEN] Completed manifest generation for {InstallationType}", installation.InstallationType); } @@ -324,6 +324,40 @@ private static GameInstallationType ExtractInstallationTypeFromManifestId(Manife return GameInstallationType.Unknown; } + private static bool ContainsGeneralsManifest(IEnumerable manifests) => + manifests.Any(m => + m.Id.Value.Contains(".gameinstallation.generals", StringComparison.OrdinalIgnoreCase) || + (!m.Id.Value.Contains(".gameinstallation.zerohour", StringComparison.OrdinalIgnoreCase) && m.TargetGame == GameType.Generals)); + + private static bool ContainsZeroHourManifest(IEnumerable manifests) => + manifests.Any(m => + m.Id.Value.Contains(".gameinstallation.zerohour", StringComparison.OrdinalIgnoreCase) || + (!m.Id.Value.Contains(".gameinstallation.generals", StringComparison.OrdinalIgnoreCase) && m.TargetGame == GameType.ZeroHour)); + + private static GameInstallation ReconstructInstallationFromManifests( + string sourcePath, + IReadOnlyList manifests) + { + var firstManifest = manifests[0]; + var installationType = ExtractInstallationTypeFromManifestId(firstManifest.Id); + + var installation = new GameInstallation(sourcePath, installationType) + { + Id = Guid.NewGuid().ToString(), + DetectedAt = DateTime.UtcNow, + }; + + var hasGeneralsManifest = ContainsGeneralsManifest(manifests); + var hasZeroHourManifest = ContainsZeroHourManifest(manifests); + + installation.SetPaths( + hasGeneralsManifest ? sourcePath : null, + hasZeroHourManifest ? sourcePath : null); + + installation.Fetch(); + return installation; + } + /// /// Attempts to load game clients from existing manifests in the pool. /// @@ -353,14 +387,14 @@ private async Task> TryLoadGameClientsFromManifestsAsync( if (generalsClient != null) { clients.Add(generalsClient); - logger!.LogDebug( + logger.LogDebug( "Loaded Generals client from manifest for installation {Id}", installation.Id); } else { needsDetection = true; - logger!.LogDebug( + logger.LogDebug( "No manifest found for Generals in installation {Id} - will trigger detection", installation.Id); } @@ -378,23 +412,31 @@ private async Task> TryLoadGameClientsFromManifestsAsync( if (zeroHourClient != null) { clients.Add(zeroHourClient); - logger!.LogDebug( + logger.LogDebug( "Loaded ZeroHour client from manifest for installation {Id}", installation.Id); } else { needsDetection = true; - logger!.LogDebug( + logger.LogDebug( "No manifest found for ZeroHour in installation {Id} - will trigger detection", installation.Id); } } + if (clients.Count == 0 && Directory.Exists(installation.InstallationPath)) + { + needsDetection = true; + logger.LogDebug( + "No clients loaded from manifests for installation {Id} - will trigger detection", + installation.Id); + } + if (clients.Count > 0) { installation.PopulateGameClients(clients); - logger!.LogInformation( + logger.LogInformation( "Populated {Count} clients from manifests for installation {Id}", clients.Count, installation.Id); @@ -409,14 +451,14 @@ private async Task> TryLoadGameClientsFromManifestsAsync( if (installationsNeedingDetection.Count > 0) { - logger!.LogInformation( + logger.LogInformation( "{NeedDetectionCount} of {TotalCount} installations need game client detection", installationsNeedingDetection.Count, installations.Count); } else { - logger!.LogInformation( + logger.LogInformation( "All {TotalCount} installations loaded from existing manifests - no detection needed", installations.Count); } @@ -438,6 +480,11 @@ private async Task> TryLoadGameClientsFromManifestsAsync( string gamePath, CancellationToken cancellationToken) { + if (contentManifestPool is null) + { + return null; + } + try { // Search for ANY GameInstallation manifest matching this installation type and game type @@ -449,11 +496,11 @@ private async Task> TryLoadGameClientsFromManifestsAsync( Take = 100, // Get all matching manifests }; - var searchResult = await contentManifestPool!.SearchManifestsAsync(searchQuery, cancellationToken); + var searchResult = await contentManifestPool.SearchManifestsAsync(searchQuery, cancellationToken); if (!searchResult.Success || searchResult.Data == null) { - logger!.LogDebug( + logger.LogDebug( "No manifests found when searching for {GameType} in installation {Id}", gameType, installation.Id); @@ -498,7 +545,7 @@ private async Task> TryLoadGameClientsFromManifestsAsync( Version = matchingManifest.Version, }; - logger!.LogInformation( + logger.LogInformation( "Loaded {GameType} client from existing manifest {ManifestId} using ClientId {ClientId} (version {Version})", gameType, matchingManifest.Id, @@ -508,7 +555,7 @@ private async Task> TryLoadGameClientsFromManifestsAsync( return gameClient; } - logger!.LogDebug( + logger.LogDebug( "No existing manifest found for {InstallType} {GameType} in installation {Id}", installTypeString, gameType, @@ -518,7 +565,7 @@ private async Task> TryLoadGameClientsFromManifestsAsync( } catch (Exception ex) { - logger!.LogWarning( + logger.LogWarning( ex, "Error loading game client from manifest for {GameType} in installation {Id}", gameType, @@ -562,27 +609,31 @@ private async Task GenerateAndPoolManifestForGameTypeAsync( versionForManifest = detectedVersion; } + if (contentManifestPool is null || manifestGenerationService is null) + { + return; + } + var idResult = ManifestIdGenerator.GenerateGameInstallationId( installation, gameType, versionForId); var manifestId = ManifestId.Create(idResult); - var existingManifest = await contentManifestPool!.GetManifestAsync( + var existingManifest = await contentManifestPool.GetManifestAsync( manifestId, cancellationToken); if (existingManifest.Success && existingManifest.Data != null) { - logger!.LogDebug( + logger.LogDebug( "Manifest {Id} already exists in pool, skipping generation", manifestId); return; } - var manifestBuilder = await manifestGenerationService! - .CreateGameInstallationManifestAsync( - gamePath, - gameType, - installation.InstallationType, - versionForManifest); + var manifestBuilder = await manifestGenerationService.CreateGameInstallationManifestAsync( + gamePath, + gameType, + installation.InstallationType, + versionForManifest); var manifest = manifestBuilder.Build(); manifest.ContentType = ContentType.GameInstallation; @@ -591,7 +642,7 @@ private async Task GenerateAndPoolManifestForGameTypeAsync( // Store the installation path in metadata for persistence across sessions manifest.Metadata.SourcePath = installation.InstallationPath; - var addResult = await contentManifestPool!.AddManifestAsync( + var addResult = await contentManifestPool.AddManifestAsync( manifest, gamePath, null, cancellationToken); if (addResult.Success) @@ -609,7 +660,7 @@ private async Task GenerateAndPoolManifestForGameTypeAsync( normalizedVersion); } - logger!.LogInformation( + logger.LogInformation( "Pooled GameInstallation manifest {Id} for {InstallationId} ({GameType})", manifestId, installation.Id, @@ -617,7 +668,7 @@ private async Task GenerateAndPoolManifestForGameTypeAsync( } else { - logger!.LogWarning( + logger.LogWarning( "Failed to pool {GameType} GameInstallation manifest for {InstallationId}: {Errors}", gameType, installation.Id, @@ -651,11 +702,11 @@ private async Task> LoadInstallationsFromManifestsAsync(C var searchResult = await contentManifestPool.SearchManifestsAsync(searchQuery, cancellationToken); if (!searchResult.Success || searchResult.Data == null || !searchResult.Data.Any()) { - logger!.LogDebug("No GameInstallation manifests found in pool"); + logger.LogDebug("No GameInstallation manifests found in pool"); return []; } - logger!.LogInformation( + logger.LogInformation( "Found {Count} GameInstallation manifests, reconstructing installations", searchResult.Data.Count()); @@ -666,47 +717,44 @@ private async Task> LoadInstallationsFromManifestsAsync(C var hasPath = !string.IsNullOrEmpty(m.Metadata.SourcePath); if (!hasPath) { - logger!.LogDebug("Skipping manifest {Id} - no SourcePath in metadata", m.Id); + logger.LogDebug("Skipping manifest {Id} - no SourcePath in metadata", m.Id); } return hasPath; }) - .GroupBy(m => m.Metadata.SourcePath!, StringComparer.OrdinalIgnoreCase); + .GroupBy(m => m.Metadata.SourcePath, PathHelper.PathComparer); foreach (var group in manifestsByPath) { var sourcePath = group.Key; - - // Determine installation type from the first manifest ID - var firstManifest = group.First(); - var installationType = ExtractInstallationTypeFromManifestId(firstManifest.Id); - - // Create GameInstallation object - var installation = new GameInstallation(sourcePath, installationType) + if (string.IsNullOrEmpty(sourcePath)) { - Id = Guid.NewGuid().ToString(), // Generate new ID - DetectedAt = DateTime.UtcNow, - }; + continue; + } - // Populate Generals/ZeroHour paths - installation.Fetch(); + var groupManifests = group.ToList(); + if (groupManifests.Count == 0) + { + continue; + } + var installation = ReconstructInstallationFromManifests(sourcePath, groupManifests); installations.Add(installation); - logger!.LogInformation( + logger.LogInformation( "Reconstructed {InstallationType} installation from manifests: {Path}", - installationType, + installation.InstallationType, sourcePath); } - logger!.LogInformation( + logger.LogInformation( "Loaded {Count} installations from {ManifestCount} manifests", installations.Count, searchResult.Data.Count()); } catch (Exception ex) { - logger!.LogError( + logger.LogError( ex, "Error loading installations from manifests"); } @@ -723,14 +771,14 @@ private async Task> TryInitializeCacheAsync(CancellationTo { if (_cachedInstallations != null) { - logger!.LogInformation( + logger.LogInformation( "[DIAGNOSTIC] TryInitializeCacheAsync: Cache already initialized with {Count} installations", _cachedInstallations.Count); return OperationResult.CreateSuccess(true); } - logger!.LogInformation( + logger.LogInformation( "[DIAGNOSTIC] TryInitializeCacheAsync: Cache is null, initializing via auto-detection and manual installations"); await _cacheLock.WaitAsync(cancellationToken); @@ -784,7 +832,7 @@ private async Task> TryInitializeCacheAsync(CancellationTo foreach (var manifestInstall in manifestInstallations) { var existingByPath = installations.FirstOrDefault(i => - i.InstallationPath.Equals(manifestInstall.InstallationPath, StringComparison.OrdinalIgnoreCase)); + PathHelper.AreSamePath(i.InstallationPath, manifestInstall.InstallationPath)); if (existingByPath == null) { @@ -883,7 +931,7 @@ private async Task PopulateGameClientsAndManifestsAsync(List i if (manifestGenerationService == null || contentManifestPool == null) { - logger!.LogDebug("Manifest generation skipped: services not available"); + logger.LogDebug("Manifest generation skipped: services not available"); return; } @@ -894,7 +942,7 @@ private async Task PopulateGameClientsAndManifestsAsync(List i if (installationsNeedingDetection.Count > 0) { // Run game client detection ONLY for installations without manifests - logger!.LogInformation( + logger.LogInformation( "Running game client detection for {Count} installations (out of {Total} total)", installationsNeedingDetection.Count, installations.Count); @@ -905,7 +953,7 @@ private async Task PopulateGameClientsAndManifestsAsync(List i if (!clientResult.Success) { - logger!.LogWarning("Client detection failed: {Errors}", string.Join(", ", clientResult.Errors)); + logger.LogWarning("Client detection failed: {Errors}", string.Join(", ", clientResult.Errors)); return; } @@ -914,7 +962,7 @@ private async Task PopulateGameClientsAndManifestsAsync(List i { var installationClients = clientsByInstallation.FirstOrDefault(g => g.Key == installation.Id)?.ToList() ?? Enumerable.Empty(); installation.PopulateGameClients(installationClients); - logger!.LogInformation( + logger.LogInformation( "Populated {ClientCount} clients for installation {Id} via detection", installationClients.Count(), installation.Id); @@ -949,10 +997,10 @@ private async Task CreateAndRegisterSingleInstallationManifestAsync( var baseGameClient = installation.AvailableGameClients .FirstOrDefault(c => c.GameType == gameType && !c.IsPublisherClient); - if (baseGameClient == null) + if (baseGameClient == null || contentManifestPool == null || manifestGenerationService == null) { - logger!.LogWarning( - "No base game client found for {GameType} in installation {InstallationId}, skipping GameInstallation manifest creation", + logger.LogWarning( + "No base game client found or manifest services unavailable for {GameType} in installation {InstallationId}, skipping GameInstallation manifest creation", gameType, installation.Id); return; @@ -971,7 +1019,7 @@ private async Task CreateAndRegisterSingleInstallationManifestAsync( } // Create the GameInstallation manifest - var manifestBuilder = await manifestGenerationService!.CreateGameInstallationManifestAsync( + var manifestBuilder = await manifestGenerationService.CreateGameInstallationManifestAsync( installationPath, gameType, installation.InstallationType, @@ -980,11 +1028,11 @@ private async Task CreateAndRegisterSingleInstallationManifestAsync( var manifest = manifestBuilder.Build(); // Register the manifest to the pool - var addResult = await contentManifestPool!.AddManifestAsync(manifest, installationPath, null, cancellationToken); + var addResult = await contentManifestPool.AddManifestAsync(manifest, installationPath, null, cancellationToken); if (addResult.Success) { - logger!.LogInformation( + logger.LogInformation( "Registered GameInstallation manifest {ManifestId} for {GameType} in installation {InstallationId}", manifest.Id, gameType, @@ -992,7 +1040,7 @@ private async Task CreateAndRegisterSingleInstallationManifestAsync( } else { - logger!.LogWarning( + logger.LogWarning( "Failed to register GameInstallation manifest for {GameType} in installation {InstallationId}: {Errors}", gameType, installation.Id, @@ -1001,7 +1049,7 @@ private async Task CreateAndRegisterSingleInstallationManifestAsync( } catch (Exception ex) { - logger!.LogError( + logger.LogError( ex, "Error creating GameInstallation manifest for {GameType} in installation {InstallationId}", gameType, diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index b62d5aeef..d7eba6242 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 await HandleImmediateProcessExitAsync(process, configuration, launcherStartTime, capturedErrors, cancellationToken); } } @@ -210,7 +215,7 @@ public async Task> TerminateProcessAsync(int processId, Ca catch (InvalidOperationException ex) { // Process already exited - logger.LogInformation("[Terminate] Process {ProcessId} already exited: {Message}", processId, ex.Message); + logger.LogInformation(ex, "[Terminate] Process {ProcessId} already exited", processId); } catch (System.ComponentModel.Win32Exception ex) { @@ -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) @@ -770,11 +793,15 @@ private ProcessStartInfo ConfigureProcessStartInfo(GameLaunchConfiguration confi private async Task> HandleImmediateProcessExitAsync( Process process, GameLaunchConfiguration configuration, - BoundedErrorBuffer capturedErrors) + DateTime? launcherStartTime, + BoundedErrorBuffer capturedErrors, + CancellationToken cancellationToken) { - 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 (process.ExitCode == ProcessConstants.ExitCodeSuccess) { logger.LogInformation( "[Process] Launcher process {ProcessId} exited with code 0 - attempting to find spawned game process", @@ -784,56 +811,113 @@ private async Task> HandleImmediateProcessExitA ? configuration.ExpectedChildProcessName : Path.GetFileNameWithoutExtension(configuration.ExecutablePath); - DateTime? launcherStartTime = null; - try + var spawnedProcess = await PollForSpawnedGameProcessAsync(configuration, executableName, launcherStartTime, cancellationToken); + if (spawnedProcess != null) { - launcherStartTime = process.StartTime.ToUniversalTime(); + var spawnedProcessInfo = AdoptSpawnedProcess(process, spawnedProcess, configuration, executableName); + return OperationResult.CreateSuccess(spawnedProcessInfo); } - catch (Exception ex) + } + + return HandleFailedProcessExit(process, capturedErrors); + } + + private async Task PollForSpawnedGameProcessAsync( + GameLaunchConfiguration configuration, + string executableName, + DateTime? launcherStartTime, + CancellationToken cancellationToken) + { + var workingDir = configuration.WorkingDirectory ?? Path.GetDirectoryName(configuration.ExecutablePath) ?? string.Empty; + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(ProcessConstants.LauncherExitGracePeriodMs); + + Process? spawnedProcess = null; + while (!cancellationToken.IsCancellationRequested && DateTime.UtcNow < deadline) + { + spawnedProcess = FindAdoptableGameProcess(executableName, workingDir, launcherStartTime); + if (spawnedProcess != null) { - logger.LogDebug(ex, "[Process] Unable to inspect start time for exiting launcher {ProcessId}", process.Id); + break; } - var spawnedProcess = FindSpawnedGameProcess( - executableName, - configuration.WorkingDirectory ?? Path.GetDirectoryName(configuration.ExecutablePath)!, - launcherStartTime); + await Task.Delay(ProcessConstants.SpawnedChildPollIntervalMs, cancellationToken); + } + if (cancellationToken.IsCancellationRequested) + { if (spawnedProcess != null) { - logger.LogInformation( - "[Process] Found spawned game process {ProcessId} for executable {ExecutableName}", - spawnedProcess.Id, - executableName); + CleanupSpawnedProcessUponCancellation(spawnedProcess); + } - process.Dispose(); + cancellationToken.ThrowIfCancellationRequested(); + } - _managedProcesses[spawnedProcess.Id] = spawnedProcess; + return spawnedProcess; + } - try - { - spawnedProcess.EnableRaisingEvents = true; - spawnedProcess.Exited += OnProcessExited; - } - catch (Exception ex) + private void CleanupSpawnedProcessUponCancellation(Process spawnedProcess) + { + _ = Task.Run(() => + { + try + { + if (!spawnedProcess.HasExited) { - logger.LogWarning(ex, "Failed to enable raising events for spawned process {ProcessId}", spawnedProcess.Id); + spawnedProcess.Kill(entireProcessTree: true); } + } + catch (Exception ex) + { + logger.LogDebug(ex, "[Process] Ignored exception while terminating adopted process upon cancellation"); + } + finally + { + spawnedProcess.Dispose(); + } + }); + } - var spawnedProcessInfo = BuildProcessInfo(spawnedProcess, configuration.ExecutablePath); + private GameProcessInfo AdoptSpawnedProcess( + Process launcherProcess, + Process spawnedProcess, + GameLaunchConfiguration configuration, + string executableName) + { + logger.LogInformation( + "[Process] Found spawned game process {ProcessId} for executable {ExecutableName}", + spawnedProcess.Id, + executableName); - logger.LogInformation("Started game process {ProcessId} for executable {ExecutablePath}", spawnedProcess.Id, configuration.ExecutablePath); - return OperationResult.CreateSuccess(spawnedProcessInfo); - } + launcherProcess.Dispose(); + _managedProcesses[spawnedProcess.Id] = spawnedProcess; + + try + { + spawnedProcess.EnableRaisingEvents = true; + spawnedProcess.Exited += OnProcessExited; } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to enable raising events for spawned process {ProcessId}", spawnedProcess.Id); + } + + var spawnedProcessInfo = BuildProcessInfo(spawnedProcess, configuration.ExecutablePath); + logger.LogInformation("Started game process {ProcessId} for executable {ExecutablePath}", spawnedProcess.Id, configuration.ExecutablePath); + return spawnedProcessInfo; + } + private OperationResult HandleFailedProcessExit( + Process process, + BoundedErrorBuffer capturedErrors) + { + var exitCode = process.ExitCode; logger.LogWarning("Process {ProcessId} exited immediately with code {ExitCode}", process.Id, exitCode); DrainStandardError(process, capturedErrors); process.Dispose(); var stderrTail = capturedErrors.ToString(); - if (exitCode != 0) { var detail = string.IsNullOrWhiteSpace(stderrTail) @@ -850,7 +934,6 @@ private async Task> HandleImmediateProcessExitA } var suffix = string.IsNullOrWhiteSpace(stderrTail) ? string.Empty : $" {stderrTail}"; - logger.LogError( "[Process] Process exited immediately with code 0 and no spawned process was found. Output: {Output}", string.IsNullOrWhiteSpace(stderrTail) ? "No output was captured." : stderrTail); @@ -899,6 +982,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,37 +993,48 @@ private async Task> AdoptExpectedChildProcessAs Process launcher, GameLaunchConfiguration configuration, string workingDirectory, + DateTime? launcherStartTime, BoundedErrorBuffer capturedErrors, CancellationToken cancellationToken) { - var expectedName = configuration.ExpectedChildProcessName!; + var expectedName = configuration.ExpectedChildProcessName; var timeout = configuration.ExpectedChildDiscoveryTimeout ?? TimeSpan.FromMilliseconds(ProcessConstants.SpawnedChildDiscoveryTimeoutMs); var deadline = DateTime.UtcNow + timeout; 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 +1217,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 +1293,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/GameProfileManager.cs b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs index 215e83db3..329907b24 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs @@ -55,7 +55,7 @@ public async Task> CreateProfileAsync(Create { // Validate Tool profile content configuration var validationError = await Core.Helpers.ToolProfileHelper.ValidateToolProfileContentAsync( - request.EnabledContentIds!, + request.EnabledContentIds, manifestPool, cancellationToken); @@ -65,7 +65,7 @@ public async Task> CreateProfileAsync(Create } // Set toolContentId to the single ModdingTool content ID - toolContentId = request.EnabledContentIds!.First(); + toolContentId = request.EnabledContentIds.First(); logger.LogInformation( "Detected Tool profile creation for tool: {ToolContentId}", diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs index 2ffca7e4c..1e2f34104 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. /// @@ -1571,8 +1608,8 @@ private void ValidateDependencyConflicts( // First try to match by both game type AND installation path (most specific match) var exactPathMatches = allInstallationsResult.Data .Where(inst => - ((profile.GameClient?.GameType == Core.Models.Enums.GameType.Generals && inst.HasGenerals && !string.IsNullOrEmpty(inst.GeneralsPath) && inst.GeneralsPath.Equals(profile.GameClient?.WorkingDirectory, StringComparison.OrdinalIgnoreCase)) || - (profile.GameClient?.GameType == Core.Models.Enums.GameType.ZeroHour && inst.HasZeroHour && !string.IsNullOrEmpty(inst.ZeroHourPath) && inst.ZeroHourPath.Equals(profile.GameClient?.WorkingDirectory, StringComparison.OrdinalIgnoreCase)))) + ((profile.GameClient?.GameType == Core.Models.Enums.GameType.Generals && inst.HasGenerals && !string.IsNullOrEmpty(inst.GeneralsPath) && !string.IsNullOrEmpty(profile.GameClient?.WorkingDirectory) && PathHelper.AreSamePath(inst.GeneralsPath, profile.GameClient.WorkingDirectory)) || + (profile.GameClient?.GameType == Core.Models.Enums.GameType.ZeroHour && inst.HasZeroHour && !string.IsNullOrEmpty(inst.ZeroHourPath) && !string.IsNullOrEmpty(profile.GameClient?.WorkingDirectory) && PathHelper.AreSamePath(inst.ZeroHourPath, profile.GameClient.WorkingDirectory)))) .ToList(); if (exactPathMatches.Count == 1) @@ -1778,7 +1815,7 @@ private WorkspaceStrategy ResolveSupportedWorkspaceStrategy(WorkspaceStrategy st return null; } - foreach (var idString in profile.EnabledContentIds!) + foreach (var idString in profile.EnabledContentIds) { if (!ManifestId.TryCreate(idString, out var id)) { 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..cbc256796 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; @@ -275,7 +287,8 @@ public async Task LoadFromManifestAsync(ContentDisplayItem item) // Retrieve content from CAS to staging var result = await contentStorageService.RetrieveContentAsync( Core.Models.Manifest.ManifestId.Create(_originalManifestId), - _stagingPath); + _stagingPath, + _cts?.Token ?? CancellationToken.None); if (result.Success) { @@ -346,7 +359,7 @@ public async Task ImportContentAsync(string path) var extension = Path.GetExtension(path); if (extension.Equals(".zip", StringComparison.OrdinalIgnoreCase)) { - await Task.Run(() => ZipFile.ExtractToDirectory(path, _stagingPath, true)); + await Task.Run(() => ZipFile.ExtractToDirectory(path, _stagingPath, true), _cts?.Token ?? CancellationToken.None); } else { @@ -369,7 +382,7 @@ public async Task ImportContentAsync(string path) var targetSubDir = Path.Combine(_stagingPath, dirName); logger?.LogDebug("ImportContentAsync: Preserving directory structure. Source: {Source}, Target: {Target}", path, targetSubDir); - await Task.Run(() => CopyDirectory(dirInfo, new DirectoryInfo(targetSubDir))); + await Task.Run(() => CopyDirectory(dirInfo, new DirectoryInfo(targetSubDir)), _cts?.Token ?? CancellationToken.None); } // Auto-organization: If we have .map files at the root level, move them into subdirectories @@ -487,24 +500,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 +710,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 +736,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token) + _cts.Token, + entryPoint) : await localContentService.CreateLocalContentManifestAsync( _stagingPath, ContentName, @@ -660,7 +745,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token); + _cts.Token, + entryPoint); if (result.Success) { @@ -770,6 +856,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,12 +886,29 @@ 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)) { var dirInfo = new DirectoryInfo(_stagingPath); - var items = await Task.Run(() => BuildDirectoryTree(dirInfo)); + var items = await Task.Run(() => BuildDirectoryTree(dirInfo), _cts?.Token ?? CancellationToken.None); foreach (var item in items) { FileTree.Add(item); @@ -791,10 +917,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 +961,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 +987,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..e0c0d118b 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Notifications; @@ -13,6 +14,7 @@ namespace GenHub.Features.GameProfiles.ViewModels; /// A specialized ViewModel for the Add Local Content Demo. /// This bypasses complex service logic and guarantees static mock data is loaded. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public partial class DemoAddLocalContentViewModel : AddLocalContentViewModel { private readonly INotificationService? _notificationService; @@ -147,6 +149,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/DemoGameProfileSettingsViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoGameProfileSettingsViewModel.cs index 31fe7ac04..3ba1a485f 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoGameProfileSettingsViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoGameProfileSettingsViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Input; using GenHub.Core.Interfaces.Common; @@ -20,6 +21,7 @@ namespace GenHub.Features.GameProfiles.ViewModels; /// A specialized ViewModel for the Game Profile Settings Demo. /// This bypasses complex service logic and guarantees static mock data is loaded. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public partial class DemoGameProfileSettingsViewModel : GameProfileSettingsViewModel { /// 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..145e0e2b9 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; @@ -1537,6 +1551,7 @@ private async Task CopyProfile(GameProfileItemViewModel profile) TshScreenEdgeScrollEnabledInWindowedApp = sourceProfile.TshScreenEdgeScrollEnabledInWindowedApp, TshShowMoneyPerMinute = sourceProfile.TshShowMoneyPerMinute, TshSystemTimeFontSize = sourceProfile.TshSystemTimeFontSize, + TshGameWindowTransitionSpeedMultiplier = sourceProfile.TshGameWindowTransitionSpeedMultiplier, // GeneralsOnline Settings GoShowFps = sourceProfile.GoShowFps, diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index ae01bb830..493d49e7a 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -85,7 +85,13 @@ protected virtual async Task LoadAvailableContentAsync() }); } - var coreItems = await _profileContentLoader!.LoadAvailableContentAsync( + if (_profileContentLoader == null) + { + StatusMessage = "Content loader unavailable"; + return; + } + + var coreItems = await _profileContentLoader.LoadAvailableContentAsync( SelectedContentType, new ObservableCollection(coreAvailableInstallations), enabledContentIds); @@ -419,8 +425,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 +703,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 +771,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..4cda6cfe5 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(); @@ -137,7 +137,7 @@ public virtual async Task InitializeForProfileAsync(string profileId) CurrentProfileId = profileId; _logger?.LogInformation("InitializeForProfileAsync called with profileId: {ProfileId}", profileId); - var profileResult = await _gameProfileManager!.GetProfileAsync(profileId); + var profileResult = await _gameProfileManager.GetProfileAsync(profileId); if (!profileResult.Success || profileResult.Data == null) { _logger?.LogWarning("Failed to load profile {ProfileId}: {Errors}", profileId, string.Join(", ", profileResult.Errors)); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs index 38db92e52..e7aaec40f 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 (contentItem is null || !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..a70ab3f9a 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs @@ -1,16 +1,21 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; using System.IO; +using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using GenHub.Common.ViewModels; using GenHub.Core.Constants; using GenHub.Core.Extensions; +using GenHub.Core.Helpers; 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; @@ -22,9 +27,9 @@ namespace GenHub.Features.GameProfiles.ViewModels; public partial class GameSettingsViewModel(IGameSettingsService gameSettingsService, ILogger logger) : ViewModelBase { /// - /// The available texture quality levels. + /// Gets the available texture quality levels. /// - public static readonly TextureQuality[] TextureQualityValues = Enum.GetValues(); + public static IReadOnlyList TextureQualityValues { get; } = Enum.GetValues(); private const TextureQuality MaxTextureQuality = TextureQuality.VeryHigh; // Will be VeryHigh when SH version supports 'very high' texture quality (see TheSuperHackers/GeneralsGameCode#1629) private const int TextureReductionOffset = GameSettingsConstants.TextureQuality.ReductionOffset; @@ -316,32 +321,35 @@ partial void OnStaticGameLODChanged(string value) private bool _tshScreenEdgeScrollEnabledInWindowedApp = GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInWindowedApp; [ObservableProperty] - private int _tshMoneyTransactionVolume = 50; + private int _tshMoneyTransactionVolume = GameSettingsTheSuperHackersConstants.DefaultMoneyTransactionVolume; + + [ObservableProperty] + private float _tshGameWindowTransitionSpeedMultiplier = GameSettingsTheSuperHackersConstants.DefaultGameWindowTransitionSpeedMultiplier; // ===== 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 +431,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 +467,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 @@ -537,6 +551,7 @@ public Core.Models.GameProfile.UpdateProfileRequest GetProfileSettings() TshScreenEdgeScrollEnabledInFullscreenApp = TshScreenEdgeScrollEnabledInFullscreenApp, TshScreenEdgeScrollEnabledInWindowedApp = TshScreenEdgeScrollEnabledInWindowedApp, TshMoneyTransactionVolume = TshMoneyTransactionVolume, + TshGameWindowTransitionSpeedMultiplier = GameSettingsMapper.NormalizeTransitionSpeedMultiplier(TshGameWindowTransitionSpeedMultiplier) ?? GameSettingsTheSuperHackersConstants.DefaultGameWindowTransitionSpeedMultiplier, // GeneralsOnline settings GoShowFps = GoShowFps, @@ -631,6 +646,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 +706,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 +727,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. /// @@ -812,6 +862,10 @@ private void LoadTshSettingsFromProfile(Core.Models.GameProfile.GameProfile prof if (profile.TshScreenEdgeScrollEnabledInFullscreenApp.HasValue) TshScreenEdgeScrollEnabledInFullscreenApp = profile.TshScreenEdgeScrollEnabledInFullscreenApp.Value; if (profile.TshScreenEdgeScrollEnabledInWindowedApp.HasValue) TshScreenEdgeScrollEnabledInWindowedApp = profile.TshScreenEdgeScrollEnabledInWindowedApp.Value; if (profile.TshMoneyTransactionVolume.HasValue) TshMoneyTransactionVolume = profile.TshMoneyTransactionVolume.Value; + if (GameSettingsMapper.NormalizeTransitionSpeedMultiplier(profile.TshGameWindowTransitionSpeedMultiplier) is { } speedVal) + { + TshGameWindowTransitionSpeedMultiplier = speedVal; + } } private void LoadGeneralsOnlineSettingsFromProfile(Core.Models.GameProfile.GameProfile profile) @@ -853,8 +907,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 +934,83 @@ 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 is { Success: true }; + var generalsOnlineWritten = goResult is { Success: true }; + var generalsOnlineBlocked = writeGeneralsOnlineSettings && !generalsOnlineWritten; + + if (optionsSaved) { _currentOptions = options; OptionsFileExists = true; + } + + var optionsErrors = new List(); + if (result is null) + { + optionsErrors.Add("SaveOptions result was null"); + } + else if (result is { Success: false }) + { + optionsErrors.AddRange(result.Errors); + } + + var generalsOnlineErrors = new List(); + if (goLoadError != null) + { + generalsOnlineErrors.Add(goLoadError); + } + + if (goResult is { 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 +1024,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. /// @@ -917,7 +1072,12 @@ private void OpenFileLocation() var directory = System.IO.Path.GetDirectoryName(OptionsFilePath); if (!string.IsNullOrEmpty(directory) && System.IO.Directory.Exists(directory)) { - System.Diagnostics.Process.Start("explorer.exe", directory); + Process.Start(new ProcessStartInfo + { + FileName = PlatformConstants.WindowsExplorerPath, + Arguments = directory, + UseShellExecute = true, + }); _logger.LogInformation("Opened file location {Directory}", directory); } else @@ -1068,6 +1228,14 @@ private void ApplyTshGameplayProperties(Dictionary tsh) if (tsh.TryGetValue("ShowMoneyPerMinute", out var smpm)) TshShowMoneyPerMinute = ParseBool(smpm); if (tsh.TryGetValue("PlayerObserverEnabled", out var poe)) TshPlayerObserverEnabled = ParseBool(poe); if (tsh.TryGetValue("MoneyTransactionVolume", out var mtv) && int.TryParse(mtv, out var mtvVal)) TshMoneyTransactionVolume = mtvVal; + if (tsh.TryGetValue(GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey, out var gwt)) + { + var parsed = GameSettingsMapper.ParseTransitionSpeedMultiplier(gwt); + if (parsed.HasValue) + { + TshGameWindowTransitionSpeedMultiplier = parsed.Value; + } + } } private void ApplyTshUiCursorProperties(Dictionary tsh) @@ -1167,12 +1335,15 @@ private IniOptions CreateOptionsFromViewModel() tshDict["ScreenEdgeScrollEnabledInFullscreenApp"] = BoolToString(TshScreenEdgeScrollEnabledInFullscreenApp); tshDict["ScreenEdgeScrollEnabledInWindowedApp"] = BoolToString(TshScreenEdgeScrollEnabledInWindowedApp); tshDict["MoneyTransactionVolume"] = TshMoneyTransactionVolume.ToString(); + tshDict[GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey] = (GameSettingsMapper.NormalizeTransitionSpeedMultiplier(TshGameWindowTransitionSpeedMultiplier) ?? GameSettingsTheSuperHackersConstants.DefaultGameWindowTransitionSpeedMultiplier).ToString(CultureInfo.InvariantCulture); return options; } private void ApplyGeneralsOnlineSettings(GeneralsOnlineSettings settings) { + settings.EnsureNestedSectionsInitialized(); + GoShowFps = settings.ShowFps; GoShowPing = settings.ShowPing; GoShowPlayerRanks = settings.ShowPlayerRanks; @@ -1199,20 +1370,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 +1414,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..12ae3350a 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:GenHub.Features.GameProfiles.ViewModels" xmlns:conv="clr-namespace:GenHub.Infrastructure.Converters" + xmlns:material="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="550" x:Class="GenHub.Features.GameProfiles.Views.AddLocalContentView" x:DataType="vm:AddLocalContentViewModel" @@ -13,113 +14,137 @@ + - - - - - + - - - + + + - + - + - + - + + Classes="glass"> + + + + + + - + - + - - + + - - + + + + + - + - - + + - + - - - + + + + - + ToolTip.Tip="{Binding Name}"> + + + + + + + + + + + + @@ -193,9 +248,10 @@ - - - + + + + @@ -207,8 +263,8 @@ @@ -218,8 +274,8 @@ - - + + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml.cs index 941a115d4..598e05199 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml.cs @@ -34,6 +34,13 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) InitializeBrowseActions(); } + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + (DataContext as IDisposable)?.Dispose(); + } + /// /// Called when the data context changes. /// diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml index 8823922d1..437ab5873 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml @@ -44,7 +44,9 @@ - + @@ -163,19 +164,19 @@ - - - @@ -208,7 +209,7 @@ - diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml index ec0efcbaa..c64a7e1c9 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml @@ -5,6 +5,7 @@ xmlns:vm="clr-namespace:GenHub.Features.GameProfiles.ViewModels" xmlns:models="clr-namespace:GenHub.Core.Models.Enums;assembly=GenHub.Core" xmlns:conv="clr-namespace:GenHub.Infrastructure.Converters" + xmlns:material="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="600" x:Class="GenHub.Features.GameProfiles.Views.GameProfileContentEditorView" x:DataType="vm:GameProfileSettingsViewModel"> @@ -30,161 +31,133 @@ - - - + - - - - - - - - - - - - - - - + - - + - - - - @@ -197,19 +170,19 @@ + Padding="12,20"> - + @@ -217,9 +190,9 @@ Classes.selected="{Binding SelectedContentEditorCategory, Converter={StaticResource EqualityConverter}, ConverterParameter={x:Static vm:ContentEditorCategory.AvailableContent}}" Command="{Binding SelectContentEditorCategoryCommand}" CommandParameter="{x:Static vm:ContentEditorCategory.AvailableContent}"> - - - + + + @@ -230,35 +203,44 @@ - - + - + - - - - - - + + + FontSize="12.5" Foreground="{DynamicResource TextPrimary}" Opacity="0.9" TextWrapping="Wrap" VerticalAlignment="Center" /> @@ -278,17 +260,14 @@ CommandParameter="{Binding}" ToolTip.Tip="Click to remove from profile"> - + - - + - - - - - + + @@ -300,13 +279,11 @@ Command="{Binding $parent[ItemsControl].((vm:GameProfileSettingsViewModel)DataContext).EditContentCommand}" CommandParameter="{Binding}" IsVisible="{Binding IsEditable}" - Background="Transparent" BorderThickness="0" Padding="8" Margin="0,0,12,0" VerticalAlignment="Center" AutomationProperties.Name="Edit Content" ToolTip.Tip="Edit Content"> - + @@ -317,11 +294,12 @@ - + - + + + + @@ -343,7 +321,7 @@ - + @@ -352,19 +330,23 @@ - - - - diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml.cs index 507907307..fceda504a 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Threading; @@ -17,10 +16,8 @@ namespace GenHub.Features.GameProfiles.Views; public partial class GameProfileContentEditorView : UserControl { private static readonly TimeSpan AnimationDuration = TimeSpan.FromMilliseconds(350); - private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(16); // ~60fps private readonly List<(string Name, Control Control, ContentEditorCategory Category)> _sections = []; - private readonly Stopwatch _animationStopwatch = new(); private ScrollViewer? _scrollViewer; private GameProfileSettingsViewModel? _subscribedViewModel; @@ -30,6 +27,7 @@ public partial class GameProfileContentEditorView : UserControl private DispatcherTimer? _animationTimer; private double _animStartOffset; private double _animTargetOffset; + private DateTime _animStartTime; /// /// Initializes a new instance of the class. @@ -80,6 +78,7 @@ protected override void OnUnloaded(RoutedEventArgs e) if (_scrollViewer != null) { _scrollViewer.ScrollChanged -= OnScrollChanged; + _scrollViewer.PointerWheelChanged -= OnPointerWheelChanged; } if (_subscribedViewModel != null) @@ -103,15 +102,16 @@ private void SetupScrollSpy() // Unsubscribe first to avoid duplicate subscriptions _scrollViewer.ScrollChanged -= OnScrollChanged; + _scrollViewer.PointerWheelChanged -= OnPointerWheelChanged; if (_subscribedViewModel != null) { _subscribedViewModel.ScrollToSectionRequested -= OnScrollToSectionRequested; } - // Subscribe to scroll changes + // Subscribe to scroll and input changes _scrollViewer.ScrollChanged += OnScrollChanged; + _scrollViewer.PointerWheelChanged += OnPointerWheelChanged; - // Add our handler to the multicast delegate (don't replace other views' handlers) _subscribedViewModel = vm; _subscribedViewModel.ScrollToSectionRequested += OnScrollToSectionRequested; } @@ -130,6 +130,14 @@ private void MapSection(string name, ContentEditorCategory category) } } + private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (_isScrollingProgrammatically) + { + StopAnimation(); + } + } + private void OnScrollToSectionRequested(string sectionName) { if (_scrollViewer == null) @@ -137,7 +145,6 @@ private void OnScrollToSectionRequested(string sectionName) return; } - // Find the target section Control? targetControl = null; foreach (var section in _sections) { @@ -148,13 +155,7 @@ private void OnScrollToSectionRequested(string sectionName) } } - if (targetControl == null) - { - return; - } - - // Calculate target offset - if (_scrollViewer.Content is not Control content) + if (targetControl == null || _scrollViewer.Content is not Control content) { return; } @@ -166,27 +167,40 @@ private void OnScrollToSectionRequested(string sectionName) } var pos = transform.Value.Transform(new Point(0, 0)); - var targetY = Math.Max(0, Math.Min(pos.Y, _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height)); + var maxScrollY = Math.Max(0, _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height); + var targetY = Math.Clamp(pos.Y, 0, maxScrollY); - // Start smooth scroll animation - StartAnimation(_scrollViewer.Offset.Y, targetY); + StartAnimation(targetY); } - private void StartAnimation(double fromY, double toY) + private void StartAnimation(double targetY) { - StopAnimation(); + if (_scrollViewer == null) + { + return; + } + + StopAnimationTimer(); + + var currentY = _scrollViewer.Offset.Y; + if (Math.Abs(currentY - targetY) < 1.0) + { + _scrollViewer.Offset = new Vector(_scrollViewer.Offset.X, targetY); + _isScrollingProgrammatically = false; + return; + } _isScrollingProgrammatically = true; - _animStartOffset = fromY; - _animTargetOffset = toY; - _animationStopwatch.Restart(); + _animStartOffset = currentY; + _animTargetOffset = targetY; + _animStartTime = DateTime.UtcNow; - _animationTimer = new DispatcherTimer { Interval = FrameInterval }; + _animationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; _animationTimer.Tick += OnAnimationTick; _animationTimer.Start(); } - private void StopAnimation() + private void StopAnimationTimer() { if (_animationTimer != null) { @@ -194,7 +208,11 @@ private void StopAnimation() _animationTimer.Stop(); _animationTimer = null; } + } + private void StopAnimation() + { + StopAnimationTimer(); _isScrollingProgrammatically = false; } @@ -206,7 +224,7 @@ private void OnAnimationTick(object? sender, EventArgs e) return; } - var elapsed = _animationStopwatch.Elapsed; + var elapsed = DateTime.UtcNow - _animStartTime; var t = Math.Min(1.0, elapsed.TotalMilliseconds / AnimationDuration.TotalMilliseconds); // Ease-in-out quadratic @@ -219,7 +237,8 @@ private void OnAnimationTick(object? sender, EventArgs e) if (t >= 1.0) { - StopAnimation(); + StopAnimationTimer(); + Dispatcher.UIThread.Post(() => _isScrollingProgrammatically = false, DispatcherPriority.Normal); } } @@ -230,7 +249,21 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) return; } - // Find the last section whose top is at or above the viewport top + var maxScrollY = _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height; + var isAtBottom = maxScrollY > 0 && _scrollViewer.Offset.Y >= (maxScrollY - 25); + + if (isAtBottom && _sections.Count > 0) + { + var lastCategory = _sections[^1].Category; + if (vm.SelectedContentEditorCategory != lastCategory) + { + vm.UpdateContentEditorCategoryFromScroll(lastCategory); + } + + return; + } + + var threshold = Math.Max(60, _scrollViewer.Viewport.Height * 0.35); ContentEditorCategory? activeCategory = null; foreach (var (_, control, category) in _sections) @@ -245,15 +278,14 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) var position = transform.Value.Transform(new Point(0, 0)); - // Section is at or above viewport top (with small buffer) - if (position.Y <= 50) + if (position.Y <= threshold) { activeCategory = category; } } catch { - // Ignore visual tree detachment errors + // Visual tree detachment safety } } @@ -261,9 +293,9 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) { vm.UpdateContentEditorCategoryFromScroll(activeCategory.Value); } - else if (!activeCategory.HasValue && vm.SelectedContentEditorCategory != ContentEditorCategory.EnabledContent) + else if (!activeCategory.HasValue && _sections.Count > 0 && vm.SelectedContentEditorCategory != _sections[0].Category) { - vm.UpdateContentEditorCategoryFromScroll(ContentEditorCategory.EnabledContent); + vm.UpdateContentEditorCategoryFromScroll(_sections[0].Category); } } } \ No newline at end of file diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml index 1ca0c9013..160dd91c5 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml @@ -7,6 +7,7 @@ xmlns:models="clr-namespace:GenHub.Core.Models.Enums;assembly=GenHub.Core" xmlns:conv="clr-namespace:GenHub.Infrastructure.Converters" xmlns:converters="clr-namespace:Avalonia.Data.Converters;assembly=Avalonia.Base" + xmlns:material="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="1200" x:Class="GenHub.Features.GameProfiles.Views.GameProfileContentSettingsView" x:DataType="vm:GameProfileSettingsViewModel"> @@ -17,7 +18,6 @@ - @@ -43,11 +43,6 @@ - - - - - @@ -55,29 +50,41 @@ + Background="{DynamicResource SurfaceCardBrush}" + BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" + CornerRadius="12" + Padding="18" + Margin="0,0,0,16"> - - - + - - + + FontSize="13" Foreground="{DynamicResource TextPrimary}" Opacity="0.9" TextWrapping="Wrap" VerticalAlignment="Center" /> @@ -99,14 +106,11 @@ CommandParameter="{Binding}" ToolTip.Tip="Remove from profile"> - + - - - - - + @@ -120,8 +124,7 @@ Padding="8" Margin="8,0,12,0" AutomationProperties.Name="Edit" ToolTip.Tip="Edit"> - + @@ -134,10 +137,14 @@ + Background="{DynamicResource SurfaceCardBrush}" + BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" + CornerRadius="12" + Padding="18" + Margin="0,0,0,16"> - + @@ -163,16 +170,22 @@ - - - diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml index ecb661d50..9f6a53c88 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml @@ -130,12 +130,17 @@ - - + @@ -164,12 +169,17 @@ - - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml.cs index 79074045a..1f39dc357 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using GenHub.Features.GameProfiles.ViewModels; @@ -14,8 +15,7 @@ namespace GenHub.Features.GameProfiles.Views; public partial class GameProfileGeneralSettingsView : UserControl { private static readonly TimeSpan AnimationDuration = TimeSpan.FromMilliseconds(350); - private readonly Dictionary _sections = []; - private readonly Dictionary _sectionToCategoryMap = []; + private readonly List<(string Name, Control Control, GeneralSettingsCategory Category)> _sections = []; private ScrollViewer? _scrollViewer; private bool _isScrollingProgrammatically; private DispatcherTimer? _animationTimer; @@ -81,7 +81,7 @@ protected override void OnDataContextChanged(EventArgs e) protected override void OnUnloaded(RoutedEventArgs e) { base.OnUnloaded(e); - _animationTimer?.Stop(); + StopAnimation(); if (_boundViewModel != null) { @@ -92,7 +92,10 @@ protected override void OnUnloaded(RoutedEventArgs e) private void MapSections() { - if (_sections.Count > 0) return; + if (_sections.Count > 0) + { + return; + } MapSection("IdentitySection", GeneralSettingsCategory.Identity); MapSection("AppearanceSection", GeneralSettingsCategory.Appearance); @@ -109,6 +112,8 @@ private void AttachHandlers(GameProfileSettingsViewModel vm) { _scrollViewer.ScrollChanged -= OnScrollChanged; _scrollViewer.ScrollChanged += OnScrollChanged; + _scrollViewer.PointerWheelChanged -= OnPointerWheelChanged; + _scrollViewer.PointerWheelChanged += OnPointerWheelChanged; } } @@ -118,6 +123,7 @@ private void DetachHandlers(GameProfileSettingsViewModel vm) if (_scrollViewer != null) { _scrollViewer.ScrollChanged -= OnScrollChanged; + _scrollViewer.PointerWheelChanged -= OnPointerWheelChanged; } } @@ -126,44 +132,51 @@ private void MapSection(string name, GeneralSettingsCategory category) var control = this.FindControl(name); if (control != null) { - _sections[name] = control; - _sectionToCategoryMap[name] = category; + _sections.Add((name, control, category)); + } + } + + private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (_isScrollingProgrammatically) + { + StopAnimation(); } } private void OnScrollToSectionRequested(string sectionName) { - if (_scrollViewer == null || !_sections.TryGetValue(sectionName, out var targetControl)) + if (_scrollViewer == null) { return; } - _isScrollingProgrammatically = true; - - Dispatcher.UIThread.InvokeAsync( - () => + Control? targetControl = null; + foreach (var (name, control, _) in _sections) + { + if (name == sectionName) { - if (_scrollViewer.Content is Control content) - { - var transform = targetControl.TransformToVisual(content); - if (transform.HasValue) - { - var pos = transform.Value.Transform(new Point(0, 0)); - StartAnimation(pos.Y); - } - else - { - // Reset if no animation starts - _isScrollingProgrammatically = false; - } - } - else - { - // Reset if no content - _isScrollingProgrammatically = false; - } - }, - DispatcherPriority.Background); + targetControl = control; + break; + } + } + + if (targetControl == null || _scrollViewer.Content is not Control content) + { + return; + } + + var transform = targetControl.TransformToVisual(content); + if (!transform.HasValue) + { + return; + } + + var pos = transform.Value.Transform(new Point(0, 0)); + var maxScrollY = Math.Max(0, _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height); + var targetY = Math.Clamp(pos.Y, 0, maxScrollY); + + StartAnimation(targetY); } private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) @@ -173,31 +186,43 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) return; } - GeneralSettingsCategory? activeCategory = null; + var maxScrollY = _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height; + var isAtBottom = maxScrollY > 0 && _scrollViewer.Offset.Y >= (maxScrollY - 25); - foreach (var kvp in _sections) + if (isAtBottom && _sections.Count > 0) { - var section = kvp.Value; - var category = _sectionToCategoryMap[kvp.Key]; + var lastCategory = _sections[^1].Category; + if (vm.SelectedGeneralCategory != lastCategory) + { + vm.UpdateGeneralCategoryFromScroll(lastCategory); + } + return; + } + + var threshold = Math.Max(60, _scrollViewer.Viewport.Height * 0.35); + GeneralSettingsCategory? activeCategory = null; + + foreach (var (_, control, category) in _sections) + { try { - var transform = section.TransformToVisual(_scrollViewer); - if (transform == null) + var transform = control.TransformToVisual(_scrollViewer); + if (!transform.HasValue) { continue; } var position = transform.Value.Transform(new Point(0, 0)); - if (position.Y <= 50) + if (position.Y <= threshold) { activeCategory = category; } } catch (InvalidOperationException) { - // Ignore transformation errors (can happen if control is not yet fully attached to visual tree) + // Ignore transformation errors } } @@ -205,15 +230,31 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) { vm.UpdateGeneralCategoryFromScroll(activeCategory.Value); } + else if (!activeCategory.HasValue && _sections.Count > 0 && vm.SelectedGeneralCategory != _sections[0].Category) + { + vm.UpdateGeneralCategoryFromScroll(_sections[0].Category); + } } private void StartAnimation(double targetY) { - if (_scrollViewer == null) return; + if (_scrollViewer == null) + { + return; + } - StopAnimation(); + StopAnimationTimer(); - _animStartOffset = _scrollViewer.Offset.Y; + var currentY = _scrollViewer.Offset.Y; + if (Math.Abs(currentY - targetY) < 1.0) + { + _scrollViewer.Offset = new Vector(_scrollViewer.Offset.X, targetY); + _isScrollingProgrammatically = false; + return; + } + + _isScrollingProgrammatically = true; + _animStartOffset = currentY; _animTargetOffset = targetY; _animStartTime = DateTime.UtcNow; @@ -225,15 +266,19 @@ private void StartAnimation(double targetY) _animationTimer.Start(); } - private void StopAnimation() + private void StopAnimationTimer() { if (_animationTimer != null) { - _animationTimer.Stop(); _animationTimer.Tick -= OnAnimationTick; + _animationTimer.Stop(); _animationTimer = null; } + } + private void StopAnimation() + { + StopAnimationTimer(); _isScrollingProgrammatically = false; } @@ -258,7 +303,8 @@ private void OnAnimationTick(object? sender, EventArgs e) if (t >= 1.0) { - StopAnimation(); + StopAnimationTimer(); + Dispatcher.UIThread.Post(() => _isScrollingProgrammatically = false, DispatcherPriority.Normal); } } } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileLauncherView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileLauncherView.axaml index 7f17168c6..a308eaea8 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileLauncherView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileLauncherView.axaml @@ -9,7 +9,8 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="550" x:Class="GenHub.Features.GameProfiles.Views.GameProfileLauncherView" x:Name="ProfileLauncher" - x:DataType="vm:GameProfileLauncherViewModel"> + x:DataType="vm:GameProfileLauncherViewModel" + x:CompileBindings="True"> @@ -35,32 +36,29 @@ - - - - @@ -150,12 +148,13 @@ + @@ -172,7 +171,7 @@ - + + + + + + + + + + + + + + @@ -26,7 +82,7 @@ CommandParameter="0" ToolTip.Tip="Content"> + Width="28" Height="28" /> @@ -36,7 +92,7 @@ CommandParameter="1" ToolTip.Tip="Profile Settings"> + Width="28" Height="28" /> @@ -46,7 +102,7 @@ CommandParameter="2" ToolTip.Tip="Game Settings"> + Width="28" Height="28" /> diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml index 299e21c2d..0428518c8 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml @@ -87,7 +87,7 @@ - + @@ -95,8 +95,8 @@ @@ -209,73 +209,50 @@ - - - - - - - - - - - - @@ -389,7 +366,7 @@ diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs index 64273c1c5..bd3288392 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs @@ -3,7 +3,6 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; -using Avalonia.VisualTree; using GenHub.Core.Constants; using GenHub.Features.GameProfiles.ViewModels; @@ -18,11 +17,6 @@ public partial class GameProfileSettingsWindow : Window private static double? _savedWidth; private static double? _savedHeight; - // Fields for manual drag detection to allow double-click to work - private bool _isMouseDown; - private Point _mouseDownPosition; - private PointerPressedEventArgs? _pressedEventArgs; - /// /// Initializes a new instance of the class. /// @@ -30,9 +24,6 @@ public GameProfileSettingsWindow() { InitializeComponent(); - // Wire up drag handlers to the header in the shared content view - WireUpDragHandlers(); - // Subscribe to DataContext changes to handle commands DataContextChanged += OnDataContextChanged; @@ -50,69 +41,19 @@ public GameProfileSettingsWindow() /// The event arguments. public void OnHeaderPointerPressed(object? sender, PointerPressedEventArgs e) { - if (e.ClickCount == 2) + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - _isMouseDown = false; - _pressedEventArgs = null; - } - else - { - _isMouseDown = true; - _mouseDownPosition = e.GetPosition(this); - _pressedEventArgs = e; - } - } - - /// - /// Handles pointer moved to initiate drag only after a threshold, allowing double-clicks to pass through. - /// - /// The sender. - /// The event arguments. - public void OnHeaderPointerMoved(object? sender, PointerEventArgs e) - { - if (!_isMouseDown || _pressedEventArgs == null) - { - return; - } - - var currentPosition = e.GetPosition(this); - var distance = Math.Sqrt(Math.Pow(currentPosition.X - _mouseDownPosition.X, 2) + Math.Pow(currentPosition.Y - _mouseDownPosition.Y, 2)); - - // Drag threshold of 3 pixels - if (distance > 3) - { - if (WindowState == WindowState.Maximized) + if (e.ClickCount == 2 && CanResize) { - var screenX = Position.X + (currentPosition.X * RenderScaling); - var screenY = Position.Y + (currentPosition.Y * RenderScaling); - - WindowState = WindowState.Normal; - - var targetWidth = _savedWidth ?? Width; - var newX = screenX - ((targetWidth * RenderScaling) / 2); - var newY = screenY - (_mouseDownPosition.Y * RenderScaling); - - Position = new PixelPoint((int)newX, (int)newY); + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + else + { + BeginMoveDrag(e); } - - BeginMoveDrag(_pressedEventArgs); - _isMouseDown = false; - _pressedEventArgs = null; } } - /// - /// Handles pointer released to reset drag state. - /// - /// The sender. - /// The event arguments. - public void OnHeaderPointerReleased(object? sender, PointerReleasedEventArgs e) - { - _isMouseDown = false; - _pressedEventArgs = null; - } - /// /// Handles the toggle fullscreen button click. /// @@ -140,20 +81,6 @@ protected override void OnClosed(EventArgs e) base.OnClosed(e); } - /// - /// Wires up pointer event handlers to the header border in the shared content view. - /// - private void WireUpDragHandlers() - { - // Find the named header border in the shared content view - if (this.FindControl("ContentView")?.FindControl("HeaderBorder") is { } headerBorder) - { - headerBorder.PointerPressed += OnHeaderPointerPressed; - headerBorder.PointerMoved += OnHeaderPointerMoved; - headerBorder.PointerReleased += OnHeaderPointerReleased; - } - } - private void InitializeComponent() { AvaloniaXamlLoader.Load(this); diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameSettingsView.axaml index 7a0629487..897c4a78f 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameSettingsView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameSettingsView.axaml @@ -319,6 +319,11 @@ + + + + + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameSettingsView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/GameSettingsView.axaml.cs index 13373a510..bd5f421a2 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameSettingsView.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameSettingsView.axaml.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using GenHub.Features.GameProfiles.ViewModels; @@ -14,7 +15,6 @@ namespace GenHub.Features.GameProfiles.Views; public partial class GameSettingsView : UserControl { private static readonly TimeSpan AnimationDuration = TimeSpan.FromMilliseconds(350); - private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(16); // ~60fps private readonly List<(string Name, Control Control, SettingsCategory Category)> _sections = []; @@ -50,6 +50,7 @@ protected override void OnLoaded(RoutedEventArgs e) } // Map sections in top-to-bottom order (order matters for scroll spy) + _sections.Clear(); MapSection("VideoSection", SettingsCategory.Video); MapSection("AudioSection", SettingsCategory.Audio); MapSection("ControlsSection", SettingsCategory.Controls); @@ -59,7 +60,10 @@ protected override void OnLoaded(RoutedEventArgs e) if (DataContext is GameSettingsViewModel vm) { vm.ScrollToSectionRequested = OnScrollToSectionRequested; + _scrollViewer.ScrollChanged -= OnScrollChanged; _scrollViewer.ScrollChanged += OnScrollChanged; + _scrollViewer.PointerWheelChanged -= OnPointerWheelChanged; + _scrollViewer.PointerWheelChanged += OnPointerWheelChanged; } } @@ -76,6 +80,7 @@ protected override void OnUnloaded(RoutedEventArgs e) if (_scrollViewer != null) { _scrollViewer.ScrollChanged -= OnScrollChanged; + _scrollViewer.PointerWheelChanged -= OnPointerWheelChanged; } if (DataContext is GameSettingsViewModel vm) @@ -93,6 +98,14 @@ private void MapSection(string name, SettingsCategory category) } } + private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (_isScrollingProgrammatically) + { + StopAnimation(); + } + } + private void OnScrollToSectionRequested(string sectionName) { if (_scrollViewer == null) @@ -100,7 +113,6 @@ private void OnScrollToSectionRequested(string sectionName) return; } - // Find the target section Control? targetControl = null; foreach (var section in _sections) { @@ -111,13 +123,7 @@ private void OnScrollToSectionRequested(string sectionName) } } - if (targetControl == null) - { - return; - } - - // Calculate target offset - if (_scrollViewer.Content is not Control content) + if (targetControl == null || _scrollViewer.Content is not Control content) { return; } @@ -129,27 +135,40 @@ private void OnScrollToSectionRequested(string sectionName) } var pos = transform.Value.Transform(new Point(0, 0)); - var targetY = Math.Max(0, Math.Min(pos.Y, _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height)); + var maxScrollY = Math.Max(0, _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height); + var targetY = Math.Clamp(pos.Y, 0, maxScrollY); - // Start smooth scroll animation - StartAnimation(_scrollViewer.Offset.Y, targetY); + StartAnimation(targetY); } - private void StartAnimation(double fromY, double toY) + private void StartAnimation(double targetY) { - StopAnimation(); + if (_scrollViewer == null) + { + return; + } + + StopAnimationTimer(); + + var currentY = _scrollViewer.Offset.Y; + if (Math.Abs(currentY - targetY) < 1.0) + { + _scrollViewer.Offset = new Vector(_scrollViewer.Offset.X, targetY); + _isScrollingProgrammatically = false; + return; + } _isScrollingProgrammatically = true; - _animStartOffset = fromY; - _animTargetOffset = toY; + _animStartOffset = currentY; + _animTargetOffset = targetY; _animStartTime = DateTime.UtcNow; - _animationTimer = new DispatcherTimer { Interval = FrameInterval }; + _animationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; _animationTimer.Tick += OnAnimationTick; _animationTimer.Start(); } - private void StopAnimation() + private void StopAnimationTimer() { if (_animationTimer != null) { @@ -157,7 +176,11 @@ private void StopAnimation() _animationTimer.Stop(); _animationTimer = null; } + } + private void StopAnimation() + { + StopAnimationTimer(); _isScrollingProgrammatically = false; } @@ -182,7 +205,8 @@ private void OnAnimationTick(object? sender, EventArgs e) if (t >= 1.0) { - StopAnimation(); + StopAnimationTimer(); + Dispatcher.UIThread.Post(() => _isScrollingProgrammatically = false, DispatcherPriority.Normal); } } @@ -193,7 +217,21 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) return; } - // Find the last section whose top is at or above the viewport top + var maxScrollY = _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height; + var isAtBottom = maxScrollY > 0 && _scrollViewer.Offset.Y >= (maxScrollY - 25); + + if (isAtBottom && _sections.Count > 0) + { + var lastCategory = _sections[^1].Category; + if (vm.SelectedCategory != lastCategory) + { + vm.UpdateCategoryFromScroll(lastCategory); + } + + return; + } + + var threshold = Math.Max(60, _scrollViewer.Viewport.Height * 0.35); SettingsCategory? activeCategory = null; foreach (var (_, control, category) in _sections) @@ -208,15 +246,14 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) var position = transform.Value.Transform(new Point(0, 0)); - // Section is at or above viewport top (with small buffer) - if (position.Y <= 50) + if (position.Y <= threshold) { activeCategory = category; } } catch { - // Ignore visual tree detachment errors + // Visual tree detachment safety } } @@ -224,9 +261,9 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) { vm.UpdateCategoryFromScroll(activeCategory.Value); } - else if (!activeCategory.HasValue && vm.SelectedCategory != SettingsCategory.Video) + else if (!activeCategory.HasValue && _sections.Count > 0 && vm.SelectedCategory != _sections[0].Category) { - vm.UpdateCategoryFromScroll(SettingsCategory.Video); + vm.UpdateCategoryFromScroll(_sections[0].Category); } } } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml index 789357efc..1b599e7d1 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml @@ -17,128 +17,189 @@ TransparencyLevelHint="AcrylicBlur"> - - + + + + + + - + - - + + - + - - - - - - - - - - + - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs index 07fe4ce37..427a9f3b2 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs @@ -1,8 +1,8 @@ +using System; using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; using GenHub.Features.GameProfiles.ViewModels.Wizard; -using System; namespace GenHub.Features.GameProfiles.Views.Wizard; @@ -17,9 +17,6 @@ public partial class SetupWizardView : Window public SetupWizardView() { InitializeComponent(); -#if DEBUG - this.AttachDevTools(); -#endif } /// diff --git a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs index 2dc4a45d8..cc85bccab 100644 --- a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs +++ b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs @@ -1,12 +1,15 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; +using System.Security; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameSettings; @@ -33,6 +36,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 +73,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); + + if (!File.Exists(filePath)) + { + _logger.LogWarning("File not found at {FilePath}, returning defaults", filePath); + return OperationResult.CreateSuccess(new IniOptions()); + } - _logger.LogDebug("Reading file"); - var lines = await File.ReadAllLinesAsync(filePath); - _logger.LogDebug("Parsing {LineCount} lines", lines.Length); - var options = ParseOptionsIni(lines); + _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) - { - _logger.LogError(ex, "Failed to load Options.ini for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to load options: {ex.Message}"); - } - finally - { - _optionsIniWriteSemaphore.Release(); + _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 +184,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 +204,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); + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonSerializer.Deserialize(json, _jsonSerializerOptions); - if (settings == null) + if (settings == null) + { + _logger.LogWarning("Failed to deserialize GeneralsOnline settings, returning defaults"); + return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); + } + + settings.EnsureNestedSectionsInitialized(); + + _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)) + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + _logger.LogDebug("Creating directory: {Directory}", directory); + Directory.CreateDirectory(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(); + } + } + } - var json = JsonSerializer.Serialize(settings, _jsonSerializerOptions); - await File.WriteAllTextAsync(settingsPath, json, Encoding.UTF8); + /// + /// 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); + } - _logger.LogInformation("Saved GeneralsOnline settings to {SettingsPath}", settingsPath); - return OperationResult.CreateSuccess(true); + private static void DiscardTemporarySettingsFile(string? temporaryPath) + { + if (temporaryPath == null || !File.Exists(temporaryPath)) + { + return; + } + + 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. } } @@ -364,7 +425,7 @@ private static void CategorizeRootSettings(IniOptions options, Dictionary(StringComparer.OrdinalIgnoreCase) { "CursorCaptureEnabledInWindowedMenu", "CursorCaptureEnabledInWindowedGame", "DrawScrollAnchor", "DynamicLOD", - "GameTimeFontSize", "LanguageFilter", "MaxParticleCount", + "GameTimeFontSize", GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey, "LanguageFilter", "MaxParticleCount", "MoneyTransactionVolume", "MoveScrollAnchor", "NetworkLatencyFontSize", "PlayerObserverEnabled", "RenderFpsFontSize", "ResolutionFontAdjustment", "Retaliation", "ScreenEdgeScrollEnabledInFullscreenApp", @@ -488,6 +549,9 @@ private static void ParseAudioSection(AudioSettings audio, Dictionary SerializeTheSuperHackersSettings(TheSuperHackersSettings settings) @@ -716,6 +792,7 @@ private static Dictionary SerializeTheSuperHackersSettings(TheSu ["CursorCaptureEnabledInFullscreenMenu"] = BoolToString(settings.CursorCaptureEnabledInFullscreenMenu), ["CursorCaptureEnabledInWindowedGame"] = BoolToString(settings.CursorCaptureEnabledInWindowedGame), ["CursorCaptureEnabledInWindowedMenu"] = BoolToString(settings.CursorCaptureEnabledInWindowedMenu), + [GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey] = (GameSettingsMapper.NormalizeTransitionSpeedMultiplier(settings.GameWindowTransitionSpeedMultiplier) ?? GameSettingsTheSuperHackersConstants.DefaultGameWindowTransitionSpeedMultiplier).ToString(CultureInfo.InvariantCulture), ["MoneyTransactionVolume"] = settings.MoneyTransactionVolume.ToString(), ["NetworkLatencyFontSize"] = settings.NetworkLatencyFontSize.ToString(), ["PlayerObserverEnabled"] = BoolToString(settings.PlayerObserverEnabled), @@ -728,14 +805,6 @@ private static 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 +818,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/GitHub/Services/OctokitGitHubApiClient.cs b/GenHub/GenHub/Features/GitHub/Services/OctokitGitHubApiClient.cs index 01f26bd03..eae64c363 100644 --- a/GenHub/GenHub/Features/GitHub/Services/OctokitGitHubApiClient.cs +++ b/GenHub/GenHub/Features/GitHub/Services/OctokitGitHubApiClient.cs @@ -192,9 +192,9 @@ public async Task GetLatestReleaseAsync( CancellationToken cancellationToken = default) { var cacheKey = $"GitHub_LatestRelease_{owner}_{repositoryName}"; - if (cache.TryGetValue(cacheKey, out GitHubRelease? cachedRelease)) + if (cache.TryGetValue(cacheKey, out GitHubRelease? cachedRelease) && cachedRelease != null) { - return cachedRelease!; + return cachedRelease; } try @@ -237,9 +237,9 @@ public async Task GetReleaseByTagAsync( CancellationToken cancellationToken = default) { var cacheKey = $"GitHub_ReleaseByTag_{owner}_{repositoryName}_{tag}"; - if (cache.TryGetValue(cacheKey, out GitHubRelease? cachedRelease)) + if (cache.TryGetValue(cacheKey, out GitHubRelease? cachedRelease) && cachedRelease != null) { - return cachedRelease!; + return cachedRelease; } try @@ -281,9 +281,9 @@ public async Task> GetReleasesAsync( CancellationToken cancellationToken = default) { var cacheKey = $"GitHub_Releases_{owner}_{repo}"; - if (cache.TryGetValue(cacheKey, out IEnumerable? cachedReleases)) + if (cache.TryGetValue(cacheKey, out IEnumerable? cachedReleases) && cachedReleases != null) { - return cachedReleases!; + return cachedReleases; } try diff --git a/GenHub/GenHub/Features/GitHub/Views/GitHubTokenDialogView.axaml b/GenHub/GenHub/Features/GitHub/Views/GitHubTokenDialogView.axaml index ff65239c2..f5d604d24 100644 --- a/GenHub/GenHub/Features/GitHub/Views/GitHubTokenDialogView.axaml +++ b/GenHub/GenHub/Features/GitHub/Views/GitHubTokenDialogView.axaml @@ -107,11 +107,11 @@ ToolTip.Tip="Click to open GitHub PAT creation page"> 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/FaqService.cs b/GenHub/GenHub/Features/Info/Services/FaqService.cs index be41e3809..f85419f13 100644 --- a/GenHub/GenHub/Features/Info/Services/FaqService.cs +++ b/GenHub/GenHub/Features/Info/Services/FaqService.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AngleSharp; @@ -21,8 +23,7 @@ namespace GenHub.Features.Info.Services; /// The logger. public class FaqService(IHttpClientFactory httpClientFactory, ILogger logger) : IFaqService { - private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; - private readonly ILogger _logger = logger; + private static readonly Regex HtmlTagRegex = new("<.*?>", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); /// public IReadOnlyList SupportedLanguages => InfoConstants.SupportedFaqLanguages; @@ -40,7 +41,7 @@ public async Task>> GetFaqAsync( } var url = $"{InfoConstants.FaqBaseUrl}?lang={language}"; - using var client = _httpClientFactory.CreateClient(); + using var client = httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(DownloadDefaults.TimeoutSeconds); var html = await client.GetStringAsync(url, cancellationToken); @@ -52,7 +53,7 @@ public async Task>> GetFaqAsync( } catch (Exception ex) { - _logger.LogError(ex, "Failed to fetch FAQ."); + logger.LogError(ex, "Failed to fetch FAQ."); return OperationResult>.CreateFailure("Failed to load FAQ. Please check your internet connection."); } } @@ -118,7 +119,7 @@ private static List ParseFaq(IDocument document) private static string ExtractAnswerText(IElement section, IElement questionHeader) { - var sb = new System.Text.StringBuilder(); + var sb = new StringBuilder(); // Get all siblings after the h3, or just all children that serve as content foreach (var child in section.Children) @@ -180,6 +181,6 @@ private static string CleanText(string input) if (string.IsNullOrWhiteSpace(input)) return input; // Remove HTML tags that might have been double-encoded or preserved - return System.Text.RegularExpressions.Regex.Replace(input, "<.*?>", string.Empty); + return HtmlTagRegex.Replace(input, string.Empty); } } diff --git a/GenHub/GenHub/Features/Info/Services/MockToolServices.cs b/GenHub/GenHub/Features/Info/Services/MockToolServices.cs index 0e1aaae7e..8c0732b49 100644 --- a/GenHub/GenHub/Features/Info/Services/MockToolServices.cs +++ b/GenHub/GenHub/Features/Info/Services/MockToolServices.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Reactive.Linq; 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; @@ -22,13 +24,14 @@ using GenHub.Core.Models.Notifications; using GenHub.Core.Models.Results; using GenHub.Core.Models.Storage; +using GenHub.Core.Models.Tools; using GenHub.Core.Models.Tools.MapManager; using GenHub.Core.Models.Tools.ReplayManager; -// Alias to avoid ambiguity if both have ImportResult using MapImportResult = GenHub.Core.Models.Tools.MapManager.ImportResult; using ReplayImportResult = GenHub.Core.Models.Tools.ReplayManager.ImportResult; +// Alias to avoid ambiguity if both have ImportResult #pragma warning disable SA1649 // File name should match first type name #pragma warning disable SA1402 // File may only contain a single type @@ -37,12 +40,14 @@ namespace GenHub.Features.Info.Services; /// /// Mock implementation of for testing and demos. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Mock implementation for testing/demo UI")] public class MockNotificationService : INotificationService { private readonly Subject _notifications = new(); 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 +61,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); @@ -76,13 +84,17 @@ public void ShowError(string title, string message, int? autoDismissMs = null, b => Show(new NotificationMessage(NotificationType.Error, title, message, autoDismissMs, showInBadge: showInBadge)); /// - public void Dismiss(Guid id) => _dismissRequests.OnNext(id); + public void Update(Guid notificationId, string message, string? title = null) + => _updateRequests.OnNext((notificationId, title, message)); + + /// + public void Dismiss(Guid notificationId) => _dismissRequests.OnNext(notificationId); /// public void DismissAll() => _dismissAllRequests.OnNext(true); /// - public void MarkAsRead(Guid id) + public void MarkAsRead(Guid notificationId) { } @@ -113,39 +125,45 @@ public class MockUploadHistoryService : IUploadHistoryService public long MaxUploadBytesPerPeriod => 1024 * 1024 * 50; // 50MB mock /// - public Task> GetUploadHistoryAsync() + public Task> GetUploadHistoryAsync(string? category = null) { - return Task.FromResult>([]); + return Task.FromResult>([]); } /// - public Task GetUsageInfoAsync() + public Task GetUsageInfoAsync(string? category = null) { // UsageInfo is a record struct with (UsedBytes, LimitBytes, ResetDate) return Task.FromResult(new UsageInfo(1024 * 1024 * 5, 1024 * 1024 * 50, DateTime.UtcNow.AddDays(1))); } /// - public Task CanUploadAsync(long fileSizeBytes) + public Task CanUploadAsync(long fileSizeBytes, string? category = null) { return Task.FromResult(true); } /// - public void RecordUpload(long fileSizeBytes, string url, string fileName) + public void RecordUpload(long fileSizeBytes, string url, string fileName, string? fileKey = null, string? deleteToken = null, string? fileHash = null, string? category = null) { } /// - public Task RemoveHistoryItemAsync(string url) + public Task FindExistingUploadAsync(string fileHash) { - return Task.CompletedTask; + return Task.FromResult(null); } /// - public Task ClearHistoryAsync() + public Task RemoveHistoryItemAsync(string url, bool deleteFromCloud = true) { - return Task.CompletedTask; + return Task.FromResult(true); + } + + /// + public Task<(int Deleted, int Failed)> ClearHistoryAsync(bool deleteFromCloud = true, string? category = null) + { + return Task.FromResult((0, 0)); } } @@ -155,21 +173,21 @@ public Task ClearHistoryAsync() public class MockReplayDirectoryService : IReplayDirectoryService { /// - public Task DeleteReplaysAsync(IEnumerable replays, CancellationToken cancellationToken) => Task.FromResult(true); + public Task DeleteReplaysAsync(IEnumerable replays, CancellationToken ct = default) => Task.FromResult(true); /// - public string GetReplayDirectory(GameType gameType) + public string GetReplayDirectory(GameType version) { return "C:\\Mock\\Replays"; } /// - public void EnsureDirectoryExists(GameType gameType) + public void EnsureDirectoryExists(GameType version) { } /// - public Task> GetReplaysAsync(GameType gameType, CancellationToken cancellationToken = default) + public Task> GetReplaysAsync(GameType version, CancellationToken ct = default) { // Populate mock data for both game types for demo purposes var list = new List @@ -180,7 +198,7 @@ public Task> GetReplaysAsync(GameType gameType, Cancel FullPath = "C:\\Mock\\Demo1.rep", SizeInBytes = 1024 * 500, LastModified = DateTime.UtcNow.AddDays(-1), - GameVersion = gameType, // Use requested type so it appears valid + GameVersion = version, // Use requested type so it appears valid }, new() { @@ -188,7 +206,7 @@ public Task> GetReplaysAsync(GameType gameType, Cancel FullPath = "C:\\Mock\\Demo2.rep", SizeInBytes = 1024 * 1200, LastModified = DateTime.UtcNow.AddHours(-5), - GameVersion = gameType, // Use requested type so it appears valid + GameVersion = version, // Use requested type so it appears valid }, }; @@ -196,7 +214,7 @@ public Task> GetReplaysAsync(GameType gameType, Cancel } /// - public void OpenInExplorer(GameType gameType) + public void OpenInExplorer(GameType version) { } @@ -248,15 +266,15 @@ public Task ImportFromZipAsync(string zipPath, GameType targ public class MockReplayExportService : IReplayExportService { /// - public Task ExportToZipAsync(IEnumerable replays, string destinationPath, IProgress? progress, CancellationToken cancellationToken) + public Task ExportToZipAsync(IEnumerable replays, string destinationPath, IProgress? progress = null, CancellationToken ct = default) { return Task.FromResult(destinationPath); } /// - public Task UploadToUploadThingAsync(IEnumerable replays, IProgress? progress, CancellationToken cancellationToken) + public Task> UploadToUploadThingAsync(IEnumerable replays, IProgress? progress = null, CancellationToken ct = default) { - return Task.FromResult("https://mock.upload/share/1234"); + return Task.FromResult(OperationResult.CreateSuccess(new GenHub.Core.Models.Tools.UploadThing.UploadResult(ToolConstants.MockUrls.MockReplayUploadUrl, "mock_key_1", "mock_delete_token_1"))); } } @@ -266,21 +284,21 @@ public class MockReplayExportService : IReplayExportService public class MockMapDirectoryService : IMapDirectoryService { /// - public Task DeleteMapsAsync(IEnumerable maps, CancellationToken cancellationToken) => Task.FromResult(true); + public Task DeleteMapsAsync(IEnumerable maps, CancellationToken ct = default) => Task.FromResult(true); /// - public void EnsureDirectoryExists(GameType gameType) + public void EnsureDirectoryExists(GameType version) { } /// - public string GetMapDirectory(GameType gameType) + public string GetMapDirectory(GameType version) { return "C:\\Mock\\Maps"; } /// - public Task> GetMapsAsync(GameType gameType, CancellationToken ct = default) + public Task> GetMapsAsync(GameType version, CancellationToken ct = default) { var list = new List { @@ -338,13 +356,13 @@ public Task> GetMapsAsync(GameType gameType, Cancellation } /// - public Task RenameMapAsync(MapFile map, string newName, CancellationToken cancellationToken) + public Task RenameMapAsync(MapFile map, string newName, CancellationToken ct = default) { return Task.FromResult(true); } /// - public void OpenInExplorer(GameType gameType) + public void OpenInExplorer(GameType version) { } @@ -397,15 +415,15 @@ public Task ImportFromZipAsync(string zipPath, GameType targetV public class MockMapExportService : IMapExportService { /// - public Task ExportToZipAsync(IEnumerable maps, string destinationPath, IProgress? progress, CancellationToken cancellationToken) + public Task ExportToZipAsync(IEnumerable maps, string destinationPath, IProgress? progress = null, CancellationToken ct = default) { return Task.FromResult(destinationPath); } /// - public Task UploadToUploadThingAsync(IEnumerable maps, IProgress? progress, CancellationToken cancellationToken) + public Task> UploadToUploadThingAsync(IEnumerable maps, IProgress? progress = null, CancellationToken ct = default) { - return Task.FromResult("https://mock.upload/maps/123"); + return Task.FromResult(OperationResult.CreateSuccess(new GenHub.Core.Models.Tools.UploadThing.UploadResult(ToolConstants.MockUrls.MockMapUploadUrl, "mock_key_2", "mock_delete_token_2"))); } } @@ -457,7 +475,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 +495,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 +610,12 @@ public static void UseDefaultConfiguration() /// public bool GetAutoCheckForUpdatesOnStartup() => true; + /// + public bool GetAutoCheckForUpdatesPeriodically() => true; + + /// + public int GetPeriodicUpdateCheckIntervalMinutes() => AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + /// public bool GetEnableDetailedLogging() => false; @@ -617,6 +660,9 @@ public static void UseDefaultConfiguration() /// public string GetLogsPath() => @"C:\GenHub\Logs"; + + /// + public CsvCatalogConfiguration GetCsvCatalogConfiguration() => new(); } /// diff --git a/GenHub/GenHub/Features/Info/ViewModels/FaqSectionViewModel.cs b/GenHub/GenHub/Features/Info/ViewModels/FaqSectionViewModel.cs index 694a332d5..1c7509939 100644 --- a/GenHub/GenHub/Features/Info/ViewModels/FaqSectionViewModel.cs +++ b/GenHub/GenHub/Features/Info/ViewModels/FaqSectionViewModel.cs @@ -6,10 +6,8 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using GenHub.Core.Constants; using GenHub.Core.Interfaces.Info; using GenHub.Core.Models.Info; -using GenHub.Core.Models.Results; using Microsoft.Extensions.Logging; namespace GenHub.Features.Info.ViewModels; @@ -17,11 +15,12 @@ namespace GenHub.Features.Info.ViewModels; /// /// ViewModel for the FAQ section. /// -public partial class FaqSectionViewModel(IFaqService faqService, ILogger logger) : ObservableObject, IInfoSectionViewModel +public sealed partial class FaqSectionViewModel(IFaqService faqService, ILogger logger) : ObservableObject, IInfoSectionViewModel, IDisposable { - private readonly IFaqService _faqService = faqService; - private readonly ILogger _logger = logger; + private readonly object _gate = new(); private CancellationTokenSource? _loadCts; + private int _loadGeneration; + private bool _disposed; [ObservableProperty] private bool _isLoading; @@ -29,16 +28,22 @@ public partial class FaqSectionViewModel(IFaqService faqService, ILogger - public string Id => "faq"; + [ObservableProperty] + private LanguageOption _selectedLanguageOption = new("English", "en", "avares://GenHub/Assets/Images/Flags/en.png"); - /// - public string Title => "Zero Hour"; + [ObservableProperty] + private FaqCategoryViewModel? _selectedCategory; /// /// Gets the icon key. /// - public string IconKey => "HelpCircleOutline"; // Material Design Icon + public static string IconKey => "HelpCircleOutline"; + + /// + public string Id => "faq"; + + /// + public string Title => "Zero Hour"; /// public int Order => 0; @@ -59,23 +64,47 @@ public partial class FaqSectionViewModel(IFaqService faqService, ILogger - /// Initializes static members of the class. - /// - static FaqSectionViewModel() + /// + public async Task InitializeAsync() { + await LoadFaqAsync(); } /// - public async Task InitializeAsync() + public void Dispose() { - await LoadFaqAsync(); + CancellationTokenSource? ctsToDispose = null; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + _loadGeneration++; + ctsToDispose = _loadCts; + _loadCts = null; + } + + if (ctsToDispose != null) + { + ctsToDispose.Cancel(); + ctsToDispose.Dispose(); + } + + GC.SuppressFinalize(this); + } + + private static async Task CancelAndDisposeAsync(CancellationTokenSource? cts) + { + if (cts == null) + { + return; + } + + await cts.CancelAsync(); + cts.Dispose(); } [RelayCommand] @@ -95,31 +124,32 @@ partial void OnSelectedLanguageOptionChanged(LanguageOption value) [RelayCommand] private async Task LoadFaqAsync() { - _loadCts?.Cancel(); - _loadCts = new CancellationTokenSource(); - var token = _loadCts.Token; + if (!TryPrepareLoad(out var token, out var currentGeneration, out var oldCts)) + { + return; + } + + await CancelAndDisposeAsync(oldCts); + + if (!IsCurrentGeneration(currentGeneration)) + { + return; + } IsLoading = true; StatusMessage = string.Empty; try { - var result = await _faqService.GetFaqAsync(SelectedLanguageOption.Code, token); - if (result.Success) + var result = await faqService.GetFaqAsync(SelectedLanguageOption.Code, token); + if (token.IsCancellationRequested || !IsCurrentGeneration(currentGeneration)) { - await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync( - () => - { - Categories.Clear(); - foreach (var category in result.Data) - { - Categories.Add(new FaqCategoryViewModel(category)); - } - - SelectedCategory = Categories.FirstOrDefault(); - }, - Avalonia.Threading.DispatcherPriority.Normal, - token); + return; + } + + if (result.Success && result.Data != null) + { + await PopulateCategoriesAsync(result.Data, currentGeneration, token); } else { @@ -128,16 +158,78 @@ await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync( } catch (OperationCanceledException) { - // Expected + // Expected when a newer load request preempts this one. } catch (Exception ex) { - _logger.LogError(ex, "Error loading FAQ"); + logger.LogError(ex, "Error loading FAQ"); StatusMessage = "An unexpected error occurred."; } finally { - IsLoading = false; + CompleteLoad(currentGeneration); + } + } + + private bool TryPrepareLoad(out CancellationToken token, out int generation, out CancellationTokenSource? oldCts) + { + lock (_gate) + { + if (_disposed) + { + token = CancellationToken.None; + generation = 0; + oldCts = null; + return false; + } + + oldCts = _loadCts; + var cts = new CancellationTokenSource(); + _loadCts = cts; + generation = ++_loadGeneration; + token = cts.Token; + return true; + } + } + + private bool IsCurrentGeneration(int generation) + { + lock (_gate) + { + return !_disposed && _loadGeneration == generation; + } + } + + private async Task PopulateCategoriesAsync(IReadOnlyList categories, int generation, CancellationToken token) + { + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync( + () => + { + if (!IsCurrentGeneration(generation)) + { + return; + } + + Categories.Clear(); + foreach (var category in categories) + { + Categories.Add(new FaqCategoryViewModel(category)); + } + + SelectedCategory = Categories.FirstOrDefault(); + }, + Avalonia.Threading.DispatcherPriority.Normal, + token); + } + + private void CompleteLoad(int generation) + { + lock (_gate) + { + if (!_disposed && _loadGeneration == generation) + { + IsLoading = false; + } } } } diff --git a/GenHub/GenHub/Features/Info/ViewModels/GenHubInfoSectionViewModel.cs b/GenHub/GenHub/Features/Info/ViewModels/GenHubInfoSectionViewModel.cs index 279684a16..c58162188 100644 --- a/GenHub/GenHub/Features/Info/ViewModels/GenHubInfoSectionViewModel.cs +++ b/GenHub/GenHub/Features/Info/ViewModels/GenHubInfoSectionViewModel.cs @@ -27,6 +27,7 @@ namespace GenHub.Features.Info.ViewModels; /// The changelogs view model. /// The Generals Online changelog view model. /// Optional notification service for demo actions. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Observable property access on view model")] public partial class GenHubInfoSectionViewModel( IInfoContentProvider contentProvider, ChangelogsViewModel changelogsViewModel, diff --git a/GenHub/GenHub/Features/Info/ViewModels/InfoViewModel.cs b/GenHub/GenHub/Features/Info/ViewModels/InfoViewModel.cs index fad7a3215..5cbde989f 100644 --- a/GenHub/GenHub/Features/Info/ViewModels/InfoViewModel.cs +++ b/GenHub/GenHub/Features/Info/ViewModels/InfoViewModel.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using GenHub.Common.ViewModels; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Info; using GenHub.Core.Messages; using GenHub.Features.Info.ViewModels; @@ -16,9 +18,10 @@ namespace GenHub.Features.Info.ViewModels; /// /// ViewModel for the Info tab, managing multiple info sections. /// -public partial class InfoViewModel : ViewModelBase, IDisposable, IRecipient +[SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Observable property access on view model")] +public sealed partial class InfoViewModel : ViewModelBase, IDisposable, IRecipient { - private readonly IEnumerable _sectionViewModels; + private bool _disposed; [ObservableProperty] private IInfoSectionViewModel? _selectedSection; @@ -26,129 +29,198 @@ public partial class InfoViewModel : ViewModelBase, IDisposable, IRecipient + /// Initializes a new instance of the class. + /// + /// The available info section view models. + public InfoViewModel(IEnumerable sectionViewModels) + { + Sections = new ObservableCollection(sectionViewModels.OrderBy(s => s.Order)); + + // Default to GenHub Guide + SelectedSection = Sections.OfType().FirstOrDefault() + ?? Sections.FirstOrDefault(); + + // Initialize sidebar items + UpdateSidebarItems(); + + // Register for navigation messages + WeakReferenceMessenger.Default.Register(this); + } + /// /// Gets the list of available modules. /// - public ObservableCollection Modules { get; } = ["GenHub Guide", "Zero Hour", "GeneralsOnline"]; + public ObservableCollection Modules { get; } = + [ + InfoConstants.ModuleGuide, + InfoConstants.ModuleZeroHour, + InfoConstants.ModuleGeneralsOnline, + ]; /// /// Gets the available info sections. /// public ObservableCollection Sections { get; } + /// + /// Resolves the module name corresponding to the specified section ID. + /// + /// The section ID. + /// The resolved module name. + public static string ResolveModuleForSection(string sectionId) + { + if (string.Equals(sectionId, InfoConstants.SectionFaq, StringComparison.OrdinalIgnoreCase)) + { + return InfoConstants.ModuleZeroHour; + } + + if (string.Equals(sectionId, InfoConstants.SectionGoChangelog, StringComparison.OrdinalIgnoreCase)) + { + return InfoConstants.ModuleGeneralsOnline; + } + + return InfoConstants.ModuleGuide; + } + /// /// Opens a specific section by ID, switching modules if necessary. /// /// The ID of the section to open. public void OpenSection(string sectionId) { - SelectedModule = (sectionId.Equals("faq", StringComparison.OrdinalIgnoreCase) || - sectionId.Equals("go-changelog", StringComparison.OrdinalIgnoreCase)) - ? "GeneralsOnline" - : "GenHub Guide"; - - // 2. Force update sections context to ensure the list is populated for the target module - // (SelectedModule setter calls UpdateSidebarItems, but we need to be sure before searching) - - // 3. Find the section in the current (filtered) Sections list - var targetSection = Sections.FirstOrDefault(s => s.Id.Equals(sectionId, StringComparison.OrdinalIgnoreCase)); + SelectedModule = ResolveModuleForSection(sectionId); + var targetSection = Sections.FirstOrDefault(s => string.Equals(s.Id, sectionId, StringComparison.OrdinalIgnoreCase)); if (targetSection != null) { SelectedSection = targetSection; + return; + } - // Also ensure it's selected in the sidebar - if (SelectedSection is GenHubInfoSectionViewModel genHubSection) - { - // GenHubInfoSectionViewModel is a container, so we usually select a sub-section inside it? - // No, GenHubInfoSection IS the "Guide" container in the Main Tabs basically? - // Wait, InfoViewModel structure is: - // Sections = [GenHubInfoSectionViewModel (Guide), FaqSectionViewModel (FAQ), etc?] + TryOpenSubSection(sectionId); + } - // Let's re-read UpdateSidebarItems logic. - // If Guide is selected: - // GenHubInfoSectionViewModel is found. - // SelectedSection = genHubSection; - // SidebarItems = genHubSection.Sections; - // SelectedSidebarItem = genHubSection.SelectedSection; + /// + /// Initializes the view model and the selected section. + /// + /// A task representing the asynchronous operation. + [SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Observable property access on view model")] + public async Task InitializeAsync() + { + if (SelectedSection != null) + { + await SelectedSection.InitializeAsync(); + } + } - // So "Quickstart" is actually a SUB-section of GenHubInfoSectionViewModel. + /// + public void Receive(OpenInfoSectionMessage message) + { + OpenSection(message.Value); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } - // CORRECTION: OpenSection logic needs to handle this hierarchy. - // Ideally, we find the GenHubInfoSectionViewModel, and tell IT to select "quickstart". + WeakReferenceMessenger.Default.UnregisterAll(this); + var faqSection = Sections.OfType().FirstOrDefault(); + if (faqSection != null) + { + faqSection.PropertyChanged -= OnFaqSectionPropertyChanged; + } - // Determine if the ID belongs to GenHubSection or is a top level section. - // The "Sections" property of InfoViewModel contains the TOP LEVEL providers (GuideContainer, FAQ, Changelogs). + _disposed = true; + GC.SuppressFinalize(this); + } - // Users pass "quickstart". This is inside "GenHub Guide". + partial void OnSelectedModuleChanged(string value) + { + UpdateSidebarItems(); + } - // Let's try to find it in the GenHubInfoSectionViewModel. + partial void OnSelectedSectionChanged(IInfoSectionViewModel? value) + { + if (value != null) + { + _ = value.InitializeAsync(); + } + } + + partial void OnSelectedSidebarItemChanged(object? value) + { + if (string.Equals(SelectedModule, InfoConstants.ModuleGuide, StringComparison.Ordinal) || + string.Equals(SelectedModule, InfoConstants.ModuleGeneralsOnline, StringComparison.Ordinal)) + { + var genHubSection = Sections.OfType().FirstOrDefault(); + if (genHubSection != null && value is InfoSectionViewModel infoSection) + { + genHubSection.SelectedSection = infoSection; } } else { - // It might be a sub-section of the GenHubInfoSectionViewModel - var genHubSection = Sections.OfType().FirstOrDefault(); - if (genHubSection != null) - { - // We need to check all potential sub-sections. - // The GenHubInfoSectionViewModel might only show filtered sections in its public 'Sections' property based on context. - // However, we can try to switch context to find it. - - // Heuristic search: - // 1. Try Guide Context - genHubSection.SetModuleContext(GeneralsHubModule.Guide); - if (genHubSection.Sections.Any(s => s.Id.Equals(sectionId, StringComparison.OrdinalIgnoreCase))) - { - SelectedModule = "GenHub Guide"; - OpenSubSection(genHubSection, sectionId); - return; - } - - // 2. Try GeneralsOnline Context - genHubSection.SetModuleContext(GeneralsHubModule.GeneralsOnline); - if (genHubSection.Sections.Any(s => s.Id.Equals(sectionId, StringComparison.OrdinalIgnoreCase))) - { - SelectedModule = "GeneralsOnline"; - OpenSubSection(genHubSection, sectionId); - return; - } - } + var faqSection = Sections.OfType().FirstOrDefault(); + if (faqSection != null && value is FaqCategoryViewModel faqCategory) + { + faqSection.SelectedCategory = faqCategory; + } } } - [ObservableProperty] - private string _selectedModule = "GenHub Guide"; - - /// - /// Gets a value indicating whether the "GenHub Guide" module is selected. - /// - public bool IsGuideSelected => SelectedModule == "GenHub Guide"; - - /// - /// Gets a value indicating whether the "Zero Hour" module is selected. - /// - public bool IsZeroHourSelected => SelectedModule == "Zero Hour"; - - /// - /// Gets a value indicating whether the "GeneralsOnline" module is selected. - /// - public bool IsGeneralsOnlineSelected => SelectedModule == "GeneralsOnline"; + private void TryOpenSubSection(string sectionId) + { + var genHubSection = Sections.OfType().FirstOrDefault(); + if (genHubSection == null) + { + return; + } - /// - /// Gets the items to display in the sidebar for the current module. - /// - [ObservableProperty] - private System.Collections.IEnumerable? _sidebarItems; + // 1. Try Guide Context + genHubSection.SetModuleContext(GeneralsHubModule.Guide); + var guideSubSection = genHubSection.Sections.FirstOrDefault(s => string.Equals(s.Id, sectionId, StringComparison.OrdinalIgnoreCase)); + if (guideSubSection != null) + { + SelectedModule = InfoConstants.ModuleGuide; + SelectedSection = genHubSection; + genHubSection.SelectedSection = guideSubSection; + SelectedSidebarItem = guideSubSection; + return; + } - [ObservableProperty] - private object? _selectedSidebarItem; + // 2. Try GeneralsOnline Context + genHubSection.SetModuleContext(GeneralsHubModule.GeneralsOnline); + var goSubSection = genHubSection.Sections.FirstOrDefault(s => string.Equals(s.Id, sectionId, StringComparison.OrdinalIgnoreCase)); + if (goSubSection != null) + { + SelectedModule = InfoConstants.ModuleGeneralsOnline; + SelectedSection = genHubSection; + genHubSection.SelectedSection = goSubSection; + SelectedSidebarItem = goSubSection; + return; + } - partial void OnSelectedModuleChanged(string value) - { - OnPropertyChanged(nameof(IsGuideSelected)); - OnPropertyChanged(nameof(IsZeroHourSelected)); - OnPropertyChanged(nameof(IsGeneralsOnlineSelected)); + var previousModule = string.Equals(SelectedModule, InfoConstants.ModuleGeneralsOnline, StringComparison.Ordinal) + ? GeneralsHubModule.GeneralsOnline + : GeneralsHubModule.Guide; + genHubSection.SetModuleContext(previousModule); UpdateSidebarItems(); } @@ -161,22 +233,19 @@ private void UpdateSidebarItems() faqSection.PropertyChanged -= OnFaqSectionPropertyChanged; } - if (IsGuideSelected) + if (string.Equals(SelectedModule, InfoConstants.ModuleGuide, StringComparison.Ordinal)) { var genHubSection = Sections.OfType().FirstOrDefault(); if (genHubSection != null) { - // Filter for Guide sections (exclude FAQ and Changelog identifiers if needed, - // but for now we'll filter them in the ViewModel or just reuse the section) - // Actually, we need to switch the context of the GenHubInfoSectionViewModel - genHubSection.SetModuleContext(GeneralsHubModule.Guide); - - SelectedSection = genHubSection; - SidebarItems = genHubSection.Sections; - SelectedSidebarItem = genHubSection.SelectedSection; + genHubSection.SetModuleContext(GeneralsHubModule.Guide); + + SelectedSection = genHubSection; + SidebarItems = genHubSection.Sections; + SelectedSidebarItem = genHubSection.SelectedSection; } } - else if (IsGeneralsOnlineSelected) + else if (string.Equals(SelectedModule, InfoConstants.ModuleGeneralsOnline, StringComparison.Ordinal)) { var genHubSection = Sections.OfType().FirstOrDefault(); if (genHubSection != null) @@ -208,6 +277,7 @@ private void UpdateSidebarItems() } } + [SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Observable property access on view model")] private void OnFaqSectionPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName == nameof(FaqSectionViewModel.SelectedCategory) && sender is FaqSectionViewModel faqSection) @@ -215,95 +285,4 @@ private void OnFaqSectionPropertyChanged(object? sender, System.ComponentModel.P SelectedSidebarItem = faqSection.SelectedCategory; } } - - partial void OnSelectedSidebarItemChanged(object? value) - { - if (IsGuideSelected || IsGeneralsOnlineSelected) - { - var genHubSection = Sections.OfType().FirstOrDefault(); - if (genHubSection != null && value is InfoSectionViewModel infoSection) - { - genHubSection.SelectedSection = infoSection; - } - } - else - { - var faqSection = Sections.OfType().FirstOrDefault(); - if (faqSection != null && value is FaqCategoryViewModel faqCategory) - { - faqSection.SelectedCategory = faqCategory; - } - } - } - - // Keep SelectedSection for content binding - - /// - /// Initializes a new instance of the class. - /// - /// The available info section view models. - public InfoViewModel(IEnumerable sectionViewModels) - { - _sectionViewModels = sectionViewModels; - Sections = new ObservableCollection(_sectionViewModels.OrderBy(s => s.Order)); - - // Default to GenHub Guide - SelectedSection = Sections.OfType().FirstOrDefault() - ?? Sections.FirstOrDefault(); - - // Initialize sidebar items - UpdateSidebarItems(); - - // Register for navigation messages - WeakReferenceMessenger.Default.Register(this); - } - - /// - public void Receive(OpenInfoSectionMessage message) - { - OpenSection(message.Value); - } - - /// - /// Initializes the view model and the selected section. - /// - /// A task representing the asynchronous operation. - public async Task InitializeAsync() - { - if (SelectedSection != null) - { - await SelectedSection.InitializeAsync(); - } - } - - /// - public void Dispose() - { - var faqSection = Sections.OfType().FirstOrDefault(); - if (faqSection != null) - { - faqSection.PropertyChanged -= OnFaqSectionPropertyChanged; - } - - GC.SuppressFinalize(this); - } - - partial void OnSelectedSectionChanged(IInfoSectionViewModel? value) - { - if (value != null) - { - _ = value.InitializeAsync(); - } - } - - private void OpenSubSection(GenHubInfoSectionViewModel parent, string sectionId) - { - var target = parent.Sections.FirstOrDefault(s => s.Id.Equals(sectionId, StringComparison.OrdinalIgnoreCase)); - if (target != null) - { - SelectedSection = parent; - parent.SelectedSection = target; - SelectedSidebarItem = target; - } - } } diff --git a/GenHub/GenHub/Features/Info/Views/ChangelogsView.axaml b/GenHub/GenHub/Features/Info/Views/ChangelogsView.axaml index e6793a0d9..c5292d053 100644 --- a/GenHub/GenHub/Features/Info/Views/ChangelogsView.axaml +++ b/GenHub/GenHub/Features/Info/Views/ChangelogsView.axaml @@ -78,7 +78,7 @@ - + diff --git a/GenHub/GenHub/Features/Info/Views/FaqSectionView.axaml b/GenHub/GenHub/Features/Info/Views/FaqSectionView.axaml index fb9048b2b..aac17c917 100644 --- a/GenHub/GenHub/Features/Info/Views/FaqSectionView.axaml +++ b/GenHub/GenHub/Features/Info/Views/FaqSectionView.axaml @@ -18,9 +18,9 @@ - + + + + @@ -82,7 +91,7 @@ - + diff --git a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml index 62f0318db..543bdf0ea 100644 --- a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml +++ b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml @@ -28,9 +28,9 @@ @@ -92,10 +92,10 @@ - - - - + SelectedItem="{Binding SelectedSidebarItem, Mode=TwoWay}" + IsPaneOpen="{Binding IsPaneOpen, Mode=TwoWay}" + OpenPaneLength="{Binding OpenPaneLength, Mode=TwoWay}"> + HorizontalAlignment="Stretch"> - + @@ -84,28 +62,24 @@ - - - + - + TextWrapping="Wrap" + Foreground="{DynamicResource TextPrimary}"/> diff --git a/GenHub/GenHub/Features/Info/Views/ScanWizardDemoView.axaml b/GenHub/GenHub/Features/Info/Views/ScanWizardDemoView.axaml index 2c6336f3e..c60f512e2 100644 --- a/GenHub/GenHub/Features/Info/Views/ScanWizardDemoView.axaml +++ b/GenHub/GenHub/Features/Info/Views/ScanWizardDemoView.axaml @@ -47,7 +47,7 @@ - + diff --git a/GenHub/GenHub/Features/Info/Views/WorkspaceDemoView.axaml b/GenHub/GenHub/Features/Info/Views/WorkspaceDemoView.axaml index e873d5d07..5ee5c9ab9 100644 --- a/GenHub/GenHub/Features/Info/Views/WorkspaceDemoView.axaml +++ b/GenHub/GenHub/Features/Info/Views/WorkspaceDemoView.axaml @@ -32,7 +32,7 @@ - + @@ -65,7 +65,7 @@ - + diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index 18e049652..361d79f47 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -971,7 +971,7 @@ private async Task> LaunchProfileAsync(Gam var launchConfig = BuildGameLaunchConfiguration(finalExecutablePath, workspaceInfo, arguments, profile, installation); - var archiveRootError = ValidateRetailArchiveRoots(launchConfig.EnvironmentVariables, installation, profile.GameClient!.GameType); + var archiveRootError = ValidateRetailArchiveRoots(launchConfig.EnvironmentVariables, installation, profile.GameClient.GameType); if (archiveRootError is not null) { logger.LogError("[GameLauncher] Retail archive root validation failed: {Error}", archiveRootError); @@ -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/Launching/SteamLauncher.cs b/GenHub/GenHub/Features/Launching/SteamLauncher.cs index 8d02ff433..fb1839a75 100644 --- a/GenHub/GenHub/Features/Launching/SteamLauncher.cs +++ b/GenHub/GenHub/Features/Launching/SteamLauncher.cs @@ -71,7 +71,7 @@ internal SteamLauncher( /// /// Configuration for the proxy launcher. /// - private class ProxyConfig + private sealed class ProxyConfig { public string? TargetExecutable { get; set; } @@ -238,9 +238,12 @@ public async Task> PrepareForProfileAsync config.WorkingDirectory, config.Arguments.Length); - foreach (var directory in appIdDirectories) + if (!string.IsNullOrEmpty(steamAppId)) { - await WriteSteamAppIdAsync(steamAppId!, directory, rollback, cancellationToken); + foreach (var directory in appIdDirectories) + { + await WriteSteamAppIdAsync(steamAppId, directory, rollback, cancellationToken); + } } foreach (var (sourcePath, destinationPath) in dependencyCopies) @@ -664,7 +667,7 @@ public async Task WriteTextAsync( await writer(stagingPath, contents, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); EnsureCapturedFileIsUnchanged(path); - var preparedContents = File.ReadAllBytes(stagingPath); + var preparedContents = await File.ReadAllBytesAsync(stagingPath, cancellationToken); File.Move(stagingPath, path, overwrite: true); _preparedFiles[path] = preparedContents; TrackMutation(path); 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/NotificationActionViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationActionViewModel.cs index 21a413cac..6f5ee49eb 100644 --- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationActionViewModel.cs +++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationActionViewModel.cs @@ -11,24 +11,26 @@ namespace GenHub.Features.Notifications.ViewModels; /// /// ViewModel for a single notification action button. /// -public partial class NotificationActionViewModel : ObservableObject +/// The notification action. +/// Callback to invoke when the action is executed. +public partial class NotificationActionViewModel(NotificationAction action, Action? onExecute) : ObservableObject { - private readonly NotificationAction _action; + private static readonly IBrush DefaultForegroundBrush = new SolidColorBrush(Colors.White); /// /// Gets the action style. /// - public NotificationActionStyle Style => _action.Style; + public NotificationActionStyle Style => action.Style; /// /// Gets the text to display on the action button. /// - public string Text { get; } + public string Text { get; } = action.Text; /// /// Gets the command to execute when the action button is clicked. /// - public ICommand ExecuteCommand { get; } + public ICommand ExecuteCommand { get; } = new RelayCommand(() => onExecute?.Invoke()); /// /// Gets the background brush for the action button based on its style. @@ -47,24 +49,6 @@ public partial class NotificationActionViewModel : ObservableObject /// public IBrush ForegroundBrush => Style switch { - NotificationActionStyle.Primary => new SolidColorBrush(Colors.White), - NotificationActionStyle.Secondary => new SolidColorBrush(Colors.White), - NotificationActionStyle.Danger => new SolidColorBrush(Colors.White), - NotificationActionStyle.Success => new SolidColorBrush(Colors.White), - _ => new SolidColorBrush(Colors.White), + _ => DefaultForegroundBrush, }; - - /// - /// Initializes a new instance of the class. - /// - /// The notification action. - /// Callback to invoke when the action is executed. - public NotificationActionViewModel( - NotificationAction action, - Action? onExecute) - { - _action = action ?? throw new ArgumentNullException(nameof(action)); - Text = action.Text; - ExecuteCommand = new RelayCommand(() => onExecute?.Invoke()); - } } 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/Notifications/Views/NotificationContainerView.axaml b/GenHub/GenHub/Features/Notifications/Views/NotificationContainerView.axaml index b415d769a..8c25eb28e 100644 --- a/GenHub/GenHub/Features/Notifications/Views/NotificationContainerView.axaml +++ b/GenHub/GenHub/Features/Notifications/Views/NotificationContainerView.axaml @@ -9,15 +9,16 @@ x:DataType="vm:NotificationManagerViewModel"> - - + diff --git a/GenHub/GenHub/Features/Notifications/Views/NotificationFeedItemView.axaml b/GenHub/GenHub/Features/Notifications/Views/NotificationFeedItemView.axaml index e2c400560..33338e1b3 100644 --- a/GenHub/GenHub/Features/Notifications/Views/NotificationFeedItemView.axaml +++ b/GenHub/GenHub/Features/Notifications/Views/NotificationFeedItemView.axaml @@ -9,23 +9,30 @@ + + + + + + @@ -70,20 +107,20 @@ + Margin="0,0,10,0" /> - + - + @@ -101,15 +138,11 @@ - @@ -126,10 +159,10 @@ VerticalAlignment="Top" Margin="0,8,8,0"> + Fill="#808090" + Width="12" + Height="12" + Stretch="Uniform" /> diff --git a/GenHub/GenHub/Features/Notifications/Views/NotificationFeedView.axaml b/GenHub/GenHub/Features/Notifications/Views/NotificationFeedView.axaml index 76d6af3b9..6762b6a38 100644 --- a/GenHub/GenHub/Features/Notifications/Views/NotificationFeedView.axaml +++ b/GenHub/GenHub/Features/Notifications/Views/NotificationFeedView.axaml @@ -11,21 +11,29 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - + + + + + + + @@ -254,7 +211,7 @@ - @@ -278,7 +235,7 @@ MinHeight="220"> + Foreground="{DynamicResource AccentBadgeBackgroundBrush}" Width="32" Height="32" /> diff --git a/GenHub/GenHub/Features/Notifications/Views/NotificationToastView.axaml b/GenHub/GenHub/Features/Notifications/Views/NotificationToastView.axaml index ce158191b..6e93ff2fb 100644 --- a/GenHub/GenHub/Features/Notifications/Views/NotificationToastView.axaml +++ b/GenHub/GenHub/Features/Notifications/Views/NotificationToastView.axaml @@ -9,129 +9,147 @@ x:DataType="vm:NotificationItemViewModel"> - + - - + - + - + - + + + @@ -148,11 +166,11 @@ MaxWidth="400" MinWidth="320" Margin="0,4" - BoxShadow="0 8 32 0 #40000000"> + BoxShadow="0 8 32 0 #50000000"> - + @@ -174,11 +192,11 @@ VerticalAlignment="Center"/> - + @@ -199,20 +217,15 @@ Width="12" Height="12" Stretch="Uniform"/> - - - - - @@ -224,18 +237,12 @@ Content="{Binding ActionText}" BorderThickness="0" CornerRadius="6" - Padding="16,8" - Margin="44,12,0,0" + Padding="14,6" + Margin="44,10,0,0" HorizontalAlignment="Left" FontWeight="SemiBold" - FontSize="13" - Cursor="Hand"> - - - - - - + FontSize="12.5" + Cursor="Hand" /> diff --git a/GenHub/GenHub/Features/Settings/Models/SettingsSectionItem.cs b/GenHub/GenHub/Features/Settings/Models/SettingsSectionItem.cs new file mode 100644 index 000000000..727fe3170 --- /dev/null +++ b/GenHub/GenHub/Features/Settings/Models/SettingsSectionItem.cs @@ -0,0 +1,12 @@ +namespace GenHub.Features.Settings.Models; + +/// +/// Represents a section entry in the settings sidebar navigation. +/// +/// The unique identifier of the settings section. +/// The display title of the settings section. +/// The SVG path data representing the section icon. +public sealed record SettingsSectionItem( + string Id, + string Title, + string IconData); diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index 32cce78ad..d411e72e7 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -26,7 +26,9 @@ using GenHub.Core.Messages; using GenHub.Core.Models.AppUpdate; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Theming; using GenHub.Features.AppUpdate.Interfaces; +using GenHub.Features.Settings.Models; using Microsoft.Extensions.Logging; namespace GenHub.Features.Settings.ViewModels; @@ -38,11 +40,6 @@ public partial class SettingsViewModel : ObservableObject, IDisposable { private static readonly char[] LineSeparators = ['\r', '\n']; - /// - /// Gets the available themes for selection in the UI. - /// - public static IEnumerable AvailableThemes => ["Dark", "Light"]; - /// /// Gets the available workspace strategies for selection in the UI. /// @@ -53,6 +50,29 @@ public partial class SettingsViewModel : ObservableObject, IDisposable /// public static string CurrentVersion => AppConstants.FullDisplayVersion; + /// + /// Gets the available themes for selection in the UI. + /// + public IReadOnlyList AvailableThemes => _themeService?.AvailableThemes ?? ThemeConstants.AllThemes; + + /// + /// Gets the list of available settings sections for sidebar navigation. + /// + public IReadOnlyList Sections { get; } = + [ + new(SettingsConstants.SectionGameConfig, "Game Configuration", "M7,5V19H17V5H7M7,3H17A2,2 0 0,1 19,5V19A2,2 0 0,1 17,21H7A2,2 0 0,1 5,19V5A2,2 0 0,1 7,3M9,7H15V9H9V7M9,11H15V13H9V11M9,15H15V17H9V15Z"), + new(SettingsConstants.SectionDownloads, "Downloads", "M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"), + new(SettingsConstants.SectionAppearance, "Appearance", "M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"), + new(SettingsConstants.SectionDataDirectories, "Data Directories", "M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z"), + new(SettingsConstants.SectionLogs, "Logs", "M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"), + new(SettingsConstants.SectionPerformance, "Performance", "M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z"), + new(SettingsConstants.SectionCas, "CAS Storage", "M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,16.21 4,14Z"), + new(SettingsConstants.SectionLocalContent, "Local Content", "M19,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10L12,6H19A2,2 0 0,1 21,8H21L4,8V18L6.14,10H23.21L20.93,18.5C20.7,19.37 19.92,20 19,20Z"), + new(SettingsConstants.SectionGitHubDiscovery, "GitHub Discovery", "M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z"), + new(SettingsConstants.SectionUpdates, "Updates", "M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.86,17.45 19.71,14H17.58C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"), + new(SettingsConstants.SectionDangerZone, "Danger Zone", "M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z"), + ]; + private readonly IUserSettingsService _userSettingsService; private readonly ICasService _casService; private readonly IGameProfileManager _profileManager; @@ -68,6 +88,8 @@ public partial class SettingsViewModel : ObservableObject, IDisposable private readonly IGameInstallationService _installationService; private readonly IStorageLocationService _storageLocationService; private readonly IUserDataTracker _userDataTracker; + private readonly IDialogService _dialogService; + private readonly IThemeService? _themeService; private bool _isViewVisible; private bool _disposed; @@ -78,7 +100,19 @@ public partial class SettingsViewModel : ObservableObject, IDisposable private int _downloadTimeoutSeconds = DownloadDefaults.TimeoutSeconds; [ObservableProperty] - private string _theme = "Dark"; + private SettingsSectionItem? _selectedSection; + + [ObservableProperty] + private bool _isPaneOpen = true; + + [ObservableProperty] + private double _openPaneLength = SidebarConstants.DefaultOpenPaneLength; + + [ObservableProperty] + private string _theme = ThemeConstants.DefaultTheme.Id; + + [ObservableProperty] + private ColorTheme _selectedTheme = ThemeConstants.DefaultTheme; [ObservableProperty] private string _latestVersion = "Checking..."; @@ -129,22 +163,22 @@ public partial class SettingsViewModel : ObservableObject, IDisposable private bool _autoCheckForUpdatesOnStartup = true; [ObservableProperty] - private bool _allowBackgroundDownloads = true; + private bool _autoCheckForUpdatesPeriodically = true; [ObservableProperty] - private bool _enableDetailedLogging = false; + private int _periodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; [ObservableProperty] - private WorkspaceStrategy _defaultWorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy; + private bool _allowBackgroundDownloads = true; [ObservableProperty] - private bool _isSaving = false; + private bool _enableDetailedLogging = false; [ObservableProperty] - private bool _showSaveNotification = false; + private WorkspaceStrategy _defaultWorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy; [ObservableProperty] - private string _saveButtonText = "Save Settings"; + private bool _isSaving = false; [ObservableProperty] private string? _cachePath; @@ -195,12 +229,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 +244,8 @@ public partial class SettingsViewModel : ObservableObject, IDisposable /// Game installation service. /// Storage location service. /// User data tracker service. + /// Dialog service used to confirm destructive actions. + /// Theme service for dynamic accent theming. /// GitHub token storage. public SettingsViewModel( IUserSettingsService userSettingsService, @@ -230,6 +260,8 @@ public SettingsViewModel( IGameInstallationService installationService, IStorageLocationService storageLocationService, IUserDataTracker userDataTracker, + IDialogService dialogService, + IThemeService? themeService = null, IGitHubTokenStorage? gitHubTokenStorage = null) { _userSettingsService = userSettingsService ?? throw new ArgumentNullException(nameof(userSettingsService)); @@ -244,6 +276,8 @@ 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)); + _themeService = themeService; _gitHubTokenStorage = gitHubTokenStorage; LoadSettings(); @@ -252,7 +286,7 @@ public SettingsViewModel( // Initialize with default if needed if (string.IsNullOrWhiteSpace(_theme)) { - _theme = AppConstants.DefaultThemeName; + _theme = ThemeConstants.DefaultTheme.Id; } if (DownloadTimeoutSeconds == 0) DownloadTimeoutSeconds = 30; @@ -482,10 +516,17 @@ private void LoadSettings() try { var settings = _userSettingsService.Get(); - Theme = settings.Theme ?? AppConstants.DefaultThemeName; + var currentThemeId = settings.Theme ?? ThemeConstants.DefaultTheme.Id; + SelectedTheme = AvailableThemes.FirstOrDefault(t => + string.Equals(t.Id, currentThemeId, StringComparison.OrdinalIgnoreCase) || + string.Equals(t.DisplayName, currentThemeId, StringComparison.OrdinalIgnoreCase)) + ?? ThemeConstants.DefaultTheme; + Theme = SelectedTheme.Id; WorkspacePath = settings.WorkspacePath; MaxConcurrentDownloads = settings.MaxConcurrentDownloads; AutoCheckForUpdatesOnStartup = settings.AutoCheckForUpdatesOnStartup; + AutoCheckForUpdatesPeriodically = settings.AutoCheckForUpdatesPeriodically; + PeriodicUpdateCheckIntervalMinutes = settings.PeriodicUpdateCheckIntervalMinutes; AllowBackgroundDownloads = settings.AllowBackgroundDownloads; EnableDetailedLogging = settings.EnableDetailedLogging; DefaultWorkspaceStrategy = settings.DefaultWorkspaceStrategy; @@ -514,6 +555,8 @@ private void LoadSettings() catch (Exception ex) { _logger.LogError(ex, "Failed to load settings"); + Theme = AppConstants.DefaultThemeName; + SelectedTheme = ThemeConstants.DefaultTheme; } } @@ -525,8 +568,6 @@ private async Task SaveSettings() try { IsSaving = true; - SaveButtonText = "Saving..."; - ShowSaveNotification = false; // Validate settings before saving if (!ValidateSettings()) @@ -540,6 +581,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,37 +612,44 @@ 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); _logger.LogInformation("Settings saved successfully"); - - // Show success notification - ShowSaveNotification = true; - - // Hide notification after 3 seconds - _ = Task.Delay(TimeIntervals.NotificationHideDelay).ContinueWith(_ => ShowSaveNotification = false); } catch (Exception ex) { _logger.LogError(ex, "Failed to save settings"); + _notificationService.ShowError( + "Settings Not Saved", + ex.Message, + (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds); } finally { IsSaving = false; - SaveButtonText = "Save Settings"; } } [RelayCommand] private async Task ResetToDefaults() { + if (IsSaving) return; + try { - Theme = AppConstants.DefaultThemeName; + Theme = ThemeConstants.DefaultTheme.Id; WorkspacePath = string.Empty; MaxConcurrentDownloads = DownloadDefaults.MaxConcurrentDownloads; AutoCheckForUpdatesOnStartup = true; + AutoCheckForUpdatesPeriodically = true; + PeriodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; AllowBackgroundDownloads = true; EnableDetailedLogging = false; DefaultWorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy; @@ -744,6 +794,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 +1065,6 @@ private async Task DeletePatAsync() HasGitHubPat = false; IsPatValid = false; PatStatusMessage = "GitHub PAT removed"; - AvailableArtifacts.Clear(); } catch (Exception ex) { @@ -1034,66 +1091,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 +1358,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; } } @@ -1337,9 +1410,55 @@ private void OnDownloadSettingsChanged(DownloadSettingsChangedMessage message) DownloadUserAgent = message.UserAgent; } + partial void OnThemeChanged(string value) + { + var matchingTheme = AvailableThemes.FirstOrDefault(t => + string.Equals(t.Id, value, StringComparison.OrdinalIgnoreCase) || + string.Equals(t.DisplayName, value, StringComparison.OrdinalIgnoreCase)) ?? ThemeConstants.DefaultTheme; + + if (SelectedTheme != matchingTheme) + { + SelectedTheme = matchingTheme; + _themeService?.ApplyTheme(matchingTheme); + _userSettingsService.Update(settings => settings.Theme = matchingTheme.Id); + _ = _userSettingsService.SaveAsync(); + } + } + + /// + /// Selects and immediately applies the specified color theme. + /// + /// The color theme to select and apply. + [RelayCommand] + private async Task SelectColorTheme(ColorTheme? theme) + { + if (theme is null) + { + return; + } + + SelectedTheme = theme; + Theme = theme.Id; + _themeService?.ApplyTheme(theme); + + _userSettingsService.Update(settings => + { + settings.Theme = theme.Id; + }); + await _userSettingsService.SaveAsync(); + } + private void OnThemeSettingsChanged(ThemeChangedMessage message) { - Theme = message.ThemeName; + var matchingTheme = AvailableThemes.FirstOrDefault(t => + string.Equals(t.Id, message.ThemeName, StringComparison.OrdinalIgnoreCase) || + string.Equals(t.DisplayName, message.ThemeName, StringComparison.OrdinalIgnoreCase)); + + if (matchingTheme != null) + { + SelectedTheme = matchingTheme; + Theme = matchingTheme.Id; + } } [RelayCommand] diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml index 719a8ae05..07543eee6 100644 --- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml +++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml @@ -3,7 +3,10 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:GenHub.Features.Settings.ViewModels" + xmlns:models="clr-namespace:GenHub.Features.Settings.Models" + xmlns:theming="clr-namespace:GenHub.Core.Models.Theming;assembly=GenHub.Core" xmlns:converters="clr-namespace:GenHub.Infrastructure.Converters" + xmlns:commonControls="clr-namespace:GenHub.Common.Controls" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600" x:Class="GenHub.Features.Settings.Views.SettingsView" x:DataType="vm:SettingsViewModel" @@ -12,290 +15,177 @@ + + + + + + + + - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + + - - + + + FontSize="14" Foreground="{DynamicResource TextSecondary}" /> - - - - - - + + Width="16" Height="16" Foreground="{DynamicResource TextPrimary}"/> - + @@ -312,15 +202,15 @@ - + + Width="16" Height="16" Foreground="{DynamicResource TextPrimary}"/> - + @@ -363,7 +253,7 @@ Width="120" FormatString="F0" LostFocus="OnTextBoxLostFocus" /> - @@ -388,41 +278,96 @@ - - + + + Width="16" Height="16" Foreground="{DynamicResource TextPrimary}"/> - - + + - - - + + + + + + + + + + + + + + + + + + + + + - + + Width="16" Height="16" Foreground="{DynamicResource TextPrimary}"/> - + @@ -465,9 +410,9 @@ Watermark="Default (platform-dependent)" LostFocus="OnTextBoxLostFocus" /> public partial class SettingsView : UserControl { + private SettingsViewModel? _boundViewModel; + /// /// Initializes a new instance of the class. /// @@ -20,7 +26,7 @@ public SettingsView() InitializeComponent(); // Handle pointer press to unfocus text boxes when clicking elsewhere - this.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); + AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); } /// @@ -33,6 +39,11 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) if (DataContext is SettingsViewModel vm) { vm.IsViewVisible = true; + HookViewModel(vm); + if (vm.SelectedSection != null) + { + ScrollToSection(vm.SelectedSection); + } } } @@ -43,9 +54,11 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); + UnhookViewModel(); if (DataContext is SettingsViewModel vm) { vm.IsViewVisible = false; + _ = vm.SaveSettingsCommand.ExecuteAsync(null); } } @@ -56,10 +69,77 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); + UnhookViewModel(); if (DataContext is SettingsViewModel vm) { // Sync visibility state with current visual tree state - vm.IsViewVisible = this.VisualRoot != null; + vm.IsViewVisible = VisualRoot != null; + HookViewModel(vm); + } + } + + private void HookViewModel(SettingsViewModel vm) + { + if (ReferenceEquals(_boundViewModel, vm)) + { + return; + } + + UnhookViewModel(); + _boundViewModel = vm; + _boundViewModel.PropertyChanged += OnViewModelPropertyChanged; + } + + private void UnhookViewModel() + { + if (_boundViewModel != null) + { + _boundViewModel.PropertyChanged -= OnViewModelPropertyChanged; + _boundViewModel = null; + } + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SettingsViewModel.SelectedSection) && _boundViewModel != null) + { + ScrollToSection(_boundViewModel.SelectedSection); + } + } + + private void ScrollToSection(SettingsSectionItem? section) + { + if (section is null) + { + return; + } + + var expanderName = section.Id switch + { + SettingsConstants.SectionGameConfig => "Expander_GameConfig", + SettingsConstants.SectionDownloads => "Expander_Downloads", + SettingsConstants.SectionAppearance => "Expander_Appearance", + SettingsConstants.SectionDataDirectories => "Expander_DataDirectories", + SettingsConstants.SectionLogs => "Expander_Logs", + SettingsConstants.SectionPerformance => "Expander_Performance", + SettingsConstants.SectionCas => "Expander_Cas", + SettingsConstants.SectionLocalContent => "Expander_LocalContent", + SettingsConstants.SectionGitHubDiscovery => "Expander_GitHubDiscovery", + SettingsConstants.SectionUpdates => "Expander_Updates", + SettingsConstants.SectionDangerZone => "Expander_DangerZone", + _ => null, + }; + + if (expanderName is null) + { + return; + } + + var expander = this.FindControl(expanderName); + if (expander != null) + { + expander.IsExpanded = true; + Dispatcher.UIThread.Post(() => expander.BringIntoView(), DispatcherPriority.Render); } } @@ -68,7 +148,7 @@ private void OnPointerPressed(object? sender, Avalonia.Input.PointerPressedEvent // If clicking outside of a TextBox, clear focus from any focused TextBox if (e.Source is not TextBox) { - this.Focus(); + Focus(); } } @@ -95,24 +175,6 @@ private void OnOpenPatCreationUrl(object? sender, RoutedEventArgs e) } } - private void OnViewWorkflowRun(object? sender, RoutedEventArgs e) - { - if (sender is Button button && button.Tag is string url && !string.IsNullOrEmpty(url)) - { - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) - { - UseShellExecute = true, - }); - } - catch - { - // Silently fail if browser cannot be opened - } - } - } - /// /// Loads and initializes the XAML components for this view. /// diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs index 020ecc314..e839380da 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs @@ -116,13 +116,7 @@ public ICasStorage GetStorage(ContentType contentType) public IReadOnlyList GetAllStorages() { var storages = _storages.Values.ToList(); - foreach (var legacyInstallationStorage in _legacyInstallationStorages) - { - if (!storages.Contains(legacyInstallationStorage)) - { - storages.Add(legacyInstallationStorage); - } - } + storages.AddRange(_legacyInstallationStorages.Where(legacyInstallationStorage => !storages.Contains(legacyInstallationStorage))); return storages.AsReadOnly(); } @@ -180,6 +174,17 @@ private static string NormalizeRoot(string? rootPath) : Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); } + private static bool IsInsideApplicationDirectory(string rootPath) + { + var appBaseDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppContext.BaseDirectory)); + var normalizedRootPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); + + return normalizedRootPath.Equals(appBaseDirectory, PathHelper.PathComparison) || + normalizedRootPath.StartsWith( + appBaseDirectory + Path.DirectorySeparatorChar, + PathHelper.PathComparison); + } + private void InitializePool(CasPoolType poolType) { // Double-check locking to ensure thread safety @@ -312,15 +317,4 @@ private void RefreshLegacyInstallationPool(string activeInstallationRoot) string.Join(", ", retainedRoots)); } } - - private bool IsInsideApplicationDirectory(string rootPath) - { - var appBaseDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppContext.BaseDirectory)); - var normalizedRootPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); - - return normalizedRootPath.Equals(appBaseDirectory, PathHelper.PathComparison) || - normalizedRootPath.StartsWith( - appBaseDirectory + Path.DirectorySeparatorChar, - PathHelper.PathComparison); - } } diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapDirectoryService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapDirectoryService.cs index ffe46ae2a..bd20d0f0e 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/Services/MapDirectoryService.cs +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapDirectoryService.cs @@ -1,3 +1,10 @@ +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.Interfaces.Common; using GenHub.Core.Interfaces.Tools.MapManager; @@ -5,12 +12,6 @@ using GenHub.Core.Models.Tools.MapManager; using GenHub.Infrastructure.Imaging; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace GenHub.Features.Tools.MapManager.Services; @@ -238,7 +239,12 @@ public void OpenInExplorer(GameType version) try { - System.Diagnostics.Process.Start("explorer.exe", directory); + Process.Start(new ProcessStartInfo + { + FileName = PlatformConstants.WindowsExplorerPath, + Arguments = directory, + UseShellExecute = true, + }); } catch (Exception ex) { @@ -251,7 +257,12 @@ public void RevealInExplorer(MapFile map) { try { - System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{map.FullPath}\""); + Process.Start(new ProcessStartInfo + { + FileName = PlatformConstants.WindowsExplorerPath, + Arguments = string.Format(PlatformConstants.WindowsExplorerSelectArgument, map.FullPath), + UseShellExecute = true, + }); } catch (Exception ex) { diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapExportService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapExportService.cs index 4c47f8503..0aaa42b0e 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/Services/MapExportService.cs +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapExportService.cs @@ -1,8 +1,3 @@ -using GenHub.Core.Constants; -using GenHub.Core.Interfaces.Services; -using GenHub.Core.Interfaces.Tools.MapManager; -using GenHub.Core.Models.Tools.MapManager; -using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.IO; @@ -10,6 +5,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Services; +using GenHub.Core.Interfaces.Tools.MapManager; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.MapManager; +using GenHub.Core.Models.Tools.UploadThing; +using Microsoft.Extensions.Logging; namespace GenHub.Features.Tools.MapManager.Services; @@ -22,61 +24,53 @@ public sealed class MapExportService( ILogger logger) : IMapExportService { /// - /// Maximum total upload size. + /// Maximum single upload size (10 MB gateway limit). /// - private const long MaxTotalUploadBytes = MapManagerConstants.MaxUploadBytesPerPeriod; + private const long MaxTotalUploadBytes = MapManagerConstants.MaxMapSizeBytes; /// - public async Task UploadToUploadThingAsync( + public async Task> UploadToUploadThingAsync( IEnumerable maps, IProgress? progress = null, CancellationToken ct = default) { + var mapList = maps.ToList(); + if (mapList.Count == 0) + { + return OperationResult.CreateFailure("No maps selected for upload."); + } + string? zipToUpload = null; bool isTemporaryZip = false; try { - var mapList = maps.ToList(); - if (mapList.Count == 0) return null; - - if (mapList.Count == 1 && mapList[0].FileName.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase)) - { - var (isValid, errorMessage) = importService.ValidateZip(mapList[0].FullPath); - if (!isValid) - { - logger.LogError("ZIP validation failed for upload: {Error}", errorMessage); - throw new ArgumentException(errorMessage ?? "Invalid ZIP archive for upload."); - } + var (path, isTemp, uploadProgress) = await ResolveZipToUploadAsync(mapList, progress, ct); + zipToUpload = path; + isTemporaryZip = isTemp; - zipToUpload = mapList[0].FullPath; - } - else + if (string.IsNullOrEmpty(zipToUpload) || !File.Exists(zipToUpload)) { - var tempZip = Path.Combine(Path.GetTempPath(), $"genhub_maps_{Guid.NewGuid()}.zip"); - var createdZip = await ExportToZipAsync(mapList, tempZip, progress, ct); - if (createdZip == null) return null; - - zipToUpload = createdZip; - isTemporaryZip = true; + return OperationResult.CreateFailure("Failed to prepare map archive for upload."); } if (new FileInfo(zipToUpload).Length > MaxTotalUploadBytes) { - logger.LogError("File exceeds size limit: {Path}", zipToUpload); - return null; + logger.LogError("File exceeds size limit of 10MB: {Path}", zipToUpload); + return OperationResult.CreateFailure("Exported archive exceeds maximum size limit of 10MB."); } - return await uploadThingService.UploadFileAsync(zipToUpload, progress, ct); + return await uploadThingService.UploadFileAsync(zipToUpload, uploadProgress, ct); } - catch (ArgumentException) + catch (ArgumentException ex) { - throw; + logger.LogError(ex, "Invalid map argument for upload"); + return OperationResult.CreateFailure(ex.Message); } - catch (Exception ex) + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or InvalidOperationException) && ex is not OperationCanceledException) { logger.LogError(ex, "Failed to upload to UploadThing"); - return null; + return OperationResult.CreateFailure($"Map export failed: {ex.Message}"); } finally { @@ -111,7 +105,7 @@ public sealed class MapExportService( foreach (var map in mapList) { count++; - progress?.Report((double)count / total * 0.4); + progress?.Report((double)count / total); if (map.IsDirectory) { @@ -161,4 +155,29 @@ public sealed class MapExportService( return null; } } + + private async Task<(string? Path, bool IsTemporary, IProgress? UploadProgress)> ResolveZipToUploadAsync( + IReadOnlyList mapList, + IProgress? progress, + CancellationToken ct) + { + if (mapList.Count == 1 && mapList[0].FileName.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase)) + { + var (isValid, errorMessage) = importService.ValidateZip(mapList[0].FullPath); + if (!isValid) + { + logger.LogError("ZIP validation failed for upload: {Error}", errorMessage); + throw new ArgumentException(errorMessage ?? "Invalid ZIP archive for upload."); + } + + return (mapList[0].FullPath, false, progress); + } + + var tempZip = Path.Combine(Path.GetTempPath(), $"{MapManagerConstants.TempShareFilePrefix}{Guid.NewGuid()}{FileTypes.ZipFileExtension}"); + var zipProgress = progress != null ? new Progress(p => progress.Report(p * 0.25)) : null; + var uploadProgress = progress != null ? new Progress(p => progress.Report(0.25 + (p * 0.75))) : null; + + var createdZip = await ExportToZipAsync(mapList, tempZip, zipProgress, ct); + return (createdZip, true, uploadProgress); + } } diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs index a1e4365df..64486684b 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs @@ -2,6 +2,7 @@ using GenHub.Core.Interfaces.Tools.MapManager; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Tools.MapManager; +using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -23,7 +24,7 @@ public sealed class MapImportService( MapNameParser mapNameParser, ILogger logger) : IMapImportService { - private static readonly char[] PathSeparators = ['/', '\'']; + private static readonly char[] PathSeparators = ['/', '\\']; /// public async Task ImportFromUrlAsync( @@ -42,7 +43,7 @@ public async Task ImportFromUrlAsync( var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); - var fileName = GetFileNameFromUri(new Uri(url), response); + var fileName = ExtractFileName(new Uri(url), response); Directory.CreateDirectory(tempDir); var tempPath = Path.Combine(tempDir, fileName); @@ -97,7 +98,7 @@ public async Task ImportFromUrlAsync( result = await ImportFromFilesAsync([tempPath], targetVersion, ct); } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to import from URL: {Url}", url); result.Errors.Add($"Import failed: {ex.Message}"); @@ -207,7 +208,7 @@ public async Task ImportFromFilesAsync( }; result.ImportedMaps.Add(mapFile); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to import file: {FilePath}", filePath); result.Errors.Add($"Failed to import {Path.GetFileName(filePath)}: {ex.Message}"); @@ -226,7 +227,7 @@ public async Task ImportFromZipAsync( CancellationToken ct = default) { return await Task.Run( - () => + async () => { var result = new ImportResult(); var (isValid, errorMessage) = ValidateZip(zipPath); @@ -256,6 +257,7 @@ public async Task ImportFromZipAsync( int totalMaps = 0; int processedMaps = 0; + long expandedBytes = 0; // Count total maps for progress foreach (var group in entriesByDirectory) @@ -271,6 +273,8 @@ public async Task ImportFromZipAsync( foreach (var mapEntry in mapEntries) { + ct.ThrowIfCancellationRequested(); + if (mapEntry.Length > IMapImportService.MaxMapSizeBytes) { result.Errors.Add($"Map too large: {mapEntry.Name}"); @@ -288,39 +292,72 @@ public async Task ImportFromZipAsync( } var mapDirPath = GetUniqueDirectoryPath(Path.Combine(targetDir, mapDirName)); - Directory.CreateDirectory(mapDirPath); - - // Extract the .map file var mapDestPath = Path.Combine(mapDirPath, mapEntry.Name); - mapEntry.ExtractToFile(mapDestPath, false); - var assetFiles = new List(); string? thumbnailPath = null; - // Extract related asset files from the same directory in the ZIP - if (!string.IsNullOrEmpty(directoryName)) + long mapExpandedBytes = 0; + + try { - var assetEntries = entries.Where(e => - !e.Name.EndsWith(".map", StringComparison.OrdinalIgnoreCase) && - MapManagerConstants.AllowedExtensions.Contains(Path.GetExtension(e.Name), StringComparer.OrdinalIgnoreCase)); + Directory.CreateDirectory(mapDirPath); - foreach (var assetEntry in assetEntries) + await using (var mapStream = mapEntry.Open()) { - var assetDestPath = Path.Combine(mapDirPath, assetEntry.Name); - if (!File.Exists(assetDestPath)) - { - assetEntry.ExtractToFile(assetDestPath, false); - } + mapExpandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + mapStream, + mapDestPath, + mapEntry.FullName, + IMapImportService.MaxMapSizeBytes, + MapManagerConstants.MaxAggregateUncompressedBytes - expandedBytes - mapExpandedBytes, + cancellationToken: ct); + } - assetFiles.Add(assetDestPath); + // Extract related asset files from the same directory in the ZIP + if (!string.IsNullOrEmpty(directoryName)) + { + var assetEntries = entries.Where(e => + !e.Name.EndsWith(".map", StringComparison.OrdinalIgnoreCase) && + MapManagerConstants.AllowedExtensions.Contains(Path.GetExtension(e.Name), StringComparer.OrdinalIgnoreCase)); - // Check for thumbnail - if (assetEntry.Name.Equals(MapManagerConstants.DefaultThumbnailName, StringComparison.OrdinalIgnoreCase) || - (thumbnailPath == null && assetEntry.Name.EndsWith(".tga", StringComparison.OrdinalIgnoreCase))) + foreach (var assetEntry in assetEntries) { - thumbnailPath = assetDestPath; + var assetDestPath = Path.Combine(mapDirPath, assetEntry.Name); + if (!File.Exists(assetDestPath)) + { + await using var assetStream = assetEntry.Open(); + mapExpandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + assetStream, + assetDestPath, + assetEntry.FullName, + MapManagerConstants.MaxAssetSizeBytes, + MapManagerConstants.MaxAggregateUncompressedBytes - expandedBytes - mapExpandedBytes, + cancellationToken: ct); + } + + assetFiles.Add(assetDestPath); + + // Check for thumbnail + if (assetEntry.Name.Equals(MapManagerConstants.DefaultThumbnailName, StringComparison.OrdinalIgnoreCase) || + (thumbnailPath == null && assetEntry.Name.EndsWith(".tga", StringComparison.OrdinalIgnoreCase))) + { + thumbnailPath = assetDestPath; + } } } + + expandedBytes += mapExpandedBytes; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning( + "Discarding map {Entry} from {ZipPath}: {Reason}", + mapEntry.FullName, + zipPath, + ex.Message); + result.Errors.Add(ex.Message); + DeleteDirectoryBestEffort(mapDirPath); + continue; } var totalSize = new FileInfo(mapDestPath).Length + assetFiles.Sum(f => new FileInfo(f).Length); @@ -352,6 +389,11 @@ public async Task ImportFromZipAsync( progress?.Report(1.0); } + catch (OperationCanceledException) + { + logger.LogInformation("Import from ZIP was cancelled: {ZipPath}", zipPath); + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to import from ZIP: {ZipPath}", zipPath); @@ -512,7 +554,7 @@ public async Task ImportFromStreamAsync( } } - private static string GetFileNameFromUri(Uri uri, HttpResponseMessage response) + private static string ExtractFileName(Uri uri, HttpResponseMessage response) { var rawName = response.Content.Headers.ContentDisposition?.FileNameStar ?? response.Content.Headers.ContentDisposition?.FileName; @@ -543,6 +585,25 @@ private static string GetFileNameFromUri(Uri uri, HttpResponseMessage response) return $"map_{Guid.NewGuid():N}.zip"; } + private static void DeleteDirectoryBestEffort(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException) + { + // Best effort cleanup + } + catch (UnauthorizedAccessException) + { + // Best effort cleanup + } + } + private static string GetUniqueFilePath(string path) { if (!File.Exists(path)) diff --git a/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs b/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs index 716ab7f69..35dbbdcf4 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs +++ b/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs @@ -1,3 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -6,6 +16,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.Content; using GenHub.Core.Interfaces.Notifications; @@ -14,15 +25,10 @@ using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Tools.MapManager; +using GenHub.Core.Models.Tools.UploadThing; using GenHub.Features.Tools.ViewModels; using GenHub.Infrastructure.Imaging; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Threading.Tasks; namespace GenHub.Features.Tools.MapManager.ViewModels; @@ -92,8 +98,18 @@ public MapManagerViewModel( private bool isBusy; [ObservableProperty] + private bool isIndeterminate; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ProgressPercentage))] private double progress; + /// + /// Gets the current progress as a whole integer percentage between 0 and 100. + /// + [SuppressMessage("Major Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Instance property required for Avalonia UI data binding")] + public int ProgressPercentage => (int)Math.Round(Progress * 100); + [ObservableProperty] private string statusMessage = "Ready"; @@ -139,8 +155,8 @@ private void ApplyFilter() var source = SelectedTab == GameType.Generals ? GeneralsMaps : ZeroHourMaps; var filtered = string.IsNullOrWhiteSpace(SearchText) ? (IEnumerable)source - : source.Where(m => m.DisplayName?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) == true || - m.DirectoryName?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) == true); + : source.Where(m => (m.DisplayName is not null && m.DisplayName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)) || + (m.DirectoryName is not null && m.DirectoryName.Contains(SearchText, StringComparison.OrdinalIgnoreCase))); // Replace the collection to avoid multiple notifications CurrentMaps = new ObservableCollection(filtered); @@ -234,6 +250,7 @@ public async Task InitializeAsync() public async Task LoadMapsAsync() { IsBusy = true; + IsIndeterminate = true; StatusMessage = "Loading maps..."; try { @@ -310,8 +327,7 @@ public async Task ImportFilesAsync(IEnumerable filePaths) { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { // Show notification toast explaining what the button does _notificationService.ShowInfo( @@ -321,6 +337,7 @@ public async Task ImportFilesAsync(IEnumerable filePaths) } IsBusy = true; + IsIndeterminate = true; StatusMessage = "Importing files..."; try { @@ -351,6 +368,27 @@ public async Task ImportFilesAsync(IEnumerable filePaths) } } + private static bool IsDemoPath(string path) => + path.Contains(MapManagerConstants.WindowsMockPathSegment, StringComparison.OrdinalIgnoreCase) || + path.Contains(MapManagerConstants.UnixMockPathSegment, StringComparison.OrdinalIgnoreCase); + + private static string GetUniqueZipDestinationPath(string directory, string rawZipName) + { + var safeZipName = PathHelper.SanitizeFileName(rawZipName); + if (string.IsNullOrWhiteSpace(safeZipName)) + { + safeZipName = MapManagerConstants.DefaultZipName; + } + + var zipExtension = Path.GetExtension(MapManagerConstants.ZipFilePattern); + if (!safeZipName.EndsWith(zipExtension, StringComparison.OrdinalIgnoreCase)) + { + safeZipName += zipExtension; + } + + return PathHelper.GetUniqueNumberedPath(Path.Combine(directory, safeZipName)); + } + [RelayCommand] private async Task ImportFromUrlAsync() { @@ -361,8 +399,7 @@ private async Task ImportFromUrlAsync() // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { // Show notification toast explaining what the button does _notificationService.ShowInfo( @@ -372,12 +409,19 @@ private async Task ImportFromUrlAsync() } IsBusy = true; - StatusMessage = "Importing from URL..."; + IsIndeterminate = false; Progress = 0; + StatusMessage = "Downloading from URL..."; try { - var result = await _importService.ImportFromUrlAsync(ImportUrl, SelectedTab, new Progress(p => Progress = p)); + var progressHandler = new Progress(p => + { + Progress = p; + StatusMessage = "Downloading from URL..."; + }); + + var result = await _importService.ImportFromUrlAsync(ImportUrl, SelectedTab, progressHandler); if (result.Success) { _notificationService.ShowSuccess("Import Complete", $"Imported {result.FilesImported} file(s) from URL."); @@ -410,8 +454,7 @@ private async Task BrowseAndImportAsync() { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { // Show notification toast explaining what the button does _notificationService.ShowInfo( @@ -452,8 +495,7 @@ private async Task DeleteSelectedAsync() } // Check if any selected maps are demo items (have mock paths) - var demoMaps = SelectedMaps.Where(m => m.FullPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - m.FullPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)).ToList(); + var demoMaps = SelectedMaps.Where(m => IsDemoPath(m.FullPath)).ToList(); if (demoMaps.Count > 0) { // Show notification toast explaining what the button does @@ -464,6 +506,7 @@ private async Task DeleteSelectedAsync() } IsBusy = true; + IsIndeterminate = true; StatusMessage = "Deleting maps..."; // Capture selected maps before clearing @@ -488,7 +531,7 @@ private async Task DeleteSelectedAsync() } else { - _notificationService.ShowError("Delete Failed", "Could not delete selected maps."); + _notificationService.ShowError(MapManagerConstants.DeleteFailedTitle, "Could not delete selected maps."); StatusMessage = "Deletion error."; } @@ -504,8 +547,7 @@ private async Task ExportToZipAsync() } // Check if any selected maps are demo items (have mock paths) - var demoMaps = SelectedMaps.Where(m => m.FullPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - m.FullPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)).ToList(); + var demoMaps = SelectedMaps.Where(m => IsDemoPath(m.FullPath)).ToList(); if (demoMaps.Count > 0) { // Show notification toast explaining what the button does @@ -516,32 +558,22 @@ private async Task ExportToZipAsync() } IsBusy = true; - StatusMessage = "Creating ZIP..."; + IsIndeterminate = false; Progress = 0; + StatusMessage = "Creating ZIP..."; try { var directory = _directoryService.GetMapDirectory(SelectedTab); - var safeZipName = ZipName.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase) - ? ZipName - : ZipName + Path.GetExtension(MapManagerConstants.ZipFilePattern); - - var destinationPath = Path.Combine(directory, safeZipName); + var destinationPath = GetUniqueZipDestinationPath(directory, ZipName); - if (File.Exists(destinationPath)) + var progressHandler = new Progress(p => { - var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty; - var nameOnly = Path.GetFileNameWithoutExtension(destinationPath); - var ext = Path.GetExtension(destinationPath); - int count = 1; - while (File.Exists(destinationPath)) - { - destinationPath = Path.Combine(dir, $"{nameOnly} ({count}){ext}"); - count++; - } - } + Progress = p; + StatusMessage = "Creating ZIP..."; + }); - var result = await _exportService.ExportToZipAsync([.. SelectedMaps], destinationPath, new Progress(p => Progress = p)); + var result = await _exportService.ExportToZipAsync([.. SelectedMaps], destinationPath, progressHandler); if (result != null) { _notificationService.ShowSuccess("Zip Created", $"Created {Path.GetFileName(result)} in map folder."); @@ -549,16 +581,7 @@ private async Task ExportToZipAsync() // Reload maps to show the new ZIP await LoadMapsAsync(); - - // Reveal in Explorer - try - { - System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{result}\""); - } - catch - { - /* Ignore explorer errors */ - } + PathHelper.RevealInExplorer(result); } else { @@ -587,94 +610,155 @@ private async Task UploadAndShareAsync() return; } - // Check if any selected maps are demo items (have mock paths) - var demoMaps = SelectedMaps.Where(m => m.FullPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - m.FullPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)).ToList(); + if (ValidateDemoMapsSelected()) + { + return; + } + + long totalSizeBytes = ToolUploadHelper.CalculateMapsSize(SelectedMaps); + if (!await ValidateUploadLimitsAsync(totalSizeBytes)) + { + return; + } + + string? fileHash = null; + if (SelectedMaps.Count == 1 && File.Exists(SelectedMaps[0].FullPath)) + { + var (reused, computedHash) = await TryReuseExistingUploadAsync(SelectedMaps[0].FullPath); + if (reused) + { + return; + } + + fileHash = computedHash; + } + + IsHistoryOpen = false; + IsBusy = true; + IsIndeterminate = false; + Progress = 0; + StatusMessage = "Preparing upload..."; + + try + { + var isZip = SelectedMaps.Count == 1 && SelectedMaps[0].FileName.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase); + var progressHandler = new Progress(p => + { + Progress = p; + int percent = (int)Math.Round(p * 100); + StatusMessage = ToolUploadHelper.FormatUploadStageMessage(MapManagerConstants.UploadCategory, isZip, percent); + }); + + var uploadResult = await _exportService.UploadToUploadThingAsync([.. SelectedMaps], progressHandler); + if (uploadResult.Success) + { + await HandleSuccessfulUploadAsync(uploadResult.Data, totalSizeBytes, fileHash); + } + else + { + StatusMessage = "Upload failed."; + var error = uploadResult.FirstError ?? "Upload failed. Please check your internet connection."; + _notificationService.ShowError("Upload Failed", error); + } + } + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or HttpRequestException or InvalidOperationException) && ex is not OperationCanceledException) + { + _logger.LogError(ex, "Upload failed"); + _notificationService.ShowError("Upload Error", "Failed to complete upload."); + StatusMessage = "Upload error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + private bool ValidateDemoMapsSelected() + { + var demoMaps = SelectedMaps.Where(m => IsDemoPath(m.FullPath)).ToList(); if (demoMaps.Count > 0) { - // Show notification toast explaining what the button does _notificationService.ShowInfo( "Upload and Share", "Uploads selected maps to UploadThing cloud service (max 10MB) and copies the share link to your clipboard. You can then share the link with others to download maps."); - return; + return true; } - // Calculate total size of selected maps - long totalSizeBytes = SelectedMaps.Sum(r => new FileInfo(r.FullPath).Length); + return false; + } - // Check file size limit (10MB max per file/batch typically, but user said "File too large. Maximum upload size is 10MB") - // Note: The UI says "max 10MB per file". But usually there is a total limit too if zipped. - // Let's enforce the 10MB limit based on total size if it's a ZIP, or per file? - // If multiple files are selected, they are zipped. The ZIP must be < 10MB? - // Start simple: If total > 10MB, warn. + private async Task ValidateUploadLimitsAsync(long totalSizeBytes) + { if (totalSizeBytes > MapManagerConstants.MaxMapSizeBytes) { _notificationService.ShowError( "File Too Large", "File too large. Maximum upload size is 10MB."); StatusMessage = "Upload too large (Max 10MB)."; - return; + return false; } - // Check rate limit - var isAllowed = await _uploadHistoryService.CanUploadAsync(totalSizeBytes); + var isAllowed = await _uploadHistoryService.CanUploadAsync(totalSizeBytes, MapManagerConstants.UploadCategory); if (!isAllowed) { - var usage = await _uploadHistoryService.GetUsageInfoAsync(); + var usage = await _uploadHistoryService.GetUsageInfoAsync(MapManagerConstants.UploadCategory); var resetDateLocal = usage.ResetDate.ToLocalTime(); _notificationService.ShowError( "Rate Limit Exceeded", "Upload limit exceeded for the current 3-day period. Please remove items from your Upload History to free up quota immediately."); - StatusMessage = $"Limited reached. Resets {resetDateLocal:g}."; - return; + StatusMessage = $"Limit reached. Resets {resetDateLocal:g}."; + return false; } - IsBusy = true; - StatusMessage = "Uploading to cloud (UploadThing)..."; - Progress = 0; + return true; + } - try + private async Task<(bool Reused, string? FileHash)> TryReuseExistingUploadAsync(string filePath) + { + var fileHash = await ToolUploadHelper.ComputeFileSha256Async(filePath); + if (string.IsNullOrEmpty(fileHash)) { - var url = await _exportService.UploadToUploadThingAsync([.. SelectedMaps], new Progress(p => Progress = p)); - if (url != null) - { - var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; - var clipboard = lifetime?.MainWindow?.Clipboard; - if (clipboard != null) - { - await clipboard.SetTextAsync(url); - } - - // Record successful upload - var fileName = SelectedMaps.Count == 1 ? SelectedMaps[0].FileName : "maps.zip"; - _uploadHistoryService.RecordUpload(totalSizeBytes, url, fileName); - - // Refresh history if open - if (IsHistoryOpen) - { - await LoadHistoryAsync(); - } + return (false, null); + } - StatusMessage = "Uploaded! Link copied to clipboard."; - _notificationService.ShowSuccess("Upload Complete", "Link copied to clipboard!"); - } - else + var existingUpload = await _uploadHistoryService.FindExistingUploadAsync(fileHash); + if (existingUpload?.Url != null && await ToolUploadHelper.VerifyShareUrlAliveAsync(existingUpload.Url)) + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) { - StatusMessage = "Upload failed. Check API key."; - _notificationService.ShowError("Upload Failed", "Upload failed. Please check your API key and internet connection."); + await clipboard.SetTextAsync(existingUpload.Url); } + + StatusMessage = "Reused existing upload! Link copied to clipboard."; + _notificationService.ShowSuccess("Upload Complete", "Existing link copied to clipboard!"); + return (true, fileHash); } - catch (Exception ex) + + return (false, fileHash); + } + + private async Task HandleSuccessfulUploadAsync(UploadResult uploadResult, long totalSizeBytes, string? fileHash) + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) { - _logger.LogError(ex, "Upload failed"); - _notificationService.ShowError("Upload Error", ex.Message); - StatusMessage = "Upload error."; + await clipboard.SetTextAsync(uploadResult.PublicUrl); } - finally + + var fileName = SelectedMaps.Count == 1 ? SelectedMaps[0].FileName : $"{MapManagerConstants.DefaultZipName}{Path.GetExtension(MapManagerConstants.ZipFilePattern)}"; + _uploadHistoryService.RecordUpload(totalSizeBytes, uploadResult.PublicUrl, fileName, uploadResult.FileKey, uploadResult.DeleteToken, fileHash, MapManagerConstants.UploadCategory); + + if (IsHistoryOpen) { - IsBusy = false; - Progress = 0; + await LoadHistoryAsync(); } + + StatusMessage = "Uploaded! Link copied to clipboard."; + _notificationService.ShowSuccess("Upload Complete", "Link copied to clipboard!"); } [RelayCommand] @@ -682,8 +766,7 @@ private void OpenFolder() { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { // Show notification toast explaining what the button does _notificationService.ShowInfo( @@ -699,8 +782,7 @@ private void OpenFolder() private void RevealFile(MapFile map) { // Check if map is a demo item (has mock path) - if (map.FullPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - map.FullPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(map.FullPath)) { // Show notification toast explaining what the button does _notificationService.ShowInfo( @@ -722,8 +804,7 @@ private async Task UncompressSelectedAsync() if (zipFiles.Count == 0) return; // Check if any selected maps are demo items (have mock paths) - var demoMaps = SelectedMaps.Where(m => m.FullPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - m.FullPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)).ToList(); + var demoMaps = SelectedMaps.Where(m => IsDemoPath(m.FullPath)).ToList(); if (demoMaps.Count > 0) { // Show notification toast explaining what the button does @@ -785,8 +866,7 @@ private void ToggleMapPackPanel() { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { _notificationService.ShowInfo( "MapPacks", @@ -825,8 +905,7 @@ private async Task CreateMapPackAsync() } // Check if any selected maps are demo items (have mock paths) - var demoMaps = SelectedMaps.Where(m => m.FullPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - m.FullPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)).ToList(); + var demoMaps = SelectedMaps.Where(m => IsDemoPath(m.FullPath)).ToList(); if (demoMaps.Count > 0) { // Show notification toast explaining what the button does @@ -882,8 +961,7 @@ private async Task LoadMapPackAsync(MapPack mapPack) { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { // Show notification toast explaining what the button does _notificationService.ShowInfo( @@ -913,8 +991,7 @@ private async Task UnloadMapPackAsync(MapPack mapPack) { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { // Show notification toast explaining what the button does _notificationService.ShowInfo( @@ -944,8 +1021,7 @@ private async Task DeleteMapPackAsync(MapPack mapPack) { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { // Show notification toast explaining what the button does _notificationService.ShowInfo( @@ -966,30 +1042,30 @@ private async Task DeleteMapPackAsync(MapPack mapPack) catch (Exception ex) { _logger.LogError(ex, "Failed to delete MapPack"); - _notificationService.ShowError("Delete Failed", "Failed to delete MapPack."); + _notificationService.ShowError(MapManagerConstants.DeleteFailedTitle, "Failed to delete MapPack."); } } // History Commands - [RelayCommand] - private void ToggleHistory() + partial void OnIsHistoryOpenChanged(bool value) { + if (!value) + { + return; + } + // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { + IsHistoryOpen = false; _notificationService.ShowInfo( "Upload History", "Shows a list of your previously uploaded maps, allowing you to manage them and copy download links."); return; } - IsHistoryOpen = !IsHistoryOpen; - if (IsHistoryOpen) - { - _ = LoadHistoryAsync(); - } + _ = LoadHistoryAsync(); } [RelayCommand] @@ -997,15 +1073,16 @@ private async Task LoadHistoryAsync() { try { - var history = await _uploadHistoryService.GetUploadHistoryAsync(); - UploadHistory.Clear(); + var history = await _uploadHistoryService.GetUploadHistoryAsync(MapManagerConstants.UploadCategory); + var viewModels = history.Select(item => new UploadHistoryItemViewModel(item)).ToList(); - foreach (var item in history) + UploadHistory.Clear(); + foreach (var vm in viewModels) { - UploadHistory.Add(new UploadHistoryItemViewModel(item)); + UploadHistory.Add(vm); } - // Verify file existence + // Verify file existence asynchronously _ = Task.Run(async () => { using var httpClient = new System.Net.Http.HttpClient @@ -1013,31 +1090,29 @@ private async Task LoadHistoryAsync() Timeout = TimeSpan.FromSeconds(5), }; - foreach (var viewModel in UploadHistory) + foreach (var vm in viewModels) { + bool exists = false; try { - var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Head, viewModel.Url); - var response = await httpClient.SendAsync(request); - - await Dispatcher.UIThread.InvokeAsync(() => - { - viewModel.FileExists = response.IsSuccessStatusCode; - viewModel.IsVerified = true; - }); + using var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Head, vm.Url); + using var response = await httpClient.SendAsync(request); + exists = response.IsSuccessStatusCode; } catch { - await Dispatcher.UIThread.InvokeAsync(() => - { - viewModel.FileExists = false; - viewModel.IsVerified = true; - }); + exists = false; } + + await Dispatcher.UIThread.InvokeAsync(() => + { + vm.FileExists = exists; + vm.IsVerified = true; + }); } }); } - catch (Exception ex) + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or JsonException) && ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to load upload history"); } @@ -1048,8 +1123,7 @@ private async Task CopyUrlAsync(string url) { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { _notificationService.ShowInfo( "Copy Link", @@ -1062,12 +1136,12 @@ private async Task CopyUrlAsync(string url) var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; var clipboard = lifetime?.MainWindow?.Clipboard; if (clipboard != null) - { - await clipboard.SetTextAsync(url); - _notificationService.ShowSuccess("Copied", "Link copied to clipboard."); - } + { + await clipboard.SetTextAsync(url); + _notificationService.ShowSuccess("Copied", "Link copied to clipboard."); + } } - catch (Exception ex) + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException) && ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to copy URL"); } @@ -1078,54 +1152,73 @@ private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { _notificationService.ShowInfo( - "Remove From History", - "Removes the item from local history without deleting the hosted file."); + "Delete Upload", + "Permanently deletes the uploaded file from cloud storage and removes it from history."); return; } try { - await _uploadHistoryService.RemoveHistoryItemAsync(item.Url); + var success = await _uploadHistoryService.RemoveHistoryItemAsync(item.Url, deleteFromCloud: true); await LoadHistoryAsync(); - _notificationService.ShowSuccess( - "Removed", - "Removed from local history. The hosted file was not deleted."); + if (success) + { + _notificationService.ShowSuccess( + "Deleted", + "File deleted from cloud storage and upload history."); + } + else + { + _notificationService.ShowError(MapManagerConstants.DeleteFailedTitle, "Failed to delete file from cloud storage."); + } } - catch (Exception ex) + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or HttpRequestException or JsonException) && ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to remove history item"); + _notificationService.ShowError(MapManagerConstants.DeleteFailedTitle, "Failed to delete history item."); } } + /// + /// Clears all upload history and deletes hosted files from cloud storage. + /// [RelayCommand] private async Task ClearHistoryAsync() { // Check if current tab is using demo paths var demoPath = _directoryService.GetMapDirectory(SelectedTab); - if (demoPath.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - demoPath.Contains("/Mock/", StringComparison.OrdinalIgnoreCase)) + if (IsDemoPath(demoPath)) { _notificationService.ShowInfo( "Clear History", - "Clears local upload history without deleting hosted files."); + "Permanently deletes all uploaded files from cloud storage and clears upload history."); return; } try { - await _uploadHistoryService.ClearHistoryAsync(); + var (deleted, failed) = await _uploadHistoryService.ClearHistoryAsync(deleteFromCloud: true, category: MapManagerConstants.UploadCategory); await LoadHistoryAsync(); - _notificationService.ShowSuccess( - "Cleared", - "Local history cleared. Hosted files were not deleted."); + if (failed == 0) + { + _notificationService.ShowSuccess( + "Cleared", + $"All {deleted} uploaded files deleted from cloud storage and history cleared."); + } + else + { + _notificationService.ShowWarning( + "Partially Cleared", + $"Cleared {deleted} history items. {failed} item(s) could not be deleted from cloud storage."); + } } - catch (Exception ex) + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or HttpRequestException or JsonException) && ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to clear history"); + _notificationService.ShowError("Clear Failed", "Failed to clear history."); } } diff --git a/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml index d4b7c7035..4d813348a 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml +++ b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml @@ -6,28 +6,33 @@ xmlns:vm_tools="using:GenHub.Features.Tools.ViewModels" xmlns:models="using:GenHub.Core.Models.Tools.MapManager" xmlns:enums="using:GenHub.Core.Models.Enums" + xmlns:converters="using:GenHub.Infrastructure.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600" x:Class="GenHub.Features.Tools.MapManager.Views.MapManagerView" x:DataType="vm:MapManagerViewModel" x:Name="Root" - xmlns:converters="using:GenHub.Infrastructure.Converters" DragDrop.AllowDrop="True"> - - - - - - - + + + + + + + + + + + - - + + - + - + + - + + - - - - + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - - - + + + + + + + + - + - - + + + Foreground="{DynamicResource TextSecondary}" FontStyle="Italic" Margin="12,0"/> - + - + + - + IsLightDismissEnabled="False"> + - + - - + + - + - - + + - + @@ -256,21 +299,21 @@ + HorizontalAlignment="Center" Foreground="{DynamicResource TextMuted}" Margin="0,20"/> - - + + @@ -280,15 +323,15 @@ - + - - - + + - - + - + - + - + - - + + + FontSize="12" FontStyle="Italic" Foreground="{DynamicResource TextMuted}"/> - + @@ -375,7 +419,7 @@ + FontSize="12" Foreground="{DynamicResource TextMuted}" HorizontalAlignment="Center" Margin="0,10"/> @@ -383,6 +427,7 @@ + + + + + + + + + + + + + diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayDirectoryService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayDirectoryService.cs index 4c2878ed9..e536adade 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayDirectoryService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayDirectoryService.cs @@ -118,7 +118,7 @@ public void OpenInExplorer(GameType version) { Process.Start(new ProcessStartInfo { - FileName = PlatformConstants.WindowsExplorerExecutable, + FileName = PlatformConstants.WindowsExplorerPath, Arguments = path, UseShellExecute = true, }); @@ -132,7 +132,7 @@ public void RevealInExplorer(ReplayFile replay) { Process.Start(new ProcessStartInfo { - FileName = PlatformConstants.WindowsExplorerExecutable, + FileName = PlatformConstants.WindowsExplorerPath, Arguments = string.Format(PlatformConstants.WindowsExplorerSelectArgument, replay.FullPath), UseShellExecute = true, }); diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs index 1c801ee98..cf17b6687 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs @@ -1,8 +1,3 @@ -using GenHub.Core.Constants; -using GenHub.Core.Interfaces.Services; -using GenHub.Core.Interfaces.Tools.ReplayManager; -using GenHub.Core.Models.Tools.ReplayManager; -using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.IO; @@ -10,6 +5,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Services; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Core.Models.Tools.UploadThing; +using Microsoft.Extensions.Logging; namespace GenHub.Features.Tools.ReplayManager.Services; @@ -22,56 +24,48 @@ public sealed class ReplayExportService( ILogger logger) : IReplayExportService { /// - public async Task UploadToUploadThingAsync( + public async Task> UploadToUploadThingAsync( IEnumerable replays, IProgress? progress = null, CancellationToken ct = default) { + var replayList = replays.ToList(); + if (replayList.Count == 0) + { + return OperationResult.CreateFailure("No replays selected for upload."); + } + string? zipToUpload = null; bool isTemporaryZip = false; try { - var replayList = replays.ToList(); - if (replayList.Count == 0) return null; - - if (replayList.Count == 1 && replayList[0].FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - { - var (isValid, errorMessage) = zipValidationService.ValidateZip(replayList[0].FullPath); - if (!isValid) - { - logger.LogError("ZIP validation failed for upload: {Error}", errorMessage); - throw new ArgumentException(errorMessage ?? "Invalid ZIP archive for upload."); - } + var (path, isTemp, uploadProgress) = await ResolveZipToUploadAsync(replayList, progress, ct); + zipToUpload = path; + isTemporaryZip = isTemp; - zipToUpload = replayList[0].FullPath; - } - else + if (string.IsNullOrEmpty(zipToUpload) || !File.Exists(zipToUpload)) { - var tempZip = Path.Combine(Path.GetTempPath(), $"{ReplayManagerConstants.TempShareFilePrefix}{Guid.NewGuid()}.zip"); - var createdZip = await ExportToZipAsync(replayList, tempZip, progress, ct); - if (createdZip == null) return null; - - zipToUpload = createdZip; - isTemporaryZip = true; + return OperationResult.CreateFailure("Failed to prepare replay archive for upload."); } - if (new FileInfo(zipToUpload).Length > ReplayManagerConstants.MaxReplaySizeBytes) + if (new FileInfo(zipToUpload).Length > ReplayManagerConstants.MaxUploadBytesPerPeriod) { logger.LogError("File exceeds size limit: {Path}", zipToUpload); - return null; + return OperationResult.CreateFailure("Exported archive exceeds maximum size limit of 10MB."); } - return await uploadThingService.UploadFileAsync(zipToUpload, progress, ct); + return await uploadThingService.UploadFileAsync(zipToUpload, uploadProgress, ct); } - catch (ArgumentException) + catch (ArgumentException ex) { - throw; // Bubble up validation errors + logger.LogError(ex, "Invalid replay argument for upload"); + return OperationResult.CreateFailure(ex.Message); } - catch (Exception ex) + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or InvalidOperationException) && ex is not OperationCanceledException) { logger.LogError(ex, "Failed to upload to UploadThing"); - return null; + return OperationResult.CreateFailure($"Replay export failed: {ex.Message}"); } finally { @@ -106,7 +100,7 @@ public sealed class ReplayExportService( foreach (var replay in replayList) { count++; - progress?.Report((double)count / total * 0.4); + progress?.Report((double)count / total); if (!File.Exists(replay.FullPath)) continue; archive.CreateEntryFromFile(replay.FullPath, replay.FileName); @@ -122,4 +116,29 @@ public sealed class ReplayExportService( return null; } } + + private async Task<(string? Path, bool IsTemporary, IProgress? UploadProgress)> ResolveZipToUploadAsync( + IReadOnlyList replayList, + IProgress? progress, + CancellationToken ct) + { + if (replayList.Count == 1 && replayList[0].FileName.EndsWith(FileTypes.ZipFileExtension, StringComparison.OrdinalIgnoreCase)) + { + var (isValid, errorMessage) = zipValidationService.ValidateZip(replayList[0].FullPath); + if (!isValid) + { + logger.LogError("ZIP validation failed for upload: {Error}", errorMessage); + throw new ArgumentException(errorMessage ?? "Invalid ZIP archive for upload."); + } + + return (replayList[0].FullPath, false, progress); + } + + var tempZip = Path.Combine(Path.GetTempPath(), $"{ReplayManagerConstants.TempShareFilePrefix}{Guid.NewGuid()}{FileTypes.ZipFileExtension}"); + var zipProgress = progress != null ? new Progress(p => progress.Report(p * 0.25)) : null; + var uploadProgress = progress != null ? new Progress(p => progress.Report(0.25 + (p * 0.75))) : null; + + var createdZip = await ExportToZipAsync(replayList, tempZip, zipProgress, ct); + return (createdZip, true, uploadProgress); + } } diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs index 827c83b21..d69ad6c7f 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs @@ -1,4 +1,3 @@ -using GenHub.Core.Constants; using System; using System.Collections.Generic; using System.IO; @@ -6,11 +5,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Tools.ReplayManager; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; namespace GenHub.Features.Tools.ReplayManager.Services; @@ -36,8 +37,8 @@ public async Task ImportFromUrlAsync( try { - var directUrl = await urlParserService.GetDirectDownloadUrlAsync(url, ct); - if (string.IsNullOrEmpty(directUrl)) + var directUrls = await urlParserService.GetDirectDownloadUrlsAsync(url, ct); + if (directUrls.Count == 0) { return new ImportResult { @@ -48,72 +49,51 @@ public async Task ImportFromUrlAsync( }; } - var tempPath = Path.Combine(Path.GetTempPath(), $"{ReplayManagerConstants.TempImportFilePrefix}{Guid.NewGuid()}.rep"); - try - { - var source = urlParserService.IdentifySource(url); - var userAgent = (source == ReplaySource.GeneralsOnline || source == ReplaySource.GenTool) - ? ApiConstants.BrowserUserAgent - : ApiConstants.DefaultUserAgent; + var importedFiles = new List(); + var errors = new List(); + int skipped = 0; + var source = urlParserService.IdentifySource(url); + var userAgent = (source == ReplaySource.GeneralsOnline || source == ReplaySource.GenTool || source == ReplaySource.Strata) + ? ApiConstants.BrowserUserAgent + : ApiConstants.DefaultUserAgent; - var downloadProgress = progress != null ? new Progress(p => progress.Report(p.Percentage / 100.0)) : null; - var downloadConfig = new DownloadConfiguration - { - Url = new Uri(directUrl), - DestinationPath = tempPath, - UserAgent = userAgent, - }; - - var result = await downloadService.DownloadFileAsync(downloadConfig, progress: downloadProgress, cancellationToken: ct); - - if (!result.Success) - { - return new ImportResult - { - Success = false, - FilesImported = 0, - FilesSkipped = 0, - Errors = [ErrorMessages.DownloadFailed], - }; - } - - var info = new FileInfo(tempPath); - if (info.Length > ReplayManagerConstants.MaxReplaySizeBytes) - { - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } - - return new ImportResult + for (int i = 0; i < directUrls.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var fileIndex = i; + var totalFiles = directUrls.Count; + var downloadProgress = progress != null + ? new Progress(p => { - Success = false, - FilesImported = 0, - FilesSkipped = 0, - Errors = [string.Format(ErrorMessages.ReplayExceedsMaxSize, info.Length / 1024.0)], - }; - } + var overallProgress = (fileIndex + (p.Percentage / 100.0)) / totalFiles; + progress.Report(overallProgress); + }) + : null; + + var skippedCount = await DownloadAndImportReplayUrlAsync( + directUrls[i], + userAgent, + targetVersion, + downloadProgress, + importedFiles, + errors, + ct); + + skipped += skippedCount; + } - // Detect if the downloaded file is a ZIP by checking magic bytes - if (IsZipFile(tempPath)) - { - logger.LogInformation(LogMessages.DetectedZipFile); - return await ImportFromZipAsync(tempPath, targetVersion, progress, ct); - } + progress?.Report(1.0); - var importedFileName = GetFileNameFromUri(new Uri(directUrl)); - using var stream = File.OpenRead(tempPath); - return await ImportFromStreamAsync(stream, importedFileName, targetVersion, ct); - } - finally + return new ImportResult { - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } - } + Success = importedFiles.Count > 0, + FilesImported = importedFiles.Count, + FilesSkipped = skipped, + ImportedFiles = importedFiles, + Errors = errors, + }; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to import from URL: {Url}", url); return new ImportResult { Success = false, FilesImported = 0, FilesSkipped = 0, Errors = [ex.Message] }; @@ -171,7 +151,7 @@ public async Task ImportFromFilesAsync( skipped++; } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { errors.Add($"Failed to import {Path.GetFileName(path)}: {ex.Message}"); skipped++; @@ -218,26 +198,41 @@ public async Task ImportFromZipAsync( var entries = archive.Entries.Where(e => !string.IsNullOrEmpty(e.Name)).ToList(); int total = entries.Count; int count = 0; + long expandedBytes = 0; + + directoryService.EnsureDirectoryExists(targetVersion); + var targetDir = directoryService.GetReplayDirectory(targetVersion); foreach (var entry in entries) { + ct.ThrowIfCancellationRequested(); + count++; progress?.Report((double)count / total); - using var stream = entry.Open(); - var result = await ImportFromStreamAsync(stream, entry.Name, targetVersion, ct); - if (result.Success) + var targetPath = GetUniquePath(Path.Combine(targetDir, Path.GetFileName(entry.Name))); + + try { - imported.AddRange(result.ImportedFiles); + await using var stream = entry.Open(); + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + stream, + targetPath, + entry.FullName, + ReplayManagerConstants.MaxReplaySizeBytes, + ReplayManagerConstants.MaxAggregateUncompressedBytes - expandedBytes, + cancellationToken: ct); + imported.Add(targetPath); } - else + catch (Exception ex) when (ex is not OperationCanceledException) { - errors.AddRange(result.Errors); + logger.LogWarning(ex, "Discarding replay entry {Entry} from {ZipPath}", entry.FullName, zipPath); + errors.Add(ex.Message); skipped++; } } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, LogMessages.FailedToImportFromZip, zipPath); errors.Add(string.Format(ErrorMessages.FailedToProcessZip, ex.Message)); @@ -279,7 +274,7 @@ public async Task ImportFromStreamAsync( ImportedFiles = [targetPath], }; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, LogMessages.FailedToImportStream, fileName); return new ImportResult { Success = false, FilesImported = 0, FilesSkipped = 1, Errors = [ex.Message] }; @@ -336,16 +331,93 @@ private static string GetUniquePath(string path) return path; } - private static string GetFileNameFromUri(Uri uri) + private static string ExtractFileName(Uri uri) { try { var fileName = Path.GetFileName(uri.LocalPath); - return string.IsNullOrEmpty(fileName) ? ReplayManagerConstants.DefaultImportedReplayFileName : fileName; + if (string.IsNullOrEmpty(fileName)) + { + return ReplayManagerConstants.DefaultImportedReplayFileName; + } + + if (!fileName.EndsWith(FileTypes.ReplayFileExtension, StringComparison.OrdinalIgnoreCase) && + !fileName.EndsWith(FileTypes.ZipFileExtension, StringComparison.OrdinalIgnoreCase)) + { + return $"{fileName}{FileTypes.ReplayFileExtension}"; + } + + return fileName; } catch { - return "imported_replay.rep"; + return ReplayManagerConstants.DefaultImportedReplayFileName; + } + } + + private async Task DownloadAndImportReplayUrlAsync( + string directUrl, + string userAgent, + GameType targetVersion, + IProgress? downloadProgress, + List importedFiles, + List errors, + CancellationToken ct) + { + var tempPath = Path.Combine(Path.GetTempPath(), $"{ReplayManagerConstants.TempImportFilePrefix}{Guid.NewGuid()}{FileTypes.ReplayFileExtension}"); + + try + { + var downloadConfig = new DownloadConfiguration + { + Url = new Uri(directUrl), + DestinationPath = tempPath, + UserAgent = userAgent, + }; + + var result = await downloadService.DownloadFileAsync(downloadConfig, progress: downloadProgress, cancellationToken: ct); + if (!result.Success) + { + errors.Add($"{ErrorMessages.DownloadFailed}: {directUrl}"); + return 1; + } + + var isZip = IsZipFile(tempPath); + var maxAllowedBytes = isZip ? ReplayManagerConstants.MaxUploadBytesPerPeriod : ReplayManagerConstants.MaxReplaySizeBytes; + var info = new FileInfo(tempPath); + if (info.Length > maxAllowedBytes) + { + errors.Add(string.Format(ErrorMessages.ReplayExceedsMaxSize, info.Length / 1024.0)); + return 1; + } + + if (isZip) + { + logger.LogInformation(LogMessages.DetectedZipFile); + var zipResult = await ImportFromZipAsync(tempPath, targetVersion, null, ct); + importedFiles.AddRange(zipResult.ImportedFiles); + errors.AddRange(zipResult.Errors); + return Math.Max(zipResult.FilesSkipped, zipResult.Success ? 0 : 1); + } + + var importedFileName = ExtractFileName(new Uri(directUrl)); + using var stream = File.OpenRead(tempPath); + var singleResult = await ImportFromStreamAsync(stream, importedFileName, targetVersion, ct); + if (singleResult.Success) + { + importedFiles.AddRange(singleResult.ImportedFiles); + return singleResult.FilesSkipped; + } + + errors.AddRange(singleResult.Errors); + return 1; + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } } } } diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs index f7d74d459..e47ecaffb 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading; @@ -23,23 +25,31 @@ public ReplaySource IdentifySource(string url) return ReplaySource.Unknown; } - // Check for raw Match ID (e.g., "151553") + // Check for raw match ID (e.g., "151553") if (long.TryParse(url, out _)) { return ReplaySource.GeneralsOnline; } - if (url.Contains(ApiConstants.UploadThingUrlFragment)) + if (url.Contains(ApiConstants.UploadThingUrlFragment, StringComparison.OrdinalIgnoreCase) || + url.Contains(ApiConstants.UploadThingUfsUrlFragment, StringComparison.OrdinalIgnoreCase) || + url.Contains(ApiConstants.UploadThingUfsShortUrlFragment, StringComparison.OrdinalIgnoreCase)) { return ReplaySource.UploadThing; } - if (url.Contains(ApiConstants.GeneralsOnlineViewMatchFragment)) + if (url.Contains(ApiConstants.StrataUrlFragment, StringComparison.OrdinalIgnoreCase) || + url.Contains(ApiConstants.GameReplaysDomainFragment, StringComparison.OrdinalIgnoreCase)) + { + return ReplaySource.Strata; + } + + if (url.Contains(ApiConstants.GeneralsOnlineViewMatchFragment, StringComparison.OrdinalIgnoreCase)) { return ReplaySource.GeneralsOnline; } - if (url.Contains(ApiConstants.GenToolUrlFragment)) + if (url.Contains(ApiConstants.GenToolUrlFragment, StringComparison.OrdinalIgnoreCase)) { return ReplaySource.GenTool; } @@ -61,6 +71,13 @@ public bool IsValidReplayUrl(string url) /// public async Task GetDirectDownloadUrlAsync(string url, CancellationToken ct = default) + { + var urls = await GetDirectDownloadUrlsAsync(url, ct); + return urls.Count > 0 ? urls[0] : null; + } + + /// + public async Task> GetDirectDownloadUrlsAsync(string url, CancellationToken ct = default) { var source = IdentifySource(url); logger.LogInformation(LogMessages.IdentifyingUrlSource, url, source); @@ -69,17 +86,18 @@ public bool IsValidReplayUrl(string url) { return source switch { - ReplaySource.UploadThing => url, // UploadThing links are usually direct (utfs.io/f/...) - ReplaySource.DirectLink => url, - ReplaySource.GeneralsOnline => await ExtractGeneralsOnlineUrlAsync(url, ct), - ReplaySource.GenTool => await ExtractGenToolUrlAsync(url, ct), - _ => null, + ReplaySource.UploadThing => [url], + ReplaySource.DirectLink => [url], + ReplaySource.GeneralsOnline => await ExtractGeneralsOnlineUrlsAsync(url, ct), + ReplaySource.GenTool => await ExtractGenToolUrlsAsync(url, ct), + ReplaySource.Strata => await ExtractStrataUrlsAsync(url, ct), + _ => [], }; } catch (Exception ex) { logger.LogError(ex, LogMessages.FailedToExtractDownloadUrl, url); - return null; + return []; } } @@ -89,50 +107,100 @@ public bool IsValidReplayUrl(string url) [GeneratedRegex(RegexConstants.GenToolReplayPattern, RegexOptions.IgnoreCase)] private static partial Regex GenToolRegex(); - private async Task ExtractGeneralsOnlineUrlAsync(string url, CancellationToken ct) + [GeneratedRegex(RegexConstants.StrataReplayPattern, RegexOptions.IgnoreCase)] + private static partial Regex StrataRegex(); + + private async Task> ExtractGeneralsOnlineUrlsAsync(string url, CancellationToken ct) { - // If the URL is just a number, treat it as a match ID if (long.TryParse(url, out long matchId)) { - // Reconstruct: https://www.playgenerals.online/viewmatch?match=123 - // Note: ApiConstants.GeneralsOnlineViewMatchFragment is "playgenerals.online/viewmatch" - // We use GeneralsOnlineConstants.WebsiteUrl which is "https://www.playgenerals.online" url = $"{GeneralsOnlineConstants.WebsiteUrl}/viewmatch?match={matchId}"; } - // Example: https://www.playgenerals.online/viewmatch?match=354994 - // Search for a link matching *_replay.rep var html = await httpClient.GetStringAsync(url, ct); + var matches = GeneralsOnlineRegex().Matches(html); + var results = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Match match in matches) + { + if (match.Success && !string.IsNullOrWhiteSpace(match.Value)) + { + results.Add(match.Value); + } + } - // Regex to find matchdata link: https://matchdata.playgenerals.online/..._replay.rep - var match = GeneralsOnlineRegex().Match(html); - if (match.Success) + if (results.Count == 0) { - return match.Value; + logger.LogWarning(LogMessages.CouldNotFindReplayLinkGeneralsOnline, url); } - logger.LogWarning(LogMessages.CouldNotFindReplayLinkGeneralsOnline, url); - return null; + return results.ToList(); } - private async Task ExtractGenToolUrlAsync(string url, CancellationToken ct) + private async Task> ExtractGenToolUrlsAsync(string url, CancellationToken ct) { var html = await httpClient.GetStringAsync(url, ct); - var match = GenToolRegex().Match(html); - if (match.Success) + var matches = GenToolRegex().Matches(html); + var results = new HashSet(StringComparer.OrdinalIgnoreCase); + var baseUri = new Uri(url); + + foreach (Match match in matches) { + if (!match.Success) + { + continue; + } + var relativeUrl = match.Groups[1].Value; if (Uri.IsWellFormedUriString(relativeUrl, UriKind.Absolute)) { - return relativeUrl; + results.Add(relativeUrl); + } + else if (Uri.TryCreate(baseUri, relativeUrl, out var absoluteUri)) + { + results.Add(absoluteUri.ToString()); + } + } + + if (results.Count == 0) + { + logger.LogWarning(LogMessages.CouldNotFindReplayLinkGenTool, url); + } + + return results.ToList(); + } + + private async Task> ExtractStrataUrlsAsync(string url, CancellationToken ct) + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd(ApiConstants.BrowserUserAgent); + using var response = await httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + var html = await response.Content.ReadAsStringAsync(ct); + + var matches = StrataRegex().Matches(html); + var results = new HashSet(StringComparer.OrdinalIgnoreCase); + var baseUri = new Uri(url); + + foreach (Match match in matches) + { + var extracted = match.Groups["url"].Success ? match.Groups["url"].Value : match.Value; + if (string.IsNullOrWhiteSpace(extracted)) + { + continue; } - var baseUri = new Uri(url); - var absoluteUri = new Uri(baseUri, relativeUrl); - return absoluteUri.ToString(); + if (Uri.IsWellFormedUriString(extracted, UriKind.Absolute)) + { + results.Add(extracted); + } + else if (Uri.TryCreate(baseUri, extracted, out var absoluteUri)) + { + results.Add(absoluteUri.ToString()); + } } - logger.LogWarning(LogMessages.CouldNotFindReplayLinkGenTool, url); - return null; + logger.LogInformation("Extracted {Count} replay URLs from Strata match: {Url}", results.Count, url); + return results.ToList(); } } diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs index f17d697cd..96b8ee132 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs @@ -1,3 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -5,21 +16,17 @@ using Avalonia.Threading; 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.Notifications; using GenHub.Core.Interfaces.Tools.ReplayManager; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Core.Models.Tools.UploadThing; using GenHub.Features.Tools.ViewModels; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace GenHub.Features.Tools.ReplayManager.ViewModels; @@ -40,16 +47,6 @@ public partial class ReplayManagerViewModel( INotificationService notificationService, ILogger logger) : ObservableObject { - private static string SanitizeFileName(string fileName) - { - var invalidChars = Path.GetInvalidFileNameChars(); - return string.Concat(fileName.Where(c => !invalidChars.Contains(c))); - } - - private static bool IsDemoPath(string path) => - path.Contains("\\Mock\\", StringComparison.OrdinalIgnoreCase) || - path.Contains("/Mock/", StringComparison.OrdinalIgnoreCase); - [ObservableProperty] private GameType selectedTab = GameType.ZeroHour; @@ -60,197 +57,45 @@ private static bool IsDemoPath(string path) => private bool isBusy; [ObservableProperty] - private double progress; + private bool isIndeterminate; [ObservableProperty] - private string statusMessage = "Ready"; + [NotifyPropertyChangedFor(nameof(ProgressPercentage))] + private double progress; /// - /// The name of the ZIP file to export or upload. + /// Gets the current progress as a whole integer percentage between 0 and 100. /// - [ObservableProperty] - private string zipName = "replays.zip"; + [SuppressMessage("Major Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Instance property required for Avalonia UI data binding")] + public int ProgressPercentage => (int)Math.Round(Progress * 100); - /// - /// Whether the upload history flyout is open. - /// [ObservableProperty] - private bool isHistoryOpen; - - /// - /// Gets the list of upload history items. - /// - public ObservableCollection UploadHistory { get; } = []; - - /// - /// Toggles the upload history flyout. - /// - [RelayCommand] - private async Task ToggleHistoryAsync() - { - // Check if current tab is using demo paths - var demoPath = directoryService.GetReplayDirectory(SelectedTab); - if (IsDemoPath(demoPath)) - { - notificationService.ShowInfo( - "Upload History", - "Shows a list of your previously uploaded replays, allowing you to manage them and copy download links."); - return; - } + private string statusMessage = "Ready"; - IsHistoryOpen = !IsHistoryOpen; - if (IsHistoryOpen) - { - await LoadHistoryAsync(); - } - } + [ObservableProperty] + private string searchText = string.Empty; - /// - /// Loads the upload history. - /// - private async Task LoadHistoryAsync() + partial void OnSearchTextChanged(string value) { - try - { - var history = await uploadHistoryService.GetUploadHistoryAsync(); - UploadHistory.Clear(); - - // Add items to collection - foreach (var item in history) - { - UploadHistory.Add(new UploadHistoryItemViewModel(item)); - } - - // Verify file existence for each item asynchronously - _ = Task.Run(async () => - { - using var httpClient = new System.Net.Http.HttpClient - { - Timeout = TimeSpan.FromSeconds(5), - }; - - foreach (var viewModel in UploadHistory) - { - try - { - // Use head request to check if file exists without downloading it - var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Head, viewModel.Url); - var response = await httpClient.SendAsync(request); - - await Dispatcher.UIThread.InvokeAsync(() => - { - viewModel.FileExists = response.IsSuccessStatusCode; - viewModel.IsVerified = true; - }); - } - catch - { - // If request fails, assume file doesn't exist - await Dispatcher.UIThread.InvokeAsync(() => - { - viewModel.FileExists = false; - viewModel.IsVerified = true; - }); - } - } - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load upload history"); - } + ApplyFilter(); } /// - /// Copies a URL to the clipboard. + /// The name of the ZIP file to export or upload. /// - /// The URL to copy. - [RelayCommand] - private async Task CopyUrlAsync(string url) - { - if (string.IsNullOrEmpty(url)) return; - - // Check if current tab is using demo paths - var demoPath = directoryService.GetReplayDirectory(SelectedTab); - if (IsDemoPath(demoPath)) - { - notificationService.ShowInfo( - "Copy Link", - "Copies the download link of the uploaded file to your clipboard."); - return; - } - - var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; - var clipboard = lifetime?.MainWindow?.Clipboard; - if (clipboard != null) - { - await clipboard.SetTextAsync(url); - notificationService.ShowSuccess("Copied", "Link copied to clipboard!"); - } - } + [ObservableProperty] + private string zipName = ReplayManagerConstants.DefaultZipName; /// - /// Removes a specific upload history item. + /// Whether the upload history flyout is open. /// - /// The history item to remove. - [RelayCommand] - private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) - { - // Check if current tab is using demo paths - var demoPath = directoryService.GetReplayDirectory(SelectedTab); - if (IsDemoPath(demoPath)) - { - notificationService.ShowInfo( - "Remove From History", - "Removes the item from local history without deleting the hosted file."); - return; - } - - try - { - await uploadHistoryService.RemoveHistoryItemAsync(item.Url); - await LoadHistoryAsync(); - notificationService.ShowSuccess( - "Removed", - "Removed from local history. The hosted file was not deleted."); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to remove history item"); - notificationService.ShowError("Remove Failed", "Failed to remove history item."); - } - } + [ObservableProperty] + private bool isHistoryOpen; /// - /// Clears all upload history. + /// Gets the list of upload history items. /// - [RelayCommand] - private async Task ClearHistoryAsync() - { - // Check if current tab is using demo paths - var demoPath = directoryService.GetReplayDirectory(SelectedTab); - if (IsDemoPath(demoPath)) - { - notificationService.ShowInfo( - "Clear History", - "Clears local upload history without deleting hosted files."); - return; - } - - try - { - await uploadHistoryService.ClearHistoryAsync(); - await LoadHistoryAsync(); - notificationService.ShowSuccess( - "Cleared", - "Local history cleared. Hosted files were not deleted."); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to clear history"); - notificationService.ShowError("Clear Failed", "Failed to clear history."); - } - } + public ObservableCollection UploadHistory { get; } = []; /// /// Gets the list of replays for Generals. @@ -272,6 +117,11 @@ private async Task ClearHistoryAsync() /// public bool HasSelectedZips => SelectedReplays.Any(r => r.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + /// + /// Gets the collection of all replays for the current tab. + /// + public ObservableCollection CurrentReplays { get; } = []; + /// /// Updates the collection of selected replays. /// @@ -291,11 +141,6 @@ public void UpdateSelectedReplays(IEnumerable selected) UncompressSelectedCommand.NotifyCanExecuteChanged(); } - /// - /// Gets the collection of all replays for the current tab. - /// - public ObservableCollection CurrentReplays { get; } = []; - /// /// Initializes the ViewModel by loading replays for the current tab. /// @@ -313,6 +158,7 @@ public async Task InitializeAsync() public async Task LoadReplaysAsync() { IsBusy = true; + IsIndeterminate = true; StatusMessage = "Loading replays..."; try { @@ -339,12 +185,7 @@ await Dispatcher.UIThread.InvokeAsync(() => } } - // Update CurrentReplays by clearing and adding items (don't replace the reference!) - CurrentReplays.Clear(); - foreach (var r in replays) - { - CurrentReplays.Add(r); - } + ApplyFilter(); }); StatusMessage = $"Loaded {replays.Count} replays."; @@ -380,6 +221,7 @@ public async Task ImportFilesAsync(System.Collections.Generic.IEnumerable + path.Contains(ReplayManagerConstants.WindowsMockPathSegment, StringComparison.OrdinalIgnoreCase) || + path.Contains(ReplayManagerConstants.UnixMockPathSegment, StringComparison.OrdinalIgnoreCase); + + private static string GetUniqueZipDestinationPath(string directory, string rawZipName) + { + var safeZipName = PathHelper.SanitizeFileName(rawZipName); + if (string.IsNullOrWhiteSpace(safeZipName)) + { + safeZipName = ReplayManagerConstants.DefaultZipName; + } + + var zipExtension = Path.GetExtension(ReplayManagerConstants.ZipFilePattern); + if (!safeZipName.EndsWith(zipExtension, StringComparison.OrdinalIgnoreCase)) + { + safeZipName += zipExtension; + } + + return PathHelper.GetUniqueNumberedPath(Path.Combine(directory, safeZipName)); + } + + /// + /// Toggles the upload history flyout. + /// + partial void OnIsHistoryOpenChanged(bool value) + { + if (!value) + { + return; + } + + // Check if current tab is using demo paths + var demoPath = directoryService.GetReplayDirectory(SelectedTab); + if (IsDemoPath(demoPath)) + { + IsHistoryOpen = false; + notificationService.ShowInfo( + "Upload History", + "Shows a list of your previously uploaded replays, allowing you to manage them and copy download links."); + return; + } + + _ = LoadHistoryAsync(); + } + + /// + /// Loads the upload history. + /// + private async Task LoadHistoryAsync() + { + try + { + var history = await uploadHistoryService.GetUploadHistoryAsync(ReplayManagerConstants.UploadCategory); + var viewModels = history.Select(item => new UploadHistoryItemViewModel(item)).ToList(); + + UploadHistory.Clear(); + foreach (var vm in viewModels) + { + UploadHistory.Add(vm); + } + + // Verify file existence for each item asynchronously + _ = Task.Run(async () => + { + using var httpClient = new System.Net.Http.HttpClient + { + Timeout = TimeSpan.FromSeconds(5), + }; + + foreach (var vm in viewModels) + { + bool exists = false; + try + { + using var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Head, vm.Url); + using var response = await httpClient.SendAsync(request); + exists = response.IsSuccessStatusCode; + } + catch + { + exists = false; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + vm.FileExists = exists; + vm.IsVerified = true; + }); + } + }); + } + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or JsonException) && ex is not OperationCanceledException) + { + logger.LogError(ex, "Failed to load upload history"); + } + } + + /// + /// Copies a URL to the clipboard. + /// + /// The URL to copy. + [RelayCommand] + private async Task CopyUrlAsync(string url) + { + if (string.IsNullOrEmpty(url)) return; + + // Check if current tab is using demo paths + var demoPath = directoryService.GetReplayDirectory(SelectedTab); + if (IsDemoPath(demoPath)) + { + notificationService.ShowInfo( + "Copy Link", + "Copies the download link of the uploaded file to your clipboard."); + return; + } + + try + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(url); + notificationService.ShowSuccess("Copied", "Link copied to clipboard!"); + } + } + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException) && ex is not OperationCanceledException) + { + logger.LogError(ex, "Failed to copy URL"); + } + } + + /// + /// Removes a specific upload history item. + /// + /// The history item to remove. + [RelayCommand] + private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) + { + // Check if current tab is using demo paths + var demoPath = directoryService.GetReplayDirectory(SelectedTab); + if (IsDemoPath(demoPath)) + { + notificationService.ShowInfo( + "Delete Upload", + "Permanently deletes the uploaded file from cloud storage and removes it from history."); + return; + } + + try + { + var success = await uploadHistoryService.RemoveHistoryItemAsync(item.Url, deleteFromCloud: true); + await LoadHistoryAsync(); + if (success) + { + notificationService.ShowSuccess( + "Deleted", + "File deleted from cloud storage and upload history."); + } + else + { + notificationService.ShowError(ReplayManagerConstants.DeleteFailedTitle, "Failed to delete file from cloud storage."); + } + } + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or HttpRequestException or JsonException) && ex is not OperationCanceledException) + { + logger.LogError(ex, "Failed to remove history item"); + notificationService.ShowError(ReplayManagerConstants.DeleteFailedTitle, "Failed to delete history item."); + } + } + + /// + /// Clears all upload history and deletes hosted files from cloud storage. + /// + [RelayCommand] + private async Task ClearHistoryAsync() + { + // Check if current tab is using demo paths + var demoPath = directoryService.GetReplayDirectory(SelectedTab); + if (IsDemoPath(demoPath)) + { + notificationService.ShowInfo( + "Clear History", + "Permanently deletes all uploaded files from cloud storage and clears upload history."); + return; + } + + try + { + var (deleted, failed) = await uploadHistoryService.ClearHistoryAsync(deleteFromCloud: true, category: ReplayManagerConstants.UploadCategory); + await LoadHistoryAsync(); + if (failed == 0) + { + notificationService.ShowSuccess( + "Cleared", + $"All {deleted} uploaded files deleted from cloud storage and history cleared."); + } + else + { + notificationService.ShowWarning( + "Partially Cleared", + $"Cleared {deleted} history items. {failed} item(s) could not be deleted from cloud storage."); + } + } + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or HttpRequestException or JsonException) && ex is not OperationCanceledException) + { + logger.LogError(ex, "Failed to clear history"); + notificationService.ShowError("Clear Failed", "Failed to clear history."); + } + } + [RelayCommand] private async Task ImportFromUrlAsync() { @@ -430,12 +483,19 @@ private async Task ImportFromUrlAsync() } IsBusy = true; - StatusMessage = "Importing from URL..."; + IsIndeterminate = false; Progress = 0; + StatusMessage = "Downloading from URL..."; try { - var result = await importService.ImportFromUrlAsync(ImportUrl, SelectedTab, new Progress(p => Progress = p)); + var progressHandler = new Progress(p => + { + Progress = p; + StatusMessage = "Downloading from URL..."; + }); + + var result = await importService.ImportFromUrlAsync(ImportUrl, SelectedTab, progressHandler); if (result.Success) { notificationService.ShowSuccess("Import Complete", $"Imported {result.FilesImported} file(s) from URL."); @@ -520,6 +580,7 @@ private async Task DeleteSelectedAsync() } IsBusy = true; + IsIndeterminate = true; StatusMessage = "Deleting replays..."; int count = SelectedReplays.Count; var result = await directoryService.DeleteReplaysAsync([.. SelectedReplays], CancellationToken.None); @@ -530,7 +591,7 @@ private async Task DeleteSelectedAsync() } else { - notificationService.ShowError("Delete Failed", "Could not delete selected replays."); + notificationService.ShowError(ReplayManagerConstants.DeleteFailedTitle, "Could not delete selected replays."); StatusMessage = "Deletion error."; } @@ -559,33 +620,22 @@ private async Task ExportToZipAsync() } IsBusy = true; - StatusMessage = "Creating ZIP..."; + IsIndeterminate = false; Progress = 0; + StatusMessage = "Creating ZIP..."; try { var directory = directoryService.GetReplayDirectory(SelectedTab); - var safeZipName = SanitizeFileName(ZipName); - if (!safeZipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - safeZipName += ".zip"; + var destinationPath = GetUniqueZipDestinationPath(directory, ZipName); - var destinationPath = Path.Combine(directory, safeZipName); - - // Handle filename conflict by appending (1), (2), etc. - if (File.Exists(destinationPath)) + var progressHandler = new Progress(p => { - var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty; - var nameOnly = Path.GetFileNameWithoutExtension(destinationPath); - var ext = Path.GetExtension(destinationPath); - int count = 1; - while (File.Exists(destinationPath)) - { - destinationPath = Path.Combine(dir, $"{nameOnly} ({count}){ext}"); - count++; - } - } + Progress = p; + StatusMessage = "Creating ZIP..."; + }); - var result = await exportService.ExportToZipAsync([.. SelectedReplays], destinationPath, new Progress(p => Progress = p)); + var result = await exportService.ExportToZipAsync([.. SelectedReplays], destinationPath, progressHandler); if (result != null) { notificationService.ShowSuccess("Zip Created", $"Created {Path.GetFileName(result)} in replay folder."); @@ -595,14 +645,7 @@ private async Task ExportToZipAsync() await LoadReplaysAsync(); // Reveal in Explorer - try - { - System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{result}\""); - } - catch - { - /* Ignore explorer errors */ - } + PathHelper.RevealInExplorer(result); } else { @@ -631,90 +674,155 @@ private async Task UploadAndShareAsync() return; } - // Check if any selected replays are demo items (have mock paths) + if (ValidateDemoReplaysSelected()) + { + return; + } + + long totalSizeBytes = ToolUploadHelper.CalculateReplaysSize(SelectedReplays); + if (!await ValidateUploadLimitsAsync(totalSizeBytes)) + { + return; + } + + string? fileHash = null; + if (SelectedReplays.Count == 1 && File.Exists(SelectedReplays[0].FullPath)) + { + var (reused, computedHash) = await TryReuseExistingUploadAsync(SelectedReplays[0].FullPath); + if (reused) + { + return; + } + + fileHash = computedHash; + } + + IsHistoryOpen = false; + IsBusy = true; + IsIndeterminate = false; + Progress = 0; + StatusMessage = "Preparing upload..."; + + try + { + var isZip = SelectedReplays.Count == 1 && SelectedReplays[0].FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + var progressHandler = new Progress(p => + { + Progress = p; + int percent = (int)Math.Round(p * 100); + StatusMessage = ToolUploadHelper.FormatUploadStageMessage(ReplayManagerConstants.UploadCategory, isZip, percent); + }); + + var uploadResult = await exportService.UploadToUploadThingAsync([.. SelectedReplays], progressHandler); + if (uploadResult.Success) + { + await HandleSuccessfulUploadAsync(uploadResult.Data, totalSizeBytes, fileHash); + } + else + { + StatusMessage = "Upload failed."; + var error = uploadResult.FirstError ?? "Upload failed. Please check your internet connection."; + notificationService.ShowError("Upload Failed", error); + } + } + catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or HttpRequestException or InvalidOperationException) && ex is not OperationCanceledException) + { + logger.LogError(ex, "Upload failed"); + notificationService.ShowError("Upload Error", "Failed to complete upload."); + StatusMessage = "Upload error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + private bool ValidateDemoReplaysSelected() + { var demoReplays = SelectedReplays.Where(r => IsDemoPath(r.FullPath)).ToList(); if (demoReplays.Count > 0) { - // Show notification toast explaining what the button does notificationService.ShowInfo( "Upload and Share", "Uploads selected replays to UploadThing cloud service (max 10MB) and copies the share link to your clipboard. You can then share the link with others to download replays."); - return; + return true; } - // Calculate total size of selected replays - long totalSizeBytes = SelectedReplays.Sum(r => new FileInfo(r.FullPath).Length); + return false; + } - // Check file size limit - const long MaxReplayUploadSize = 10 * 1024 * 1024; // 10MB - if (totalSizeBytes > MaxReplayUploadSize) + private async Task ValidateUploadLimitsAsync(long totalSizeBytes) + { + if (totalSizeBytes > ReplayManagerConstants.MaxUploadBytesPerPeriod) { notificationService.ShowError( "File Too Large", "File too large. Maximum upload size is 10MB."); StatusMessage = "Upload too large (Max 10MB)."; - return; + return false; } - // Check rate limit - var isAllowed = await uploadHistoryService.CanUploadAsync(totalSizeBytes); + var isAllowed = await uploadHistoryService.CanUploadAsync(totalSizeBytes, ReplayManagerConstants.UploadCategory); if (!isAllowed) { - var usage = await uploadHistoryService.GetUsageInfoAsync(); + var usage = await uploadHistoryService.GetUsageInfoAsync(ReplayManagerConstants.UploadCategory); var resetDateLocal = usage.ResetDate.ToLocalTime(); notificationService.ShowError( "Rate Limit Exceeded", "Upload limit exceeded for the current 3-day period. Please remove items from your Upload History to free up quota immediately."); - StatusMessage = $"Limited reached. Resets {resetDateLocal:g}."; - return; + StatusMessage = $"Limit reached. Resets {resetDateLocal:g}."; + return false; } - IsBusy = true; - StatusMessage = "Uploading to cloud (UploadThing)..."; - Progress = 0; + return true; + } - try + private async Task<(bool Reused, string? FileHash)> TryReuseExistingUploadAsync(string filePath) + { + var fileHash = await ToolUploadHelper.ComputeFileSha256Async(filePath); + if (string.IsNullOrEmpty(fileHash)) { - var url = await exportService.UploadToUploadThingAsync([.. SelectedReplays], new Progress(p => Progress = p)); - if (url != null) - { - var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; - var clipboard = lifetime?.MainWindow?.Clipboard; - if (clipboard != null) - { - await clipboard.SetTextAsync(url); - } - - // Record successful upload - var fileName = SelectedReplays.Count == 1 ? SelectedReplays[0].FileName : "replays.zip"; - uploadHistoryService.RecordUpload(totalSizeBytes, url, fileName); - - // Refresh history if open - if (IsHistoryOpen) - { - await LoadHistoryAsync(); - } + return (false, null); + } - StatusMessage = "Uploaded! Link copied to clipboard."; - notificationService.ShowSuccess("Upload Complete", "Link copied to clipboard!"); - } - else + var existingUpload = await uploadHistoryService.FindExistingUploadAsync(fileHash); + if (existingUpload?.Url != null && await ToolUploadHelper.VerifyShareUrlAliveAsync(existingUpload.Url)) + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) { - StatusMessage = "Upload failed. Check API key."; - notificationService.ShowError("Upload Failed", "Upload failed. Please check your API key and internet connection."); + await clipboard.SetTextAsync(existingUpload.Url); } + + StatusMessage = "Reused existing upload! Link copied to clipboard."; + notificationService.ShowSuccess("Upload Complete", "Existing link copied to clipboard!"); + return (true, fileHash); } - catch (Exception ex) + + return (false, fileHash); + } + + private async Task HandleSuccessfulUploadAsync(UploadResult uploadResult, long totalSizeBytes, string? fileHash) + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) { - logger.LogError(ex, "Upload failed"); - notificationService.ShowError("Upload Error", ex.Message); - StatusMessage = "Upload error."; + await clipboard.SetTextAsync(uploadResult.PublicUrl); } - finally + + var fileName = SelectedReplays.Count == 1 ? SelectedReplays[0].FileName : $"{ReplayManagerConstants.DefaultZipName}{Path.GetExtension(ReplayManagerConstants.ZipFilePattern)}"; + uploadHistoryService.RecordUpload(totalSizeBytes, uploadResult.PublicUrl, fileName, uploadResult.FileKey, uploadResult.DeleteToken, fileHash, ReplayManagerConstants.UploadCategory); + + if (IsHistoryOpen) { - IsBusy = false; - Progress = 0; + await LoadHistoryAsync(); } + + StatusMessage = "Uploaded! Link copied to clipboard."; + notificationService.ShowSuccess("Upload Complete", "Link copied to clipboard!"); } [RelayCommand] @@ -816,17 +924,23 @@ private async Task UncompressSelectedAsync() } } - partial void OnSelectedTabChanged(GameType value) + private void ApplyFilter() { - // Update CurrentReplays to show the correct collection's items + var source = SelectedTab == GameType.Generals ? GeneralsReplays : ZeroHourReplays; + var filtered = string.IsNullOrWhiteSpace(SearchText) + ? (IEnumerable)source + : source.Where(r => r.FileName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); + CurrentReplays.Clear(); - var sourceCollection = value == GameType.Generals ? GeneralsReplays : ZeroHourReplays; - foreach (var replay in sourceCollection) + foreach (var replay in filtered) { CurrentReplays.Add(replay); } + } - // Load replays for the new tab + partial void OnSelectedTabChanged(GameType value) + { + ApplyFilter(); _ = LoadReplaysAsync(); } } diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml index 15c8f1273..463beb6fd 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml @@ -3,66 +3,92 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:GenHub.Features.Tools.ReplayManager.ViewModels" + xmlns:vm_tools="using:GenHub.Features.Tools.ViewModels" xmlns:models="using:GenHub.Core.Models.Tools.ReplayManager" xmlns:enums="using:GenHub.Core.Models.Enums" + xmlns:converters="using:GenHub.Infrastructure.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600" x:Class="GenHub.Features.Tools.ReplayManager.Views.ReplayManagerView" x:DataType="vm:ReplayManagerViewModel" + x:Name="Root" DragDrop.AllowDrop="True"> - - - - + + + + + + + - - + - + + - + - - + - - - - - + + + RowHeight="42"> - - - + + + - + + + + @@ -96,9 +129,20 @@ + + + + + + + - - - + + + - - - - - + + + + + + + + - + - - + + + Foreground="{DynamicResource TextSecondary}" FontStyle="Italic" Margin="12,0"/> - + - + + - + IsLightDismissEnabled="False"> + - + - - - + + + - + - - + + @@ -234,21 +291,21 @@ + HorizontalAlignment="Center" Foreground="{DynamicResource TextMuted}" Margin="0,20"/> - - + + @@ -258,8 +315,8 @@ - + @@ -274,13 +331,89 @@ + + + + + + + + + + + + + + + diff --git a/GenHub/GenHub/Features/Tools/Services/ProgressableStreamContent.cs b/GenHub/GenHub/Features/Tools/Services/ProgressableStreamContent.cs new file mode 100644 index 000000000..1822cdc7e --- /dev/null +++ b/GenHub/GenHub/Features/Tools/Services/ProgressableStreamContent.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; + +namespace GenHub.Features.Tools.Services; + +/// +/// An wrapper around a that reports byte upload progress. +/// +public sealed class ProgressableStreamContent( + Stream content, + long totalBytes, + IProgress? progress = null, + int bufferSize = ToolConstants.DefaultUploadBufferSize) : HttpContent +{ + private const double MinProgressFraction = 0.01; + private const double MaxProgressFraction = 0.99; + + /// + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + return SerializeToStreamAsync(stream, context, CancellationToken.None); + } + + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize); + + var buffer = new byte[bufferSize]; + long uploadedBytes = 0; + + if (content.CanSeek) + { + content.Seek(0, SeekOrigin.Begin); + } + + while (true) + { + var bytesRead = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (bytesRead == 0) + { + break; + } + + await stream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + uploadedBytes += bytesRead; + + if (totalBytes > 0 && progress != null) + { + var fraction = (double)uploadedBytes / totalBytes; + progress.Report(Math.Min(MaxProgressFraction, Math.Max(MinProgressFraction, fraction))); + } + } + } + + /// + protected override bool TryComputeLength(out long length) + { + length = totalBytes; + return true; + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + content.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs b/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs index ba7c855e4..15c4d888d 100644 --- a/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs +++ b/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Services; using GenHub.Core.Models.Common; using GenHub.Core.Models.Tools; using Microsoft.Extensions.Logging; @@ -18,9 +19,11 @@ namespace GenHub.Features.Tools.Services; /// /// Initializes a new instance of the class. /// +/// UploadThing cloud storage service. /// Logger instance. /// Application configuration service. public sealed class UploadHistoryService( + IUploadThingService uploadThingService, ILogger logger, IAppConfiguration appConfig) : IUploadHistoryService { @@ -35,7 +38,6 @@ public sealed class UploadHistoryService( PropertyNameCaseInsensitive = true, }; - private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly string _historyFilePath = Path.Combine(appConfig.GetConfiguredDataPath(), "upload_history.json"); private List? _cache; @@ -43,47 +45,88 @@ public sealed class UploadHistoryService( public long MaxUploadBytesPerPeriod => MapManagerConstants.MaxUploadBytesPerPeriod; /// - public async Task CanUploadAsync(long fileSizeBytes) + public async Task CanUploadAsync(long fileSizeBytes, string? category = null) { - var usage = await GetUsageInfoAsync(); + var usage = await GetUsageInfoAsync(category); return usage.UsedBytes + fileSizeBytes <= usage.LimitBytes; } /// - public void RecordUpload(long fileSizeBytes, string url, string fileName) + public void RecordUpload( + long fileSizeBytes, + string url, + string fileName, + string? fileKey = null, + string? deleteToken = null, + string? fileHash = null, + string? category = null) { lock (FileLock) { try { var history = LoadHistoryInternal(); + var resolvedCategory = string.IsNullOrEmpty(category) ? InferCategory(fileName) : category; + history.Add(new UploadRecord { Timestamp = DateTime.UtcNow, SizeBytes = fileSizeBytes, Url = url, FileName = fileName, + FileKey = fileKey, + DeleteToken = deleteToken, + FileHash = fileHash, + Category = resolvedCategory, }); SaveHistoryInternal(history); _cache = history; // Update cache - _logger.LogInformation("Recorded upload of {Size} bytes. Total history: {Count} items.", fileSizeBytes, history.Count); + logger.LogInformation("Recorded upload of {Size} bytes for category '{Category}'. Total history: {Count} items.", fileSizeBytes, resolvedCategory, history.Count); + } + catch (IOException ex) + { + logger.LogError(ex, "Failed to record upload"); } - catch (Exception ex) + catch (UnauthorizedAccessException ex) { - _logger.LogError(ex, "Failed to record upload"); + logger.LogError(ex, "Failed to record upload"); } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to record upload"); + } + } + } + + /// + public Task FindExistingUploadAsync(string fileHash) + { + if (string.IsNullOrWhiteSpace(fileHash)) + { + return Task.FromResult(null); } + + var history = LoadHistoryInternal(); + var existing = history.FirstOrDefault(r => + !string.IsNullOrEmpty(r.FileHash) && + string.Equals(r.FileHash, fileHash, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(r.Url)); + + return Task.FromResult(existing); } /// - public Task GetUsageInfoAsync() + public Task GetUsageInfoAsync(string? category = null) { var history = LoadHistoryInternal(); var periodStart = DateTime.UtcNow.AddDays(-RateLimitDays); - var recentUploads = history.Where(r => r.Timestamp >= periodStart).ToList(); + var recentUploads = history + .Where(r => r.Timestamp >= periodStart && MatchesCategory(r, category)) + .ToList(); var usedBytes = recentUploads.Sum(r => r.SizeBytes); + var limitBytes = GetLimitForCategory(category); // Reset date is when the oldest upload in the current window expires var oldestInWindow = recentUploads.OrderBy(r => r.Timestamp).FirstOrDefault(); @@ -91,26 +134,62 @@ public Task GetUsageInfoAsync() ? oldestInWindow.Timestamp.AddDays(RateLimitDays) : DateTime.UtcNow; - return Task.FromResult(new UsageInfo(usedBytes, MaxUploadBytesPerPeriod, resetDate)); + return Task.FromResult(new UsageInfo(usedBytes, limitBytes, resetDate)); } /// - public Task> GetUploadHistoryAsync() + public Task> GetUploadHistoryAsync(string? category = null) { var history = LoadHistoryInternal(); - var items = history.Select(r => new UploadHistoryItem( + var filtered = history.Where(r => MatchesCategory(r, category)); + + var items = filtered.Select(r => new UploadHistoryItem( r.Timestamp, r.SizeBytes, r.Url ?? string.Empty, - r.FileName ?? "Unknown File")); + r.FileName ?? "Unknown File", + r.Category ?? InferCategory(r))).ToList(); - return Task.FromResult(items); + return Task.FromResult>(items); } /// - public Task RemoveHistoryItemAsync(string url) + public async Task RemoveHistoryItemAsync(string url, bool deleteFromCloud = true) { + UploadRecord? matchingRecord = null; + lock (FileLock) + { + var history = LoadHistoryInternal(); + matchingRecord = history.FirstOrDefault(r => r.Url == url); + } + + if (matchingRecord == null) + { + return true; + } + + if (deleteFromCloud && !string.IsNullOrEmpty(matchingRecord.FileKey) && !string.IsNullOrEmpty(matchingRecord.DeleteToken)) + { + try + { + var deleteResult = await uploadThingService.DeleteFileAsync(matchingRecord.FileKey, matchingRecord.DeleteToken); + if (!deleteResult.Success || !deleteResult.Data) + { + logger.LogWarning( + "Failed to delete file {Key} from cloud storage for {Url}. Preserving local history item for retry.", + matchingRecord.FileKey, + url); + return false; + } + } + catch (OperationCanceledException ex) + { + logger.LogWarning(ex, "Timeout or cancellation occurred while deleting file from cloud storage for {Url}", url); + return false; + } + } + lock (FileLock) { var history = LoadHistoryInternal(); @@ -119,32 +198,125 @@ public Task RemoveHistoryItemAsync(string url) { SaveHistoryInternal(history); _cache = history; - _logger.LogInformation( - "Removed {Count} item(s) for {Url} from local upload history without deleting the hosted file.", + logger.LogInformation( + "Removed {Count} item(s) for {Url} from upload history.", removed, url); } } - return Task.CompletedTask; + return true; } /// - public Task ClearHistoryAsync() + public async Task<(int Deleted, int Failed)> ClearHistoryAsync(bool deleteFromCloud = true, string? category = null) { + List candidateRecords = []; + lock (FileLock) + { + var history = LoadHistoryInternal(); + candidateRecords = history.Where(r => MatchesCategory(r, category)).ToList(); + } + + var (successfullyDeleted, failedDeletions) = deleteFromCloud + ? await DeleteRecordsFromCloudAsync(candidateRecords) + : (candidateRecords.ToHashSet(), new HashSet()); + + int removed = 0; lock (FileLock) { var history = LoadHistoryInternal(); - if (history.Count > 0) + var targetUrls = successfullyDeleted.Select(r => r.Url).Where(u => !string.IsNullOrEmpty(u)).OfType().ToHashSet(); + removed = history.RemoveAll(r => (r.Url != null && targetUrls.Contains(r.Url)) || successfullyDeleted.Contains(r)); + if (removed > 0) { - history.Clear(); SaveHistoryInternal(history); _cache = history; - _logger.LogInformation("Cleared local upload history without deleting hosted files."); + logger.LogInformation("Cleared {RemovedCount} upload history items for category '{Category}'. Failed cloud deletions: {FailedCount}.", removed, category ?? "all", failedDeletions.Count); } } - return Task.CompletedTask; + return (removed, failedDeletions.Count); + } + + private static long GetLimitForCategory(string? category) => + string.Equals(category, ReplayManagerConstants.UploadCategory, StringComparison.OrdinalIgnoreCase) + ? ReplayManagerConstants.MaxUploadBytesPerPeriod + : MapManagerConstants.MaxUploadBytesPerPeriod; + + private static string InferCategory(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + return MapManagerConstants.UploadCategory; + } + + if (fileName.EndsWith(".rep", StringComparison.OrdinalIgnoreCase) || + fileName.Equals($"{ReplayManagerConstants.DefaultZipName}{Path.GetExtension(ReplayManagerConstants.ZipFilePattern)}", StringComparison.OrdinalIgnoreCase)) + { + return ReplayManagerConstants.UploadCategory; + } + + return MapManagerConstants.UploadCategory; + } + + private static string InferCategory(UploadRecord record) + { + if (!string.IsNullOrEmpty(record.Category)) + { + return record.Category; + } + + return InferCategory(record.FileName); + } + + private static bool MatchesCategory(UploadRecord record, string? category) + { + if (string.IsNullOrEmpty(category)) + { + return true; + } + + var inferred = InferCategory(record); + return string.Equals(inferred, category, StringComparison.OrdinalIgnoreCase); + } + + private async Task<(HashSet Succeeded, HashSet Failed)> DeleteRecordsFromCloudAsync(IEnumerable records) + { + var successfullyDeleted = new HashSet(); + var failedDeletions = new HashSet(); + + foreach (var record in records) + { + if (record.FileKey is not { Length: > 0 } fileKey || record.DeleteToken is not { Length: > 0 } deleteToken) + { + successfullyDeleted.Add(record); + continue; + } + + try + { + var deleteResult = await uploadThingService.DeleteFileAsync(fileKey, deleteToken); + if (deleteResult.Success && deleteResult.Data) + { + successfullyDeleted.Add(record); + } + else + { + failedDeletions.Add(record); + logger.LogWarning( + "Failed to delete file {Key} from cloud storage during clear history.", + fileKey); + } + } + catch (OperationCanceledException ex) + { + failedDeletions.Add(record); + logger.LogWarning(ex, "Timeout or cancellation occurred while deleting file {Key} from cloud during clear history", fileKey); + } + } + + return (successfullyDeleted, failedDeletions); } private List LoadHistoryInternal() @@ -160,18 +332,18 @@ private List LoadHistoryInternal() { if (!File.Exists(_historyFilePath)) { - _cache = new List(); - return new List(); + _cache = []; + return []; } var json = File.ReadAllText(_historyFilePath); if (string.IsNullOrWhiteSpace(json)) { - _cache = new List(); - return new List(); + _cache = []; + return []; } - var history = JsonSerializer.Deserialize>(json, JsonOptions) ?? new List(); + var history = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; // Clean up old entries (expired retention) var retentionCutoff = DateTime.UtcNow.AddDays(-HistoryRetentionDays); @@ -190,14 +362,42 @@ private List LoadHistoryInternal() return new List(migratedHistory); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { - _logger.LogError(ex, "Failed to load upload history."); + logger.LogError(ex, "Failed to load upload history."); + + // If loading from disk failed, don't overwrite with an empty cache if we had one + if (_cache != null) + { + return new List(_cache); + } + + // Quarantine unparseable file to avoid data loss on future writes + QuarantineCorruptHistoryFile(); + + _cache = []; return []; } } } + private void QuarantineCorruptHistoryFile() + { + try + { + if (File.Exists(_historyFilePath)) + { + var backupPath = $"{_historyFilePath}.corrupt.{DateTime.UtcNow:yyyyMMddHHmmss}.bak"; + File.Copy(_historyFilePath, backupPath, overwrite: true); + logger.LogWarning("Quarantined corrupt upload history file to {Path}", backupPath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogWarning(ex, "Failed to quarantine corrupt upload history file."); + } + } + private void SaveHistoryInternal(List history) { lock (FileLock) @@ -213,9 +413,9 @@ private void SaveHistoryInternal(List history) var json = JsonSerializer.Serialize(history, JsonOptions); File.WriteAllText(_historyFilePath, json); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { - _logger.LogError(ex, "Failed to save upload history"); + logger.LogError(ex, "Failed to save upload history"); } } } diff --git a/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs b/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs index 001b43517..0c1f7d61c 100644 --- a/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs +++ b/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs @@ -1,37 +1,124 @@ -using GenHub.Core.Interfaces.Services; -using Microsoft.Extensions.Logging; using System; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Services; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.UploadThing; +using Microsoft.Extensions.Logging; namespace GenHub.Features.Tools.Services; /// -/// Disabled UploadThing integration. +/// Service for uploading and deleting files via the GenHub upload gateway proxy. /// -/// -/// Upload and delete operations must remain disabled until the application obtains -/// narrowly scoped, short-lived credentials from a trusted backend. -/// public sealed class UploadThingService( + HttpClient httpClient, ILogger logger) : IUploadThingService { /// - public Task UploadFileAsync( + public async Task> UploadFileAsync( string filePath, IProgress? progress = null, CancellationToken ct = default) { - logger.LogWarning( - "Cloud uploads are disabled until short-lived credentials are available."); - return Task.FromResult(null); + if (!File.Exists(filePath)) + { + logger.LogError("File to upload does not exist: {Path}", filePath); + return OperationResult.CreateFailure($"File not found: {filePath}"); + } + + try + { + var rawFileName = Path.GetFileName(filePath); + var fileName = PathHelper.SanitizeFileName(rawFileName); + if (string.IsNullOrWhiteSpace(fileName)) + { + fileName = ApiConstants.DefaultUploadFileName; + } + + var fileLength = new FileInfo(filePath).Length; + var streamProgress = progress != null ? new Progress(p => progress.Report(p * 0.85)) : null; + await using var fileStream = File.OpenRead(filePath); + using var fileContent = new ProgressableStreamContent(fileStream, fileLength, streamProgress); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(ApiConstants.MediaTypeZip); + + using var formContent = new MultipartFormDataContent(); + formContent.Add(fileContent, "file", fileName); + + progress?.Report(0.88); + using var response = await httpClient.PostAsync(ApiConstants.DefaultUploadUrl, formContent, ct); + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + logger.LogError("Upload failed with status {Status}: {Error}", response.StatusCode, errorBody); + var message = !string.IsNullOrWhiteSpace(errorBody) + ? $"Upload rejected ({response.StatusCode}): {errorBody}" + : $"Upload failed with status {response.StatusCode}"; + return OperationResult.CreateFailure(message); + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + if (result?.PublicUrl == null || result.FileKey == null || result.DeleteToken == null) + { + logger.LogError("Gateway returned incomplete upload response."); + return OperationResult.CreateFailure("Gateway returned incomplete upload response."); + } + + progress?.Report(1.0); + logger.LogInformation("File uploaded successfully to {Url}", result.PublicUrl); + + return OperationResult.CreateSuccess(new UploadResult(result.PublicUrl, result.FileKey, result.DeleteToken)); + } + catch (Exception ex) when ((ex is HttpRequestException or IOException or UnauthorizedAccessException or JsonException or FormatException or InvalidOperationException) && ex is not OperationCanceledException) + { + logger.LogError(ex, "Exception occurred during file upload"); + return OperationResult.CreateFailure($"Upload error: {ex.Message}"); + } } /// - public Task DeleteFileAsync(string fileKey, CancellationToken ct = default) + public async Task> DeleteFileAsync(string fileKey, string deleteToken, CancellationToken ct = default) { - logger.LogWarning( - "Cloud file deletion is disabled until short-lived credentials are available."); - return Task.FromResult(false); + if (string.IsNullOrWhiteSpace(fileKey) || string.IsNullOrWhiteSpace(deleteToken)) + { + logger.LogWarning("Cannot delete file: fileKey or deleteToken is missing."); + return OperationResult.CreateFailure("Missing fileKey or deleteToken."); + } + + try + { + var deleteRequest = new DeleteUploadRequest(fileKey, deleteToken); + using var response = await httpClient.PostAsJsonAsync(ApiConstants.DefaultUploadDeleteUrl, deleteRequest, ct); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(ct); + logger.LogError("Delete request rejected with status {Status}: {Error}", response.StatusCode, error); + return OperationResult.CreateFailure($"Delete failed with status {response.StatusCode}: {error}"); + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + var isSuccess = result?.Success ?? response.IsSuccessStatusCode; + + if (isSuccess) + { + logger.LogInformation("File {Key} deleted successfully from cloud storage.", fileKey); + return OperationResult.CreateSuccess(true); + } + + return OperationResult.CreateFailure("Cloud storage reported deletion failure."); + } + catch (Exception ex) when ((ex is HttpRequestException or JsonException or InvalidOperationException) && ex is not OperationCanceledException) + { + logger.LogError(ex, "Exception occurred while deleting file {Key}", fileKey); + return OperationResult.CreateFailure($"Deletion error: {ex.Message}"); + } } } diff --git a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs index 12c39dc0a..8d269e1c2 100644 --- a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs @@ -7,6 +7,7 @@ using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Tools; using Microsoft.Extensions.Logging; @@ -53,12 +54,8 @@ public partial class ToolsViewModel(IToolManager toolService, ILogger IsPaneOpen = true; - - [RelayCommand] - private void ClosePane() => IsPaneOpen = false; + [ObservableProperty] + private double _openPaneLength = SidebarConstants.DefaultOpenPaneLength; [ObservableProperty] private bool _isDetailsDialogOpen = false; @@ -97,11 +94,7 @@ public async Task InitializeAsync() if (HasTools) { - // Select the first tool by default - if (InstalledTools.Count > 0) - { - SelectedTool = InstalledTools[0]; - } + SelectedTool = InstalledTools[0]; } logger.LogInformation("Loaded {Count} tool plugins", InstalledTools.Count); @@ -115,7 +108,7 @@ public async Task InitializeAsync() catch (Exception ex) { ShowStatusMessage($"⚠ An error occurred while loading tools: {ex.Message}", error: true); - logger.LogError(ex, "Error loading tools"); + logger.LogError(ex, "Error initializing ToolsViewModel"); } finally { @@ -123,6 +116,25 @@ public async Task InitializeAsync() } } + private static async Task AutoHideStatusAsync(Action onHide, System.Threading.CancellationToken cancellationToken) + { + try + { + await Task.Delay(3000, cancellationToken); + onHide(); + } + catch (OperationCanceledException) + { + // Timer was cancelled, ignore + } + } + + [RelayCommand] + private void OpenPane() => IsPaneOpen = true; + + [RelayCommand] + private void ClosePane() => IsPaneOpen = false; + /// /// Adds a new tool plugin from a file. /// @@ -404,7 +416,7 @@ private void CloseDetailsDialog() ToolForDetails = null; } - private async void ShowStatusMessage(string message, bool success = false, bool error = false, bool info = false) + private void ShowStatusMessage(string message, bool success = false, bool error = false, bool info = false) { // Cancel any existing hide timer _statusHideCts?.Cancel(); @@ -414,16 +426,8 @@ private async void ShowStatusMessage(string message, bool success = false, bool SetStatusType(success, error, info); IsStatusVisible = true; - // Auto-hide after 5 seconds - _statusHideCts = new System.Threading.CancellationTokenSource(); - try - { - await Task.Delay(3000, _statusHideCts.Token); - IsStatusVisible = false; - } - catch (TaskCanceledException) - { - // Timer was cancelled, ignore - } + var cts = new System.Threading.CancellationTokenSource(); + _statusHideCts = cts; + _ = AutoHideStatusAsync(() => IsStatusVisible = false, cts.Token); } } \ No newline at end of file diff --git a/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs b/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs index ec37faf80..1ff11635b 100644 --- a/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs @@ -1,5 +1,6 @@ using System; using CommunityToolkit.Mvvm.ComponentModel; +using GenHub.Core.Constants; using GenHub.Core.Models.Common; namespace GenHub.Features.Tools.ViewModels; @@ -53,7 +54,7 @@ public partial class UploadHistoryItemViewModel(UploadHistoryItem item) : Observ /// /// Gets the status color based on activity. /// - public string StatusColor => IsActive ? "#4CAF50" : "#888888"; + public string StatusColor => IsActive ? UiConstants.StatusSuccessColor : UiConstants.StatusErrorColor; private static string GetTimeAgo(DateTime timestamp) { diff --git a/GenHub/GenHub/Features/Tools/Views/ToolIcons.axaml b/GenHub/GenHub/Features/Tools/Views/ToolIcons.axaml new file mode 100644 index 000000000..656667f22 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/Views/ToolIcons.axaml @@ -0,0 +1,21 @@ + + + M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z + M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z + M6.5 20Q4.22 20 2.61 18.43 1 16.85 1 14.58 1 12.63 2.17 11.1 3.35 9.57 5.25 9.15 5.88 6.85 7.75 5.43 9.63 4 12 4 14.93 4 16.96 6.04 19 8.07 19 11 20.73 11.2 21.86 12.5 23 13.78 23 15.5 23 17.38 21.69 18.69 20.38 20 18.5 20H13Q12.18 20 11.59 19.41 11 18.83 11 18V12.85L9.4 14.4L8 13L12 9L16 13L14.6 14.4L13 12.85V18H18.5Q19.55 18 20.27 17.27 21 16.55 21 15.5 21 14.45 20.27 13.73 19.55 13 18.5 13H17V11Q17 8.93 15.54 7.46 14.08 6 12 6 9.93 6 8.46 7.46 7 8.93 7 11H6.5Q5.05 11 4.03 12.03 3 13.05 3 14.5 3 15.95 4.03 17 5.05 18 6.5 18H9V20M12 13Z + M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10M10,22C9.75,22 9.54,21.82 9.5,21.58L9.13,18.93C8.5,18.68 7.96,18.34 7.44,17.94L4.95,18.95C4.73,19.03 4.46,18.95 4.34,18.73L2.34,15.27C2.21,15.05 2.27,14.78 2.46,14.63L4.57,12.97L4.5,12L4.57,11L2.46,9.37C2.27,9.22 2.21,8.95 2.34,8.73L4.34,5.27C4.46,5.05 4.73,4.96 4.95,5.05L7.44,6.05C7.96,5.66 8.5,5.32 9.13,5.07L9.5,2.42C9.54,2.18 9.75,2 10,2H14C14.25,2 14.46,2.18 14.5,2.42L14.87,5.07C15.5,5.32 16.04,5.66 16.56,6.05L19.05,5.05C19.27,4.96 19.54,5.05 19.66,5.27L21.66,8.73C21.79,8.95 21.73,9.22 21.54,9.37L19.43,11L19.5,12L19.43,13L21.54,14.63C21.73,14.78 21.79,15.05 21.66,15.27L19.66,18.73C19.54,18.95 19.27,19.04 19.05,18.95L16.56,17.95C16.04,18.34 15.5,18.68 14.87,18.93L14.5,21.58C14.46,21.82 14.25,22 14,22H10M11.25,4L10.88,6.61C9.68,6.86 8.62,7.5 7.85,8.39L5.44,7.35L4.69,8.65L6.8,10.2C6.4,11.37 6.4,12.64 6.8,13.8L4.68,15.36L5.43,16.66L7.86,15.62C8.63,16.5 9.68,17.14 10.87,17.38L11.24,20H12.76L13.13,17.39C14.32,17.14 15.37,16.5 16.14,15.62L18.57,16.66L19.32,15.36L17.2,13.81C17.6,12.64 17.6,11.37 17.2,10.2L19.31,8.65L18.56,7.35L16.15,8.39C15.38,7.5 14.32,6.86 13.12,6.62L12.75,4H11.25Z + M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z + M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8,9H16V19H8V9M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z + M15,16H19V18H15V16M15,8H22V10H15V8M15,12H21V14H15V12M11,10V18H5V10H11M13,8H3V18A2,2 0 0,0 5,20H11A2,2 0 0,0 13,18V8M14,5H11L10,4H6L5,5H2V7H14V5Z + M9.3 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4H10L12 6H20C21.1 6 22 6.9 22 8V14.6C21.4 14.2 20.7 13.8 20 13.5V8H4V18H9.3C9.3 18.1 9.2 18.2 9.2 18.3L8.8 19L9.1 19.7C9.2 19.8 9.2 19.9 9.3 20M23 19C22.1 21.3 19.7 23 17 23S11.9 21.3 11 19C11.9 16.7 14.3 15 17 15S22.1 16.7 23 19M19.5 19C19.5 17.6 18.4 16.5 17 16.5S14.5 17.6 14.5 19 15.6 21.5 17 21.5 19.5 20.4 19.5 19M17 18C16.4 18 16 18.4 16 19S16.4 20 17 20 18 19.6 18 19 17.6 18 17 18 + M6.1,10L4,18V8H21A2,2 0 0,0 19,6H12L10,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H19C19.9,20 20.7,19.4 20.9,18.5L23.2,10H6.1M19,18H6L7.6,12H20.6L19,18Z + M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z + M20.5,3L20.34,3.03L15,5.1L9,3L3.36,4.9C3.15,4.97 3,5.15 3,5.38V20.5A0.5,0.5 0 0,0 3.5,21L3.66,20.97L9,18.9L15,21L20.64,19.1C20.85,19.03 21,18.85 21,18.62V3.5A0.5,0.5 0 0,0 20.5,3M10,5.47L14,6.87V18.53L10,17.13V5.47M5,6.46L8,5.45V17.15L5,18.31V6.46M19,17.54L16,18.55V6.86L19,5.7V17.54Z + M22 4V13.81C21.39 13.46 20.72 13.22 20 13.09V10H5.76L4 6.47V18H13.09C13.04 18.33 13 18.66 13 19C13 19.34 13.04 19.67 13.09 20H4C2.9 20 2 19.11 2 18V6C2 4.89 2.9 4 4 4H5L7 8H10L8 4H10L12 8H15L13 4H15L17 8H20L18 4H22M17 22L22 19L17 16V22Z + M21,16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V7.5C3,7.12 3.21,6.79 3.53,6.62L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.79,6.79 21,7.12 21,7.5V16.5M12,4.15L10.11,5.22L16,8.61L17.96,7.5L12,4.15M6.04,7.5L12,10.85L13.96,9.75L8.08,6.35L6.04,7.5M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V9.21L13,12.58V19.29L19,15.91Z + M16.5,6V17.5A4,4 0 0,1 12.5,21.5A4,4 0 0,1 8.5,17.5V5A2.5,2.5 0 0,1 11,2.5A2.5,2.5 0 0,1 13.5,5V15.5A1,1 0 0,1 12.5,16.5A1,1 0 0,1 11.5,15.5V6H10V15.5A2.5,2.5 0 0,0 12.5,18A2.5,2.5 0 0,0 15,15.5V5A4,4 0 0,0 11,1A4,4 0 0,0 7,5V17.5A5.5,5.5 0 0,0 12.5,23A5.5,5.5 0 0,0 18,17.5V6H16.5Z + M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z + M2 12H4V17H20V12H22V17C22 18.11 21.11 19 20 19H4C2.9 19 2 18.11 2 17V12M12 15L17.55 9.54L16.13 8.13L13 11.25V2H11V11.25L7.88 8.13L6.46 9.55L12 15Z + M12 17V15H14V17H12M14 13V11H12V13H14M14 9V7H12V9H14M10 11H12V9H10V11M10 15H12V13H10V15M21 5V19C21 20.1 20.1 21 19 21H5C3.9 21 3 20.1 3 19V5C3 3.9 3.9 3 5 3H19C20.1 3 21 3.9 21 5M19 5H12V7H10V5H5V19H19V5Z + diff --git a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml index 8b740afd7..033583eb8 100644 --- a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml +++ b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml @@ -18,8 +18,8 @@ @@ -27,100 +27,125 @@ - - - - - + + + + - + + Margin="4,0,12,0"/> - + @@ -129,44 +154,23 @@ - @@ -175,7 +179,7 @@ - + @@ -185,21 +189,21 @@ Classes.error="{Binding IsStatusError}" Classes.info="{Binding IsStatusInfo}" IsVisible="{Binding IsStatusVisible}"> - + - + - - + + @@ -214,28 +218,40 @@ - - - - - - - + + + + + + + - - - + + + - - - + + + - - - + + + -