From f65e42649dc8a5cbdd713d7350f4f0bcb7e6314e Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 13 Jun 2026 02:54:26 -0500 Subject: [PATCH 1/7] feat: prompt FOMOD configuration after archive downloads Detect fomod/ModuleConfig.xml in downloaded archives, offer an optional post-download wizard, remember dismiss/configure state, and flag FOMOD archives during file-tree enumeration. --- ...14-fomod-archive-discovery-requirements.md | 79 ++++++++ docs/knowledgebase/fomod-support.md | 11 +- .../vortex-mo2-feature-parity-living-plan.md | 14 +- .../Services/Fomod/FomodArchiveProbe.cs | 38 ++++ .../Fomod/FomodConfiguredComponentMerger.cs | 51 ++++++ .../Fomod/FomodDownloadPromptState.cs | 165 +++++++++++++++++ src/ModSync.GUI/Models/FileTreeNode.cs | 5 + .../Services/ArchiveEnumerationService.cs | 2 + .../Services/DownloadOrchestrationService.cs | 13 +- .../FomodPostDownloadPromptService.cs | 169 ++++++++++++++++++ src/ModSync.Tests/FomodArchiveProbeTests.cs | 113 ++++++++++++ 11 files changed, 649 insertions(+), 11 deletions(-) create mode 100644 docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md create mode 100644 src/ModSync.Core/Services/Fomod/FomodArchiveProbe.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs create mode 100644 src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs create mode 100644 src/ModSync.Tests/FomodArchiveProbeTests.cs diff --git a/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md b/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md new file mode 100644 index 00000000..9ca13983 --- /dev/null +++ b/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md @@ -0,0 +1,79 @@ +--- +title: "FOMOD archive discovery after download" +status: reviewed +date: 2026-06-14 +origin: docs/plans/vortex-mo2-feature-parity-living-plan.md +--- + +# FOMOD archive discovery after download + +## Summary + +After GUI downloads finish, detect FOMOD installers inside downloaded archives and +optionally prompt the user to run the existing FOMOD configuration wizard. Skipping +is allowed and remembered per archive file. + +## Problem Frame + +FOMOD parser and installer dialog exist, but discovery still requires the user to +manually pick an extracted folder via **Configure FOMOD Mod**. Downloads already land +archives in the mod workspace without surfacing FOMOD packages automatically. + +## Requirements + +**Download detection and prompt** + +- R1: After a GUI download session completes, scan selected components' downloaded + archive files in the mod directory using archive entry listing (no full extract for + detection). +- R2: When `fomod/ModuleConfig.xml` is found, show an optional post-download prompt to + configure installer options now. +- R3: Choosing **No** dismisses the prompt for that archive on that component; the user + can still use **Configure FOMOD Mod** manually later. +- R4: Choosing **Yes** extracts the archive to the mod workspace, runs the existing + FOMOD wizard, and merges generated options/instructions into the existing component. + +**Archive enumeration** + +- R5: When building the mod file tree, mark archive nodes that contain a FOMOD + installer so the UI can distinguish them later. + +**Persistence** + +- R6: Dismissed and configured outcomes are stored per archive file name on the + component's resource metadata so prompts do not repeat unnecessarily. + +## Success Criteria + +- A downloaded archive containing `fomod/ModuleConfig.xml` triggers the post-download + prompt once per archive until dismissed or configured. +- Accepting the prompt runs the existing FOMOD wizard without requiring a manual folder + picker first. +- Archive enumeration marks FOMOD archives without requiring extraction. + +## Scope Boundaries + +**In scope** + +- GUI download orchestration path used by **Fetch Downloads** (wizard and Getting + Started). +- Reuse of `FomodDetector`, `FomodInstallerDialog`, and existing mapper/presenter stack. + +**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 + +- Detection uses archive entry listing; extraction happens only when the user accepts + the prompt. +- Prompt state is tracked per archive file name in resource handler metadata. +- Wizard output merges into the existing instruction-file component rather than + creating a separate standalone component. + +## Outstanding Questions + +- Whether all GUI download entry points beyond **Fetch Downloads** should share the + same prompt hook in a follow-up slice. diff --git a/docs/knowledgebase/fomod-support.md b/docs/knowledgebase/fomod-support.md index e5e6ebb8..9e57681a 100644 --- a/docs/knowledgebase/fomod-support.md +++ b/docs/knowledgebase/fomod-support.md @@ -49,8 +49,15 @@ GUI (`src/ModSync.GUI/`): ## Deferred `[OPEN]` -- Detection hook in `DownloadCacheService`/`ArchiveEnumerationService` that offers - the guided flow when `FomodDetector` matches an archive. +- CLI download/install parity for FOMOD post-download prompts. +- Plugin images from `image path`. + +## Post-download hook `[REPO]` + +- `FomodArchiveProbe` detects `fomod/ModuleConfig.xml` inside downloaded archives via entry listing. +- `FomodPostDownloadPromptService` runs after GUI **Fetch Downloads** completes; optional prompt per archive. +- `FomodDownloadPromptState` stores dismissed/configured outcomes in resource handler metadata. +- `ArchiveEnumerationService` sets `FileTreeNode.IsFomodInstaller` when an archive contains FOMOD metadata. ## Verification diff --git a/docs/plans/vortex-mo2-feature-parity-living-plan.md b/docs/plans/vortex-mo2-feature-parity-living-plan.md index 2b416584..cca35045 100644 --- a/docs/plans/vortex-mo2-feature-parity-living-plan.md +++ b/docs/plans/vortex-mo2-feature-parity-living-plan.md @@ -20,7 +20,7 @@ Single authoritative tracker for parity work. Individual slice plans under | 3 | Profiles | Merged (#157) | | 4 | Managed deployment | **Merged** (#158 core); install wiring deferred | | 5 | File conflicts | Core #160 + GUI #165 merged | -| 6 | FOMOD | Parser + installer dialog merged (#166); archive hook deferred | +| 6 | FOMOD | Parser + installer dialog merged (#166); archive hook in PR | | 7 | (roadmap tail) | Per slice plans | ## Delta update (2026-06-14) @@ -35,16 +35,16 @@ Single authoritative tracker for parity work. Individual slice plans under ### Partial -- Deployment: `DeploymentService` not wired into install execution; no GUI toggle. -- FOMOD: no automatic archive enumeration hook in download flow. +- Deployment: `DeploymentService` not wired into install execution; no GUI toggle (see PR #168). +- FOMOD post-download archive discovery hook landing in current PR. - Update checking: no endorsement UI; check results not persisted via `DownloadCacheService`. -- Desktop validation skipped for update badges (headless agent). +- Desktop validation skipped for FOMOD prompts and update badges (headless agent). ### Next -1. Wire `DeploymentService` into install execution + optional GUI toggle (plan 116 slice 2). -2. FOMOD archive discovery hook in download/archive enumeration. -3. Migrate `NexusModsDownloadHandler` to `NexusApiClient` when download handler branch is stable. +1. Merge managed deployment install wiring (#168) when ready. +2. Migrate `NexusModsDownloadHandler` to `NexusApiClient` when download handler branch is stable. +3. Managed deployment P2: dry-run/VFS staging parity or document install-only validation. ## Superseded diff --git a/src/ModSync.Core/Services/Fomod/FomodArchiveProbe.cs b/src/ModSync.Core/Services/Fomod/FomodArchiveProbe.cs new file mode 100644 index 00000000..87946d8c --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodArchiveProbe.cs @@ -0,0 +1,38 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System.Collections.Generic; + +using JetBrains.Annotations; + +using ModSync.Core.Utility; + +namespace ModSync.Core.Services.Fomod +{ + /// + /// Detects FOMOD installers inside downloaded archive files without extracting them. + /// + public static class FomodArchiveProbe + { + public static bool TryDetectInArchive( + [NotNull] string archivePath, + out string moduleConfigEntryPath) + { + moduleConfigEntryPath = null; + + if (string.IsNullOrWhiteSpace(archivePath)) + { + return false; + } + + if (!ArchiveHelper.TryGetArchiveEntries(archivePath, out HashSet entries, out _)) + { + return false; + } + + moduleConfigEntryPath = FomodDetector.FindModuleConfigPath(entries); + return moduleConfigEntryPath != null; + } + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs b/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs new file mode 100644 index 00000000..81b6abb1 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs @@ -0,0 +1,51 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +using JetBrains.Annotations; + +namespace ModSync.Core.Services.Fomod +{ + /// + /// Merges FOMOD wizard output into an existing mod component from the instruction file. + /// + public static class FomodConfiguredComponentMerger + { + public static void MergeInto( + [NotNull] ModComponent target, + [NotNull] ModComponent configured) + { + if (target is null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (configured is null) + { + throw new ArgumentNullException(nameof(configured)); + } + + var existingOptionGuids = new HashSet(target.Options.Select(option => option.Guid)); + foreach (Option option in configured.Options) + { + if (existingOptionGuids.Contains(option.Guid)) + { + continue; + } + + target.Options.Add(option); + existingOptionGuids.Add(option.Guid); + } + + foreach (Instruction instruction in configured.Instructions) + { + instruction.SetParentComponent(target); + target.Instructions.Add(instruction); + } + } + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs b/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs new file mode 100644 index 00000000..bb180f8b --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs @@ -0,0 +1,165 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +using JetBrains.Annotations; + +namespace ModSync.Core.Services.Fomod +{ + /// + /// Tracks per-archive FOMOD prompt outcomes on . + /// + public static class FomodDownloadPromptState + { + public const string HandlerMetadataKey = "fomodPromptStatus"; + + public const string StatusDismissed = "dismissed"; + + public const string StatusConfigured = "configured"; + + public static bool ShouldPrompt([NotNull] ModComponent component, [NotNull] string archiveFileName) + { + if (component is null) + { + throw new ArgumentNullException(nameof(component)); + } + + if (string.IsNullOrWhiteSpace(archiveFileName)) + { + return false; + } + + string status = GetStatus(component, archiveFileName); + return string.IsNullOrEmpty(status); + } + + [CanBeNull] + public static string GetStatus([NotNull] ModComponent component, [NotNull] string archiveFileName) + { + if (TryGetStatusDictionary(component, out Dictionary statuses) + && statuses.TryGetValue(NormalizeFileName(archiveFileName), out string status)) + { + return status; + } + + return null; + } + + public static void MarkDismissed([NotNull] ModComponent component, [NotNull] string archiveFileName) + { + SetStatus(component, archiveFileName, StatusDismissed); + } + + public static void MarkConfigured([NotNull] ModComponent component, [NotNull] string archiveFileName) + { + SetStatus(component, archiveFileName, StatusConfigured); + } + + private static void SetStatus( + [NotNull] ModComponent component, + [NotNull] string archiveFileName, + [NotNull] string status) + { + if (component is null) + { + throw new ArgumentNullException(nameof(component)); + } + + if (string.IsNullOrWhiteSpace(archiveFileName)) + { + throw new ArgumentException("Archive file name cannot be null or whitespace.", nameof(archiveFileName)); + } + + if (string.IsNullOrWhiteSpace(status)) + { + throw new ArgumentException("Status cannot be null or whitespace.", nameof(status)); + } + + foreach (ResourceMetadata resource in component.ResourceRegistry.Values) + { + if (resource?.Files is null + || !resource.Files.ContainsKey(archiveFileName)) + { + continue; + } + + if (resource.HandlerMetadata is null) + { + resource.HandlerMetadata = new Dictionary(StringComparer.Ordinal); + } + + if (!TryGetStatusDictionaryFromMetadata(resource.HandlerMetadata, out Dictionary statuses)) + { + statuses = new Dictionary(StringComparer.OrdinalIgnoreCase); + resource.HandlerMetadata[HandlerMetadataKey] = statuses; + } + + statuses[NormalizeFileName(archiveFileName)] = status; + return; + } + } + + private static bool TryGetStatusDictionary( + [NotNull] ModComponent component, + out Dictionary statuses) + { + statuses = null; + if (component.ResourceRegistry is null) + { + return false; + } + + foreach (ResourceMetadata resource in component.ResourceRegistry.Values) + { + if (resource?.HandlerMetadata is null) + { + continue; + } + + if (TryGetStatusDictionaryFromMetadata(resource.HandlerMetadata, out statuses)) + { + return true; + } + } + + return false; + } + + private static bool TryGetStatusDictionaryFromMetadata( + [NotNull] IDictionary handlerMetadata, + out Dictionary statuses) + { + statuses = null; + if (!handlerMetadata.TryGetValue(HandlerMetadataKey, out object raw) + || raw is null) + { + return false; + } + + if (raw is Dictionary stringDict) + { + statuses = stringDict; + return true; + } + + if (raw is IDictionary objectDict) + { + statuses = objectDict.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.ToString() ?? string.Empty, + StringComparer.OrdinalIgnoreCase); + return true; + } + + return false; + } + + [NotNull] + private static string NormalizeFileName([NotNull] string archiveFileName) => + archiveFileName.Trim(); + } +} diff --git a/src/ModSync.GUI/Models/FileTreeNode.cs b/src/ModSync.GUI/Models/FileTreeNode.cs index d3f874da..9d54771f 100644 --- a/src/ModSync.GUI/Models/FileTreeNode.cs +++ b/src/ModSync.GUI/Models/FileTreeNode.cs @@ -45,6 +45,11 @@ public FileTreeNode() /// public bool IsArchive { get; set; } + /// + /// True when an archive listing contains a FOMOD installer package. + /// + public bool IsFomodInstaller { get; set; } + /// /// The archive file path if this node or its parent is from an archive. /// diff --git a/src/ModSync.GUI/Services/ArchiveEnumerationService.cs b/src/ModSync.GUI/Services/ArchiveEnumerationService.cs index 4338e58a..8bbf02ed 100644 --- a/src/ModSync.GUI/Services/ArchiveEnumerationService.cs +++ b/src/ModSync.GUI/Services/ArchiveEnumerationService.cs @@ -12,6 +12,7 @@ using JetBrains.Annotations; using ModSync.Core; +using ModSync.Core.Services.Fomod; using ModSync.Core.Utility; using ModSync.Models; @@ -92,6 +93,7 @@ private async Task AddArchiveNodeAsync(ObservableCollection rootNo IsArchive = true, IsDirectory = false, IsExpanded = false, + IsFomodInstaller = FomodArchiveProbe.TryDetectInArchive(filePath, out _), }; try diff --git a/src/ModSync.GUI/Services/DownloadOrchestrationService.cs b/src/ModSync.GUI/Services/DownloadOrchestrationService.cs index 79ea8254..e1e80c5a 100644 --- a/src/ModSync.GUI/Services/DownloadOrchestrationService.cs +++ b/src/ModSync.GUI/Services/DownloadOrchestrationService.cs @@ -329,8 +329,17 @@ await Task.WhenAll(selectedComponents.Select(async component => IsDownloadInProgress = false; await Dispatcher.UIThread.InvokeAsync(() => DownloadStateChanged?.Invoke(this, EventArgs.Empty)); - await Logger.LogVerboseAsync("[DownloadOrchestration] Running post-download validation"); - await Dispatcher.UIThread.InvokeAsync(() => onScanComplete?.Invoke()); + await Logger.LogVerboseAsync("[DownloadOrchestration] Running post-download FOMOD prompts"); + await Dispatcher.UIThread.InvokeAsync(async () => + { + await FomodPostDownloadPromptService.PromptForDetectedArchivesAsync( + _parentWindow, + selectedComponents, + _mainConfig.sourcePath.FullName).ConfigureAwait(true); + + await Logger.LogVerboseAsync("[DownloadOrchestration] Running post-download validation"); + onScanComplete?.Invoke(); + }); } } catch (Exception ex) diff --git a/src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs b/src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs new file mode 100644 index 00000000..da548124 --- /dev/null +++ b/src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs @@ -0,0 +1,169 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; + +using JetBrains.Annotations; + +using ModSync.Core; +using ModSync.Core.Services.FileSystem; +using ModSync.Core.Services.Fomod; +using ModSync.Core.Utility; +using ModSync.Dialogs; + +namespace ModSync.Services +{ + public static class FomodPostDownloadPromptService + { + public static async Task PromptForDetectedArchivesAsync( + [NotNull] Window parentWindow, + [NotNull][ItemNotNull] IReadOnlyList components, + [NotNull] string modDirectory) + { + if (parentWindow is null) + { + throw new ArgumentNullException(nameof(parentWindow)); + } + + if (components is null) + { + throw new ArgumentNullException(nameof(components)); + } + + if (string.IsNullOrWhiteSpace(modDirectory)) + { + throw new ArgumentException("Mod directory cannot be null or whitespace.", nameof(modDirectory)); + } + + foreach (ModComponent component in components.Where(c => c.IsSelected)) + { + foreach (string archivePath in GetDownloadedArchivePaths(component, modDirectory)) + { + string archiveFileName = Path.GetFileName(archivePath); + if (!FomodArchiveProbe.TryDetectInArchive(archivePath, out _)) + { + continue; + } + + if (!FomodDownloadPromptState.ShouldPrompt(component, archiveFileName)) + { + continue; + } + + bool? configure = await ConfirmationDialog.ShowConfirmationDialogAsync( + parentWindow, + $"A FOMOD installer was detected in '{archiveFileName}' for mod '{component.Name}'." + + Environment.NewLine + + Environment.NewLine + + "Configure installer options now?" + + Environment.NewLine + + Environment.NewLine + + "Choose No to skip for now. You can still use Mod Management → Configure FOMOD Mod later."); + + if (configure != true) + { + FomodDownloadPromptState.MarkDismissed(component, archiveFileName); + continue; + } + + string extractedDirectory = await ExtractArchiveAsync(archivePath, modDirectory).ConfigureAwait(true); + if (extractedDirectory is null) + { + await InformationDialog.ShowInformationDialogAsync( + parentWindow, + $"Failed to extract '{archiveFileName}' for FOMOD configuration."); + continue; + } + + ModComponent configured = await FomodInstallerDialog.ShowForExtractedArchiveAsync( + parentWindow, + extractedDirectory).ConfigureAwait(true); + + if (configured is null) + { + continue; + } + + FomodConfiguredComponentMerger.MergeInto(component, configured); + FomodDownloadPromptState.MarkConfigured(component, archiveFileName); + + await InformationDialog.ShowInformationDialogAsync( + parentWindow, + $"FOMOD configuration applied to '{component.Name}' from '{archiveFileName}'."); + } + } + } + + [ItemNotNull] + private static IEnumerable GetDownloadedArchivePaths( + [NotNull] ModComponent component, + [NotNull] string modDirectory) + { + if (component.ResourceRegistry is null || component.ResourceRegistry.Count == 0) + { + yield break; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ResourceMetadata resource in component.ResourceRegistry.Values) + { + if (resource?.Files is null) + { + continue; + } + + foreach (string fileName in resource.Files.Keys) + { + if (string.IsNullOrWhiteSpace(fileName) || !seen.Add(fileName)) + { + continue; + } + + string filePath = Path.Combine(modDirectory, fileName); + if (!File.Exists(filePath) || !ArchiveHelper.IsArchive(filePath)) + { + continue; + } + + yield return filePath; + } + } + } + + [CanBeNull] + private static async Task ExtractArchiveAsync( + [NotNull] string archivePath, + [NotNull] string modDirectory) + { + string extractFolderName = Path.GetFileNameWithoutExtension(archivePath); + string extractedDirectory = Path.Combine(modDirectory, extractFolderName); + + try + { + var fileSystemProvider = new RealFileSystemProvider(); + _ = await fileSystemProvider.ExtractArchiveAsync(archivePath, modDirectory).ConfigureAwait(false); + + if (FomodArchiveDiscovery.FindModuleConfigPath(extractedDirectory) != null) + { + return extractedDirectory; + } + + await Logger.LogWarningAsync( + $"[FomodPostDownload] Extracted '{archivePath}' but no fomod/ModuleConfig.xml found under '{extractedDirectory}'."); + } + catch (Exception ex) + { + await Logger.LogExceptionAsync(ex, $"[FomodPostDownload] Failed to extract '{archivePath}'"); + } + + return null; + } + } +} diff --git a/src/ModSync.Tests/FomodArchiveProbeTests.cs b/src/ModSync.Tests/FomodArchiveProbeTests.cs new file mode 100644 index 00000000..142192fe --- /dev/null +++ b/src/ModSync.Tests/FomodArchiveProbeTests.cs @@ -0,0 +1,113 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; + +using ModSync.Core; +using ModSync.Core.Services.Fomod; + +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public sealed class FomodArchiveProbeTests + { + [Test] + public void TryDetectInArchive_FindsNestedModuleConfig() + { + string workDir = Path.Combine(Path.GetTempPath(), "modsync-fomod-probe-" + Path.GetRandomFileName()); + Directory.CreateDirectory(workDir); + string archivePath = Path.Combine(workDir, "TestMod.zip"); + + try + { + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("TestMod-1.0/fomod/ModuleConfig.xml"); + using (StreamWriter writer = new StreamWriter(entry.Open())) + { + writer.Write("Test"); + } + } + + Assert.That( + FomodArchiveProbe.TryDetectInArchive(archivePath, out string moduleConfigEntryPath), + Is.True); + Assert.That(moduleConfigEntryPath, Does.Contain("fomod/ModuleConfig.xml").IgnoreCase); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + + [Test] + public void TryDetectInArchive_ReturnsFalseForPlainZip() + { + string workDir = Path.Combine(Path.GetTempPath(), "modsync-fomod-probe-" + Path.GetRandomFileName()); + Directory.CreateDirectory(workDir); + string archivePath = Path.Combine(workDir, "Plain.zip"); + + try + { + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("readme.txt"); + using (StreamWriter writer = new StreamWriter(entry.Open())) + { + writer.Write("hello"); + } + } + + Assert.That( + FomodArchiveProbe.TryDetectInArchive(archivePath, out string moduleConfigEntryPath), + Is.False); + Assert.That(moduleConfigEntryPath, Is.Null); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + } + + [TestFixture] + public sealed class FomodDownloadPromptStateTests + { + [Test] + public void ShouldPrompt_ReturnsFalseAfterDismissedOrConfigured() + { + var component = new ModComponent + { + ResourceRegistry = new Dictionary + { + ["https://example.test/mod.zip"] = new ResourceMetadata + { + Files = new Dictionary + { + ["ExampleMod.zip"] = true, + }, + HandlerMetadata = new Dictionary(), + }, + }, + }; + + Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "ExampleMod.zip"), Is.True); + + FomodDownloadPromptState.MarkDismissed(component, "ExampleMod.zip"); + Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "ExampleMod.zip"), Is.False); + + component.ResourceRegistry["https://example.test/mod2.zip"] = new ResourceMetadata + { + Files = new Dictionary { ["OtherMod.zip"] = true }, + HandlerMetadata = new Dictionary(), + }; + FomodDownloadPromptState.MarkConfigured(component, "OtherMod.zip"); + Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "OtherMod.zip"), Is.False); + } + } +} From 32938d97f211868956ab621d639ec1020fc4077e Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 13 Jun 2026 03:00:08 -0500 Subject: [PATCH 2/7] fix: scope FOMOD prompt state to the owning resource entry Look up HandlerMetadata from the ResourceRegistry row that contains the archive file so multi-download mods track dismiss/configure independently. --- .../Fomod/FomodDownloadPromptState.cs | 64 ++++++++++--------- src/ModSync.Tests/FomodArchiveProbeTests.cs | 4 +- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs b/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs index bb180f8b..07581aa2 100644 --- a/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs +++ b/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs @@ -40,13 +40,20 @@ public static bool ShouldPrompt([NotNull] ModComponent component, [NotNull] stri [CanBeNull] public static string GetStatus([NotNull] ModComponent component, [NotNull] string archiveFileName) { - if (TryGetStatusDictionary(component, out Dictionary statuses) - && statuses.TryGetValue(NormalizeFileName(archiveFileName), out string status)) + if (!TryGetResourceForArchive(component, archiveFileName, out ResourceMetadata resource)) { - return status; + return null; } - return null; + if (resource.HandlerMetadata is null + || !TryGetStatusDictionaryFromMetadata(resource.HandlerMetadata, out Dictionary statuses)) + { + return null; + } + + return statuses.TryGetValue(NormalizeFileName(archiveFileName), out string status) + ? status + : null; } public static void MarkDismissed([NotNull] ModComponent component, [NotNull] string archiveFileName) @@ -79,51 +86,46 @@ private static void SetStatus( throw new ArgumentException("Status cannot be null or whitespace.", nameof(status)); } - foreach (ResourceMetadata resource in component.ResourceRegistry.Values) + if (!TryGetResourceForArchive(component, archiveFileName, out ResourceMetadata resource)) { - if (resource?.Files is null - || !resource.Files.ContainsKey(archiveFileName)) - { - continue; - } - - if (resource.HandlerMetadata is null) - { - resource.HandlerMetadata = new Dictionary(StringComparer.Ordinal); - } + return; + } - if (!TryGetStatusDictionaryFromMetadata(resource.HandlerMetadata, out Dictionary statuses)) - { - statuses = new Dictionary(StringComparer.OrdinalIgnoreCase); - resource.HandlerMetadata[HandlerMetadataKey] = statuses; - } + if (resource.HandlerMetadata is null) + { + resource.HandlerMetadata = new Dictionary(StringComparer.Ordinal); + } - statuses[NormalizeFileName(archiveFileName)] = status; - return; + if (!TryGetStatusDictionaryFromMetadata(resource.HandlerMetadata, out Dictionary statuses)) + { + statuses = new Dictionary(StringComparer.OrdinalIgnoreCase); + resource.HandlerMetadata[HandlerMetadataKey] = statuses; } + + statuses[NormalizeFileName(archiveFileName)] = status; } - private static bool TryGetStatusDictionary( + private static bool TryGetResourceForArchive( [NotNull] ModComponent component, - out Dictionary statuses) + [NotNull] string archiveFileName, + out ResourceMetadata resource) { - statuses = null; + resource = null; if (component.ResourceRegistry is null) { return false; } - foreach (ResourceMetadata resource in component.ResourceRegistry.Values) + foreach (ResourceMetadata candidate in component.ResourceRegistry.Values) { - if (resource?.HandlerMetadata is null) + if (candidate?.Files is null + || !candidate.Files.ContainsKey(archiveFileName)) { continue; } - if (TryGetStatusDictionaryFromMetadata(resource.HandlerMetadata, out statuses)) - { - return true; - } + resource = candidate; + return true; } return false; diff --git a/src/ModSync.Tests/FomodArchiveProbeTests.cs b/src/ModSync.Tests/FomodArchiveProbeTests.cs index 142192fe..417acd85 100644 --- a/src/ModSync.Tests/FomodArchiveProbeTests.cs +++ b/src/ModSync.Tests/FomodArchiveProbeTests.cs @@ -101,11 +101,13 @@ public void ShouldPrompt_ReturnsFalseAfterDismissedOrConfigured() FomodDownloadPromptState.MarkDismissed(component, "ExampleMod.zip"); Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "ExampleMod.zip"), Is.False); - component.ResourceRegistry["https://example.test/mod2.zip"] = new ResourceMetadata + var registry = new Dictionary(component.ResourceRegistry); + registry["https://example.test/mod2.zip"] = new ResourceMetadata { Files = new Dictionary { ["OtherMod.zip"] = true }, HandlerMetadata = new Dictionary(), }; + component.ResourceRegistry = registry; FomodDownloadPromptState.MarkConfigured(component, "OtherMod.zip"); Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "OtherMod.zip"), Is.False); } From a36200897ba6efc6d3693c96db8ee54d14e1eef7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 13 Jun 2026 04:50:45 -0500 Subject: [PATCH 3/7] feat: add CLI FOMOD post-download orchestration and agent choices path Move FOMOD presenter and post-download flow into Core with host-based GUI/CLI wiring. Add --fomod-skip, --fomod-choices, and a TTY console wizard so install/convert/merge can configure archives headlessly via MODSYNC_FOMOD_CHOICES or interactively when attached to a terminal. --- ...fomod-cli-download-prompts-requirements.md | 101 ++++ docs/knowledgebase/agent-action-parity.md | 5 +- docs/knowledgebase/agent-native-audit.md | 10 +- docs/knowledgebase/core-cli-reference.md | 6 + docs/knowledgebase/fomod-support.md | 10 +- ...23-feat-fomod-cli-download-prompts-plan.md | 450 ++++++++++++++++++ .../CLI/ConsoleInteractionCapabilities.cs | 35 ++ .../CLI/FomodCliPostDownloadHosts.cs | 119 +++++ src/ModSync.Core/CLI/FomodConsoleWizard.cs | 186 ++++++++ src/ModSync.Core/CLI/ModBuildConverter.cs | 52 ++ .../Fomod/FomodArchiveExtractService.cs | 60 +++ .../Services/Fomod/FomodChoicesApplier.cs | 127 +++++ .../Services/Fomod/FomodChoicesFile.cs | 40 ++ .../Services/Fomod/FomodChoicesFileHost.cs | 71 +++ .../Fomod/FomodConfiguredComponentMerger.cs | 101 +++- .../Fomod/FomodDownloadedArchivePaths.cs | 54 +++ .../Fomod}/FomodInstallerPresenter.cs | 9 +- .../Fomod/FomodPostDownloadOptions.cs | 25 + .../Fomod/FomodPostDownloadOptionsResolver.cs | 97 ++++ .../Fomod/FomodPostDownloadOrchestrator.cs | 100 ++++ .../Services/Fomod/FomodPromptContext.cs | 37 ++ .../Services/Fomod/IFomodPostDownloadHost.cs | 33 ++ .../Dialogs/FomodInstallerDialog.axaml.cs | 2 +- .../Services/FomodGuiPostDownloadHost.cs | 71 +++ .../FomodPostDownloadPromptService.cs | 152 +----- .../FomodConfiguredComponentMergerTests.cs | 40 ++ .../FomodInstallerPresenterTests.cs | 53 ++- .../FomodPostDownloadOptionsResolverTests.cs | 40 ++ .../FomodPostDownloadOrchestratorTests.cs | 121 +++++ 29 files changed, 2041 insertions(+), 166 deletions(-) create mode 100644 docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md create mode 100644 docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md create mode 100644 src/ModSync.Core/CLI/ConsoleInteractionCapabilities.cs create mode 100644 src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs create mode 100644 src/ModSync.Core/CLI/FomodConsoleWizard.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodArchiveExtractService.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodChoicesApplier.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodChoicesFile.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodDownloadedArchivePaths.cs rename src/{ModSync.GUI/Services => ModSync.Core/Services/Fomod}/FomodInstallerPresenter.cs (98%) create mode 100644 src/ModSync.Core/Services/Fomod/FomodPostDownloadOptions.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs create mode 100644 src/ModSync.Core/Services/Fomod/FomodPromptContext.cs create mode 100644 src/ModSync.Core/Services/Fomod/IFomodPostDownloadHost.cs create mode 100644 src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs create mode 100644 src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs create mode 100644 src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs create mode 100644 src/ModSync.Tests/FomodPostDownloadOrchestratorTests.cs 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 new file mode 100644 index 00000000..941b8123 --- /dev/null +++ b/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md @@ -0,0 +1,101 @@ +--- +title: "CLI FOMOD post-download prompts" +status: reviewed +date: 2026-06-14 +origin: docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md +supersedes_deferred: CLI download/install parity from GUI FOMOD discovery slice +--- + +# CLI FOMOD post-download prompts + +## Summary + +After CLI download phases on `install`, `convert`, and `merge`, detect FOMOD installers in downloaded archives and offer the same configuration outcomes as the GUI post-download hook. Interactive terminals run a full step wizard; non-interactive environments warn and continue by default, with global configuration via CLI flags, environment variables, and ModSync settings. + +## Problem Frame + +PR #169 added GUI post-download FOMOD detection and optional configuration, but headless `install -d` and `convert -d` workflows still download FOMOD archives without surfacing installer choices. Agents and power users running full-build CLI installs hit validation/install with mapper defaults only, diverging from GUI-configured `Choose` options and instructions. + +## Requirements + +**Post-download detection and scope** + +- R1: After CLI download completes on `install -d`, `convert -d`, and `merge -d`, scan **selected** components' downloaded archive files in the mod/source directory using `FomodArchiveProbe` (entry listing, no full extract for detection). +- R2: When `fomod/ModuleConfig.xml` is found and `FomodDownloadPromptState.ShouldPrompt` is true, run the post-download FOMOD flow for that archive. +- R3: Component scope matches GUI: only components with `IsSelected == true` at the hook point (after selection filters are applied for each verb). + +**Interactive TTY behavior** + +- R4: When stdin is an interactive TTY, ask Yes/No to configure now; **No** calls `MarkDismissed` (GUI parity). +- R5: On **Yes**, extract the archive, run the **full** FOMOD wizard in the terminal (all install steps, groups, and plugin choices) using the same presenter rules as `FomodInstallerDialog`, then merge into the existing component via `FomodConfiguredComponentMerger` and `MarkConfigured`. + +**Non-interactive behavior** + +- R6: When stdin is not an interactive TTY (CI, agents, redirected I/O), default mode is **warn and continue**: emit a structured `WARN:` line per detected FOMOD archive and proceed without persisting dismiss state. +- R7: Global `--fomod-skip` (and equivalent env/settings) persists `MarkDismissed` for archives handled under that skip policy in the session. +- R8: Non-interactive mode is configurable globally via environment variable, CLI startup/global flags, and ModSync settings dialog; v1 ships the `warn-continue` mode but the resolution pipeline must accept future modes without redesign. + +**Agent / automation path** + +- R9: Non-interactive runs may supply a FOMOD choices sidecar file (CLI flag and `MODSYNC_FOMOD_CHOICES` env) so cloud agents can configure without TTY; when present, apply selections and `MarkConfigured` without prompting. + +**Persistence by verb** + +- R10: On `convert -d` (and `merge -d` when output is written), after FOMOD configuration, persist merged component state (options, instructions, `HandlerMetadata` including `fomodPromptStatus`) to the instruction output path. +- R11: On `install -d`, FOMOD changes apply in-memory for the current run only unless the user sets an explicit output/save flag documented in the plan. + +**Shared orchestration** + +- R12: Core owns post-download FOMOD orchestration; GUI and CLI are thin hosts over the same service (unified pipeline parity with validation). +- R13: Headless `FomodInstallerPresenter` lives in Core so CLI does not reference Avalonia. + +**Correctness fixes bundled in this slice** + +- R14: Applying wizard selections must not merge plugins from steps that are no longer visible after condition-flag changes (hidden-step parity with visible validation). +- R15: Re-configuring the same archive updates existing option selections and replaces superseded FOMOD-generated instructions instead of silently skipping duplicate option GUIDs. + +## Success Criteria + +- Headless `install -d` against a fixture with a FOMOD archive prompts on TTY, applies choices, and proceeds to validation with merged options. +- Non-TTY `convert -d` emits `WARN:` once per unprompted archive and does not hang on `ReadLine`. +- `--fomod-skip` on a non-TTY run persists dismissed state into serialized TOML on convert output. +- `--fomod-choices` configures a FOMOD mod without TTY and marks configured state. +- GUI **Fetch Downloads** behavior unchanged after GUI delegates to the Core orchestrator. +- `docs/knowledgebase/agent-action-parity.md` documents post-download FOMOD configure for CLI. + +## Scope Boundaries + +**In scope** + +- Core orchestrator, CLI console host, GUI adapter refactor, settings/env/CLI config resolution, tests, KB updates. +- Hooks on `install -d`, `convert -d`, `merge -d`. +- Spectre.Console or equivalent injectable console prompt layer behind a host interface. + +**Deferred for later** + +- Validation blocking when FOMOD choices are unset. +- Plugin images in terminal wizard. +- Download scope alignment so CLI only downloads `IsSelected` components (separate download-system change). +- Additional non-TTY modes beyond `warn-continue` (fail-fast, auto-dismiss-all) once v1 config plumbing exists. + +**Outside this product's identity** + +- Headless API server or MCP tools inside the desktop app (scripts/CLI remain the agent path). + +## Key Decisions + +- Architecture: Core `FomodPostDownloadOrchestrator` + `IFomodPostDownloadHost` adapters (GUI dialog host, console TTY host, warn-continue host, choices-file host). +- TTY detection uses input redirect, output redirect, `Environment.UserInteractive`, and explicit `--non-interactive` / `--interactive` overrides. +- Config precedence: CLI flags > environment variables > `settings.json` > TTY-derived default. +- Selected-only FOMOD scan even when download fetched more components. +- Convert/merge output persistence uses existing `-o` / output path; no silent in-place overwrite of input without documented flag. + +## Dependencies / Assumptions + +- PR #169 Core pieces (`FomodArchiveProbe`, `FomodDownloadPromptState`, `FomodConfiguredComponentMerger`) remain the persistence and detection layer. +- Instruction paths continue to use `<>` / `<>` placeholders after FOMOD merge. +- `ModSync.Tests` remains the single test project. + +## Outstanding Questions + +- None blocking — user confirmed synthesis call-outs (2026-06-14 session). diff --git a/docs/knowledgebase/agent-action-parity.md b/docs/knowledgebase/agent-action-parity.md index 54528478..4571c183 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) | +| 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) | | 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,7 +30,7 @@ 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 | -| `ScrapeDownloadsButton` | `install -d` or `convert -d` | Partial | +| `ScrapeDownloadsButton` | `install -d` or `convert -d` | Partial — FOMOD configure after download: GUI (PR #169); CLI planned Plan 123 | | `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 | @@ -72,5 +72,6 @@ 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). 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 ac600ba3..081dad87 100644 --- a/docs/knowledgebase/agent-native-audit.md +++ b/docs/knowledgebase/agent-native-audit.md @@ -9,7 +9,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Metric | Value | |--------|-------| | Principles scored | 8 / 8 | -| Weighted average | **62%** | +| Weighted average | **65%** | | Headless agent readiness | Strong for Core CLI + tests | | Desktop-only gap | GUI wizard, downloads UX, widescreen flow | @@ -17,7 +17,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | # | Principle | Score | Summary | |---|-----------|-------|---------| -| 1 | **Parity** | 14/25 (56%) | Core paths (`validate`, `install`, convert) are CLI-accessible; many wizard-only flows lack headless equivalents. | +| 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. | | 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. | @@ -38,6 +38,8 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | 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 | | Install mods | Wizard or `install` | Yes | | Mod selection / filters | `ModSelectionPage` UI | Partial — CLI `--select` | | Widescreen-only install block | Dynamic wizard pages | No — desktop only | @@ -46,9 +48,9 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec **Strengths:** `[REPO]` `ModBuildConverter` covers validate/install/convert/merge; `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. +**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`). -**Recommendations (Tier 1):** Keep `agent-action-parity.md` current when wizard pages change. Document `--select` examples for tier/category installs. +**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`. --- diff --git a/docs/knowledgebase/core-cli-reference.md b/docs/knowledgebase/core-cli-reference.md index 2a44b57e..e61f0b00 100644 --- a/docs/knowledgebase/core-cli-reference.md +++ b/docs/knowledgebase/core-cli-reference.md @@ -16,6 +16,12 @@ All verbs inherit from `BaseOptions`: |------|-------------| | `-v` / `--verbose` | Verbose logging | | `--plaintext` | Plain-text log output (no ANSI) | +| `--fomod-skip` | Skip FOMOD post-download configuration; marks archives dismissed for this run | +| `--fomod-choices` | JSON sidecar with FOMOD plugin selections (see [fomod-support.md](fomod-support.md)) | +| `--interactive` | Force interactive FOMOD prompts when I/O is redirected | +| `--non-interactive` | Force warn-continue FOMOD behavior (no TTY wizard) | + +Environment: `MODSYNC_FOMOD_CHOICES` (choices file path), `MODSYNC_FOMOD_POST_DOWNLOAD_MODE` (`warn-continue` or `skip`). Settings key: `fomodPostDownloadMode` in `%AppData%/ModSync/settings.json`. ## Verbs diff --git a/docs/knowledgebase/fomod-support.md b/docs/knowledgebase/fomod-support.md index 9e57681a..6fb42bb5 100644 --- a/docs/knowledgebase/fomod-support.md +++ b/docs/knowledgebase/fomod-support.md @@ -49,9 +49,17 @@ GUI (`src/ModSync.GUI/`): ## Deferred `[OPEN]` -- CLI download/install parity for FOMOD post-download prompts. - Plugin images from `image path`. +## Planned: CLI post-download 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]` - `FomodArchiveProbe` detects `fomod/ModuleConfig.xml` inside downloaded archives via entry listing. 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 new file mode 100644 index 00000000..f57f7148 --- /dev/null +++ b/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md @@ -0,0 +1,450 @@ +--- +title: "feat: CLI FOMOD post-download prompts" +status: active +date: 2026-06-14 +origin: docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md +deepened: 2026-06-14 +--- + +# Plan 123 — CLI FOMOD post-download prompts + +## Summary + +Unify post-download FOMOD configuration in Core, move the headless wizard presenter into `ModSync.Core`, and wire CLI `install -d`, `convert -d`, and `merge -d` to the same orchestrator the GUI uses. Interactive terminals get the full step wizard; non-interactive runs warn-and-continue by default, with `--fomod-skip`, settings/env config, and optional `--fomod-choices` for agents. This slice also fixes hidden-step merge and re-configure merge correctness called out in review. + +## Problem Frame + +GUI users get optional FOMOD configuration after **Fetch Downloads**; CLI users running full-build `install -d` download the same archives but never configure installer options, breaking agent-action parity and producing different validation/install outcomes. The presenter and post-download loop currently live in the GUI assembly, blocking Core CLI integration. + +(see origin: `docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md`) + +## Requirements + +| ID | Requirement | Plan coverage | +|----|-------------|---------------| +| R1 | CLI post-download FOMOD scan on `install`/`convert`/`merge` `-d` | U3, U4 | +| R2 | Probe + `ShouldPrompt` gate | U2 | +| R3 | Selected components only | U2, U3 | +| R4 | TTY Yes/No; No → `MarkDismissed` | U4 | +| R5 | Full terminal wizard + merge + `MarkConfigured` | U4, U5 | +| R6 | Non-TTY default warn-continue, no dismiss | U4, U6 | +| R7 | `--fomod-skip` persists dismiss | U3, U6 | +| R8 | Config via env, CLI, settings | U3 | +| R9 | `--fomod-choices` for agents | U6 | +| R10 | Convert/merge output persists FOMOD state | U4 | +| R11 | Install in-memory only | U4 | +| R12 | Core orchestrator; GUI/CLI hosts | U1, U2, U7 | +| R13 | Presenter in Core | U1 | +| R14 | Hidden-step apply fix | U5 | +| R15 | Re-configure merge fix | U5 | + +--- + +## Key Technical Decisions + +### KTD-1: Core orchestrator + host adapters (not inline `ModBuildConverter` logic) + +Mirror `InstallationValidationPipeline` + `ConfirmationCallback`: one `FomodPostDownloadOrchestrator` in Core; GUI and CLI supply `IFomodPostDownloadHost` implementations. **Reject** duplicating the loop inside `ModBuildConverter` (~3.5k lines). + +### KTD-2: Move `FomodInstallerPresenter` to Core + +Presenter is already headless; GUI dialog becomes a view. Enables CLI wizard and removes `ModSync.Tests` → GUI dependency for presenter tests. + +### KTD-3: Per-verb hook placement (not inside `DownloadAllModFilesAsync` alone) + +| Verb | Hook after download | Component filter | Persist | +|------|---------------------|------------------|---------| +| `install -d` | ~L3058, before validation | `IsSelected` | No | +| `convert -d` | ~L1906, before autogen/serialize | `IsSelected` after `ApplySelectionFilters` if `-s`/`--select` used; else all loaded components that remain selected | Yes when `-o` | +| `merge -d` | ~L2438, before serialize | All (merge sets `IsSelected=true`) | Yes when output set | + +Convert runs selection filters **after** download today; FOMOD hook for convert must run **after** `ApplySelectionFilters` (~L2025) when building the scan list, or immediately before serialize with the same filtered list. + +### KTD-4: TTY and mode resolution + +``` +FomodPostDownloadMode resolved by: + 1. --fomod-skip / --fomod-choices (mutually exclusive with interactive prompt path) + 2. KOTOR_MODSYNC_FOMOD_MODE env + 3. settings.json fomodPostDownloadMode + 4. If interactive TTY (see KTD-5) → Interactive + 5. Else → WarnContinue +``` + +Add `FomodPostDownloadMode` enum: `Interactive`, `WarnContinue` (v1); reserve `Block`, `AutoDismiss` for future settings values without schema break. + +### KTD-5: Interactive TTY detection + +Single helper `ConsoleInteractionCapabilities` (Core.CLI): + +- `IsInteractivePromptAvailable` = `!forceNonInteractive && !Console.IsInputRedirected && (!Console.IsOutputRedirected || allowOutputRedirect) && Environment.UserInteractive != false` +- Honor `CI`/`GITHUB_ACTIONS` as non-interactive unless `--interactive` +- Reuse patterns from `ConsoleProgressDisplay` but **do not** share render lock with progress UI during wizard + +### KTD-6: Console wizard implementation + +Add **Spectre.Console** package to `ModSync.Core` (netstandard2.0+ compatible) behind `FomodConsoleWizard` using injected `IAnsiConsole` for testability. Map `FomodGroupType` → `SelectionPrompt` / `MultiSelectionPrompt` per best-practices research. Fallback plain-text path when `TERM=dumb` or `--plain-text`. + +### KTD-7: Hidden-step apply fix (R14) + +Change `ApplySelectionsToComponent` to apply only plugins belonging to **currently visible** steps (recompute `GetVisibleStepIndices` at finish). Add regression test mirroring flag-dependent visibility scenario from `FomodInstallerPresenterTests`. + +### KTD-8: Re-configure merge (R15) + +Extend `FomodConfiguredComponentMerger` to update `Option.IsSelected` for matching GUIDs and remove/replace prior FOMOD-sourced instructions (tagged via source metadata or replace-all instructions from configured component when merging post-download). Narrow scope: only instructions added by FOMOD mapper (detectable by Choose/Copy patterns from mapper) to avoid wiping hand-authored instructions. + +### KTD-9: ResourceRegistry mutation discipline + +Orchestrator and download code must never assign through `ResourceRegistry` getter copy. Use in-place `HandlerMetadata` mutation on resolved `ResourceMetadata` references only; add `ModComponent` internal helper if needed for archive→resource lookup. + +### KTD-10: Agent choices file + +JSON sidecar v1 schema (stable names, not indices): + +```json +{ + "version": 1, + "archives": [ + { + "archiveFileName": "ExampleMod.zip", + "selections": [ + { "stepName": "Main", "groupName": "Quality", "plugins": ["High"] } + ] + } + ] +} +``` + +`FomodChoicesFileHost` applies selections programmatically via presenter API without terminal I/O. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TD + DL[Download completes CLI or GUI] + ORCH[FomodPostDownloadOrchestrator] + PROBE[FomodArchiveProbe] + STATE[FomodDownloadPromptState] + HOST{IFomodPostDownloadHost} + GUI[FomodGuiPostDownloadHost] + TTY[FomodConsolePostDownloadHost] + WARN[FomodWarnContinueHost] + CHOICES[FomodChoicesFileHost] + EXT[Extract archive] + PRES[FomodInstallerPresenter] + MERGE[FomodConfiguredComponentMerger] + OUT[Continue verb pipeline] + + DL --> ORCH + ORCH --> PROBE + ORCH --> STATE + ORCH --> HOST + HOST --> GUI + HOST --> TTY + HOST --> WARN + HOST --> CHOICES + TTY --> EXT + CHOICES --> EXT + GUI --> EXT + EXT --> PRES + PRES --> MERGE + MERGE --> STATE + ORCH --> OUT +``` + +--- + +## Scope Boundaries + +**In scope:** This plan's R1–R15, KB/agent-parity updates, tests listed per unit. + +### Deferred to Follow-Up Work + +- CLI download only fetches `IsSelected` components (`DownloadAllModFilesAsync` behavior change). +- `--save-instruction-file` on `install -d` after FOMOD. +- `fomod configure` standalone CLI verb (re-run wizard on existing archive). +- Plugin images in terminal UI. +- Validation gate blocking install when FOMOD unset. + +**Out of scope:** MCP-in-app tools; widescreen wizard. + +--- + +## Risks and Dependencies + +| Risk | Mitigation | +|------|------------| +| ResourceRegistry copy drops metadata (C1) | KTD-9; P0 serialization test | +| Hidden-step stale selections (C3) | KTD-7; presenter test | +| Non-TTY infinite re-warn (C4) | Document; `--fomod-skip` persists; choices file for CI | +| Dual settings serializers | Same JSON keys in `SettingsData` + `AppSettings`; round-trip test | +| Spectre.Console on net48 | Verify package TFM; plain-text fallback | +| Concurrent download + FOMOD hook race | Run FOMOD orchestrator **after** full download batch completes (current hook design) | + +**Depends on:** PR #169 merged or cherry-picked Core FOMOD probe/state/merger. + +--- + +## Implementation Units + +### U1. Move presenter and session models to Core + +**Goal:** Headless FOMOD wizard logic is consumable from CLI without GUI reference. + +**Requirements:** R13 + +**Files:** + +- Move `src/ModSync.GUI/Services/FomodInstallerPresenter.cs` → `src/ModSync.Core/Services/Fomod/FomodInstallerPresenter.cs` +- Move session model types to `src/ModSync.Core/Services/Fomod/FomodInstallerSessionModels.cs` (or same folder) +- Update `src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs` +- Update `src/ModSync.Tests/FomodInstallerPresenterTests.cs` + +**Approach:** Namespace `ModSync.Core.Services.Fomod`. GUI project adds no logic—update usings only. + +**Test scenarios:** + +- Happy path: existing `FomodInstallerPresenterTests` pass unchanged after namespace move. +- Build: `ModSync.Core` builds without GUI reference. + +**Verification:** `dotnet test --filter FullyQualifiedName~FomodInstallerPresenterTests` + +--- + +### U2. Core orchestrator and extract helper + +**Goal:** Single post-download FOMOD loop shared by GUI and CLI. + +**Requirements:** R1, R2, R3, R12 + +**Dependencies:** U1 + +**Files:** + +- `src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs` (new) +- `src/ModSync.Core/Services/Fomod/IFomodPostDownloadHost.cs` (new) +- `src/ModSync.Core/Services/Fomod/FomodPromptContext.cs` (new) +- `src/ModSync.Core/Services/Fomod/FomodArchiveExtractService.cs` (new — lift from GUI service) +- `src/ModSync.Tests/FomodPostDownloadOrchestratorTests.cs` (new) + +**Approach:** + +- Port `GetDownloadedArchivePaths` logic into orchestrator (or shared static helper). +- Loop: selected components → archives → probe → `ShouldPrompt` → host.AskConfigure → extract → parse/map → host.RunWizard → merge → mark state. +- Fake host in tests records decisions without I/O. + +**Test scenarios:** + +- Happy path: probe positive → host Configure → merge updates `Options` → `MarkConfigured`. +- Dismiss path: host Dismiss → `MarkDismissed`, no merge. +- Skip prompt when `ShouldPrompt` false. +- Selected-only: deselected component with FOMOD archive on disk is not scanned. +- Extract failure: host notified; no `MarkConfigured`. +- Multi-archive: two zips on one component, independent state keys. + +**Verification:** New test class green; no Avalonia reference in test project for orchestrator tests. + +--- + +### U3. Config resolution and CLI flags + +**Goal:** Global FOMOD post-download mode via CLI, env, and settings. + +**Requirements:** R7, R8 + +**Dependencies:** U2 + +**Files:** + +- `src/ModSync.Core/Services/Fomod/FomodPostDownloadOptions.cs` (new) +- `src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs` (new) +- `src/ModSync.Core/CLI/ModBuildConverter.cs` (add `--fomod-skip`, `--fomod-choices`, `--fomod-non-interactive` to shared options) +- `src/ModSync.GUI/Models/AppSettings.cs` (add `FomodPostDownloadMode` string, default `warn-continue`) +- `src/ModSync.GUI/Dialogs/SettingsDialog.cs` (expose setting in Downloads or Advanced section) +- `src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs` (new) + +**Approach:** Precedence per KTD-4. CLI `SettingsData` mirrors GUI key `fomodPostDownloadMode`. + +**Test scenarios:** + +- CLI flag overrides env overrides settings. +- Default mode when no config and non-TTY is `WarnContinue`. +- `--fomod-skip` forces skip semantics for session. + +**Verification:** Resolver unit tests. + +--- + +### U4. CLI hosts and `ModBuildConverter` hooks + +**Goal:** Wire install/convert/merge download completion to orchestrator with correct hosts. + +**Requirements:** R4–R6, R10, R11 + +**Dependencies:** U2, U3 + +**Files:** + +- `src/ModSync.Core/CLI/ConsoleInteractionCapabilities.cs` (new) +- `src/ModSync.Core/CLI/FomodWarnContinuePostDownloadHost.cs` (new) +- `src/ModSync.Core/CLI/FomodConsolePostDownloadHost.cs` (new) +- `src/ModSync.Core/CLI/ModBuildConverter.cs` (three hook call sites) +- `src/ModSync.Tests/FomodCliPostDownloadTests.cs` (new) + +**Approach:** + +- Factory selects host from resolved options + TTY capabilities. +- `install -d`: orchestrator after download, before validation; selected components. +- `convert -d`: orchestrator after `ApplySelectionFilters`, before serialize; persist via existing output path. +- `merge -d`: same as convert. +- Warn host logs: `WARN: FOMOD installer detected in '{archive}' for mod '{name}'. Configure later via GUI or re-run with --fomod-choices.` + +**Test scenarios:** + +- Redirected stdin: no hang; WARN emitted; exit continues. +- `--fomod-skip` + convert `-o`: dismissed state in output TOML. +- TTY fake: Yes → wizard host invoked (mock console). +- Install path: no file write after FOMOD; in-memory options changed. + +**Verification:** `FomodCliPostDownloadTests` + manual `install -d` smoke on desktop. + +--- + +### U5. Console wizard + presenter correctness fixes + +**Goal:** Full terminal wizard and fix hidden-step / re-configure merge bugs. + +**Requirements:** R5, R14, R15 + +**Dependencies:** U1, U4 + +**Files:** + +- `src/ModSync.Core/CLI/FomodConsoleWizard.cs` (new) +- Add Spectre.Console to `src/ModSync.Core/ModSync.Core.csproj` (if TFM compatible) +- `src/ModSync.Core/Services/Fomod/FomodInstallerPresenter.cs` (KTD-7 apply visible-only) +- `src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs` (KTD-8) +- `src/ModSync.Tests/FomodInstallerPresenterTests.cs` (hidden-step regression) +- `src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs` (new) + +**Approach:** + +- Console wizard loop matches `FomodInstallerDialog` Next/Finish: recompute visible indices after each selection; validate each visible step before advance. +- Merger: upsert options by GUID; strip prior FOMOD-generated instructions before append (use heuristic: instructions referencing `<>` paths under extracted folder name, or add `Instruction.SourceMetadata` tag in mapper—plan defers exact tag to implementation discovery). + +**Test scenarios:** + +- Hidden step: select high tier extras, go back, select low tier, finish → extras not applied. +- Re-configure: second merge updates `IsSelected`, does not duplicate Choose instructions. +- Console wizard integration: mock `IAnsiConsole` selects plugins; session validates. + +**Verification:** Presenter + merger tests; optional Spectre `TestConsole` snapshot. + +--- + +### U6. FOMOD choices file host (agent path) + +**Goal:** Cloud agents configure FOMOD without TTY. + +**Requirements:** R9 + +**Dependencies:** U2, U5 + +**Files:** + +- `src/ModSync.Core/Services/Fomod/FomodChoicesFile.cs` (new model) +- `src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs` (new) +- `src/ModSync.Core/Services/Fomod/FomodChoicesApplier.cs` (new — drives presenter without UI) +- `src/ModSync.Tests/FomodChoicesFileTests.cs` (new) +- `docs/knowledgebase/core-cli-reference.md` (document flags) + +**Test scenarios:** + +- Valid choices file → `MarkConfigured`, options merged. +- Missing plugin name → actionable error listing valid plugins. +- Choices + `--fomod-skip` → skip wins (documented). + +**Verification:** Unit tests; example JSON in test fixtures. + +--- + +### U7. GUI adapter refactor + +**Goal:** GUI download hook delegates to Core orchestrator; behavior unchanged for users. + +**Requirements:** R12 + +**Dependencies:** U2 + +**Files:** + +- `src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs` (new) +- `src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs` (thin wrapper) +- `src/ModSync.GUI/Services/DownloadOrchestrationService.cs` (unchanged call site) + +**Approach:** GUI host implements `AskConfigure` via `ConfirmationDialog`; `RunWizard` via `FomodInstallerDialog`. + +**Test scenarios:** + +- Test expectation: none — manual desktop validation per `AGENTS.md`; optional headless test with fake host already in U2. + +**Verification:** Desktop Fetch Downloads → FOMOD prompt still appears. + +--- + +### U8. Docs, KB, and agent-native parity + +**Goal:** Document CLI FOMOD paths; upgrade parity tables. + +**Requirements:** Success criteria + +**Dependencies:** U4, U6 + +**Files:** + +- `docs/knowledgebase/fomod-support.md` +- `docs/knowledgebase/agent-action-parity.md` +- `docs/knowledgebase/download-system.md` +- `docs/plans/vortex-mo2-feature-parity-living-plan.md` +- `docs/knowledgebase/agent-native-audit.md` (FOMOD parity row) + +**Test scenarios:** + +- Test expectation: none — doc accuracy review against implemented flags. + +**Verification:** Links and flag names match `ModBuildConverter` help text. + +--- + +## Verification Strategy + +```bash +dotnet build ModSync.sln --configuration Debug +dotnet test src/ModSync.Tests/ModSync.Tests.csproj \ + --filter "FullyQualifiedName~Fomod" --configuration Debug +dotnet test src/ModSync.Tests/ModSync.Tests.csproj \ + --filter "FullyQualifiedName~FomodCliPostDownload" --configuration Debug +``` + +Manual (desktop): `./scripts/agents/launch_gui_desktop.sh` — Fetch Downloads with FOMOD fixture; confirm prompt. + +Manual (TTY): `dotnet run --project src/KOTORModSync.Core/KOTORModSync.Core.csproj -- install -d ...` with minimal FOMOD zip fixture. + +--- + +## Open Questions (implementation-time) + +- Exact instruction tagging for safe re-configure merge (KTD-8) — resolve during U5 by inspecting `FomodToComponentMapper` output shapes. +- Whether `install -y` should suppress FOMOD Yes/No but still allow wizard when user passes `--fomod-choices` (default: `-y` skips confirmation prompts only, not FOMOD wizard unless `--fomod-skip`). + +--- + +## Sources and Research + +- Repo research: Core hook points, settings dual-schema, test templates (`ModBuildConverterCliIntegrationTests`) +- Architecture: orchestrator + host pattern (validation pipeline precedent) +- Best practices: Spectre.Console + `IAnsiConsole`, non-interactive-first flags, choices JSON by name +- Feasibility: presenter move mandatory; no GUI reference from Core +- Correctness review: C1–C10 ranked risks embedded in KTD-7–9 and unit tests diff --git a/src/ModSync.Core/CLI/ConsoleInteractionCapabilities.cs b/src/ModSync.Core/CLI/ConsoleInteractionCapabilities.cs new file mode 100644 index 00000000..c182772e --- /dev/null +++ b/src/ModSync.Core/CLI/ConsoleInteractionCapabilities.cs @@ -0,0 +1,35 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; + +namespace ModSync.Core.CLI +{ + public static class ConsoleInteractionCapabilities + { + public static bool IsInteractiveTerminal(bool forceInteractive, bool forceNonInteractive) + { + if (forceNonInteractive) + { + return false; + } + + if (forceInteractive) + { + return true; + } + + try + { + return !Console.IsInputRedirected + && !Console.IsOutputRedirected + && Environment.UserInteractive; + } + catch + { + return false; + } + } + } +} diff --git a/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs b/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs new file mode 100644 index 00000000..63b74320 --- /dev/null +++ b/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs @@ -0,0 +1,119 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using JetBrains.Annotations; + +using ModSync.Core.Services.Fomod; + +namespace ModSync.Core.CLI +{ + public sealed class FomodWarnContinuePostDownloadHost : IFomodPostDownloadHost + { + public Task AskConfigureAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + string message = + $"WARN: FOMOD installer detected in '{context.ArchiveFileName}' for mod '{context.Component.Name}'. " + + "Configure later via GUI or re-run with --fomod-choices."; + Console.Error.WriteLine(message); + return Task.FromResult(FomodConfigurePromptResult.AlreadyHandled); + } + + public Task RunWizardAsync( + string extractedArchiveDirectory, + FomodPromptContext context, + CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task ReportExtractFailureAsync( + FomodPromptContext context, + string message, + CancellationToken cancellationToken = default) + { + Console.Error.WriteLine(message); + return Task.CompletedTask; + } + + public Task ReportConfiguredAsync(FomodPromptContext context, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + public sealed class FomodSkipPostDownloadHost : IFomodPostDownloadHost + { + public Task AskConfigureAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) => + Task.FromResult(FomodConfigurePromptResult.Dismiss); + + public Task RunWizardAsync( + string extractedArchiveDirectory, + FomodPromptContext context, + CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task ReportExtractFailureAsync( + FomodPromptContext context, + string message, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task ReportConfiguredAsync(FomodPromptContext context, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + public sealed class FomodConsolePostDownloadHost : IFomodPostDownloadHost + { + public async Task AskConfigureAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + await Console.Out.WriteLineAsync( + $"A FOMOD installer was detected in '{context.ArchiveFileName}' for mod '{context.Component.Name}'.") + .ConfigureAwait(false); + await Console.Out.WriteLineAsync("Configure installer options now? [y/N]: ").ConfigureAwait(false); + + string response = Console.ReadLine()?.Trim(); + if (string.Equals(response, "y", StringComparison.OrdinalIgnoreCase) + || string.Equals(response, "yes", StringComparison.OrdinalIgnoreCase)) + { + return FomodConfigurePromptResult.Configure; + } + + return FomodConfigurePromptResult.Dismiss; + } + + public Task RunWizardAsync( + string extractedArchiveDirectory, + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + ModComponent configured = FomodConsoleWizard.Run(extractedArchiveDirectory, context.Component.Name); + return Task.FromResult(configured); + } + + public Task ReportExtractFailureAsync( + FomodPromptContext context, + string message, + CancellationToken cancellationToken = default) + { + Console.Error.WriteLine(message); + return Task.CompletedTask; + } + + public async Task ReportConfiguredAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + await Console.Out.WriteLineAsync( + $"FOMOD configuration applied to '{context.Component.Name}' from '{context.ArchiveFileName}'.") + .ConfigureAwait(false); + } + } +} diff --git a/src/ModSync.Core/CLI/FomodConsoleWizard.cs b/src/ModSync.Core/CLI/FomodConsoleWizard.cs new file mode 100644 index 00000000..0c0325f2 --- /dev/null +++ b/src/ModSync.Core/CLI/FomodConsoleWizard.cs @@ -0,0 +1,186 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using JetBrains.Annotations; + +using ModSync.Core.Services.Fomod; + +namespace ModSync.Core.CLI +{ + /// + /// Full terminal FOMOD wizard using the shared presenter rules. + /// + public static class FomodConsoleWizard + { + [CanBeNull] + public static ModComponent Run([NotNull] string extractedArchiveDirectory, [CanBeNull] string componentDisplayName) + { + string moduleConfigPath = FomodArchiveDiscovery.FindModuleConfigPath(extractedArchiveDirectory); + if (moduleConfigPath is null) + { + Console.Error.WriteLine("No fomod/ModuleConfig.xml was found in the extracted archive."); + return null; + } + + FomodModuleConfig config = FomodParser.ParseModuleConfigXmlFile(moduleConfigPath); + FomodInfo info = null; + string infoPath = FomodArchiveDiscovery.FindInfoPath(extractedArchiveDirectory); + if (infoPath != null) + { + info = FomodParser.ParseInfoXmlFile(infoPath); + } + + string archiveFileName = Path.GetFileName(extractedArchiveDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + ".zip"; + ModComponent component = FomodToComponentMapper.Map(info, config, archiveFileName); + FomodInstallerSession session = FomodInstallerPresenter.CreateSession(config, component); + + string title = string.IsNullOrWhiteSpace(componentDisplayName) + ? "FOMOD Installer" + : $"FOMOD Installer — {componentDisplayName}"; + Console.WriteLine(); + Console.WriteLine(title); + Console.WriteLine(new string('=', Math.Min(title.Length, 72))); + + IReadOnlyList visibleStepIndices = FomodInstallerPresenter.GetVisibleStepIndices(session); + int visibleCursor = 0; + + while (visibleCursor < visibleStepIndices.Count) + { + int stepIndex = visibleStepIndices[visibleCursor]; + FomodInstallerStepModel step = session.Steps[stepIndex]; + + Console.WriteLine(); + Console.WriteLine($"Step {visibleCursor + 1} of {visibleStepIndices.Count}: {step.Name}"); + Console.WriteLine(new string('-', 40)); + + for (int groupIndex = 0; groupIndex < step.Groups.Count; groupIndex++) + { + FomodInstallerGroupModel group = step.Groups[groupIndex]; + Console.WriteLine(); + Console.WriteLine(group.Name + " (" + DescribeGroupType(group.GroupType) + ")"); + + for (int pluginIndex = 0; pluginIndex < group.Plugins.Count; pluginIndex++) + { + FomodInstallerPluginModel plugin = group.Plugins[pluginIndex]; + string marker = plugin.IsSelected ? "[x]" : "[ ]"; + string required = plugin.IsRequired ? " (required)" : string.Empty; + Console.WriteLine($" {pluginIndex + 1}. {marker} {plugin.Name}{required}"); + if (!string.IsNullOrWhiteSpace(plugin.Description)) + { + Console.WriteLine($" {plugin.Description}"); + } + } + + if (group.GroupType == FomodGroupType.SelectExactlyOne + || group.GroupType == FomodGroupType.SelectAtMostOne) + { + Console.Write($"Select option number for '{group.Name}' (0 to clear): "); + if (!TryReadSelection(group.Plugins.Count, out int selection)) + { + return null; + } + + for (int pluginIndex = 0; pluginIndex < group.Plugins.Count; pluginIndex++) + { + bool selected = selection == pluginIndex + 1; + FomodInstallerPresenter.TrySetPluginSelected(session, stepIndex, groupIndex, pluginIndex, selected); + } + } + else + { + for (int pluginIndex = 0; pluginIndex < group.Plugins.Count; pluginIndex++) + { + FomodInstallerPluginModel plugin = group.Plugins[pluginIndex]; + if (plugin.IsRequired) + { + FomodInstallerPresenter.TrySetPluginSelected(session, stepIndex, groupIndex, pluginIndex, true); + continue; + } + + Console.Write($"Include '{plugin.Name}'? [y/N]: "); + string response = Console.ReadLine()?.Trim(); + bool selected = string.Equals(response, "y", StringComparison.OrdinalIgnoreCase) + || string.Equals(response, "yes", StringComparison.OrdinalIgnoreCase); + FomodInstallerPresenter.TrySetPluginSelected(session, stepIndex, groupIndex, pluginIndex, selected); + } + } + } + + visibleStepIndices = FomodInstallerPresenter.GetVisibleStepIndices(session); + string validationMessage = FomodInstallerPresenter.ValidateStep(session, stepIndex); + if (!string.IsNullOrEmpty(validationMessage)) + { + Console.WriteLine(); + Console.WriteLine("Validation: " + validationMessage); + continue; + } + + if (visibleCursor < visibleStepIndices.Count - 1) + { + Console.WriteLine(); + Console.Write("Press Enter for next step, or type 'back' to revise: "); + string nav = Console.ReadLine()?.Trim(); + if (string.Equals(nav, "back", StringComparison.OrdinalIgnoreCase)) + { + if (visibleCursor > 0) + { + visibleCursor--; + } + + visibleStepIndices = FomodInstallerPresenter.GetVisibleStepIndices(session); + continue; + } + } + + visibleCursor++; + visibleStepIndices = FomodInstallerPresenter.GetVisibleStepIndices(session); + } + + FomodInstallerPresenter.ApplySelectionsToComponent(session); + return component; + } + + private static bool TryReadSelection(int maxOption, out int selection) + { + selection = 0; + string line = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(line)) + { + return true; + } + + if (!int.TryParse(line, out int value) || value < 0 || value > maxOption) + { + Console.Error.WriteLine("Invalid selection."); + return false; + } + + selection = value; + return true; + } + + [NotNull] + private static string DescribeGroupType(FomodGroupType groupType) + { + switch (groupType) + { + case FomodGroupType.SelectExactlyOne: + return "pick one"; + case FomodGroupType.SelectAtMostOne: + return "pick at most one"; + case FomodGroupType.SelectAtLeastOne: + return "pick at least one"; + case FomodGroupType.SelectAll: + return "all required"; + default: + return "optional"; + } + } + } +} diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index 3b558243..7fe25c6d 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -17,6 +17,7 @@ using ModSync.Core.Services; using ModSync.Core.Services.Download; +using ModSync.Core.Services.Fomod; using ModSync.Core.Services.Validation; using ModSync.Core.Utility; @@ -179,6 +180,8 @@ private static void EnsureConfigInitialized() s_config.kpatcherExecutablePath = settings.KPatcherExecutablePath; } + s_fomodPostDownloadMode = settings.FomodPostDownloadMode; + Logger.LogVerbose("Settings loaded successfully from settings.json"); } } @@ -346,8 +349,13 @@ private sealed class SettingsData [JsonProperty("kpatcherExecutablePath")] public string KPatcherExecutablePath { get; set; } + + [JsonProperty("fomodPostDownloadMode")] + public string FomodPostDownloadMode { get; set; } } + private static string s_fomodPostDownloadMode; + public class BaseOptions { [Option('v', "verbose", Required = false, HelpText = "Enable verbose output for debugging.")] @@ -355,6 +363,18 @@ public class BaseOptions [Option("plaintext", Required = false, HelpText = "Use plaintext output instead of fancy ANSI progress display.")] public bool PlainText { get; set; } + + [Option("fomod-skip", Required = false, HelpText = "Skip FOMOD post-download configuration and mark archives dismissed for this run.")] + public bool FomodSkip { get; set; } + + [Option("fomod-choices", Required = false, HelpText = "Path to JSON file with FOMOD plugin selections for non-interactive runs.")] + public string FomodChoices { get; set; } + + [Option("interactive", Required = false, HelpText = "Force interactive terminal prompts even when I/O is redirected.")] + public bool Interactive { get; set; } + + [Option("non-interactive", Required = false, HelpText = "Force non-interactive FOMOD behavior (warn-continue or choices file).")] + public bool NonInteractive { get; set; } } [Verb("convert", HelpText = "Convert between formats or merge instruction sets, output to stdout or file")] @@ -959,6 +979,27 @@ void WriteOutput(string message) } [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MA0051:Method is too long", Justification = "")] + private static async Task RunFomodPostDownloadHookAsync( + [NotNull] List components, + [NotNull] string modDirectory, + [NotNull] BaseOptions opts) + { + if (components is null || components.Count == 0 || string.IsNullOrWhiteSpace(modDirectory)) + { + return; + } + + FomodPostDownloadOptions fomodOptions = FomodPostDownloadOptionsResolver.Resolve( + opts.FomodSkip, + opts.FomodChoices, + opts.Interactive, + opts.NonInteractive, + s_fomodPostDownloadMode); + + IFomodPostDownloadHost host = FomodPostDownloadOptionsResolver.CreateHost(fomodOptions); + await FomodPostDownloadOrchestrator.ProcessAsync(components, modDirectory, host).ConfigureAwait(false); + } + private static async Task DownloadAllModFilesAsync(List components, string destinationDirectory, bool verbose, bool sequential = true, CancellationToken cancellationToken = default) { int componentCount = components.Count(c => c.ResourceRegistry != null && c.ResourceRegistry.Count > 0); @@ -2024,6 +2065,11 @@ private static async Task RunConvertAsync(ConvertOptions opts) ApplySelectionFilters(components, opts.Select); + if (opts.Download && !string.IsNullOrWhiteSpace(opts.SourcePath)) + { + await RunFomodPostDownloadHookAsync(components, opts.SourcePath, opts).ConfigureAwait(false); + } + // Apply spoiler-free content if provided if (!string.IsNullOrEmpty(opts.SpoilerFreePath)) { @@ -2442,6 +2488,11 @@ private static async Task RunMergeAsync(MergeOptions opts) ApplySelectionFilters(components, opts.Select); + if (opts.Download && !string.IsNullOrWhiteSpace(opts.SourcePath)) + { + await RunFomodPostDownloadHookAsync(components, opts.SourcePath, opts).ConfigureAwait(false); + } + // Apply spoiler-free content if provided if (!string.IsNullOrEmpty(opts.SpoilerFreePath)) { @@ -3058,6 +3109,7 @@ await Logger.LogWarningAsync( } LogAllErrors(s_globalDownloadCache, forceConsoleOutput: true); + await RunFomodPostDownloadHookAsync(components, sourceDir, opts).ConfigureAwait(false); } int selectedCount = components.Count(c => c.IsSelected); diff --git a/src/ModSync.Core/Services/Fomod/FomodArchiveExtractService.cs b/src/ModSync.Core/Services/Fomod/FomodArchiveExtractService.cs new file mode 100644 index 00000000..ca5940f5 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodArchiveExtractService.cs @@ -0,0 +1,60 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using JetBrains.Annotations; + +using ModSync.Core.Services.FileSystem; + +namespace ModSync.Core.Services.Fomod +{ + public static class FomodArchiveExtractService + { + [CanBeNull] + public static async Task ExtractAsync( + [NotNull] string archivePath, + [NotNull] string modDirectory, + CancellationToken cancellationToken = default) + { + if (archivePath is null) + { + throw new ArgumentNullException(nameof(archivePath)); + } + + if (modDirectory is null) + { + throw new ArgumentNullException(nameof(modDirectory)); + } + + string extractFolderName = Path.GetFileNameWithoutExtension(archivePath); + string extractedDirectory = Path.Combine(modDirectory, extractFolderName); + + try + { + var fileSystemProvider = new RealFileSystemProvider(); + _ = await fileSystemProvider.ExtractArchiveAsync(archivePath, modDirectory).ConfigureAwait(false); + + if (FomodArchiveDiscovery.FindModuleConfigPath(extractedDirectory) != null) + { + return extractedDirectory; + } + + await Logger.LogWarningAsync( + $"[FomodPostDownload] Extracted '{archivePath}' but no fomod/ModuleConfig.xml found under '{extractedDirectory}'.") + .ConfigureAwait(false); + } + catch (Exception ex) + { + await Logger.LogExceptionAsync(ex, $"[FomodPostDownload] Failed to extract '{archivePath}'") + .ConfigureAwait(false); + } + + return null; + } + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodChoicesApplier.cs b/src/ModSync.Core/Services/Fomod/FomodChoicesApplier.cs new file mode 100644 index 00000000..f1289bde --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodChoicesApplier.cs @@ -0,0 +1,127 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using JetBrains.Annotations; + +using Newtonsoft.Json; + +namespace ModSync.Core.Services.Fomod +{ + public static class FomodChoicesApplier + { + [NotNull] + public static ModComponent ApplyChoices( + [NotNull] string extractedArchiveDirectory, + [NotNull] FomodArchiveChoices archiveChoices) + { + string moduleConfigPath = FomodArchiveDiscovery.FindModuleConfigPath(extractedArchiveDirectory); + if (moduleConfigPath is null) + { + throw new InvalidOperationException("No fomod/ModuleConfig.xml found in extracted archive."); + } + + FomodModuleConfig config = FomodParser.ParseModuleConfigXmlFile(moduleConfigPath); + FomodInfo info = null; + string infoPath = FomodArchiveDiscovery.FindInfoPath(extractedArchiveDirectory); + if (infoPath != null) + { + info = FomodParser.ParseInfoXmlFile(infoPath); + } + + string archiveFileName = archiveChoices.ArchiveFileName + ?? Path.GetFileName(extractedArchiveDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + ".zip"; + + ModComponent component = FomodToComponentMapper.Map(info, config, archiveFileName); + FomodInstallerSession session = FomodInstallerPresenter.CreateSession(config, component); + + foreach (FomodGroupSelection selection in archiveChoices.Selections ?? Enumerable.Empty()) + { + int stepIndex = FindStepIndex(session, selection.StepName); + int groupIndex = FindGroupIndex(session, stepIndex, selection.GroupName); + FomodInstallerGroupModel group = session.Steps[stepIndex].Groups[groupIndex]; + + var selectedNames = new HashSet( + selection.Plugins ?? new List(), + StringComparer.OrdinalIgnoreCase); + + for (int pluginIndex = 0; pluginIndex < group.Plugins.Count; pluginIndex++) + { + FomodInstallerPluginModel plugin = group.Plugins[pluginIndex]; + bool shouldSelect = selectedNames.Contains(plugin.Name); + if (!FomodInstallerPresenter.TrySetPluginSelected(session, stepIndex, groupIndex, pluginIndex, shouldSelect)) + { + throw new InvalidOperationException( + $"Cannot apply selection for required plugin '{plugin.Name}' in group '{group.Name}'."); + } + } + + string validation = FomodInstallerPresenter.ValidateStep(session, stepIndex); + if (!string.IsNullOrEmpty(validation)) + { + throw new InvalidOperationException(validation); + } + } + + FomodInstallerPresenter.ApplySelectionsToComponent(session); + return component; + } + + [NotNull] + public static FomodChoicesFile LoadFromFile([NotNull] string path) + { + string json = File.ReadAllText(path); + FomodChoicesFile choices = JsonConvert.DeserializeObject(json); + if (choices is null) + { + throw new InvalidOperationException($"FOMOD choices file '{path}' is empty or invalid."); + } + + return choices; + } + + [CanBeNull] + public static FomodArchiveChoices FindArchiveChoices( + [NotNull] FomodChoicesFile choicesFile, + [NotNull] string archiveFileName) + { + return choicesFile.Archives?.FirstOrDefault( + archive => string.Equals(archive.ArchiveFileName, archiveFileName, StringComparison.OrdinalIgnoreCase)); + } + + private static int FindStepIndex([NotNull] FomodInstallerSession session, [CanBeNull] string stepName) + { + for (int index = 0; index < session.Steps.Count; index++) + { + if (string.Equals(session.Steps[index].Name, stepName, StringComparison.OrdinalIgnoreCase)) + { + return index; + } + } + + throw new InvalidOperationException($"FOMOD step '{stepName}' was not found."); + } + + private static int FindGroupIndex( + [NotNull] FomodInstallerSession session, + int stepIndex, + [CanBeNull] string groupName) + { + IReadOnlyList groups = session.Steps[stepIndex].Groups; + for (int index = 0; index < groups.Count; index++) + { + if (string.Equals(groups[index].Name, groupName, StringComparison.OrdinalIgnoreCase)) + { + return index; + } + } + + throw new InvalidOperationException($"FOMOD group '{groupName}' was not found in step '{session.Steps[stepIndex].Name}'."); + } + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodChoicesFile.cs b/src/ModSync.Core/Services/Fomod/FomodChoicesFile.cs new file mode 100644 index 00000000..7ad7d499 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodChoicesFile.cs @@ -0,0 +1,40 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System.Collections.Generic; + +using Newtonsoft.Json; + +namespace ModSync.Core.Services.Fomod +{ + public sealed class FomodChoicesFile + { + [JsonProperty("version")] + public int Version { get; set; } = 1; + + [JsonProperty("archives")] + public List Archives { get; set; } = new List(); + } + + public sealed class FomodArchiveChoices + { + [JsonProperty("archiveFileName")] + public string ArchiveFileName { get; set; } + + [JsonProperty("selections")] + public List Selections { get; set; } = new List(); + } + + public sealed class FomodGroupSelection + { + [JsonProperty("stepName")] + public string StepName { get; set; } + + [JsonProperty("groupName")] + public string GroupName { get; set; } + + [JsonProperty("plugins")] + public List Plugins { get; set; } = new List(); + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs b/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs new file mode 100644 index 00000000..c784c6eb --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs @@ -0,0 +1,71 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +using JetBrains.Annotations; + +namespace ModSync.Core.Services.Fomod +{ + public sealed class FomodChoicesFileHost : IFomodPostDownloadHost + { + [NotNull] + private readonly FomodChoicesFile _choicesFile; + + public FomodChoicesFileHost([NotNull] FomodChoicesFile choicesFile) + { + _choicesFile = choicesFile ?? throw new ArgumentNullException(nameof(choicesFile)); + } + + public Task AskConfigureAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + FomodArchiveChoices archiveChoices = FomodChoicesApplier.FindArchiveChoices(_choicesFile, context.ArchiveFileName); + if (archiveChoices is null) + { + return Task.FromResult(FomodConfigurePromptResult.AlreadyHandled); + } + + return Task.FromResult(FomodConfigurePromptResult.Configure); + } + + public Task RunWizardAsync( + string extractedArchiveDirectory, + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + FomodArchiveChoices archiveChoices = FomodChoicesApplier.FindArchiveChoices(_choicesFile, context.ArchiveFileName); + if (archiveChoices is null) + { + return Task.FromResult(null); + } + + try + { + ModComponent configured = FomodChoicesApplier.ApplyChoices(extractedArchiveDirectory, archiveChoices); + return Task.FromResult(configured); + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Failed to apply FOMOD choices for '{context.ArchiveFileName}': {ex.Message}"); + return Task.FromResult(null); + } + } + + public Task ReportExtractFailureAsync( + FomodPromptContext context, + string message, + CancellationToken cancellationToken = default) + { + Console.Error.WriteLine(message); + return Task.CompletedTask; + } + + public Task ReportConfiguredAsync(FomodPromptContext context, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs b/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs index 81b6abb1..710d68d6 100644 --- a/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs +++ b/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using JetBrains.Annotations; @@ -15,9 +16,12 @@ namespace ModSync.Core.Services.Fomod /// public static class FomodConfiguredComponentMerger { + private const string ModDirectoryPlaceholder = "<>"; + public static void MergeInto( [NotNull] ModComponent target, - [NotNull] ModComponent configured) + [NotNull] ModComponent configured, + [NotNull] string archiveFileName) { if (target is null) { @@ -29,16 +33,33 @@ public static void MergeInto( throw new ArgumentNullException(nameof(configured)); } - var existingOptionGuids = new HashSet(target.Options.Select(option => option.Guid)); - foreach (Option option in configured.Options) + if (string.IsNullOrWhiteSpace(archiveFileName)) + { + throw new ArgumentException("Archive file name cannot be null or whitespace.", nameof(archiveFileName)); + } + + string archiveFolder = Path.GetFileNameWithoutExtension(archiveFileName); + string archivePathPrefix = ModDirectoryPlaceholder + "/" + archiveFolder + "/"; + + var configuredOptionGuids = new HashSet(configured.Options.Select(option => option.Guid)); + RemovePriorFomodInstructions(target, archivePathPrefix, configuredOptionGuids); + + foreach (Option configuredOption in configured.Options) { - if (existingOptionGuids.Contains(option.Guid)) + Option existing = target.Options.FirstOrDefault(option => option.Guid == configuredOption.Guid); + if (existing is null) { + target.Options.Add(configuredOption); continue; } - target.Options.Add(option); - existingOptionGuids.Add(option.Guid); + existing.IsSelected = configuredOption.IsSelected; + existing.Instructions.Clear(); + foreach (Instruction instruction in configuredOption.Instructions) + { + instruction.SetParentComponent(target); + existing.Instructions.Add(instruction); + } } foreach (Instruction instruction in configured.Instructions) @@ -47,5 +68,73 @@ public static void MergeInto( target.Instructions.Add(instruction); } } + + private static void RemovePriorFomodInstructions( + [NotNull] ModComponent target, + [NotNull] string archivePathPrefix, + [NotNull] HashSet configuredOptionGuids) + { + RemoveMatchingInstructions(target.Instructions, archivePathPrefix, configuredOptionGuids); + + foreach (Option option in target.Options) + { + RemoveMatchingInstructions(option.Instructions, archivePathPrefix, configuredOptionGuids); + } + } + + private static void RemoveMatchingInstructions( + [NotNull] System.Collections.ObjectModel.ObservableCollection instructions, + [NotNull] string archivePathPrefix, + [NotNull] HashSet configuredOptionGuids) + { + for (int index = instructions.Count - 1; index >= 0; index--) + { + Instruction instruction = instructions[index]; + if (InstructionReferencesArchive(instruction, archivePathPrefix) + || IsChooseForConfiguredOptions(instruction, configuredOptionGuids)) + { + instructions.RemoveAt(index); + } + } + } + + private static bool InstructionReferencesArchive([NotNull] Instruction instruction, [NotNull] string archivePathPrefix) + { + if (instruction.Source is null) + { + return false; + } + + foreach (string source in instruction.Source) + { + if (!string.IsNullOrEmpty(source) + && source.StartsWith(archivePathPrefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool IsChooseForConfiguredOptions( + [NotNull] Instruction instruction, + [NotNull] HashSet configuredOptionGuids) + { + if (instruction.Action != Instruction.ActionType.Choose || instruction.Source is null || instruction.Source.Count == 0) + { + return false; + } + + foreach (string source in instruction.Source) + { + if (!Guid.TryParse(source, out Guid optionGuid) || !configuredOptionGuids.Contains(optionGuid)) + { + return false; + } + } + + return true; + } } } diff --git a/src/ModSync.Core/Services/Fomod/FomodDownloadedArchivePaths.cs b/src/ModSync.Core/Services/Fomod/FomodDownloadedArchivePaths.cs new file mode 100644 index 00000000..5dbd8097 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodDownloadedArchivePaths.cs @@ -0,0 +1,54 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using JetBrains.Annotations; + +using ModSync.Core.Utility; + +namespace ModSync.Core.Services.Fomod +{ + public static class FomodDownloadedArchivePaths + { + [ItemNotNull] + public static IEnumerable GetPaths( + [NotNull] ModComponent component, + [NotNull] string modDirectory) + { + if (component.ResourceRegistry is null || component.ResourceRegistry.Count == 0) + { + yield break; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ResourceMetadata resource in component.ResourceRegistry.Values) + { + if (resource?.Files is null) + { + continue; + } + + foreach (string fileName in resource.Files.Keys) + { + if (string.IsNullOrWhiteSpace(fileName) || !seen.Add(fileName)) + { + continue; + } + + string filePath = Path.Combine(modDirectory, fileName); + if (!File.Exists(filePath) || !ArchiveHelper.IsArchive(filePath)) + { + continue; + } + + yield return filePath; + } + } + } + } +} diff --git a/src/ModSync.GUI/Services/FomodInstallerPresenter.cs b/src/ModSync.Core/Services/Fomod/FomodInstallerPresenter.cs similarity index 98% rename from src/ModSync.GUI/Services/FomodInstallerPresenter.cs rename to src/ModSync.Core/Services/Fomod/FomodInstallerPresenter.cs index da202ecd..6c921dac 100644 --- a/src/ModSync.GUI/Services/FomodInstallerPresenter.cs +++ b/src/ModSync.Core/Services/Fomod/FomodInstallerPresenter.cs @@ -8,10 +8,7 @@ using JetBrains.Annotations; -using ModSync.Core; -using ModSync.Core.Services.Fomod; - -namespace ModSync.Services +namespace ModSync.Core.Services.Fomod { /// /// Headless FOMOD installer wizard state: maps parsed config to component options, @@ -241,8 +238,10 @@ public static void ApplySelectionsToComponent([NotNull] FomodInstallerSession se option.IsSelected = false; } - foreach (FomodInstallerStepModel step in session.Steps) + IReadOnlyList visibleStepIndices = GetVisibleStepIndices(session); + foreach (int stepIndex in visibleStepIndices) { + FomodInstallerStepModel step = session.Steps[stepIndex]; foreach (FomodInstallerGroupModel group in step.Groups) { foreach (FomodInstallerPluginModel plugin in group.Plugins.Where(plugin => plugin.IsSelected)) diff --git a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptions.cs b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptions.cs new file mode 100644 index 00000000..d80ab274 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptions.cs @@ -0,0 +1,25 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +namespace ModSync.Core.Services.Fomod +{ + public enum FomodPostDownloadMode + { + Interactive, + WarnContinue, + SkipAll, + ApplyChoicesFile, + } + + public sealed class FomodPostDownloadOptions + { + public FomodPostDownloadMode Mode { get; set; } = FomodPostDownloadMode.WarnContinue; + + public string ChoicesFilePath { get; set; } + + public bool ForceInteractive { get; set; } + + public bool ForceNonInteractive { get; set; } + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs new file mode 100644 index 00000000..c1776271 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs @@ -0,0 +1,97 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.IO; + +using ModSync.Core.CLI; + +namespace ModSync.Core.Services.Fomod +{ + public static class FomodPostDownloadOptionsResolver + { + public const string EnvChoicesPath = "MODSYNC_FOMOD_CHOICES"; + + public const string EnvPostDownloadMode = "MODSYNC_FOMOD_POST_DOWNLOAD_MODE"; + + public const string SettingsKey = "fomodPostDownloadMode"; + + public static FomodPostDownloadOptions Resolve( + bool fomodSkip, + string fomodChoicesPath, + bool forceInteractive, + bool forceNonInteractive, + string settingsMode) + { + if (fomodSkip) + { + return new FomodPostDownloadOptions { Mode = FomodPostDownloadMode.SkipAll }; + } + + string choicesPath = !string.IsNullOrWhiteSpace(fomodChoicesPath) + ? fomodChoicesPath + : Environment.GetEnvironmentVariable(EnvChoicesPath); + + if (!string.IsNullOrWhiteSpace(choicesPath)) + { + return new FomodPostDownloadOptions + { + Mode = FomodPostDownloadMode.ApplyChoicesFile, + ChoicesFilePath = choicesPath, + ForceInteractive = forceInteractive, + ForceNonInteractive = forceNonInteractive, + }; + } + + string mode = !string.IsNullOrWhiteSpace(settingsMode) + ? settingsMode + : Environment.GetEnvironmentVariable(EnvPostDownloadMode); + + if (string.Equals(mode, "skip", StringComparison.OrdinalIgnoreCase)) + { + return new FomodPostDownloadOptions { Mode = FomodPostDownloadMode.SkipAll }; + } + + if (ConsoleInteractionCapabilities.IsInteractiveTerminal(forceInteractive, forceNonInteractive)) + { + return new FomodPostDownloadOptions + { + Mode = FomodPostDownloadMode.Interactive, + ForceInteractive = forceInteractive, + ForceNonInteractive = forceNonInteractive, + }; + } + + return new FomodPostDownloadOptions + { + Mode = FomodPostDownloadMode.WarnContinue, + ForceInteractive = forceInteractive, + ForceNonInteractive = forceNonInteractive, + }; + } + + public static IFomodPostDownloadHost CreateHost(FomodPostDownloadOptions options) + { + switch (options.Mode) + { + case FomodPostDownloadMode.SkipAll: + return new FomodSkipPostDownloadHost(); + case FomodPostDownloadMode.ApplyChoicesFile: + if (string.IsNullOrWhiteSpace(options.ChoicesFilePath) || !File.Exists(options.ChoicesFilePath)) + { + throw new FileNotFoundException( + $"FOMOD choices file not found: '{options.ChoicesFilePath}'."); + } + + FomodChoicesFile choicesFile = FomodChoicesApplier.LoadFromFile(options.ChoicesFilePath); + return new FomodChoicesFileHost(choicesFile); + case FomodPostDownloadMode.Interactive: + return new FomodConsolePostDownloadHost(); + case FomodPostDownloadMode.WarnContinue: + default: + return new FomodWarnContinuePostDownloadHost(); + } + } + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs new file mode 100644 index 00000000..b787ed44 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs @@ -0,0 +1,100 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using JetBrains.Annotations; + +namespace ModSync.Core.Services.Fomod +{ + public static class FomodPostDownloadOrchestrator + { + public static async Task ProcessAsync( + [NotNull][ItemNotNull] IEnumerable components, + [NotNull] string modDirectory, + [NotNull] IFomodPostDownloadHost host, + CancellationToken cancellationToken = default) + { + if (components is null) + { + throw new ArgumentNullException(nameof(components)); + } + + if (string.IsNullOrWhiteSpace(modDirectory)) + { + throw new ArgumentException("Mod directory cannot be null or whitespace.", nameof(modDirectory)); + } + + if (host is null) + { + throw new ArgumentNullException(nameof(host)); + } + + foreach (ModComponent component in components.Where(c => c.IsSelected)) + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (string archivePath in FomodDownloadedArchivePaths.GetPaths(component, modDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string archiveFileName = System.IO.Path.GetFileName(archivePath); + if (!FomodArchiveProbe.TryDetectInArchive(archivePath, out _)) + { + continue; + } + + if (!FomodDownloadPromptState.ShouldPrompt(component, archiveFileName)) + { + continue; + } + + var context = new FomodPromptContext(component, archivePath, archiveFileName); + FomodConfigurePromptResult promptResult = await host.AskConfigureAsync(context, cancellationToken) + .ConfigureAwait(false); + + if (promptResult == FomodConfigurePromptResult.Dismiss) + { + FomodDownloadPromptState.MarkDismissed(component, archiveFileName); + continue; + } + + if (promptResult == FomodConfigurePromptResult.AlreadyHandled) + { + continue; + } + + string extractedDirectory = await FomodArchiveExtractService + .ExtractAsync(archivePath, modDirectory, cancellationToken) + .ConfigureAwait(false); + + if (extractedDirectory is null) + { + await host.ReportExtractFailureAsync( + context, + $"Failed to extract '{archiveFileName}' for FOMOD configuration.", + cancellationToken).ConfigureAwait(false); + continue; + } + + ModComponent configured = await host.RunWizardAsync(extractedDirectory, context, cancellationToken) + .ConfigureAwait(false); + + if (configured is null) + { + continue; + } + + FomodConfiguredComponentMerger.MergeInto(component, configured, archiveFileName); + FomodDownloadPromptState.MarkConfigured(component, archiveFileName); + await host.ReportConfiguredAsync(context, cancellationToken).ConfigureAwait(false); + } + } + } + } +} diff --git a/src/ModSync.Core/Services/Fomod/FomodPromptContext.cs b/src/ModSync.Core/Services/Fomod/FomodPromptContext.cs new file mode 100644 index 00000000..2ce0d7f0 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodPromptContext.cs @@ -0,0 +1,37 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using JetBrains.Annotations; + +namespace ModSync.Core.Services.Fomod +{ + public sealed class FomodPromptContext + { + [NotNull] + public ModComponent Component { get; } + + [NotNull] + public string ArchivePath { get; } + + [NotNull] + public string ArchiveFileName { get; } + + public FomodPromptContext( + [NotNull] ModComponent component, + [NotNull] string archivePath, + [NotNull] string archiveFileName) + { + Component = component ?? throw new System.ArgumentNullException(nameof(component)); + ArchivePath = archivePath ?? throw new System.ArgumentNullException(nameof(archivePath)); + ArchiveFileName = archiveFileName ?? throw new System.ArgumentNullException(nameof(archiveFileName)); + } + } + + public enum FomodConfigurePromptResult + { + Dismiss, + Configure, + AlreadyHandled, + } +} diff --git a/src/ModSync.Core/Services/Fomod/IFomodPostDownloadHost.cs b/src/ModSync.Core/Services/Fomod/IFomodPostDownloadHost.cs new file mode 100644 index 00000000..92dd0278 --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/IFomodPostDownloadHost.cs @@ -0,0 +1,33 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System.Threading; +using System.Threading.Tasks; + +using JetBrains.Annotations; + +namespace ModSync.Core.Services.Fomod +{ + public interface IFomodPostDownloadHost + { + Task AskConfigureAsync( + [NotNull] FomodPromptContext context, + CancellationToken cancellationToken = default); + + [CanBeNull] + Task RunWizardAsync( + [NotNull] string extractedArchiveDirectory, + [NotNull] FomodPromptContext context, + CancellationToken cancellationToken = default); + + Task ReportExtractFailureAsync( + [NotNull] FomodPromptContext context, + [NotNull] string message, + CancellationToken cancellationToken = default); + + Task ReportConfiguredAsync( + [NotNull] FomodPromptContext context, + CancellationToken cancellationToken = default); + } +} diff --git a/src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs b/src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs index 6a71a4bb..4ea56f25 100644 --- a/src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs +++ b/src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs @@ -16,7 +16,7 @@ using ModSync.Core; using ModSync.Core.Services.Fomod; -using ModSync.Services; +using ModSync.Core.Services.Fomod; namespace ModSync.Dialogs { diff --git a/src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs b/src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs new file mode 100644 index 00000000..02edeae5 --- /dev/null +++ b/src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs @@ -0,0 +1,71 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Controls; + +using JetBrains.Annotations; + +using ModSync.Core; +using ModSync.Core.Services.Fomod; +using ModSync.Dialogs; + +namespace ModSync.Services +{ + public sealed class FomodGuiPostDownloadHost : IFomodPostDownloadHost + { + [NotNull] + private readonly Window _parentWindow; + + public FomodGuiPostDownloadHost([NotNull] Window parentWindow) + { + _parentWindow = parentWindow ?? throw new ArgumentNullException(nameof(parentWindow)); + } + + public async Task AskConfigureAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + bool? configure = await ConfirmationDialog.ShowConfirmationDialogAsync( + _parentWindow, + $"A FOMOD installer was detected in '{context.ArchiveFileName}' for mod '{context.Component.Name}'." + + Environment.NewLine + + Environment.NewLine + + "Configure installer options now?" + + Environment.NewLine + + Environment.NewLine + + "Choose No to skip for now. You can still use Mod Management → Configure FOMOD Mod later.") + .ConfigureAwait(true); + + if (configure == true) + { + return FomodConfigurePromptResult.Configure; + } + + return FomodConfigurePromptResult.Dismiss; + } + + public Task RunWizardAsync( + string extractedArchiveDirectory, + FomodPromptContext context, + CancellationToken cancellationToken = default) => + FomodInstallerDialog.ShowForExtractedArchiveAsync(_parentWindow, extractedArchiveDirectory); + + public Task ReportExtractFailureAsync( + FomodPromptContext context, + string message, + CancellationToken cancellationToken = default) => + InformationDialog.ShowInformationDialogAsync(_parentWindow, message); + + public Task ReportConfiguredAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) => + InformationDialog.ShowInformationDialogAsync( + _parentWindow, + $"FOMOD configuration applied to '{context.Component.Name}' from '{context.ArchiveFileName}'."); + } +} diff --git a/src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs b/src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs index da548124..33054d3e 100644 --- a/src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs +++ b/src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs @@ -4,8 +4,6 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; @@ -13,157 +11,19 @@ using JetBrains.Annotations; using ModSync.Core; -using ModSync.Core.Services.FileSystem; using ModSync.Core.Services.Fomod; -using ModSync.Core.Utility; -using ModSync.Dialogs; namespace ModSync.Services { public static class FomodPostDownloadPromptService { - public static async Task PromptForDetectedArchivesAsync( + public static Task PromptForDetectedArchivesAsync( [NotNull] Window parentWindow, [NotNull][ItemNotNull] IReadOnlyList components, - [NotNull] string modDirectory) - { - if (parentWindow is null) - { - throw new ArgumentNullException(nameof(parentWindow)); - } - - if (components is null) - { - throw new ArgumentNullException(nameof(components)); - } - - if (string.IsNullOrWhiteSpace(modDirectory)) - { - throw new ArgumentException("Mod directory cannot be null or whitespace.", nameof(modDirectory)); - } - - foreach (ModComponent component in components.Where(c => c.IsSelected)) - { - foreach (string archivePath in GetDownloadedArchivePaths(component, modDirectory)) - { - string archiveFileName = Path.GetFileName(archivePath); - if (!FomodArchiveProbe.TryDetectInArchive(archivePath, out _)) - { - continue; - } - - if (!FomodDownloadPromptState.ShouldPrompt(component, archiveFileName)) - { - continue; - } - - bool? configure = await ConfirmationDialog.ShowConfirmationDialogAsync( - parentWindow, - $"A FOMOD installer was detected in '{archiveFileName}' for mod '{component.Name}'." - + Environment.NewLine - + Environment.NewLine - + "Configure installer options now?" - + Environment.NewLine - + Environment.NewLine - + "Choose No to skip for now. You can still use Mod Management → Configure FOMOD Mod later."); - - if (configure != true) - { - FomodDownloadPromptState.MarkDismissed(component, archiveFileName); - continue; - } - - string extractedDirectory = await ExtractArchiveAsync(archivePath, modDirectory).ConfigureAwait(true); - if (extractedDirectory is null) - { - await InformationDialog.ShowInformationDialogAsync( - parentWindow, - $"Failed to extract '{archiveFileName}' for FOMOD configuration."); - continue; - } - - ModComponent configured = await FomodInstallerDialog.ShowForExtractedArchiveAsync( - parentWindow, - extractedDirectory).ConfigureAwait(true); - - if (configured is null) - { - continue; - } - - FomodConfiguredComponentMerger.MergeInto(component, configured); - FomodDownloadPromptState.MarkConfigured(component, archiveFileName); - - await InformationDialog.ShowInformationDialogAsync( - parentWindow, - $"FOMOD configuration applied to '{component.Name}' from '{archiveFileName}'."); - } - } - } - - [ItemNotNull] - private static IEnumerable GetDownloadedArchivePaths( - [NotNull] ModComponent component, - [NotNull] string modDirectory) - { - if (component.ResourceRegistry is null || component.ResourceRegistry.Count == 0) - { - yield break; - } - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (ResourceMetadata resource in component.ResourceRegistry.Values) - { - if (resource?.Files is null) - { - continue; - } - - foreach (string fileName in resource.Files.Keys) - { - if (string.IsNullOrWhiteSpace(fileName) || !seen.Add(fileName)) - { - continue; - } - - string filePath = Path.Combine(modDirectory, fileName); - if (!File.Exists(filePath) || !ArchiveHelper.IsArchive(filePath)) - { - continue; - } - - yield return filePath; - } - } - } - - [CanBeNull] - private static async Task ExtractArchiveAsync( - [NotNull] string archivePath, - [NotNull] string modDirectory) - { - string extractFolderName = Path.GetFileNameWithoutExtension(archivePath); - string extractedDirectory = Path.Combine(modDirectory, extractFolderName); - - try - { - var fileSystemProvider = new RealFileSystemProvider(); - _ = await fileSystemProvider.ExtractArchiveAsync(archivePath, modDirectory).ConfigureAwait(false); - - if (FomodArchiveDiscovery.FindModuleConfigPath(extractedDirectory) != null) - { - return extractedDirectory; - } - - await Logger.LogWarningAsync( - $"[FomodPostDownload] Extracted '{archivePath}' but no fomod/ModuleConfig.xml found under '{extractedDirectory}'."); - } - catch (Exception ex) - { - await Logger.LogExceptionAsync(ex, $"[FomodPostDownload] Failed to extract '{archivePath}'"); - } - - return null; - } + [NotNull] string modDirectory) => + FomodPostDownloadOrchestrator.ProcessAsync( + components, + modDirectory, + new FomodGuiPostDownloadHost(parentWindow)); } } diff --git a/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs b/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs new file mode 100644 index 00000000..3544a123 --- /dev/null +++ b/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs @@ -0,0 +1,40 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System.Linq; + +using ModSync.Core; +using ModSync.Core.Services.Fomod; +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public class FomodConfiguredComponentMergerTests + { + private const string ArchiveFileName = "ExampleMod.zip"; + + [Test] + public void MergeInto_Reconfigure_UpdatesExistingOptionSelection() + { + FomodModuleConfig config = FomodParser.ParseModuleConfigXml(FomodParserTests.RealisticModuleConfigXml); + ModComponent configured = FomodToComponentMapper.Map(null, config, ArchiveFileName); + FomodInstallerSession session = FomodInstallerPresenter.CreateSession(config, configured); + + FomodInstallerPresenter.TrySetPluginSelected(session, 0, 0, 0, true); + FomodInstallerPresenter.ApplySelectionsToComponent(session); + + var target = new ModComponent { Name = "Target" }; + FomodConfiguredComponentMerger.MergeInto(target, configured, ArchiveFileName); + + FomodInstallerPresenter.TrySetPluginSelected(session, 0, 0, 0, false); + FomodInstallerPresenter.TrySetPluginSelected(session, 0, 0, 1, true); + FomodInstallerPresenter.ApplySelectionsToComponent(session); + FomodConfiguredComponentMerger.MergeInto(target, configured, ArchiveFileName); + + Assert.That(target.Options.First(option => option.Name == "High Resolution").IsSelected, Is.False); + Assert.That(target.Options.First(option => option.Name == "Low Resolution").IsSelected, Is.True); + } + } +} diff --git a/src/ModSync.Tests/FomodInstallerPresenterTests.cs b/src/ModSync.Tests/FomodInstallerPresenterTests.cs index b2a03a05..1f91233d 100644 --- a/src/ModSync.Tests/FomodInstallerPresenterTests.cs +++ b/src/ModSync.Tests/FomodInstallerPresenterTests.cs @@ -7,7 +7,6 @@ using System.Linq; using ModSync.Core; using ModSync.Core.Services.Fomod; -using ModSync.Services; using NUnit.Framework; namespace ModSync.Tests @@ -127,5 +126,57 @@ public void GetVisibleStepIndices_HidesStepWhenVisibleDependencyFails() IReadOnlyList afterHigh = FomodInstallerPresenter.GetVisibleStepIndices(session); Assert.That(afterHigh, Is.EqualTo(new[] { 0, 1 })); } + + [Test] + public void ApplySelectionsToComponent_IgnoresSelectionsFromHiddenSteps() + { + FomodModuleConfig config = FomodParser.ParseModuleConfigXml(""" + + + + + + + + High + + + + + + + + + + + + + + + + + + + + + + + """); + + ModComponent component = FomodToComponentMapper.Map(null, config, ArchiveFileName); + FomodInstallerSession session = FomodInstallerPresenter.CreateSession(config, component); + + FomodInstallerPresenter.TrySetPluginSelected(session, 0, 0, 0, true); + FomodInstallerPresenter.TrySetPluginSelected(session, 1, 0, 0, true); + + FomodInstallerPresenter.TrySetPluginSelected(session, 0, 0, 0, false); + FomodInstallerPresenter.TrySetPluginSelected(session, 0, 0, 1, true); + + FomodInstallerPresenter.ApplySelectionsToComponent(session); + + Option extraOption = component.Options.First(option => option.Name == "Extra Files"); + Assert.That(extraOption.IsSelected, Is.False); + Assert.That(component.Options.First(option => option.Name == "Low").IsSelected, Is.True); + } } } diff --git a/src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs b/src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs new file mode 100644 index 00000000..452fb196 --- /dev/null +++ b/src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs @@ -0,0 +1,40 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using ModSync.Core.CLI; +using ModSync.Core.Services.Fomod; +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public class FomodPostDownloadOptionsResolverTests + { + [Test] + public void Resolve_FomodSkip_ReturnsSkipAll() + { + FomodPostDownloadOptions options = FomodPostDownloadOptionsResolver.Resolve( + fomodSkip: true, + fomodChoicesPath: null, + forceInteractive: false, + forceNonInteractive: false, + settingsMode: null); + + Assert.That(options.Mode, Is.EqualTo(FomodPostDownloadMode.SkipAll)); + } + + [Test] + public void Resolve_NonInteractiveDefault_ReturnsWarnContinue() + { + FomodPostDownloadOptions options = FomodPostDownloadOptionsResolver.Resolve( + fomodSkip: false, + fomodChoicesPath: null, + forceInteractive: false, + forceNonInteractive: true, + settingsMode: null); + + Assert.That(options.Mode, Is.EqualTo(FomodPostDownloadMode.WarnContinue)); + } + } +} diff --git a/src/ModSync.Tests/FomodPostDownloadOrchestratorTests.cs b/src/ModSync.Tests/FomodPostDownloadOrchestratorTests.cs new file mode 100644 index 00000000..f8031af2 --- /dev/null +++ b/src/ModSync.Tests/FomodPostDownloadOrchestratorTests.cs @@ -0,0 +1,121 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using ModSync.Core; +using ModSync.Core.Services.Fomod; +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public class FomodPostDownloadOrchestratorTests + { + private sealed class RecordingHost : IFomodPostDownloadHost + { + public List WarnedArchives { get; } = new List(); + + public List ConfiguredArchives { get; } = new List(); + + public FomodConfigurePromptResult NextPromptResult { get; set; } = FomodConfigurePromptResult.AlreadyHandled; + + public Task AskConfigureAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + WarnedArchives.Add(context.ArchiveFileName); + return Task.FromResult(NextPromptResult); + } + + public Task RunWizardAsync( + string extractedArchiveDirectory, + FomodPromptContext context, + CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task ReportExtractFailureAsync( + FomodPromptContext context, + string message, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task ReportConfiguredAsync( + FomodPromptContext context, + CancellationToken cancellationToken = default) + { + ConfiguredArchives.Add(context.ArchiveFileName); + return Task.CompletedTask; + } + } + + [Test] + public async Task ProcessAsync_SkipsDeselectedComponents() + { + var host = new RecordingHost(); + var component = new ModComponent { Name = "Hidden Mod", IsSelected = false }; + component.ResourceRegistry = new Dictionary + { + ["https://example.test/mod.zip"] = new ResourceMetadata + { + Files = new Dictionary { ["Example.zip"] = true }, + }, + }; + + await FomodPostDownloadOrchestrator.ProcessAsync( + new[] { component }, + TestContext.CurrentContext.WorkDirectory, + host).ConfigureAwait(false); + + Assert.That(host.WarnedArchives, Is.Empty); + } + + [Test] + public async Task ProcessAsync_Dismiss_MarksDismissed() + { + string tempDir = System.IO.Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(tempDir); + + string archivePath = System.IO.Path.Combine(tempDir, "fomod-test.zip"); + CreateZipWithFomod(archivePath); + + var host = new RecordingHost { NextPromptResult = FomodConfigurePromptResult.Dismiss }; + ModComponent component = BuildComponentWithArchive("fomod-test.zip"); + + await FomodPostDownloadOrchestrator.ProcessAsync(new[] { component }, tempDir, host).ConfigureAwait(false); + + Assert.That(FomodDownloadPromptState.GetStatus(component, "fomod-test.zip"), Is.EqualTo(FomodDownloadPromptState.StatusDismissed)); + } + + private static ModComponent BuildComponentWithArchive(string archiveFileName) + { + var component = new ModComponent { Name = "Test Mod", IsSelected = true }; + component.ResourceRegistry = new Dictionary + { + ["https://example.test/mod.zip"] = new ResourceMetadata + { + Files = new Dictionary { [archiveFileName] = true }, + HandlerMetadata = new Dictionary(), + }, + }; + return component; + } + + private static void CreateZipWithFomod(string archivePath) + { + string staging = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(archivePath), "staging"); + System.IO.Directory.CreateDirectory(System.IO.Path.Combine(staging, "fomod")); + System.IO.File.WriteAllText( + System.IO.Path.Combine(staging, "fomod", "ModuleConfig.xml"), + ""); + + System.IO.Compression.ZipFile.CreateFromDirectory(staging, archivePath); + System.IO.Directory.Delete(staging, true); + } + } +} From 495aa2e87484f8eba6cb9f5b28ddce89a3522ce6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 13 Jun 2026 05:04:27 -0500 Subject: [PATCH 4/7] feat: expose FOMOD CLI post-download mode in settings UI Wire fomodPostDownloadMode into AppSettings and the Download Settings picker so headless CLI behavior matches settings.json. Document the flow in download-system.md and refresh the parity living plan. --- docs/knowledgebase/download-system.md | 11 ++++ .../vortex-mo2-feature-parity-living-plan.md | 4 +- src/ModSync.GUI/Dialogs/SettingsDialog.axaml | 11 ++++ src/ModSync.GUI/Dialogs/SettingsDialog.cs | 51 +++++++++++++++++++ src/ModSync.GUI/Models/AppSettings.cs | 4 ++ src/ModSync.Tests/AppSettingsFomodTests.cs | 40 +++++++++++++++ 6 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/ModSync.Tests/AppSettingsFomodTests.cs diff --git a/docs/knowledgebase/download-system.md b/docs/knowledgebase/download-system.md index 6dace2ba..50fdd3b7 100644 --- a/docs/knowledgebase/download-system.md +++ b/docs/knowledgebase/download-system.md @@ -60,6 +60,17 @@ 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 post-download (CLI + GUI) + +`[REPO]` After downloads complete, selected components with FOMOD archives may be configured before validation/install: + +| Surface | Behavior | +|---------|----------| +| GUI | `DownloadOrchestrationService` → `FomodPostDownloadPromptService` → `FomodGuiPostDownloadHost` (installer dialog) | +| CLI `install -d` / `convert -d` / `merge -d` | `FomodPostDownloadOrchestrator` with TTY wizard, warn-continue, skip, or `--fomod-choices` / `MODSYNC_FOMOD_CHOICES` | + +**Settings** (`settings.json` key `fomodPostDownloadMode`): `warn-continue` (default) or `skip` — affects headless CLI only; GUI always prompts. Also: `--fomod-skip`, `--interactive` / `--non-interactive`, env `MODSYNC_FOMOD_POST_DOWNLOAD_MODE`. See [fomod-support.md](fomod-support.md) and [core-cli-reference.md](core-cli-reference.md). + ## Typical agent workflow 1. Clone **`./mod-builds`** at repo root if testing full builds — [mod-builds-sources.md](mod-builds-sources.md). diff --git a/docs/plans/vortex-mo2-feature-parity-living-plan.md b/docs/plans/vortex-mo2-feature-parity-living-plan.md index cca35045..dd3f8d43 100644 --- a/docs/plans/vortex-mo2-feature-parity-living-plan.md +++ b/docs/plans/vortex-mo2-feature-parity-living-plan.md @@ -20,7 +20,7 @@ Single authoritative tracker for parity work. Individual slice plans under | 3 | Profiles | Merged (#157) | | 4 | Managed deployment | **Merged** (#158 core); install wiring deferred | | 5 | File conflicts | Core #160 + GUI #165 merged | -| 6 | FOMOD | Parser + installer dialog merged (#166); archive hook in PR | +| 6 | FOMOD | Parser + installer dialog merged (#166); GUI + CLI post-download in PR #169 | | 7 | (roadmap tail) | Per slice plans | ## Delta update (2026-06-14) @@ -36,7 +36,7 @@ Single authoritative tracker for parity work. Individual slice plans under ### Partial - Deployment: `DeploymentService` not wired into install execution; no GUI toggle (see PR #168). -- FOMOD post-download archive discovery hook landing in current PR. +- FOMOD post-download: GUI dialog + CLI orchestrator (`--fomod-choices`, settings `fomodPostDownloadMode`) in PR #169. - Update checking: no endorsement UI; check results not persisted via `DownloadCacheService`. - Desktop validation skipped for FOMOD prompts and update badges (headless agent). diff --git a/src/ModSync.GUI/Dialogs/SettingsDialog.axaml b/src/ModSync.GUI/Dialogs/SettingsDialog.axaml index 72bc55b0..e389ec69 100644 --- a/src/ModSync.GUI/Dialogs/SettingsDialog.axaml +++ b/src/ModSync.GUI/Dialogs/SettingsDialog.axaml @@ -306,6 +306,17 @@ IsChecked="{Binding filterDownloadsByResolution}" ToolTip.Tip="When enabled, automatically skips downloading files that don't match your system's screen resolution. For example, if you have a 1920x1080 display, files with '3840x2160' in the name will be skipped. This saves bandwidth and disk space by only downloading resolution-specific files that match your display." /> + + + + + + ("FomodPostDownloadModeComboBox"); + if (modeComboBox == null) + { + return; + } + + Models.AppSettings appSettings = Models.SettingsManager.LoadSettings(); + _fomodPostDownloadMode = string.IsNullOrWhiteSpace(appSettings.FomodPostDownloadMode) + ? "warn-continue" + : appSettings.FomodPostDownloadMode; + + int selectedIndex = string.Equals( + _fomodPostDownloadMode, + "skip", + StringComparison.OrdinalIgnoreCase) + ? 1 + : 0; + modeComboBox.SelectedIndex = selectedIndex; + } + catch (Exception ex) + { + Logger.LogException(ex, "Failed to load FOMOD post-download settings"); + } + } + private void LoadFileEncodingSettings() { try @@ -781,6 +813,8 @@ private async Task SaveAppSettingsAsync() await SaveNxmProtocolSettingsAsync(settings); + settings.FomodPostDownloadMode = _fomodPostDownloadMode; + Models.SettingsManager.SaveSettings(settings); Logger.Log( @@ -947,6 +981,23 @@ await Dispatcher.UIThread.InvokeAsync( } } + [UsedImplicitly] + private void FomodPostDownloadModeComboBox_SelectionChanged( + object sender, + SelectionChangedEventArgs e + ) + { + if ( + !(sender is ComboBox comboBox && comboBox.SelectedItem is ComboBoxItem selectedItem) + ) + { + return; + } + + _fomodPostDownloadMode = selectedItem.Tag?.ToString() ?? "warn-continue"; + Logger.LogVerbose($"FOMOD post-download mode changed to: {_fomodPostDownloadMode}"); + } + [UsedImplicitly] private void FileEncodingComboBox_SelectionChanged( object sender, diff --git a/src/ModSync.GUI/Models/AppSettings.cs b/src/ModSync.GUI/Models/AppSettings.cs index 54dc44b0..e9746657 100644 --- a/src/ModSync.GUI/Models/AppSettings.cs +++ b/src/ModSync.GUI/Models/AppSettings.cs @@ -92,6 +92,10 @@ public sealed class AppSettings [JsonPropertyName("registerNxmProtocolHandler")] public bool RegisterNxmProtocolHandler { get; set; } + [JsonPropertyName("fomodPostDownloadMode")] + [CanBeNull] + public string FomodPostDownloadMode { get; set; } = "warn-continue"; + public AppSettings() { } diff --git a/src/ModSync.Tests/AppSettingsFomodTests.cs b/src/ModSync.Tests/AppSettingsFomodTests.cs new file mode 100644 index 00000000..f207ca2b --- /dev/null +++ b/src/ModSync.Tests/AppSettingsFomodTests.cs @@ -0,0 +1,40 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System.Text.Json; +using ModSync.Core.Services.Fomod; +using ModSync.Models; +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public class AppSettingsFomodTests + { + [Test] + public void FomodPostDownloadMode_RoundTripsInJson() + { + var settings = new AppSettings + { + FomodPostDownloadMode = "skip", + }; + + string json = JsonSerializer.Serialize(settings); + AppSettings deserialized = JsonSerializer.Deserialize(json); + + Assert.That(deserialized, Is.Not.Null); + Assert.That(deserialized.FomodPostDownloadMode, Is.EqualTo("skip")); + } + + [Test] + public void FomodPostDownloadMode_UsesSameKeyAsCliSettings() + { + var settings = new AppSettings { FomodPostDownloadMode = "warn-continue" }; + + string json = JsonSerializer.Serialize(settings); + + Assert.That(json, Does.Contain(FomodPostDownloadOptionsResolver.SettingsKey)); + } + } +} From e11d9a79fc1838a4846a35e1fc114c92f1d1aeb6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 13 Jun 2026 05:08:38 -0500 Subject: [PATCH 5/7] fix: remove duplicate FOMOD namespace import Clears CS0105 in FomodInstallerDialog; prior Windows CI failure was an MSBuild worker crash (MSB4166), not a compile error. --- src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs b/src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs index 4ea56f25..b8eeb525 100644 --- a/src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs +++ b/src/ModSync.GUI/Dialogs/FomodInstallerDialog.axaml.cs @@ -16,7 +16,6 @@ using ModSync.Core; using ModSync.Core.Services.Fomod; -using ModSync.Core.Services.Fomod; namespace ModSync.Dialogs { From ab1fe429526c30b9386fd474f50695bbbddf6344 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 13 Jun 2026 05:20:53 -0500 Subject: [PATCH 6/7] test: add CLI merge -d --fomod-skip integration smoke Covers the Plan 123 verification filter for FomodCliPostDownload using a local FOMOD archive and merge without network download. --- .../FomodCliPostDownloadIntegrationTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/ModSync.Tests/FomodCliPostDownloadIntegrationTests.cs diff --git a/src/ModSync.Tests/FomodCliPostDownloadIntegrationTests.cs b/src/ModSync.Tests/FomodCliPostDownloadIntegrationTests.cs new file mode 100644 index 00000000..bbef7e6c --- /dev/null +++ b/src/ModSync.Tests/FomodCliPostDownloadIntegrationTests.cs @@ -0,0 +1,128 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; + +using ModSync.Core; +using ModSync.Core.CLI; +using ModSync.Core.Services; + +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + [Category("Integration")] + public sealed class FomodCliPostDownloadIntegrationTests + { + private string _testDirectory; + private MainConfig _previousMainConfig; + + [SetUp] + public void SetUp() + { + _testDirectory = Path.Combine(Path.GetTempPath(), "ModSync_FomodCli_" + Guid.NewGuid()); + Directory.CreateDirectory(_testDirectory); + _previousMainConfig = MainConfig.Instance; + MainConfig.Instance = new MainConfig(); + } + + [TearDown] + public void TearDown() + { + MainConfig.Instance = _previousMainConfig; + + try + { + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } + } + + [Test] + public void Merge_WithDownloadAndFomodSkip_CompletesSuccessfully() + { + const string archiveName = "cli-fomod-mod.zip"; + string sourceDir = Path.Combine(_testDirectory, "mods"); + Directory.CreateDirectory(sourceDir); + CreateFomodArchive(Path.Combine(sourceDir, archiveName)); + + string existingToml = Path.Combine(_testDirectory, "existing.toml"); + string incomingMd = Path.Combine(_testDirectory, "incoming.md"); + string outputToml = Path.Combine(_testDirectory, "merged.toml"); + + File.WriteAllText(existingToml, @"[[thisMod]] +Guid = ""a1b2c3d4-e5f6-7890-abcd-ef1234567890"" +Name = ""CLI FOMOD Mod"" +IsSelected = true +ModLinkFilenames = { ""https://example.com/cli-fomod-mod.zip"" = { } } +"); + + File.WriteAllText(incomingMd, @"### CLI FOMOD Mod + +**Name:** [CLI FOMOD Mod](https://example.com/cli-fomod-mod.zip) + +**Author:** Test Author + +**Description:** Synthetic FOMOD mod for CLI post-download integration. + +**Category & Tier:** Immersion / 1 - Essential + +**Installation Method:** FOMOD + +___ +"); + + string[] args = + { + "merge", + "--existing", existingToml, + "--incoming", incomingMd, + "--use-existing-order", + "--prefer-existing-instructions", + "-f", "toml", + "-o", outputToml, + "--source-path", sourceDir, + "--auto-generate-local", + "-d", + "--fomod-skip", + }; + + int exitCode = ModBuildConverter.Run(args); + + Assert.Multiple(() => + { + Assert.That(exitCode, Is.EqualTo(0), "merge -d --fomod-skip should succeed with local FOMOD archive"); + Assert.That(File.Exists(outputToml), Is.True); + }); + + var merged = ModComponentSerializationService + .DeserializeModComponentFromString(File.ReadAllText(outputToml), "toml") + .ToList(); + + Assert.That(merged, Has.Count.EqualTo(1)); + } + + private static void CreateFomodArchive(string archivePath) + { + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("CLI-FOMOD-1.0/fomod/ModuleConfig.xml"); + using (StreamWriter writer = new StreamWriter(entry.Open())) + { + writer.Write("CLI FOMOD Mod"); + } + } + } + } +} From eb6280d29ae24ed28d82dddbb3390687e793a6b3 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 13 Jun 2026 06:18:47 -0500 Subject: [PATCH 7/7] fix: address FOMOD post-download correctness audit findings Persist prompt status updates after TOML object-dictionary round-trip, preserve fomodPostDownloadMode across FromCurrentState saves, honor settings warn-continue on TTY, record warned state for headless runs, and pass real archive names into the console wizard. --- .../CLI/FomodCliPostDownloadHosts.cs | 6 +++- src/ModSync.Core/CLI/FomodConsoleWizard.cs | 11 +++++-- .../Services/Fomod/FomodChoicesFileHost.cs | 3 ++ .../Fomod/FomodDownloadPromptState.cs | 9 ++++- .../Fomod/FomodPostDownloadOptionsResolver.cs | 22 ++++++++++++- src/ModSync.GUI/Models/AppSettings.cs | 5 +++ src/ModSync.Tests/AppSettingsFomodTests.cs | 21 ++++++++++++ src/ModSync.Tests/FomodArchiveProbeTests.cs | 33 +++++++++++++++++++ .../FomodPostDownloadOptionsResolverTests.cs | 26 +++++++++++++++ 9 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs b/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs index 63b74320..66d7a311 100644 --- a/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs +++ b/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs @@ -23,6 +23,7 @@ public Task AskConfigureAsync( $"WARN: FOMOD installer detected in '{context.ArchiveFileName}' for mod '{context.Component.Name}'. " + "Configure later via GUI or re-run with --fomod-choices."; Console.Error.WriteLine(message); + FomodDownloadPromptState.MarkWarned(context.Component, context.ArchiveFileName); return Task.FromResult(FomodConfigurePromptResult.AlreadyHandled); } @@ -94,7 +95,10 @@ public Task RunWizardAsync( FomodPromptContext context, CancellationToken cancellationToken = default) { - ModComponent configured = FomodConsoleWizard.Run(extractedArchiveDirectory, context.Component.Name); + ModComponent configured = FomodConsoleWizard.Run( + extractedArchiveDirectory, + context.Component.Name, + context.ArchiveFileName); return Task.FromResult(configured); } diff --git a/src/ModSync.Core/CLI/FomodConsoleWizard.cs b/src/ModSync.Core/CLI/FomodConsoleWizard.cs index 0c0325f2..2f6630ff 100644 --- a/src/ModSync.Core/CLI/FomodConsoleWizard.cs +++ b/src/ModSync.Core/CLI/FomodConsoleWizard.cs @@ -19,7 +19,10 @@ namespace ModSync.Core.CLI public static class FomodConsoleWizard { [CanBeNull] - public static ModComponent Run([NotNull] string extractedArchiveDirectory, [CanBeNull] string componentDisplayName) + public static ModComponent Run( + [NotNull] string extractedArchiveDirectory, + [CanBeNull] string componentDisplayName, + [CanBeNull] string archiveFileName = null) { string moduleConfigPath = FomodArchiveDiscovery.FindModuleConfigPath(extractedArchiveDirectory); if (moduleConfigPath is null) @@ -36,8 +39,10 @@ public static ModComponent Run([NotNull] string extractedArchiveDirectory, [CanB info = FomodParser.ParseInfoXmlFile(infoPath); } - string archiveFileName = Path.GetFileName(extractedArchiveDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + ".zip"; - ModComponent component = FomodToComponentMapper.Map(info, config, archiveFileName); + string resolvedArchiveFileName = !string.IsNullOrWhiteSpace(archiveFileName) + ? archiveFileName + : Path.GetFileName(extractedArchiveDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + ".zip"; + ModComponent component = FomodToComponentMapper.Map(info, config, resolvedArchiveFileName); FomodInstallerSession session = FomodInstallerPresenter.CreateSession(config, component); string title = string.IsNullOrWhiteSpace(componentDisplayName) diff --git a/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs b/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs index c784c6eb..2ba38f76 100644 --- a/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs +++ b/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs @@ -27,6 +27,9 @@ public Task AskConfigureAsync( FomodArchiveChoices archiveChoices = FomodChoicesApplier.FindArchiveChoices(_choicesFile, context.ArchiveFileName); if (archiveChoices is null) { + Console.Error.WriteLine( + $"WARN: No FOMOD choices entry for archive '{context.ArchiveFileName}' in the choices file."); + FomodDownloadPromptState.MarkWarned(context.Component, context.ArchiveFileName); return Task.FromResult(FomodConfigurePromptResult.AlreadyHandled); } diff --git a/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs b/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs index 07581aa2..0dbdc484 100644 --- a/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs +++ b/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs @@ -21,6 +21,8 @@ public static class FomodDownloadPromptState public const string StatusConfigured = "configured"; + public const string StatusWarned = "warned"; + public static bool ShouldPrompt([NotNull] ModComponent component, [NotNull] string archiveFileName) { if (component is null) @@ -66,6 +68,11 @@ public static void MarkConfigured([NotNull] ModComponent component, [NotNull] st SetStatus(component, archiveFileName, StatusConfigured); } + public static void MarkWarned([NotNull] ModComponent component, [NotNull] string archiveFileName) + { + SetStatus(component, archiveFileName, StatusWarned); + } + private static void SetStatus( [NotNull] ModComponent component, [NotNull] string archiveFileName, @@ -99,10 +106,10 @@ private static void SetStatus( if (!TryGetStatusDictionaryFromMetadata(resource.HandlerMetadata, out Dictionary statuses)) { statuses = new Dictionary(StringComparer.OrdinalIgnoreCase); - resource.HandlerMetadata[HandlerMetadataKey] = statuses; } statuses[NormalizeFileName(archiveFileName)] = status; + resource.HandlerMetadata[HandlerMetadataKey] = statuses; } private static bool TryGetResourceForArchive( diff --git a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs index c1776271..28ced5c9 100644 --- a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs +++ b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOptionsResolver.cs @@ -53,7 +53,27 @@ public static FomodPostDownloadOptions Resolve( return new FomodPostDownloadOptions { Mode = FomodPostDownloadMode.SkipAll }; } - if (ConsoleInteractionCapabilities.IsInteractiveTerminal(forceInteractive, forceNonInteractive)) + if (forceInteractive) + { + return new FomodPostDownloadOptions + { + Mode = FomodPostDownloadMode.Interactive, + ForceInteractive = forceInteractive, + ForceNonInteractive = forceNonInteractive, + }; + } + + if (string.Equals(mode, "warn-continue", StringComparison.OrdinalIgnoreCase)) + { + return new FomodPostDownloadOptions + { + Mode = FomodPostDownloadMode.WarnContinue, + ForceInteractive = forceInteractive, + ForceNonInteractive = forceNonInteractive, + }; + } + + if (ConsoleInteractionCapabilities.IsInteractiveTerminal(false, forceNonInteractive)) { return new FomodPostDownloadOptions { diff --git a/src/ModSync.GUI/Models/AppSettings.cs b/src/ModSync.GUI/Models/AppSettings.cs index e9746657..cc0e7b1b 100644 --- a/src/ModSync.GUI/Models/AppSettings.cs +++ b/src/ModSync.GUI/Models/AppSettings.cs @@ -113,6 +113,8 @@ public static AppSettings FromCurrentState([NotNull] MainConfig mainConfig, [Can Logger.LogVerbose($"[AppSettings.FromCurrentState] Theme: '{currentTheme}'"); Logger.LogVerbose($"[AppSettings.FromCurrentState] SpoilerFreeMode: '{spoilerFreeMode}'"); + AppSettings persisted = SettingsManager.LoadSettings(); + return new AppSettings { Theme = currentTheme ?? "/Styles/LightStyle.axaml", @@ -136,6 +138,9 @@ public static AppSettings FromCurrentState([NotNull] MainConfig mainConfig, [Can EnableFileWatcher = mainConfig.enableFileWatcher, SpoilerFreeMode = spoilerFreeMode, RegisterNxmProtocolHandler = mainConfig.registerNxmProtocolHandler, + FomodPostDownloadMode = string.IsNullOrWhiteSpace(persisted.FomodPostDownloadMode) + ? "warn-continue" + : persisted.FomodPostDownloadMode, }; } diff --git a/src/ModSync.Tests/AppSettingsFomodTests.cs b/src/ModSync.Tests/AppSettingsFomodTests.cs index f207ca2b..24a2d83b 100644 --- a/src/ModSync.Tests/AppSettingsFomodTests.cs +++ b/src/ModSync.Tests/AppSettingsFomodTests.cs @@ -3,8 +3,11 @@ // See LICENSE.txt file in the project root for full license information. using System.Text.Json; + +using ModSync.Core; using ModSync.Core.Services.Fomod; using ModSync.Models; + using NUnit.Framework; namespace ModSync.Tests @@ -36,5 +39,23 @@ public void FomodPostDownloadMode_UsesSameKeyAsCliSettings() Assert.That(json, Does.Contain(FomodPostDownloadOptionsResolver.SettingsKey)); } + + [Test] + public void FomodPostDownloadMode_FromCurrentState_PreservesPersistedValue() + { + AppSettings original = SettingsManager.LoadSettings(); + try + { + SettingsManager.SaveSettings(new AppSettings { FomodPostDownloadMode = "skip" }); + + AppSettings roundTrip = AppSettings.FromCurrentState(new MainConfig(), "/Styles/LightStyle.axaml"); + + Assert.That(roundTrip.FomodPostDownloadMode, Is.EqualTo("skip")); + } + finally + { + SettingsManager.SaveSettings(original); + } + } } } diff --git a/src/ModSync.Tests/FomodArchiveProbeTests.cs b/src/ModSync.Tests/FomodArchiveProbeTests.cs index 417acd85..b848ecf5 100644 --- a/src/ModSync.Tests/FomodArchiveProbeTests.cs +++ b/src/ModSync.Tests/FomodArchiveProbeTests.cs @@ -111,5 +111,38 @@ public void ShouldPrompt_ReturnsFalseAfterDismissedOrConfigured() FomodDownloadPromptState.MarkConfigured(component, "OtherMod.zip"); Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "OtherMod.zip"), Is.False); } + + [Test] + public void MarkConfigured_UpdatesStatus_WhenHandlerMetadataUsesObjectDictionary() + { + var component = new ModComponent + { + ResourceRegistry = new Dictionary + { + ["https://example.test/mod.zip"] = new ResourceMetadata + { + Files = new Dictionary { ["ExampleMod.zip"] = true }, + HandlerMetadata = new Dictionary + { + [FomodDownloadPromptState.HandlerMetadataKey] = new Dictionary + { + ["ExampleMod.zip"] = FomodDownloadPromptState.StatusDismissed, + }, + }, + }, + }, + }; + + FomodDownloadPromptState.MarkConfigured(component, "ExampleMod.zip"); + + Assert.That( + FomodDownloadPromptState.GetStatus(component, "ExampleMod.zip"), + Is.EqualTo(FomodDownloadPromptState.StatusConfigured)); + + ResourceMetadata resource = component.ResourceRegistry["https://example.test/mod.zip"]; + Assert.That( + resource.HandlerMetadata[FomodDownloadPromptState.HandlerMetadataKey], + Is.InstanceOf>()); + } } } diff --git a/src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs b/src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs index 452fb196..aad7daca 100644 --- a/src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs +++ b/src/ModSync.Tests/FomodPostDownloadOptionsResolverTests.cs @@ -36,5 +36,31 @@ public void Resolve_NonInteractiveDefault_ReturnsWarnContinue() Assert.That(options.Mode, Is.EqualTo(FomodPostDownloadMode.WarnContinue)); } + + [Test] + public void Resolve_SettingsWarnContinue_ReturnsWarnContinueEvenWhenForceInteractiveFalse() + { + FomodPostDownloadOptions options = FomodPostDownloadOptionsResolver.Resolve( + fomodSkip: false, + fomodChoicesPath: null, + forceInteractive: false, + forceNonInteractive: false, + settingsMode: "warn-continue"); + + Assert.That(options.Mode, Is.EqualTo(FomodPostDownloadMode.WarnContinue)); + } + + [Test] + public void Resolve_ForceInteractive_OverridesSettingsWarnContinue() + { + FomodPostDownloadOptions options = FomodPostDownloadOptionsResolver.Resolve( + fomodSkip: false, + fomodChoicesPath: null, + forceInteractive: true, + forceNonInteractive: false, + settingsMode: "warn-continue"); + + Assert.That(options.Mode, Is.EqualTo(FomodPostDownloadMode.Interactive)); + } } }