diff --git a/.cursor/skills/local_desktop_gui_testing/SKILL.md b/.cursor/skills/local_desktop_gui_testing/SKILL.md index af9a65fa..c3dc90cb 100644 --- a/.cursor/skills/local_desktop_gui_testing/SKILL.md +++ b/.cursor/skills/local_desktop_gui_testing/SKILL.md @@ -12,7 +12,7 @@ Use this skill when a task needs a real desktop session or VM to: Do **not** use this skill for paste-flow / wizard UX smoke that Avalonia headless already covers. Prefer: ```bash -./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests" +./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~Headless|FullyQualifiedName~GuiSmoke" ``` Do not use this skill for pure parser / serialization / core-only work. diff --git a/AGENTS.md b/AGENTS.md index 2267d301..5fcad0a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,11 @@ Cloud agents run headless (no X11 desktop). The following applies: `HeadlessTestApp` (`src/ModSync.Tests/HeadlessTestApp.cs`) bootstraps `Avalonia.Headless.XUnit` with `UseHeadlessDrawing = true`. Agents should use this path for GUI surface smoke instead of a real desktop: ```bash -# Preferred wrapper +# Preferred wrapper — expanded Avalonia smoke (GuiSmoke + *Headless*) +./scripts/agents/run_headless_tests.sh \ + --filter "FullyQualifiedName~Headless|FullyQualifiedName~GuiSmoke" + +# Narrow GuiSmoke only ./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests" # Or direct diff --git a/STRATEGY.md b/STRATEGY.md index 5ca8b302..1384de04 100644 --- a/STRATEGY.md +++ b/STRATEGY.md @@ -31,7 +31,7 @@ Encode install guides as machine-executable instruction files built on HoloPatch ### Guide ingestion -Turning existing human-written install guides (markdown, pasted text) into instruction files with draft executable instructions. +Turning existing human-written install guides (markdown, pasted text) into instruction files with draft executable instructions. **Shipped** (paste/clipboard + `convert --stdin --parse-directions`) — see [guide-ingestion.md](docs/knowledgebase/guide-ingestion.md) and [brainstorm](docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md). _Why it serves the approach:_ Authors never re-enter what they already wrote; the encoded-build supply grows from guides that already exist. @@ -43,13 +43,13 @@ _Why it serves the approach:_ The instruction file stays the single source of tr ### "Install with ModSync" entry points -One-click handoff from where mods live into the installer: `nxm://` protocol handling is shipped; a `modsync://` scheme for build links is future work. +One-click handoff from where mods live into the installer: `nxm://` protocol handling is shipped; `modsync://` Phase 1 (parse + CLI + handoff) is shipped; Phase 2 (OS registration + MainWindow consume) is open — see [modsync-protocol-handler.md](docs/knowledgebase/modsync-protocol-handler.md) and [brainstorm](docs/brainstorms/2026-07-13-modsync-protocol-requirements.md). _Why it serves the approach:_ Removes the manual download-and-place chain that causes most order-sensitive failures. ### Multi-author builds -Merge tooling, install profiles, and (future) publish/share flows so any author's build can be combined, compatibility-fixed, and distributed. +Merge tooling and install profiles are shipped; publish/share flows remain open ([plan stub](docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md)). Prefer finishing `modsync://` Phase 2 consume/registration first so share links have an open path. _Why it serves the approach:_ Breaks the single-curator bottleneck — the crux of the target problem. diff --git a/docs/brainstorms/2026-05-29-gui-cli-unified-pipeline-requirements.md b/docs/brainstorms/2026-05-29-gui-cli-unified-pipeline-requirements.md index ba4f90b5..a0a73b38 100644 --- a/docs/brainstorms/2026-05-29-gui-cli-unified-pipeline-requirements.md +++ b/docs/brainstorms/2026-05-29-gui-cli-unified-pipeline-requirements.md @@ -34,3 +34,14 @@ Every install-wizard and legacy GUI validation/install action runs through the s - Rewriting Avalonia UI or wizard page order. - Removing `ComponentValidation` class — it becomes a pipeline stage, not a CLI-only shortcut. + +## Implemented + +Shipped in PR #94 / plan `docs/plans/2026-05-29-023-unify-gui-cli-validation-pipeline-plan.md`. + +| Area | Path | +|------|------| +| Shared pipeline | `src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs` | +| CLI `validate` | `src/ModSync.Core/CLI/ModBuildConverter.cs` | +| GUI wiring | `ValidatePage`, `ValidationPipelineDialogMapper` | +| KB | `docs/knowledgebase/validation-pipeline.md`, `agent-action-parity.md` | diff --git a/docs/brainstorms/2026-05-29-mod-builds-pipeline-requirements.md b/docs/brainstorms/2026-05-29-mod-builds-pipeline-requirements.md index 98a9fb2d..df7593c6 100644 --- a/docs/brainstorms/2026-05-29-mod-builds-pipeline-requirements.md +++ b/docs/brainstorms/2026-05-29-mod-builds-pipeline-requirements.md @@ -62,3 +62,13 @@ Machine instructions and GUIDs live in TOML; markdown carries human metadata. ** - Re-enabling excluded documentation round-trip tests in one pass. - Resolving all Nexus/download dependencies in CI. + +## Implemented + +Shipped in PR #91 and related plans `017`–`020` / `022`. + +| Area | Path | +|------|------| +| Round-trip / dry-run | `FullBuildSerializationRoundTripTests`, `FullBuildMergedDryRunTests` | +| Agent script | `scripts/agents/cli_full_build_pipeline.sh`, `cli_validate.sh` | +| KB | `docs/knowledgebase/mod-builds-sources.md`, `core-cli-reference.md` | diff --git a/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md b/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md index 9ca13983..dc4e3831 100644 --- a/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md +++ b/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md @@ -1,7 +1,8 @@ --- title: "FOMOD archive discovery after download" -status: reviewed +status: completed date: 2026-06-14 +completed: 2026-07-13 origin: docs/plans/vortex-mo2-feature-parity-living-plan.md --- @@ -59,10 +60,13 @@ archives in the mod workspace without surfacing FOMOD packages automatically. Started). - Reuse of `FomodDetector`, `FomodInstallerDialog`, and existing mapper/presenter stack. -**Deferred** +**Deferred (completed separately)** + +- CLI download/install parity → [2026-06-14-fomod-cli-download-prompts-requirements.md](2026-06-14-fomod-cli-download-prompts-requirements.md) +- Validation blocking when FOMOD choices are unset → `FomodConfigurationGate` + +**Still deferred** -- CLI download/install parity. -- Validation blocking when FOMOD choices are unset. - Plugin images and advanced conditional file-install runtime beyond current mapper. ## Key Decisions @@ -73,7 +77,19 @@ archives in the mod workspace without surfacing FOMOD packages automatically. - Wizard output merges into the existing instruction-file component rather than creating a separate standalone component. +## Implemented + +Shipped in PR #169 and follow-ups. + +| Area | Path | +|------|------| +| Archive probe | `src/ModSync.Core/Services/Fomod/FomodArchiveProbe.cs` | +| Prompt state | `src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs` | +| GUI post-download | `src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs` | +| GUI dialog host | `src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs` | +| File-tree mark | `src/ModSync.GUI/Services/ArchiveEnumerationService.cs` | +| KB | `docs/knowledgebase/fomod-support.md`, `docs/knowledgebase/download-system.md` | + ## Outstanding Questions -- Whether all GUI download entry points beyond **Fetch Downloads** should share the - same prompt hook in a follow-up slice. +- None. diff --git a/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md b/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md index 2c4561a4..f2c4f1f4 100644 --- a/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md +++ b/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md @@ -1,9 +1,11 @@ --- title: "CLI FOMOD post-download prompts" -status: reviewed +status: completed date: 2026-06-14 +completed: 2026-07-13 origin: docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md supersedes_deferred: CLI download/install parity from GUI FOMOD discovery slice +plan: docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md --- # CLI FOMOD post-download prompts @@ -112,3 +114,21 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but ## Outstanding Questions - None blocking — user confirmed synthesis call-outs (2026-06-14 session). + +## Implemented + +Shipped across Plan 123 commits plus the configured-only gate. + +| Area | Path | +|------|------| +| Orchestrator | `src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs` | +| Hosts / options | `FomodCliPostDownloadHosts.cs`, `FomodChoicesFileHost.cs`, `FomodPostDownloadOptionsResolver.cs` | +| TTY wizard | `src/ModSync.Core/CLI/FomodConsoleWizard.cs` | +| Presenter (Core) | `src/ModSync.Core/Services/Fomod/FomodInstallerPresenter.cs` | +| CLI hooks | `ModBuildConverter` `RunFomodPostDownloadHookAsync` on `install`/`convert`/`merge` `-d` | +| Configured-only gate | `src/ModSync.Core/Services/Fomod/FomodConfigurationGate.cs` | +| Pipeline / install | `InstallationValidationPipeline`, `InstallationService`, `InstallStartPage` | +| Tests | `FomodPostDownloadOrchestratorTests`, `FomodConfigurationGateTests`, `FomodCliPostDownloadIntegrationTests` | +| KB | `docs/knowledgebase/fomod-support.md`, `download-system.md`, `core-cli-reference.md`, `agent-action-parity.md` | + +Plan: [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) → **completed**. diff --git a/docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md b/docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md new file mode 100644 index 00000000..41c5c176 --- /dev/null +++ b/docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md @@ -0,0 +1,59 @@ +--- +title: "Guide paste ingestion + draft instructions" +status: completed +date: 2026-07-13 +completed: 2026-07-13 +strategy_track: Guide ingestion +plan: docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md +origin: STRATEGY.md + docs/knowledgebase/product-vision.md +--- + +# Guide paste ingestion + draft instructions + +## Summary + +Players and authors can paste (or pipe) an existing community install guide into ModSync and get components plus review-flagged draft `Instruction` objects from prose — no re-entry of what they already wrote. + +## Problem Frame + +Markdown guide import and guide emission already existed, but ingestion was file/drag-drop only, and `NaturalLanguageInstructionParser` was dead code. The STRATEGY guide-ingestion track needed a clipboard/stdin entry point and wired draft-instruction generation. + +## Requirements + +- R1: GUI paste/clipboard import routes text through the existing format-sniffing cascade (`DeserializeModComponentFromString`: TOML → Markdown → YAML → XML/JSON). +- R2: After import, components with `Directions` prose and no authored instructions get draft instructions from `NaturalLanguageInstructionParser` via `DraftInstructionService`. +- R3: Draft paths obey `<>` / `<>` sandboxing; drafts are never auto-trusted (review flag / log warning). +- R4: Unparseable prose degrades gracefully (no drafts; components still load). +- R5: CLI parity: `convert --stdin --parse-directions` (and file input) emit TOML with draft instructions. +- R6: Tests in `ModSync.Tests`; paste control smoke via Avalonia headless; real OS clipboard remains a desktop check. + +## Success Criteria + +- Getting Started **Import from Clipboard** loads a pasted Deadly Stream-style guide into components. +- Draft instructions appear for prose-only components and are flagged for review. +- `convert --stdin --parse-directions` produces equivalent TOML for agents. +- Round-trip / documentation tests stay green. + +## Scope Boundaries + +**In scope:** Paste entry point, NL parser wiring, CLI stdin/parse-directions, tests, KB updates. + +**Out of scope:** `modsync://` deep links; publish/share multi-author flows; auto-trusting drafts at install time. + +## Implemented + +Shipped in `a37efbaf` (feat) + headless smoke (`653c7771`). + +| Area | Path | +|------|------| +| Draft wiring | `src/ModSync.Core/Parsing/DraftInstructionService.cs` | +| NL parser | `src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs` | +| GUI import | `FileLoadingService.ImportFromTextAsync`, `ImportFromClipboardButton` | +| CLI | `ModBuildConverter` `--stdin` / `--parse-directions` | +| Tests | `GuideIngestionTests`, `GuiSmokeHeadlessTests` | +| Plan | [docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) → **completed** | +| KB | [docs/knowledgebase/guide-ingestion.md](../knowledgebase/guide-ingestion.md) | + +## Strategy alignment + +Closes STRATEGY **Guide ingestion** entry-point + prose→instruction gaps called out in `docs/knowledgebase/product-vision.md` rows 1b/1c. diff --git a/docs/brainstorms/2026-07-13-modsync-protocol-requirements.md b/docs/brainstorms/2026-07-13-modsync-protocol-requirements.md new file mode 100644 index 00000000..ea2e4ea7 --- /dev/null +++ b/docs/brainstorms/2026-07-13-modsync-protocol-requirements.md @@ -0,0 +1,64 @@ +--- +title: "modsync:// Install with ModSync deep links" +status: ready-for-plan +date: 2026-07-13 +strategy_track: "Install with ModSync" entry points +related: docs/knowledgebase/modsync-protocol-handler.md +plan: docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md +--- + +# modsync:// Install with ModSync deep links + +## Summary + +Finish the `"Install with ModSync"` track: OS registration for `modsync://` plus MainWindow consumption of buffered handoff URLs (fetch instruction file over http(s) and load into the normal instruction pipeline). + +## Problem Frame + +Phase 1 is shipped: URL parse (`ModSyncUrl`), CLI `--modsync=` / bare positional, single-instance pipe forward, and `ModSyncHandoffQueue` buffering ([modsync-protocol-handler.md](../knowledgebase/modsync-protocol-handler.md)). Browsers and the GUI still cannot complete a one-click open: there is no OS scheme registration and nothing drains the queue to fetch/load the instruction file. + +## Requirements + +**Already satisfied (Phase 1) — do not re-litigate** + +- R0a: `modsync://install|open` with `url`/`instruction` http(s) targets; optional `game=kotor|kotor2`. +- R0b: Reject `file://` and bare local paths. +- R0c: CLI + single-instance enqueue into `ModSyncHandoffQueue`. + +**Phase 2 — open** + +- R1: Register/unregister `modsync://` on Windows/Linux (settings toggle); macOS via `CFBundleURLTypes` in `ModSync.app` (mirror nxm). +- R2: MainWindow (or `ModSyncHandoffService`) drains `ModSyncHandoffQueue`, parses via `ModSyncUrl.TryParse`, fetches the instruction URL over http(s), and loads through the existing instruction-file / paste cascade. +- R3: Failures (bad URL, network, parse) surface a clear dialog/log and do not crash startup. +- R4: KB + `product-vision` row 2 updated when Phase 2 ships; agent path remains `convert`/`install -i` with a downloaded file. + +## Success Criteria + +- Clicking a `modsync://install?url=https://…/build.toml` link (after OS registration) opens ModSync and loads that instruction set. +- `--modsync=` / secondary-instance forward already works; Phase 2 makes the primary instance *consume* the queue. +- Invalid local paths never fetch or write outside the intended download cache. + +## Scope Boundaries + +**In scope:** OS registration, settings toggle, queue drain/fetch/load, tests, KB. + +**Out of scope:** Publish/share hosting ([plan stub 003](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md)); changing Phase 1 URL grammar; Nexus downloads inside `modsync://` (use `nxm://`). + +## Key Decisions + +- Reuse nxm registration/handoff patterns (`NxmProtocolRegistrationService`, `NxmHandoffService`). +- http and https both allowed for instruction URLs (matches current `ModSyncUrl` tests). +- Prefer sequencing publish/share *after* Phase 2 so share links have a working open path. + +## Dependencies / Assumptions + +- Phase 1 code under `src/ModSync.Core/Services/Protocol/` and GUI handoff types remain stable. +- Guide paste / draft-instruction path applies when fetched content is markdown. + +## Outstanding Questions + +- Whether v1 auto-advances the install wizard after load or only loads into Getting Started / wizard page 0 (same as `--instructionFile=` today is fine). + +## Plan + +[docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md](../plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md) — deepen/execute from this requirements doc. diff --git a/docs/knowledgebase/README.md b/docs/knowledgebase/README.md index db8278de..44f13587 100644 --- a/docs/knowledgebase/README.md +++ b/docs/knowledgebase/README.md @@ -30,7 +30,7 @@ Use these when citing findings in plans, PRs, or audits: | Install wizard or real install | [install-lifecycle.md](install-lifecycle.md) | [validation-pipeline.md](validation-pipeline.md) | | Missing mod archives / downloads | [download-system.md](download-system.md) | [install-lifecycle.md](install-lifecycle.md) | | Headless build/test/core | `.github/copilot-instructions.md` | `core-cli-reference.md` | -| Avalonia GUI UX smoke (no X11) | `AGENTS.md` §Headless Avalonia GUI smoke | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests"` | +| Avalonia GUI UX smoke (no X11) | `AGENTS.md` §Headless Avalonia GUI smoke | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~Headless\|FullyQualifiedName~GuiSmoke"` | | GUI / install wizard / full-build | `docs/local_desktop_agent_runbook.md` | `agent-action-parity.md` → [install-lifecycle.md](install-lifecycle.md) | | Release or versioning | `docs/manual-release.md` | [Release readiness checklist](../plans/2026-07-13-005-release-readiness-checklist.md) → `docs/solutions/manual-release-workflow.md` | | Agent capability gaps | `agent-native-audit.md` | `agent-action-parity.md` | @@ -57,9 +57,9 @@ Use these when citing findings in plans, PRs, or audits: - [Install profiles](install-profiles.md) — named loadouts, `ProfileService` capture/apply, `ProfileManagerDialog` - [Managed deployment engine](managed-deployment.md) — hardlink deploy, per-component manifests, uninstall/purge (Phase 4 slice 1) - [File-level conflicts](file-conflicts.md) — `FileConflictAnalyzer` dry-run VFS attribution and `ConflictsDialog` results UI -- [FOMOD installer support](fomod-support.md) — ModuleConfig parser, Option/Choose mapping, FomodInstallerDialog GUI +- [FOMOD installer support](fomod-support.md) — ModuleConfig parser, GUI/CLI post-download configure, `--fomod-choices`, configured-only gate - [nxm protocol handler](nxm-protocol-handler.md) — nxm:// URL parsing, OS scheme registration, single-instance hand-off, free-user download path -- [modsync protocol handler](modsync-protocol-handler.md) — modsync:// build deep links (parse + CLI + handoff; OS registration planned) +- [modsync protocol handler](modsync-protocol-handler.md) — modsync:// build deep links (parse + CLI + handoff + consume; OS registration shipped, Settings toggle deferred) ### Architecture and agent parity @@ -138,9 +138,12 @@ KB routing: [rebrand-legacy-strings.md](rebrand-legacy-strings.md), [gui-archite | Topic | Status | Pointers | |-------|--------|----------| -| Paste / guide ingestion | Shipped | [product-vision.md](product-vision.md) 1b/1c; plan [001](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) | +| Paste / guide ingestion | Shipped | [guide-ingestion.md](guide-ingestion.md); [product-vision.md](product-vision.md) 1b/1c; plan [001](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) | | Headless Avalonia GUI smoke | Shipped | `GuiSmokeHeadlessTests`; `AGENTS.md` §Headless Avalonia GUI smoke | -| FOMOD configuration gate | Shipped | [fomod-support.md](fomod-support.md) — fail-closed until configured | +| FOMOD discovery + CLI prompts + gate | Shipped | [fomod-support.md](fomod-support.md); fail-closed until configured; Plan [123](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) | +| `modsync://` Phase 1 (parse/CLI/handoff) | Shipped | [modsync-protocol-handler.md](modsync-protocol-handler.md) | +| `modsync://` Phase 2 (OS reg + consume) | Shipped (Settings toggle deferred) | [modsync-protocol-handler.md](modsync-protocol-handler.md); #176 | +| Multi-author publish/share | Open | [plan stub 003](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) | | Release readiness checklist | Active | [005](../plans/2026-07-13-005-release-readiness-checklist.md) | ### Always-on rules (do not duplicate here) diff --git a/docs/knowledgebase/agent-action-parity.md b/docs/knowledgebase/agent-action-parity.md index 561c0b61..5ff7748c 100644 --- a/docs/knowledgebase/agent-action-parity.md +++ b/docs/knowledgebase/agent-action-parity.md @@ -15,7 +15,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | 5 | `GameDirectoryPage` | Pick game dir | `-g` / `--kotorPath=` | Full | | 6 | `AspyrNoticePage` | Acknowledge (K2) | No CLI equivalent | UI | | 7 | `ModSelectionPage` | Select mods, filters | `install` without `--select` = select all; `install --select category:X` / `tier:X` | Full (install); Partial (subset only with `--select`) | -| 8 | `DownloadsExplainPage` | Continue (downloads may run) | `install -d` or `convert -d` | Partial — see [download-system.md](download-system.md); FOMOD post-download configure is GUI-only until [Plan 123](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) | +| 8 | `DownloadsExplainPage` | Continue (downloads may run) | `install -d` or `convert -d` (+ FOMOD: TTY / `--fomod-choices` / `--fomod-skip`) | Partial — live download status is `[UI]`; FOMOD configure is Full via CLI (see [fomod-support.md](fomod-support.md)) | | 9 | `ValidatePage` | Run validation | `validate --full --dry-run --use-file-selection` (same Core `InstallationValidationPipeline` as GUI) | Full | | 10 | `InstallStartPage` | Confirm install | `install -y` (runs `InstallationValidationPipeline` / `WizardFull` pre-check unless `--skip-validation`) | Full | | 11 | `InstallingPage` | Watch progress | `install` (console progress) | Full — see [install-lifecycle.md](install-lifecycle.md) | @@ -30,8 +30,8 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | `Step1ModDirectoryPicker` | `--modDirectory=` / `-s` | Full | | `Step1KotorDirectoryPicker` | `--kotorPath=` / `-g` | Full | | `Step2Button` (load file) | `--instructionFile=` / `-i` | Full | -| `ImportFromClipboardButton` (paste guide / TOML) | Headless: `GuiSmokeHeadlessTests` + `ControlsHeadlessTests`; Core `FileLoadingService.ImportFromTextAsync` | Partial — button/event smoke is headless; real clipboard OS paste still `[UI]` | -| `ScrapeDownloadsButton` | `install -d` or `convert -d` | Partial — FOMOD configure after download: GUI (PR #169); CLI planned Plan 123 | +| `ImportFromClipboardButton` (paste guide / TOML) | Core: `convert --stdin` / `-i` + optional `--parse-directions` ([guide-ingestion.md](guide-ingestion.md)); headless button smoke: `GuiSmokeHeadlessTests` | Full (file/stdin ingest); Partial (OS clipboard paste still `[UI]`) | +| `ScrapeDownloadsButton` | `install -d` or `convert -d` (+ FOMOD flags as needed) | Full for download+FOMOD configure; Partial for live status/stop UI | | `ValidateButton` | `validate --full --dry-run --use-file-selection` (via `InstallationValidationPipeline`) | Full | | `OpenModDirectoryButton` | `ls` / file tools on mod dir | Full | | Download status / stop | No first-class CLI | UI | @@ -41,7 +41,8 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | Goal | Recommended path | |------|------------------| | Smoke-test repo | `./scripts/agents/run_headless_tests.sh` | -| GUI UX smoke (paste button, page-0 layout, validate log splitter) | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests"` (Avalonia.Headless — **no desktop**) | +| GUI UX smoke (paste import, wizard page order, page-0 layout, validate log splitter) | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~Headless\|FullyQualifiedName~GuiSmoke"` (Avalonia.Headless — **no desktop**) | +| Ingest guide → draft TOML | `convert -i guide.md --parse-directions -f toml -o out.toml` or `convert --stdin --parse-directions` — [guide-ingestion.md](guide-ingestion.md) | | Validate TOML structure only | `./scripts/agents/cli_validate.sh --input path.toml` | | Full validation | `cli_validate.sh` with `--game-dir`, `--source-dir`, `--full` | | Validate only TOML-selected mods | `cli_validate.sh` … `--use-file-selection` (matches GUI Mod Selection) | @@ -57,7 +58,8 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | CLI install | `CliInstallIntegrationTests`, `ValidationPipelineParityTests` | End-to-end install; install pre-check uses same pipeline as wizard | | VFS validation | `VirtualFileSystemDryRunValidationTests` | Dry-run matches VFS rules | | Wizard UI | `WizardFlowHeadlessTests` | Page flow without full desktop | -| GUI UX smoke | `GuiSmokeHeadlessTests` | Paste-import button, Welcome/Landing scroll layout, ValidatePage log splitter | +| GUI UX smoke | `GuiSmokeHeadlessTests` | Paste-import button + `LoadInstructionTextAsync` markdown (no clipboard), Welcome→ValidatePage key controls, compact ScrollViewer layout, ValidatePage log splitter | +| Guide ingest | `GuideIngestionTests` | `--stdin` / `--parse-directions` draft + sandboxed paths | | Wizard validation UX | `WizardValidationStagePresenter`, `ValidationPipelineDialogMapper` | Stage cards / dialog mapper parity ([PR #110](https://github.com/th3w1zard1/ModSync/pull/110)) | | Version alignment | `ReleaseVersionAlignmentTests` | Release metadata consistency | @@ -75,6 +77,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG 6. **CI test coverage** — green CI runs subsets only; local `run_headless_tests.sh` is broader. See [ci-test-matrix.md](ci-test-matrix.md). 7. **Validation pipeline fail-fast** — `InstallationValidationPipeline` stops after environment failure (no conflict/order/archive/dry-run stages). GUI and CLI both use `ValidationPipelineResult.IsSuccess`; do not infer pass from an empty dry-run result. 8. **Install pre-check opt-out** — `install --skip-validation` and `install_best_effort.sh` skip the wizard-equivalent pipeline; default `install` does not. -9. **FOMOD post-download** — GUI prompts after Fetch Downloads (PR #169). CLI parity: TTY wizard, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES` per [Plan 123](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md). +9. **FOMOD post-download** — GUI prompts after Fetch Downloads (PR #169). CLI: TTY wizard, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`, or non-TTY **warn-continue** (marks `warned`; `FomodConfigurationGate` still blocks validate/install until `configured`). See [fomod-support.md](fomod-support.md). +10. **Guide drafts** — `--parse-directions` / GUI paste drafts are review-flagged; never treat as trusted install instructions without review. See [guide-ingestion.md](guide-ingestion.md). See [agent-native-audit.md](agent-native-audit.md) for scored principles and [core-cli-reference.md](core-cli-reference.md) for flags. diff --git a/docs/knowledgebase/agent-native-audit.md b/docs/knowledgebase/agent-native-audit.md index 081dad87..69182100 100644 --- a/docs/knowledgebase/agent-native-audit.md +++ b/docs/knowledgebase/agent-native-audit.md @@ -17,7 +17,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | # | Principle | Score | Summary | |---|-----------|-------|---------| -| 1 | **Parity** | 17/25 (68%) | Core paths plus CLI FOMOD post-download (Plan 123) close a major GUI-only gap; widescreen/Aspyr remain UI-only. | +| 1 | **Parity** | 17/25 (68%) | Core paths plus shipped CLI FOMOD post-download and guide ingest close major GUI-only gaps; widescreen/Aspyr remain UI-only. | | 2 | **Granularity** | 16/20 (80%) | CLI verbs are composable; scripts wrap common combos without hiding primitives. | | 3 | **Composability** | 12/15 (80%) | New agent workflows combine `dotnet run` + scripts + tests without code changes. | | 4 | **Emergent capability** | 10/15 (67%) | Agents can fix TOMLs and run installs; limited without Nexus keys, real game dirs, or desktop. | @@ -35,22 +35,23 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | User / GUI capability | Agent path | Parity | |----------------------|------------|--------| | Load instruction file | GUI `--instructionFile=` or CLI `-i` | Yes | +| Paste / ingest guide | GUI clipboard; CLI `convert --stdin` / `-i` + `--parse-directions` | Yes (OS clipboard still `[UI]`) | | Set mod / game directories | GUI preload or CLI `-g` / `-s` | Yes | | Run validation | `ValidatePage` or `validate --full` | Yes (full needs dirs) | | Fetch downloads | Wizard / `ScrapeDownloadsButton` | Partial — CLI `install -d` / `convert -d` | -| Post-download FOMOD configure | GUI after Fetch Downloads (PR #169) | Partial — Plan 123: TTY wizard, `--fomod-choices`, `--fomod-skip` | -| FOMOD step wizard | `FomodInstallerDialog` | Partial — GUI today; Plan 123 terminal wizard | +| Post-download FOMOD configure | GUI after Fetch Downloads (PR #169) | Yes — CLI TTY wizard, `--fomod-choices`, `--fomod-skip`; non-TTY warn-continue still gated at validate/install | +| FOMOD step wizard | `FomodInstallerDialog` / CLI `FomodConsoleWizard` | Yes (TTY); non-TTY needs choices file | | Install mods | Wizard or `install` | Yes | | Mod selection / filters | `ModSelectionPage` UI | Partial — CLI `--select` | | Widescreen-only install block | Dynamic wizard pages | No — desktop only | | Rich-text / spoiler UI | GUI controls | No | | Telemetry-auth sidecar | Separate Python stack | Routed via `telemetry-auth/README.md` | -**Strengths:** `[REPO]` `ModBuildConverter` covers validate/install/convert/merge; `install_best_effort.sh` documents a full-build-style headless path. +**Strengths:** `[REPO]` `ModBuildConverter` covers validate/install/convert/merge; guide ingest + FOMOD CLI hosts close former GUI-only gaps; `install_best_effort.sh` documents a full-build-style headless path. -**Gaps:** `[OPEN]` No headless API for every wizard button; widescreen and Aspyr notice flows are `[UI]` only. FOMOD post-download configure is GUI-only until Plan 123 (`docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md`). +**Gaps:** `[OPEN]` No headless API for every wizard button; widescreen and Aspyr notice flows are `[UI]` only. Non-TTY FOMOD warn-continue / skip leave archives unconfigured until `--fomod-choices` (gate still fails closed). Download status/stop remain GUI-only. -**Recommendations (Tier 1):** Keep `agent-action-parity.md` current when wizard pages change. Ship Plan 123 to close FOMOD action-parity gap; document `--fomod-choices` in `core-cli-reference.md`. +**Recommendations (Tier 1):** Keep `agent-action-parity.md` current when wizard pages change. Prefer `convert --parse-directions` and documented FOMOD flags over inventing new MCP surfaces. --- diff --git a/docs/knowledgebase/core-cli-reference.md b/docs/knowledgebase/core-cli-reference.md index e61f0b00..81b3a248 100644 --- a/docs/knowledgebase/core-cli-reference.md +++ b/docs/knowledgebase/core-cli-reference.md @@ -95,11 +95,13 @@ See [cli-selection-semantics.md](cli-selection-semantics.md) for install vs vali ### `convert` -Convert format, autogenerate links, download, or merge (with `-m`). +Convert format, autogenerate links, download, merge (with `-m`), or ingest a pasted/piped guide. | Flag | Description | |------|-------------| -| `-i` / `--input` | Single-file input | +| `-i` / `--input` | Single-file input (required unless `--stdin`) | +| `--stdin` | Read guide/instruction content from standard input instead of `--input` (format auto-detected: TOML, Markdown, YAML, XML, JSON). Cannot combine with `--input` | +| `--parse-directions` | Draft executable instructions from natural-language `Directions` prose for components that have none; drafted components are flagged for review in the output (never auto-trusted) | | `-o` / `--output` | Output path (stdout if omitted) | | `-f` / `--format` | `toml`, `yaml`, `json`, `xml`, `ini`, `markdown` | | `-a` / `--auto` | Autogenerate from URLs (no download) | @@ -111,7 +113,21 @@ Convert format, autogenerate links, download, or merge (with `-m`). | Merge preference flags | `--prefer-existing-*`, `--prefer-incoming-*`, `--exclude-*-only`, `--use-existing-order` | | `--concurrent`, `--ignore-errors`, `--spoiler-free` | As labeled in `--help` | | `--auto-generate-local` | Generate instructions from local archives in `--source-path` for components missing instructions | -| `--nexus-mods-api-key` | No | Nexus key for `convert` / merge downloads (name differs from `install --nexus-api-key`) | +| `--nexus-mods-api-key` | Nexus key for `convert` / merge downloads (name differs from `install --nexus-api-key`) | + +**Guide paste / draft instructions (CLI parity for GUI Import from Clipboard):** + +```bash +# Pipe a markdown guide (or TOML/YAML/XML/JSON) and emit review-flagged TOML with draft instructions +cat ./mod-builds/content/k1/full.md | dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert --stdin --parse-directions -f toml -o ./tmp/ingested.toml + +# Same from a file (no pipe) +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert -i ./mod-builds/content/k1/full.md --parse-directions -f toml -o ./tmp/ingested.toml +``` + +Draft paths always use `<>` / `<>`. Review before `install`. Tests: `GuideIngestionTests`. See [guide-ingestion.md](guide-ingestion.md). --- @@ -211,6 +227,7 @@ Used by `scripts/agents/launch_gui_desktop.sh`. See `agent-action-parity.md`. ## Related docs +- [Guide ingestion](guide-ingestion.md) - [CLI selection semantics](cli-selection-semantics.md) - [Agent action parity](agent-action-parity.md) - [HoloPatcher resources](holopatcher-resources.md) diff --git a/docs/knowledgebase/download-system.md b/docs/knowledgebase/download-system.md index 50fdd3b7..080fab00 100644 --- a/docs/knowledgebase/download-system.md +++ b/docs/knowledgebase/download-system.md @@ -60,6 +60,8 @@ Helper: **`scripts/agents/cli_validate.sh`** does not download; use **`install - **Parity gap**: CLI can download archives; it does not replicate the GUI download status panel or stop button. See [agent-action-parity.md](agent-action-parity.md). +FOMOD after download is available on CLI (`install -d` / `convert -d` / `merge -d`) via TTY wizard, `--fomod-choices`, or `--fomod-skip`. Non-interactive **warn-continue** / skip print recovery hints but leave archives unconfigured — `FomodConfigurationGate` still blocks validate/install. + ## FOMOD post-download (CLI + GUI) `[REPO]` After downloads complete, selected components with FOMOD archives may be configured before validation/install: diff --git a/docs/knowledgebase/fomod-support.md b/docs/knowledgebase/fomod-support.md index cbd0af66..63c74252 100644 --- a/docs/knowledgebase/fomod-support.md +++ b/docs/knowledgebase/fomod-support.md @@ -50,20 +50,16 @@ GUI (`src/ModSync.GUI/`): ## Deferred `[OPEN]` - Plugin images from `image path`. +- In-validate **Configure FOMOD** action / `fomod configure` CLI verb (recovery hints only today). -## Planned: CLI post-download parity `[REPO]` +## Post-download hook + CLI parity `[REPO]` -Requirements: [docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md](../brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md) - -Plan: [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) - -- Core `FomodPostDownloadOrchestrator` + CLI console host + `--fomod-skip` / `--fomod-choices` -- Full TTY wizard; non-TTY default warn-continue; convert output persists FOMOD state - -## Post-download hook `[REPO]` +**Shipped.** Requirements (completed): [archive discovery](../brainstorms/2026-06-14-fomod-archive-discovery-requirements.md), [CLI prompts](../brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md). Plan 123: [shipped](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md). - `FomodArchiveProbe` detects `fomod/ModuleConfig.xml` inside downloaded archives via entry listing. -- `FomodPostDownloadPromptService` runs after GUI **Fetch Downloads** completes; optional prompt per archive. +- `FomodPostDownloadOrchestrator` + `IFomodPostDownloadHost` adapters unify GUI and CLI after download. +- GUI: `FomodPostDownloadPromptService` / `FomodGuiPostDownloadHost` after **Fetch Downloads**. +- CLI `install -d` / `convert -d` / `merge -d`: TTY wizard, warn-continue, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES` (also `--interactive` / `--non-interactive`, env `MODSYNC_FOMOD_POST_DOWNLOAD_MODE`). - `FomodDownloadPromptState` stores dismissed/configured/warned outcomes in resource handler metadata. - **`configured`**: permanently skips the Fetch Downloads / CLI post-download prompt; required to pass `FomodConfigurationGate`. - **`dismissed`**: does **not** pass the gate; Fetch Downloads / CLI will **re-prompt** (documented recovery path). @@ -73,6 +69,8 @@ Plan: [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../pla - **R3 missing archives:** registered archive paths missing on disk are **fail-closed** when the archive already has FOMOD prompt state (configured/dismissed/warned). Otherwise they emit a soft warning (generic missing downloads stay with component archive validation). - `ArchiveEnumerationService` sets `FileTreeNode.IsFomodInstaller` when an archive contains FOMOD metadata. +Non-TTY **warn-continue** / `--fomod-skip` print recovery hints (`FomodConfigurationGate.RecoveryHint`); they do **not** satisfy the configured-only gate. + ## Verification ```bash @@ -83,3 +81,4 @@ Plans: - [docs/plans/2026-06-12-115-fomod-parser-plan.md](../plans/2026-06-12-115-fomod-parser-plan.md) - [docs/plans/2026-06-14-121-fomod-installer-dialog-plan.md](../plans/2026-06-14-121-fomod-installer-dialog-plan.md) +- [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) (shipped; residual TTY polish / in-validate recovery are deferred) diff --git a/docs/knowledgebase/guide-ingestion.md b/docs/knowledgebase/guide-ingestion.md new file mode 100644 index 00000000..f2a551ec --- /dev/null +++ b/docs/knowledgebase/guide-ingestion.md @@ -0,0 +1,55 @@ +# Guide ingestion (paste + draft instructions) + +`[REPO]` How community install guides become instruction files with optional draft executable steps. Vision context: [product-vision.md](product-vision.md). CLI flags: [core-cli-reference.md](core-cli-reference.md). + +## What shipped + +| Capability | Surface | Agent path | +|------------|---------|------------| +| Format-sniff import (TOML → Markdown → YAML → XML/JSON) | GUI paste / file open; Core deserialize | `convert --stdin` or `convert -i` | +| Markdown guide → components (`Directions` preserved) | `MarkdownParser` | Same convert path | +| Prose → draft `Instruction` objects | `DraftInstructionService` + `NaturalLanguageInstructionParser` | `convert --parse-directions` | +| GUI clipboard paste | `ImportFromClipboardButton` → `FileLoadingService.LoadInstructionTextAsync` | Headless smoke: `GuiSmokeHeadlessTests`; real OS clipboard still `[UI]` | +| Guide emission (components → markdown) | `GenerateModDocumentation` | Round-trip covered by `GuideIngestionTests` + documentation tests | + +Draft instructions are **review-flagged** in output (never auto-trusted). Paths must use `<>` / `<>` only. + +## When drafts are generated (decision) + +**Drafts from natural-language Directions are opt-in except for paste.** + +| Path | Drafts from prose? | Why | +|------|--------------------|-----| +| GUI **paste / clipboard** | Yes (always) | Paste is an explicit “ingest this guide” action; users expect draft instructions | +| CLI `convert --parse-directions` | Yes (flag required) | Agent/parity path; keeps plain `convert` non-destructive | +| GUI **file open / drag-drop** of `.md` | No | Opening an authored guide or TOML companion must not rewrite empty `Instructions` with heuristic drafts | + +File-based markdown import can still produce drafts by converting with `--parse-directions` (or pasting the file contents). There is no separate GUI feature flag for file open; paste + CLI is the intentional surface. + +## Agent workflow + +```bash +# Ingest a guide file to review-flagged TOML (opt-in drafts) +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert -i ./path/to/guide.md --parse-directions -f toml -o ./tmp/draft.toml + +# Or pipe pasted text +cat clipboard.txt | dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert --stdin --parse-directions -f toml -o ./tmp/draft.toml +``` + +Then review drafts, fix TOML as needed, and use `validate` / `install` as usual. + +## Tests + +```bash +dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~GuideIngestionTests" +./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests" +``` + +## Related + +- Requirements (completed): [docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md](../brainstorms/2026-07-13-guide-paste-ingestion-requirements.md) +- Plan (completed): [docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) +- [agent-action-parity.md](agent-action-parity.md) +- [product-vision.md](product-vision.md) diff --git a/docs/knowledgebase/product-overview.md b/docs/knowledgebase/product-overview.md index 569c82a5..fbab176f 100644 --- a/docs/knowledgebase/product-overview.md +++ b/docs/knowledgebase/product-overview.md @@ -62,11 +62,12 @@ KOTOR modding often requires dozens of repetitive, order-sensitive steps per mod | Capability | State | |------------|--------| | Paste-a-guide ingestion | Shipped (GUI clipboard + CLI `--stdin`) | -| Draft instructions from guide prose | Shipped (review-flagged drafts via `DraftInstructionService`) | +| Draft instructions from guide prose | Shipped (review-flagged drafts via `DraftInstructionService` / `--parse-directions`) | | Headless Avalonia GUI smoke | Shipped (`GuiSmokeHeadlessTests`) | +| FOMOD CLI post-download configure | Shipped (TTY wizard, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`, `--fomod-skip`) | | FOMOD configure-before-validate/install gate | Shipped (`FomodConfigurationGate`; fail-closed on unreadable archives) | | `nxm://` entry point | Shipped | -| `modsync://` / publish-share | Not shipped — see [product-vision.md](product-vision.md) | +| `modsync://` (parse/CLI/handoff/consume + OS registration) | Shipped — Settings checkbox deferred; see [modsync-protocol-handler.md](modsync-protocol-handler.md) | ## Related diff --git a/docs/knowledgebase/product-vision.md b/docs/knowledgebase/product-vision.md index 3a9c925e..27205160 100644 --- a/docs/knowledgebase/product-vision.md +++ b/docs/knowledgebase/product-vision.md @@ -30,17 +30,17 @@ Row numbers map each capability from [The full vision](#the-full-vision) above; | 1a. Guide import (markdown → components) | **Shipped.** `MarkdownParser` parses Deadly Stream-style guides into components, prose preserved in `Directions`; verified round-trippable | `[REPO]` `src/ModSync.Core/Parsing/MarkdownParser.cs`, `DocumentationRoundTripTests` | | 1b. Paste-a-guide ingestion | **Shipped.** Getting Started **Import from Clipboard** + `FileLoadingService.ImportFromTextAsync`; CLI `convert --stdin` | `[REPO]` [guide-ingestion.md](guide-ingestion.md), `GuideIngestionTests`, `GuiSmokeHeadlessTests` | | 1c. Prose → executable instructions | **Shipped.** `DraftInstructionService` wires `NaturalLanguageInstructionParser`; drafts review-flagged, never auto-trusted | `[REPO]` `DraftInstructionService.cs`, `convert --parse-directions`, [guide-ingestion.md](guide-ingestion.md) | -| 2. "Install with ModSync" entry points | **Partial.** `nxm://` shipped; `modsync://` parse + enqueue exist, but GUI drain/fetch/OS registration incomplete | `[REPO]` `NxmProtocolRegistrationService`; `ModSyncUrl.cs`, `ModSyncHandoffQueue`; [brainstorm](../brainstorms/2026-07-13-modsync-protocol-requirements.md) | +| 2. "Install with ModSync" entry points | **Shipped (Settings toggle deferred).** `nxm://` + `modsync://` parse/CLI/handoff/consume + OS registration | `[REPO]` [modsync-protocol-handler.md](modsync-protocol-handler.md); `ModSyncHandoffService`, `ModSyncProtocolRegistrationService` | | 3. Guide emission (components → guide) | **Shipped.** `GenerateModDocumentation` in `ModComponentSerializationService` | `[REPO]` `src/ModSync.Core/Services/ModComponentSerializationService.cs` | | 4. Multi-author builds | **Partial.** Merge tooling (`merge` CLI) and install profiles exist; publish/share flows do not | `[REPO]` merge/profiles; [plan stub](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) | -`[SYNTH]` Net: guide round-trip (import + paste + draft instructions + emit) is shipped. Active gaps are finishing `modsync://` consume/registration and multi-author publish/share. +`[SYNTH]` Net: guide round-trip (import + paste + draft instructions + emit) and `modsync://` entry points are shipped. Active gap is multi-author publish/share (and optional Settings toggle for protocol registration). ## Where the work is tracked - Strategy and tracks: [`STRATEGY.md`](../../STRATEGY.md) (repo root) - Guide paste (done): [docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md](../brainstorms/2026-07-13-guide-paste-ingestion-requirements.md), [plan 001](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) -- `modsync://` (open): [brainstorm](../brainstorms/2026-07-13-modsync-protocol-requirements.md), [plan stub 002](../plans/2026-07-13-002-feat-modsync-protocol-plan.md) +- `modsync://` (shipped; Settings toggle deferred): [modsync-protocol-handler.md](modsync-protocol-handler.md), [plan 006](../plans/2026-07-13-006-feat-modsync-protocol-os-registration-plan.md) - Multi-author publish/share (open): [plan stub 003](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) - Release readiness quadruple-check: [plan 005](../plans/2026-07-13-005-release-readiness-checklist.md) @@ -49,6 +49,7 @@ Row numbers map each capability from [The full vision](#the-full-vision) above; - [product-overview.md](product-overview.md) — what ModSync is today - [guide-ingestion.md](guide-ingestion.md) — paste/draft CLI and GUI paths - [nxm-protocol-handler.md](nxm-protocol-handler.md) — shipped Nexus entry-point track +- [modsync-protocol-handler.md](modsync-protocol-handler.md) — build deep links (consume + OS registration shipped) - [install-profiles.md](install-profiles.md) — multi-build loadouts - [fomod-support.md](fomod-support.md) — FOMOD discovery, CLI prompts, configured-only gate - [agent-action-parity.md](agent-action-parity.md) — GUI vs CLI coverage of these flows diff --git a/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md b/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md index f57f7148..035511d9 100644 --- a/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md +++ b/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md @@ -1,7 +1,8 @@ --- title: "feat: CLI FOMOD post-download prompts" -status: active +status: completed date: 2026-06-14 +completed: 2026-07-13 origin: docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md deepened: 2026-06-14 --- diff --git a/docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md b/docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md index 5c9d0dac..cc890523 100644 --- a/docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md +++ b/docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md @@ -1,8 +1,10 @@ --- title: "feat: guide paste ingestion + NaturalLanguageInstructionParser wiring" -status: active +status: completed date: 2026-07-13 -origin: .cursor plan "ModSync Vision and Ingestion" (vision docs landed separately in STRATEGY.md + docs/knowledgebase/product-vision.md) +completed: 2026-07-13 +origin: docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md +related_strategy: STRATEGY.md + docs/knowledgebase/product-vision.md --- # Plan 2026-07-13-001 — Guide paste ingestion diff --git a/docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md b/docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md new file mode 100644 index 00000000..3553e671 --- /dev/null +++ b/docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md @@ -0,0 +1,69 @@ +--- +title: modsync protocol OS registration +type: feature +status: active +date: 2026-07-13 +origin: docs/brainstorms/2026-07-13-modsync-protocol-requirements.md +related: docs/knowledgebase/modsync-protocol-handler.md +--- + +# Plan: modsync:// OS registration (follow-up) + +Phase 2 of the "Install with ModSync" build-link track. Phase 1 (parse + CLI + single-instance handoff + tests) is shipped separately. + +## Goal + +Register ModSync as the OS handler for the `modsync://` URL scheme so browsers and other apps can open build deep links without a manual `--modsync=` launch. + +## Context + +`[REPO]` `nxm://` already has a full registration stack (`NxmProtocolRegistrationService`, Settings toggle, `NxmHandlerProbe`, macOS `Info.plist`). Reuse that phase model rather than inventing a second registration architecture. + +`[REPO]` Phase 1 already accepts `--modsync=` / positional `modsync://...`, forwards over the single-instance pipe, and buffers in `ModSyncHandoffQueue`. OS registration only needs to launch the same CLI surface the OS already uses for nxm (`"" "%1"` / desktop `Exec=`). + +## Implementation units (suggested) + +### U1. `ModSyncProtocolRegistrationService` + +Mirror `NxmProtocolRegistrationService`: + +- Windows: `HKCU\Software\Classes\modsync` via `reg.exe` → open command with `--modsync="%1"` or bare `"%1"`. +- Linux: `~/.local/share/applications/modsync-protocol.desktop` with `MimeType=x-scheme-handler/modsync;` + `xdg-mime default`. +- macOS: add `modsync` to `CFBundleURLTypes` alongside `nxm` in app-bundle `Info.plist` (declarative; requires `ModSync.app`). + +Pure builders (`BuildDesktopFileContent`, `BuildWindowsRegCommands`) must be unit-tested like nxm. + +### U2. Settings preference + +- Persist `registerModSyncProtocolHandler` in `AppSettings` / `MainConfig`. +- Settings → Download (or a dedicated "Install with ModSync") checkbox parallel to the nxm toggle. +- On save / startup drift repair: `Register()` / `Unregister()` like nxm Phase 3. + +### U3. Handler probe + conflict UX (optional, later phase) + +- Read-only probe for who owns `modsync://` (usually only ModSync; conflict risk is low vs nxm/MO2/Vortex). +- Confirm before overwrite only if another handler is detected. + +### U4. MainWindow consumption (can ship with or before U1) + +- `ModSyncHandoffService` drains `ModSyncHandoffQueue`, parses via `ModSyncUrl.TryParse`, fetches the instruction URL over http(s), and loads it through the existing instruction-file pipeline (wizard preload / Getting Started load). +- Fail closed on non-http(s) or parse failure; surface a clear error dialog. + +## Out of scope for this follow-up plan body + +- Changing the Phase 1 URL grammar. +- Publishing builds to a ModSync-hosted CDN (links may point at GitHub raw / any https host). +- Desktop E2E browser-click validation (still recommended after U1+U4). + +## Verification (when implemented) + +```bash +dotnet build ModSync.sln --configuration Debug +dotnet test src/ModSync.Tests/ModSync.Tests.csproj \ + --filter "FullyQualifiedName~ModSyncUrl|FullyQualifiedName~ModSyncProtocol|FullyQualifiedName~CLIArgumentsModSync" +``` + +## Dependencies + +- Phase 1 parse/CLI/handoff (this repo, already landed). +- Prefer modeling after plans 113–119 under `docs/plans/2026-06-*-nxm-*.md`. diff --git a/docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md b/docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md new file mode 100644 index 00000000..15ee4d7c --- /dev/null +++ b/docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md @@ -0,0 +1,39 @@ +--- +title: "feat: multi-author publish/share flows" +status: stub +date: 2026-07-13 +strategy_track: Multi-author builds +origin: STRATEGY.md + docs/knowledgebase/product-vision.md +--- + +# Plan stub 2026-07-13-003 — Multi-author publish/share + +## Summary + +Close the remaining multi-author gap: after merge tooling and install profiles, authors need a supported way to **publish and share** an instruction file (link or artifact) so players can open it via ModSync — not only the single canonical curator build. + +## Current state `[REPO]` + +| Piece | Status | +|-------|--------| +| `merge` CLI verb | Shipped | +| Install profiles | Shipped ([install-profiles.md](../knowledgebase/install-profiles.md)) | +| Guide emission (`GenerateModDocumentation`) | Shipped | +| Hosted publish / share UX | **Missing** | +| `modsync://` deep-link consume path | Phase 1 shipped; Phase 2 open — [002](2026-07-13-002-feat-modsync-protocol-os-registration-plan.md) | + +## Proposed outcome (v1 sketch) + +- Author exports a shareable instruction URL or packaged artifact from an existing instruction file. +- Player opens via `modsync://install?url=…` (depends on plan 002) or downloads then loads as today. +- No requirement to replace `mod-builds` as the community source of truth in v1 — focus on *any* author's encoded build being distributable. + +## Non-goals (v1) + +- Full Nexus Mods upload integration. +- Multiplayer collab editing. +- Replacing Deadly Stream / GitHub hosting — ModSync links *to* hosts, does not become the CDN. + +## Next step + +Flesh a brainstorm requirements doc (success criteria, auth/trust model for remote URLs, whether share is "copy link" only vs in-app publish) before a full ce-plan. Prefer sequencing **after** modsync:// consume (002) so share links have a working open path. diff --git a/docs/residual-review-findings/2026-07-13-security-paste-fomod.md b/docs/residual-review-findings/2026-07-13-security-paste-fomod.md new file mode 100644 index 00000000..7ec357f2 --- /dev/null +++ b/docs/residual-review-findings/2026-07-13-security-paste-fomod.md @@ -0,0 +1,31 @@ +# Residual review findings — paste / FOMOD security (2026-07-13) + +Branch: `fix/paste-draft-path-sandbox` (from `feat/guide-paste-ingestion`) +Review focus: path sandboxing for NL draft instructions (`DraftInstructionService`) and GUI/CLI review-flag parity after paste / `--parse-directions` + +## Finding (medium) — closed + +**Issue:** `DraftInstructionService.IsSandboxedPath` / `TrySanitizeInstruction` only checked `StartsWith("<>")` / `StartsWith("<>")` and did **not** reject `..` segments (or glued forms like `<>../outside`). Paste + `convert --parse-directions` could therefore draft instructions that escape the placeholder roots after `Path.GetFullPath`. + +**Fix:** + +1. `IsSandboxedPath` now requires a separator after the placeholder (when a relative path follows) and rejects `..`, drive-letter segments (`:`), and rooted segments — same rejection posture as `FomodToComponentMapper.NormalizeRelativePath`. +2. `GuideIngestionTests` cover `<>/../outside`, glued traversal, drive-after-placeholder, and prose cases that must not retain escaping paths. +3. GUI/CLI parity: successful drafts call `ApplyReviewFlag`, which sets `InstallationWarning` to `ReviewFlagMessage` (same text CLI writes as `# VALIDATION ISSUES:`). Paste path re-applies the flag and logs that message; `InstallStartPage` surfaces `InstallationWarning` on the pre-install review list. + +## Residual Review Findings + +| ID | Severity | Status | Notes | +|----|----------|--------|-------| +| PASTE-PATH-001 | Medium | **Closed** | `..` / rooted / drive escapes after placeholder rejected; tests in `GuideIngestionTests` | +| PASTE-REVIEW-001 | Medium (UX/parity) | **Closed** | Draft review flag on `InstallationWarning` + InstallStartPage display; CLI still emits `ReviewFlagMessage` via `ComponentValidationContext` | + +**Residual actionable work from this finding: none.** + +## Verification + +```bash +dotnet test src/ModSync.Tests/ModSync.Tests.csproj \ + --filter "FullyQualifiedName~GuideIngestionTests" \ + --configuration Debug +``` diff --git a/scripts/agents/README.md b/scripts/agents/README.md index c6b7b5ac..f8e8e1bc 100644 --- a/scripts/agents/README.md +++ b/scripts/agents/README.md @@ -37,6 +37,15 @@ ## Common flows +### Guide ingest (draft instructions) + +```bash +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert -i ./mod-builds/content/k1/full.md --parse-directions -f toml -o ./tmp/draft.toml +``` + +See [guide-ingestion.md](../../docs/knowledgebase/guide-ingestion.md). + ### Headless smoke test ```bash @@ -51,13 +60,18 @@ Optional filter: ### Avalonia GUI UX smoke (no desktop / no X11) -Uses `Avalonia.Headless.XUnit` via `HeadlessTestApp` — do **not** launch the real GUI for paste-flow or page-0 layout smoke: +Uses `Avalonia.Headless.XUnit` via `HeadlessTestApp` — do **not** launch the real GUI for paste-flow, wizard page-order, or page-0 layout smoke: ```bash +# Expanded filter: GuiSmokeHeadlessTests + all *Headless* suites +./scripts/agents/run_headless_tests.sh \ + --filter "FullyQualifiedName~Headless|FullyQualifiedName~GuiSmoke" + +# Narrow GuiSmoke only (paste import, wizard order, compact layout, ValidatePage splitter) ./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests" ``` -Covers: `ImportFromClipboardButton` invokable, Welcome/Landing scroll layout in a compact host, ValidatePage log `GridSplitter`. +`GuiSmokeHeadlessTests` covers: `ImportFromClipboardButton`, `LoadInstructionTextAsync` sample markdown (no OS clipboard), Welcome→ValidatePage key controls, Welcome/Landing/early-page ScrollViewer layout, ValidatePage log `GridSplitter`. ### Validate instruction file diff --git a/scripts/agents/run_headless_tests.sh b/scripts/agents/run_headless_tests.sh index baeb560c..9cf39434 100755 --- a/scripts/agents/run_headless_tests.sh +++ b/scripts/agents/run_headless_tests.sh @@ -13,6 +13,18 @@ Options: --filter EXPR dotnet test filter (default: exclude LongRunning) --configuration NAME Debug or Release (default: Debug) -h, --help Show this help + +Examples: + # All non-LongRunning tests + ./scripts/agents/run_headless_tests.sh + + # Expanded Avalonia GUI smoke (GuiSmoke + all *Headless* suites; no X11) + ./scripts/agents/run_headless_tests.sh \ + --filter "FullyQualifiedName~Headless|FullyQualifiedName~GuiSmoke" + + # Narrow paste / page-0 / wizard-order smoke only + ./scripts/agents/run_headless_tests.sh \ + --filter "FullyQualifiedName~GuiSmokeHeadlessTests" EOF } diff --git a/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs b/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs index 66d7a311..116df2ed 100644 --- a/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs +++ b/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs @@ -21,7 +21,8 @@ public Task AskConfigureAsync( { string message = $"WARN: FOMOD installer detected in '{context.ArchiveFileName}' for mod '{context.Component.Name}'. " - + "Configure later via GUI or re-run with --fomod-choices."; + + "Download continues, but validate/install will fail until configured. " + + FomodConfigurationGate.RecoveryHint; Console.Error.WriteLine(message); FomodDownloadPromptState.MarkWarned(context.Component, context.ArchiveFileName); return Task.FromResult(FomodConfigurePromptResult.AlreadyHandled); @@ -38,7 +39,7 @@ public Task ReportExtractFailureAsync( string message, CancellationToken cancellationToken = default) { - Console.Error.WriteLine(message); + Console.Error.WriteLine($"{message} {FomodConfigurationGate.RecoveryHint}"); return Task.CompletedTask; } @@ -50,8 +51,15 @@ public sealed class FomodSkipPostDownloadHost : IFomodPostDownloadHost { public Task AskConfigureAsync( FomodPromptContext context, - CancellationToken cancellationToken = default) => - Task.FromResult(FomodConfigurePromptResult.Dismiss); + CancellationToken cancellationToken = default) + { + Console.Error.WriteLine( + $"WARN: Skipping FOMOD configuration for '{context.ArchiveFileName}' " + + $"(mod '{context.Component.Name}') because --fomod-skip / skip mode is active. " + + "Validate/install will fail until this archive is configured. " + + FomodConfigurationGate.RecoveryHint); + return Task.FromResult(FomodConfigurePromptResult.Dismiss); + } public Task RunWizardAsync( string extractedArchiveDirectory, @@ -62,8 +70,11 @@ public Task RunWizardAsync( public Task ReportExtractFailureAsync( FomodPromptContext context, string message, - CancellationToken cancellationToken = default) => - Task.CompletedTask; + CancellationToken cancellationToken = default) + { + Console.Error.WriteLine($"{message} {FomodConfigurationGate.RecoveryHint}"); + return Task.CompletedTask; + } public Task ReportConfiguredAsync(FomodPromptContext context, CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index 29175758..e2162b03 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -1939,7 +1939,7 @@ private static async Task RunConvertAsync(ConvertOptions opts) throw; } - List draftResults = null; + IReadOnlyList draftResults = null; if (opts.ParseDirections) { msg = "Drafting instructions from natural-language Directions prose..."; @@ -1955,7 +1955,7 @@ private static async Task RunConvertAsync(ConvertOptions opts) draftResults = Parsing.DraftInstructionService.GenerateDraftInstructions( components, logInfo: message => Logger.Log(message), - logVerbose: message => Logger.LogVerbose(message)).ToList(); + logVerbose: message => Logger.LogVerbose(message)); msg = $"Drafted instructions for {draftResults.Count} component(s) - all drafts are flagged for review"; if (s_progressDisplay != null) diff --git a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs index b0d108ab..8bc42d7b 100644 --- a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs +++ b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs @@ -271,6 +271,12 @@ public sealed class NaturalLanguageInstructionParser ), // === DELETE PATTERNS (exhaustive) === + // "Before moving ... delete / be sure to delete ..." + new InstructionPattern( + @"before\s+moving(?:\s+the\s+files?)?(?:\s+to\s+(?:your\s+)?(?:override|game).*?)?,?\s+(?:be\s+sure\s+to\s+)?delete\s+(?:the\s+following(?:\s+files?)?:?\s*)?(?.+?)(?:\.|$)", + Instruction.ActionType.Delete, + RegexOptions.IgnoreCase + ), // "Delete X.ext & .txi" new InstructionPattern( @"delete\s+(?[\w\-_.]+?)(?:\s+&\s+\.[\w]+)+\s+before", @@ -283,15 +289,15 @@ public sealed class NaturalLanguageInstructionParser Instruction.ActionType.Delete, RegexOptions.IgnoreCase ), - // "(sorted by name) X through Y" + // "(sorted by name) X through Y" (markdown emphasis may already be stripped) new InstructionPattern( - @"\(sorted\s+by\s+name\)\s+(?[\w\-_.]+?)\s+\*\*through\*\*\s+(?[\w\-_.]+)", + @"\(sorted\s+by\s+name\)\s+(?[\w\-_.]+?)\s+(?:\*\*)?through(?:\*\*)?\s+(?[\w\-_.]+)", Instruction.ActionType.Delete, RegexOptions.IgnoreCase ), - // "Delete the following files: X, Y, Z" + // "Delete the following files: X, Y, Z" / "delete the following: X, Y" new InstructionPattern( - @"delete\s+the\s+following\s+files?:\s+(?.+?)(?:\.|$)", + @"delete\s+the\s+following(?:\s+files?)?:?\s+(?.+?)(?:\.|$)", Instruction.ActionType.Delete, RegexOptions.IgnoreCase ), @@ -412,18 +418,18 @@ public sealed class NaturalLanguageInstructionParser // Destination normalization mappings private static readonly Dictionary s_destinationMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["override"] = @"<>\Override", - ["override folder"] = @"<>\Override", - ["override directory"] = @"<>\Override", - ["game's override"] = @"<>\Override", - ["your override"] = @"<>\Override", - ["main game directory"] = @"<>", - ["main directory"] = @"<>", - ["game directory"] = @"<>", - ["game directory"] = @"<>", - ["movies folder"] = @"<>\Movies", - ["movies"] = @"<>\Movies", - ["your movies folder"] = @"<>\Movies", + ["override"] = @"<>\Override", + ["override folder"] = @"<>\Override", + ["override directory"] = @"<>\Override", + ["game's override"] = @"<>\Override", + ["your override"] = @"<>\Override", + ["main game directory"] = @"<>", + ["main directory"] = @"<>", + ["game directory"] = @"<>", + ["game directory"] = @"<>", + ["movies folder"] = @"<>\Movies", + ["movies"] = @"<>\Movies", + ["your movies folder"] = @"<>\Movies", }; public NaturalLanguageInstructionParser([CanBeNull] Action logInfo = null, [CanBeNull] Action logVerbose = null) @@ -455,8 +461,12 @@ public ObservableCollection ParseInstructions( _logVerbose($"[NLParser] Parsing instructions for component: {parentComponent.Name}"); + // Deadly Stream / mod-builds guides often wrap emphasis around verbs ("**before** moving"). + // Strip common markdown emphasis markers so regex patterns see plain prose. + string normalizedInstructions = StripMarkdownEmphasis(installationInstructions); + // Split into logical units (sentences/clauses) - List units = SplitIntoProcessingUnits(installationInstructions); + List units = SplitIntoProcessingUnits(normalizedInstructions); _logVerbose($"[NLParser] Found {units.Count} instruction units to parse"); foreach (string unit in units) @@ -531,6 +541,24 @@ private static List SplitIntoProcessingUnits([NotNull] string text) return units; } + /// + /// Removes common markdown emphasis markers so guide prose matches instruction regexes. + /// + [NotNull] + private static string StripMarkdownEmphasis([NotNull] string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + // Bold/italic markers used heavily in mod-builds Installation Instructions notes. + return text + .Replace("**", string.Empty) + .Replace("__", string.Empty) + .Replace("*", string.Empty); + } + /// /// Parses a single instruction unit into zero or more Instructions. /// @@ -1037,12 +1065,12 @@ private static string NormalizeDestination([NotNull] string destination, [NotNul // Check full unit for destination clues if (fullUnit.IndexOf("override", StringComparison.OrdinalIgnoreCase) >= 0) { - return @"<>\Override"; + return @"<>\Override"; } if (fullUnit.IndexOf("movies", StringComparison.OrdinalIgnoreCase) >= 0) { - return @"<>\Movies"; + return @"<>\Movies"; } // Treat as subdirectory of mod directory if it looks like a path @@ -1052,7 +1080,7 @@ private static string NormalizeDestination([NotNull] string destination, [NotNul } // Default to override for Move/Copy - return @"<>\Override"; + return @"<>\Override"; } /// @@ -1066,29 +1094,29 @@ private static string InferDestination([NotNull] string fullUnit, Instruction.Ac // Check for destination keywords if (lower.Contains("to override") || lower.Contains("to your override") || lower.Contains("in override")) { - return @"<>\Override"; + return @"<>\Override"; } if (lower.Contains("to movies") || lower.Contains("in your movies") || lower.Contains("movies folder")) { - return @"<>\Movies"; + return @"<>\Movies"; } if (lower.Contains("main game directory") || lower.Contains("main directory") || lower.Contains("game directory")) { - return @"<>"; + return @"<>"; } // Patcher always uses kotorDirectory if (actionType == Instruction.ActionType.Patcher) { - return @"<>"; + return @"<>"; } // Default for Move/Copy if (actionType == Instruction.ActionType.Move || actionType == Instruction.ActionType.Copy) { - return @"<>\Override"; + return @"<>\Override"; } // Others don't need destination diff --git a/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs b/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs index 2ba38f76..8de34aaf 100644 --- a/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs +++ b/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs @@ -28,7 +28,9 @@ public Task AskConfigureAsync( if (archiveChoices is null) { Console.Error.WriteLine( - $"WARN: No FOMOD choices entry for archive '{context.ArchiveFileName}' in the choices file."); + $"WARN: No FOMOD choices entry for archive '{context.ArchiveFileName}' in the choices file. " + + "Download continues, but validate/install will fail until configured. " + + FomodConfigurationGate.RecoveryHint); FomodDownloadPromptState.MarkWarned(context.Component, context.ArchiveFileName); return Task.FromResult(FomodConfigurePromptResult.AlreadyHandled); } diff --git a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs index b787ed44..300e1cb4 100644 --- a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs +++ b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs @@ -87,6 +87,10 @@ await host.ReportExtractFailureAsync( if (configured is null) { + await host.ReportExtractFailureAsync( + context, + $"FOMOD configuration was not applied for '{archiveFileName}'.", + cancellationToken).ConfigureAwait(false); continue; } diff --git a/src/ModSync.Core/Services/ModComponentSerializationService.cs b/src/ModSync.Core/Services/ModComponentSerializationService.cs index 99c0dbbb..1c805572 100644 --- a/src/ModSync.Core/Services/ModComponentSerializationService.cs +++ b/src/ModSync.Core/Services/ModComponentSerializationService.cs @@ -930,19 +930,19 @@ public static string DetectFormatFromContent([NotNull] string content) return null; } - if (ParsesWithComponents(content, DeserializeModComponentFromTomlString, "toml")) + (string Format, Func> Parse)[] cascade = { - return "toml"; - } - - if (ParsesWithComponents(content, DeserializeModComponentFromMarkdownString, "markdown")) - { - return "markdown"; - } + ("toml", DeserializeModComponentFromTomlString), + ("markdown", DeserializeModComponentFromMarkdownString), + ("yaml", DeserializeModComponentFromYamlString), + }; - if (ParsesWithComponents(content, DeserializeModComponentFromYamlString, "yaml")) + foreach ((string format, Func> parse) in cascade) { - return "yaml"; + if (ParsesWithComponents(content, parse, format)) + { + return format; + } } if (content.TrimStart().StartsWith("<", StringComparison.Ordinal)) diff --git a/src/ModSync.GUI/Dialogs/WizardPages/AspyrNoticePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/AspyrNoticePage.axaml index 87f66087..655208c5 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/AspyrNoticePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/AspyrNoticePage.axaml @@ -6,21 +6,13 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - + + + + + - - - + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml index 917143a7..acc3f338 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml @@ -6,31 +6,36 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - + + + - + - + - - - - - - - - - + + + + + + + + + - - + + + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/FinishedPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/FinishedPage.axaml index 22b132e3..2380011e 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/FinishedPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/FinishedPage.axaml @@ -1,67 +1,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml index 13f9f51d..73d9ee8e 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml @@ -6,33 +6,26 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - - - - - - - - - - + + + + + + + + + + - - - + + - - + - + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs index 52e979b0..f438ed8b 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs +++ b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs @@ -124,6 +124,16 @@ private void RefreshSummary() Text = $"• {mod.Name}", TextWrapping = TextWrapping.Wrap, }); + + if (!string.IsNullOrWhiteSpace(mod.InstallationWarning)) + { + _modListPanel.Children.Add(new TextBlock + { + Text = $" ⚠ {mod.InstallationWarning}", + TextWrapping = TextWrapping.Wrap, + Opacity = 0.9, + }); + } } } } diff --git a/src/ModSync.GUI/Dialogs/WizardPages/InstallingPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/InstallingPage.axaml index a54ad4ba..f89073e5 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/InstallingPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/InstallingPage.axaml @@ -6,7 +6,8 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - + + @@ -247,4 +248,5 @@ TextAlignment="Center" /> + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/ModSelectionPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/ModSelectionPage.axaml index 228e5c5a..69522238 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/ModSelectionPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/ModSelectionPage.axaml @@ -123,7 +123,10 @@ Grid.Row="2" Padding="8" CornerRadius="8"> - + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/PreamblePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/PreamblePage.axaml index 56f98622..28c3cc83 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/PreamblePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/PreamblePage.axaml @@ -6,63 +6,19 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WelcomePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WelcomePage.axaml index 2328f4e2..806b9e97 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WelcomePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WelcomePage.axaml @@ -18,10 +18,9 @@ + TextAlignment="Center" + TextWrapping="Wrap" /> - + ItemSpacing="16" + LineSpacing="4" + Orientation="Horizontal"> - + @@ -48,7 +48,7 @@ Padding="14,12" CornerRadius="10"> - + - + @@ -107,7 +106,7 @@ Text="1" /> - + - + - + - + - + - + - + - + - + - + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenCompletePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenCompletePage.axaml index f634413f..8e69d170 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenCompletePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenCompletePage.axaml @@ -1,28 +1,19 @@ - - - - - - - - + + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenInstallingPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenInstallingPage.axaml index 3cf406b0..3abd8f2d 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenInstallingPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenInstallingPage.axaml @@ -6,14 +6,10 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenModSelectionPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenModSelectionPage.axaml index 87c0a2ed..4a8c1872 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenModSelectionPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenModSelectionPage.axaml @@ -6,13 +6,13 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - + + + + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenNoticePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenNoticePage.axaml index 2502f4a0..ca55e029 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenNoticePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenNoticePage.axaml @@ -6,17 +6,12 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - + + + + + - + - - diff --git a/src/ModSync.GUI/Services/FileLoadingService.cs b/src/ModSync.GUI/Services/FileLoadingService.cs index acb022fc..e6630866 100644 --- a/src/ModSync.GUI/Services/FileLoadingService.cs +++ b/src/ModSync.GUI/Services/FileLoadingService.cs @@ -21,6 +21,8 @@ namespace ModSync.Services { public class FileLoadingService { + private const int MaxInstructionSizeBytes = 524288000; + private readonly MainConfig _mainConfig; private readonly Window _parentWindow; @@ -44,19 +46,13 @@ public async Task LoadInstructionFileAsync( { try { - var fileInfo = new FileInfo(filePath); - const int maxInstructionSize = 524288000; - if (fileInfo.Length > maxInstructionSize) - - + if (fileInfo.Length > MaxInstructionSizeBytes) { #pragma warning disable MA0004 // Use Task. await Logger.LogAsync($"Invalid {fileType} selected: '{fileInfo.Name}' - file too large"); #pragma warning restore MA0004 // Use Task. return false; - - } // Auto-detect format (TOML/JSON/YAML/embedded-Markdown) @@ -71,8 +67,6 @@ public async Task LoadInstructionFileAsync( #pragma warning restore MA0004 // Use Task. } catch (Exception ex) - - { #pragma warning disable MA0004 // Use Task. await Logger.LogExceptionAsync(ex); @@ -105,8 +99,7 @@ public async Task LoadInstructionTextAsync( return false; } - const int maxInstructionSize = 524288000; - if (content.Length > maxInstructionSize) + if (content.Length > MaxInstructionSizeBytes) { #pragma warning disable MA0004 // Use Task. await Logger.LogWarningAsync($"Text from {sourceDescription} is too large to import."); @@ -167,11 +160,7 @@ private async Task ApplyLoadedComponentsAsync( if (_mainConfig.allComponents.Count == 0) { _mainConfig.allComponents = newComponents; - if (loadedFileName != null) - { - LastLoadedFileName = loadedFileName; - } - + SetLastLoadedFileName(loadedFileName); #pragma warning disable MA0004 // Use Task. await Logger.LogAsync($"Loaded {newComponents.Count} components from {fileType}."); @@ -196,16 +185,7 @@ private async Task ApplyLoadedComponentsAsync( "Currently Loaded Components", fileType, (existing, incoming) => - { - if (existing.Guid == incoming.Guid) - { - return true; - } - - return FuzzyMatcher.FuzzyMatchComponents(existing, incoming); - }); - - + existing.Guid == incoming.Guid || FuzzyMatcher.FuzzyMatchComponents(existing, incoming)); #pragma warning disable MA0004 // Use Task. await conflictDialog.ShowDialog(_parentWindow); @@ -216,12 +196,7 @@ private async Task ApplyLoadedComponentsAsync( int originalCount = _mainConfig.allComponents.Count; _mainConfig.allComponents = conflictDialog.MergedComponents; int newCount = _mainConfig.allComponents.Count; - if (loadedFileName != null) - { - LastLoadedFileName = loadedFileName; - } - - + SetLastLoadedFileName(loadedFileName); #pragma warning disable MA0004 // Use Task. await Logger.LogAsync($"Merged {newComponents.Count} components from {fileType} with existing {originalCount} components using hybrid matching (GUID then Name/Author). Total components now: {newCount}"); @@ -238,10 +213,7 @@ private async Task ApplyLoadedComponentsAsync( } case false: _mainConfig.allComponents = newComponents; - if (loadedFileName != null) - { - LastLoadedFileName = loadedFileName; - } + SetLastLoadedFileName(loadedFileName); #pragma warning disable MA0004 // Use Task. await Logger.LogAsync($"Overwrote existing config with {newComponents.Count} components from {fileType}."); #pragma warning restore MA0004 // Use Task. @@ -277,8 +249,6 @@ public async Task LoadMarkdownFileAsync( string fileContents; using (var reader = new StreamReader(filePath)) - - { #pragma warning disable MA0004 // Use Task. fileContents = await reader.ReadToEndAsync(); @@ -290,8 +260,6 @@ public async Task LoadMarkdownFileAsync( #pragma warning restore MA0004 // Use Task. } catch (Exception ex) - - { #pragma warning disable MA0004 // Use Task. await Logger.LogExceptionAsync(ex); @@ -315,194 +283,190 @@ public async Task LoadMarkdownContentAsync( { try { - { - MarkdownParserResult parseResult = null; - MarkdownImportProfile configuredProfile; + MarkdownParserResult parseResult = null; + MarkdownImportProfile configuredProfile; - if (editorMode) - { - // UI elements must be created and shown on the UI thread + if (editorMode) + { + // UI elements must be created and shown on the UI thread #pragma warning disable MA0004 // Use Task. - await Dispatcher.UIThread.InvokeAsync(async () => - { - var dialog = new RegexImportDialog(fileContents, profile ?? MarkdownImportProfile.CreateDefault()); + await Dispatcher.UIThread.InvokeAsync(async () => + { + var dialog = new RegexImportDialog(fileContents, profile ?? MarkdownImportProfile.CreateDefault()); - dialog.Closed += (_, __) => + dialog.Closed += (_, __) => + { + if (!dialog.LoadSuccessful || !(dialog.DataContext is RegexImportDialogViewModel vm)) { - if (!dialog.LoadSuccessful || !(dialog.DataContext is RegexImportDialogViewModel vm)) - { - return; - } + return; + } - configuredProfile = vm.ConfiguredProfile; - parseResult = vm.ConfirmLoad(); - ProcessModLinks(parseResult.Components); + configuredProfile = vm.ConfiguredProfile; + parseResult = vm.ConfirmLoad(); + ProcessModLinks(parseResult.Components); - // Log asynchronously without blocking the UI - _ = Task.Run(async () => - { + // Log asynchronously without blocking the UI + _ = Task.Run(async () => + { #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Markdown parsing completed using {(configuredProfile.Mode == RegexMode.Raw ? "raw" : "individual")} regex mode."); + await Logger.LogAsync($"Markdown parsing completed using {(configuredProfile.Mode == RegexMode.Raw ? "raw" : "individual")} regex mode."); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Found {parseResult.Components?.Count ?? 0} components with {parseResult.Components?.Sum(c => c.ResourceRegistry.Count) ?? 0} total links."); + await Logger.LogAsync($"Found {parseResult.Components?.Count ?? 0} components with {parseResult.Components?.Sum(c => c.ResourceRegistry.Count) ?? 0} total links."); #pragma warning restore MA0004 // Use Task. - if (parseResult.Warnings?.Count > 0) - { + if (parseResult.Warnings?.Count > 0) + { #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($"Markdown parsing completed with {parseResult.Warnings.Count} warnings."); + await Logger.LogWarningAsync($"Markdown parsing completed with {parseResult.Warnings.Count} warnings."); #pragma warning restore MA0004 // Use Task. - foreach (string warning in parseResult.Warnings) - { + foreach (string warning in parseResult.Warnings) + { #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($" - {warning}"); - } -#pragma warning restore MA0004 // Use Task. + await Logger.LogWarningAsync($" - {warning}"); } - }); - }; +#pragma warning restore MA0004 // Use Task. + } + }); + }; #pragma warning disable MA0004 // Use Task. - await dialog.ShowDialog(_parentWindow); + await dialog.ShowDialog(_parentWindow); #pragma warning restore MA0004 // Use Task. - }); + }); #pragma warning restore MA0004 // Use Task. - if (parseResult is null) - { - return false; - } - } - else + if (parseResult is null) { - configuredProfile = profile ?? MarkdownImportProfile.CreateDefault(); - var parser = new MarkdownParser(configuredProfile, - logInfo => Logger.Log(logInfo), - logVerbose => Logger.LogVerbose(logVerbose)); - parseResult = parser.Parse(fileContents); + return false; + } + } + else + { + configuredProfile = profile ?? MarkdownImportProfile.CreateDefault(); + var parser = new MarkdownParser(configuredProfile, + logInfo => Logger.Log(logInfo), + logVerbose => Logger.LogVerbose(logVerbose)); + parseResult = parser.Parse(fileContents); - ProcessModLinks(parseResult.Components); + ProcessModLinks(parseResult.Components); #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync("Markdown parsing completed using default profile."); + await Logger.LogAsync("Markdown parsing completed using default profile."); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Found {parseResult.Components?.Count ?? 0} components with {parseResult.Components?.Sum(c => c.ResourceRegistry.Count) ?? 0} total links."); + await Logger.LogAsync($"Found {parseResult.Components?.Count ?? 0} components with {parseResult.Components?.Sum(c => c.ResourceRegistry.Count) ?? 0} total links."); #pragma warning restore MA0004 // Use Task. - if (parseResult.Warnings?.Count > 0) - { + if (parseResult.Warnings?.Count > 0) + { #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($"Markdown parsing completed with {parseResult.Warnings.Count} warnings."); + await Logger.LogWarningAsync($"Markdown parsing completed with {parseResult.Warnings.Count} warnings."); #pragma warning restore MA0004 // Use Task. - foreach (string warning in parseResult.Warnings) - { + foreach (string warning in parseResult.Warnings) + { #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($" - {warning}"); - } -#pragma warning restore MA0004 // Use Task. + await Logger.LogWarningAsync($" - {warning}"); } +#pragma warning restore MA0004 // Use Task. } + } - if (draftInstructionsFromProse && parseResult.Components != null) - { + if (draftInstructionsFromProse && parseResult.Components != null) + { #pragma warning disable MA0004 // Use Task. - await GenerateDraftInstructionsFromProseAsync(parseResult.Components.ToList()); + await GenerateDraftInstructionsFromProseAsync(parseResult.Components); #pragma warning restore MA0004 // Use Task. - } + } - _mainConfig.preambleContent = parseResult.PreambleContent ?? string.Empty; - _mainConfig.epilogueContent = parseResult.EpilogueContent ?? string.Empty; - _mainConfig.widescreenWarningContent = parseResult.WidescreenWarningContent ?? string.Empty; - _mainConfig.aspyrExclusiveWarningContent = parseResult.AspyrExclusiveWarningContent ?? string.Empty; + _mainConfig.preambleContent = parseResult.PreambleContent ?? string.Empty; + _mainConfig.epilogueContent = parseResult.EpilogueContent ?? string.Empty; + _mainConfig.widescreenWarningContent = parseResult.WidescreenWarningContent ?? string.Empty; + _mainConfig.aspyrExclusiveWarningContent = parseResult.AspyrExclusiveWarningContent ?? string.Empty; #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Stored {_mainConfig.preambleContent.Length} characters in preamble and {_mainConfig.epilogueContent.Length} characters in epilogue."); + await Logger.LogAsync($"Stored {_mainConfig.preambleContent.Length} characters in preamble and {_mainConfig.epilogueContent.Length} characters in epilogue."); #pragma warning restore MA0004 // Use Task. - if (_mainConfig.allComponents.Count == 0) - { - _mainConfig.allComponents = new List( - parseResult.Components - ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") - ); + if (_mainConfig.allComponents.Count == 0) + { + _mainConfig.allComponents = new List( + parseResult.Components + ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") + ); #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Loaded {parseResult.Components.Count} components from markdown."); + await Logger.LogAsync($"Loaded {parseResult.Components.Count} components from markdown."); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await tryAutoGenerate(parseResult.Components.ToList()); + await tryAutoGenerate(parseResult.Components.ToList()); #pragma warning restore MA0004 // Use Task. - } - else - { + } + else + { #pragma warning disable MA0004 // Use Task. - bool? confirmResult = await ShowConfigLoadConfirmationAsync("markdown file", editorMode); + bool? confirmResult = await ShowConfigLoadConfirmationAsync("markdown file", editorMode); #pragma warning restore MA0004 // Use Task. - if (confirmResult == true) + if (confirmResult == true) + { + // Create the dialog on the UI thread + ComponentMergeConflictDialog conflictDialog = await Dispatcher.UIThread.InvokeAsync(() => new ComponentMergeConflictDialog( + _mainConfig.allComponents, + new List( + parseResult.Components + ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") + ), + "Currently Loaded Components", + "Markdown File", + FuzzyMatcher.FuzzyMatchComponents)); + + // Show the dialog on the UI thread as well + await Dispatcher.UIThread.InvokeAsync(() => conflictDialog.ShowDialog(_parentWindow)); + + if (conflictDialog.UserConfirmed && conflictDialog.MergedComponents != null) { - // Create the dialog on the UI thread - ComponentMergeConflictDialog conflictDialog = await Dispatcher.UIThread.InvokeAsync(() => new ComponentMergeConflictDialog( - _mainConfig.allComponents, - new List( - parseResult.Components - ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") - ), - "Currently Loaded Components", - "Markdown File", - FuzzyMatcher.FuzzyMatchComponents)); - - // Show the dialog on the UI thread as well - await Dispatcher.UIThread.InvokeAsync(() => conflictDialog.ShowDialog(_parentWindow)); - - if (conflictDialog.UserConfirmed && conflictDialog.MergedComponents != null) - { - int originalCount = _mainConfig.allComponents.Count; - _mainConfig.allComponents = conflictDialog.MergedComponents; - int newCount = _mainConfig.allComponents.Count; + int originalCount = _mainConfig.allComponents.Count; + _mainConfig.allComponents = conflictDialog.MergedComponents; + int newCount = _mainConfig.allComponents.Count; #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Merged {parseResult.Components.Count} parsed components with existing {originalCount} components. Total components now: {newCount}"); + await Logger.LogAsync($"Merged {parseResult.Components.Count} parsed components with existing {originalCount} components. Total components now: {newCount}"); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await tryAutoGenerate(_mainConfig.allComponents); + await tryAutoGenerate(_mainConfig.allComponents); #pragma warning restore MA0004 // Use Task. - } - else - { + } + else + { #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync("Merge cancelled by user."); + await Logger.LogAsync("Merge cancelled by user."); #pragma warning restore MA0004 // Use Task. - return false; - } + return false; } - else if (confirmResult == false) - { - _mainConfig.allComponents = new List( - parseResult.Components - ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") - ); + } + else if (confirmResult == false) + { + _mainConfig.allComponents = new List( + parseResult.Components + ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") + ); #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Overwrote existing config with {parseResult.Components.Count} components from markdown."); + await Logger.LogAsync($"Overwrote existing config with {parseResult.Components.Count} components from markdown."); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await tryAutoGenerate(parseResult.Components.ToList()); + await tryAutoGenerate(parseResult.Components.ToList()); #pragma warning restore MA0004 // Use Task. - } - else - { - return false; - } } + else + { + return false; + } + } #pragma warning disable MA0004 // Use Task. - await onComponentsLoaded(); + await onComponentsLoaded(); #pragma warning restore MA0004 // Use Task. - return true; - } + return true; } catch (Exception ex) - - { #pragma warning disable MA0004 // Use Task. await Logger.LogExceptionAsync(ex); @@ -534,8 +498,6 @@ public async Task SaveTomlFileAsync(string filePath, List co return true; } catch (Exception ex) - - { #pragma warning disable MA0004 // Use Task. await Logger.LogExceptionAsync(ex); @@ -546,7 +508,15 @@ public async Task SaveTomlFileAsync(string filePath, List co #region Private Helper Methods - private static async Task GenerateDraftInstructionsFromProseAsync([NotNull][ItemNotNull] List components) + private void SetLastLoadedFileName([CanBeNull] string loadedFileName) + { + if (loadedFileName != null) + { + LastLoadedFileName = loadedFileName; + } + } + + private static async Task GenerateDraftInstructionsFromProseAsync([NotNull][ItemNotNull] IEnumerable components) { #pragma warning disable MA0004 // Use Task. IReadOnlyList draftResults = await Task.Run(() => DraftInstructionService.GenerateDraftInstructions( @@ -557,8 +527,12 @@ private static async Task GenerateDraftInstructionsFromProseAsync([NotNull][Item foreach (DraftInstructionResult draftResult in draftResults) { + // ApplyReviewFlag runs inside GenerateDraftInstructions; re-apply is idempotent and keeps + // InstallationWarning aligned with CLI ReviewFlagMessage / validation-issue text. + DraftInstructionService.ApplyReviewFlag(draftResult.Component); #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($"'{draftResult.Component.Name}': {draftResult.DraftInstructionCount} draft instruction(s) parsed from guide prose - review before installing (never auto-trusted)."); + await Logger.LogWarningAsync( + $"'{draftResult.Component.Name}': {draftResult.DraftInstructionCount} draft instruction(s). {DraftInstructionService.ReviewFlagMessage}"); #pragma warning restore MA0004 // Use Task. } } diff --git a/src/ModSync.Tests/GuideIngestionTests.cs b/src/ModSync.Tests/GuideIngestionTests.cs index 58deb27d..2ab51d2e 100644 --- a/src/ModSync.Tests/GuideIngestionTests.cs +++ b/src/ModSync.Tests/GuideIngestionTests.cs @@ -35,6 +35,18 @@ public sealed class GuideIngestionTests private const string PatcherProse = "Run the installer, then move the files from the patch to your override."; + private const string DeleteBeforeMoveProse = + "Make sure to delete LSI_win01.tpc and LSI_box01.tpc **before** moving to override."; + + private const string BeforeMovingDeleteProse = + "Before moving the files to the override folder, be sure to delete the following: PFBI01 through PFBI04, and PMBI01 through PMBI04."; + + private const string RerunPatcherProse = + "Install the main mod, then re-run the patcher and select the K1CP compatibility install option and install it as well, if using K1CP."; + + private const string MoveExceptProse = + "The file has the wrong readme; move all the files in the Creatures folder, except for the readme and Gizka.jpg (any .jpg/.png files are always previews and can be deleted), to the override."; + private const string MarkdownGuide = @"### Guide Ingestion Test Mod **Name:** [Guide Ingestion Test Mod](https://example.com/guide-ingestion-test-mod.zip) @@ -96,22 +108,20 @@ private static void AssertInstructionIsSandboxed(Instruction instruction) { foreach (string source in instruction.Source) { - Assert.That( - source.StartsWith(ModDirectoryPlaceholder, StringComparison.Ordinal) - || source.StartsWith(KotorDirectoryPlaceholder, StringComparison.Ordinal), - Is.True, - $"Source '{source}' must start with {ModDirectoryPlaceholder} or {KotorDirectoryPlaceholder}"); + Assert.That(DraftInstructionService.IsSandboxedPath(source), Is.True, + $"Source '{source}' must be confined to a placeholder root without '..' / rooted escapes"); Assert.That(source, Does.Not.Contain("<>"), "Legacy placeholder must be normalized away"); + Assert.That(source.Replace('\\', '/').Split('/'), Does.Not.Contain(".."), + $"Source '{source}' must not contain '..' segments"); } if (!string.IsNullOrEmpty(instruction.Destination)) { - Assert.That( - instruction.Destination.StartsWith(ModDirectoryPlaceholder, StringComparison.Ordinal) - || instruction.Destination.StartsWith(KotorDirectoryPlaceholder, StringComparison.Ordinal), - Is.True, - $"Destination '{instruction.Destination}' must start with {ModDirectoryPlaceholder} or {KotorDirectoryPlaceholder}"); + Assert.That(DraftInstructionService.IsSandboxedPath(instruction.Destination), Is.True, + $"Destination '{instruction.Destination}' must be confined to a placeholder root without '..' / rooted escapes"); Assert.That(instruction.Destination, Does.Not.Contain("<>"), "Legacy placeholder must be normalized away"); + Assert.That(instruction.Destination.Replace('\\', '/').Split('/'), Does.Not.Contain(".."), + $"Destination '{instruction.Destination}' must not contain '..' segments"); } } @@ -157,6 +167,72 @@ public void DraftInstructions_PatcherProse_ProducesSandboxedDrafts() } } + [Test] + public void DraftInstructions_DeleteBeforeMoveProse_ProducesSandboxedDelete() + { + ModComponent component = CreateComponent(DeleteBeforeMoveProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1), "Delete-before-move prose from mod-builds should draft"); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Delete), Is.True, + "Prose that deletes files before moving should draft a Delete instruction"); + + foreach (Instruction instruction in component.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + + [Test] + public void DraftInstructions_BeforeMovingDeleteProse_ProducesSandboxedDelete() + { + ModComponent component = CreateComponent(BeforeMovingDeleteProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Delete), Is.True); + + foreach (Instruction instruction in component.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + + [Test] + public void DraftInstructions_RerunPatcherProse_ProducesSandboxedPatcher() + { + ModComponent component = CreateComponent(RerunPatcherProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Patcher), Is.True, + "Re-run patcher / compatibility option prose should draft a Patcher instruction"); + + foreach (Instruction instruction in component.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + + [Test] + public void DraftInstructions_MoveExceptProse_ProducesSandboxedMove() + { + ModComponent component = CreateComponent(MoveExceptProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Move), Is.True); + + foreach (Instruction instruction in component.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + [Test] public void DraftInstructions_ComponentWithExistingInstructions_IsNeverTouched() { @@ -246,6 +322,103 @@ public void TrySanitizeInstruction_RejectsNonSandboxedPaths() }); } + [Test] + public void TrySanitizeInstruction_RejectsParentDirectoryTraversalAfterPlaceholder() + { + var traversalSource = new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { "<>/../outside" }, + Destination = "<>/Override", + }; + + var traversalDestination = new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { @"<>\textures\*" }, + Destination = @"<>\..\Windows", + }; + + var gluedTraversal = new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { "<>../outside" }, + Destination = "<>/Override", + }; + + var driveAfterPlaceholder = new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { @"<>\C:\Windows\evil.dll" }, + Destination = @"<>\Override", + }; + + Assert.Multiple(() => + { + Assert.That(DraftInstructionService.IsSandboxedPath("<>/../outside"), Is.False); + Assert.That(DraftInstructionService.IsSandboxedPath(@"<>\..\Windows"), Is.False); + Assert.That(DraftInstructionService.IsSandboxedPath("<>../outside"), Is.False); + Assert.That(DraftInstructionService.IsSandboxedPath(@"<>\C:\Windows\evil.dll"), Is.False); + Assert.That(DraftInstructionService.IsSandboxedPath("<>/textures/*"), Is.True); + Assert.That(DraftInstructionService.TrySanitizeInstruction(traversalSource), Is.False, + "Source with '..' after placeholder must be dropped"); + Assert.That(DraftInstructionService.TrySanitizeInstruction(traversalDestination), Is.False, + "Destination with '..' after placeholder must be dropped"); + Assert.That(DraftInstructionService.TrySanitizeInstruction(gluedTraversal), Is.False, + "Missing separator before '..' must be dropped"); + Assert.That(DraftInstructionService.TrySanitizeInstruction(driveAfterPlaceholder), Is.False, + "Drive letter segment after placeholder must be dropped"); + }); + } + + [Test] + public void DraftInstructions_TraversalProse_DoesNotKeepEscapingPaths() + { + ModComponent escapeComponent = CreateComponent( + "Move everything from <>/../outside to your Override."); + ModComponent bareDotDot = CreateComponent( + "Move .. to override."); + + IReadOnlyList escapeResults = + DraftInstructionService.GenerateDraftInstructions(new[] { escapeComponent }); + IReadOnlyList bareResults = + DraftInstructionService.GenerateDraftInstructions(new[] { bareDotDot }); + + Assert.Multiple(() => + { + foreach (Instruction instruction in escapeComponent.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + + foreach (Instruction instruction in bareDotDot.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + + if (escapeResults.Count > 0) + { + Assert.That(escapeComponent.InstallationWarning, Does.Contain(DraftInstructionService.ReviewFlagMessage)); + } + + if (bareResults.Count > 0) + { + Assert.That(bareDotDot.InstallationWarning, Does.Contain(DraftInstructionService.ReviewFlagMessage)); + } + }); + } + + [Test] + public void DraftInstructions_SuccessfulDraft_AppliesReviewFlagMessage() + { + ModComponent component = CreateComponent(MoveFoldersProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.InstallationWarning, Is.EqualTo(DraftInstructionService.ReviewFlagMessage)); + } + #endregion #region Paste cascade format sniffing @@ -294,22 +467,36 @@ public void PasteCascade_MarkdownGuideString_DeserializesComponentWithDirections #region Real guide (mod-builds) - [Test] - public void RealGuide_K1FullMarkdown_DraftedInstructionsAreAllSandboxed() + [TestCase("k1", "full.md")] + [TestCase("k2", "full.md")] + [TestCase("k1", "spoiler-free.md")] + [TestCase("k2", "spoiler-free.md")] + [TestCase("k1", "full_mobile.md")] + public void RealGuide_ModBuildsMarkdown_DraftedInstructionsAreAllSandboxed(string gameFolder, string guideFile) { string repoRoot = ResolveRepoRoot(); - string markdownPath = Path.Combine(repoRoot, "mod-builds", "content", "k1", "full.md"); + string markdownPath = Path.Combine(repoRoot, "mod-builds", "content", gameFolder, guideFile); if (!File.Exists(markdownPath)) { Assert.Ignore($"mod-builds guide not found: {markdownPath}"); } - List components = FileLoadingService.LoadFromFile(markdownPath).ToList(); - Assert.That(components, Is.Not.Empty); + List components; + try + { + components = FileLoadingService.LoadFromFile(markdownPath).ToList(); + } + catch (InvalidDataException ex) + { + Assert.Ignore($"Guide is not a component markdown list ({gameFolder}/{guideFile}): {ex.Message}"); + return; + } + + Assert.That(components, Is.Not.Empty, $"Expected components from {gameFolder}/{guideFile}"); IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(components); - Assert.That(results, Is.Not.Empty, "Real guide prose should draft instructions for at least one component"); + Assert.That(results, Is.Not.Empty, $"Real guide prose ({gameFolder}/{guideFile}) should draft instructions for at least one component"); foreach (DraftInstructionResult result in results) { @@ -323,6 +510,28 @@ public void RealGuide_K1FullMarkdown_DraftedInstructionsAreAllSandboxed() #endregion + #region Guide emission (GenerateModDocumentation) + + [Test] + public void GenerateModDocumentation_AfterDraftingGuide_RoundTripsComponentNameAndDirections() + { + IReadOnlyList components = ModComponentSerializationService.DeserializeModComponentFromString(MarkdownGuide); + Assert.That(components, Has.Count.EqualTo(1)); + + DraftInstructionService.GenerateDraftInstructions(components); + + string emitted = ModComponentSerializationService.GenerateModDocumentation(components.ToList()); + Assert.That(emitted, Does.Contain("Guide Ingestion Test Mod")); + Assert.That(emitted, Does.Contain("Move everything from the Straight Fixes")); + + IReadOnlyList reparsed = ModComponentSerializationService.DeserializeModComponentFromString(emitted); + Assert.That(reparsed, Has.Count.EqualTo(1)); + Assert.That(reparsed[0].Name, Is.EqualTo("Guide Ingestion Test Mod")); + Assert.That(reparsed[0].Directions, Does.Contain("Move everything from the Straight Fixes")); + } + + #endregion + #region CLI parity (convert --stdin --parse-directions) [Test] @@ -374,6 +583,38 @@ public void CliConvert_StdinWithParseDirections_EmitsReviewFlaggedTomlWithDraftI } } + [Test] + public void CliConvert_FileInputWithParseDirections_EmitsReviewFlaggedToml() + { + string inputMd = Path.Combine(_testDirectory, "guide.md"); + string outputToml = Path.Combine(_testDirectory, "from-file.toml"); + File.WriteAllText(inputMd, MarkdownGuide); + + int exitCode = ModBuildConverter.Run(new[] + { + "convert", + "--input", inputMd, + "--parse-directions", + "-f", "toml", + "-o", outputToml, + "--plaintext", + }); + + Assert.That(exitCode, Is.EqualTo(0), "convert -i guide.md --parse-directions should succeed"); + Assert.That(File.Exists(outputToml), Is.True); + string tomlOutput = File.ReadAllText(outputToml); + Assert.That(tomlOutput, Does.Contain(DraftInstructionService.ReviewFlagMessage)); + + var reloaded = ModComponentSerializationService + .DeserializeModComponentFromString(tomlOutput, "toml") + .ToList(); + Assert.That(reloaded[0].Instructions, Is.Not.Empty); + foreach (Instruction instruction in reloaded[0].Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + [Test] public void CliConvert_StdinCombinedWithInput_Fails() { @@ -417,6 +658,18 @@ private static string ResolveRepoRoot() } } + // Walk up from the test directory (covers git worktrees and nested bin layouts). + DirectoryInfo dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "ModSync.sln"))) + { + return dir.FullName; + } + + dir = dir.Parent; + } + throw new DirectoryNotFoundException("Could not locate repository root containing ModSync.sln"); } } diff --git a/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs b/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs index 5e67c52c..46ee3997 100644 --- a/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs +++ b/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs @@ -3,6 +3,7 @@ // See LICENSE.txt file in the project root for full license information. using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; @@ -11,19 +12,40 @@ using Avalonia.Threading; using Avalonia.VisualTree; using ModSync.Controls; +using ModSync.Core; using ModSync.Dialogs.WizardPages; +using ModSync.Services; using Xunit; namespace ModSync.Tests.HeadlessUITests { /// - /// Headless Avalonia smoke coverage for paste-import entry points and - /// wizard page-0 layout constraints. Prefer these over a real desktop - /// session for agent GUI UX smoke. + /// Headless Avalonia smoke coverage for paste-import entry points, + /// wizard page-order key controls, and compact-host layout constraints. + /// Prefer these over a real desktop session for agent GUI UX smoke. /// [Collection(HeadlessTestApp.CollectionName)] public sealed class GuiSmokeHeadlessTests { + private const string SampleClipboardMarkdown = @"### Example Clipboard Mod + +**Name:** [Example Clipboard Mod](https://deadlystream.com/files/file/9999-example-clipboard-mod/) + +**Author:** Headless Smoke Author + +**Description:** Minimal markdown guide used by GuiSmokeHeadlessTests to exercise LoadInstructionTextAsync without a real OS clipboard. + +**Category & Tier:** Immersion / 1 - Essential + +**Non-English Functionality:** YES + +**Installation Method:** Loose-File Mod + +**Installation Instructions:** Copy the files into Override. + +___ +"; + [AvaloniaFact(DisplayName = "GettingStarted Import from Clipboard button exists and raises event")] public async Task ImportFromClipboardButton_Exists_And_IsInvokable() { @@ -53,11 +75,146 @@ await Dispatcher.UIThread.InvokeAsync( } } + [AvaloniaFact(DisplayName = "LoadInstructionTextAsync imports sample markdown without real clipboard")] + public async Task LoadInstructionTextAsync_SampleMarkdown_ImportsComponents() + { + List previousComponents = MainConfig.AllComponents; + MainConfig.AllComponents = new List(); + + Window window = await Dispatcher.UIThread.InvokeAsync( + () => + { + var host = new Window { Width = 640, Height = 480 }; + host.Show(); + return host; + }, + DispatcherPriority.Background); + await PumpEventsAsync(); + + try + { + var config = new MainConfig(); + var service = new FileLoadingService(config, window); + int componentsLoadedCallbacks = 0; + int autoGenerateCallbacks = 0; + + bool loaded = await service.LoadInstructionTextAsync( + SampleClipboardMarkdown, + editorMode: false, + onComponentsLoaded: () => + { + componentsLoadedCallbacks++; + return Task.CompletedTask; + }, + tryAutoGenerate: _ => + { + autoGenerateCallbacks++; + return Task.CompletedTask; + }, + sourceDescription: "headless sample markdown"); + + Assert.True(loaded); + Assert.NotEmpty(MainConfig.AllComponents); + Assert.Contains( + MainConfig.AllComponents, + c => (c.Name ?? string.Empty).Contains("Clipboard", StringComparison.OrdinalIgnoreCase)); + Assert.True(autoGenerateCallbacks >= 1 || componentsLoadedCallbacks >= 1); + } + finally + { + MainConfig.AllComponents = previousComponents; + await CloseWindowAsync(window); + } + } + + [AvaloniaFact(DisplayName = "Wizard pages Welcome through ValidatePage expose key controls")] + public async Task WizardPageOrder_WelcomeThroughValidate_KeyControlsPresent() + { + List previousComponents = MainConfig.AllComponents; + var components = new List + { + new ModComponent + { + Guid = Guid.NewGuid(), + Name = "Smoke Mod", + IsSelected = true, + Category = new List { "Test" }, + Tier = "1 - Essential", + }, + }; + MainConfig.AllComponents = components; + var config = new MainConfig(); + + try + { + Control[] pages = + { + new WelcomePage(), + new ModDirectoryPage(config), + new GameDirectoryPage(config), + new ModSelectionPage(components), + new DownloadsExplainPage(components), + new ValidatePage(components, config), + }; + + string[][] requiredNames = + { + Array.Empty(), + new[] { "SourcePathPicker" }, + new[] { "DestinationPathPicker" }, + new[] { "SelectAllButton", "DeselectAllButton", "SearchTextBox" }, + new[] { "SelectionSummaryText" }, + new[] { "ValidateButton", "LogExpander", "SummaryText", "ErrorCountBadge" }, + }; + + for (int i = 0; i < pages.Length; i++) + { + Control page = pages[i]; + Window window = await HostInWindowAsync(page, width: 1100, height: 800); + try + { + await PumpEventsAsync(); + + if (page is WelcomePage) + { + TextBlock title = page.GetVisualDescendants() + .OfType() + .FirstOrDefault(tb => string.Equals(tb.Text, "Welcome to ModSync", StringComparison.Ordinal)); + Assert.NotNull(title); + Assert.True(title.IsVisible); + } + + if (page is DownloadsExplainPage downloads) + { + await downloads.OnNavigatedToAsync(default); + await PumpEventsAsync(); + TextBlock summary = downloads.FindControl("SelectionSummaryText"); + Assert.NotNull(summary); + Assert.False(string.IsNullOrWhiteSpace(summary.Text)); + } + + foreach (string name in requiredNames[i]) + { + Control control = page.FindControl(name); + Assert.True(control != null, $"Expected control '{name}' on {page.GetType().Name}"); + } + } + finally + { + await CloseWindowAsync(window); + } + } + } + finally + { + MainConfig.AllComponents = previousComponents; + } + } + [AvaloniaFact(DisplayName = "WelcomePage fits compact host via ScrollViewer without clipping primary title")] public async Task WelcomePage_CompactHost_KeepsTitleReachable() { var page = new WelcomePage(); - // Compact wizard content host size (below design 1280x720). Window window = await HostInWindowAsync(page, width: 960, height: 540); try @@ -71,10 +228,11 @@ public async Task WelcomePage_CompactHost_KeepsTitleReachable() .OfType() .FirstOrDefault(tb => string.Equals(tb.Text, "Welcome to ModSync", StringComparison.Ordinal)); Assert.NotNull(title); - - // Layout should resolve without throwing; title remains in the visual tree. Assert.True(title.IsVisible); Assert.True(page.Bounds.Height > 0 || window.Bounds.Height > 0); + + WrapPanel badgeRow = page.GetVisualDescendants().OfType().FirstOrDefault(); + Assert.NotNull(badgeRow); } finally { @@ -82,6 +240,41 @@ public async Task WelcomePage_CompactHost_KeepsTitleReachable() } } + [AvaloniaFact(DisplayName = "Early wizard pages stay scrollable in a compact host")] + public async Task EarlyWizardPages_CompactHost_UseScrollViewer() + { + var config = new MainConfig(); + Control[] pages = + { + new WelcomePage(), + new ModDirectoryPage(config), + new GameDirectoryPage(config), + new DownloadsExplainPage(new List()), + }; + + foreach (Control page in pages) + { + Window window = await HostInWindowAsync(page, width: 720, height: 420); + try + { + await PumpEventsAsync(); + + ScrollViewer scrollViewer = page.GetVisualDescendants().OfType().FirstOrDefault(); + Assert.True( + scrollViewer != null, + $"{page.GetType().Name} should host a ScrollViewer for compact-window overflow"); + Assert.Equal( + Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + scrollViewer.VerticalScrollBarVisibility); + Assert.True(page.Bounds.Width <= window.Bounds.Width + 1 || window.Bounds.Width > 0); + } + finally + { + await CloseWindowAsync(window); + } + } + } + [AvaloniaFact(DisplayName = "LandingPageView page-0 content is scrollable in a compact host")] public async Task LandingPageView_CompactHost_UsesScrollViewer() { @@ -124,7 +317,6 @@ public async Task ValidatePage_LogSplitter_IsPresent() Assert.NotNull(splitter); Assert.NotNull(logScroll); Assert.Equal(GridResizeDirection.Rows, splitter.ResizeDirection); - // Fixed MaxHeight was the old non-resizable constraint; star+splitter replaces it. Assert.True(double.IsNaN(logScroll.MaxHeight) || logScroll.MaxHeight > 500); } finally @@ -133,6 +325,136 @@ public async Task ValidatePage_LogSplitter_IsPresent() } } + + [AvaloniaFact(DisplayName = "InstallStartPage mod list scrolls without fixed MaxHeight")] + public async Task InstallStartPage_ModList_IsScrollableWithoutMaxHeight() + { + var page = new InstallStartPage(); + Window window = await HostInWindowAsync(page, width: 960, height: 540); + try + { + await PumpEventsAsync(); + ScrollViewer modListScroll = page.FindControl("ModListScrollViewer"); + Assert.NotNull(modListScroll); + Assert.Equal(Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, modListScroll.VerticalScrollBarVisibility); + Assert.True(IsUnconstrainedMaxHeight(modListScroll.MaxHeight)); + AssertNoFixedMaxHeightAncestor(modListScroll); + } + finally + { + await CloseWindowAsync(window); + } + } + + [AvaloniaFact(DisplayName = "WidescreenModSelectionPage mod list uses ScrollViewer")] + public async Task WidescreenModSelectionPage_ModList_IsScrollable() + { + var page = new WidescreenModSelectionPage(); + Window window = await HostInWindowAsync(page, width: 900, height: 500); + try + { + await PumpEventsAsync(); + ScrollViewer modListScroll = page.FindControl("ModListScrollViewer"); + Assert.NotNull(modListScroll); + } + finally + { + await CloseWindowAsync(window); + } + } + + [AvaloniaFact(DisplayName = "Preamble and notice pages scroll without MaxHeight caps")] + public async Task PreambleAndNoticePages_ScrollWithoutMaxHeight() + { + var preamble = new PreamblePage("## Compact host preamble"); + Window preambleWindow = await HostInWindowAsync(preamble, width: 900, height: 480); + try + { + await PumpEventsAsync(); + ScrollViewer contentScroll = preamble.FindControl("ContentScrollViewer"); + Assert.NotNull(contentScroll); + AssertNoFixedMaxHeightAncestor(contentScroll); + } + finally + { + await CloseWindowAsync(preambleWindow); + } + + foreach (Control page in new Control[] { new AspyrNoticePage("Aspyr"), new WidescreenNoticePage("WS") }) + { + Window window = await HostInWindowAsync(page, width: 900, height: 480); + try + { + await PumpEventsAsync(); + ScrollViewer scroll = page.FindControl("ContentScrollViewer"); + Assert.NotNull(scroll); + AssertNoFixedMaxHeightAncestor(scroll); + } + finally + { + await CloseWindowAsync(window); + } + } + } + + [AvaloniaFact(DisplayName = "Later wizard pages remain scrollable in compact hosts")] + public async Task LaterWizardPages_CompactHost_UseScrollViewer() + { + Control[] pages = + { + new FinishedPage(), + new InstallingPage(), + new BaseInstallCompletePage(), + new WidescreenCompletePage(), + new WidescreenInstallingPage(), + new DownloadsExplainPage(), + }; + + foreach (Control page in pages) + { + Window window = await HostInWindowAsync(page, width: 900, height: 480); + try + { + await PumpEventsAsync(); + ScrollViewer scrollViewer = page.GetVisualDescendants().OfType().FirstOrDefault(); + Assert.True(scrollViewer != null, page.GetType().Name + " should host a ScrollViewer"); + } + finally + { + await CloseWindowAsync(window); + } + } + + var modSelection = new ModSelectionPage(); + Window modWindow = await HostInWindowAsync(modSelection, width: 900, height: 500); + try + { + await PumpEventsAsync(); + Assert.NotNull(modSelection.FindControl("ModListScrollViewer")); + } + finally + { + await CloseWindowAsync(modWindow); + } + } + + private static bool IsUnconstrainedMaxHeight(double maxHeight) => + double.IsNaN(maxHeight) || double.IsInfinity(maxHeight) || maxHeight <= 0 || maxHeight >= 10_000; + + private static void AssertNoFixedMaxHeightAncestor(Control control) + { + Control current = control; + while (current != null) + { + if (!IsUnconstrainedMaxHeight(current.MaxHeight)) + { + Assert.Fail($"Found fixed MaxHeight={current.MaxHeight} on {current.GetType().Name}; use star rows instead."); + } + + current = current.Parent as Control; + } + } + private static async Task HostInWindowAsync(Control control, double width, double height) { Window window = await Dispatcher.UIThread.InvokeAsync(