From 5ff731956cf8f923d0e8c4c26f55ad6dc30c0ce1 Mon Sep 17 00:00:00 2001 From: CnBarrier Date: Wed, 16 Sep 2026 01:11:21 +0800 Subject: [PATCH 01/18] feat!: replace CLI and TUI with Avalonia desktop scaffold - Add the desktop application, main window, MVVM base, and icon. - Remove CLI and TUI projects and their tests. - Move localization resources to the repository root. - Update application identity, project configuration, and agent guidance. - Remove ARCHITECTURE.md and ignore .agents files. BREAKING CHANGE: CLI commands and the terminal UI are removed. The desktop window does not yet expose mod management workflows. --- .gitignore | 2 + AGENTS.md | 96 +- ARCHITECTURE.md | 132 --- UnityAssetsPatcher.slnx | 8 +- localization/Strings.resx | 33 + localization/Strings.zh-CN.resx | 33 + .../AppConfig.cs | 5 +- .../UninstallCompositionService.cs | 3 +- src/UnityAssetsPatcher.CLI/CLIApplication.cs | 108 --- src/UnityAssetsPatcher.CLI/CLIExitCodes.cs | 8 - src/UnityAssetsPatcher.CLI/CLIOptions.cs | 19 - src/UnityAssetsPatcher.CLI/CLIOutput.cs | 860 ------------------ .../CLIServiceCollectionExtensions.cs | 56 -- src/UnityAssetsPatcher.CLI/CheckCLICommand.cs | 68 -- src/UnityAssetsPatcher.CLI/ICLICommand.cs | 8 - .../InspectCLICommand.cs | 144 --- .../InstallCLICommand.cs | 181 ---- .../RecoveryCLICommand.cs | 105 --- .../RepositoryCLICommand.cs | 69 -- .../UninstallCLICommand.cs | 178 ---- .../UnityAssetsPatcher.CLI.csproj | 18 - src/UnityAssetsPatcher.GUI/App.axaml | 10 + src/UnityAssetsPatcher.GUI/App.axaml.cs | 39 + src/UnityAssetsPatcher.GUI/Assets/icon.ico | Bin 0 -> 33919 bytes .../Styles/DesignTokens.axaml | 87 ++ .../Styles/Typography.axaml | 39 + .../UnityAssetsPatcher.GUI.csproj | 49 + .../ViewModels/MainWindowViewModel.cs | 78 ++ .../ViewModels/NavigationItemViewModel.cs | 19 + .../ViewModels/Pages/AboutPageViewModel.cs | 3 + .../Pages/InstallModPageViewModel.cs | 3 + .../Pages/ManageModsPageViewModel.cs | 3 + .../ViewModels/Pages/SettingsPageViewModel.cs | 3 + .../ViewModels/ViewModelBase.cs | 5 + .../Views/AboutPage.axaml | 11 + .../Views/AboutPage.axaml.cs | 11 + .../Views/InstallModPage.axaml | 34 + .../Views/InstallModPage.axaml.cs | 11 + .../Views/MainWindow.axaml | 144 +++ .../Views/MainWindow.axaml.cs | 11 + .../Views/ManageModsPage.axaml | 11 + .../Views/ManageModsPage.axaml.cs | 11 + .../Views/SettingsPage.axaml | 11 + .../Views/SettingsPage.axaml.cs | 11 + .../Updates/GitHubUpdateManifestClient.cs | 2 +- .../AnalyzerReleases.Shipped.md | 1 - .../AnalyzerReleases.Unshipped.md | 15 - .../CSharpIdentifier.cs | 11 - .../Descriptors.cs | 72 -- .../LocaleFileParser.cs | 205 ----- .../LocaleModels.cs | 49 - .../LocalizationFormatParser.cs | 139 --- .../LocalizationKeyExtractor.cs | 16 - .../LocalizedStringsGenerator.cs | 443 --------- ...AssetsPatcher.LocalizationGenerator.csproj | 17 - .../Flows/RepositoryInitializationFlow.cs | 45 - .../Framework/ActionButton.cs | 50 - .../Framework/ChoiceItem.cs | 51 -- .../Framework/ChoiceItemList.cs | 57 -- .../Framework/ConfirmationBar.cs | 84 -- .../Framework/DataTableView.cs | 28 - .../Framework/InputField.cs | 11 - .../Framework/ScrollableContentView.cs | 107 --- .../Framework/StyledLabel.cs | 31 - .../Framework/SummaryTableView.cs | 43 - .../Framework/TerminalPathNormalizer.cs | 18 - .../Framework/TerminalTheme.cs | 109 --- .../Framework/TextViewer.cs | 16 - .../Framework/ToggleItem.cs | 76 -- .../Framework/ViewExtensions.cs | 20 - .../Framework/WindowsNativeFilePicker.cs | 107 --- .../Framework/WorkingIndicator.cs | 72 -- .../Lifecycle/TerminalFlowContext.cs | 24 - .../Lifecycle/TerminalSession.cs | 312 ------- .../Localization/JSON/en-US.json | 144 --- .../Localization/JSON/zh-CN.json | 144 --- .../Localization/OperationErrorFormatter.cs | 140 --- .../Navigation/TerminalNavigator.cs | 84 -- .../Navigation/TerminalRoute.cs | 10 - .../Navigation/TerminalRouteTable.cs | 78 -- .../Pages/InspectAssets/InspectAssetsLogic.cs | 227 ----- .../Pages/InspectAssets/InspectAssetsView.cs | 531 ----------- .../Pages/InstallMod/InstallModLogic.cs | 213 ----- .../Pages/InstallMod/InstallModView.cs | 483 ---------- .../Pages/MainMenu/MainMenuLogic.cs | 155 ---- .../Pages/MainMenu/MainMenuView.cs | 150 --- .../RepositoryIssue/RepositoryIssueLogic.cs | 296 ------ .../RepositoryIssue/RepositoryIssueView.cs | 322 ------- .../Pages/Settings/SettingsLogic.cs | 27 - .../Pages/Settings/SettingsView.cs | 32 - .../Pages/TerminalContentView.cs | 21 - .../Pages/TerminalOperationPageView.cs | 25 - .../Pages/TerminalPageLogic.cs | 167 ---- .../Pages/TerminalPageView.cs | 55 -- .../Pages/UninstallMod/UninstallModLogic.cs | 157 ---- .../Pages/UninstallMod/UninstallModView.cs | 362 -------- .../Shell/ApplicationBannerView.cs | 43 - .../Shell/ITerminalContentHost.cs | 8 - .../Shell/ITerminalRenderRequester.cs | 6 - .../Shell/TerminalFooterView.cs | 26 - .../Shell/TerminalPageHeaderView.cs | 56 -- .../Shell/TerminalShellView.cs | 111 --- src/UnityAssetsPatcher.TUI/TerminalApp.cs | 50 - .../TerminalServiceCollectionExtensions.cs | 21 - .../UnityAssetsPatcher.TUI.csproj | 30 - src/UnityAssetsPatcher/Program.cs | 33 +- .../UnityAssetsPatcher.csproj | 6 +- .../CLIApplicationTests.cs | 223 ----- .../CliDependencyInjectionTests.cs | 38 - .../RepositoryCLICommandTests.cs | 151 --- .../UninstallCLIOutputTests.cs | 183 ---- .../UnityAssetsPatcher.CLI.Tests.csproj | 29 - .../LocalizedStringsGeneratorTests.cs | 340 ------- ...Patcher.LocalizationGenerator.Tests.csproj | 29 - .../PlaceholderTests.cs | 16 - .../UnityAssetsPatcher.TUI.Tests.csproj | 29 - 116 files changed, 766 insertions(+), 9454 deletions(-) delete mode 100644 ARCHITECTURE.md create mode 100644 localization/Strings.resx create mode 100644 localization/Strings.zh-CN.resx delete mode 100644 src/UnityAssetsPatcher.CLI/CLIApplication.cs delete mode 100644 src/UnityAssetsPatcher.CLI/CLIExitCodes.cs delete mode 100644 src/UnityAssetsPatcher.CLI/CLIOptions.cs delete mode 100644 src/UnityAssetsPatcher.CLI/CLIOutput.cs delete mode 100644 src/UnityAssetsPatcher.CLI/CLIServiceCollectionExtensions.cs delete mode 100644 src/UnityAssetsPatcher.CLI/CheckCLICommand.cs delete mode 100644 src/UnityAssetsPatcher.CLI/ICLICommand.cs delete mode 100644 src/UnityAssetsPatcher.CLI/InspectCLICommand.cs delete mode 100644 src/UnityAssetsPatcher.CLI/InstallCLICommand.cs delete mode 100644 src/UnityAssetsPatcher.CLI/RecoveryCLICommand.cs delete mode 100644 src/UnityAssetsPatcher.CLI/RepositoryCLICommand.cs delete mode 100644 src/UnityAssetsPatcher.CLI/UninstallCLICommand.cs delete mode 100644 src/UnityAssetsPatcher.CLI/UnityAssetsPatcher.CLI.csproj create mode 100644 src/UnityAssetsPatcher.GUI/App.axaml create mode 100644 src/UnityAssetsPatcher.GUI/App.axaml.cs create mode 100644 src/UnityAssetsPatcher.GUI/Assets/icon.ico create mode 100644 src/UnityAssetsPatcher.GUI/Styles/DesignTokens.axaml create mode 100644 src/UnityAssetsPatcher.GUI/Styles/Typography.axaml create mode 100644 src/UnityAssetsPatcher.GUI/UnityAssetsPatcher.GUI.csproj create mode 100644 src/UnityAssetsPatcher.GUI/ViewModels/MainWindowViewModel.cs create mode 100644 src/UnityAssetsPatcher.GUI/ViewModels/NavigationItemViewModel.cs create mode 100644 src/UnityAssetsPatcher.GUI/ViewModels/Pages/AboutPageViewModel.cs create mode 100644 src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallModPageViewModel.cs create mode 100644 src/UnityAssetsPatcher.GUI/ViewModels/Pages/ManageModsPageViewModel.cs create mode 100644 src/UnityAssetsPatcher.GUI/ViewModels/Pages/SettingsPageViewModel.cs create mode 100644 src/UnityAssetsPatcher.GUI/ViewModels/ViewModelBase.cs create mode 100644 src/UnityAssetsPatcher.GUI/Views/AboutPage.axaml create mode 100644 src/UnityAssetsPatcher.GUI/Views/AboutPage.axaml.cs create mode 100644 src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml create mode 100644 src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml.cs create mode 100644 src/UnityAssetsPatcher.GUI/Views/MainWindow.axaml create mode 100644 src/UnityAssetsPatcher.GUI/Views/MainWindow.axaml.cs create mode 100644 src/UnityAssetsPatcher.GUI/Views/ManageModsPage.axaml create mode 100644 src/UnityAssetsPatcher.GUI/Views/ManageModsPage.axaml.cs create mode 100644 src/UnityAssetsPatcher.GUI/Views/SettingsPage.axaml create mode 100644 src/UnityAssetsPatcher.GUI/Views/SettingsPage.axaml.cs delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/AnalyzerReleases.Shipped.md delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/AnalyzerReleases.Unshipped.md delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/CSharpIdentifier.cs delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/Descriptors.cs delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/LocaleFileParser.cs delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/LocaleModels.cs delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/LocalizationFormatParser.cs delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/LocalizationKeyExtractor.cs delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/LocalizedStringsGenerator.cs delete mode 100644 src/UnityAssetsPatcher.LocalizationGenerator/UnityAssetsPatcher.LocalizationGenerator.csproj delete mode 100644 src/UnityAssetsPatcher.TUI/Flows/RepositoryInitializationFlow.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/ActionButton.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/ChoiceItem.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/ChoiceItemList.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/ConfirmationBar.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/DataTableView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/InputField.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/ScrollableContentView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/StyledLabel.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/SummaryTableView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/TerminalPathNormalizer.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/TerminalTheme.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/TextViewer.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/ToggleItem.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/ViewExtensions.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/WindowsNativeFilePicker.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Framework/WorkingIndicator.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Lifecycle/TerminalFlowContext.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Lifecycle/TerminalSession.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Localization/JSON/en-US.json delete mode 100644 src/UnityAssetsPatcher.TUI/Localization/JSON/zh-CN.json delete mode 100644 src/UnityAssetsPatcher.TUI/Localization/OperationErrorFormatter.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Navigation/TerminalNavigator.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Navigation/TerminalRoute.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Navigation/TerminalRouteTable.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/InspectAssets/InspectAssetsLogic.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/InspectAssets/InspectAssetsView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/InstallMod/InstallModLogic.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/InstallMod/InstallModView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/MainMenu/MainMenuLogic.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/MainMenu/MainMenuView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/RepositoryIssue/RepositoryIssueLogic.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/RepositoryIssue/RepositoryIssueView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/Settings/SettingsLogic.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/Settings/SettingsView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/TerminalContentView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/TerminalOperationPageView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/TerminalPageLogic.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/TerminalPageView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/UninstallMod/UninstallModLogic.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Pages/UninstallMod/UninstallModView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Shell/ApplicationBannerView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Shell/ITerminalContentHost.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Shell/ITerminalRenderRequester.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Shell/TerminalFooterView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Shell/TerminalPageHeaderView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/Shell/TerminalShellView.cs delete mode 100644 src/UnityAssetsPatcher.TUI/TerminalApp.cs delete mode 100644 src/UnityAssetsPatcher.TUI/TerminalServiceCollectionExtensions.cs delete mode 100644 src/UnityAssetsPatcher.TUI/UnityAssetsPatcher.TUI.csproj delete mode 100644 tests/UnityAssetsPatcher.CLI.Tests/CLIApplicationTests.cs delete mode 100644 tests/UnityAssetsPatcher.CLI.Tests/CliDependencyInjectionTests.cs delete mode 100644 tests/UnityAssetsPatcher.CLI.Tests/RepositoryCLICommandTests.cs delete mode 100644 tests/UnityAssetsPatcher.CLI.Tests/UninstallCLIOutputTests.cs delete mode 100644 tests/UnityAssetsPatcher.CLI.Tests/UnityAssetsPatcher.CLI.Tests.csproj delete mode 100644 tests/UnityAssetsPatcher.LocalizationGenerator.Tests/LocalizedStringsGeneratorTests.cs delete mode 100644 tests/UnityAssetsPatcher.LocalizationGenerator.Tests/UnityAssetsPatcher.LocalizationGenerator.Tests.csproj delete mode 100644 tests/UnityAssetsPatcher.TUI.Tests/PlaceholderTests.cs delete mode 100644 tests/UnityAssetsPatcher.TUI.Tests/UnityAssetsPatcher.TUI.Tests.csproj diff --git a/.gitignore b/.gitignore index ced8730d..61c974c9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ x86/ bld/ [Bb]in/ [Oo]bj/ +/localization/*.Designer.cs [Ll]og/ [Ll]ogs/ @@ -60,5 +61,6 @@ nunit-*.xml .idea/ # Agent tool files +.agents/ .codex/ .claude/ diff --git a/AGENTS.md b/AGENTS.md index 82c795dd..25587fe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,6 @@ # AGENTS.md -UnityAssetsPatcher is a .NET 10 console app for inspecting Unity assets files and installing or uninstalling mods. - -It provides a Terminal.Gui-based interactive UI and a non-interactive CLI. +UnityAssetsPatcher is a .NET 10 desktop application evolving into an alternative mod manager for Unity games with special modding requirements, particularly where runtime mod frameworks such as `BepInEx` are impractical. ## Commands @@ -11,35 +9,81 @@ It provides a Terminal.Gui-based interactive UI and a non-interactive CLI. | Build | `dotnet build UnityAssetsPatcher.slnx` | | Test | `dotnet test UnityAssetsPatcher.slnx` | +- Documentation-only changes: check content, links, and the diff; builds and tests are not required. +- Localized code changes: run the build command above and the affected existing tests. +- Changes spanning projects, dependencies, or build configuration: run both full-solution commands above. Full validation may also be used when the impact of a code change is unclear. +- After checks pass, repeat or broaden validation only when subsequent changes, failures, or unresolved concerns justify it. +- Do not add tests, including test files or test cases, unless the user explicitly requests them. + ## Repository Structure -| Directory | Responsibility | -| :------------------------------------------------------ | :--------------------------------------------------------------------------------------------------- | -| `src/UnityAssetsPatcher/` | Executable entry point and composition root; owns dependency registration and concrete logging setup | -| `src/UnityAssetsPatcher.Application/` | Use cases, workflow orchestration, DTOs, and infrastructure abstractions | -| `src/UnityAssetsPatcher.CLI/` | Non-interactive command parsing and text/JSON presentation | -| `src/UnityAssetsPatcher.Domain/` | Domain models, value objects, validation rules, and domain errors | -| `src/UnityAssetsPatcher.Infrastructure/` | File system, compression, persistence, backup, package, and AssetsTools.NET implementations | -| `src/UnityAssetsPatcher.LocalizationGenerator/` | Roslyn source generator for strongly typed localized strings | -| `src/UnityAssetsPatcher.TUI/` | Interactive terminal UI and localization inputs | +| Directory | Responsibility | +| :--------------------------------------- | :--------------------------------------------------------------------------------------------------- | +| `docs/` | User and mod author documentation | +| `schema/` | Mod manifest schema | +| `src/UnityAssetsPatcher/` | Executable entry point and composition root; owns dependency registration and concrete logging setup | +| `src/UnityAssetsPatcher.Application/` | Use cases, workflow orchestration, DTOs, and infrastructure abstractions | +| `src/UnityAssetsPatcher.Domain/` | Domain models, value objects, validation rules, and domain errors | +| `src/UnityAssetsPatcher.Infrastructure/` | File system, compression, persistence, backup, package, and AssetsTools.NET implementations | +| `src/UnityAssetsPatcher.GUI/` | Avalonia-based UI | +| `tests/` | Unit and integration tests | -## Project Architecture +## Workflows -[ARCHITECTURE.md](./ARCHITECTURE.md) +1. Inspect only the code and documentation relevant to the task. For bugs, support the diagnosis with code or reproduction evidence and distinguish hypotheses from confirmed causes. +2. Present the intended behavior, scope, and validation plan. Obtain explicit approval before editing implementation files. +3. Before approval, read-only inspection, local builds, and verified isolated tests or reproductions are allowed, including disposable diagnostic artifacts. Operations involving real game files or user data require separate authorization. +4. Once approved, complete implementation, validation, and fixes caused by the change without repeated approval. Ask again only for material changes to requirements, observable behavior, architecture, or impact. +5. Do not add test files or test cases unless explicitly requested. Run existing tests as appropriate and report coverage gaps. +6. Follow the validation rules under [Commands](#commands). Continue until the agreed behavior and applicable checks pass; report evidence and remaining work when unrelated failures or external blockers prevent completion. -## Workflows +### Infrastructure + +- Infrastructure implementations must propagate original exceptions from underlying platform APIs and third-party libraries without wrapping, translating, or replacing them. + +### Error Handling + +The exception policy follows the [.NET best practices for exceptions](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions) and [CA1031](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1031). Exceptions are divided into expected operational failures, cancellation, and unexpected exceptions. These categories must not be inferred from timing or from a broad `catch`; they are determined by an explicit contract and by ownership of the operation. + +#### Expected Failures + +- Expected operational failures must be represented by `OperationFailed` and handled as ordinary control flow by callers. +- A failure is expected only when the application has deliberately defined its meaning and recovery or presentation behavior. The fact that an exception came from I/O, a third-party library, or user input does not by itself make it expected. +- Catch only specific exception types whose meaning is known at that layer and whose conversion to an application error is intentional. Do not use `catch (Exception)` to manufacture an `OperationFailed` result for an unknown defect. +- Infrastructure implementations must propagate original exceptions. The application layer is responsible for translating known infrastructure failures into `OperationFailed` results. +- Cleanup must use `using`, `await using`, or `finally` whenever possible. If a broad catch is required to roll back or restore state, it must rethrow with `throw;` after restoring the invariant. If rollback also fails, the propagated exception must preserve both failures rather than discard the original one. See [how to use `finally` blocks](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/how-to-use-finally-blocks) and [how to rethrow exceptions without losing stack information](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions#capture-and-rethrow-exceptions-properly). + +#### Cancellation + +- Cancellation is cooperative control flow, not an operational failure or an unexpected error. Use `CancellationToken`, propagate it through every cancellable call, and use `ThrowIfCancellationRequested()` when the operation itself must acknowledge cancellation. See [.NET task cancellation](https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/task-cancellation) and [recommended `CancellationToken` patterns](https://devblogs.microsoft.com/premier-developer/recommended-patterns-for-cancellationtoken/). +- Intermediate layers must normally allow `OperationCanceledException` to propagate. They may catch it to restore state, but must then rethrow it with `throw;`. +- The component that creates the operation's `CancellationTokenSource`, or the lifecycle boundary explicitly given control of that source, owns the operation's cancellation. Receiving a token as a method parameter does not by itself make that method the owner. +- Only the lifetime owner may consume cancellation. It must verify both that its token was requested and that the exception carries the token for that operation: `catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested && exception.CancellationToken == cancellationToken)`. Integrations that cannot preserve token identity require a narrowly scoped, documented adapter; cancellation timing alone is not sufficient evidence. +- Never use `catch (Exception) when (cancellationToken.IsCancellationRequested)`. A cancellation request does not prove that an unrelated exception was caused by cancellation. +- Cancellation must not leave state inconsistent. After an operation has passed its point of no cancellation or has completed its side effects, it must finish or roll back rather than report cancellation merely because the token was subsequently signaled. +- An `OperationCanceledException` that is not attributable to the owning token must follow the expected- or unexpected-error policy appropriate to its known cause; it must not be silently treated as caller cancellation. + +#### Unexpected Exceptions + +- Lower layers must allow unexpected exceptions to propagate unchanged. They must not swallow them, convert them into `OperationFailed`, or log and rethrow them. +- `Program.Main` is the single process-level unexpected-exception boundary. Its protected execution includes bootstrap and dependency composition, CLI or TUI execution, and asynchronous disposal. An unexpected exception reaching this boundary is recorded once, receives generic user-facing output, selects a non-zero exit code, and terminates the application. The application must not attempt to continue. +- The logging implementation used by this boundary must be owned outside the dependency container lifetime it reports on, so failures from host execution or asynchronous container disposal can still be recorded before logging is flushed and disposed. +- `CLIApplication`, `TerminalApp`, sessions, commands, views, page logic, application handlers, and infrastructure services are not final unexpected-exception boundaries. They must not log, present, or convert an unexpected exception merely because they are the outermost type in their project or feature. +- A general `catch (Exception)` below `Program.Main` is permitted only when it is necessary to restore an invariant or roll back, perform best-effort cleanup before rethrowing, or transfer the same failure into an awaited completion mechanism such as `TaskCompletionSource`. Such a catch must not classify the failure as expected, continue normal execution, or create a duplicate log entry. When an exception must cross a callback boundary, preserve its traceback with `throw;` or [`ExceptionDispatchInfo`](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.exceptionservices.exceptiondispatchinfo). +- `Program.Main` may consume an `OperationCanceledException` as normal process cancellation only if the entry point owns the matching process-lifetime token. Otherwise an escaped cancellation exception is handled by the unexpected-exception boundary. +- User-facing unexpected-error output must not expose stack traces, exception messages, paths, or other internal details. Complete diagnostic detail belongs only in the single boundary log entry. +- `AppDomain.UnhandledException` and `TaskScheduler.UnobservedTaskException` are diagnostic fallbacks, not application error-handling boundaries. An unhandled exception notification can occur while the process is terminating and is not a safe recovery point. See [.NET guidance for `AppDomain.UnhandledException`](https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-appdomain-unhandledexception) and [`TaskScheduler.UnobservedTaskException`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler.unobservedtaskexception). -### 1. Implementing a Feature +#### Asynchronous Exception Propagation -1. Read the user's requirements and inspect the relevant existing code, configuration, content, and documentation. -2. Before editing implementation files, present a concrete plan that describes the intended behavior, affected areas, and validation steps. -3. Do not modify source code or other implementation files, and do not run commands that rewrite or generate files, until the user explicitly approves the plan and authorizes implementation. Read-only inspection is allowed. -4. After approval, implement only the approved scope. If a materially different approach or broader scope becomes necessary, explain the change and request approval again before proceeding. -5. Validate the implementation with the commands required by this document and report the result. +- Every asynchronous operation that can fail must return a `Task` or `Task` and must be awaited or otherwise explicitly observed by the component that owns its lifetime. Exceptions from asynchronous methods are stored in their tasks and propagate when those tasks are awaited. See [asynchronous exception handling](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/#handle-asynchronous-exceptions). +- Fire-and-forget tasks that can fault are prohibited. Keeping a task in a field is sufficient only when the owning lifecycle reliably awaits it and propagates its failure. Waiting only for eventual disposal is insufficient when an unexpected failure is required to terminate the currently running application; the failure must be connected promptly to that lifecycle. +- `async void` is permitted only for genuine event handlers. The handler must contain minimal adapter code and await a `Task`-returning operation that holds the testable workflow. A caller cannot await an `async void` method or catch exceptions from it directly; those exceptions are raised through the current `SynchronizationContext`. See the guidance for [async return types](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types#void-return-type). +- For the pinned Terminal.Gui `2.4.18-develop.53` version, `MainLoopSyncContext` posts asynchronous continuations back through the application loop, and `IApplication.RunAsync` with no error handler rethrows an unhandled loop exception through its returned task. The session must await that task, and no error handler may resume the loop after an unexpected exception. See the package's pinned commit sources for [`MainLoopSyncContext`](https://github.com/tui-cs/Terminal.Gui/blob/48efa0c5c005763de035f2ee4afed28b7df98db3/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs) and [`ApplicationImpl.RunAsync`](https://github.com/tui-cs/Terminal.Gui/blob/48efa0c5c005763de035f2ee4afed28b7df98db3/Terminal.Gui/App/ApplicationImpl.Run.cs). +- Every callback-to-task bridge must complete for success, cancellation, and failure. Transferring an unexpected exception into an awaited `TaskCompletionSource` is propagation, not handling; the receiving lifecycle must still terminate and pass it to `Program.Main`. +- An unexpected exception from background work must be connected to the awaited application lifecycle and ultimately reach `Program.Main`. Catching and swallowing it merely to keep the UI or command running violates this policy. -### 2. Fixing a Bug +### Application Configuration -1. Start from the user's problem description. Inspect the relevant code and, when practical, reproduce the issue using read-only or non-mutating diagnostics. -2. Identify the root cause and support the diagnosis with concrete evidence from the code or reproduction. Do not present an unverified guess as the cause. -3. Explain the root cause, affected behavior, proposed fix, and validation plan to the user. Wait for explicit approval before modifying source code or other implementation files. -4. After approval, implement the fix within the agreed scope, add or update regression coverage when appropriate, and run the required validation commands. +- `AppConfig` is the static source for application identity, version, and fixed application directories. +- `AppRuntimeConfig` is the singleton source for mutable process-level settings such as verbose logging. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 472cfe5d..00000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,132 +0,0 @@ -# ARCHITECTURE.md - -This document describes the architecture of Unity Assets Patcher. It serves as a critical, living template designed to equip agents with a rapid and comprehensive understanding of the codebase's architecture, enabling efficient navigation and effective contribution from day one. Update this document as the codebase evolves. - -## Project Structure - -This section provides a high-level overview of the project's directory and file structure, categorised by architectural layer or major functional area. It is essential for quickly navigating the codebase, locating relevant files, and understanding the overall organization and separation of concerns. - -[Project Root]/ -├── .github/ # GitHub Actions or other CI/CD configurations -├── docs/ # User and mod author documentation -├── schema/ # Mod manifest schema -├── src/ -│ ├── UnityAssetsPatcher/ # Executable entry point and composition root -│ │ ├── Assets/ -│ │ └── Logging/ -│ ├── UnityAssetsPatcher.Application/ # Use cases, workflows, DTOs, and infrastructure abstractions -│ │ ├── Assets/ -│ │ ├── Composition/ -│ │ ├── Contracts/ -│ │ ├── Features/ -│ │ ├── Installation/ -│ │ ├── IO/ -│ │ ├── Messaging/ -│ │ ├── Mods/ -│ │ ├── Operations/ -│ │ ├── Patching/ -│ │ ├── Repository/ -│ │ ├── Uninstallation/ -│ │ ├── Updates/ -│ │ ├── AppConfig.cs -│ │ └── AppRuntimeConfig.cs -│ ├── UnityAssetsPatcher.CLI/ # Non-interactive command parsing and presentation -│ ├── UnityAssetsPatcher.Domain/ # Domain models, value objects, and validation rules -│ │ ├── Assets/ -│ │ ├── Integrity/ -│ │ └── Json/ -│ ├── UnityAssetsPatcher.Infrastructure/ # File IO, compression, persistence, packages, and AssetsTools.NET adapters -│ │ ├── AssetsTools/ -│ │ ├── Installation/ -│ │ ├── IO/ -│ │ ├── Mods/ -│ │ ├── Repository/ -│ │ └── Updates/ -│ ├── UnityAssetsPatcher.LocalizationGenerator/ # Roslyn source generator for localized strings -│ └── UnityAssetsPatcher.TUI/ # Terminal.Gui interactive UI and localization -│ ├── Flows/ -│ ├── Framework/ -│ ├── Lifecycle/ -│ ├── Localization/ -│ ├── Navigation/ -│ ├── Pages/ -│ ├── Shell/ -│ └── UnityAssetsPatcher.TUI.csproj -├── tests/ # Unit and integration tests -├── .editorconfig -├── .gitattributes -├── .gitignore -├── AGENTS.md -├── ARCHITECTURE.md -├── CHANGELOG.md -├── global.json -├── LICENSE -├── README.md -├── README_ZH.md -└── UnityAssetsPatcher.slnx - -## Infrastructure - -- Infrastructure implementations must propagate original exceptions from underlying platform APIs and third-party libraries without wrapping, translating, or replacing them. - -## Error handling - -The exception policy follows the [.NET best practices for exceptions](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions) and [CA1031](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1031). Exceptions are divided into expected operational failures, cancellation, and unexpected exceptions. These categories must not be inferred from timing or from a broad `catch`; they are determined by an explicit contract and by ownership of the operation. - -### Expected failures - -- Expected operational failures must be represented by `OperationFailed` and handled as ordinary control flow by callers. -- A failure is expected only when the application has deliberately defined its meaning and recovery or presentation behavior. The fact that an exception came from I/O, a third-party library, or user input does not by itself make it expected. -- Catch only specific exception types whose meaning is known at that layer and whose conversion to an application error is intentional. Do not use `catch (Exception)` to manufacture an `OperationFailed` result for an unknown defect. -- Infrastructure implementations must propagate original exceptions. The application layer is responsible for translating known infrastructure failures into `OperationFailed` results. -- Cleanup must use `using`, `await using`, or `finally` whenever possible. If a broad catch is required to roll back or restore state, it must rethrow with `throw;` after restoring the invariant. If rollback also fails, the propagated exception must preserve both failures rather than discard the original one. See [how to use `finally` blocks](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/how-to-use-finally-blocks) and [how to rethrow exceptions without losing stack information](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions#capture-and-rethrow-exceptions-properly). - -### Cancellation - -- Cancellation is cooperative control flow, not an operational failure or an unexpected error. Use `CancellationToken`, propagate it through every cancellable call, and use `ThrowIfCancellationRequested()` when the operation itself must acknowledge cancellation. See [.NET task cancellation](https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/task-cancellation) and [recommended `CancellationToken` patterns](https://devblogs.microsoft.com/premier-developer/recommended-patterns-for-cancellationtoken/). -- Intermediate layers must normally allow `OperationCanceledException` to propagate. They may catch it to restore state, but must then rethrow it with `throw;`. -- The component that creates the operation's `CancellationTokenSource`, or the lifecycle boundary explicitly given control of that source, owns the operation's cancellation. Receiving a token as a method parameter does not by itself make that method the owner. -- Only the lifetime owner may consume cancellation. It must verify both that its token was requested and that the exception carries the token for that operation: `catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested && exception.CancellationToken == cancellationToken)`. Integrations that cannot preserve token identity require a narrowly scoped, documented adapter; cancellation timing alone is not sufficient evidence. -- Never use `catch (Exception) when (cancellationToken.IsCancellationRequested)`. A cancellation request does not prove that an unrelated exception was caused by cancellation. -- Cancellation must not leave state inconsistent. After an operation has passed its point of no cancellation or has completed its side effects, it must finish or roll back rather than report cancellation merely because the token was subsequently signaled. -- An `OperationCanceledException` that is not attributable to the owning token must follow the expected- or unexpected-error policy appropriate to its known cause; it must not be silently treated as caller cancellation. - -### Unexpected exceptions - -- Lower layers must allow unexpected exceptions to propagate unchanged. They must not swallow them, convert them into `OperationFailed`, or log and rethrow them. -- `Program.Main` is the single process-level unexpected-exception boundary. Its protected execution includes bootstrap and dependency composition, CLI or TUI execution, and asynchronous disposal. An unexpected exception reaching this boundary is recorded once, receives generic user-facing output, selects a non-zero exit code, and terminates the application. The application must not attempt to continue. -- The logging implementation used by this boundary must be owned outside the dependency container lifetime it reports on, so failures from host execution or asynchronous container disposal can still be recorded before logging is flushed and disposed. -- `CLIApplication`, `TerminalApp`, sessions, commands, views, page logic, application handlers, and infrastructure services are not final unexpected-exception boundaries. They must not log, present, or convert an unexpected exception merely because they are the outermost type in their project or feature. -- A general `catch (Exception)` below `Program.Main` is permitted only when it is necessary to restore an invariant or roll back, perform best-effort cleanup before rethrowing, or transfer the same failure into an awaited completion mechanism such as `TaskCompletionSource`. Such a catch must not classify the failure as expected, continue normal execution, or create a duplicate log entry. When an exception must cross a callback boundary, preserve its traceback with `throw;` or [`ExceptionDispatchInfo`](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.exceptionservices.exceptiondispatchinfo). -- `Program.Main` may consume an `OperationCanceledException` as normal process cancellation only if the entry point owns the matching process-lifetime token. Otherwise an escaped cancellation exception is handled by the unexpected-exception boundary. -- User-facing unexpected-error output must not expose stack traces, exception messages, paths, or other internal details. Complete diagnostic detail belongs only in the single boundary log entry. -- `AppDomain.UnhandledException` and `TaskScheduler.UnobservedTaskException` are diagnostic fallbacks, not application error-handling boundaries. An unhandled exception notification can occur while the process is terminating and is not a safe recovery point. See [.NET guidance for `AppDomain.UnhandledException`](https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-appdomain-unhandledexception) and [`TaskScheduler.UnobservedTaskException`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler.unobservedtaskexception). - -### Asynchronous exception propagation - -- Every asynchronous operation that can fail must return a `Task` or `Task` and must be awaited or otherwise explicitly observed by the component that owns its lifetime. Exceptions from asynchronous methods are stored in their tasks and propagate when those tasks are awaited. See [asynchronous exception handling](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/#handle-asynchronous-exceptions). -- Fire-and-forget tasks that can fault are prohibited. Keeping a task in a field is sufficient only when the owning lifecycle reliably awaits it and propagates its failure. Waiting only for eventual disposal is insufficient when an unexpected failure is required to terminate the currently running application; the failure must be connected promptly to that lifecycle. -- `async void` is permitted only for genuine event handlers. The handler must contain minimal adapter code and await a `Task`-returning operation that holds the testable workflow. A caller cannot await an `async void` method or catch exceptions from it directly; those exceptions are raised through the current `SynchronizationContext`. See the guidance for [async return types](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types#void-return-type). -- For the pinned Terminal.Gui `2.4.18-develop.53` version, `MainLoopSyncContext` posts asynchronous continuations back through the application loop, and `IApplication.RunAsync` with no error handler rethrows an unhandled loop exception through its returned task. The session must await that task, and no error handler may resume the loop after an unexpected exception. See the package's pinned commit sources for [`MainLoopSyncContext`](https://github.com/tui-cs/Terminal.Gui/blob/48efa0c5c005763de035f2ee4afed28b7df98db3/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs) and [`ApplicationImpl.RunAsync`](https://github.com/tui-cs/Terminal.Gui/blob/48efa0c5c005763de035f2ee4afed28b7df98db3/Terminal.Gui/App/ApplicationImpl.Run.cs). -- Every callback-to-task bridge must complete for success, cancellation, and failure. Transferring an unexpected exception into an awaited `TaskCompletionSource` is propagation, not handling; the receiving lifecycle must still terminate and pass it to `Program.Main`. -- An unexpected exception from background work must be connected to the awaited application lifecycle and ultimately reach `Program.Main`. Catching and swallowing it merely to keep the UI or command running violates this policy. - -## Application configuration - -- `AppConfig` is the static source for application identity, version, and fixed application directories. -- `AppRuntimeConfig` is the singleton source for mutable process-level settings such as verbose logging. - -## Terminal user interface - -- Pages that initiate application work must be split into a `*View` and a corresponding `*Logic`. -- Views own Terminal.Gui controls, layout, input collection, event forwarding, and rendering only. Views must not resolve application services, dispatch requests, or orchestrate application workflows. -- Page logic owns application request dispatch, state transitions, re-entry protection, expected operation-result handling, cancellation, and operation lifetime. Page logic must not handle or log unexpected exceptions; those exceptions must propagate through an observed task and the awaited Terminal.Gui lifecycle to the `Program.Main` process boundary. Page logic must not reference Terminal.Gui types or access views. -- Page logic must expose immutable presentation state or results that views can render, and must remain independently testable without a running Terminal.Gui application. -- Terminal.Gui controls may only be created, mutated, focused, navigated, rendered, or disposed on the UI thread. -- Views must marshal notifications received from background threads through `View.App.Invoke` or `IApplication.Invoke` before applying them to the UI. -- Application dispatch and other work that may perform CPU-intensive or synchronous blocking operations must be started on a worker thread by page logic. A method returning `Task` must not be assumed non-blocking when it can perform work before returning an incomplete task. -- Genuinely asynchronous I/O should be awaited directly. `Task.Yield` must not be used as a substitute for moving CPU-intensive work off the UI thread. -- Terminal.Gui event handlers must remain short and non-blocking. A genuine asynchronous event handler may be `async void`, but it must await the `Task` returned by page logic and must not consume unexpected exceptions. Unobserved fire-and-forget tasks are prohibited. -- Page logic must prevent unintended concurrent operations, link work to the page or session cancellation token, and prevent completed background work from updating a disposed view. -- Service scopes used by background operations must be created, used, and disposed within the operation lifetime. -- Background execution and operation lifetime must be owned explicitly by each page logic; a shared global task runner must not be reintroduced. diff --git a/UnityAssetsPatcher.slnx b/UnityAssetsPatcher.slnx index ec953850..d7ce9e84 100644 --- a/UnityAssetsPatcher.slnx +++ b/UnityAssetsPatcher.slnx @@ -2,19 +2,13 @@ - + - - - - - diff --git a/localization/Strings.resx b/localization/Strings.resx new file mode 100644 index 00000000..64ec21f5 --- /dev/null +++ b/localization/Strings.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 2.0 + + + Install Mod + + + Settings + + + Manage Mods + + + About + + + Drag a mod package into the area below, or click to select a file. + + + Drop a mod package here + + + or click to select a file + + + Supports .zip mod packages + + diff --git a/localization/Strings.zh-CN.resx b/localization/Strings.zh-CN.resx new file mode 100644 index 00000000..f1b77fbc --- /dev/null +++ b/localization/Strings.zh-CN.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 2.0 + + + 安装 Mod + + + 设置 + + + 管理 Mod + + + 关于 + + + 拖拽 Mod 包到下方区域,或点击选择文件。 + + + 拖拽 Mod 包到这里 + + + 或点击选择文件 + + + 支持 .zip 格式的 Mod 包 + + diff --git a/src/UnityAssetsPatcher.Application/AppConfig.cs b/src/UnityAssetsPatcher.Application/AppConfig.cs index ae4bdd0d..744fbe6d 100644 --- a/src/UnityAssetsPatcher.Application/AppConfig.cs +++ b/src/UnityAssetsPatcher.Application/AppConfig.cs @@ -4,12 +4,11 @@ namespace UnityAssetsPatcher.Application; public static class AppConfig { - public const string Name = "Unity Assets Patcher"; - public const string Identifier = "UnityAssetsPatcher"; + public const string Name = "UnityAssetsPatcher"; public static string DisplayVersion { get; } = GetVersion(); public static string ApplicationDataDirectory { get; } = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Identifier); + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Name); public static string LogDirectory { get; } = Path.Combine(ApplicationDataDirectory, "logs"); public static string RepositoryDirectory { get; } = Path.Combine(ApplicationDataDirectory, "backup"); diff --git a/src/UnityAssetsPatcher.Application/Uninstallation/UninstallCompositionService.cs b/src/UnityAssetsPatcher.Application/Uninstallation/UninstallCompositionService.cs index 1a51cb4a..5a57cccb 100644 --- a/src/UnityAssetsPatcher.Application/Uninstallation/UninstallCompositionService.cs +++ b/src/UnityAssetsPatcher.Application/Uninstallation/UninstallCompositionService.cs @@ -78,7 +78,8 @@ public async Task AnalyzeAsync( if (!TrustedPath.PathComparer.Equals(targetLayer.GameInstanceFingerprint, fingerprint)) { - throw new UninstallValidationException("The selected game directory does not match the layer game instance."); + throw new UninstallValidationException( + "The selected game directory does not match the layer game instance."); } string normalizedWorkingDirectory = _pathResolver.ResolveExistingDirectory(workingDirectory); diff --git a/src/UnityAssetsPatcher.CLI/CLIApplication.cs b/src/UnityAssetsPatcher.CLI/CLIApplication.cs deleted file mode 100644 index d0037168..00000000 --- a/src/UnityAssetsPatcher.CLI/CLIApplication.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.CommandLine; -using System.CommandLine.Invocation; -using System.CommandLine.Parsing; - -namespace UnityAssetsPatcher.CLI; - -public sealed class CLIApplication -{ - private readonly RootCommand _rootCommand; - private readonly InvocationConfiguration _invocationConfiguration; - private readonly TextWriter _error; - private readonly CLIOptions? _options; - - public CLIApplication( - IEnumerable commands, - TextWriter output, - TextWriter error, - CLIOptions? options = null) - { - ArgumentNullException.ThrowIfNull(commands); - ArgumentNullException.ThrowIfNull(output); - ArgumentNullException.ThrowIfNull(error); - - _rootCommand = new RootCommand("Inspect, install, and uninstall Unity assets file mods."); - _error = error; - _options = options; - - if (options is not null) - { - _rootCommand.Options.Add(options.Format); - } - - foreach (ICLICommand command in commands) - { - _rootCommand.Subcommands.Add(command.Command); - } - - _invocationConfiguration = new InvocationConfiguration - { - Output = output, - Error = error, - EnableDefaultExceptionHandler = false - }; - } - - public int Run(IReadOnlyList arguments) - { - return RunAsync(arguments).GetAwaiter().GetResult(); - } - - public async Task RunAsync( - IReadOnlyList arguments, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(arguments); - - ParseResult parseResult = _rootCommand.Parse(arguments); - - if (parseResult.Errors.Count == 0) - { - return await parseResult - .InvokeAsync(_invocationConfiguration, cancellationToken) - .ConfigureAwait(false); - } - - if (parseResult.Action is ParseErrorAction parseErrorAction) - { - parseErrorAction.ShowHelp = true; - } - - if (_options is not null && parseResult.GetValue(_options.Format) == CLIOutputFormat.Json) - { - CLIOutput.WriteUsageFailure( - _error, - GetCommandName(parseResult), - parseResult.Errors.Select(error => error.Message)); - - return CLIExitCodes.UsageError; - } - - var errorConfiguration = new InvocationConfiguration - { - Output = _error, - Error = _error, - EnableDefaultExceptionHandler = false - }; - - await parseResult.InvokeAsync(errorConfiguration, cancellationToken).ConfigureAwait(false); - - return CLIExitCodes.UsageError; - } - - private static string GetCommandName(ParseResult parseResult) - { - var names = new List(); - - for (CommandResult current = parseResult.CommandResult; - !ReferenceEquals(current, parseResult.RootCommandResult); - current = (CommandResult)current.Parent!) - { - names.Add(current.Command.Name); - } - - names.Reverse(); - - return names.Count == 0 ? "root" : string.Join('.', names); - } -} diff --git a/src/UnityAssetsPatcher.CLI/CLIExitCodes.cs b/src/UnityAssetsPatcher.CLI/CLIExitCodes.cs deleted file mode 100644 index 4eb6d534..00000000 --- a/src/UnityAssetsPatcher.CLI/CLIExitCodes.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace UnityAssetsPatcher.CLI; - -internal static class CLIExitCodes -{ - public const int Success = 0; - public const int OperationFailed = 1; - public const int UsageError = 2; -} diff --git a/src/UnityAssetsPatcher.CLI/CLIOptions.cs b/src/UnityAssetsPatcher.CLI/CLIOptions.cs deleted file mode 100644 index 06e101cc..00000000 --- a/src/UnityAssetsPatcher.CLI/CLIOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.CommandLine; - -namespace UnityAssetsPatcher.CLI; - -public enum CLIOutputFormat -{ - Text, - Json -} - -public sealed class CLIOptions -{ - public Option Format { get; } = new("--format") - { - Description = "Output format.", - DefaultValueFactory = _ => CLIOutputFormat.Text, - Recursive = true - }; -} diff --git a/src/UnityAssetsPatcher.CLI/CLIOutput.cs b/src/UnityAssetsPatcher.CLI/CLIOutput.cs deleted file mode 100644 index faafa370..00000000 --- a/src/UnityAssetsPatcher.CLI/CLIOutput.cs +++ /dev/null @@ -1,860 +0,0 @@ -using System.Collections; -using System.CommandLine; -using System.Globalization; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.Application.Assets; -using UnityAssetsPatcher.Application.Features.Inspect; -using UnityAssetsPatcher.Application.Features.Install; -using UnityAssetsPatcher.Application.Features.Uninstall; -using UnityAssetsPatcher.Application.IO; -using UnityAssetsPatcher.Application.Installation; -using UnityAssetsPatcher.Application.Mods; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Application.Patching; -using UnityAssetsPatcher.Application.Repository; -using UnityAssetsPatcher.Domain.Assets; - -namespace UnityAssetsPatcher.CLI; - -internal static class CLIOutput -{ - public static int WriteSuccess( - ParseResult parseResult, - CLIOptions options, - string command, - JsonObject data, - Action writeText) - { - if (parseResult.GetValue(options.Format) == CLIOutputFormat.Json) - { - WriteJson(parseResult.InvocationConfiguration.Output, new JsonObject - { - ["schemaVersion"] = 1, - ["success"] = true, - ["command"] = command, - ["data"] = data - }); - } - else - { - writeText(parseResult.InvocationConfiguration.Output); - } - - return 0; - } - - public static int WriteResult( - ParseResult parseResult, - CLIOptions options, - string command, - OperationResult result, - Func createJson, - Action writeText) - { - return result switch - { - OperationSucceeded succeeded => WriteSuccess( - parseResult, - options, - command, - createJson(succeeded.Value), - output => writeText(output, succeeded.Value)), - OperationFailed failed => WriteFailure(parseResult, options, command, failed.Error), - _ => throw new ArgumentOutOfRangeException(nameof(result)) - }; - } - - public static int WriteFailure( - ParseResult parseResult, - CLIOptions options, - string command, - OperationError error) - { - TextWriter output = parseResult.InvocationConfiguration.Error; - string code = error.Code.Value; - string message = OperationErrorText.Format(error); - - if (parseResult.GetValue(options.Format) == CLIOutputFormat.Json) - { - JsonObject envelope = ErrorEnvelope(command, code, message, []); - envelope["error"]!["parameters"] = new JsonObject( - error.Parameters.Select(parameter => - KeyValuePair.Create(parameter.Key, ToJsonNode(parameter.Value)))); - if (error.Recovery is not null) - { - envelope["recovery"] = Recovery(error.Recovery); - } - - WriteJson(output, envelope); - } - else - { - OperationErrorText.WriteTextFailure(output, error, false); - } - - return 1; - } - - public static void WriteFailure(TextWriter error, OperationError operationError) - { - ArgumentNullException.ThrowIfNull(error); - ArgumentNullException.ThrowIfNull(operationError); - - OperationErrorText.WriteTextFailure(error, operationError, true); - } - - public static void WriteUsageFailure(TextWriter error, string command, IEnumerable messages) - { - string[] details = messages.ToArray(); - WriteJson(error, new JsonObject - { - ["schemaVersion"] = 1, - ["success"] = false, - ["command"] = command, - ["error"] = new JsonObject - { - ["code"] = "usage_error", - ["message"] = details.FirstOrDefault() ?? "Invalid command-line arguments.", - ["causes"] = - new JsonArray(details.Skip(1).Select(value => JsonValue.Create(value)).ToArray()) - } - }); - } - - public static JsonObject InspectList(string path, InspectListResult result) - { - return new JsonObject - { - ["assetsFilePath"] = path, - ["totalCount"] = result.TotalCount, - ["returnedCount"] = result.Assets.Count, - ["assets"] = new JsonArray(result.Assets.Select(asset => new JsonObject - { - ["pathId"] = asset.PathId, - ["typeName"] = asset.TypeName, - ["name"] = asset.Name - }).ToArray()) - }; - } - - public static JsonObject InspectFields(string path, long pathId, AssetField fieldTree) - { - return new JsonObject - { - ["assetsFilePath"] = path, - ["pathId"] = pathId, - ["fieldTree"] = InspectField(fieldTree) - }; - } - - public static void WriteInspectListText(TextWriter output, InspectListResult result) - { - output.WriteLine("Path ID\tType Name\tName"); - foreach (InspectAssetSummary asset in result.Assets) - { - output.WriteLine( - $"{asset.PathId.ToString(CultureInfo.InvariantCulture)}\t{asset.TypeName}\t{asset.Name}"); - } - - if (result.Assets.Count < result.TotalCount) - { - output.WriteLine(); - output.WriteLine($"Showing {result.Assets.Count} of {result.TotalCount} assets."); - } - } - - public static void WriteInspectFieldsText(TextWriter output, AssetField fieldTree) - { - WriteInspectFieldText(output, fieldTree, 0); - } - - private static JsonObject InspectField(AssetField field) - { - return new JsonObject - { - ["name"] = field.Name, - ["typeName"] = field.TypeName, - ["value"] = field.Value?.ToInvariantString(), - ["children"] = new JsonArray(field.Children.Select(InspectField).ToArray()) - }; - } - - private static void WriteInspectFieldText(TextWriter output, AssetField field, int depth) - { - string value = field.Value is null ? string.Empty : $": {field.Value.ToInvariantString()}"; - output.WriteLine($"{new string(' ', depth * 2)}{field.Name} ({field.TypeName}){value}"); - - foreach (AssetField child in field.Children) - { - WriteInspectFieldText(output, child, depth + 1); - } - } - - public static JsonObject InstallPreview(InstallPreviewResult result) - { - return new JsonObject - { - ["mod"] = Mod(result.ModName, result.ModVersion, result.ModAuthor), - ["changes"] = Changes(result.Changes), - ["optionalGroups"] = new JsonArray(result.OptionalGroups.Select(group => new JsonObject - { - ["name"] = group.Name, - ["description"] = group.Description - }).ToArray()), - ["timing"] = Timing(result.Timing) - }; - } - - public static JsonObject InstallResult(InstallModResult result) - { - return new JsonObject - { - ["installId"] = result.InstallId, - ["mod"] = Mod(result.ModName, result.ModVersion), - ["changes"] = Changes(result.Changes), - ["optionalGroups"] = - new JsonArray(result.OptionalGroups.Select(value => JsonValue.Create(value)).ToArray()), - ["timing"] = Timing(result.Timing), - ["recovery"] = Recovery(result.Recovery) - }; - } - - public static JsonObject InstalledMods(IReadOnlyList installed) - { - return new JsonObject - { - ["mods"] = new JsonArray(installed.Select(record => new JsonObject - { - ["installId"] = record.InstallId, - ["name"] = record.ModName, - ["version"] = record.ModVersion, - ["game"] = record.GameName, - ["installedAt"] = record.InstalledAt.ToString("O") - }).ToArray()) - }; - } - - public static JsonObject RepositoryClear(RepositoryClearResult result) - { - return new JsonObject - { - ["previousFormatVersion"] = result.PreviousFormatVersion, - ["formatVersion"] = result.FormatVersion - }; - } - - public static JsonObject UninstallPreview(UninstallPreviewResult result) - { - return new JsonObject - { - ["installId"] = result.InstallId, - ["mod"] = Mod(result.ModName, result.ModVersion), - ["installedAt"] = result.InstalledAt.ToString("O"), - ["gameDirectory"] = result.GameDirectory, - ["canUninstall"] = result.CanUninstall, - ["dependencyFailures"] = new JsonArray(result.DependencyFailures.Select(failure => new JsonObject - { - ["name"] = failure.ModName, - ["version"] = failure.ModVersion, - ["relativePath"] = failure.RelativePath, - ["diagnostic"] = new JsonObject - { - ["code"] = EnumName(failure.Diagnostic.Code), - ["assetsFilePath"] = failure.Diagnostic.AssetsFilePath, - ["pathId"] = failure.Diagnostic.PathId, - ["fieldPath"] = failure.Diagnostic.FieldPath, - ["expected"] = failure.Diagnostic.Expected, - ["actual"] = failure.Diagnostic.Actual, - ["detail"] = failure.Diagnostic.Detail - } - }).ToArray()), - ["changedFiles"] = new JsonArray(result.ChangedFiles.Select(file => new JsonObject - { - ["relativePath"] = file.RelativePath, - ["action"] = EnumName(file.Action), - ["status"] = EnumName(file.Status) - }).ToArray()) - }; - } - - public static JsonObject UninstallResult(UninstallModResult result) - { - return new JsonObject - { - ["installId"] = result.InstallId, - ["mod"] = Mod(result.ModName, result.ModVersion), - ["changedFiles"] = new JsonArray(result.ChangedFiles.Select(file => new JsonObject - { - ["relativePath"] = file.RelativePath, - ["action"] = EnumName(file.Action), - ["status"] = EnumName(file.Status) - }).ToArray()), - ["recovery"] = Recovery(result.Recovery) - }; - } - - public static void WriteInstallPreviewText(TextWriter output, InstallPreviewResult result) - { - output.WriteLine($"Preview: {result.ModName} {result.ModVersion} by {result.ModAuthor}"); - WriteChanges(output, result.Changes, true); - - if (result.OptionalGroups.Count > 0) - { - output.WriteLine("Optional groups:"); - foreach ((string name, string? description) in result.OptionalGroups) - { - output.WriteLine(description is null ? $"- {name}" : $"- {name}: {description}"); - } - } - } - - public static void WriteInstallResultText(TextWriter output, InstallModResult result) - { - WriteRecoveryText(output, result.Recovery); - output.WriteLine($"Installed: {result.ModName} {result.ModVersion}"); - output.WriteLine($"Install ID: {result.InstallId}"); - WriteChanges(output, result.Changes, false); - } - - public static void WriteInstalledModsText(TextWriter output, IReadOnlyList installed) - { - if (installed.Count == 0) - { - output.WriteLine("No installed mods."); - return; - } - - foreach (InstallRecordSummary record in installed) - { - string game = record.GameName is null ? string.Empty : $" | {record.GameName}"; - output.WriteLine( - $"{record.InstallId} | {record.ModName} {record.ModVersion}{game} | {record.InstalledAt:O}"); - } - } - - public static void WriteRepositoryClearText(TextWriter output, RepositoryClearResult result) - { - output.WriteLine( - $"Cleared unsupported backup repository format {result.PreviousFormatVersion} and initialized format {result.FormatVersion}."); - } - - public static void WriteUninstallPreviewText(TextWriter output, UninstallPreviewResult result) - { - output.WriteLine($"Preview uninstall: {result.ModName} {result.ModVersion}"); - output.WriteLine($"Install ID: {result.InstallId}"); - output.WriteLine($"Game directory: {result.GameDirectory}"); - if (result.CanUninstall) - { - output.WriteLine( - $"Can uninstall: yes; {result.ChangedFiles.Count} file(s) will be recomposed or restored."); - } - else if (result.DependencyFailures.Count > 0) - { - output.WriteLine("Can uninstall: no; real patch dependencies were found."); - } - else - { - output.WriteLine("Can uninstall: no; current game files do not match the active composition."); - } - - foreach (UninstallChangedFileResult file in result.ChangedFiles) - { - output.WriteLine($"- {EnumName(file.Action)} {file.RelativePath}: {EnumName(file.Status)}"); - } - - if (result.DependencyFailures.Count > 0) - { - output.WriteLine("Real patch dependencies:"); - foreach (UninstallDependencyFailureResult failure in result.DependencyFailures) - { - PatchDiagnostic diagnostic = failure.Diagnostic; - output.WriteLine( - $"- {failure.ModName} {failure.ModVersion} at {failure.RelativePath}: " + - $"{EnumName(diagnostic.Code)}{FormatDiagnosticValues(diagnostic)}"); - } - } - } - - public static void WriteUninstallResultText(TextWriter output, UninstallModResult result) - { - WriteRecoveryText(output, result.Recovery); - output.WriteLine($"Uninstalled: {result.ModName} {result.ModVersion}"); - output.WriteLine($"Install ID: {result.InstallId}"); - output.WriteLine($"Changed files: {result.ChangedFiles.Count}"); - foreach (UninstallChangedFileResult file in result.ChangedFiles) - { - output.WriteLine($"- {EnumName(file.Action)} {file.RelativePath}: {EnumName(file.Status)}"); - } - } - - private static string FormatDiagnosticValues(PatchDiagnostic diagnostic) - { - var values = new List(); - if (diagnostic.AssetsFilePath is not null) - { - values.Add($"file={diagnostic.AssetsFilePath}"); - } - - if (diagnostic.PathId is not null) - { - values.Add($"pathId={diagnostic.PathId}"); - } - - if (diagnostic.FieldPath is not null) - { - values.Add($"field={diagnostic.FieldPath}"); - } - - if (diagnostic.Expected is not null) - { - values.Add($"expected={diagnostic.Expected}"); - } - - if (diagnostic.Actual is not null) - { - values.Add($"actual={diagnostic.Actual}"); - } - - if (diagnostic.Detail is not null) - { - values.Add($"detail={diagnostic.Detail}"); - } - - return values.Count == 0 ? string.Empty : $" ({string.Join(", ", values)})"; - } - - private static JsonNode? ToJsonNode(object? value) - { - JsonNode? node = value switch - { - null => null, - string text => JsonValue.Create(text), - bool boolean => JsonValue.Create(boolean), - byte number => JsonValue.Create(number), - sbyte number => JsonValue.Create(number), - short number => JsonValue.Create(number), - ushort number => JsonValue.Create(number), - int number => JsonValue.Create(number), - uint number => JsonValue.Create(number), - long number => JsonValue.Create(number), - ulong number => JsonValue.Create(number), - float number => JsonValue.Create(number), - double number => JsonValue.Create(number), - decimal number => JsonValue.Create(number), - char character => JsonValue.Create(character.ToString()), - _ => JsonValue.Create(value.ToString()) - }; - - return node; - } - - private static JsonObject ErrorEnvelope( - string command, - string code, - string message, - IEnumerable causes) - { - return new JsonObject - { - ["schemaVersion"] = 1, - ["success"] = false, - ["command"] = command, - ["error"] = new JsonObject - { - ["code"] = code, - ["message"] = message, - ["causes"] = new JsonArray(causes.ToArray()) - } - }; - } - - private static void WriteJson(TextWriter output, JsonObject value) - { - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) - { - value.WriteTo(writer); - } - - output.WriteLine(Encoding.UTF8.GetString(stream.ToArray())); - } - - private static JsonObject Mod(string name, string version, string? author = null) - { - var result = new JsonObject - { - ["name"] = name, - ["version"] = version - }; - - if (author is not null) - { - result["author"] = author; - } - - return result; - } - - private static JsonArray Changes(IReadOnlyList changes) - { - return new JsonArray(changes.Select(change => - { - var json = new JsonObject - { - ["kind"] = EnumName(change.Kind), - ["name"] = change.Name, - ["path"] = change.Path, - ["backupPath"] = change.BackupPath, - ["assetCount"] = change.AssetCount, - ["operationCount"] = change.OperationCount - }; - - if (change.Preview is not null) - { - if (change.Preview.Diagnostic is { } diagnostic) - { - json["diagnostic"] = new JsonObject - { - ["code"] = EnumName(diagnostic.Code), - ["assetsFilePath"] = diagnostic.AssetsFilePath, - ["pathId"] = diagnostic.PathId, - ["fieldPath"] = diagnostic.FieldPath, - ["expected"] = diagnostic.Expected, - ["actual"] = diagnostic.Actual, - ["detail"] = diagnostic.Detail - }; - } - - json["assets"] = new JsonArray(change.Preview.Assets.Select(asset => new JsonObject - { - ["pathId"] = asset.Asset.PathId.Value, - ["typeName"] = asset.Asset.TypeName, - ["operations"] = new JsonArray(asset.Operations.Select(operation => new JsonObject - { - ["path"] = operation.Path, - ["oldValue"] = operation.OldValue, - ["from"] = operation.FromText, - ["to"] = operation.ToText, - ["willChange"] = operation.WillChange - }).ToArray()) - }).ToArray()); - } - - return json; - }).ToArray()); - } - - private static JsonObject Timing(TimingSnapshot timing) - { - return new JsonObject - { - ["elapsedMilliseconds"] = timing.Elapsed.TotalMilliseconds, - ["steps"] = new JsonArray(timing.Steps.Select(step => new JsonObject - { - ["name"] = step.Name, - ["elapsedMilliseconds"] = step.Elapsed.TotalMilliseconds - }).ToArray()) - }; - } - - public static JsonObject RecoveryPreview(RepositoryRecoveryPreview preview) - { - return new JsonObject - { - ["status"] = EnumName(preview.Status), - ["gameDirectory"] = preview.GameDirectory, - ["kind"] = preview.Kind, - ["installId"] = preview.InstallId, - ["action"] = preview.Action is null ? null : EnumName(preview.Action.Value), - ["canRecover"] = preview.CanRecover, - ["files"] = new JsonArray(preview.Files.Select(file => new JsonObject - { - ["relativePath"] = file.RelativePath, - ["action"] = EnumName(file.Action) - }).ToArray()), - ["issues"] = RecoveryIssues(preview.Issues) - }; - } - - public static JsonObject RecoveryReport(RepositoryRecoveryReport recovery) - { - return Recovery(recovery); - } - - public static void WriteRecoveryPreviewText(TextWriter output, RepositoryRecoveryPreview preview) - { - output.WriteLine($"Recovery preview: {EnumName(preview.Status)}"); - if (preview.GameDirectory is not null) - { - output.WriteLine($"Game directory: {preview.GameDirectory}"); - } - - if (preview.Kind is not null) - { - output.WriteLine($"Transaction: {preview.Kind} {preview.InstallId}"); - } - - if (preview.Action is not null) - { - output.WriteLine($"Action: {EnumName(preview.Action.Value)}"); - } - - foreach (RepositoryRecoveryFileChange file in preview.Files) - { - output.WriteLine($"- {EnumName(file.Action)}: {file.RelativePath}"); - } - - foreach (RepositoryRecoveryIssue issue in preview.Issues) - { - output.WriteLine($"- {RecoveryIssueCode(issue.Code)}: {RecoveryIssueText(issue)} ({issue.Path})"); - } - } - - public static void WriteRecoveryReportText(TextWriter output, RepositoryRecoveryReport recovery) - { - WriteRecoveryText(output, recovery); - } - - private static JsonObject Recovery(RepositoryRecoveryReport recovery) - { - return new JsonObject - { - ["status"] = EnumName(recovery.Status), - ["operations"] = new JsonArray(recovery.Operations.Select(operation => new JsonObject - { - ["kind"] = operation.Kind, - ["installId"] = operation.InstallId, - ["action"] = operation.Action - }).ToArray()), - ["issues"] = RecoveryIssues(recovery.Issues) - }; - } - - private static JsonArray RecoveryIssues(IEnumerable issues) - { - return new JsonArray(issues.Select(issue => new JsonObject - { - ["code"] = RecoveryIssueCode(issue.Code), - ["message"] = RecoveryIssueText(issue), - ["path"] = issue.Path, - ["parameters"] = new JsonObject(issue.Parameters.Select(parameter => - KeyValuePair.Create(parameter.Key, parameter.Value))) - }).ToArray()); - } - - internal static void WriteRecoveryText(TextWriter output, RepositoryRecoveryReport recovery) - { - if (recovery.Status == RepositoryRecoveryStatus.Clean) - { - return; - } - - output.WriteLine($"Backup recovery: {EnumName(recovery.Status)}"); - foreach (RepositoryRecoveryOperation operation in recovery.Operations) - { - output.WriteLine($"- {operation.Kind} {operation.InstallId}: {operation.Action}"); - } - - foreach (RepositoryRecoveryIssue issue in recovery.Issues) - { - output.WriteLine($"- {RecoveryIssueCode(issue.Code)}: {RecoveryIssueText(issue)} ({issue.Path})"); - } - } - - private static void WriteChanges(TextWriter output, IReadOnlyList changes, bool preview) - { - foreach (InstallChange change in changes) - { - output.WriteLine($"- {EnumName(change.Kind)} {change.Name}: {change.Path}"); - if (preview && change.Preview is not null) - { - if (change.Preview.Diagnostic is { } diagnostic) - { - output.WriteLine($" - planning failed [{EnumName(diagnostic.Code)}]: {diagnostic.Detail}"); - } - - foreach (PatchPreviewAssetResult asset in change.Preview.Assets) - { - output.WriteLine($" Path ID {asset.Asset.PathId} ({asset.Asset.TypeName})"); - foreach (PatchPreviewOperationResult operation in asset.Operations) - { - string result = operation.WillChange - ? $"{operation.OldValue} -> {operation.ToText}" - : $"skipped; expected {operation.FromText}, found {operation.OldValue}"; - output.WriteLine($" - {operation.Path}: {result}"); - } - } - } - } - } - - private static string EnumName(T value) where T : struct, Enum - { - string name = value.ToString(); - return char.ToLowerInvariant(name[0]) + name[1..]; - } - - private static string RecoveryIssueCode(RepositoryRecoveryIssueCode code) - { - return code switch - { - RepositoryRecoveryIssueCode.RepositoryUnsafe => "repository_unsafe", - RepositoryRecoveryIssueCode.RecoveryUnsafe => "recovery_unsafe", - RepositoryRecoveryIssueCode.OperationFailed => "operation_failed", - RepositoryRecoveryIssueCode.UnexpectedFailure => "unexpected_failure", - _ => throw new ArgumentOutOfRangeException(nameof(code), code, null) - }; - } - - private static string RecoveryIssueText(RepositoryRecoveryIssue issue) - { - if (issue.Parameters.GetValueOrDefault("detail") is { Length: > 0 } detail) - { - return detail; - } - - return issue.Code switch - { - RepositoryRecoveryIssueCode.RepositoryUnsafe => "The backup repository is damaged or unsafe.", - RepositoryRecoveryIssueCode.RecoveryUnsafe => "Recovery cannot continue safely.", - RepositoryRecoveryIssueCode.OperationFailed => "The recovery operation failed.", - RepositoryRecoveryIssueCode.UnexpectedFailure => "An unexpected recovery failure occurred.", - _ => "Recovery failed." - }; - } -} - -internal static class OperationErrorText -{ - public static string Format(OperationError error) - { - string context = ParameterText(error, "path") ?? string.Empty; - string? detail = ParameterText(error, "detail"); - - if (!string.IsNullOrWhiteSpace(detail)) - { - return detail; - } - - if (error.Code == FileErrorCodes.NotFound) - { - return $"The file was not found: {context}"; - } - - if (error.Code == FileErrorCodes.DirectoryNotFound) - { - return $"The directory was not found: {context}"; - } - - if (error.Code == FileErrorCodes.AccessDenied) - { - return $"Access was denied: {context}"; - } - - if (error.Code == FileErrorCodes.ReadFailed || error.Code == FileErrorCodes.SystemFailure) - { - return $"The file operation failed: {context}"; - } - - if (error.Code == ManifestErrorCodes.MissingProperty) - { - string? property = ParameterText(error, "property"); - - return property is null - ? "A required manifest property is missing." - : $"Required manifest property '{property}' is missing."; - } - - if (error.Code == ManifestErrorCodes.UnsupportedSchema) - { - return "The manifest schema version is not supported."; - } - - if (error.Code.Value.StartsWith("manifest.", StringComparison.Ordinal)) - { - return "The mod manifest is invalid."; - } - - if (error.Code.Value.StartsWith("mod_package.", StringComparison.Ordinal)) - { - return "The mod package is invalid."; - } - - return error.Code switch - { - _ when error.Code == GameDirectoryErrorCodes.Required => "Select the game directory to continue.", - _ when error.Code == GameDirectoryErrorCodes.NotFound => - $"The game directory was not found: {context}", - _ when error.Code == AssetErrorCodes.NotFound => "The requested asset was not found.", - _ when error.Code == PatchErrorCodes.PlanningFailed => - "The patch cannot be applied to the selected game files.", - _ when error.Code == ModOperationErrorCodes.InstallRecordNotFound => - "The selected install record was not found.", - _ when error.Code == ModOperationErrorCodes.FileIntegrityMismatch => - "An installed file or backup no longer matches its record.", - _ when error.Code == ModOperationErrorCodes.InstallPreviewStale => - "The install preview is stale. Preview the installation again.", - _ when error.Code == RepositoryErrorCodes.OperationAlreadyRunning => - "Another mutating operation is already running.", - _ when error.Code == RepositoryErrorCodes.RecoveryRequired => - "An interrupted operation must be recovered first.", - _ when error.Code == RepositoryErrorCodes.Unsafe => - "The backup repository is damaged or unsafe.", - _ when error.Code == RepositoryErrorCodes.UnsupportedVersion => - "The backup repository version is not supported.", - _ when error.Code == RepositoryErrorCodes.ClearNotAllowed => - "The backup repository can only be cleared when its format is unsupported.", - _ => "The operation failed." - }; - } - - private static IEnumerable> VisibleParameters(OperationError error) - { - if (error.Code == ManifestErrorCodes.MissingProperty) - { - return []; - } - - return error.Parameters.Where(parameter => - parameter.Key is not "instance_path" and not "schema_path" and not "keyword"); - } - - private static string? ParameterText(OperationError error, string key) - { - return error.Parameters.TryGetValue(key, out object? value) ? value?.ToString() : null; - } - - private static string FormatValue(object? value) - { - return value switch - { - null => "", - string text => text, - IEnumerable values => string.Join(", ", values.Cast().Select(FormatValue)), - IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), - _ => value.ToString() ?? string.Empty - }; - } - - public static void WriteTextFailure(TextWriter error, OperationError operationError, bool includeParameters) - { - error.WriteLine($"Error [{operationError.Code.Value}]: {Format(operationError)}"); - - if (includeParameters) - { - foreach ((string key, object? value) in VisibleParameters(operationError) - .OrderBy(parameter => parameter.Key)) - { - error.WriteLine($" {key}: {FormatValue(value)}"); - } - } - - if (operationError.Recovery is not null) - { - CLIOutput.WriteRecoveryText(error, operationError.Recovery); - } - } -} diff --git a/src/UnityAssetsPatcher.CLI/CLIServiceCollectionExtensions.cs b/src/UnityAssetsPatcher.CLI/CLIServiceCollectionExtensions.cs deleted file mode 100644 index 1e248f50..00000000 --- a/src/UnityAssetsPatcher.CLI/CLIServiceCollectionExtensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace UnityAssetsPatcher.CLI; - -public static class CLIServiceCollectionExtensions -{ - extension(IServiceCollection services) - { - public IServiceCollection AddUnityAssetsPatcherCli() - { - ArgumentNullException.ThrowIfNull(services); - - services.AddSingleton(provider => new CheckCLICommand( - provider.GetRequiredService(), - () => Environment.CurrentDirectory, - Console.Error)); - - services.AddSingleton(provider => new CLIApplication( - provider.GetServices(), - Console.Out, - Console.Error, - provider.GetService())); - - return services; - } - - public IServiceCollection AddUnityAssetsPatcherOperationalCommands() - { - ArgumentNullException.ThrowIfNull(services); - - services.AddSingleton(); - - services.AddSingleton(provider => new InspectCLICommand( - provider.GetRequiredService(), - provider.GetRequiredService())); - - services.AddSingleton(provider => new InstallCLICommand( - provider.GetRequiredService(), - provider.GetRequiredService())); - - services.AddSingleton(provider => new UninstallCLICommand( - provider.GetRequiredService(), - provider.GetRequiredService())); - - services.AddSingleton(provider => new RecoveryCLICommand( - provider.GetRequiredService(), - provider.GetRequiredService())); - - services.AddSingleton(provider => new RepositoryCLICommand( - provider.GetRequiredService(), - provider.GetRequiredService())); - - return services; - } - } -} diff --git a/src/UnityAssetsPatcher.CLI/CheckCLICommand.cs b/src/UnityAssetsPatcher.CLI/CheckCLICommand.cs deleted file mode 100644 index 53b44f0f..00000000 --- a/src/UnityAssetsPatcher.CLI/CheckCLICommand.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.CommandLine; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Features.Check; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; - -namespace UnityAssetsPatcher.CLI; - -public sealed class CheckCLICommand : ICLICommand -{ - public Command Command { get; } - - private readonly IServiceScopeFactory _scopeFactory; - private readonly Func _getCurrentDirectory; - private readonly TextWriter _error; - private readonly Option _configOption; - - public CheckCLICommand( - IServiceScopeFactory scopeFactory, - Func getCurrentDirectory, - TextWriter error) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(getCurrentDirectory); - ArgumentNullException.ThrowIfNull(error); - - _scopeFactory = scopeFactory; - _getCurrentDirectory = getCurrentDirectory; - _error = error; - _configOption = new Option("--config", "-c") - { - Description = "Manifest JSON or mod ZIP path (default: ./manifest.json)." - }; - - Command = new Command("check", "Validate a mod manifest."); - - Command.Options.Add(_configOption); - - Command.SetAction(ExecuteAsync); - } - - private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) - { - string sourcePath = parseResult.GetValue(_configOption) ?? - Path.Combine(_getCurrentDirectory(), "manifest.json"); - - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new CheckManifestRequest(sourcePath), cancellationToken) - .ConfigureAwait(false); - - return result switch - { - OperationSucceeded => CLIExitCodes.Success, - OperationFailed failed => WriteFailure(failed.Error), - _ => throw new InvalidOperationException("The check operation returned an unknown result.") - }; - } - - private int WriteFailure(OperationError error) - { - CLIOutput.WriteFailure(_error, error); - - return CLIExitCodes.OperationFailed; - } -} diff --git a/src/UnityAssetsPatcher.CLI/ICLICommand.cs b/src/UnityAssetsPatcher.CLI/ICLICommand.cs deleted file mode 100644 index e0ee4ce3..00000000 --- a/src/UnityAssetsPatcher.CLI/ICLICommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.CommandLine; - -namespace UnityAssetsPatcher.CLI; - -public interface ICLICommand -{ - public Command Command { get; } -} diff --git a/src/UnityAssetsPatcher.CLI/InspectCLICommand.cs b/src/UnityAssetsPatcher.CLI/InspectCLICommand.cs deleted file mode 100644 index 2bd1a62e..00000000 --- a/src/UnityAssetsPatcher.CLI/InspectCLICommand.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.CommandLine; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Features.Inspect; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Domain.Assets; - -namespace UnityAssetsPatcher.CLI; - -public sealed class InspectCLICommand : ICLICommand -{ - private const int DefaultLimit = 100; - - public Command Command { get; } - - private readonly IServiceScopeFactory _scopeFactory; - private readonly CLIOptions _options; - - public InspectCLICommand(IServiceScopeFactory scopeFactory, CLIOptions options) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(options); - - _scopeFactory = scopeFactory; - _options = options; - - Command = new Command("inspect", "Inspect assets file contents."); - Command.Subcommands.Add(CreateListCommand()); - Command.Subcommands.Add(CreateFieldsCommand()); - } - - private Command CreateListCommand() - { - var assetsFile = AssetsFileArgument(); - var all = new Option("--all") - { - Description = "Print every asset summary row." - }; - var limit = new Option("--limit") - { - Description = "Maximum number of asset summary rows to print." - }; - var command = new Command("list", "Print asset summary rows."); - command.Arguments.Add(assetsFile); - command.Options.Add(all); - command.Options.Add(limit); - command.Validators.Add(result => - { - bool printAll = result.GetValue(all); - int? rowLimit = result.GetValue(limit); - - if (printAll && result.GetResult(limit) is { Implicit: false }) - { - result.AddError("--all and --limit cannot be used together."); - } - else if (rowLimit <= 0) - { - result.AddError("--limit must be greater than 0."); - } - }); - command.SetAction((parseResult, cancellationToken) => ExecuteList( - parseResult, - parseResult.GetRequiredValue(assetsFile), - parseResult.GetValue(all) ? null : parseResult.GetValue(limit) ?? DefaultLimit, - cancellationToken)); - - return command; - } - - private Command CreateFieldsCommand() - { - var assetsFile = AssetsFileArgument(); - var pathId = new Argument("path-id") - { - Description = "Asset Path ID to inspect." - }; - var command = new Command("fields", "Print the field tree for one asset."); - command.Arguments.Add(assetsFile); - command.Arguments.Add(pathId); - command.SetAction((parseResult, cancellationToken) => ExecuteFields( - parseResult, - parseResult.GetRequiredValue(assetsFile), - parseResult.GetRequiredValue(pathId), - cancellationToken)); - - return command; - } - - private async Task ExecuteList( - ParseResult parseResult, - string assetsFilePath, - int? limit, - CancellationToken cancellationToken) - { - string fullPath = Path.GetFullPath(assetsFilePath); - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new InspectListRequest(fullPath, limit), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult( - parseResult, - _options, - "inspect.list", - result, - value => CLIOutput.InspectList(fullPath, value), - CLIOutput.WriteInspectListText); - } - - private async Task ExecuteFields( - ParseResult parseResult, - string assetsFilePath, - long pathId, - CancellationToken cancellationToken) - { - string fullPath = Path.GetFullPath(assetsFilePath); - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new InspectFieldsRequest(fullPath, pathId), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult( - parseResult, - _options, - "inspect.fields", - result, - value => CLIOutput.InspectFields(fullPath, pathId, value), - CLIOutput.WriteInspectFieldsText); - } - - private static Argument AssetsFileArgument() - { - return new Argument("assets-file") - { - Description = "Path to the Unity assets file." - }; - } -} diff --git a/src/UnityAssetsPatcher.CLI/InstallCLICommand.cs b/src/UnityAssetsPatcher.CLI/InstallCLICommand.cs deleted file mode 100644 index a61a8d60..00000000 --- a/src/UnityAssetsPatcher.CLI/InstallCLICommand.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System.CommandLine; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Features.Install; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; - -namespace UnityAssetsPatcher.CLI; - -public sealed class InstallCLICommand : ICLICommand -{ - public Command Command { get; } - - private readonly IServiceScopeFactory _scopeFactory; - private readonly CLIOptions _options; - - public InstallCLICommand( - IServiceScopeFactory scopeFactory, - CLIOptions options) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(options); - - _scopeFactory = scopeFactory; - _options = options; - - Command = new Command("install", "Preview or install a mod package."); - Command.Subcommands.Add(CreatePreviewCommand()); - Command.Subcommands.Add(CreateApplyCommand()); - } - - private Command CreatePreviewCommand() - { - var package = PackageOption(); - var gameDirectory = GameDirectoryOption(); - var optionalGroups = OptionalGroupsOption(); - var command = new Command("preview", "Analyze an installation without changing files."); - command.Options.Add(package); - command.Options.Add(gameDirectory); - command.Options.Add(optionalGroups); - command.SetAction((parseResult, cancellationToken) => ExecutePreview( - parseResult, - parseResult.GetRequiredValue(package), - parseResult.GetValue(gameDirectory), - parseResult.GetValue(optionalGroups) ?? [], - cancellationToken)); - return command; - } - - private Command CreateApplyCommand() - { - var package = PackageOption(); - var gameDirectory = GameDirectoryOption(); - var optionalGroups = OptionalGroupsOption(); - var yes = YesOption(); - var command = new Command("apply", "Install a mod package after validation."); - command.Options.Add(package); - command.Options.Add(gameDirectory); - command.Options.Add(optionalGroups); - command.Options.Add(yes); - RequireConfirmation(command, yes); - command.SetAction((parseResult, cancellationToken) => ExecuteApply( - parseResult, - parseResult.GetRequiredValue(package), - parseResult.GetValue(gameDirectory), - parseResult.GetValue(optionalGroups) ?? [], - cancellationToken)); - return command; - } - - private async Task ExecutePreview( - ParseResult parseResult, - string packagePath, - string? gameDirectory, - IReadOnlyList optionalGroups, - CancellationToken cancellationToken) - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new PreviewInstallRequest(CreateRequest(packagePath, gameDirectory, optionalGroups)), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult( - parseResult, - _options, - "install.preview", - result, - CLIOutput.InstallPreview, - CLIOutput.WriteInstallPreviewText); - } - - private async Task ExecuteApply( - ParseResult parseResult, - string packagePath, - string? gameDirectory, - IReadOnlyList optionalGroups, - CancellationToken cancellationToken) - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new InstallModRequest(CreateRequest(packagePath, gameDirectory, optionalGroups)), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult( - parseResult, - _options, - "install.apply", - result, - CLIOutput.InstallResult, - CLIOutput.WriteInstallResultText); - } - - private static InstallRequest CreateRequest( - string packagePath, - string? gameDirectory, - IReadOnlyList optionalGroups) - { - return new InstallRequest(Path.GetFullPath(packagePath), FullPathOrNull(gameDirectory)) - { - SelectedOptionalGroups = optionalGroups - }; - } - - private static Option PackageOption() - { - return new Option("--package", "-p") - { - Description = "Mod ZIP package path.", - Required = true - }; - } - - private static Option GameDirectoryOption() - { - return new Option("--game-directory", "-g") - { - Description = "Game installation directory; omit to resolve it from Steam." - }; - } - - private static Option OptionalGroupsOption() - { - return new Option("--optional-group", "-o") - { - Description = "Optional content group to include; repeat for multiple groups.", - Arity = ArgumentArity.OneOrMore, - AllowMultipleArgumentsPerToken = false, - DefaultValueFactory = _ => [] - }; - } - - private static Option YesOption() - { - return new Option("--yes", "-y") - { - Description = "Confirm the mutating operation." - }; - } - - private static void RequireConfirmation(Command command, Option yes) - { - command.Validators.Add(result => - { - if (result.GetResult(yes) is not { Implicit: false } optionResult || - !optionResult.GetValueOrDefault()) - { - result.AddError("Required option '--yes' was not provided."); - } - }); - } - - private static string? FullPathOrNull(string? path) - { - return string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path); - } -} diff --git a/src/UnityAssetsPatcher.CLI/RecoveryCLICommand.cs b/src/UnityAssetsPatcher.CLI/RecoveryCLICommand.cs deleted file mode 100644 index ce77cb43..00000000 --- a/src/UnityAssetsPatcher.CLI/RecoveryCLICommand.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.CommandLine; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.Application.Features.Recovery; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; - -namespace UnityAssetsPatcher.CLI; - -public sealed class RecoveryCLICommand : ICLICommand -{ - public Command Command { get; } - - private readonly IServiceScopeFactory _scopeFactory; - private readonly CLIOptions _options; - - public RecoveryCLICommand(IServiceScopeFactory scopeFactory, CLIOptions options) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(options); - - _scopeFactory = scopeFactory; - _options = options; - Command = new Command("recovery", "Preview or recover an interrupted install or uninstall."); - Command.Subcommands.Add(CreatePreviewCommand()); - Command.Subcommands.Add(CreateApplyCommand()); - } - - private Command CreatePreviewCommand() - { - var gameDirectory = GameDirectoryOption(); - var command = new Command("preview", "Show every recovery action without changing files."); - command.Options.Add(gameDirectory); - command.SetAction((parseResult, cancellationToken) => ExecutePreview( - parseResult, - parseResult.GetRequiredValue(gameDirectory), - cancellationToken)); - return command; - } - - private Command CreateApplyCommand() - { - var gameDirectory = GameDirectoryOption(); - var yes = new Option("--yes", "-y") { Description = "Confirm the mutating operation." }; - var command = new Command("apply", "Recover an interrupted operation."); - command.Options.Add(gameDirectory); - command.Options.Add(yes); - command.Validators.Add(result => - { - if (result.GetResult(yes) is not { Implicit: false } optionResult || - !optionResult.GetValueOrDefault()) - { - result.AddError("Required option '--yes' was not provided."); - } - }); - command.SetAction((parseResult, cancellationToken) => ExecuteApply( - parseResult, - parseResult.GetRequiredValue(gameDirectory), - cancellationToken)); - return command; - } - - private async Task ExecutePreview( - ParseResult parseResult, - string gameDirectory, - CancellationToken cancellationToken) - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new PreviewRecoveryRequest(Path.GetFullPath(gameDirectory)), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult(parseResult, _options, "recovery.preview", result, - CLIOutput.RecoveryPreview, CLIOutput.WriteRecoveryPreviewText); - } - - private async Task ExecuteApply( - ParseResult parseResult, - string gameDirectory, - CancellationToken cancellationToken) - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new RecoverRecoveryRequest(Path.GetFullPath(gameDirectory)), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult(parseResult, _options, "recovery.apply", result, - CLIOutput.RecoveryReport, CLIOutput.WriteRecoveryReportText); - } - - private static Option GameDirectoryOption() - { - return new Option("--game-directory", "-g") - { - Description = "User-confirmed game installation directory.", - Required = true - }; - } -} diff --git a/src/UnityAssetsPatcher.CLI/RepositoryCLICommand.cs b/src/UnityAssetsPatcher.CLI/RepositoryCLICommand.cs deleted file mode 100644 index 9ec50a23..00000000 --- a/src/UnityAssetsPatcher.CLI/RepositoryCLICommand.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.CommandLine; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Features.RepositoryManagement; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Application.Repository; - -namespace UnityAssetsPatcher.CLI; - -public sealed class RepositoryCLICommand : ICLICommand -{ - public Command Command { get; } - - private readonly IServiceScopeFactory _scopeFactory; - private readonly CLIOptions _options; - - public RepositoryCLICommand(IServiceScopeFactory scopeFactory, CLIOptions options) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(options); - - _scopeFactory = scopeFactory; - _options = options; - Command = new Command("repository", "Manage the backup repository."); - Command.Subcommands.Add(CreateClearCommand()); - } - - private Command CreateClearCommand() - { - var yes = new Option("--yes", "-y") - { - Description = "Confirm permanent deletion of the unsupported backup repository." - }; - var command = new Command( - "clear", - "Permanently clear an unsupported backup repository and initialize the current format."); - command.Options.Add(yes); - command.Validators.Add(result => - { - if (result.GetResult(yes) is not { Implicit: false } optionResult || - !optionResult.GetValueOrDefault()) - { - result.AddError("Required option '--yes' was not provided."); - } - }); - command.SetAction(ExecuteClear); - - return command; - } - - private async Task ExecuteClear(ParseResult parseResult, CancellationToken cancellationToken) - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new ClearUnsupportedRepositoryRequest(), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult( - parseResult, - _options, - "repository.clear", - result, - CLIOutput.RepositoryClear, - CLIOutput.WriteRepositoryClearText); - } -} diff --git a/src/UnityAssetsPatcher.CLI/UninstallCLICommand.cs b/src/UnityAssetsPatcher.CLI/UninstallCLICommand.cs deleted file mode 100644 index 3512cd7a..00000000 --- a/src/UnityAssetsPatcher.CLI/UninstallCLICommand.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System.CommandLine; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.Application.Features.Uninstall; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; - -namespace UnityAssetsPatcher.CLI; - -public sealed class UninstallCLICommand : ICLICommand -{ - public Command Command { get; } - - private readonly IServiceScopeFactory _scopeFactory; - private readonly CLIOptions _options; - - public UninstallCLICommand( - IServiceScopeFactory scopeFactory, - CLIOptions options) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(options); - - _scopeFactory = scopeFactory; - _options = options; - - Command = new Command("uninstall", "List, preview, or uninstall installed mods."); - Command.Subcommands.Add(CreateListCommand()); - Command.Subcommands.Add(CreatePreviewCommand()); - Command.Subcommands.Add(CreateApplyCommand()); - } - - private Command CreateListCommand() - { - var command = new Command("list", "List installed mod layers."); - command.SetAction((parseResult, cancellationToken) => ExecuteList(parseResult, cancellationToken)); - return command; - } - - private Command CreatePreviewCommand() - { - var id = IdOption(); - var gameDirectory = GameDirectoryOption(); - var command = new Command("preview", "Analyze an uninstall without changing game files."); - command.Options.Add(id); - command.Options.Add(gameDirectory); - command.SetAction((parseResult, cancellationToken) => ExecutePreview( - parseResult, - parseResult.GetRequiredValue(id), - parseResult.GetValue(gameDirectory), - cancellationToken)); - return command; - } - - private Command CreateApplyCommand() - { - var id = IdOption(); - var gameDirectory = GameDirectoryOption(); - var yes = YesOption(); - var command = new Command("apply", "Uninstall a mod and compose the remaining layers."); - command.Options.Add(id); - command.Options.Add(gameDirectory); - command.Options.Add(yes); - RequireConfirmation(command, yes); - command.SetAction((parseResult, cancellationToken) => ExecuteApply( - parseResult, - parseResult.GetRequiredValue(id), - parseResult.GetValue(gameDirectory), - cancellationToken)); - return command; - } - - private async Task ExecuteList(ParseResult parseResult, CancellationToken cancellationToken) - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>>( - new ListInstalledModsRequest(), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult( - parseResult, - _options, - "uninstall.list", - result, - CLIOutput.InstalledMods, - CLIOutput.WriteInstalledModsText); - } - - private async Task ExecutePreview( - ParseResult parseResult, - string installId, - string? gameDirectory, - CancellationToken cancellationToken) - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new UninstallPreviewRequest(installId, FullPathOrNull(gameDirectory)), - cancellationToken) - .ConfigureAwait(false); - - return CLIOutput.WriteResult( - parseResult, - _options, - "uninstall.preview", - result, - CLIOutput.UninstallPreview, - CLIOutput.WriteUninstallPreviewText); - } - - private async Task ExecuteApply( - ParseResult parseResult, - string installId, - string? gameDirectory, - CancellationToken cancellationToken) - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - var result = await dispatcher - .DispatchAsync>( - new UninstallModRequest(installId, FullPathOrNull(gameDirectory)), - cancellationToken) - .ConfigureAwait(false); - return CLIOutput.WriteResult( - parseResult, - _options, - "uninstall.apply", - result, - CLIOutput.UninstallResult, - CLIOutput.WriteUninstallResultText); - } - - private static Option IdOption() - { - return new Option("--id") - { - Description = "Stable layer ID from 'uninstall list'.", - Required = true - }; - } - - private static Option GameDirectoryOption() - { - return new Option("--game-directory", "-g") - { - Description = "Game installation directory; omit to resolve it from Steam." - }; - } - - private static Option YesOption() - { - return new Option("--yes", "-y") - { - Description = "Confirm the mutating operation." - }; - } - - private static void RequireConfirmation(Command command, Option yes) - { - command.Validators.Add(result => - { - if (result.GetResult(yes) is not { Implicit: false } optionResult || - !optionResult.GetValueOrDefault()) - { - result.AddError("Required option '--yes' was not provided."); - } - }); - } - - private static string? FullPathOrNull(string? path) - { - return string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path); - } -} diff --git a/src/UnityAssetsPatcher.CLI/UnityAssetsPatcher.CLI.csproj b/src/UnityAssetsPatcher.CLI/UnityAssetsPatcher.CLI.csproj deleted file mode 100644 index 99d641f1..00000000 --- a/src/UnityAssetsPatcher.CLI/UnityAssetsPatcher.CLI.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - diff --git a/src/UnityAssetsPatcher.GUI/App.axaml b/src/UnityAssetsPatcher.GUI/App.axaml new file mode 100644 index 00000000..0c25fca6 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/App.axaml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/UnityAssetsPatcher.GUI/App.axaml.cs b/src/UnityAssetsPatcher.GUI/App.axaml.cs new file mode 100644 index 00000000..aa376f78 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/App.axaml.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using RentADeveloper.ResXLocalization; +using UnityAssetsPatcher.GUI.ViewModels; +using UnityAssetsPatcher.GUI.Views; + +namespace UnityAssetsPatcher.GUI; + +public partial class App : Avalonia.Application +{ + private readonly MainWindowViewModel _mainWindowViewModel; + + public App() : this(new MainWindowViewModel()) { } + + public App(MainWindowViewModel mainWindowViewModel) + { + _mainWindowViewModel = mainWindowViewModel; + } + + public override void Initialize() + { + Localizer.Current.CurrentCulture = CultureInfo.CurrentUICulture; + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = _mainWindowViewModel + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/UnityAssetsPatcher.GUI/Assets/icon.ico b/src/UnityAssetsPatcher.GUI/Assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..00c0ade45ac51e5a1ebb57622c894f706c659e48 GIT binary patch literal 33919 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 + + + 4 + 6 + 8 + 12 + 16 + 20 + 24 + + 232 + 32,24 + 12,20,12,16 + 10,0,0,16 + 12,0,0,0 + 12,8 + 0,0,0,2 + 4,8 + 20 + + 40 + 3 + 20 + 1 + 12 + 6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UnityAssetsPatcher.GUI/Styles/Typography.axaml b/src/UnityAssetsPatcher.GUI/Styles/Typography.axaml new file mode 100644 index 00000000..d0a1b298 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Styles/Typography.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/src/UnityAssetsPatcher.GUI/UnityAssetsPatcher.GUI.csproj b/src/UnityAssetsPatcher.GUI/UnityAssetsPatcher.GUI.csproj new file mode 100644 index 00000000..22c22960 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/UnityAssetsPatcher.GUI.csproj @@ -0,0 +1,49 @@ + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + PublicResXFileCodeGenerator + Strings.Designer.cs + UnityAssetsPatcher.GUI.Localization + $(MSBuildProjectDirectory)\..\..\localization\Strings.Designer.cs + CSharp + UnityAssetsPatcher.GUI.Localization + Strings + true + + + + true + true + Strings.resx + + + AboutPage.axaml + + + + + + + diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/MainWindowViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/MainWindowViewModel.cs new file mode 100644 index 00000000..af273fd1 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,78 @@ +using UnityAssetsPatcher.Application; +using UnityAssetsPatcher.GUI.Localization; +using UnityAssetsPatcher.GUI.ViewModels.Pages; + +namespace UnityAssetsPatcher.GUI.ViewModels; + +public sealed class MainWindowViewModel : ViewModelBase +{ + public static string Title => AppConfig.Name; + public ViewModelBase CurrentPage => _selected.Page; + public IReadOnlyList PrimaryItems { get; } + public IReadOnlyList PinnedItems { get; } + + private NavigationItemViewModel _selected; + + public MainWindowViewModel() + { + PrimaryItems = + [ + new NavigationItemViewModel(StringsKeys.MainMenu_InstallMod_Title, new InstallModPageViewModel()), + new NavigationItemViewModel(StringsKeys.Navigation_ManageMods, new ManageModsPageViewModel()), + new NavigationItemViewModel(StringsKeys.MainMenu_Settings_Title, new SettingsPageViewModel()) + ]; + + PinnedItems = + [ + new NavigationItemViewModel(StringsKeys.Navigation_About, new AboutPageViewModel(), true) + ]; + + _selected = PrimaryItems[0]; + } + + public NavigationItemViewModel? PrimarySelection + { + get => _selected.IsPinned ? null : _selected; + set + { + if (value is null) + { + OnPropertyChanged(); + } + else + { + Select(value); + } + } + } + + public NavigationItemViewModel? PinnedSelection + { + get => _selected.IsPinned ? _selected : null; + set + { + if (value is null) + { + OnPropertyChanged(); + } + else + { + Select(value); + } + } + } + + private void Select(NavigationItemViewModel item) + { + if (ReferenceEquals(_selected, item)) + { + return; + } + + _selected = item; + + OnPropertyChanged(nameof(PrimarySelection)); + OnPropertyChanged(nameof(PinnedSelection)); + OnPropertyChanged(nameof(CurrentPage)); + } +} diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/NavigationItemViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/NavigationItemViewModel.cs new file mode 100644 index 00000000..649f40d0 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/ViewModels/NavigationItemViewModel.cs @@ -0,0 +1,19 @@ +using RentADeveloper.ResXLocalization; + +namespace UnityAssetsPatcher.GUI.ViewModels; + +public sealed class NavigationItemViewModel : ViewModelBase +{ + public string Title => Localizer.Current.Get(_titleKey); + public ViewModelBase Page { get; } + public bool IsPinned { get; } + + private readonly ResourceKey _titleKey; + + public NavigationItemViewModel(ResourceKey titleKey, ViewModelBase page, bool isPinned = false) + { + _titleKey = titleKey; + Page = page; + IsPinned = isPinned; + } +} diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/Pages/AboutPageViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/AboutPageViewModel.cs new file mode 100644 index 00000000..a57cd90f --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/AboutPageViewModel.cs @@ -0,0 +1,3 @@ +namespace UnityAssetsPatcher.GUI.ViewModels.Pages; + +public sealed class AboutPageViewModel : ViewModelBase; diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallModPageViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallModPageViewModel.cs new file mode 100644 index 00000000..8e4f2d39 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallModPageViewModel.cs @@ -0,0 +1,3 @@ +namespace UnityAssetsPatcher.GUI.ViewModels.Pages; + +public sealed class InstallModPageViewModel : ViewModelBase; diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/Pages/ManageModsPageViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/ManageModsPageViewModel.cs new file mode 100644 index 00000000..92216915 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/ManageModsPageViewModel.cs @@ -0,0 +1,3 @@ +namespace UnityAssetsPatcher.GUI.ViewModels.Pages; + +public sealed class ManageModsPageViewModel : ViewModelBase; diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/Pages/SettingsPageViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/SettingsPageViewModel.cs new file mode 100644 index 00000000..473b0cda --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/SettingsPageViewModel.cs @@ -0,0 +1,3 @@ +namespace UnityAssetsPatcher.GUI.ViewModels.Pages; + +public sealed class SettingsPageViewModel : ViewModelBase; diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/ViewModelBase.cs b/src/UnityAssetsPatcher.GUI/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..33285b2f --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/ViewModels/ViewModelBase.cs @@ -0,0 +1,5 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace UnityAssetsPatcher.GUI.ViewModels; + +public abstract class ViewModelBase : ObservableObject { } diff --git a/src/UnityAssetsPatcher.GUI/Views/AboutPage.axaml b/src/UnityAssetsPatcher.GUI/Views/AboutPage.axaml new file mode 100644 index 00000000..94783393 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/AboutPage.axaml @@ -0,0 +1,11 @@ + + + + + diff --git a/src/UnityAssetsPatcher.GUI/Views/AboutPage.axaml.cs b/src/UnityAssetsPatcher.GUI/Views/AboutPage.axaml.cs new file mode 100644 index 00000000..86c856a5 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/AboutPage.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UnityAssetsPatcher.GUI.Views; + +public partial class AboutPage : UserControl +{ + public AboutPage() + { + InitializeComponent(); + } +} diff --git a/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml b/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml new file mode 100644 index 00000000..a89e60e9 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + diff --git a/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml.cs b/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml.cs new file mode 100644 index 00000000..d74cf633 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UnityAssetsPatcher.GUI.Views; + +public partial class InstallModPage : UserControl +{ + public InstallModPage() + { + InitializeComponent(); + } +} diff --git a/src/UnityAssetsPatcher.GUI/Views/MainWindow.axaml b/src/UnityAssetsPatcher.GUI/Views/MainWindow.axaml new file mode 100644 index 00000000..8f23f40f --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/MainWindow.axaml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UnityAssetsPatcher.GUI/Views/MainWindow.axaml.cs b/src/UnityAssetsPatcher.GUI/Views/MainWindow.axaml.cs new file mode 100644 index 00000000..8ab03826 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UnityAssetsPatcher.GUI.Views; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} diff --git a/src/UnityAssetsPatcher.GUI/Views/ManageModsPage.axaml b/src/UnityAssetsPatcher.GUI/Views/ManageModsPage.axaml new file mode 100644 index 00000000..5f3f65b6 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/ManageModsPage.axaml @@ -0,0 +1,11 @@ + + + + + diff --git a/src/UnityAssetsPatcher.GUI/Views/ManageModsPage.axaml.cs b/src/UnityAssetsPatcher.GUI/Views/ManageModsPage.axaml.cs new file mode 100644 index 00000000..66fb7306 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/ManageModsPage.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UnityAssetsPatcher.GUI.Views; + +public partial class ManageModsPage : UserControl +{ + public ManageModsPage() + { + InitializeComponent(); + } +} diff --git a/src/UnityAssetsPatcher.GUI/Views/SettingsPage.axaml b/src/UnityAssetsPatcher.GUI/Views/SettingsPage.axaml new file mode 100644 index 00000000..770ef4f9 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/SettingsPage.axaml @@ -0,0 +1,11 @@ + + + + + diff --git a/src/UnityAssetsPatcher.GUI/Views/SettingsPage.axaml.cs b/src/UnityAssetsPatcher.GUI/Views/SettingsPage.axaml.cs new file mode 100644 index 00000000..fc71a01a --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/Views/SettingsPage.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UnityAssetsPatcher.GUI.Views; + +public partial class SettingsPage : UserControl +{ + public SettingsPage() + { + InitializeComponent(); + } +} diff --git a/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateManifestClient.cs b/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateManifestClient.cs index bb684910..6424b587 100644 --- a/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateManifestClient.cs +++ b/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateManifestClient.cs @@ -81,7 +81,7 @@ private static HttpRequestMessage CreateRequest() request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - request.Headers.UserAgent.ParseAdd(AppConfig.Identifier); + request.Headers.UserAgent.ParseAdd(AppConfig.Name); return request; } diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/AnalyzerReleases.Shipped.md b/src/UnityAssetsPatcher.LocalizationGenerator/AnalyzerReleases.Shipped.md deleted file mode 100644 index 8b137891..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/AnalyzerReleases.Shipped.md +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/AnalyzerReleases.Unshipped.md b/src/UnityAssetsPatcher.LocalizationGenerator/AnalyzerReleases.Unshipped.md deleted file mode 100644 index 78590107..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/AnalyzerReleases.Unshipped.md +++ /dev/null @@ -1,15 +0,0 @@ -### New Rules - -| Rule ID | Category | Severity | Notes | -|---------|--------------|----------|-------------| -| LOC001 | Localization | Error | Descriptors | -| LOC002 | Localization | Error | Descriptors | -| LOC004 | Localization | Error | Descriptors | -| LOC005 | Localization | Error | Descriptors | -| LOC006 | Localization | Error | Descriptors | -| LOC007 | Localization | Error | Descriptors | -| LOC009 | Localization | Error | Descriptors | -| LOC010 | Localization | Error | Descriptors | -| LOC012 | Localization | Error | Descriptors | -| LOC013 | Localization | Error | Descriptors | -| LOC014 | Localization | Error | Descriptors | diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/CSharpIdentifier.cs b/src/UnityAssetsPatcher.LocalizationGenerator/CSharpIdentifier.cs deleted file mode 100644 index 5b66bece..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/CSharpIdentifier.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.CodeAnalysis.CSharp; - -namespace UnityAssetsPatcher.LocalizationGenerator; - -internal static class CSharpIdentifier -{ - public static bool IsSupported(string value) - { - return SyntaxFacts.IsValidIdentifier(value) && SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None; - } -} diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/Descriptors.cs b/src/UnityAssetsPatcher.LocalizationGenerator/Descriptors.cs deleted file mode 100644 index 0c215711..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/Descriptors.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace UnityAssetsPatcher.LocalizationGenerator; - -internal static class Descriptors -{ - internal static readonly DiagnosticDescriptor PrimaryLocaleNotFound = Create( - "LOC001", - "Primary locale file not found", - "Primary locale file 'en-US.json' was not found"); - - internal static readonly DiagnosticDescriptor PrimaryLocaleEmpty = Create( - "LOC002", - "Primary locale file is empty", - "Primary locale file 'en-US.json' contains no entries"); - - internal static readonly DiagnosticDescriptor LocaleHasExtraKey = Create( - "LOC004", - "Locale file contains an extra key", - "Locale file '{0}' contains key '{1}', which is not defined by en-US.json"); - - internal static readonly DiagnosticDescriptor LocaleMissingKey = Create( - "LOC005", - "Locale file is missing a key", - "Locale file '{0}' is missing key '{1}' defined by en-US.json"); - - internal static readonly DiagnosticDescriptor DuplicateKey = Create( - "LOC006", - "Locale file contains a duplicate key", - "Locale file '{0}' contains duplicate key '{1}'"); - - internal static readonly DiagnosticDescriptor InvalidKey = Create( - "LOC007", - "Locale key cannot be generated", - "Locale key '{1}' in '{0}' is not a supported C# member name"); - - internal static readonly DiagnosticDescriptor InvalidFormat = Create( - "LOC009", - "Locale value contains an invalid format", - "Locale value for key '{1}' in '{0}' has an invalid format: {2}"); - - internal static readonly DiagnosticDescriptor DuplicateLocale = Create( - "LOC010", - "Locale is defined more than once", - "Culture '{0}' is defined by more than one locale file"); - - internal static readonly DiagnosticDescriptor PlaceholderMismatch = Create( - "LOC012", - "Locale placeholders do not match", - "Placeholders for key '{1}' in '{0}' do not match en-US.json"); - - internal static readonly DiagnosticDescriptor LocaleCannotBeRead = Create( - "LOC013", - "Locale file cannot be read", - "Locale file '{0}' could not be read"); - - internal static readonly DiagnosticDescriptor InvalidCultureName = Create( - "LOC014", - "Locale filename is not a culture name", - "Locale filename '{0}' is not a recognized culture name"); - - private static DiagnosticDescriptor Create(string id, string title, string messageFormat) - { - return new DiagnosticDescriptor( - id, - title, - messageFormat, - "Localization", - DiagnosticSeverity.Error, - true); - } -} diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/LocaleFileParser.cs b/src/UnityAssetsPatcher.LocalizationGenerator/LocaleFileParser.cs deleted file mode 100644 index 6062d1f0..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/LocaleFileParser.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace UnityAssetsPatcher.LocalizationGenerator; - -internal static class LocaleFileParser -{ - private static Regex EntryPattern { get; } = new( - "\"(?(?:\\\\(?:[\"\\\\/bfnrt]|u[0-9A-Fa-f]{4})|[^\"\\\\])*)\"\\s*:\\s*" + - "\"(?(?:\\\\(?:[\"\\\\/bfnrt]|u[0-9A-Fa-f]{4})|[^\"\\\\])*)\"", - RegexOptions.CultureInvariant); - - private static HashSet ReservedMemberNames { get; } = new(StringComparer.Ordinal) - { - "LocalizedStrings", - "Culture", - "GetString", - "GetStringOrDefault" - }; - - public static LocaleDefinition? Parse(LocaleSource source, SourceProductionContext context) - { - string fileName = Path.GetFileName(source.Path); - - if (source.Content is null) - { - Report(context, Descriptors.LocaleCannotBeRead, source, null, fileName); - - return null; - } - - string cultureName; - - try - { - string requestedCultureName = Path.GetFileNameWithoutExtension(source.Path); - var culture = CultureInfo.GetCultureInfo(requestedCultureName); - cultureName = culture.Name; - } - catch (CultureNotFoundException) - { - Report(context, Descriptors.InvalidCultureName, source, null, fileName); - - return null; - } - - return ExtractEntries(source, cultureName, fileName, context); - } - - private static LocaleDefinition? ExtractEntries( - LocaleSource source, - string cultureName, - string fileName, - SourceProductionContext context) - { - string content = source.Content?.ToString() ?? string.Empty; - MatchCollection matches = EntryPattern.Matches(content); - ImmutableArray.Builder entries = ImmutableArray.CreateBuilder(); - var keys = new HashSet(StringComparer.Ordinal); - bool isValid = true; - - foreach (Match match in matches) - { - string key = DecodeJsonString(match.Groups["key"].Value); - - if (!keys.Add(key)) - { - Report(context, Descriptors.DuplicateKey, source, key, fileName, key); - isValid = false; - - continue; - } - - if (!CSharpIdentifier.IsSupported(key) || ReservedMemberNames.Contains(key)) - { - Report(context, Descriptors.InvalidKey, source, key, fileName, key); - isValid = false; - - continue; - } - - string value = DecodeJsonString(match.Groups["value"].Value); - bool isFormatValid = LocalizationFormatParser.TryGetPlaceholders( - value, - out var placeholders, - out string? error); - - if (!isFormatValid) - { - Report(context, Descriptors.InvalidFormat, source, key, fileName, key, error ?? string.Empty); - isValid = false; - - continue; - } - - entries.Add(new LocalizedEntry(key, value, placeholders)); - } - - if (!isValid) - { - return null; - } - - return new LocaleDefinition(source, cultureName, entries.ToImmutable()); - } - - private static string DecodeJsonString(string value) - { - var builder = new StringBuilder(value.Length); - int index = 0; - - while (index < value.Length) - { - char current = value[index]; - - if (current != '\\') - { - builder.Append(current); - index++; - - continue; - } - - char escaped = value[index + 1]; - - if (escaped == 'u') - { - string hexValue = value.Substring(index + 2, 4); - int characterValue = int.Parse(hexValue, NumberStyles.HexNumber, CultureInfo.InvariantCulture); - - builder.Append((char)characterValue); - - index += 6; - - continue; - } - - builder.Append(escaped switch - { - '"' => '"', - '\\' => '\\', - '/' => '/', - 'b' => '\b', - 'f' => '\f', - 'n' => '\n', - 'r' => '\r', - 't' => '\t', - _ => escaped - }); - - index += 2; - } - - return builder.ToString(); - } - - private static void Report( - SourceProductionContext context, - DiagnosticDescriptor descriptor, - LocaleSource source, - string? key, - params object[] arguments) - { - Location location = CreateLocation(source, key); - - context.ReportDiagnostic(Diagnostic.Create(descriptor, location, arguments)); - } - - internal static Location CreateLocation(LocaleSource source, string? key) - { - if (source.Content is null || key is null) - { - return CreateStartLocation(source.Path); - } - - string content = source.Content.ToString(); - int keyIndex = content.IndexOf($"\"{key}\"", StringComparison.Ordinal); - - if (keyIndex < 0) - { - return CreateStartLocation(source.Path); - } - - var span = new TextSpan(keyIndex, key.Length + 2); - LinePositionSpan lineSpan = source.Content.Lines.GetLinePositionSpan(span); - - return Location.Create(source.Path, span, lineSpan); - } - - private static Location CreateStartLocation(string path) - { - var span = new TextSpan(0, 0); - var linePosition = new LinePosition(0, 0); - var lineSpan = new LinePositionSpan(linePosition, linePosition); - - return Location.Create(path, span, lineSpan); - } -} diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/LocaleModels.cs b/src/UnityAssetsPatcher.LocalizationGenerator/LocaleModels.cs deleted file mode 100644 index c5cbb387..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/LocaleModels.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis.Text; - -namespace UnityAssetsPatcher.LocalizationGenerator; - -internal sealed class LocaleSource -{ - public string Path { get; } - - public SourceText? Content { get; } - - public LocaleSource(string path, SourceText? content) - { - Path = path; - Content = content; - } -} - -internal sealed class LocaleDefinition -{ - public LocaleSource Source { get; } - - public string CultureName { get; } - - public ImmutableArray Entries { get; } - - public LocaleDefinition(LocaleSource source, string cultureName, ImmutableArray entries) - { - Source = source; - CultureName = cultureName; - Entries = entries; - } -} - -internal sealed class LocalizedEntry -{ - public string Key { get; } - - public string Value { get; } - - public ImmutableArray Placeholders { get; } - - public LocalizedEntry(string key, string value, ImmutableArray placeholders) - { - Key = key; - Value = value; - Placeholders = placeholders; - } -} diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/LocalizationFormatParser.cs b/src/UnityAssetsPatcher.LocalizationGenerator/LocalizationFormatParser.cs deleted file mode 100644 index 6ef61cbf..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/LocalizationFormatParser.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Text; - -namespace UnityAssetsPatcher.LocalizationGenerator; - -internal static class LocalizationFormatParser -{ - public static bool TryGetPlaceholders( - string value, - out ImmutableArray placeholders, - out string? error) - { - ImmutableArray.Builder names = ImmutableArray.CreateBuilder(); - var seenNames = new HashSet(StringComparer.Ordinal); - int index = 0; - - while (index < value.Length) - { - char current = value[index]; - - switch (current) - { - case '{' when IsEscapedBrace(value, index, '{'): - index += 2; - - continue; - case '{': - { - int closingIndex = value.IndexOf('}', index + 1); - - if (closingIndex < 0) - { - placeholders = []; - error = "an opening brace is not closed"; - - return false; - } - - string name = value.Substring(index + 1, closingIndex - index - 1); - - if (!CSharpIdentifier.IsSupported(name)) - { - placeholders = []; - error = $"'{name}' is not a valid placeholder name"; - - return false; - } - - if (seenNames.Add(name)) - { - names.Add(name); - } - - index = closingIndex + 1; - - continue; - } - case '}' when IsEscapedBrace(value, index, '}'): - index += 2; - - continue; - case '}': - placeholders = []; - error = "a closing brace does not have a matching opening brace"; - - return false; - default: - index++; - break; - } - } - - placeholders = names.ToImmutable(); - error = null; - - return true; - } - - public static string CreateCompositeFormat(string value, IReadOnlyDictionary placeholderIndexes) - { - var builder = new StringBuilder(value.Length); - int index = 0; - - while (index < value.Length) - { - char current = value[index]; - - switch (current) - { - case '{' when IsEscapedBrace(value, index, '{'): - builder.Append("{{"); - - index += 2; - - continue; - case '{': - { - int closingIndex = value.IndexOf('}', index + 1); - string name = value.Substring(index + 1, closingIndex - index - 1); - - builder.Append('{'); - - builder.Append(placeholderIndexes[name]); - - builder.Append('}'); - - index = closingIndex + 1; - - continue; - } - case '}' when IsEscapedBrace(value, index, '}'): - builder.Append("}}"); - - index += 2; - - continue; - default: - builder.Append(current); - - index++; - break; - } - } - - return builder.ToString(); - } - - public static string CreateDisplayText(string value) - { - return value.Replace("{{", "{").Replace("}}", "}"); - } - - private static bool IsEscapedBrace(string value, int index, char brace) - { - return index + 1 < value.Length && value[index + 1] == brace; - } -} diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/LocalizationKeyExtractor.cs b/src/UnityAssetsPatcher.LocalizationGenerator/LocalizationKeyExtractor.cs deleted file mode 100644 index aa09a905..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/LocalizationKeyExtractor.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; - -namespace UnityAssetsPatcher.LocalizationGenerator; - -internal static class LocalizationKeyExtractor -{ - private static readonly Regex PropertyPattern = - new("^\\s*\"(?[A-Za-z_][A-Za-z0-9_]*)\"\\s*:", RegexOptions.Multiline | RegexOptions.CultureInvariant); - - public static List Extract(string content) - { - return (from Match match in PropertyPattern.Matches(content) select match.Groups["key"].Value).ToList(); - } -} diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/LocalizedStringsGenerator.cs b/src/UnityAssetsPatcher.LocalizationGenerator/LocalizedStringsGenerator.cs deleted file mode 100644 index 92afe8e2..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/LocalizedStringsGenerator.cs +++ /dev/null @@ -1,443 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; - -namespace UnityAssetsPatcher.LocalizationGenerator; - -/// -/// Generates a culture-aware, strongly typed localization catalog from locale JSON supplied as additional files. -/// -[Generator(LanguageNames.CSharp)] -public sealed class LocalizedStringsGenerator : IIncrementalGenerator -{ - private const string PrimaryCultureName = "en-US"; - private const string DefaultOutputNamespace = "UnityAssetsPatcher.Localization"; - - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var localeSources = context.AdditionalTextsProvider - .Where(static file => file.Path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) - .Select(static (file, cancellationToken) => new LocaleSource(file.Path, file.GetText(cancellationToken))); - - var outputNamespaces = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) => - { - bool hasRootNamespace = provider.GlobalOptions.TryGetValue( - "build_property.RootNamespace", - out string? rootNamespace); - - return hasRootNamespace && !string.IsNullOrWhiteSpace(rootNamespace) - ? $"{rootNamespace}.Localization" - : DefaultOutputNamespace; - }); - - var inputs = localeSources - .Collect() - .Combine(outputNamespaces); - - context.RegisterSourceOutput(inputs, - static (productionContext, input) => { Execute(productionContext, input.Left, input.Right); }); - } - - private static void Execute( - SourceProductionContext context, - ImmutableArray sources, - string outputNamespace) - { - var locales = new List(); - bool hasErrors = false; - bool primarySourceExists = sources.Any(source => string.Equals( - System.IO.Path.GetFileName(source.Path), - $"{PrimaryCultureName}.json", - StringComparison.OrdinalIgnoreCase)); - - foreach (LocaleDefinition? locale in sources.Select(source => LocaleFileParser.Parse(source, context))) - { - if (locale is null) - { - hasErrors = true; - - continue; - } - - locales.Add(locale); - } - - if (!ValidateUniqueCultures(context, locales)) - { - hasErrors = true; - } - - LocaleDefinition? primaryLocale = locales.FirstOrDefault(locale => - string.Equals(locale.CultureName, PrimaryCultureName, StringComparison.OrdinalIgnoreCase)); - - if (primaryLocale is null) - { - if (!primarySourceExists) - { - context.ReportDiagnostic(Diagnostic.Create(Descriptors.PrimaryLocaleNotFound, Location.None)); - } - - return; - } - - if (primaryLocale.Entries.IsEmpty) - { - Location location = LocaleFileParser.CreateLocation(primaryLocale.Source, null); - - context.ReportDiagnostic(Diagnostic.Create(Descriptors.PrimaryLocaleEmpty, location)); - - return; - } - - if (!ValidateLocaleContracts(context, primaryLocale, locales)) - { - hasErrors = true; - } - - if (hasErrors) - { - return; - } - - string generatedSource = GenerateSource(outputNamespace, primaryLocale, locales); - - context.AddSource("LocalizedStrings.g.cs", SourceText.From(generatedSource, Encoding.UTF8)); - } - - private static bool ValidateUniqueCultures(SourceProductionContext context, List locales) - { - var cultures = new HashSet(StringComparer.OrdinalIgnoreCase); - bool isValid = true; - - foreach (LocaleDefinition locale in locales) - { - if (cultures.Add(locale.CultureName)) - { - continue; - } - - Location location = LocaleFileParser.CreateLocation(locale.Source, null); - - context.ReportDiagnostic(Diagnostic.Create( - Descriptors.DuplicateLocale, - location, - locale.CultureName)); - - isValid = false; - } - - return isValid; - } - - private static bool ValidateLocaleContracts( - SourceProductionContext context, - LocaleDefinition primaryLocale, - List locales) - { - var primaryEntries = primaryLocale.Entries.ToDictionary( - entry => entry.Key, - StringComparer.Ordinal); - - bool isValid = true; - - foreach (LocaleDefinition locale in locales) - { - if (ReferenceEquals(locale, primaryLocale)) - { - continue; - } - - var localeEntries = locale.Entries.ToDictionary( - entry => entry.Key, - StringComparer.Ordinal); - - isValid &= ReportKeyDifferences(context, locale, primaryEntries, localeEntries); - - isValid &= ReportPlaceholderDifferences(context, locale, primaryEntries, localeEntries); - } - - return isValid; - } - - private static bool ReportKeyDifferences( - SourceProductionContext context, - LocaleDefinition locale, - Dictionary primaryEntries, - Dictionary localeEntries) - { - bool isValid = true; - - foreach (string key in localeEntries.Keys.Where(key => !primaryEntries.ContainsKey(key))) - { - ReportLocaleDiagnostic(context, Descriptors.LocaleHasExtraKey, locale, key); - isValid = false; - } - - foreach (string key in primaryEntries.Keys.Where(key => !localeEntries.ContainsKey(key))) - { - ReportLocaleDiagnostic(context, Descriptors.LocaleMissingKey, locale, key); - isValid = false; - } - - return isValid; - } - - private static bool ReportPlaceholderDifferences( - SourceProductionContext context, - LocaleDefinition locale, - Dictionary primaryEntries, - Dictionary localeEntries) - { - bool isValid = true; - - foreach (var pair in primaryEntries) - { - if (!localeEntries.TryGetValue(pair.Key, out LocalizedEntry? localeEntry)) - { - continue; - } - - var primaryPlaceholders = new HashSet(pair.Value.Placeholders, StringComparer.Ordinal); - var localePlaceholders = new HashSet(localeEntry.Placeholders, StringComparer.Ordinal); - - if (primaryPlaceholders.SetEquals(localePlaceholders)) - { - continue; - } - - ReportLocaleDiagnostic(context, Descriptors.PlaceholderMismatch, locale, pair.Key); - isValid = false; - } - - return isValid; - } - - private static void ReportLocaleDiagnostic( - SourceProductionContext context, - DiagnosticDescriptor descriptor, - LocaleDefinition locale, - string key) - { - string fileName = System.IO.Path.GetFileName(locale.Source.Path); - Location location = LocaleFileParser.CreateLocation(locale.Source, key); - - context.ReportDiagnostic(Diagnostic.Create(descriptor, location, fileName, key)); - } - - private static string GenerateSource( - string outputNamespace, - LocaleDefinition primaryLocale, - List locales) - { - var builder = new StringBuilder(); - - AppendHeader(builder, outputNamespace); - - AppendAccessors(builder, primaryLocale); - - AppendLookup(builder, primaryLocale, locales); - - builder.AppendLine("}"); - - return builder.ToString(); - } - - private static void AppendHeader(StringBuilder builder, string outputNamespace) - { - builder.AppendLine("// "); - - builder.AppendLine("#nullable enable"); - - builder.AppendLine(); - - builder.AppendLine("using System;"); - - builder.AppendLine("using System.Globalization;"); - - builder.AppendLine(); - - builder.AppendLine($"namespace {outputNamespace};"); - - builder.AppendLine(); - - builder.AppendLine("internal sealed class LocalizedStrings"); - - builder.AppendLine("{"); - - builder.AppendLine(" internal CultureInfo Culture { get; }"); - - builder.AppendLine(); - - builder.AppendLine(" internal LocalizedStrings(CultureInfo culture)"); - - builder.AppendLine(" {"); - - builder.AppendLine(" ArgumentNullException.ThrowIfNull(culture);"); - - builder.AppendLine(); - - builder.AppendLine(" Culture = culture;"); - - builder.AppendLine(" }"); - - builder.AppendLine(); - } - - private static void AppendAccessors(StringBuilder builder, LocaleDefinition primaryLocale) - { - foreach (LocalizedEntry entry in primaryLocale.Entries) - { - string keyLiteral = SymbolDisplay.FormatLiteral(entry.Key, true); - - if (entry.Placeholders.IsEmpty) - { - builder.AppendLine($" internal string {entry.Key} => GetString({keyLiteral});"); - - builder.AppendLine(); - - continue; - } - - string parameters = string.Join( - ", ", - entry.Placeholders.Select(placeholder => $"object? {placeholder}")); - - string arguments = string.Join(", ", entry.Placeholders); - - builder.AppendLine($" internal string {entry.Key}({parameters})"); - - builder.AppendLine(" {"); - - builder.AppendLine( - $" return string.Format(this.Culture, GetString({keyLiteral}), " + - $"new object?[] {{ {arguments} }});"); - - builder.AppendLine(" }"); - - builder.AppendLine(); - } - } - - private static void AppendLookup( - StringBuilder builder, - LocaleDefinition primaryLocale, - List locales) - { - builder.AppendLine(" internal string GetFormat(string key)"); - - builder.AppendLine(" {"); - - builder.AppendLine(" return GetString(key);"); - - builder.AppendLine(" }"); - - builder.AppendLine(); - - builder.AppendLine(" private string GetString(string key)"); - - builder.AppendLine(" {"); - - builder.AppendLine(" CultureInfo currentCulture = Culture;"); - - builder.AppendLine(); - - builder.AppendLine(" while (!Equals(currentCulture, CultureInfo.InvariantCulture))"); - - builder.AppendLine(" {"); - - builder.AppendLine(" string? value = GetStringOrDefault(currentCulture.Name, key);"); - - builder.AppendLine(); - - builder.AppendLine(" if (value is not null)"); - - builder.AppendLine(" {"); - - builder.AppendLine(" return value;"); - - builder.AppendLine(" }"); - - builder.AppendLine(); - - builder.AppendLine(" currentCulture = currentCulture.Parent;"); - - builder.AppendLine(" }"); - - builder.AppendLine(); - - builder.AppendLine($" return GetStringOrDefault(\"{PrimaryCultureName}\", key) ?? key;"); - - builder.AppendLine(" }"); - - builder.AppendLine(); - - builder.AppendLine(" private static string? GetStringOrDefault(string cultureName, string key)"); - - builder.AppendLine(" {"); - - builder.AppendLine(" return cultureName switch"); - - builder.AppendLine(" {"); - - foreach (LocaleDefinition locale in locales.OrderBy( - locale => locale.CultureName, - StringComparer.Ordinal)) - { - AppendLocaleLookup(builder, primaryLocale, locale); - } - - builder.AppendLine(" _ => null"); - - builder.AppendLine(" };"); - - builder.AppendLine(" }"); - } - - private static void AppendLocaleLookup( - StringBuilder builder, - LocaleDefinition primaryLocale, - LocaleDefinition locale) - { - string cultureLiteral = SymbolDisplay.FormatLiteral(locale.CultureName, true); - var entries = locale.Entries.ToDictionary( - entry => entry.Key, - StringComparer.Ordinal); - - builder.AppendLine($" {cultureLiteral} => key switch"); - - builder.AppendLine(" {"); - - foreach (LocalizedEntry primaryEntry in primaryLocale.Entries) - { - LocalizedEntry entry = entries[primaryEntry.Key]; - string keyLiteral = SymbolDisplay.FormatLiteral(entry.Key, true); - string value = CreateGeneratedValue(primaryEntry, entry); - string valueLiteral = SymbolDisplay.FormatLiteral(value, true); - - builder.AppendLine($" {keyLiteral} => {valueLiteral},"); - } - - builder.AppendLine(" _ => null"); - - builder.AppendLine(" },"); - } - - private static string CreateGeneratedValue(LocalizedEntry primaryEntry, LocalizedEntry localizedEntry) - { - if (primaryEntry.Placeholders.IsEmpty) - { - return LocalizationFormatParser.CreateDisplayText(localizedEntry.Value); - } - - var placeholderIndexes = primaryEntry.Placeholders - .Select((placeholder, index) => new KeyValuePair(placeholder, index)) - .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); - - return LocalizationFormatParser.CreateCompositeFormat(localizedEntry.Value, placeholderIndexes); - } -} diff --git a/src/UnityAssetsPatcher.LocalizationGenerator/UnityAssetsPatcher.LocalizationGenerator.csproj b/src/UnityAssetsPatcher.LocalizationGenerator/UnityAssetsPatcher.LocalizationGenerator.csproj deleted file mode 100644 index d1fbd5e4..00000000 --- a/src/UnityAssetsPatcher.LocalizationGenerator/UnityAssetsPatcher.LocalizationGenerator.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - netstandard2.0 - latest - enable - true - true - false - - - - - - - - diff --git a/src/UnityAssetsPatcher.TUI/Flows/RepositoryInitializationFlow.cs b/src/UnityAssetsPatcher.TUI/Flows/RepositoryInitializationFlow.cs deleted file mode 100644 index c5a8c8e2..00000000 --- a/src/UnityAssetsPatcher.TUI/Flows/RepositoryInitializationFlow.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Globalization; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.TUI.Lifecycle; -using UnityAssetsPatcher.TUI.Localization; -using UnityAssetsPatcher.TUI.Pages.RepositoryIssue; - -namespace UnityAssetsPatcher.TUI.Flows; - -internal sealed class RepositoryInitializationFlow -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly LocalizedStrings _strings; - - public RepositoryInitializationFlow(IServiceScopeFactory scopeFactory) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - - _scopeFactory = scopeFactory; - _strings = new LocalizedStrings(CultureInfo.CurrentUICulture); - } - - public async Task RunAsync(TerminalFlowContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - - using var logic = new RepositoryIssueLogic(_scopeFactory, cancellationToken); - - await logic.InitializeAsync().ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - if (logic.State is RepositoryIssueState.Ready) - { - return; - } - - await context.InvokeAsync( - () => context.ContentHost.ShowContent(new RepositoryIssueView( - _strings, - logic, - context.RequestStop)), - cancellationToken).ConfigureAwait(false); - - await logic.Completion.WaitAsync(cancellationToken).ConfigureAwait(false); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/ActionButton.cs b/src/UnityAssetsPatcher.TUI/Framework/ActionButton.cs deleted file mode 100644 index 59025f1f..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/ActionButton.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Terminal.Gui.Input; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace UnityAssetsPatcher.TUI.Framework; - -public enum ActionKind -{ - Default, - Primary, - Secondary, - Dangerous -} - -public sealed class ActionButton : Button -{ - private readonly string _text; - - public ActionButton(string text, ActionKind kind = ActionKind.Default) - { - ArgumentNullException.ThrowIfNull(text); - - _text = text; - NoDecorations = true; - NoPadding = true; - ShadowStyle = ShadowStyles.None; - Margin.ShadowStyle = null; - TextAlignment = Alignment.Start; - - KeyBindings.Remove(Key.Space); - - SetScheme(kind switch - { - ActionKind.Default => TerminalTheme.Interactive, - ActionKind.Primary => TerminalTheme.PrimaryAction, - ActionKind.Secondary => TerminalTheme.SecondaryAction, - ActionKind.Dangerous => TerminalTheme.DangerousAction, - _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) - }); - - HasFocusChanged += (_, _) => UpdateText(); - - UpdateText(); - } - - private void UpdateText() - { - Text = $"{(HasFocus ? "›" : " ")} {_text}"; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/ChoiceItem.cs b/src/UnityAssetsPatcher.TUI/Framework/ChoiceItem.cs deleted file mode 100644 index 1d350622..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/ChoiceItem.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Terminal.Gui.Text; -using Terminal.Gui.ViewBase; - -namespace UnityAssetsPatcher.TUI.Framework; - -public sealed class ChoiceItem : View -{ - private const int DescriptionGap = 4; - - public ActionButton Button { get; } - public StyledLabel Description { get; } - - public ChoiceItem(string text, string description) - { - Width = Dim.Fill(); - Height = 1; - CanFocus = true; - Button = new ActionButton(text) { X = 0, Y = 0, Width = Dim.Auto() }; - - Description = new StyledLabel(description, TextRole.Muted) - { - X = Pos.Right(Button) + DescriptionGap, - Y = 0, - Width = Dim.Fill() - }; - - Button.HasFocusChanged += (_, _) => - { - Description.SetRole(Button.HasFocus ? TextRole.Selected : TextRole.Muted); - }; - - Add(Button, Description); - } - - public static void AlignDescriptions(IReadOnlyList items) - { - ArgumentNullException.ThrowIfNull(items); - - if (items.Count == 0) - { - return; - } - - int width = items.Max(item => item.Button.Text.GetColumns()); - - foreach (ChoiceItem item in items) - { - item.Button.Width = width; - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/ChoiceItemList.cs b/src/UnityAssetsPatcher.TUI/Framework/ChoiceItemList.cs deleted file mode 100644 index d1909add..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/ChoiceItemList.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Terminal.Gui.Text; -using Terminal.Gui.ViewBase; - -namespace UnityAssetsPatcher.TUI.Framework; - -public sealed class ChoiceItemList : View -{ - public ActionButton Button { get; } - public StyledLabel Description { get; } - - private const int DescriptionGap = 4; - - public ChoiceItemList(string text, string description) - { - Width = Dim.Fill(); - Height = 1; - CanFocus = true; - - Button = new ActionButton(text) - { - X = 0, - Y = 0, - Width = Dim.Auto() - }; - - Description = new StyledLabel(description, TextRole.Muted) - { - X = Pos.Right(Button) + DescriptionGap, - Y = 0, - Width = Dim.Fill() - }; - - Button.HasFocusChanged += (_, _) => - { - Description.SetRole(Button.HasFocus ? TextRole.Selected : TextRole.Muted); - }; - - Add(Button, Description); - } - - public static void AlignDescriptions(IReadOnlyList items) - { - ArgumentNullException.ThrowIfNull(items); - - if (items.Count == 0) - { - return; - } - - int width = items.Max(item => item.Button.Text.GetColumns()); - - foreach (ChoiceItemList item in items) - { - item.Button.Width = width; - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/ConfirmationBar.cs b/src/UnityAssetsPatcher.TUI/Framework/ConfirmationBar.cs deleted file mode 100644 index dc8db02b..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/ConfirmationBar.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Terminal.Gui.ViewBase; - -namespace UnityAssetsPatcher.TUI.Framework; - -public sealed class ConfirmationBar : View -{ - public ActionButton ConfirmButton { get; } - public ActionButton CancelButton { get; } - - private const int GapBetweenButtons = 4; - - public ConfirmationBar( - string confirmText, - Action confirmAction, - string cancelText, - Action cancelAction, - ActionKind confirmKind = ActionKind.Default) - : this( - confirmText, - ToAsync(confirmAction), - cancelText, - ToAsync(cancelAction), - confirmKind) { } - - public ConfirmationBar( - string confirmText, - Func confirmAction, - string cancelText, - Action cancelAction, - ActionKind confirmKind = ActionKind.Default) - : this( - confirmText, - confirmAction, - cancelText, - ToAsync(cancelAction), - confirmKind) { } - - public ConfirmationBar( - string confirmText, - Func confirmAction, - string cancelText, - Func cancelAction, - ActionKind confirmKind = ActionKind.Default) - { - ArgumentNullException.ThrowIfNull(confirmText); - ArgumentNullException.ThrowIfNull(confirmAction); - ArgumentNullException.ThrowIfNull(cancelText); - ArgumentNullException.ThrowIfNull(cancelAction); - - Width = Dim.Fill(); - Height = 1; - CanFocus = true; - - ConfirmButton = new ActionButton(confirmText, confirmKind) - { - X = 0, - Y = 0, - Width = Dim.Auto() - }; - - ConfirmButton.Accepted += async (_, _) => await confirmAction(); - - CancelButton = new ActionButton(cancelText, ActionKind.Secondary) - { - X = Pos.Right(ConfirmButton) + GapBetweenButtons, - Y = 0, - Width = Dim.Auto() - }; - CancelButton.Accepted += async (_, _) => await cancelAction(); - - Add(ConfirmButton, CancelButton); - } - - private static Func ToAsync(Action action) - { - ArgumentNullException.ThrowIfNull(action); - - return () => - { - action(); - return Task.CompletedTask; - }; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/DataTableView.cs b/src/UnityAssetsPatcher.TUI/Framework/DataTableView.cs deleted file mode 100644 index 36d197ad..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/DataTableView.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Terminal.Gui.Drawing; -using Terminal.Gui.Views; - -namespace UnityAssetsPatcher.TUI.Framework; - -public class DataTableView : TableView -{ - public DataTableView(bool showHeaders = true) - { - BorderStyle = LineStyle.None; - - Style = new TableStyle - { - ShowHeaders = showHeaders, - AlwaysShowHeaders = showHeaders, - ShowHorizontalBottomLine = false, - ShowHorizontalHeaderOverline = false, - ShowHorizontalHeaderUnderline = showHeaders, - ShowVerticalCellLines = false, - ShowVerticalCellLineForFirstColumn = false, - ShowVerticalCellLineForLastColumn = false, - ShowVerticalHeaderLines = false, - ExpandLastColumn = showHeaders - }; - - SetScheme(TerminalTheme.Interactive); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/InputField.cs b/src/UnityAssetsPatcher.TUI/Framework/InputField.cs deleted file mode 100644 index 54128e61..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/InputField.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Terminal.Gui.Views; - -namespace UnityAssetsPatcher.TUI.Framework; - -public sealed class InputField : TextField -{ - public InputField() - { - SetScheme(TerminalTheme.Interactive); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/ScrollableContentView.cs b/src/UnityAssetsPatcher.TUI/Framework/ScrollableContentView.cs deleted file mode 100644 index 26ca73b3..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/ScrollableContentView.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Drawing; -using Terminal.Gui.Input; -using Terminal.Gui.ViewBase; - -namespace UnityAssetsPatcher.TUI.Framework; - -public sealed class ScrollableContentView : View -{ - private const int MouseWheelRows = 3; - - public ScrollableContentView() - { - CanFocus = true; - ViewportSettings |= ViewportSettingsFlags.HasVerticalScrollBar; - MouseEvent += (_, mouse) => HandleMouseWheel(mouse); - - KeyDown += (_, key) => - { - int pageSize = Math.Max(1, Viewport.Height - 1); - - if (key == Key.PageUp) - { - key.Handled = ScrollVertical(-pageSize) == true; - } - else if (key == Key.PageDown) - { - key.Handled = ScrollVertical(pageSize) == true; - } - else if (key == Key.Home) - { - key.Handled = ScrollVertical(-GetContentHeight()) == true; - } - else if (key == Key.End) - { - key.Handled = ScrollVertical(GetContentHeight()) == true; - } - }; - } - - public void SetContentHeightForRows(int rowCount) - { - ArgumentOutOfRangeException.ThrowIfNegative(rowCount); - SetContentHeight(rowCount); - ScrollVertical(-GetContentHeight()); - EnsureFocusedViewIsVisible(); - } - - protected override void OnSubViewAdded(View view) - { - base.OnSubViewAdded(view); - TrackFocus(view); - } - - private void EnsureFocusedViewIsVisible() - { - View? focused = MostFocused; - - if (focused is null || ReferenceEquals(focused, this) || Viewport.Height <= 0) - { - return; - } - - Rectangle focusedFrame = focused.FrameToScreen(); - Rectangle visibleFrame = ViewportToScreen(); - - if (focusedFrame.Top < visibleFrame.Top) - { - ScrollVertical(focusedFrame.Top - visibleFrame.Top); - } - else if (focusedFrame.Bottom > visibleFrame.Bottom) - { - ScrollVertical(focusedFrame.Bottom - visibleFrame.Bottom); - } - } - - private void TrackFocus(View view) - { - view.MouseEvent += (_, mouse) => HandleMouseWheel(mouse); - view.HasFocusChanged += (_, _) => - { - if (view.HasFocus) - { - EnsureFocusedViewIsVisible(); - } - }; - - foreach (View subView in view.SubViews) - { - TrackFocus(subView); - } - } - - private void HandleMouseWheel(Mouse mouse) - { - int rows = mouse.Flags switch - { - var flags when flags.HasFlag(MouseFlags.WheeledUp) => -MouseWheelRows, - var flags when flags.HasFlag(MouseFlags.WheeledDown) => MouseWheelRows, - _ => 0 - }; - - if (rows != 0 && ScrollVertical(rows) == true) - { - mouse.Handled = true; - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/StyledLabel.cs b/src/UnityAssetsPatcher.TUI/Framework/StyledLabel.cs deleted file mode 100644 index ae94cee5..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/StyledLabel.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Terminal.Gui.Views; - -namespace UnityAssetsPatcher.TUI.Framework; - -public enum TextRole -{ - Base, - Muted, - Selected, - Title, - Label, - SectionHeader, - Preview, - Error, - Success -} - -public sealed class StyledLabel : Label -{ - public StyledLabel(string text = "", TextRole role = TextRole.Base) - { - Text = text; - - SetRole(role); - } - - public void SetRole(TextRole role) - { - SetScheme(TerminalTheme.GetTextScheme(role)); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/SummaryTableView.cs b/src/UnityAssetsPatcher.TUI/Framework/SummaryTableView.cs deleted file mode 100644 index 45b8fded..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/SummaryTableView.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Terminal.Gui.Text; -using Terminal.Gui.Views; - -namespace UnityAssetsPatcher.TUI.Framework; - -public sealed class SummaryTableView : DataTableView -{ - public SummaryTableView(IReadOnlyList<(string Label, string Value)> rows) : base(false) - { - ArgumentNullException.ThrowIfNull(rows); - - const int columnGap = 3; - int labelWidth = rows.Count == 0 ? columnGap : rows.Max(row => row.Label.GetColumns()) + columnGap; - Width = Terminal.Gui.ViewBase.Dim.Fill(); - Height = rows.Count; - CanFocus = false; - Table = new SummaryTableSource(rows); - Style.InvertSelectedCellFirstCharacter = false; - Style.ExpandLastColumn = false; - - Style.ColumnStyles[0] = new ColumnStyle - { - MinWidth = labelWidth, - MaxWidth = labelWidth, - ColorGetter = _ => TerminalTheme.Muted - }; - - Style.ColumnStyles[1] = new ColumnStyle - { - ColorGetter = _ => TerminalTheme.Base - }; - - SetScheme(TerminalTheme.Base); - } - - private sealed class SummaryTableSource(IReadOnlyList<(string Label, string Value)> rows) : ITableSource - { - public string[] ColumnNames => [string.Empty, string.Empty]; - public int Columns => 2; - public int Rows => rows.Count; - public object this[int row, int col] => col == 0 ? rows[row].Label : rows[row].Value; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/TerminalPathNormalizer.cs b/src/UnityAssetsPatcher.TUI/Framework/TerminalPathNormalizer.cs deleted file mode 100644 index 07c58bc4..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/TerminalPathNormalizer.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace UnityAssetsPatcher.TUI.Framework; - -public static class TerminalPathNormalizer -{ - public static string Normalize(string value) - { - string path = value.Trim(); - - while (path.Length >= 2 && - ((path[0] == '"' && path[^1] == '"') || - (path[0] == '\'' && path[^1] == '\''))) - { - path = path[1..^1].Trim(); - } - - return path; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/TerminalTheme.cs b/src/UnityAssetsPatcher.TUI/Framework/TerminalTheme.cs deleted file mode 100644 index 4bf27b24..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/TerminalTheme.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Terminal.Gui.Drawing; -using TerminalAttribute = Terminal.Gui.Drawing.Attribute; - -namespace UnityAssetsPatcher.TUI.Framework; - -public static class TerminalTheme -{ - public static Scheme Base { get; private set; } = null!; - public static Scheme Muted { get; private set; } = null!; - public static Scheme Selected { get; private set; } = null!; - public static Scheme Title { get; private set; } = null!; - public static Scheme Label { get; private set; } = null!; - public static Scheme SectionHeader { get; private set; } = null!; - public static Scheme Preview { get; private set; } = null!; - public static Scheme Error { get; private set; } = null!; - public static Scheme Success { get; private set; } = null!; - public static Scheme Interactive { get; private set; } = null!; - public static Scheme PrimaryAction { get; private set; } = null!; - public static Scheme SecondaryAction { get; private set; } = null!; - public static Scheme DangerousAction { get; private set; } = null!; - - static TerminalTheme() - { - Initialize(false); - } - - public static void Initialize(bool useLegacyConsoleColors) - { - Color background = useLegacyConsoleColors ? new Color("#000000") : Color.None; - Color baseForeground = useLegacyConsoleColors ? new Color("#abb2bf") : Color.None; - - Base = Create(baseForeground, background); - - Muted = Create(new Color("#5c6370"), background); - - Selected = Create(new Color("#c678dd"), background); - - Title = Create(new Color("#61afef"), background, TextStyle.Bold); - - Label = Create(new Color("#61afef"), background); - - SectionHeader = Create(new Color("#56b6c2"), background, TextStyle.Bold); - - Preview = Create(new Color("#e5c07b"), background, TextStyle.Bold); - - Error = Create(new Color("#e06c75"), background); - - Success = Create(new Color("#98c379"), background, TextStyle.Bold); - - Interactive = CreateInteractive(Base.Normal); - - PrimaryAction = CreateInteractive(Label.Normal); - - SecondaryAction = CreateInteractive(Muted.Normal); - - DangerousAction = CreateInteractive(Error.Normal); - } - - public static Scheme GetTextScheme(TextRole role) - { - return role switch - { - TextRole.Base => Base, - TextRole.Muted => Muted, - TextRole.Selected => Selected, - TextRole.Title => Title, - TextRole.Label => Label, - TextRole.SectionHeader => SectionHeader, - TextRole.Preview => Preview, - TextRole.Error => Error, - TextRole.Success => Success, - _ => throw new ArgumentOutOfRangeException(nameof(role), role, null) - }; - } - - private static Scheme CreateInteractive(TerminalAttribute normal) - { - TerminalAttribute selected = Selected.Normal; - - return new Scheme - { - Normal = normal, - Focus = selected, - HotNormal = normal, - HotFocus = selected, - Active = selected, - Editable = normal, - ReadOnly = normal, - Disabled = normal - }; - } - - private static Scheme Create(Color foreground, Color background, TextStyle style = TextStyle.None) - { - var attribute = new TerminalAttribute(foreground, background, style); - - return new Scheme - { - Normal = attribute, - Focus = attribute, - HotNormal = attribute, - HotFocus = attribute, - Active = attribute, - Editable = attribute, - ReadOnly = attribute, - Disabled = attribute - }; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/TextViewer.cs b/src/UnityAssetsPatcher.TUI/Framework/TextViewer.cs deleted file mode 100644 index bea1a66b..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/TextViewer.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Terminal.Gui.Views; - -namespace UnityAssetsPatcher.TUI.Framework; - -#pragma warning disable CS0618 -// Terminal.Gui has no bundled read-only, scrollable text viewer. -public sealed class TextViewer : TextView -{ - public TextViewer(string text = "", TextRole role = TextRole.Base) - { - ReadOnly = true; - Text = text; - SetScheme(TerminalTheme.GetTextScheme(role)); - } -} -#pragma warning restore CS0618 diff --git a/src/UnityAssetsPatcher.TUI/Framework/ToggleItem.cs b/src/UnityAssetsPatcher.TUI/Framework/ToggleItem.cs deleted file mode 100644 index 84224a88..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/ToggleItem.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Terminal.Gui.Input; -using Terminal.Gui.ViewBase; - -namespace UnityAssetsPatcher.TUI.Framework; - -public sealed class ToggleItem : View -{ - public event EventHandler? IsSelectedChanged; - - public string Name { get; } - public ActionButton Button { get; } - public StyledLabel Description { get; } - - public bool IsSelected - { - get; - set - { - if (field == value) - { - return; - } - - field = value; - - UpdateText(); - IsSelectedChanged?.Invoke(this, EventArgs.Empty); - } - } - - public ToggleItem(string name, string? description = null) - { - ArgumentNullException.ThrowIfNull(name); - Name = name; - Width = Dim.Fill(); - Height = 2; - CanFocus = true; - - Button = new ActionButton(string.Empty) - { - X = 0, Y = 0, Width = Dim.Fill() - }; - - Description = new StyledLabel(description ?? string.Empty, TextRole.Muted) - { - X = 6, - Y = 1, - Width = Dim.Fill() - }; - Button.KeyDown += (_, key) => - { - if (key != Key.Space) - { - return; - } - - key.Handled = true; - IsSelected = !IsSelected; - }; - - Button.Accepted += (_, _) => { IsSelected = !IsSelected; }; - - Button.HasFocusChanged += (_, _) => { UpdateText(); }; - - Add(Button, Description); - - UpdateText(); - } - - private void UpdateText() - { - string indicator = Button.HasFocus ? "›" : " "; - string checkbox = IsSelected ? "[*]" : "[ ]"; - Button.Text = $"{indicator} {checkbox} {Name}"; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/ViewExtensions.cs b/src/UnityAssetsPatcher.TUI/Framework/ViewExtensions.cs deleted file mode 100644 index f78e3b1c..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/ViewExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Terminal.Gui.ViewBase; - -namespace UnityAssetsPatcher.TUI.Framework; - -internal static class ViewExtensions -{ - public static void RemoveAllAndDispose(this View parent, params View[] retainedChildren) - { - ArgumentNullException.ThrowIfNull(parent); - ArgumentNullException.ThrowIfNull(retainedChildren); - - foreach (View child in parent.RemoveAll()) - { - if (!retainedChildren.Any(retained => ReferenceEquals(retained, child))) - { - child.Dispose(); - } - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/WindowsNativeFilePicker.cs b/src/UnityAssetsPatcher.TUI/Framework/WindowsNativeFilePicker.cs deleted file mode 100644 index 64f219d0..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/WindowsNativeFilePicker.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Runtime.InteropServices; - -namespace UnityAssetsPatcher.TUI.Framework; - -internal static class WindowsNativeFilePicker -{ - private const int MaximumFileCharacters = 32768; - private const int ExplorerStyle = 0x00080000; - private const int FileMustExist = 0x00001000; - private const int PathMustExist = 0x00000800; - private const int NoChangeDirectory = 0x00000008; - private const int HideReadOnly = 0x00000004; - - public static string? PickFile(string title, string fileTypeDescription) - { - ArgumentException.ThrowIfNullOrWhiteSpace(title); - ArgumentException.ThrowIfNullOrWhiteSpace(fileTypeDescription); - - if (!OperatingSystem.IsWindows()) - { - return null; - } - - nint filter = 0; - nint fileBuffer = 0; - nint dialogTitle = 0; - - try - { - filter = Marshal.StringToHGlobalUni( - $"{fileTypeDescription} (*.zip)\0*.zip\0\0"); - fileBuffer = Marshal.AllocHGlobal(MaximumFileCharacters * sizeof(char)); - dialogTitle = Marshal.StringToHGlobalUni(title); - - byte[] emptyBuffer = new byte[MaximumFileCharacters * sizeof(char)]; - Marshal.Copy(emptyBuffer, 0, fileBuffer, emptyBuffer.Length); - - var openFileName = new OpenFileName - { - StructSize = Marshal.SizeOf(), - Owner = GetConsoleWindow(), - Filter = filter, - FilterIndex = 1, - File = fileBuffer, - MaximumFile = MaximumFileCharacters, - Title = dialogTitle, - Flags = ExplorerStyle | FileMustExist | PathMustExist | - NoChangeDirectory | HideReadOnly - }; - - return GetOpenFileName(ref openFileName) == 0 - ? null - : Marshal.PtrToStringUni(fileBuffer); - } - finally - { - if (dialogTitle != 0) - { - Marshal.FreeHGlobal(dialogTitle); - } - - if (fileBuffer != 0) - { - Marshal.FreeHGlobal(fileBuffer); - } - - if (filter != 0) - { - Marshal.FreeHGlobal(filter); - } - } - } - - [DllImport("comdlg32.dll", EntryPoint = "GetOpenFileNameW", ExactSpelling = true, SetLastError = true)] - private static extern int GetOpenFileName(ref OpenFileName openFileName); - - [DllImport("kernel32.dll", ExactSpelling = true)] - private static extern nint GetConsoleWindow(); - - [StructLayout(LayoutKind.Sequential)] - private struct OpenFileName - { - public int StructSize; - public nint Owner; - public nint Instance; - public nint Filter; - public nint CustomFilter; - public int MaximumCustomFilter; - public int FilterIndex; - public nint File; - public int MaximumFile; - public nint FileTitle; - public int MaximumFileTitle; - public nint InitialDirectory; - public nint Title; - public int Flags; - public short FileOffset; - public short FileExtension; - public nint DefaultExtension; - public nint CustomData; - public nint Hook; - public nint TemplateName; - public nint Reserved; - public int Reserved2; - public int ExtendedFlags; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Framework/WorkingIndicator.cs b/src/UnityAssetsPatcher.TUI/Framework/WorkingIndicator.cs deleted file mode 100644 index 4a0c02f3..00000000 --- a/src/UnityAssetsPatcher.TUI/Framework/WorkingIndicator.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace UnityAssetsPatcher.TUI.Framework; - -public sealed class WorkingIndicator : View -{ - private readonly SpinnerView _spinner; - private readonly Label _label; - private bool _spinning; - - public WorkingIndicator(string message = "", TextRole role = TextRole.Preview) - { - Width = Dim.Fill(); - Height = 1; - SetScheme(TerminalTheme.GetTextScheme(role)); - - _spinner = new SpinnerView - { - X = 0, - Y = 0, - Style = new SpinnerStyle.Dots(), - Visible = false - }; - - _label = new Label - { - X = 0, - Y = 0, - Width = Dim.Fill() - }; - - Add(_spinner, _label); - - Initialized += (_, _) => - { - if (_spinning) - { - _spinner.AutoSpin = true; - } - }; - - if (message.Length > 0) - { - Spin(message); - } - } - - public void Spin(string message) - { - ArgumentNullException.ThrowIfNull(message); - _spinning = true; - _spinner.Visible = true; - _label.X = 2; - _label.Text = message; - - if (IsInitialized) - { - _spinner.AutoSpin = true; - } - } - - public void Still(string text) - { - ArgumentNullException.ThrowIfNull(text); - _spinning = false; - _spinner.AutoSpin = false; - _spinner.Visible = false; - _label.X = 0; - _label.Text = text; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Lifecycle/TerminalFlowContext.cs b/src/UnityAssetsPatcher.TUI/Lifecycle/TerminalFlowContext.cs deleted file mode 100644 index 69eb2038..00000000 --- a/src/UnityAssetsPatcher.TUI/Lifecycle/TerminalFlowContext.cs +++ /dev/null @@ -1,24 +0,0 @@ -using UnityAssetsPatcher.TUI.Shell; - -namespace UnityAssetsPatcher.TUI.Lifecycle; - -internal sealed class TerminalFlowContext -{ - public ITerminalContentHost ContentHost { get; } - public Action RequestStop { get; } - public Func InvokeAsync { get; } - - public TerminalFlowContext( - ITerminalContentHost contentHost, - Action requestStop, - Func invokeAsync) - { - ArgumentNullException.ThrowIfNull(contentHost); - ArgumentNullException.ThrowIfNull(requestStop); - ArgumentNullException.ThrowIfNull(invokeAsync); - - ContentHost = contentHost; - RequestStop = requestStop; - InvokeAsync = invokeAsync; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Lifecycle/TerminalSession.cs b/src/UnityAssetsPatcher.TUI/Lifecycle/TerminalSession.cs deleted file mode 100644 index e79ff462..00000000 --- a/src/UnityAssetsPatcher.TUI/Lifecycle/TerminalSession.cs +++ /dev/null @@ -1,312 +0,0 @@ -using System.Globalization; -using System.Runtime.ExceptionServices; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Terminal.Gui.App; -using Terminal.Gui.Drivers; -using UnityAssetsPatcher.TUI.Flows; -using UnityAssetsPatcher.TUI.Framework; -using UnityAssetsPatcher.TUI.Localization; -using UnityAssetsPatcher.TUI.Navigation; -using UnityAssetsPatcher.TUI.Shell; - -namespace UnityAssetsPatcher.TUI.Lifecycle; - -internal sealed class TerminalSession -{ - private readonly RepositoryInitializationFlow _repositoryInitialization; - private readonly TerminalRouteTable _routeTable; - private readonly ILogger _logger; - - public TerminalSession( - IServiceScopeFactory scopeFactory, - TerminalRouteTable routeTable, - ILogger? logger = null) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(routeTable); - - _repositoryInitialization = new RepositoryInitializationFlow(scopeFactory); - _routeTable = routeTable; - _logger = logger ?? NullLogger.Instance; - } - - public async Task RunAsync() - { - IApplication application = Terminal.Gui.App.Application.Create(); - TerminalShellView? shell = null; - Exception? runFailure = null; - try - { - application.Init(OperatingSystem.IsWindows() ? DriverRegistry.Names.WINDOWS : null); - - bool isLegacyConsole = application.Driver?.IsLegacyConsole == true; - - TerminalTheme.Initialize(isLegacyConsole); - - CultureInfo culture = CultureInfo.CurrentUICulture; - var strings = new LocalizedStrings(culture); - string? warningText = isLegacyConsole ? strings.Layout_LegacyConsoleWarning : null; - - var renderer = new ApplicationRenderer(application); - shell = new TerminalShellView( - strings.Layout_ShortcutHint, - warningText, - renderer.Render); - - var navigator = new TerminalNavigator( - shell, - _routeTable.Create(culture)); - - var runState = new RunState(this, application, shell, navigator); - var context = new TerminalFlowContext( - shell, - runState.RequestStop, - runState.InvokeAsync); - - await runState.RunAsync(context); - } - catch (Exception exception) - { - runFailure = exception; - throw; - } - finally - { - List cleanupFailures = []; - IDisposable[] resources = shell is null ? [application] : [shell, application]; - foreach (IDisposable resource in resources) - { - try - { - resource.Dispose(); - } - catch (Exception exception) - { - cleanupFailures.Add(exception); - } - } - - ThrowCleanupFailures(runFailure, cleanupFailures); - } - } - - private async Task RunStartupAsync( - TerminalFlowContext context, - TerminalNavigator navigator, - CancellationToken cancellationToken) - { - await _repositoryInitialization.RunAsync(context, cancellationToken).ConfigureAwait(false); - - await context.InvokeAsync( - () => navigator.Navigate(TerminalRoute.MainMenu), - cancellationToken).ConfigureAwait(false); - } - - private static void ThrowCleanupFailures(Exception? runFailure, List cleanupFailures) - { - if (cleanupFailures.Count == 0) - { - return; - } - - if (runFailure is not null) - { - throw new AggregateException([runFailure, .. cleanupFailures]); - } - - if (cleanupFailures.Count == 1) - { - ExceptionDispatchInfo.Capture(cleanupFailures[0]).Throw(); - } - - throw new AggregateException(cleanupFailures); - } - - private sealed class ApplicationRenderer(IApplication application) - { - public void Render() - { - application.LayoutAndDraw(); - } - } - - private sealed class RunState( - TerminalSession session, - IApplication application, - TerminalShellView shell, - TerminalNavigator navigator) - { - private CancellationTokenSource? _sessionCancellation; - private TerminalFlowContext? _context; - private Task? _startupTask; - private bool _isStopPending; - private bool _canStop; - - public async Task RunAsync(TerminalFlowContext context) - { - using var sessionCancellation = new CancellationTokenSource(); - _sessionCancellation = sessionCancellation; - _context = context; - - shell.Initialized += StartSession; - shell.IsRunningChanging += CancelStartupBeforeStopping; - - session._logger.LogInformation("Terminal application started"); - - Exception? runFailure = null; - try - { - // RunState owns cancellation and stopping; preserve exceptions from the loop unchanged. - await application.RunAsync(shell, CancellationToken.None); - } - catch (Exception exception) - { - runFailure = exception; - throw; - } - finally - { - shell.Initialized -= StartSession; - shell.IsRunningChanging -= CancelStartupBeforeStopping; - List cleanupFailures = []; - try - { - sessionCancellation.Cancel(); - } - catch (Exception exception) - { - cleanupFailures.Add(exception); - } - - if (_startupTask is not null) - { - try - { - // The loop has ended. Startup continuations do not require the UI thread, - // and queued UI work is canceled, so drain before disposing UI resources here. - _startupTask.GetAwaiter().GetResult(); - } - catch (Exception exception) - { - if (!ReferenceEquals(exception, runFailure)) - { - cleanupFailures.Add(exception); - } - } - } - - ThrowCleanupFailures(runFailure, cleanupFailures); - } - } - - public void RequestStop() - { - application.RequestStop(shell); - } - - public async Task InvokeAsync(Action action, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Lock gate = new(); - bool started = false; - using CancellationTokenRegistration registration = cancellationToken.Register(() => - { - lock (gate) - { - if (!started) - { - completion.TrySetCanceled(cancellationToken); - } - } - }); - - application.Invoke(() => - { - lock (gate) - { - if (completion.Task.IsCompleted) - { - return; - } - - started = true; - try - { - action(); - completion.TrySetResult(); - } - catch (Exception exception) - { - completion.TrySetException(exception); - } - } - }); - - await completion.Task.ConfigureAwait(false); - } - - private async void StartSession(object? sender, EventArgs eventArgs) - { - shell.Initialized -= StartSession; - await (_startupTask = RunStartupAsync(_sessionCancellation!.Token)); - } - - private async Task RunStartupAsync(CancellationToken cancellationToken) - { - try - { - await session.RunStartupAsync(_context!, navigator, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested && - exception.CancellationToken == cancellationToken) { } - } - - private async void CancelStartupBeforeStopping( - object? sender, - CancelEventArgs eventArgs) - { - await StopAfterStartupAsync(eventArgs); - } - - private async Task StopAfterStartupAsync(CancelEventArgs eventArgs) - { - if (eventArgs.NewValue || _canStop) - { - return; - } - - _sessionCancellation!.Cancel(); - - if (_startupTask is null || _startupTask.IsCompleted) - { - return; - } - - eventArgs.Cancel = true; - - if (_isStopPending) - { - return; - } - - _isStopPending = true; - // StartSession propagates startup failures; this handler waits only for completion - // so a normal stop keeps the loop alive while startup acknowledges cancellation. - await Task.WhenAny(_startupTask); - if (_startupTask.IsCompletedSuccessfully) - { - application.AddTimeout(TimeSpan.Zero, StopAfterStartup); - } - } - - private bool StopAfterStartup() - { - _canStop = true; - RequestStop(); - return false; - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/Localization/JSON/en-US.json b/src/UnityAssetsPatcher.TUI/Localization/JSON/en-US.json deleted file mode 100644 index c4aa012f..00000000 --- a/src/UnityAssetsPatcher.TUI/Localization/JSON/en-US.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "Error_AccessDeniedFormat": "Access was denied: {arg0}", - "Error_AssetNotFound": "The requested asset was not found.", - "Error_DirectoryNotFoundFormat": "The directory was not found: {arg0}", - "Error_FileIntegrityMismatch": "A current game file no longer matches the active composed layers.", - "Error_FileNotFoundFormat": "The file was not found: {arg0}", - "Error_FileSystemFailureFormat": "The file operation failed: {arg0}", - "Error_GameDirectoryNotFoundFormat": "The game directory was not found: {arg0}", - "Error_GameDirectoryRequired": "Select the game directory to continue.", - "Error_InstallPreviewStale": "The install preview is stale. Preview the installation again.", - "Error_InstallRecordNotFound": "The selected install record was not found.", - "Error_InvalidManifest": "The mod manifest is invalid.", - "Error_InvalidModPackage": "The mod package is invalid.", - "Error_OperationAlreadyRunning": "Another install, uninstall, or recovery operation is already running.", - "Error_OperationFailed": "The operation failed.", - "Error_PatchPlanningFailed": "The patch cannot be applied to the selected game files.", - "Error_RecoveryRequired": "An interrupted operation must be recovered first.", - "Error_RepositoryUnsafe": "The backup repository is damaged or unsafe.", - "Error_RepositoryClearNotAllowed": "The backup repository can only be cleared when its format is unsupported.", - "Error_UnexpectedFormat": "An unexpected error occurred. Details were written to: {logDirectory}", - "Error_UnsupportedManifestVersion": "The manifest schema version is not supported.", - "Error_UnsupportedRepositoryVersion": "The backup repository version is not supported.", - "InspectPage_AllRowsChoice": "All rows", - "InspectPage_Analyzing": "Reading assets file...", - "InspectPage_AssetsFilePathPrompt": "Assets file path", - "InspectPage_BackAction": "Back", - "InspectPage_CustomLimitChoice": "Custom limit", - "InspectPage_Description": "List assets or inspect the field tree for a selected Path ID.", - "InspectPage_First100Choice": "First 100", - "InspectPage_ListAssetsDescription": "Print an asset summary for one assets file.", - "InspectPage_ListAssetsTitle": "List assets", - "InspectPage_MaximumRowsPrompt": "Maximum rows", - "InspectPage_NameColumn": "Name", - "InspectPage_PathIdColumn": "Path ID", - "InspectPage_PathIdPrompt": "Path ID", - "InspectPage_ReturnAction": "Return to inspect menu", - "InspectPage_RowsToPrintTitle": "Rows to print", - "InspectPage_ShowFieldsDescription": "Print the field tree for one selected Path ID.", - "InspectPage_ShowFieldsTitle": "Show asset fields", - "InspectPage_ShowingAssetsFormat": "Showing {arg0} of {arg1} assets.", - "InspectPage_TypeNameColumn": "Type Name", - "InstallPage_AnalyzingMod": "Analyzing mod...", - "InstallPage_BackAction": "Back", - "InstallPage_GameDirectoryPrompt": "Game directory", - "InstallPage_InstallAction": "Install", - "InstallPage_InstallingMod": "Installing mod...", - "InstallPage_ModZipFileType": "Mod ZIP files", - "InstallPage_OptionalGroupsHeader": "This mod includes optional content:", - "InstallPage_ReturnAction": "Return to main menu", - "InstallPage_SelectModAction": "Select a Mod", - "InstallPage_SelectModDialogTitle": "Select a Mod", - "InstallPage_SubmitAction": "Submit", - "InstallPreview_DryRunStatus": "PREVIEW", - "InstallPreview_PlanningFailedFormat": "Patch planning failed ({arg0}). Installation is disabled.", - "InstallPreview_Targets": "Targets", - "InstallResult_InstalledStatus": "INSTALLED", - "InstallResult_OperatedFiles": "Operated files", - "Install_TimingHeader": "Timing", - "Layout_LegacyConsoleWarning": "Warning: You are using the legacy Windows console. Some interface elements may not render correctly.", - "Layout_ShortcutHint": "Shortcuts: ↑/↓ select | Space toggle | Enter confirm | Esc back | Ctrl+C exit", - "MainMenu_InspectAssets_Description": "List assets and inspect their field trees.", - "MainMenu_InspectAssets_Title": "Inspect Assets", - "MainMenu_InstallMod_Description": "Preview mod changes, then install with one click", - "MainMenu_InstallMod_Title": "Install Mod", - "MainMenu_Settings_Description": "Customize your experience.", - "MainMenu_Settings_Title": "Settings", - "MainMenu_Title": "Main menu", - "MainMenu_UninstallMod_Description": "Uninstall Mod and rebuild the overlay", - "MainMenu_UninstallMod_Title": "Uninstall Mod", - "PatchError_FieldNotFoundFormat": "Field not found: {arg0}", - "PatchError_InvalidConfiguration": "The patch configuration is invalid.", - "PatchError_NoMatchingAssets": "No matching assets were found.", - "PatchError_PathIdReferenceAmbiguous": "A referenced asset matched more than one Path ID.", - "PatchError_PathIdReferenceNotFound": "A referenced Path ID could not be resolved.", - "PatchError_ReplacementMatchInvalid": "The replacement asset match is invalid or ambiguous.", - "PatchError_ReplacementSourceNotFound": "The replacement source file was not found.", - "PatchError_UnsupportedValue": "The patch contains an unsupported value.", - "PatchError_ValueMismatchFormat": "Field {arg0} has value {arg2}; expected {arg1}.", - "Prompt_DirectoryNotFoundFormat": "Directory not found: {arg0}", - "Prompt_FileNotFoundFormat": "File not found: {arg0}", - "Prompt_InvalidIntegerFormat": "{arg0} must be an integer.", - "Prompt_InvalidPositiveIntegerFormat": "{arg0} must be an integer greater than 0.", - "Prompt_LabelRequiredFormat": "{arg0} is required.", - "RepositoryRecovery_ApplyAction": "Apply recovery", - "RepositoryRecovery_ApplyDescription": "Perform exactly the recovery actions shown above.", - "RepositoryRecovery_BackAction": "Choose another directory", - "RepositoryRecovery_BackDescription": "Return without changing files.", - "RepositoryRecovery_DamagedDescription": "An interrupted operation or damaged content prevents the application from continuing.", - "RepositoryRecovery_DamagedTitle": "The backup repository requires attention", - "RepositoryRecovery_ExitAction": "Exit", - "RepositoryRecovery_ExitDescription": "Close the application without making changes.", - "RepositoryRecovery_GameDirectoryPrompt": "Confirmed game directory", - "RepositoryRecovery_InterruptedOperation": "An interrupted install or uninstall operation was found.", - "RepositoryRecovery_LockedFormat": "Backup repository is read-only: {arg0}", - "RepositoryRecovery_OperationFailed": "The recovery operation could not be completed.", - "RepositoryRecovery_PreviewAction": "Preview recovery", - "RepositoryRecovery_PreviewDescription": "Validate the directory and show every file action before recovery.", - "RepositoryRecovery_PreviewTitle": "RECOVERY PREVIEW", - "RepositoryRecovery_RecoveredFormat": "Recovered {arg0} interrupted backup operation(s).", - "RepositoryRecovery_RecoveryUnsafe": "Recovery cannot continue safely. No further files were changed.", - "RepositoryRecovery_RepositoryUnsafe": "The backup repository contains damaged or untrusted data.", - "RepositoryRecovery_RetryAction": "Try to repair", - "RepositoryRecovery_RetryDescription": "Check the repository again without changing files.", - "RepositoryFormat_CancelAction": "Keep repository", - "RepositoryFormat_ClearAction": "Clear backup repository", - "RepositoryFormat_ClearDescription": "Permanently delete this unsupported repository and initialize the current format.", - "RepositoryFormat_ClearFailedFormat": "The backup repository was not cleared: {arg0}", - "RepositoryFormat_ClearWarning": "Clearing deletes all backups and install records. It does not restore modified game files.", - "RepositoryFormat_ConfirmAction": "Permanently clear repository", - "RepositoryFormat_ConfirmDescription": "This cannot be undone. Existing mods may remain applied but can no longer be uninstalled or recovered from this repository.", - "RepositoryFormat_ConfirmTitle": "Clear the unsupported backup repository?", - "RepositoryFormat_ExitAction": "Exit", - "RepositoryFormat_ExitDescription": "Close the application without changing the repository.", - "RepositoryFormat_UnsupportedDescription": "Repository operations are blocked until the unsupported repository is cleared.", - "RepositoryFormat_UnsupportedTitle": "Unsupported backup repository format", - "RepositoryFormat_UnsupportedVersionFormat": "Detected format {arg0}; this version supports format {arg1}.", - "SettingsPage_VerboseLoggingDescription": "Enable only when helping developers diagnose issues.", - "SettingsPage_VerboseLoggingName": "Verbose output", - "Summary_Author": "Author", - "Summary_Elapsed": "Elapsed", - "Summary_Mod": "Mod", - "Summary_Version": "Version", - "UninstallPage_AnalyzingMod": "Analyzing uninstall safety...", - "UninstallPage_BackAction": "Back", - "UninstallPage_CannotUninstallDependency": "Cannot uninstall because a remaining mod has a real patch dependency.", - "UninstallPage_CannotUninstallIntegrityConflict": "Cannot uninstall because one or more current game files differ from the active composed layers.", - "UninstallPage_NoInstalledModsFound": "No installed mods found.", - "UninstallPage_ReturnAction": "Return to main menu", - "UninstallPage_UninstallAction": "Uninstall", - "UninstallPage_UninstallingMod": "Uninstalling mod...", - "UninstallPreview_CannotRemoveDependency": "Cannot remove: real patch dependencies were found.", - "UninstallPreview_CannotRemoveIntegrity": "Cannot remove: current game files do not match the active composition.", - "UninstallPreview_Dependencies": "Real patch dependencies", - "UninstallPreview_DependencyDetailsFormat": "Dependent mod: {modName} {modVersion}; file: {relativePath}; diagnostic: {diagnostic}", - "UninstallPreview_Status": "UNINSTALL PREVIEW", - "UninstallPreview_WillModify": "Files to modify", - "UninstallResult_ChangedFiles": "Changed files", - "UninstallResult_Status": "UNINSTALLED", - "UninstallSummary_ChangedFiles": "Changed files", - "UninstallSummary_GameDirectory": "Game directory", - "UninstallSummary_Installed": "Installed", - "Update_AvailableFormat": "A new version is available: {version}", - "Update_DownloadFormat": "Download: {releaseUrl}" -} diff --git a/src/UnityAssetsPatcher.TUI/Localization/JSON/zh-CN.json b/src/UnityAssetsPatcher.TUI/Localization/JSON/zh-CN.json deleted file mode 100644 index 84f8c293..00000000 --- a/src/UnityAssetsPatcher.TUI/Localization/JSON/zh-CN.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "Error_AccessDeniedFormat": "没有访问权限:{arg0}", - "Error_AssetNotFound": "未找到指定资产。", - "Error_DirectoryNotFoundFormat": "目录不存在:{arg0}", - "Error_FileIntegrityMismatch": "当前游戏文件与活跃层合成结果不一致。", - "Error_FileNotFoundFormat": "文件不存在:{arg0}", - "Error_FileSystemFailureFormat": "文件操作失败:{arg0}", - "Error_GameDirectoryNotFoundFormat": "游戏目录不存在:{arg0}", - "Error_GameDirectoryRequired": "请选择游戏目录后继续。", - "Error_InstallPreviewStale": "预览已失效,请重新预览后再安装。", - "Error_InstallRecordNotFound": "未找到所选安装记录。", - "Error_InvalidManifest": "Mod manifest 无效。", - "Error_InvalidModPackage": "Mod 安装包无效。", - "Error_OperationAlreadyRunning": "另一个安装、卸载或恢复操作正在运行。", - "Error_OperationFailed": "操作失败。", - "Error_PatchPlanningFailed": "补丁无法应用到所选游戏文件。", - "Error_RecoveryRequired": "必须先恢复中断的操作。", - "Error_RepositoryUnsafe": "备份仓库已损坏或不安全。", - "Error_RepositoryClearNotAllowed": "只有格式不受支持时才能清空备份仓库。", - "Error_UnexpectedFormat": "发生意外错误,详细信息已写入:{logDirectory}", - "Error_UnsupportedManifestVersion": "不支持此 manifest 架构版本。", - "Error_UnsupportedRepositoryVersion": "不支持此备份仓库版本。", - "InspectPage_AllRowsChoice": "全部", - "InspectPage_Analyzing": "正在读取 Assets 文件……", - "InspectPage_AssetsFilePathPrompt": "Assets 文件路径", - "InspectPage_BackAction": "返回", - "InspectPage_CustomLimitChoice": "自定义数量", - "InspectPage_Description": "列出资产,或查看指定 Path ID 的字段树", - "InspectPage_First100Choice": "前 100 个", - "InspectPage_ListAssetsDescription": "列出单个 assets 文件中的资产摘要", - "InspectPage_ListAssetsTitle": "浏览资产列表", - "InspectPage_MaximumRowsPrompt": "最大行数", - "InspectPage_NameColumn": "名称", - "InspectPage_PathIdColumn": "Path ID", - "InspectPage_PathIdPrompt": "Path ID", - "InspectPage_ReturnAction": "返回浏览菜单", - "InspectPage_RowsToPrintTitle": "输出数量", - "InspectPage_ShowFieldsDescription": "输出指定 Path ID 资产的字段树", - "InspectPage_ShowFieldsTitle": "检查资产字段", - "InspectPage_ShowingAssetsFormat": "正在显示 {arg1} 个资产中的 {arg0} 个", - "InspectPage_TypeNameColumn": "类型名称", - "InstallPage_AnalyzingMod": "正在分析 Mod……", - "InstallPage_BackAction": "返回", - "InstallPage_GameDirectoryPrompt": "游戏目录", - "InstallPage_InstallAction": "安装", - "InstallPage_InstallingMod": "正在安装 Mod……", - "InstallPage_ModZipFileType": "Mod ZIP 文件", - "InstallPage_OptionalGroupsHeader": "该 Mod 包含以下附加内容:", - "InstallPage_ReturnAction": "返回主菜单", - "InstallPage_SelectModAction": "选择一个 Mod", - "InstallPage_SelectModDialogTitle": "选择一个 Mod", - "InstallPage_SubmitAction": "提交", - "InstallPreview_DryRunStatus": "预览", - "InstallPreview_PlanningFailedFormat": "补丁规划失败({arg0}),无法执行安装。", - "InstallPreview_Targets": "目标", - "InstallResult_InstalledStatus": "已安装", - "InstallResult_OperatedFiles": "已操作文件", - "Install_TimingHeader": "各阶段耗时", - "Layout_LegacyConsoleWarning": "警告:你正在使用旧版 Windows 控制台,部分界面可能无法正确显示。", - "Layout_ShortcutHint": "快捷键:↑/↓ 选择 | Space 切换 | Enter 确认 | Esc 返回 | Ctrl+C 退出", - "MainMenu_InspectAssets_Description": "列出资产并查看字段树", - "MainMenu_InspectAssets_Title": "浏览 Assets", - "MainMenu_InstallMod_Description": "预览 Mod 更改的内容,随后一键安装", - "MainMenu_InstallMod_Title": "安装 Mod", - "MainMenu_Settings_Description": "自定义你的使用体验", - "MainMenu_Settings_Title": "设置", - "MainMenu_Title": "主菜单", - "MainMenu_UninstallMod_Description": "卸载 Mod,并重建 overlay", - "MainMenu_UninstallMod_Title": "卸载 Mod", - "PatchError_FieldNotFoundFormat": "未找到字段:{arg0}", - "PatchError_InvalidConfiguration": "补丁配置无效。", - "PatchError_NoMatchingAssets": "未找到匹配的资产。", - "PatchError_PathIdReferenceAmbiguous": "引用的资产匹配到多个 Path ID。", - "PatchError_PathIdReferenceNotFound": "无法解析引用的 Path ID。", - "PatchError_ReplacementMatchInvalid": "替换资产的匹配条件无效或存在歧义。", - "PatchError_ReplacementSourceNotFound": "未找到替换资产的源文件。", - "PatchError_UnsupportedValue": "补丁包含不支持的值。", - "PatchError_ValueMismatchFormat": "字段 {arg0} 的当前值为 {arg2},预期为 {arg1}。", - "Prompt_DirectoryNotFoundFormat": "目录不存在:{arg0}", - "Prompt_FileNotFoundFormat": "文件不存在:{arg0}", - "Prompt_InvalidIntegerFormat": "{arg0} 必须是整数", - "Prompt_InvalidPositiveIntegerFormat": "{arg0} 必须是大于 0 的整数", - "Prompt_LabelRequiredFormat": "{arg0} 为必填项", - "RepositoryRecovery_ApplyAction": "执行恢复", - "RepositoryRecovery_ApplyDescription": "执行上方明确列出的恢复操作。", - "RepositoryRecovery_BackAction": "选择其他目录", - "RepositoryRecovery_BackDescription": "不修改文件并返回。", - "RepositoryRecovery_DamagedDescription": "存在中断的操作或损坏的内容,处理完成前无法继续使用应用。", - "RepositoryRecovery_DamagedTitle": "备份仓库需要处理", - "RepositoryRecovery_ExitAction": "退出程序", - "RepositoryRecovery_ExitDescription": "不进行任何更改并关闭应用。", - "RepositoryRecovery_GameDirectoryPrompt": "已确认的游戏目录", - "RepositoryRecovery_InterruptedOperation": "检测到中断的安装或卸载操作。", - "RepositoryRecovery_LockedFormat": "备份仓库已锁定为只读:{arg0}", - "RepositoryRecovery_OperationFailed": "无法完成恢复操作。", - "RepositoryRecovery_PreviewAction": "预览恢复", - "RepositoryRecovery_PreviewDescription": "验证目录并在恢复前显示每项文件操作。", - "RepositoryRecovery_PreviewTitle": "恢复预览", - "RepositoryRecovery_RecoveredFormat": "已恢复 {arg0} 个中断的备份操作。", - "RepositoryRecovery_RecoveryUnsafe": "无法安全地继续恢复,未再更改其他文件。", - "RepositoryRecovery_RepositoryUnsafe": "备份仓库包含损坏或不可信的数据。", - "RepositoryRecovery_RetryAction": "尝试修复", - "RepositoryRecovery_RetryDescription": "重新检查备份仓库,不修改任何文件。", - "RepositoryFormat_CancelAction": "保留仓库", - "RepositoryFormat_ClearAction": "清空备份仓库", - "RepositoryFormat_ClearDescription": "永久删除这个不受支持的仓库,并初始化为当前格式。", - "RepositoryFormat_ClearFailedFormat": "未能清空备份仓库:{arg0}", - "RepositoryFormat_ClearWarning": "清空会删除所有备份和安装记录,但不会还原已修改的游戏文件。", - "RepositoryFormat_ConfirmAction": "永久清空仓库", - "RepositoryFormat_ConfirmDescription": "此操作无法撤销。现有 Mod 可能仍然生效,但之后无法再通过此仓库卸载或恢复。", - "RepositoryFormat_ConfirmTitle": "确定清空不受支持的备份仓库?", - "RepositoryFormat_ExitAction": "退出程序", - "RepositoryFormat_ExitDescription": "不修改仓库并关闭应用。", - "RepositoryFormat_UnsupportedDescription": "清空不受支持的仓库之前,所有仓库操作均被阻止。", - "RepositoryFormat_UnsupportedTitle": "不支持此备份仓库格式", - "RepositoryFormat_UnsupportedVersionFormat": "检测到格式 {arg0};当前程序支持格式 {arg1}。", - "SettingsPage_VerboseLoggingDescription": "仅在协助开发者定位问题时开启", - "SettingsPage_VerboseLoggingName": "详细输出", - "Summary_Author": "作者", - "Summary_Elapsed": "耗时", - "Summary_Mod": "Mod", - "Summary_Version": "版本", - "UninstallPage_AnalyzingMod": "正在分析卸载安全性……", - "UninstallPage_BackAction": "返回", - "UninstallPage_CannotUninstallDependency": "剩余 Mod 存在真实补丁依赖,无法卸载此层", - "UninstallPage_CannotUninstallIntegrityConflict": "当前游戏文件与活跃层合成结果不一致,无法卸载", - "UninstallPage_NoInstalledModsFound": "未找到已安装的 Mod", - "UninstallPage_ReturnAction": "返回主菜单", - "UninstallPage_UninstallAction": "卸载", - "UninstallPage_UninstallingMod": "正在卸载 Mod……", - "UninstallPreview_CannotRemoveDependency": "无法移除:存在真实补丁依赖", - "UninstallPreview_CannotRemoveIntegrity": "无法移除:当前游戏文件与活跃层合成结果不一致", - "UninstallPreview_Dependencies": "真实补丁依赖", - "UninstallPreview_DependencyDetailsFormat": "依赖 Mod:{modName} {modVersion};文件:{relativePath};诊断:{diagnostic}", - "UninstallPreview_Status": "卸载预览", - "UninstallPreview_WillModify": "即将修改", - "UninstallResult_ChangedFiles": "已变更文件", - "UninstallResult_Status": "已卸载", - "UninstallSummary_ChangedFiles": "变更文件", - "UninstallSummary_GameDirectory": "游戏目录", - "UninstallSummary_Installed": "安装时间", - "Update_AvailableFormat": "发现新版本:{version}", - "Update_DownloadFormat": "下载地址:{releaseUrl}" -} diff --git a/src/UnityAssetsPatcher.TUI/Localization/OperationErrorFormatter.cs b/src/UnityAssetsPatcher.TUI/Localization/OperationErrorFormatter.cs deleted file mode 100644 index d7763cfb..00000000 --- a/src/UnityAssetsPatcher.TUI/Localization/OperationErrorFormatter.cs +++ /dev/null @@ -1,140 +0,0 @@ -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.Application.Assets; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.Application.IO; -using UnityAssetsPatcher.Application.Installation; -using UnityAssetsPatcher.Application.Mods; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Application.Patching; -using UnityAssetsPatcher.Application.Repository; - -namespace UnityAssetsPatcher.TUI.Localization; - -internal static class OperationErrorFormatter -{ - public static string Format(LocalizedStrings strings, OperationError error) - { - string context = ParameterText(error, "path") ?? string.Empty; - - if (error.Code == FileErrorCodes.NotFound) - { - return FormatContext(strings.Error_FileNotFoundFormat, context); - } - - if (error.Code == FileErrorCodes.DirectoryNotFound) - { - return FormatContext(strings.Error_DirectoryNotFoundFormat, context); - } - - if (error.Code == FileErrorCodes.AccessDenied) - { - return FormatContext(strings.Error_AccessDeniedFormat, context); - } - - if (error.Code == FileErrorCodes.ReadFailed || error.Code == FileErrorCodes.SystemFailure) - { - return FormatContext(strings.Error_FileSystemFailureFormat, context); - } - - if (error.Code == ManifestErrorCodes.InvalidJson || - error.Code == ManifestErrorCodes.InvalidPropertyType || - error.Code == ManifestErrorCodes.BlankProperty || - error.Code == ManifestErrorCodes.EmptyCollection || - error.Code == ManifestErrorCodes.EmptyObject || - error.Code == ManifestErrorCodes.InvalidComponentType || - error.Code == ManifestErrorCodes.ConflictingProperties || - error.Code == ManifestErrorCodes.MissingProperty) - { - return strings.Error_InvalidManifest; - } - - if (error.Code == ManifestErrorCodes.UnsupportedSchema) - { - return strings.Error_UnsupportedManifestVersion; - } - - if (error.Code.Value.StartsWith("mod_package.", StringComparison.Ordinal)) - { - return strings.Error_InvalidModPackage; - } - - return error.Code switch - { - _ when error.Code == GameDirectoryErrorCodes.Required => - strings.Error_GameDirectoryRequired, - _ when error.Code == GameDirectoryErrorCodes.NotFound => - FormatContext(strings.Error_GameDirectoryNotFoundFormat, context), - _ when error.Code == AssetErrorCodes.NotFound => strings.Error_AssetNotFound, - _ when error.Code == PatchErrorCodes.PlanningFailed => - strings.Error_PatchPlanningFailed, - _ when error.Code == ModOperationErrorCodes.InstallRecordNotFound => - strings.Error_InstallRecordNotFound, - _ when error.Code == ModOperationErrorCodes.FileIntegrityMismatch => - strings.Error_FileIntegrityMismatch, - _ when error.Code == ModOperationErrorCodes.InstallPreviewStale => - strings.Error_InstallPreviewStale, - _ when error.Code == RepositoryErrorCodes.OperationAlreadyRunning => - strings.Error_OperationAlreadyRunning, - _ when error.Code == RepositoryErrorCodes.RecoveryRequired => strings.Error_RecoveryRequired, - _ when error.Code == RepositoryErrorCodes.Unsafe => - strings.Error_RepositoryUnsafe, - _ when error.Code == RepositoryErrorCodes.UnsupportedVersion => - strings.Error_UnsupportedRepositoryVersion, - _ when error.Code == RepositoryErrorCodes.ClearNotAllowed => strings.Error_RepositoryClearNotAllowed, - _ => strings.Error_OperationFailed - }; - } - - public static string FormatUnexpected(LocalizedStrings strings) - { - return strings.Error_UnexpectedFormat(AppConfig.LogDirectory); - } - - public static string Format(LocalizedStrings strings, PatchDiagnostic diagnostic) - { - return diagnostic.Code switch - { - PatchDiagnosticCode.InvalidPatchConfiguration => strings.PatchError_InvalidConfiguration, - PatchDiagnosticCode.NoMatchingAssets => strings.PatchError_NoMatchingAssets, - PatchDiagnosticCode.FieldNotFound => - strings.PatchError_FieldNotFoundFormat(diagnostic.FieldPath), - PatchDiagnosticCode.ValueMismatch => strings.PatchError_ValueMismatchFormat( - diagnostic.FieldPath, - diagnostic.Expected, - diagnostic.Actual), - PatchDiagnosticCode.UnsupportedValue => strings.PatchError_UnsupportedValue, - PatchDiagnosticCode.PathIdReferenceNotFound => strings.PatchError_PathIdReferenceNotFound, - PatchDiagnosticCode.PathIdReferenceAmbiguous => strings.PatchError_PathIdReferenceAmbiguous, - PatchDiagnosticCode.ReplacementSourceNotFound => - strings.PatchError_ReplacementSourceNotFound, - PatchDiagnosticCode.ReplacementMatchInvalid => strings.PatchError_ReplacementMatchInvalid, - _ => strings.Error_OperationFailed - }; - } - - public static string Format(LocalizedStrings strings, RepositoryRecoveryIssue issue) - { - return issue.Code switch - { - RepositoryRecoveryIssueCode.RepositoryUnsafe => strings.RepositoryRecovery_RepositoryUnsafe, - RepositoryRecoveryIssueCode.RecoveryUnsafe => strings.RepositoryRecovery_RecoveryUnsafe, - RepositoryRecoveryIssueCode.OperationFailed => strings.RepositoryRecovery_OperationFailed, - RepositoryRecoveryIssueCode.UnexpectedFailure => FormatUnexpected(strings), - _ => strings.Error_OperationFailed - }; - } - - private static string FormatContext(Func format, string context) - { - string text = format(context); - - return string.IsNullOrWhiteSpace(context) - ? text.TrimEnd().TrimEnd(':', ':') - : text; - } - - private static string? ParameterText(OperationError error, string key) - { - return error.Parameters.TryGetValue(key, out object? value) ? value?.ToString() : null; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Navigation/TerminalNavigator.cs b/src/UnityAssetsPatcher.TUI/Navigation/TerminalNavigator.cs deleted file mode 100644 index 100a8980..00000000 --- a/src/UnityAssetsPatcher.TUI/Navigation/TerminalNavigator.cs +++ /dev/null @@ -1,84 +0,0 @@ -using UnityAssetsPatcher.TUI.Pages; -using UnityAssetsPatcher.TUI.Shell; - -namespace UnityAssetsPatcher.TUI.Navigation; - -public sealed class TerminalNavigator -{ - private readonly Dictionary> _pages; - private readonly TerminalShellView _shell; - private TerminalRoute? _currentRoute; - private TerminalRoute? _pendingRoute; - private bool _isNavigating; - - public TerminalNavigator(TerminalShellView shell, IReadOnlyDictionary> pages) - { - ArgumentNullException.ThrowIfNull(shell); - ArgumentNullException.ThrowIfNull(pages); - - foreach (var createPage in pages.Values) - { - ArgumentNullException.ThrowIfNull(createPage, nameof(pages)); - } - - _shell = shell; - _pages = pages.ToDictionary(); - } - - public void Navigate(TerminalRoute route) - { - if (_isNavigating) - { - _pendingRoute = route; - - return; - } - - _isNavigating = true; - - try - { - TerminalRoute? nextRoute = route; - - while (nextRoute is { } requestedRoute) - { - _pendingRoute = null; - NavigateCore(requestedRoute); - nextRoute = _pendingRoute; - } - } - finally - { - _isNavigating = false; - _pendingRoute = null; - } - } - - private void NavigateCore(TerminalRoute route) - { - if (_currentRoute == route) - { - return; - } - - if (!_pages.TryGetValue(route, out var createPage)) - { - throw new InvalidOperationException( - $"No terminal page is registered for route '{route}'."); - } - - TerminalPageView content = - createPage() ?? - throw new InvalidOperationException($"The terminal page factory for route '{route}' returned null."); - - content.NavigationRequested += OnNavigationRequested; - - _shell.ShowContent(content); - _currentRoute = route; - } - - private void OnNavigationRequested(object? sender, TerminalRoute route) - { - Navigate(route); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Navigation/TerminalRoute.cs b/src/UnityAssetsPatcher.TUI/Navigation/TerminalRoute.cs deleted file mode 100644 index e6d58137..00000000 --- a/src/UnityAssetsPatcher.TUI/Navigation/TerminalRoute.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace UnityAssetsPatcher.TUI.Navigation; - -public enum TerminalRoute -{ - MainMenu, - InstallMod, - UninstallMod, - InspectAssets, - Settings -} diff --git a/src/UnityAssetsPatcher.TUI/Navigation/TerminalRouteTable.cs b/src/UnityAssetsPatcher.TUI/Navigation/TerminalRouteTable.cs deleted file mode 100644 index 5ac7d9e0..00000000 --- a/src/UnityAssetsPatcher.TUI/Navigation/TerminalRouteTable.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Globalization; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.TUI.Localization; -using UnityAssetsPatcher.TUI.Pages; -using UnityAssetsPatcher.TUI.Pages.InspectAssets; -using UnityAssetsPatcher.TUI.Pages.InstallMod; -using UnityAssetsPatcher.TUI.Pages.MainMenu; -using UnityAssetsPatcher.TUI.Pages.Settings; -using UnityAssetsPatcher.TUI.Pages.UninstallMod; - -namespace UnityAssetsPatcher.TUI.Navigation; - -public sealed class TerminalRouteTable -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly AppRuntimeConfig _runtimeConfig; - private readonly MainMenuLogic _mainMenuLogic; - private readonly ILoggingLevelSwitch? _loggingLevelSwitch; - - public TerminalRouteTable( - IServiceScopeFactory scopeFactory, - AppRuntimeConfig runtimeConfig, - MainMenuLogic mainMenuLogic, - ILoggingLevelSwitch? loggingLevelSwitch = null) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(runtimeConfig); - ArgumentNullException.ThrowIfNull(mainMenuLogic); - - _scopeFactory = scopeFactory; - _runtimeConfig = runtimeConfig; - _mainMenuLogic = mainMenuLogic; - _loggingLevelSwitch = loggingLevelSwitch; - } - - public IReadOnlyDictionary> Create( - CultureInfo culture) - { - ArgumentNullException.ThrowIfNull(culture); - - var strings = new LocalizedStrings(culture); - MainMenuItem[] menuItems = - [ - new( - strings.MainMenu_InstallMod_Title, - strings.MainMenu_InstallMod_Description, - TerminalRoute.InstallMod), - new( - strings.MainMenu_UninstallMod_Title, - strings.MainMenu_UninstallMod_Description, - TerminalRoute.UninstallMod), - new( - strings.MainMenu_InspectAssets_Title, - strings.MainMenu_InspectAssets_Description, - TerminalRoute.InspectAssets), - new( - strings.MainMenu_Settings_Title, - strings.MainMenu_Settings_Description, - TerminalRoute.Settings) - ]; - - return new Dictionary> - { - [TerminalRoute.MainMenu] = () => - new MainMenuView(strings, menuItems, _mainMenuLogic), - [TerminalRoute.InstallMod] = () => - new InstallModView(strings, new InstallModLogic(_scopeFactory, _runtimeConfig)), - [TerminalRoute.UninstallMod] = () => - new UninstallModView(strings, new UninstallModLogic(_scopeFactory)), - [TerminalRoute.InspectAssets] = () => - new InspectAssetsView(strings, new InspectAssetsLogic(_scopeFactory)), - [TerminalRoute.Settings] = () => - new SettingsView(strings, new SettingsLogic(_runtimeConfig, _loggingLevelSwitch)) - }; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/InspectAssets/InspectAssetsLogic.cs b/src/UnityAssetsPatcher.TUI/Pages/InspectAssets/InspectAssetsLogic.cs deleted file mode 100644 index 2751aeee..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/InspectAssets/InspectAssetsLogic.cs +++ /dev/null @@ -1,227 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Features.Inspect; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Domain.Assets; - -namespace UnityAssetsPatcher.TUI.Pages.InspectAssets; - -public enum InspectAssetsOperation -{ - ListAssets, - ShowFields -} - -public abstract record InspectAssetsState -{ - public sealed record ActionMenu : InspectAssetsState; - - public sealed record EnterListPath : InspectAssetsState; - - public sealed record SelectLimit(string AssetsFilePath) : InspectAssetsState; - - public sealed record EnterCustomLimit(string AssetsFilePath) : InspectAssetsState; - - public sealed record EnterFields : InspectAssetsState; - - public sealed record Working(InspectAssetsOperation Operation) : InspectAssetsState; - - public sealed record Assets(InspectListResult Result) : InspectAssetsState; - - public sealed record Fields(AssetField FieldTree) : InspectAssetsState; - - public sealed record Failed(InspectAssetsOperation Operation, OperationError Error) : InspectAssetsState; -} - -public sealed class InspectAssetsLogic : TerminalPageLogic -{ - public InspectAssetsLogic(IServiceScopeFactory scopeFactory) - : base(scopeFactory, new InspectAssetsState.ActionMenu()) { } - - public void ShowActionMenu() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return; - } - - CurrentState = new InspectAssetsState.ActionMenu(); - } - } - - public void ShowListPathInput() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return; - } - - if (CurrentState is not (InspectAssetsState.ActionMenu or InspectAssetsState.SelectLimit)) - { - throw new InvalidOperationException("The list path input is not available."); - } - - CurrentState = new InspectAssetsState.EnterListPath(); - } - } - - public void SubmitListPath(string assetsFilePath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(assetsFilePath); - - lock (SyncRoot) - { - if (IsUnavailable) - { - return; - } - - if (CurrentState is not InspectAssetsState.EnterListPath) - { - throw new InvalidOperationException("An assets file path is not expected."); - } - - CurrentState = new InspectAssetsState.SelectLimit(assetsFilePath); - } - } - - public void ShowCustomLimitInput() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return; - } - - if (CurrentState is not InspectAssetsState.SelectLimit state) - { - throw new InvalidOperationException("A custom limit is not available."); - } - - CurrentState = new InspectAssetsState.EnterCustomLimit(state.AssetsFilePath); - } - } - - public void ReturnToLimitChoices() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return; - } - - if (CurrentState is not InspectAssetsState.EnterCustomLimit state) - { - throw new InvalidOperationException("The limit choices are not available."); - } - - CurrentState = new InspectAssetsState.SelectLimit(state.AssetsFilePath); - } - } - - public void ShowFieldsInput() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return; - } - - if (CurrentState is not InspectAssetsState.ActionMenu) - { - throw new InvalidOperationException("The fields input is not available."); - } - - CurrentState = new InspectAssetsState.EnterFields(); - } - } - - public Task InspectListAsync(int? limit) - { - if (limit is <= 0) - { - throw new ArgumentOutOfRangeException(nameof(limit)); - } - - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - string assetsFilePath = CurrentState switch - { - InspectAssetsState.SelectLimit state => state.AssetsFilePath, - InspectAssetsState.EnterCustomLimit state => state.AssetsFilePath, - _ => throw new InvalidOperationException("An assets list is not ready for inspection.") - }; - - return StartInspection( - new InspectListRequest(assetsFilePath, limit), - InspectAssetsOperation.ListAssets, - CompleteList); - } - } - - public Task InspectFieldsAsync(string assetsFilePath, long pathId) - { - ArgumentException.ThrowIfNullOrWhiteSpace(assetsFilePath); - - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (CurrentState is not InspectAssetsState.EnterFields) - { - throw new InvalidOperationException("Asset fields are not ready for inspection."); - } - - return StartInspection( - new InspectFieldsRequest(assetsFilePath, pathId), - InspectAssetsOperation.ShowFields, - CompleteFields); - } - } - - private Task StartInspection( - TRequest request, - InspectAssetsOperation operation, - Action> complete) - where TRequest : IRequest> - { - CurrentState = new InspectAssetsState.Working(operation); - return StartOperation(request, result => - { - if (result is OperationFailed failed) - { - CurrentState = new InspectAssetsState.Failed(operation, failed.Error); - return; - } - - complete(result); - }); - } - - private void CompleteList(OperationResult result) - { - var succeeded = (OperationSucceeded)result; - CurrentState = new InspectAssetsState.Assets(succeeded.Value); - } - - private void CompleteFields(OperationResult result) - { - var succeeded = (OperationSucceeded)result; - CurrentState = new InspectAssetsState.Fields(succeeded.Value); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/InspectAssets/InspectAssetsView.cs b/src/UnityAssetsPatcher.TUI/Pages/InspectAssets/InspectAssetsView.cs deleted file mode 100644 index 4af68989..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/InspectAssets/InspectAssetsView.cs +++ /dev/null @@ -1,531 +0,0 @@ -using System.Globalization; -using System.Text; -using Terminal.Gui.Text; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; -using UnityAssetsPatcher.Application.Features.Inspect; -using UnityAssetsPatcher.Domain.Assets; -using UnityAssetsPatcher.TUI.Framework; -using UnityAssetsPatcher.TUI.Localization; - -namespace UnityAssetsPatcher.TUI.Pages.InspectAssets; - -public sealed class InspectAssetsView : TerminalOperationPageView -{ - protected override bool CanReturnToMainMenu => !_logic.IsWorking; - - private const int DefaultLimit = 100; - - private readonly LocalizedStrings _strings; - private readonly InspectAssetsLogic _logic; - private readonly View _body; - - internal InspectAssetsView( - LocalizedStrings strings, - InspectAssetsLogic logic) - { - ArgumentNullException.ThrowIfNull(strings); - ArgumentNullException.ThrowIfNull(logic); - - _strings = strings; - _logic = logic; - - _body = new View - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Fill(), - CanFocus = true - }; - - Add(_body); - RenderState(); - - Disposing += (_, _) => _logic.Dispose(); - } - - protected override void RenderState() - { - if (IsDisposed) - { - return; - } - - _body.RemoveAllAndDispose(); - - switch (_logic.State) - { - case InspectAssetsState.ActionMenu: - SetActionMenuHeader(); - RenderActionMenu(); - break; - case InspectAssetsState.EnterListPath: - SetListPathHeader(); - RenderListPathInput(); - break; - case InspectAssetsState.SelectLimit: - SetListLimitHeader(); - RenderLimitChoices(); - break; - case InspectAssetsState.EnterCustomLimit: - SetListLimitHeader(); - RenderCustomLimitInput(); - break; - case InspectAssetsState.EnterFields: - SetFieldsHeader(); - RenderFieldsInput(); - break; - case InspectAssetsState.Working state: - SetOperationHeader(state.Operation); - RenderWorking(); - break; - case InspectAssetsState.Assets state: - SetListLimitHeader(); - RenderAssets(state.Result); - break; - case InspectAssetsState.Fields state: - SetFieldsHeader(); - RenderFields(state.FieldTree); - break; - case InspectAssetsState.Failed state: - SetOperationHeader(state.Operation); - RenderError(OperationErrorFormatter.Format(_strings, state.Error)); - break; - } - } - - private void RenderActionMenu() - { - ChoiceItem list = AddChoice( - _strings.InspectPage_ListAssetsTitle, - _strings.InspectPage_ListAssetsDescription, - 0); - list.Button.Accepted += (_, _) => RunTransition(_logic.ShowListPathInput); - - ChoiceItem fields = AddChoice( - _strings.InspectPage_ShowFieldsTitle, - _strings.InspectPage_ShowFieldsDescription, - 2); - fields.Button.Accepted += (_, _) => RunTransition(_logic.ShowFieldsInput); - - ChoiceItem.AlignDescriptions([list, fields]); - list.Button.SetFocus(); - } - - private ChoiceItem AddChoice(string text, string description, int row) - { - var choice = new ChoiceItem(text, description) - { - X = 0, - Y = row - }; - - _body.Add(choice); - return choice; - } - - private void RenderListPathInput() - { - string prompt = $"{_strings.InspectPage_AssetsFilePathPrompt}: "; - var label = new StyledLabel(prompt, TextRole.Label) - { - X = 0, - Y = 0 - }; - - var input = new InputField - { - X = prompt.GetColumns(), - Y = 0, - Width = Dim.Fill() - }; - - var error = new StyledLabel(role: TextRole.Error) - { - X = 0, - Y = 2, - Width = Dim.Fill(), - Visible = false - }; - - input.Accepted += (_, _) => - { - string path = TerminalPathNormalizer.Normalize(input.Text); - - if (!File.Exists(path)) - { - input.Text = string.Empty; - error.Text = _strings.Prompt_FileNotFoundFormat(path); - error.Visible = true; - input.SetFocus(); - return; - } - - input.Text = path; - RunTransition(() => _logic.SubmitListPath(Path.GetFullPath(path))); - }; - - ActionButton back = AddButton( - _strings.InspectPage_BackAction, - 4, - () => RunTransition(_logic.ShowActionMenu)); - _body.Add(label, input, error); - input.SetFocus(); - } - - private void RenderLimitChoices() - { - ActionButton first = AddButton( - _strings.InspectPage_First100Choice, - 0, - () => RunLogicAsync(() => _logic.InspectListAsync(DefaultLimit))); - AddButton( - _strings.InspectPage_AllRowsChoice, - 2, - () => RunLogicAsync(() => _logic.InspectListAsync(null))); - AddButton( - _strings.InspectPage_CustomLimitChoice, - 4, - () => RunTransition(_logic.ShowCustomLimitInput)); - AddButton( - _strings.InspectPage_BackAction, - 6, - () => RunTransition(_logic.ShowListPathInput)); - first.SetFocus(); - } - - private void RenderCustomLimitInput() - { - string prompt = $"{_strings.InspectPage_MaximumRowsPrompt}: "; - var label = new StyledLabel(prompt, TextRole.Label) - { - X = 0, - Y = 0 - }; - var input = new InputField - { - X = prompt.GetColumns(), - Y = 0, - Width = 12 - }; - var error = new StyledLabel(role: TextRole.Error) - { - X = 0, - Y = 2, - Width = Dim.Fill(), - Visible = false - }; - - input.Accepted += async (_, _) => await SubmitCustomLimitAsync(input, error); - AddButton( - _strings.InspectPage_BackAction, - 4, - () => RunTransition(_logic.ReturnToLimitChoices)); - _body.Add(label, input, error); - input.SetFocus(); - } - - private async Task SubmitCustomLimitAsync(InputField input, StyledLabel error) - { - if (!int.TryParse( - input.Text, - NumberStyles.Integer, - CultureInfo.InvariantCulture, - out int limit) || - limit <= 0) - { - input.Text = string.Empty; - error.Text = _strings.Prompt_InvalidPositiveIntegerFormat( - _strings.InspectPage_MaximumRowsPrompt); - error.Visible = true; - input.SetFocus(); - return; - } - - await RunLogicAsync(() => _logic.InspectListAsync(limit)); - } - - private void RenderFieldsInput() - { - string pathPrompt = $"{_strings.InspectPage_AssetsFilePathPrompt}: "; - var pathLabel = new StyledLabel(pathPrompt, TextRole.Label) - { - X = 0, - Y = 0 - }; - var pathInput = new InputField - { - X = pathPrompt.GetColumns(), - Y = 0, - Width = Dim.Fill() - }; - string idPrompt = $"{_strings.InspectPage_PathIdPrompt}: "; - var idLabel = new StyledLabel(idPrompt, TextRole.Label) - { - X = 0, - Y = 2 - }; - var idInput = new InputField - { - X = idPrompt.GetColumns(), - Y = 2, - Width = 20 - }; - var error = new StyledLabel(role: TextRole.Error) - { - X = 0, - Y = 4, - Width = Dim.Fill(), - Visible = false - }; - - pathInput.Accepted += (_, _) => idInput.SetFocus(); - idInput.Accepted += async (_, _) => - await SubmitFieldsAsync(pathInput, idInput, error); - AddButton( - _strings.InspectPage_ShowFieldsTitle, - 6, - () => SubmitFieldsAsync(pathInput, idInput, error), - ActionKind.Primary); - AddButton( - _strings.InspectPage_BackAction, - 8, - () => RunTransition(_logic.ShowActionMenu)); - _body.Add(pathLabel, pathInput, idLabel, idInput, error); - pathInput.SetFocus(); - } - - private async Task SubmitFieldsAsync( - InputField pathInput, - InputField idInput, - StyledLabel error) - { - string path = TerminalPathNormalizer.Normalize(pathInput.Text); - if (!File.Exists(path)) - { - pathInput.Text = string.Empty; - error.Text = _strings.Prompt_FileNotFoundFormat(path); - error.Visible = true; - pathInput.SetFocus(); - return; - } - - if (!long.TryParse( - idInput.Text, - NumberStyles.Integer, - CultureInfo.InvariantCulture, - out long pathId)) - { - idInput.Text = string.Empty; - error.Text = _strings.Prompt_InvalidIntegerFormat(_strings.InspectPage_PathIdPrompt); - error.Visible = true; - idInput.SetFocus(); - return; - } - - await RunLogicAsync(() => - _logic.InspectFieldsAsync(Path.GetFullPath(path), pathId)); - } - - private void RenderAssets(InspectListResult result) - { - var table = new DataTableView - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Fill(3), - Table = new AssetsTableSource(_strings, result.Assets) - }; - string infoText = result.Assets.Count < result.TotalCount - ? _strings.InspectPage_ShowingAssetsFormat(result.Assets.Count, result.TotalCount) - : string.Empty; - var info = new StyledLabel(infoText, TextRole.Muted) - { - X = 0, - Y = Pos.AnchorEnd(2), - Width = Dim.Fill() - }; - ActionButton back = AddButton( - _strings.InspectPage_ReturnAction, - Pos.AnchorEnd(1), - () => RunTransition(_logic.ShowActionMenu)); - _body.Add(table, info); - table.SetFocus(); - } - - private void RenderFields(AssetField fieldTree) - { - var output = new TextViewer(FormatFieldTree(fieldTree)) - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Fill(2) - }; - AddButton( - _strings.InspectPage_ReturnAction, - Pos.AnchorEnd(1), - () => RunTransition(_logic.ShowActionMenu)); - _body.Add(output); - output.SetFocus(); - } - - private void RenderWorking() - { - _body.Add(new WorkingIndicator(_strings.InspectPage_Analyzing) - { - X = 0, - Y = 0 - }); - } - - private void RenderError(string message) - { - var error = new StyledLabel(message, TextRole.Error) - { - X = 0, - Y = 0, - Width = Dim.Fill() - }; - ActionButton back = AddButton( - _strings.InspectPage_ReturnAction, - 2, - () => RunTransition(_logic.ShowActionMenu)); - _body.Add(error); - back.SetFocus(); - } - - private void RunTransition(Action transition) - { - transition(); - RenderState(); - } - - private void SetActionMenuHeader() - { - SetHeader( - _strings.MainMenu_InspectAssets_Title, - _strings.InspectPage_Description); - } - - private void SetListPathHeader() - { - SetHeader( - _strings.InspectPage_ListAssetsTitle, - _strings.InspectPage_ListAssetsDescription); - } - - private void SetListLimitHeader() - { - SetHeader( - _strings.InspectPage_RowsToPrintTitle, - _strings.InspectPage_ListAssetsDescription); - } - - private void SetFieldsHeader() - { - SetHeader( - _strings.InspectPage_ShowFieldsTitle, - _strings.InspectPage_ShowFieldsDescription); - } - - private void SetOperationHeader(InspectAssetsOperation operation) - { - if (operation == InspectAssetsOperation.ListAssets) - { - SetListLimitHeader(); - } - else - { - SetFieldsHeader(); - } - } - - private ActionButton AddButton( - string text, - Pos y, - Action action, - ActionKind kind = ActionKind.Default) - { - var button = new ActionButton(text, kind) - { - X = 0, - Y = y - }; - button.Accepted += (_, _) => action(); - _body.Add(button); - return button; - } - - private ActionButton AddButton( - string text, - Pos y, - Func action, - ActionKind kind = ActionKind.Default) - { - var button = new ActionButton(text, kind) - { - X = 0, - Y = y - }; - button.Accepted += async (_, _) => await action(); - _body.Add(button); - return button; - } - - private static string FormatFieldTree(AssetField root) - { - var text = new StringBuilder(); - AppendField(text, root, 0); - return text.ToString().TrimEnd(); - } - - private static void AppendField(StringBuilder text, AssetField field, int depth) - { - text.Append(' ', depth * 2).Append(field.Name).Append(" (").Append(field.TypeName).Append(')'); - if (field.Value is not null) - { - text.Append(": ").Append(field.Value.ToInvariantString()); - } - - text.AppendLine(); - foreach (AssetField child in field.Children) - { - AppendField(text, child, depth + 1); - } - } - - private sealed class AssetsTableSource : ITableSource - { - private readonly LocalizedStrings _strings; - private readonly IReadOnlyList _assets; - - public AssetsTableSource( - LocalizedStrings strings, - IReadOnlyList assets) - { - _strings = strings; - _assets = assets; - } - - public string[] ColumnNames => - [ - _strings.InspectPage_PathIdColumn, - _strings.InspectPage_TypeNameColumn, - _strings.InspectPage_NameColumn - ]; - - public int Columns => 3; - public int Rows => _assets.Count; - - public object this[int row, int col] => col switch - { - 0 => _assets[row].PathId.ToString(CultureInfo.InvariantCulture), - 1 => _assets[row].TypeName, - 2 => _assets[row].Name ?? string.Empty, - _ => throw new ArgumentOutOfRangeException(nameof(col)) - }; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/InstallMod/InstallModLogic.cs b/src/UnityAssetsPatcher.TUI/Pages/InstallMod/InstallModLogic.cs deleted file mode 100644 index 55994afd..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/InstallMod/InstallModLogic.cs +++ /dev/null @@ -1,213 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.Application.Features.Install; -using UnityAssetsPatcher.Application.Installation; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Application.Patching; - -namespace UnityAssetsPatcher.TUI.Pages.InstallMod; - -public abstract record InstallModState -{ - public sealed record SelectPackage(OperationError? Error = null) : InstallModState; - - public sealed record Analyzing : InstallModState; - - public sealed record EnterGameDirectory(OperationError Error, string? Directory, bool IsPrompt) : InstallModState; - - public sealed record SelectOptionalGroups( - IReadOnlyList<(string Name, string? Description)> Groups, - IReadOnlyList SelectedGroups, - OperationError? Error = null) : InstallModState; - - public sealed record Preview(InstallPreviewResult Result, PatchDiagnostic? BlockingDiagnostic) : InstallModState; - - public sealed record Installing : InstallModState; - - public sealed record Installed(InstallModResult Result) : InstallModState; - - public sealed record InstallFailed(OperationError Error) : InstallModState; -} - -public sealed class InstallModLogic : TerminalPageLogic -{ - public bool VerboseLogging => _runtimeConfig.VerboseLogging; - - private readonly AppRuntimeConfig _runtimeConfig; - private string? _modPath; - private string? _gameDirectory; - private string[] _selectedOptionalGroups = []; - private IReadOnlyList<(string Name, string? Description)> _optionalGroups = []; - private PreparedInstall? _preparedInstall; - private bool _optionalGroupsConfirmed; - private PreviewOrigin _previewOrigin; - - public InstallModLogic(IServiceScopeFactory scopeFactory, AppRuntimeConfig runtimeConfig) - : base(scopeFactory, new InstallModState.SelectPackage()) - { - ArgumentNullException.ThrowIfNull(runtimeConfig); - _runtimeConfig = runtimeConfig; - } - - public Task PreviewPackageAsync(string modPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(modPath); - - return StartPreview(() => - { - _modPath = modPath; - _gameDirectory = null; - _selectedOptionalGroups = []; - _optionalGroups = []; - _optionalGroupsConfirmed = false; - _previewOrigin = PreviewOrigin.Package; - }); - } - - public Task SubmitGameDirectoryAsync(string? gameDirectory) - { - return StartPreview(() => - { - _gameDirectory = gameDirectory; - _previewOrigin = PreviewOrigin.GameDirectory; - }, - typeof(InstallModState.EnterGameDirectory)); - } - - public Task SubmitOptionalGroupsAsync(IReadOnlyList selectedOptionalGroups) - { - ArgumentNullException.ThrowIfNull(selectedOptionalGroups); - string[] selection = selectedOptionalGroups.ToArray(); - - return StartPreview(() => - { - _selectedOptionalGroups = selection; - _optionalGroupsConfirmed = true; - _previewOrigin = PreviewOrigin.OptionalGroups; - }, typeof(InstallModState.SelectOptionalGroups)); - } - - public Task InstallAsync() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (CurrentState is not InstallModState.Preview { BlockingDiagnostic: null }) - { - throw new InvalidOperationException("The mod is not ready to install."); - } - - var request = new InstallRequest(_modPath!, _gameDirectory) - { - SelectedOptionalGroups = _selectedOptionalGroups, - PreparedInstall = _preparedInstall - }; - - CurrentState = new InstallModState.Installing(); - return StartOperation( - new InstallModRequest(request), - CompleteInstall); - } - } - - private Task StartPreview(Action configure, Type? requiredState = null) - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (requiredState is not null && !requiredState.IsInstanceOfType(CurrentState)) - { - throw new InvalidOperationException( - $"The current install state is '{CurrentState.GetType().Name}', not '{requiredState.Name}'."); - } - - configure(); - _preparedInstall = null; - - var request = new InstallRequest(_modPath!, _gameDirectory) - { - SelectedOptionalGroups = _selectedOptionalGroups, - IncludePatchPreviewDetails = false - }; - - CurrentState = new InstallModState.Analyzing(); - return StartOperation( - new PreviewInstallRequest(request), - CompletePreview); - } - } - - private void CompletePreview(OperationResult result) - { - if (result is OperationFailed failed) - { - bool needsGameDirectory = - (failed.Error.Code == GameDirectoryErrorCodes.Required || - failed.Error.Code == GameDirectoryErrorCodes.NotFound) && - string.IsNullOrEmpty(_gameDirectory); - - CurrentState = needsGameDirectory - ? new InstallModState.EnterGameDirectory( - failed.Error, - _gameDirectory, - true) - : CreateRetryState(failed.Error); - return; - } - - InstallPreviewResult preview = ((OperationSucceeded)result).Value; - _preparedInstall = preview.PreparedInstall; - - if (preview.OptionalGroups.Count > 0 && !_optionalGroupsConfirmed) - { - _optionalGroups = preview.OptionalGroups.ToArray(); - CurrentState = new InstallModState.SelectOptionalGroups( - _optionalGroups, - _selectedOptionalGroups); - return; - } - - PatchDiagnostic? diagnostic = preview.Changes - .Select(change => change.Preview?.Diagnostic) - .FirstOrDefault(candidate => candidate is not null); - CurrentState = new InstallModState.Preview(preview, diagnostic); - } - - private InstallModState CreateRetryState(OperationError error) - { - return _previewOrigin switch - { - PreviewOrigin.GameDirectory => new InstallModState.EnterGameDirectory( - error, - _gameDirectory, - false), - PreviewOrigin.OptionalGroups => new InstallModState.SelectOptionalGroups( - _optionalGroups, - _selectedOptionalGroups, - error), - _ => new InstallModState.SelectPackage(error) - }; - } - - private void CompleteInstall(OperationResult result) - { - CurrentState = result is OperationSucceeded succeeded - ? new InstallModState.Installed(succeeded.Value) - : new InstallModState.InstallFailed(((OperationFailed)result).Error); - } - - private enum PreviewOrigin - { - Package, - GameDirectory, - OptionalGroups - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/InstallMod/InstallModView.cs b/src/UnityAssetsPatcher.TUI/Pages/InstallMod/InstallModView.cs deleted file mode 100644 index 0f625987..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/InstallMod/InstallModView.cs +++ /dev/null @@ -1,483 +0,0 @@ -using System.Globalization; -using System.Text; -using Terminal.Gui.Text; -using Terminal.Gui.ViewBase; -using UnityAssetsPatcher.Application.Features.Install; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Application.Patching; -using UnityAssetsPatcher.TUI.Framework; -using UnityAssetsPatcher.TUI.Localization; - -namespace UnityAssetsPatcher.TUI.Pages.InstallMod; - -public sealed class InstallModView : TerminalOperationPageView -{ - protected override bool CanReturnToMainMenu => !_logic.IsWorking; - - private readonly LocalizedStrings _strings; - private readonly InstallModLogic _logic; - private readonly ScrollableContentView _form; - - internal InstallModView( - LocalizedStrings strings, - InstallModLogic logic) - { - ArgumentNullException.ThrowIfNull(strings); - ArgumentNullException.ThrowIfNull(logic); - - _strings = strings; - _logic = logic; - - SetHeader(_strings.MainMenu_InstallMod_Title, _strings.MainMenu_InstallMod_Description); - - _form = new ScrollableContentView - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Fill(), - CanFocus = true - }; - - Add(_form); - RenderState(); - - Initialized += async (_, _) => - { - RequestRender(); - await SelectPackageAsync(); - }; - Disposing += (_, _) => _logic.Dispose(); - } - - protected override void RenderState() - { - if (IsDisposed) - { - return; - } - - _form.RemoveAllAndDispose(); - - switch (_logic.State) - { - case InstallModState.SelectPackage state: - RenderPackageSelection(state.Error is null - ? null - : OperationErrorFormatter.Format(_strings, state.Error)); - break; - case InstallModState.Analyzing: - RenderWorking(_strings.InstallPage_AnalyzingMod); - break; - case InstallModState.EnterGameDirectory state: - RenderGameDirectory(state); - break; - case InstallModState.SelectOptionalGroups state: - RenderOptionalGroups(state); - break; - case InstallModState.Preview state: - RenderPreview(state); - break; - case InstallModState.Installing: - RenderWorking(_strings.InstallPage_InstallingMod); - break; - case InstallModState.Installed state: - RenderInstalled(state.Result); - break; - case InstallModState.InstallFailed state: - RenderInstallFailure(OperationErrorFormatter.Format(_strings, state.Error)); - break; - } - } - - private async Task SelectPackageAsync() - { - if (_logic.IsWorking || IsDisposed) - { - return; - } - - string? path = WindowsNativeFilePicker.PickFile( - _strings.InstallPage_SelectModDialogTitle, - _strings.InstallPage_ModZipFileType); - - if (string.IsNullOrWhiteSpace(path)) - { - RequestMainMenu(); - return; - } - - path = TerminalPathNormalizer.Normalize(path); - if (string.IsNullOrWhiteSpace(path)) - { - RenderPackageSelection( - _strings.Prompt_LabelRequiredFormat(_strings.InstallPage_SelectModAction)); - return; - } - - if (!File.Exists(path)) - { - RenderPackageSelection(_strings.Prompt_FileNotFoundFormat(path)); - return; - } - - await RunLogicAsync(() => _logic.PreviewPackageAsync(path)); - } - - private async Task SubmitGameDirectoryAsync( - InputField input, - StyledLabel message) - { - string directory = TerminalPathNormalizer.Normalize(input.Text); - - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - input.Text = string.Empty; - message.Text = _strings.Prompt_DirectoryNotFoundFormat(directory); - message.SetRole(TextRole.Error); - input.SetFocus(); - return; - } - - input.Text = directory; - await RunLogicAsync(() => _logic.SubmitGameDirectoryAsync(directory)); - } - - private async Task SubmitOptionalGroupsAsync(IReadOnlyList groups) - { - string[] selectedGroups = groups - .Where(group => group.IsSelected) - .Select(group => group.Name) - .ToArray(); - - await RunLogicAsync(() => _logic.SubmitOptionalGroupsAsync(selectedGroups)); - } - - private void RenderPackageSelection(string? error) - { - ActionButton select = AddButton(_strings.InstallPage_SelectModAction, 0, SelectPackageAsync); - - if (!string.IsNullOrEmpty(error)) - { - AddLabel(error, 2, TextRole.Error); - } - - FinishScreen(string.IsNullOrEmpty(error) ? 2 : 4, select); - } - - private void RenderWorking(string message) - { - _form.Add(new WorkingIndicator(message) { X = 0, Y = 0 }); - FinishScreen(2); - } - - private void RenderGameDirectory(InstallModState.EnterGameDirectory state) - { - ActionButton select = AddButton( - _strings.InstallPage_SelectModAction, - 0, - SelectPackageAsync); - - string prompt = $"{_strings.InstallPage_GameDirectoryPrompt}: "; - var label = new StyledLabel(prompt, TextRole.Label) { X = 0, Y = 2 }; - var input = new InputField - { - X = prompt.GetColumns(), - Y = 2, - Width = Dim.Fill() - }; - input.Text = state.Directory ?? string.Empty; - StyledLabel message = AddLabel( - OperationErrorFormatter.Format(_strings, state.Error), - 4, - state.IsPrompt ? TextRole.Muted : TextRole.Error); - - input.Accepted += async (_, _) => await SubmitGameDirectoryAsync(input, message); - - _form.Add(label, input); - FinishScreen(6, state.IsPrompt ? input : select); - } - - private void RenderOptionalGroups(InstallModState.SelectOptionalGroups state) - { - ActionButton select = AddButton( - _strings.InstallPage_SelectModAction, - 0, - SelectPackageAsync); - AddLabel(_strings.InstallPage_OptionalGroupsHeader, 3, TextRole.Preview); - - var choices = new List(state.Groups.Count); - for (int index = 0; index < state.Groups.Count; index++) - { - (string name, string? description) = state.Groups[index]; - var choice = new ToggleItem(name, description) - { - X = 0, - Y = 5 + index * 2, - IsSelected = state.SelectedGroups.Contains(name) - }; - choices.Add(choice); - _form.Add(choice); - } - - int actionsRow = 6 + state.Groups.Count * 2; - AddConfirmation( - _strings.InstallPage_SubmitAction, - () => SubmitOptionalGroupsAsync(choices), - actionsRow); - - if (state.Error is { } error) - { - AddLabel( - OperationErrorFormatter.Format(_strings, error), - actionsRow + 2, - TextRole.Error); - } - - FinishScreen( - actionsRow + (state.Error is null ? 2 : 4), - state.Error is null ? choices[0].Button : select); - } - - private void RenderPreview(InstallModState.Preview state) - { - InstallPreviewResult result = state.Result; - var summaryRows = GetPreviewSummaryRows(result); - AddLabel(_strings.InstallPreview_DryRunStatus, 0, TextRole.Preview); - _form.Add(new SummaryTableView(summaryRows) { X = 0, Y = 2 }); - - if (state.BlockingDiagnostic is { } diagnostic) - { - RenderBlockedPreview(diagnostic, summaryRows.Length); - return; - } - - int nextRow = summaryRows.Length + 3; - var patches = result.Changes - .Where(change => change.Kind == InstallChangeKind.Patch) - .ToArray(); - - if (patches.Length > 0) - { - nextRow = AddPreviewTargets(patches, nextRow); - } - - string verboseText = FormatPreviewVerboseDetails(result); - if (!string.IsNullOrEmpty(verboseText)) - { - int detailsHeight = GetReportHeight(verboseText); - AddLabel(verboseText, nextRow, height: detailsHeight); - nextRow += detailsHeight + 1; - } - - ConfirmationBar actions = AddConfirmation( - _strings.InstallPage_InstallAction, - () => RunLogicAsync(_logic.InstallAsync), - nextRow + 1); - FinishScreen(nextRow + 3, actions.ConfirmButton); - } - - private void RenderBlockedPreview(PatchDiagnostic diagnostic, int summaryRowCount) - { - string message = _strings.InstallPreview_PlanningFailedFormat( - OperationErrorFormatter.Format(_strings, diagnostic)); - AddLabel(message, summaryRowCount + 3, TextRole.Error); - ActionButton back = AddButton( - _strings.InstallPage_BackAction, - summaryRowCount + 5, - RequestMainMenu); - FinishScreen(summaryRowCount + 7, back); - } - - private void RenderInstalled(InstallModResult result) - { - var summaryRows = GetResultSummaryRows(result); - string text = FormatResultDetails(result); - int detailsHeight = string.IsNullOrEmpty(text) ? 0 : GetReportHeight(text); - AddLabel(_strings.InstallResult_InstalledStatus, 0, TextRole.Success); - _form.Add(new SummaryTableView(summaryRows) { X = 0, Y = 2 }); - int detailsRow = summaryRows.Length + 3; - int actionRow = detailsRow + detailsHeight + 1; - - if (!string.IsNullOrEmpty(text)) - { - _form.Add(new TextViewer(text) - { - X = 0, - Y = detailsRow, - Width = Dim.Fill(), - Height = detailsHeight - }); - } - - ActionButton back = AddButton( - _strings.InstallPage_ReturnAction, - actionRow, - RequestMainMenu); - FinishScreen(actionRow + 2, back); - } - - private void RenderInstallFailure(string text) - { - int outputHeight = GetReportHeight(text); - AddLabel(text, 0, TextRole.Error, outputHeight); - ActionButton back = AddButton( - _strings.InstallPage_ReturnAction, - outputHeight + 1, - RequestMainMenu); - FinishScreen(outputHeight + 3, back); - } - - private int AddPreviewTargets(IReadOnlyList patches, int row) - { - AddLabel(_strings.InstallPreview_Targets, row, TextRole.SectionHeader); - row += 2; - - foreach (InstallChange patch in patches) - { - string name = $"- {patch.Name}:"; - _form.Add( - new StyledLabel(name) { X = 0, Y = row }, - new StyledLabel(patch.Path, TextRole.Muted) - { - X = name.GetColumns() + 1, - Y = row, - Width = Dim.Fill() - }); - row++; - } - - return row; - } - - private (string Label, string Value)[] GetPreviewSummaryRows(InstallPreviewResult result) - { - return - [ - (_strings.Summary_Mod, result.ModName), - (_strings.Summary_Version, result.ModVersion), - (_strings.Summary_Author, result.ModAuthor) - ]; - } - - private (string Label, string Value)[] GetResultSummaryRows(InstallModResult result) - { - return - [ - (_strings.Summary_Mod, result.ModName), - (_strings.Summary_Version, result.ModVersion), - (_strings.Summary_Elapsed, FormatElapsed(result.Timing.Elapsed)) - ]; - } - - private string FormatPreviewVerboseDetails(InstallPreviewResult result) - { - if (!_logic.VerboseLogging) - { - return string.Empty; - } - - var text = new StringBuilder(); - AppendTiming(text, result.Timing); - return text.ToString().TrimEnd(); - } - - private string FormatResultDetails(InstallModResult result) - { - if (result.Changes.Count == 0) - { - return string.Empty; - } - - StringBuilder text = new StringBuilder().AppendLine(_strings.InstallResult_OperatedFiles); - - foreach (InstallChange change in result.Changes) - { - string path = change.Kind == InstallChangeKind.Patch - ? change.Name - : Path.GetFileName(change.Path); - text.Append("- ").AppendLine(path); - } - - return text.ToString().TrimEnd(); - } - - private void AppendTiming(StringBuilder text, TimingSnapshot snapshot) - { - text.AppendLine().AppendLine(_strings.Install_TimingHeader); - foreach (TimingStep step in snapshot.Steps) - { - text.Append(step.Name).Append(" ").AppendLine(FormatElapsed(step.Elapsed)); - } - } - - private StyledLabel AddLabel( - string text, - Pos y, - TextRole role = TextRole.Base, - int? height = null) - { - var label = new StyledLabel(text, role) - { - X = 0, - Y = y, - Width = Dim.Fill() - }; - if (height is { } value) - { - label.Height = value; - } - - _form.Add(label); - return label; - } - - private ActionButton AddButton(string text, Pos y, Action action) - { - var button = new ActionButton(text) { X = 0, Y = y }; - button.Accepted += (_, _) => action(); - _form.Add(button); - return button; - } - - private ActionButton AddButton(string text, Pos y, Func action) - { - var button = new ActionButton(text) { X = 0, Y = y }; - button.Accepted += async (_, _) => await action(); - _form.Add(button); - return button; - } - - private ConfirmationBar AddConfirmation(string text, Func action, Pos y) - { - var confirmation = new ConfirmationBar( - text, - action, - _strings.InstallPage_BackAction, - RequestMainMenu) - { - X = 0, - Y = y - }; - _form.Add(confirmation); - return confirmation; - } - - private void FinishScreen(int rowCount, View? focus = null) - { - _form.SetContentHeightForRows(rowCount); - focus?.SetFocus(); - } - - private static string FormatElapsed(TimeSpan elapsed) - { - return $"{elapsed.TotalSeconds.ToString("0.###", CultureInfo.InvariantCulture)} s"; - } - - private static int GetReportHeight(string text) - { - const int maximumVisibleLines = 20; - int lineCount = text.Count(character => character == '\n') + 1; - return Math.Min(lineCount, maximumVisibleLines); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/MainMenu/MainMenuLogic.cs b/src/UnityAssetsPatcher.TUI/Pages/MainMenu/MainMenuLogic.cs deleted file mode 100644 index 34aa96d6..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/MainMenu/MainMenuLogic.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Runtime.ExceptionServices; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Application.Updates; - -namespace UnityAssetsPatcher.TUI.Pages.MainMenu; - -public sealed class MainMenuLogic : IAsyncDisposable -{ - public event EventHandler? UpdateAvailable; - - public UpdateInfo? AvailableUpdate - { - get - { - lock (_sync) - { - return _availableUpdate; - } - } - } - - private readonly IServiceScopeFactory _scopeFactory; - private readonly CancellationTokenSource _lifetimeCancellation = new(); - private readonly Lock _sync = new(); - private Task? _updateCheckTask; - private UpdateInfo? _availableUpdate; - private bool _isDisposed; - - public MainMenuLogic(IServiceScopeFactory scopeFactory) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - - _scopeFactory = scopeFactory; - } - - public Task StartUpdateCheck() - { - lock (_sync) - { - ObjectDisposedException.ThrowIf(_isDisposed, this); - - return _updateCheckTask ??= CheckForUpdateAsync(_lifetimeCancellation.Token); - } - } - - public async ValueTask DisposeAsync() - { - Task? updateCheckTask; - - lock (_sync) - { - if (_isDisposed) - { - return; - } - - _isDisposed = true; - updateCheckTask = _updateCheckTask; - } - - try - { - Exception? cancellationFailure = null; - try - { - await _lifetimeCancellation.CancelAsync().ConfigureAwait(false); - } - catch (Exception exception) - { - cancellationFailure = exception; - } - - try - { - if (updateCheckTask is not null) - { - await updateCheckTask.ConfigureAwait(false); - } - } - catch (Exception operationFailure) when (cancellationFailure is not null) - { - throw new AggregateException(operationFailure, cancellationFailure); - } - - if (cancellationFailure is not null) - { - ExceptionDispatchInfo.Capture(cancellationFailure).Throw(); - } - } - finally - { - _lifetimeCancellation.Dispose(); - } - } - - private async Task CheckForUpdateAsync(CancellationToken cancellationToken) - { - try - { - var result = await DispatchUpdateCheckAsync(cancellationToken).ConfigureAwait(false); - - cancellationToken.ThrowIfCancellationRequested(); - - if (result is OperationSucceeded { Value: { } update }) - { - PublishAvailableUpdate(update); - } - } - catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested && - exception.CancellationToken == cancellationToken) { } - } - - private async Task> DispatchUpdateCheckAsync(CancellationToken cancellationToken) - { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - Exception? operationFailure = null; - try - { - var updateCheckModule = scope.ServiceProvider.GetRequiredService(); - return await updateCheckModule.CheckForUpdateAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) - { - operationFailure = exception; - throw; - } - finally - { - try - { - await scope.DisposeAsync().ConfigureAwait(false); - } - catch (Exception disposalFailure) when (operationFailure is not null) - { - throw new AggregateException(operationFailure, disposalFailure); - } - } - } - - private void PublishAvailableUpdate(UpdateInfo update) - { - lock (_sync) - { - if (_isDisposed || _availableUpdate is not null) - { - return; - } - - _availableUpdate = update; - } - - UpdateAvailable?.Invoke(this, EventArgs.Empty); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/MainMenu/MainMenuView.cs b/src/UnityAssetsPatcher.TUI/Pages/MainMenu/MainMenuView.cs deleted file mode 100644 index 06e4b1d8..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/MainMenu/MainMenuView.cs +++ /dev/null @@ -1,150 +0,0 @@ -using Terminal.Gui.App; -using Terminal.Gui.ViewBase; -using UnityAssetsPatcher.Application.Updates; -using UnityAssetsPatcher.TUI.Framework; -using UnityAssetsPatcher.TUI.Localization; -using UnityAssetsPatcher.TUI.Navigation; - -namespace UnityAssetsPatcher.TUI.Pages.MainMenu; - -public sealed record MainMenuItem(string Title, string Description, TerminalRoute Route); - -public sealed class MainMenuView : TerminalPageView -{ - protected override bool CanReturnToMainMenu => false; - - private readonly List _choices = []; - private readonly View _updateArea; - private readonly LocalizedStrings _strings; - private readonly MainMenuLogic _logic; - private bool _hasUpdate; - - internal MainMenuView( - LocalizedStrings strings, - IReadOnlyList items, - MainMenuLogic logic) - { - ArgumentNullException.ThrowIfNull(strings); - ArgumentNullException.ThrowIfNull(items); - ArgumentNullException.ThrowIfNull(logic); - - _strings = strings; - _logic = logic; - - SetHeader(_strings.MainMenu_Title); - - _updateArea = new View - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Auto() - }; - - Add(_updateArea); - - View previous = _updateArea; - ActionButton? firstButton = null; - - foreach (MainMenuItem item in items) - { - var choice = new ChoiceItemList(item.Title, item.Description) - { - X = 0, - Y = Pos.Bottom(previous) + (firstButton is null ? 0 : 1) - }; - - choice.Button.Accepted += (_, _) => - RequestNavigation(item.Route); - _choices.Add(choice); - - Add(choice); - - firstButton ??= choice.Button; - previous = choice; - } - - ChoiceItemList.AlignDescriptions(_choices); - - Initialized += async (_, _) => - { - firstButton?.SetFocus(); - ShowAvailableUpdateIfKnown(); - await _logic.StartUpdateCheck(); - }; - - _logic.UpdateAvailable += OnUpdateAvailable; - Disposing += (_, _) => _logic.UpdateAvailable -= OnUpdateAvailable; - } - - private void ShowAvailableUpdateIfKnown() - { - if (_logic.AvailableUpdate is { } update) - { - ShowAvailableUpdate(update); - } - } - - private void OnUpdateAvailable(object? sender, EventArgs eventArgs) - { - if (IsDisposed || _logic.AvailableUpdate is null) - { - return; - } - - IApplication? application = App; - - application?.Invoke(() => - { - if (IsDisposed || _logic.AvailableUpdate is not { } update) - { - return; - } - - ShowAvailableUpdate(update); - }); - } - - private void ShowAvailableUpdate(UpdateInfo update) - { - if (_hasUpdate) - { - return; - } - - AddUpdate(update); - _hasUpdate = true; - RequestRender(); - } - - private void AddUpdate(UpdateInfo update) - { - var available = new StyledLabel( - _strings.Update_AvailableFormat(update.Version), - TextRole.Preview) - { - X = 0, - Y = 0, - Width = Dim.Fill() - }; - - var download = new StyledLabel( - _strings.Update_DownloadFormat(update.ReleaseUrl), - TextRole.Muted) - { - X = 0, - Y = 1, - Width = Dim.Fill() - }; - - var spacer = new View - { - X = 0, - Y = 2, - Width = 1, - Height = 1 - }; - - _updateArea.Add(available, download, spacer); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/RepositoryIssue/RepositoryIssueLogic.cs b/src/UnityAssetsPatcher.TUI/Pages/RepositoryIssue/RepositoryIssueLogic.cs deleted file mode 100644 index d8cb1eb1..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/RepositoryIssue/RepositoryIssueLogic.cs +++ /dev/null @@ -1,296 +0,0 @@ -using System.Globalization; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.Application.Features.Recovery; -using UnityAssetsPatcher.Application.Features.RepositoryManagement; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Application.Repository; - -namespace UnityAssetsPatcher.TUI.Pages.RepositoryIssue; - -public abstract record RepositoryIssueState -{ - public sealed record Unchecked : RepositoryIssueState; - - public sealed record Ready : RepositoryIssueState; - - public sealed record UnsupportedFormat( - string ActualVersion, - string SupportedVersion, - OperationError? ClearError = null) : RepositoryIssueState; - - public sealed record ClearConfirmation(UnsupportedFormat Unsupported) : RepositoryIssueState; - - public sealed record RecoveryProblem(RepositoryRecoveryReport Report) : RepositoryIssueState; - - public sealed record RecoveryPreview( - RepositoryRecoveryPreview Preview, - RepositoryRecoveryReport PreviousReport) : RepositoryIssueState; -} - -public sealed class RepositoryIssueLogic : TerminalPageLogic -{ - public Task Completion => _completion.Task; - - private readonly TaskCompletionSource _completion = new( - TaskCreationOptions.RunContinuationsAsynchronously); - - public RepositoryIssueLogic( - IServiceScopeFactory scopeFactory, - CancellationToken cancellationToken) - : base(scopeFactory, new RepositoryIssueState.Unchecked(), cancellationToken) { } - - public Task InitializeAsync() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - return StartOperation( - new InitializeRepositoryRequest(), - CompleteInitialization); - } - } - - public void ShowClearConfirmation() - { - lock (SyncRoot) - { - ThrowIfUnavailable(); - - if (CurrentState is not RepositoryIssueState.UnsupportedFormat unsupported) - { - throw new InvalidOperationException("The repository format warning is not active."); - } - - CurrentState = new RepositoryIssueState.ClearConfirmation(unsupported); - } - } - - public void CancelClearConfirmation() - { - lock (SyncRoot) - { - ThrowIfUnavailable(); - - if (CurrentState is not RepositoryIssueState.ClearConfirmation confirmation) - { - throw new InvalidOperationException("The repository clear confirmation is not active."); - } - - CurrentState = confirmation.Unsupported; - } - } - - public Task ClearAsync() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (CurrentState is not RepositoryIssueState.ClearConfirmation confirmation) - { - throw new InvalidOperationException("The repository clear operation has not been confirmed."); - } - - RepositoryIssueState.UnsupportedFormat unsupported = confirmation.Unsupported; - - return StartOperation( - new ClearUnsupportedRepositoryRequest(), - result => CompleteClear(result, unsupported)); - } - } - - public Task PreviewRecoveryAsync(string gameDirectory) - { - ArgumentException.ThrowIfNullOrWhiteSpace(gameDirectory); - - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (CurrentState is not RepositoryIssueState.RecoveryProblem - { - Report.Status: RepositoryRecoveryStatus.RecoveryRequired - } recovery) - { - throw new InvalidOperationException("The repository is not waiting for a recovery preview."); - } - - return StartOperation( - new PreviewRecoveryRequest(gameDirectory), - result => CompleteRecoveryPreview(result, recovery.Report)); - } - } - - public void BackToRecovery() - { - lock (SyncRoot) - { - ThrowIfUnavailable(); - - if (CurrentState is not RepositoryIssueState.RecoveryPreview preview) - { - throw new InvalidOperationException("The repository recovery preview is not active."); - } - - CurrentState = new RepositoryIssueState.RecoveryProblem(preview.PreviousReport); - } - } - - public Task RecoverAsync() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (CurrentState is not RepositoryIssueState.RecoveryPreview - { - Preview.GameDirectory: { } gameDirectory - }) - { - throw new InvalidOperationException("The repository recovery has not been previewed."); - } - - return StartOperation( - new RecoverRecoveryRequest(gameDirectory), - CompleteRecovery); - } - } - - private void CompleteInitialization(OperationResult result) - { - if (result is OperationFailed failed && - failed.Error.Code == RepositoryErrorCodes.UnsupportedVersion) - { - CurrentState = CreateUnsupportedState(failed.Error); - - return; - } - - RepositoryRecoveryReport recovery = result switch - { - OperationSucceeded succeeded => succeeded.Value, - OperationFailed recoveryFailed => - recoveryFailed.Error.Recovery ?? FailedRecovery(), - _ => throw new ArgumentOutOfRangeException(nameof(result)) - }; - - SetRecoveryState(recovery); - } - - private void CompleteClear( - OperationResult result, - RepositoryIssueState.UnsupportedFormat unsupported) - { - if (result is OperationSucceeded) - { - SetReady(); - - return; - } - - var failed = result as OperationFailed ?? - throw new ArgumentOutOfRangeException(nameof(result)); - CurrentState = unsupported with { ClearError = failed.Error }; - } - - private void CompleteRecoveryPreview( - OperationResult result, - RepositoryRecoveryReport previousReport) - { - if (result is not OperationSucceeded succeeded) - { - SetRecoveryState(FailedRecovery()); - - return; - } - - RepositoryRecoveryPreview preview = succeeded.Value; - if (preview.CanRecover) - { - CurrentState = new RepositoryIssueState.RecoveryPreview(preview, previousReport); - - return; - } - - SetRecoveryState(new RepositoryRecoveryReport(preview.Status, [], preview.Issues)); - } - - private void CompleteRecovery(OperationResult result) - { - RepositoryRecoveryReport recovery = result switch - { - OperationSucceeded succeeded => succeeded.Value, - OperationFailed failed => - failed.Error.Recovery ?? FailedRecovery(), - _ => throw new ArgumentOutOfRangeException(nameof(result)) - }; - - SetRecoveryState(recovery); - } - - private void SetRecoveryState(RepositoryRecoveryReport recovery) - { - if (recovery.Status is RepositoryRecoveryStatus.RecoveryRequired or RepositoryRecoveryStatus.Locked) - { - CurrentState = new RepositoryIssueState.RecoveryProblem(recovery); - - return; - } - - SetReady(); - } - - private void SetReady() - { - CurrentState = new RepositoryIssueState.Ready(); - _completion.TrySetResult(); - } - - private static RepositoryIssueState.UnsupportedFormat CreateUnsupportedState(OperationError error) - { - string actualVersion = ParameterText(error, "actual") ?? "?"; - string supportedVersion = ParameterText(error, "supported") ?? - RepositoryService.CurrentRepositoryFormatVersion.ToString( - CultureInfo.InvariantCulture); - - return new RepositoryIssueState.UnsupportedFormat(actualVersion, supportedVersion); - } - - private static RepositoryRecoveryReport FailedRecovery() - { - return new RepositoryRecoveryReport( - RepositoryRecoveryStatus.Locked, - [], - [new RepositoryRecoveryIssue(RepositoryRecoveryIssueCode.UnexpectedFailure, string.Empty)]); - } - - private static string? ParameterText(OperationError error, string key) - { - return error.Parameters.TryGetValue(key, out object? value) - ? Convert.ToString(value, CultureInfo.InvariantCulture) - : null; - } - - private void ThrowIfUnavailable() - { - ObjectDisposedException.ThrowIf(IsDisposed, this); - if (IsWorking) - { - throw new InvalidOperationException("A repository operation is already running."); - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/RepositoryIssue/RepositoryIssueView.cs b/src/UnityAssetsPatcher.TUI/Pages/RepositoryIssue/RepositoryIssueView.cs deleted file mode 100644 index b3be7351..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/RepositoryIssue/RepositoryIssueView.cs +++ /dev/null @@ -1,322 +0,0 @@ -using Terminal.Gui.ViewBase; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.TUI.Framework; -using UnityAssetsPatcher.TUI.Localization; - -namespace UnityAssetsPatcher.TUI.Pages.RepositoryIssue; - -public sealed class RepositoryIssueView : TerminalContentView -{ - private readonly LocalizedStrings _strings; - private readonly RepositoryIssueLogic _logic; - private readonly Action _exit; - private readonly ScrollableContentView _content; - private View? _preferredFocus; - - internal RepositoryIssueView( - LocalizedStrings strings, - RepositoryIssueLogic logic, - Action exit) - { - ArgumentNullException.ThrowIfNull(strings); - ArgumentNullException.ThrowIfNull(logic); - ArgumentNullException.ThrowIfNull(exit); - - _strings = strings; - _logic = logic; - _exit = exit; - - _content = new ScrollableContentView - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Fill() - }; - - Add(_content); - RenderState(); - - Initialized += (_, _) => _preferredFocus?.SetFocus(); - } - - private void RenderState() - { - if (IsDisposed) - { - return; - } - - _preferredFocus = null; - _content.RemoveAllAndDispose(); - - switch (_logic.State) - { - case RepositoryIssueState.UnsupportedFormat state: - RenderUnsupportedFormat(state); - break; - case RepositoryIssueState.ClearConfirmation: - RenderClearConfirmation(); - break; - case RepositoryIssueState.RecoveryProblem state: - RenderRecoveryProblem(state.Report); - break; - case RepositoryIssueState.RecoveryPreview state: - RenderRecoveryPreview(state.Preview); - break; - case RepositoryIssueState.Unchecked: - case RepositoryIssueState.Ready: - throw new InvalidOperationException( - $"Repository issue view cannot render state '{_logic.State.GetType().Name}'."); - } - } - - private void RenderUnsupportedFormat(RepositoryIssueState.UnsupportedFormat state) - { - AddLabel(_strings.RepositoryFormat_UnsupportedTitle, 0, TextRole.Error); - AddLabel(_strings.RepositoryFormat_UnsupportedDescription, 2, TextRole.Preview); - AddLabel( - _strings.RepositoryFormat_UnsupportedVersionFormat( - state.ActualVersion, - state.SupportedVersion), - 4, - TextRole.Error); - AddLabel(_strings.RepositoryFormat_ClearWarning, 6, TextRole.Error); - - int actionRow = 9; - if (state.ClearError is { } error) - { - AddLabel( - _strings.RepositoryFormat_ClearFailedFormat( - OperationErrorFormatter.Format(_strings, error)), - 8, - TextRole.Error); - actionRow = 11; - } - - var clearChoice = new ChoiceItem( - _strings.RepositoryFormat_ClearAction, - _strings.RepositoryFormat_ClearDescription) - { - X = 0, - Y = actionRow - }; - clearChoice.Button.Accepted += (_, _) => Transition(_logic.ShowClearConfirmation); - - var exitChoice = new ChoiceItem( - _strings.RepositoryFormat_ExitAction, - _strings.RepositoryFormat_ExitDescription) - { - X = 0, - Y = actionRow + 2 - }; - exitChoice.Button.Accepted += (_, _) => _exit(); - - _content.Add(clearChoice, exitChoice); - ChoiceItem.AlignDescriptions([clearChoice, exitChoice]); - FinishScreen(actionRow + 5, clearChoice.Button); - } - - private void RenderClearConfirmation() - { - AddLabel(_strings.RepositoryFormat_ConfirmTitle, 0, TextRole.Error); - AddLabel(_strings.RepositoryFormat_ConfirmDescription, 2, TextRole.Error); - - var actions = new ConfirmationBar( - _strings.RepositoryFormat_ConfirmAction, - () => RunLogicAsync(_logic.ClearAsync), - _strings.RepositoryFormat_CancelAction, - () => Transition(_logic.CancelClearConfirmation), - ActionKind.Dangerous) - { - X = 0, - Y = 5 - }; - _content.Add(actions); - FinishScreen(7, actions.CancelButton); - } - - private void RenderRecoveryProblem(RepositoryRecoveryReport recovery) - { - string details = recovery.Issues.Count == 0 - ? _strings.RepositoryRecovery_InterruptedOperation - : string.Join(Environment.NewLine, recovery.Issues.Select(issue => - OperationErrorFormatter.Format(_strings, issue))); - AddLabel(_strings.RepositoryRecovery_DamagedTitle, 0, TextRole.Error); - AddLabel(_strings.RepositoryRecovery_DamagedDescription, 2, TextRole.Preview); - AddLabel(details, 4, TextRole.Error); - - var choices = new List(); - View focus; - - if (recovery.Status == RepositoryRecoveryStatus.RecoveryRequired) - { - var input = new InputField - { - X = 0, - Y = 7, - Width = Dim.Fill() - }; - var previewChoice = new ChoiceItem( - _strings.RepositoryRecovery_PreviewAction, - _strings.RepositoryRecovery_PreviewDescription) - { - X = 0, - Y = 9 - }; - previewChoice.Button.Accepted += async (_, _) => await PreviewRecoveryAsync(input); - input.Accepted += (_, _) => previewChoice.Button.SetFocus(); - - AddLabel($"{_strings.RepositoryRecovery_GameDirectoryPrompt}: ", 6, TextRole.Label); - _content.Add(input, previewChoice); - choices.Add(previewChoice); - focus = input; - } - else - { - var retryChoice = new ChoiceItem( - _strings.RepositoryRecovery_RetryAction, - _strings.RepositoryRecovery_RetryDescription) - { - X = 0, - Y = 7 - }; - retryChoice.Button.Accepted += async (_, _) => - await RunLogicAsync(_logic.InitializeAsync); - _content.Add(retryChoice); - choices.Add(retryChoice); - focus = retryChoice.Button; - } - - var exitChoice = new ChoiceItem( - _strings.RepositoryRecovery_ExitAction, - _strings.RepositoryRecovery_ExitDescription) - { - X = 0, - Y = 12 - }; - exitChoice.Button.Accepted += (_, _) => _exit(); - _content.Add(exitChoice); - choices.Add(exitChoice); - ChoiceItem.AlignDescriptions(choices); - FinishScreen(15, focus); - } - - private void RenderRecoveryPreview(RepositoryRecoveryPreview preview) - { - string summary = $"{preview.Kind} {preview.InstallId} — {preview.Action}"; - AddLabel(_strings.RepositoryRecovery_PreviewTitle, 0, TextRole.Preview); - AddLabel(preview.GameDirectory ?? string.Empty, 2, TextRole.Label); - AddLabel(summary, 4, TextRole.Preview); - AddLabel( - string.Join(Environment.NewLine, preview.Files.Select(file => - $"- {file.Action}: {file.RelativePath}")), - 6, - TextRole.Label); - - int actionRow = 8 + preview.Files.Count; - var choices = new List(); - View? focus = null; - - if (preview.CanRecover) - { - var applyChoice = new ChoiceItem( - _strings.RepositoryRecovery_ApplyAction, - _strings.RepositoryRecovery_ApplyDescription) - { - X = 0, - Y = actionRow - }; - applyChoice.Button.Accepted += async (_, _) => - await RunLogicAsync(_logic.RecoverAsync); - _content.Add(applyChoice); - choices.Add(applyChoice); - focus = applyChoice.Button; - } - - var backChoice = new ChoiceItem( - _strings.RepositoryRecovery_BackAction, - _strings.RepositoryRecovery_BackDescription) - { - X = 0, - Y = actionRow + 2 - }; - backChoice.Button.Accepted += (_, _) => Transition(_logic.BackToRecovery); - - var exitChoice = new ChoiceItem( - _strings.RepositoryRecovery_ExitAction, - _strings.RepositoryRecovery_ExitDescription) - { - X = 0, - Y = actionRow + 4 - }; - exitChoice.Button.Accepted += (_, _) => _exit(); - - _content.Add(backChoice, exitChoice); - choices.Add(backChoice); - choices.Add(exitChoice); - ChoiceItem.AlignDescriptions(choices); - FinishScreen(actionRow + 7, focus ?? backChoice.Button); - } - - private async Task PreviewRecoveryAsync(InputField input) - { - string path = TerminalPathNormalizer.Normalize(input.Text); - if (!string.IsNullOrWhiteSpace(path)) - { - await RunLogicAsync(() => _logic.PreviewRecoveryAsync(path)); - } - } - - private async Task RunLogicAsync(Func startOperation) - { - if (_logic.IsWorking || IsDisposed) - { - return; - } - - Task operation = startOperation(); - await operation; - - if (IsDisposed || _logic.State is RepositoryIssueState.Ready) - { - return; - } - - RenderState(); - RequestRender(); - } - - private void Transition(Action transition) - { - if (_logic.IsWorking || IsDisposed) - { - return; - } - - transition(); - RenderState(); - RequestRender(); - } - - private void AddLabel(string text, Pos y, TextRole role) - { - _content.Add(new StyledLabel(text, role) - { - X = 0, - Y = y, - Width = Dim.Fill() - }); - } - - private void FinishScreen(int rowCount, View focus) - { - _content.SetContentHeightForRows(rowCount); - _preferredFocus = focus; - - if (IsInitialized) - { - focus.SetFocus(); - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/Settings/SettingsLogic.cs b/src/UnityAssetsPatcher.TUI/Pages/Settings/SettingsLogic.cs deleted file mode 100644 index dab8f944..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/Settings/SettingsLogic.cs +++ /dev/null @@ -1,27 +0,0 @@ -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.Application.Contracts; - -namespace UnityAssetsPatcher.TUI.Pages.Settings; - -public sealed class SettingsLogic -{ - public bool VerboseLogging => _runtimeConfig.VerboseLogging; - - private readonly AppRuntimeConfig _runtimeConfig; - private readonly ILoggingLevelSwitch? _loggingLevelSwitch; - - public SettingsLogic(AppRuntimeConfig runtimeConfig, ILoggingLevelSwitch? loggingLevelSwitch = null) - { - ArgumentNullException.ThrowIfNull(runtimeConfig); - - _runtimeConfig = runtimeConfig; - _loggingLevelSwitch = loggingLevelSwitch; - } - - public void SetVerboseLogging(bool enabled) - { - _runtimeConfig.VerboseLogging = enabled; - - _loggingLevelSwitch?.MinimumLevel = enabled ? LoggingLevel.Debug : LoggingLevel.Information; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/Settings/SettingsView.cs b/src/UnityAssetsPatcher.TUI/Pages/Settings/SettingsView.cs deleted file mode 100644 index 4aef85d5..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/Settings/SettingsView.cs +++ /dev/null @@ -1,32 +0,0 @@ -using UnityAssetsPatcher.TUI.Framework; -using UnityAssetsPatcher.TUI.Localization; - -namespace UnityAssetsPatcher.TUI.Pages.Settings; - -public sealed class SettingsView : TerminalPageView -{ - private readonly ToggleItem _verboseOutput; - - internal SettingsView( - LocalizedStrings strings, - SettingsLogic logic) - { - ArgumentNullException.ThrowIfNull(strings); - ArgumentNullException.ThrowIfNull(logic); - - SetHeader(strings.MainMenu_Settings_Title, strings.MainMenu_Settings_Description); - - _verboseOutput = new ToggleItem( - strings.SettingsPage_VerboseLoggingName, - strings.SettingsPage_VerboseLoggingDescription) - { - X = 0, - Y = 0, - IsSelected = logic.VerboseLogging - }; - - _verboseOutput.IsSelectedChanged += (_, _) => logic.SetVerboseLogging(_verboseOutput.IsSelected); - - Add(_verboseOutput); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/TerminalContentView.cs b/src/UnityAssetsPatcher.TUI/Pages/TerminalContentView.cs deleted file mode 100644 index 049105e6..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/TerminalContentView.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Terminal.Gui.ViewBase; -using UnityAssetsPatcher.TUI.Shell; - -namespace UnityAssetsPatcher.TUI.Pages; - -public abstract class TerminalContentView : View, ITerminalRenderRequester -{ - public event EventHandler? RenderRequested; - - protected bool IsDisposed { get; private set; } - - protected TerminalContentView() - { - Disposing += (_, _) => IsDisposed = true; - } - - protected void RequestRender() - { - RenderRequested?.Invoke(this, EventArgs.Empty); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/TerminalOperationPageView.cs b/src/UnityAssetsPatcher.TUI/Pages/TerminalOperationPageView.cs deleted file mode 100644 index ee360509..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/TerminalOperationPageView.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace UnityAssetsPatcher.TUI.Pages; - -public abstract class TerminalOperationPageView : TerminalPageView -{ - protected abstract void RenderState(); - - protected async Task RunLogicAsync(Func startOperation) - { - ArgumentNullException.ThrowIfNull(startOperation); - - Task operation = startOperation(); - RenderState(); - RequestRender(); - - await operation; - - if (IsDisposed) - { - return; - } - - RenderState(); - RequestRender(); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/TerminalPageLogic.cs b/src/UnityAssetsPatcher.TUI/Pages/TerminalPageLogic.cs deleted file mode 100644 index 0b2dabc1..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/TerminalPageLogic.cs +++ /dev/null @@ -1,167 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; - -namespace UnityAssetsPatcher.TUI.Pages; - -public abstract class TerminalPageLogic : IDisposable - where TState : notnull -{ - public TState State - { - get - { - lock (SyncRoot) - { - return CurrentState; - } - } - } - - public bool IsWorking - { - get - { - lock (SyncRoot) - { - return _isWorking; - } - } - } - - protected Lock SyncRoot { get; } = new(); - protected TState CurrentState { get; set; } - protected bool IsUnavailable => _isWorking || IsDisposed; - - protected bool IsDisposed { get; private set; } - - private readonly IServiceScopeFactory _scopeFactory; - private readonly CancellationTokenSource _lifetimeCancellation; - private bool _isWorking; - private bool _isCancellationPending; - - protected TerminalPageLogic( - IServiceScopeFactory scopeFactory, - TState initialState, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(initialState); - - _scopeFactory = scopeFactory; - CurrentState = initialState; - _lifetimeCancellation = cancellationToken.CanBeCanceled - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) - : new CancellationTokenSource(); - } - - public void Dispose() - { - lock (SyncRoot) - { - if (IsDisposed) - { - return; - } - - IsDisposed = true; - _isCancellationPending = true; - } - - try - { - _lifetimeCancellation.Cancel(); - } - finally - { - lock (SyncRoot) - { - _isCancellationPending = false; - if (!_isWorking) - { - _lifetimeCancellation.Dispose(); - } - } - } - } - - protected Task StartOperation( - TRequest request, - Action> complete) - where TRequest : IRequest> - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(complete); - - CancellationToken cancellationToken; - lock (SyncRoot) - { - ObjectDisposedException.ThrowIf(IsDisposed, this); - if (_isWorking) - { - throw new InvalidOperationException("A page operation is already running."); - } - - _isWorking = true; - cancellationToken = _lifetimeCancellation.Token; - } - - return RunOperationAsync(request, complete, cancellationToken); - } - - private async Task RunOperationAsync( - TRequest request, - Action> complete, - CancellationToken cancellationToken) - where TRequest : IRequest> - { - try - { - var result = await DispatchOnWorkerAsync( - request, - cancellationToken).ConfigureAwait(false); - - lock (SyncRoot) - { - if (!IsDisposed) - { - complete(result); - } - } - } - catch (OperationCanceledException exception) - when (cancellationToken.IsCancellationRequested && - exception.CancellationToken == cancellationToken) { } - finally - { - EndOperation(); - } - } - - private void EndOperation() - { - lock (SyncRoot) - { - _isWorking = false; - if (IsDisposed && !_isCancellationPending) - { - _lifetimeCancellation.Dispose(); - } - } - } - - private async Task> DispatchOnWorkerAsync( - TRequest request, - CancellationToken cancellationToken) - where TRequest : IRequest> - { - return await Task.Run(async () => - { - using IServiceScope scope = _scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - return await dispatcher.DispatchAsync>( - request, - cancellationToken).ConfigureAwait(false); - }, cancellationToken).ConfigureAwait(false); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/TerminalPageView.cs b/src/UnityAssetsPatcher.TUI/Pages/TerminalPageView.cs deleted file mode 100644 index 51a28599..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/TerminalPageView.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Terminal.Gui.Input; -using UnityAssetsPatcher.TUI.Navigation; - -namespace UnityAssetsPatcher.TUI.Pages; - -public abstract class TerminalPageView : TerminalContentView -{ - public event EventHandler? NavigationRequested; - - internal event EventHandler? PageHeaderChanged; - internal string PageTitle { get; private set; } = string.Empty; - internal string? PageDescription { get; private set; } - - protected virtual bool CanReturnToMainMenu => true; - - protected TerminalPageView() - { - AddCommand(Command.Cancel, () => - { - if (CanReturnToMainMenu) - { - RequestMainMenu(); - } - - return true; - }); - KeyBindings.Add(Key.Esc, Command.Cancel); - } - - protected void RequestNavigation(TerminalRoute route) - { - NavigationRequested?.Invoke(this, route); - } - - protected void RequestMainMenu() - { - RequestNavigation(TerminalRoute.MainMenu); - } - - protected void SetHeader(string title, string? description = null) - { - ArgumentException.ThrowIfNullOrEmpty(title); - - description = string.IsNullOrEmpty(description) ? null : description; - - if (PageTitle == title && PageDescription == description) - { - return; - } - - PageTitle = title; - PageDescription = description; - PageHeaderChanged?.Invoke(this, EventArgs.Empty); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/UninstallMod/UninstallModLogic.cs b/src/UnityAssetsPatcher.TUI/Pages/UninstallMod/UninstallModLogic.cs deleted file mode 100644 index 97d8082f..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/UninstallMod/UninstallModLogic.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.Application.Features.Uninstall; -using UnityAssetsPatcher.Application.Installation; -using UnityAssetsPatcher.Application.Operations; - -namespace UnityAssetsPatcher.TUI.Pages.UninstallMod; - -public abstract record UninstallModState -{ - public sealed record LoadingInstalledMods : UninstallModState; - - public sealed record InstalledMods(IReadOnlyList Records) : UninstallModState; - - public sealed record Analyzing : UninstallModState; - - public sealed record EnterGameDirectory(string InstallId, OperationError Error) : UninstallModState; - - public sealed record Preview(UninstallPreviewResult Result) : UninstallModState; - - public sealed record Uninstalling : UninstallModState; - - public sealed record Uninstalled(UninstallModResult Result) : UninstallModState; - - public sealed record Failed(OperationError Error) : UninstallModState; -} - -public sealed class UninstallModLogic : TerminalPageLogic -{ - public UninstallModLogic(IServiceScopeFactory scopeFactory) - : base(scopeFactory, new UninstallModState.LoadingInstalledMods()) { } - - public Task LoadInstalledModsAsync() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - CurrentState = new UninstallModState.LoadingInstalledMods(); - return StartOperation>( - new ListInstalledModsRequest(), - CompleteInstalledMods); - } - } - - public Task PreviewAsync(string installId) - { - ArgumentException.ThrowIfNullOrWhiteSpace(installId); - - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (CurrentState is not UninstallModState.InstalledMods) - { - throw new InvalidOperationException("An installed mod is not ready for preview."); - } - - return StartPreview(installId, null); - } - } - - public Task SubmitGameDirectoryAsync(string gameDirectory) - { - ArgumentException.ThrowIfNullOrWhiteSpace(gameDirectory); - - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (CurrentState is not UninstallModState.EnterGameDirectory state) - { - throw new InvalidOperationException("A game directory is not required."); - } - - return StartPreview(state.InstallId, gameDirectory); - } - } - - public Task UninstallAsync() - { - lock (SyncRoot) - { - if (IsUnavailable) - { - return Task.CompletedTask; - } - - if (CurrentState is not UninstallModState.Preview { Result.CanUninstall: true } state) - { - throw new InvalidOperationException("The mod is not ready to uninstall."); - } - - var request = new UninstallModRequest( - state.Result.InstallId, - state.Result.GameDirectory); - - CurrentState = new UninstallModState.Uninstalling(); - return StartOperation(request, CompleteUninstall); - } - } - - private Task StartPreview(string installId, string? gameDirectory) - { - CurrentState = new UninstallModState.Analyzing(); - return StartOperation( - new UninstallPreviewRequest(installId, gameDirectory), - result => CompletePreview(result, installId, gameDirectory)); - } - - private void CompleteInstalledMods( - OperationResult> result) - { - CurrentState = result is OperationSucceeded> succeeded - ? new UninstallModState.InstalledMods(succeeded.Value) - : new UninstallModState.Failed( - ((OperationFailed>)result).Error); - } - - private void CompletePreview( - OperationResult result, - string installId, - string? gameDirectory) - { - if (result is OperationSucceeded succeeded) - { - CurrentState = new UninstallModState.Preview(succeeded.Value); - return; - } - - OperationError error = ((OperationFailed)result).Error; - bool needsGameDirectory = - (error.Code == GameDirectoryErrorCodes.Required || - error.Code == GameDirectoryErrorCodes.NotFound) && - gameDirectory is null; - - CurrentState = needsGameDirectory - ? new UninstallModState.EnterGameDirectory(installId, error) - : new UninstallModState.Failed(error); - } - - private void CompleteUninstall(OperationResult result) - { - CurrentState = result is OperationSucceeded succeeded - ? new UninstallModState.Uninstalled(succeeded.Value) - : new UninstallModState.Failed(((OperationFailed)result).Error); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Pages/UninstallMod/UninstallModView.cs b/src/UnityAssetsPatcher.TUI/Pages/UninstallMod/UninstallModView.cs deleted file mode 100644 index 91d7c5a8..00000000 --- a/src/UnityAssetsPatcher.TUI/Pages/UninstallMod/UninstallModView.cs +++ /dev/null @@ -1,362 +0,0 @@ -using System.Globalization; -using Terminal.Gui.Text; -using Terminal.Gui.ViewBase; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.Application.Features.Uninstall; -using UnityAssetsPatcher.TUI.Framework; -using UnityAssetsPatcher.TUI.Localization; - -namespace UnityAssetsPatcher.TUI.Pages.UninstallMod; - -public sealed class UninstallModView : TerminalOperationPageView -{ - protected override bool CanReturnToMainMenu => !_logic.IsWorking; - - private readonly LocalizedStrings _strings; - private readonly UninstallModLogic _logic; - private readonly ScrollableContentView _body; - - internal UninstallModView( - LocalizedStrings strings, - UninstallModLogic logic) - { - ArgumentNullException.ThrowIfNull(strings); - ArgumentNullException.ThrowIfNull(logic); - - _strings = strings; - _logic = logic; - - SetHeader(_strings.MainMenu_UninstallMod_Title, _strings.MainMenu_UninstallMod_Description); - - _body = new ScrollableContentView - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Fill(), - CanFocus = true - }; - - Add(_body); - RenderState(); - - Initialized += async (_, _) => await RunLogicAsync(_logic.LoadInstalledModsAsync); - Disposing += (_, _) => _logic.Dispose(); - } - - protected override void RenderState() - { - if (IsDisposed) - { - return; - } - - _body.RemoveAllAndDispose(); - - switch (_logic.State) - { - case UninstallModState.LoadingInstalledMods: - FinishScreen(0); - break; - case UninstallModState.InstalledMods state: - RenderInstalledMods(state.Records); - break; - case UninstallModState.Analyzing: - RenderWorking(_strings.UninstallPage_AnalyzingMod); - break; - case UninstallModState.EnterGameDirectory state: - RenderGameDirectoryInput(state); - break; - case UninstallModState.Preview state: - RenderPreview(state.Result); - break; - case UninstallModState.Uninstalling: - RenderWorking(_strings.UninstallPage_UninstallingMod); - break; - case UninstallModState.Uninstalled state: - RenderResult(state.Result); - break; - case UninstallModState.Failed state: - RenderError(OperationErrorFormatter.Format(_strings, state.Error)); - break; - } - } - - private void RenderInstalledMods(IReadOnlyList installed) - { - if (installed.Count == 0) - { - AddLabel(_strings.UninstallPage_NoInstalledModsFound, 0, TextRole.Preview); - ActionButton back = AddButton( - _strings.UninstallPage_ReturnAction, - 2, - RequestMainMenu); - FinishScreen(4, back); - return; - } - - int row = 0; - var choices = new List(installed.Count); - foreach (InstallRecordSummary record in installed) - { - choices.Add(AddInstalledMod(record, row)); - row += 2; - } - - ChoiceItem.AlignDescriptions(choices); - FinishScreen(row, choices[0].Button); - } - - private ChoiceItem AddInstalledMod(InstallRecordSummary record, int row) - { - string installedAt = FormatInstalledAt(record.InstalledAt); - string details = record.GameName is null ? installedAt : $"{installedAt} | {record.GameName}"; - var choice = new ChoiceItem($"{record.ModName} {record.ModVersion}", details) - { - X = 0, - Y = row - }; - choice.Button.Accepted += async (_, _) => - await RunLogicAsync(() => _logic.PreviewAsync(record.InstallId)); - _body.Add(choice); - return choice; - } - - private void RenderWorking(string text) - { - _body.Add(new WorkingIndicator(text) { X = 0, Y = 0 }); - FinishScreen(2); - } - - private void RenderGameDirectoryInput(UninstallModState.EnterGameDirectory state) - { - AddLabel( - OperationErrorFormatter.Format(_strings, state.Error), - 0, - TextRole.Muted); - - string prompt = $"{_strings.InstallPage_GameDirectoryPrompt}: "; - var label = new StyledLabel(prompt, TextRole.Label) { X = 0, Y = 2 }; - var input = new InputField - { - X = prompt.GetColumns(), - Y = 2, - Width = Dim.Fill() - }; - var error = new StyledLabel(role: TextRole.Error) - { - X = 0, - Y = 4, - Width = Dim.Fill(), - Visible = false - }; - - input.Accepted += async (_, _) => - { - string path = TerminalPathNormalizer.Normalize(input.Text); - if (!Directory.Exists(path)) - { - input.Text = string.Empty; - error.Text = _strings.Prompt_DirectoryNotFoundFormat(path); - error.Visible = true; - input.SetFocus(); - return; - } - - input.Text = path; - await RunLogicAsync(() => _logic.SubmitGameDirectoryAsync(path)); - }; - - AddButton( - _strings.UninstallPage_BackAction, - 6, - () => RunLogicAsync(_logic.LoadInstalledModsAsync)); - - _body.Add(label, input, error); - FinishScreen(8, input); - } - - private void RenderPreview(UninstallPreviewResult preview) - { - AddLabel(_strings.UninstallPreview_Status, 0, TextRole.Preview); - var rows = new (string Label, string Value)[] - { - (_strings.Summary_Mod, preview.ModName), - (_strings.Summary_Version, preview.ModVersion), - (_strings.UninstallSummary_GameDirectory, preview.GameDirectory), - (_strings.UninstallSummary_Installed, FormatInstalledAt(preview.InstalledAt)) - }; - _body.Add(new SummaryTableView(rows) { X = 0, Y = 2 }); - - string availability = preview.CanUninstall - ? _strings.UninstallPreview_WillModify - : preview.DependencyFailures.Count > 0 - ? _strings.UninstallPreview_CannotRemoveDependency - : _strings.UninstallPreview_CannotRemoveIntegrity; - AddLabel( - availability, - rows.Length + 3, - preview.CanUninstall ? TextRole.Success : TextRole.Error); - - int row = rows.Length + 5; - row = AddChangedPreviewFiles(preview.ChangedFiles, row); - row = AddDependencyFailures(preview.DependencyFailures, row); - - if (!preview.CanUninstall) - { - AddLabel( - preview.DependencyFailures.Count > 0 - ? _strings.UninstallPage_CannotUninstallDependency - : _strings.UninstallPage_CannotUninstallIntegrityConflict, - row + 1, - TextRole.Error); - ActionButton back = AddButton( - _strings.UninstallPage_BackAction, - row + 3, - () => RunLogicAsync(_logic.LoadInstalledModsAsync)); - FinishScreen(row + 5, back); - return; - } - - var actions = new ConfirmationBar( - _strings.UninstallPage_UninstallAction, - () => RunLogicAsync(_logic.UninstallAsync), - _strings.UninstallPage_BackAction, - () => RunLogicAsync(_logic.LoadInstalledModsAsync), - ActionKind.Dangerous) - { - X = 0, - Y = row + 1 - }; - _body.Add(actions); - FinishScreen(row + 3, actions.ConfirmButton); - } - - private int AddChangedPreviewFiles(IReadOnlyList files, int row) - { - if (files.Count == 0) - { - return row; - } - - foreach (UninstallChangedFileResult file in files) - { - AddLabel($"- {file.RelativePath}", row++, TextRole.Muted); - } - - return row + 1; - } - - private int AddDependencyFailures( - IReadOnlyList failures, - int row) - { - if (failures.Count == 0) - { - return row; - } - - row = AddSectionHeader(_strings.UninstallPreview_Dependencies, row); - foreach (UninstallDependencyFailureResult failure in failures) - { - string diagnostic = $"{failure.Diagnostic.Code}: " + - OperationErrorFormatter.Format(_strings, failure.Diagnostic); - AddLabel( - _strings.UninstallPreview_DependencyDetailsFormat( - failure.ModName, - failure.ModVersion, - failure.RelativePath, - diagnostic), - row++); - } - - return row + 1; - } - - private int AddSectionHeader(string text, int row) - { - AddLabel(text, row, TextRole.SectionHeader); - return row + 2; - } - - private void RenderResult(UninstallModResult result) - { - AddLabel(_strings.UninstallResult_Status, 0, TextRole.Success); - var rows = new (string Label, string Value)[] - { - (_strings.Summary_Mod, result.ModName), - (_strings.Summary_Version, result.ModVersion), - (_strings.UninstallSummary_ChangedFiles, - result.ChangedFiles.Count.ToString(CultureInfo.InvariantCulture)) - }; - _body.Add(new SummaryTableView(rows) { X = 0, Y = 2 }); - - int row = rows.Length + 3; - if (result.ChangedFiles.Count > 0) - { - row = AddSectionHeader(_strings.UninstallResult_ChangedFiles, row); - foreach (UninstallChangedFileResult file in result.ChangedFiles) - { - AddLabel($"- {file.RelativePath}", row++); - } - - row++; - } - - ActionButton back = AddButton( - _strings.UninstallPage_ReturnAction, - row, - RequestMainMenu); - FinishScreen(row + 2, back); - } - - private void RenderError(string message) - { - AddLabel(message, 0, TextRole.Error); - ActionButton back = AddButton( - _strings.UninstallPage_BackAction, - 2, - () => RunLogicAsync(_logic.LoadInstalledModsAsync)); - FinishScreen(4, back); - } - - private void AddLabel(string text, Pos y, TextRole role = TextRole.Base) - { - var label = new StyledLabel(text, role) - { - X = 0, - Y = y, - Width = Dim.Fill() - }; - _body.Add(label); - } - - private ActionButton AddButton(string text, Pos y, Action action) - { - var button = new ActionButton(text) { X = 0, Y = y }; - button.Accepted += (_, _) => action(); - _body.Add(button); - return button; - } - - private ActionButton AddButton(string text, Pos y, Func action) - { - var button = new ActionButton(text) { X = 0, Y = y }; - button.Accepted += async (_, _) => await action(); - _body.Add(button); - return button; - } - - private void FinishScreen(int rowCount, View? focus = null) - { - _body.SetContentHeightForRows(rowCount); - focus?.SetFocus(); - } - - private static string FormatInstalledAt(DateTimeOffset installedAt) - { - return installedAt.LocalDateTime.ToString( - "yyyy'/'MM'/'dd HH':'mm", - CultureInfo.InvariantCulture); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Shell/ApplicationBannerView.cs b/src/UnityAssetsPatcher.TUI/Shell/ApplicationBannerView.cs deleted file mode 100644 index dce2901c..00000000 --- a/src/UnityAssetsPatcher.TUI/Shell/ApplicationBannerView.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Terminal.Gui.Drawing; -using Terminal.Gui.ViewBase; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.TUI.Framework; - -namespace UnityAssetsPatcher.TUI.Shell; - -public sealed class ApplicationBannerView : View -{ - public ApplicationBannerView() - { - X = 0; - Y = 0; - Width = Dim.Fill(); - Height = 5; - - string version = $" ({AppConfig.DisplayVersion})"; - string horizontal = new('─', AppConfig.Name.Length + version.Length + 4); - string emptyLine = $"│{new string(' ', horizontal.Length)}│"; - - AddLabel($"╭{horizontal}╮", 0, 0, TerminalTheme.Muted); - AddLabel(emptyLine, 0, 1, TerminalTheme.Muted); - AddLabel("│ ", 0, 2, TerminalTheme.Muted); - AddLabel(AppConfig.Name, 3, 2, TerminalTheme.Base); - AddLabel(version, 3 + AppConfig.Name.Length, 2, TerminalTheme.Muted); - AddLabel(" │", 3 + AppConfig.Name.Length + version.Length, 2, TerminalTheme.Muted); - AddLabel(emptyLine, 0, 3, TerminalTheme.Muted); - AddLabel($"╰{horizontal}╯", 0, 4, TerminalTheme.Muted); - } - - private void AddLabel(string text, int x, int y, Scheme scheme) - { - var label = new StyledLabel(text) - { - X = x, - Y = y - }; - - label.SetScheme(scheme); - - Add(label); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Shell/ITerminalContentHost.cs b/src/UnityAssetsPatcher.TUI/Shell/ITerminalContentHost.cs deleted file mode 100644 index 68025f1c..00000000 --- a/src/UnityAssetsPatcher.TUI/Shell/ITerminalContentHost.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Terminal.Gui.ViewBase; - -namespace UnityAssetsPatcher.TUI.Shell; - -public interface ITerminalContentHost -{ - public void ShowContent(View content); -} diff --git a/src/UnityAssetsPatcher.TUI/Shell/ITerminalRenderRequester.cs b/src/UnityAssetsPatcher.TUI/Shell/ITerminalRenderRequester.cs deleted file mode 100644 index bae0fd84..00000000 --- a/src/UnityAssetsPatcher.TUI/Shell/ITerminalRenderRequester.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace UnityAssetsPatcher.TUI.Shell; - -public interface ITerminalRenderRequester -{ - public event EventHandler? RenderRequested; -} diff --git a/src/UnityAssetsPatcher.TUI/Shell/TerminalFooterView.cs b/src/UnityAssetsPatcher.TUI/Shell/TerminalFooterView.cs deleted file mode 100644 index 8f5fa42b..00000000 --- a/src/UnityAssetsPatcher.TUI/Shell/TerminalFooterView.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Terminal.Gui.ViewBase; -using UnityAssetsPatcher.TUI.Framework; - -namespace UnityAssetsPatcher.TUI.Shell; - -public sealed class TerminalFooterView : View -{ - public TerminalFooterView(string text) - { - ArgumentNullException.ThrowIfNull(text); - - X = 0; - Y = Pos.AnchorEnd(1); - Width = Dim.Fill(); - Height = 1; - - var label = new StyledLabel(text, TextRole.Muted) - { - X = 0, - Y = 0, - Width = Dim.Fill() - }; - - Add(label); - } -} diff --git a/src/UnityAssetsPatcher.TUI/Shell/TerminalPageHeaderView.cs b/src/UnityAssetsPatcher.TUI/Shell/TerminalPageHeaderView.cs deleted file mode 100644 index 4329e8bd..00000000 --- a/src/UnityAssetsPatcher.TUI/Shell/TerminalPageHeaderView.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Terminal.Gui.ViewBase; -using UnityAssetsPatcher.TUI.Framework; - -namespace UnityAssetsPatcher.TUI.Shell; - -public sealed class TerminalPageHeaderView : View -{ - private readonly StyledLabel _title; - private readonly StyledLabel _description; - - public TerminalPageHeaderView() - { - X = 0; - Width = Dim.Fill(); - Height = 0; - Visible = false; - - _title = new StyledLabel(role: TextRole.Title) - { - X = 0, - Y = 0, - Width = Dim.Fill() - }; - - _description = new StyledLabel(role: TextRole.Muted) - { - X = 0, - Y = 1, - Width = Dim.Fill() - }; - - Add(_title, _description); - } - - internal void SetHeader(string title, string? description) - { - ArgumentException.ThrowIfNullOrEmpty(title); - - bool hasDescription = !string.IsNullOrEmpty(description); - - _title.Text = title; - _description.Text = description ?? string.Empty; - _description.Visible = hasDescription; - Height = hasDescription ? 3 : 2; - Visible = true; - } - - internal void ClearHeader() - { - _title.Text = string.Empty; - _description.Text = string.Empty; - _description.Visible = false; - Height = 0; - Visible = false; - } -} diff --git a/src/UnityAssetsPatcher.TUI/Shell/TerminalShellView.cs b/src/UnityAssetsPatcher.TUI/Shell/TerminalShellView.cs deleted file mode 100644 index 04e730b2..00000000 --- a/src/UnityAssetsPatcher.TUI/Shell/TerminalShellView.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Terminal.Gui.Drawing; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.TUI.Framework; -using UnityAssetsPatcher.TUI.Pages; - -namespace UnityAssetsPatcher.TUI.Shell; - -public sealed class TerminalShellView : Window, ITerminalContentHost -{ - private readonly TerminalPageHeaderView _pageHeader; - private readonly View _contentHost; - private readonly Action? _render; - private View? _content; - - public TerminalShellView( - string footerText, - string? warningText = null, - Action? render = null) - { - ArgumentNullException.ThrowIfNull(footerText); - - _render = render; - - Title = AppConfig.Identifier; - BorderStyle = LineStyle.None; - - SetScheme(TerminalTheme.Base); - - var banner = new ApplicationBannerView(); - - _pageHeader = new TerminalPageHeaderView - { - Y = Pos.Bottom(banner) + 1 - }; - - _contentHost = new View - { - X = 0, - Y = Pos.Bottom(_pageHeader), - Width = Dim.Fill(), - Height = Dim.Fill(2), - CanFocus = true - }; - - var footer = new TerminalFooterView(footerText); - - Add(banner, _pageHeader, _contentHost, footer); - - if (warningText is not null) - { - Add(new StyledLabel(warningText, TextRole.Preview) - { - X = 0, - Y = Pos.AnchorEnd(2), - Width = Dim.Fill() - }); - } - } - - public void ShowContent(View content) - { - ArgumentNullException.ThrowIfNull(content); - - if (_content is not null) - { - if (_content is TerminalPageView currentPage) - { - currentPage.PageHeaderChanged -= OnPageHeaderChanged; - } - - _contentHost.Remove(_content); - - _content.Dispose(); - } - - _content = content; - - if (content is TerminalPageView page) - { - _pageHeader.SetHeader(page.PageTitle, page.PageDescription); - page.PageHeaderChanged += OnPageHeaderChanged; - } - else - { - _pageHeader.ClearHeader(); - } - - if (content is ITerminalRenderRequester renderRequester && _render is not null) - { - renderRequester.RenderRequested += (_, _) => _render(); - } - - content.X = 0; - content.Y = 0; - content.Width = Dim.Fill(); - content.Height = Dim.Fill(); - content.CanFocus = true; - - _contentHost.Add(content); - } - - private void OnPageHeaderChanged(object? sender, EventArgs eventArgs) - { - if (sender is TerminalPageView page && ReferenceEquals(page, _content)) - { - _pageHeader.SetHeader(page.PageTitle, page.PageDescription); - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/TerminalApp.cs b/src/UnityAssetsPatcher.TUI/TerminalApp.cs deleted file mode 100644 index 5354dc75..00000000 --- a/src/UnityAssetsPatcher.TUI/TerminalApp.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.TUI.Lifecycle; - -namespace UnityAssetsPatcher.TUI; - -public sealed class TerminalApp -{ - private readonly IServiceScopeFactory _scopeFactory; - - public TerminalApp(IServiceScopeFactory scopeFactory) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - - _scopeFactory = scopeFactory; - } - - public async Task RunAsync() - { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - Exception? runFailure = null; - try - { - var session = scope.ServiceProvider.GetRequiredService(); - - await session.RunAsync().ConfigureAwait(false); - - return 0; - } - catch (Exception exception) - { - runFailure = exception; - throw; - } - finally - { - try - { - await scope.DisposeAsync().ConfigureAwait(false); - } - catch (Exception disposalFailure) when (runFailure is not null) - { - // The menu event and scope disposal can await the same failed update task. - if (!ReferenceEquals(runFailure, disposalFailure)) - { - throw new AggregateException(runFailure, disposalFailure); - } - } - } - } -} diff --git a/src/UnityAssetsPatcher.TUI/TerminalServiceCollectionExtensions.cs b/src/UnityAssetsPatcher.TUI/TerminalServiceCollectionExtensions.cs deleted file mode 100644 index 7ef76d06..00000000 --- a/src/UnityAssetsPatcher.TUI/TerminalServiceCollectionExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.TUI.Lifecycle; -using UnityAssetsPatcher.TUI.Navigation; -using UnityAssetsPatcher.TUI.Pages.MainMenu; - -namespace UnityAssetsPatcher.TUI; - -public static class TerminalServiceCollectionExtensions -{ - public static IServiceCollection AddUnityAssetsPatcherTUI(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - return services; - } -} diff --git a/src/UnityAssetsPatcher.TUI/UnityAssetsPatcher.TUI.csproj b/src/UnityAssetsPatcher.TUI/UnityAssetsPatcher.TUI.csproj deleted file mode 100644 index 99f87a12..00000000 --- a/src/UnityAssetsPatcher.TUI/UnityAssetsPatcher.TUI.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net10.0 - enable - enable - true - $(BaseIntermediateOutputPath)Generated - - - - - - - - - - - - - - - - - - diff --git a/src/UnityAssetsPatcher/Program.cs b/src/UnityAssetsPatcher/Program.cs index 4c28095a..27d58fff 100644 --- a/src/UnityAssetsPatcher/Program.cs +++ b/src/UnityAssetsPatcher/Program.cs @@ -1,17 +1,24 @@ +using Avalonia; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Serilog.Core; using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.CLI; +using UnityAssetsPatcher.GUI; +using UnityAssetsPatcher.GUI.ViewModels; using UnityAssetsPatcher.Infrastructure; using UnityAssetsPatcher.Logging; -using UnityAssetsPatcher.TUI; namespace UnityAssetsPatcher; public sealed class Program { - public static async Task Main(string[] args) + [STAThread] + public static int Main(string[] args) + { + return RunAsync(args).GetAwaiter().GetResult(); + } + + private static async Task RunAsync(string[] args) { Logger rootLogger; LoggingLevelSwitch loggingLevelSwitch; @@ -43,9 +50,8 @@ public static async Task Main(string[] args) .AddUnityAssetsPatcherApplication() .AddUnityAssetsPatcherUpdates() .AddUnityAssetsPatcherOperations() - .AddUnityAssetsPatcherCli() - .AddUnityAssetsPatcherOperationalCommands() - .AddUnityAssetsPatcherTUI() + .AddSingleton() + .AddSingleton() .BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, @@ -56,18 +62,9 @@ public static async Task Main(string[] args) logger.LogInformation("Application started."); - if (args.Length > 0) - { - var cliApplication = serviceProvider.GetRequiredService(); - - exitCode = await cliApplication.RunAsync(args).ConfigureAwait(false); - } - else - { - var terminalApp = serviceProvider.GetRequiredService(); - - exitCode = await terminalApp.RunAsync().ConfigureAwait(false); - } + exitCode = AppBuilder.Configure(() => serviceProvider.GetRequiredService()) + .UsePlatformDetect() + .StartWithClassicDesktopLifetime(args); } catch (Exception exception) { diff --git a/src/UnityAssetsPatcher/UnityAssetsPatcher.csproj b/src/UnityAssetsPatcher/UnityAssetsPatcher.csproj index 2489a9d3..c2bf25db 100644 --- a/src/UnityAssetsPatcher/UnityAssetsPatcher.csproj +++ b/src/UnityAssetsPatcher/UnityAssetsPatcher.csproj @@ -1,7 +1,8 @@  - Exe + WinExe + ..\UnityAssetsPatcher.GUI\Assets\icon.ico net10.0 UnityAssetsPatcher enable @@ -22,9 +23,8 @@ - + - diff --git a/tests/UnityAssetsPatcher.CLI.Tests/CLIApplicationTests.cs b/tests/UnityAssetsPatcher.CLI.Tests/CLIApplicationTests.cs deleted file mode 100644 index 72298e4a..00000000 --- a/tests/UnityAssetsPatcher.CLI.Tests/CLIApplicationTests.cs +++ /dev/null @@ -1,223 +0,0 @@ -using System.IO.Compression; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.Application.IO; -using UnityAssetsPatcher.Application.Mods; -using UnityAssetsPatcher.Infrastructure.IO; -using UnityAssetsPatcher.Infrastructure.Mods; -using Xunit; - -namespace UnityAssetsPatcher.CLI.Tests; - -public sealed class CLIApplicationTests : IDisposable -{ - private readonly string _temporaryDirectory; - private ServiceProvider? _serviceProvider; - - private const string ValidManifest = """ - { - "$schema": "https://uap.cnbarrier.com/schema-v1.json", - "name": "Test Mod", - "author": "Test Author", - "version": "1.0.0", - "targets": [ - { - "file": "sharedassets0.assets", - "patches": [ - { - "type": "Camera", - "match": { "m_Name": "Main" } - } - ] - } - ] - } - """; - - public CLIApplicationTests() - { - _temporaryDirectory = Path.Combine(Path.GetTempPath(), $"uap-cli-tests-{Guid.NewGuid():N}"); - - Directory.CreateDirectory(_temporaryDirectory); - } - - [Fact] - public async Task RunAsync_WhenCheckUsesDefaultManifest_ReturnsSuccessWithoutOutput() - { - await File.WriteAllTextAsync( - Path.Combine(_temporaryDirectory, "manifest.json"), - ValidManifest, - TestContext.Current.CancellationToken); - - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync(["check"], TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - Assert.Equal(string.Empty, output.ToString()); - Assert.Equal(string.Empty, error.ToString()); - } - - [Fact] - public async Task RunAsync_WhenCheckUsesZip_ReturnsSuccessWithoutOutput() - { - string packagePath = Path.Combine(_temporaryDirectory, "mod.zip"); - await using (ZipArchive archive = - await ZipFile.OpenAsync(packagePath, ZipArchiveMode.Create, TestContext.Current.CancellationToken)) - { - ZipArchiveEntry entry = archive.CreateEntry("manifest.json"); - - await using StreamWriter writer = new(await entry.OpenAsync(TestContext.Current.CancellationToken)); - - await writer.WriteAsync(ValidManifest); - } - - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["check", "--config", packagePath], - TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - Assert.Equal(string.Empty, output.ToString()); - Assert.Equal(string.Empty, error.ToString()); - } - - [Fact] - public async Task RunAsync_WhenManifestIsInvalid_WritesTextFailure() - { - string manifestPath = Path.Combine(_temporaryDirectory, "invalid.json"); - - await File.WriteAllTextAsync(manifestPath, "{}", TestContext.Current.CancellationToken); - - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["check", "-c", manifestPath], - TestContext.Current.CancellationToken); - - Assert.Equal(1, exitCode); - Assert.Equal(string.Empty, output.ToString()); - Assert.Equal( - $"Error [manifest.missing_property]: Required manifest property '$schema' is missing.{Environment.NewLine}", - error.ToString()); - } - - [Fact] - public async Task RunAsync_WhenManifestJsonIsMalformed_WritesManifestFailure() - { - string manifestPath = Path.Combine(_temporaryDirectory, "malformed.json"); - - await File.WriteAllTextAsync(manifestPath, "{", TestContext.Current.CancellationToken); - - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["check", "-c", manifestPath], - TestContext.Current.CancellationToken); - - Assert.Equal(1, exitCode); - Assert.Equal(string.Empty, output.ToString()); - Assert.StartsWith( - $"Error [manifest.invalid_json]: The mod manifest is invalid.{Environment.NewLine}", - error.ToString()); - Assert.Contains("byte_position: 1", error.ToString()); - Assert.Contains("line_number: 0", error.ToString()); - } - - [Fact] - public async Task RunAsync_WhenPackageManifestIsMissing_WritesPackageFailure() - { - string packagePath = Path.Combine(_temporaryDirectory, "missing-manifest.zip"); - await using (ZipArchive archive = - await ZipFile.OpenAsync(packagePath, ZipArchiveMode.Create, TestContext.Current.CancellationToken)) - { - archive.CreateEntry("payload.bin"); - } - - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["check", "-c", packagePath], - TestContext.Current.CancellationToken); - - Assert.Equal(1, exitCode); - Assert.Equal(string.Empty, output.ToString()); - Assert.StartsWith( - $"Error [mod_package.missing_manifest]: The mod package is invalid.{Environment.NewLine}", - error.ToString()); - Assert.Contains($"package_path: {packagePath}", error.ToString()); - } - - [Fact] - public async Task RunAsync_WhenArgumentsAreInvalid_ReturnsUsageErrorAndWritesHelp() - { - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["check", "--unknown"], - TestContext.Current.CancellationToken); - - Assert.Equal(2, exitCode); - Assert.Equal(string.Empty, output.ToString()); - Assert.Contains("check", error.ToString(), StringComparison.OrdinalIgnoreCase); - Assert.Contains("--unknown", error.ToString()); - } - - [Fact] - public async Task RunAsync_WhenFormatOptionIsSpecified_ReturnsUsageError() - { - (CLIApplication application, _, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["check", "--format", "Json"], - TestContext.Current.CancellationToken); - - Assert.Equal(2, exitCode); - Assert.Contains("--format", error.ToString()); - } - - [Fact] - public async Task RunAsync_WhenCheckHelpIsRequested_ListsOnlyTextCommandOptions() - { - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["check", "--help"], - TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - Assert.Contains("--config", output.ToString()); - Assert.DoesNotContain("--format", output.ToString()); - Assert.Equal(string.Empty, error.ToString()); - } - - public void Dispose() - { - _serviceProvider?.Dispose(); - Directory.Delete(_temporaryDirectory, true); - } - - private (CLIApplication Application, StringWriter Output, StringWriter Error) CreateApplication() - { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(provider => new FileSystemOperations( - provider.GetRequiredService>())); - services.AddSingleton(); - services.AddUnityAssetsPatcherApplication(); - _serviceProvider = services.BuildServiceProvider(new ServiceProviderOptions - { - ValidateScopes = true - }); - var output = new StringWriter(); - var error = new StringWriter(); - var command = new CheckCLICommand( - _serviceProvider.GetRequiredService(), - () => _temporaryDirectory, - error); - var application = new CLIApplication([command], output, error); - - return (application, output, error); - } -} diff --git a/tests/UnityAssetsPatcher.CLI.Tests/CliDependencyInjectionTests.cs b/tests/UnityAssetsPatcher.CLI.Tests/CliDependencyInjectionTests.cs deleted file mode 100644 index d8517e20..00000000 --- a/tests/UnityAssetsPatcher.CLI.Tests/CliDependencyInjectionTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.Application.IO; -using UnityAssetsPatcher.Application.Mods; -using UnityAssetsPatcher.Infrastructure.IO; -using UnityAssetsPatcher.Infrastructure.Mods; -using Xunit; - -namespace UnityAssetsPatcher.CLI.Tests; - -public sealed class CliDependencyInjectionTests -{ - [Fact] - public void AddUnityAssetsPatcherCli_WhenProviderValidationIsEnabled_RegistersApplication() - { - var services = new ServiceCollection(); - - services.AddLogging(); - - services.AddSingleton(provider => new FileSystemOperations( - provider.GetRequiredService>())); - services.AddSingleton(); - - services.AddUnityAssetsPatcherApplication(); - - services.AddUnityAssetsPatcherCli(); - - using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions - { - ValidateOnBuild = true, - ValidateScopes = true - }); - - var application = provider.GetRequiredService(); - - Assert.NotNull(application); - } -} diff --git a/tests/UnityAssetsPatcher.CLI.Tests/RepositoryCLICommandTests.cs b/tests/UnityAssetsPatcher.CLI.Tests/RepositoryCLICommandTests.cs deleted file mode 100644 index bc8e2a20..00000000 --- a/tests/UnityAssetsPatcher.CLI.Tests/RepositoryCLICommandTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.Application.IO; -using UnityAssetsPatcher.Infrastructure; -using UnityAssetsPatcher.Infrastructure.IO; -using Xunit; - -namespace UnityAssetsPatcher.CLI.Tests; - -public sealed class RepositoryCLICommandTests : IDisposable -{ - private readonly string _temporaryDirectory; - private ServiceProvider? _serviceProvider; - - public RepositoryCLICommandTests() - { - _temporaryDirectory = Path.Combine( - Path.GetTempPath(), - $"uap-repository-cli-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(_temporaryDirectory); - } - - [Fact] - public async Task RunAsync_WhenConfirmationIsMissing_ReturnsUsageErrorWithoutClearingRepository() - { - string metadataPath = WriteUnsupportedRepository(); - (CLIApplication application, _, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["repository", "clear"], - TestContext.Current.CancellationToken); - - Assert.Equal(2, exitCode); - Assert.Contains("Required option '--yes' was not provided.", error.ToString()); - Assert.True(File.Exists(metadataPath)); - } - - [Fact] - public async Task RunAsync_WhenRepositoryFormatIsUnsupported_ClearsAndInitializesCurrentFormat() - { - WriteUnsupportedRepository(); - string legacyPath = WriteFile("backup/installed/legacy/record.json", "legacy"); - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["repository", "clear", "--yes"], - TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - Assert.Contains("Cleared unsupported backup repository format 1 and initialized format 2.", output.ToString()); - Assert.Equal(string.Empty, error.ToString()); - Assert.False(File.Exists(legacyPath)); - - JsonNode metadata = JsonNode.Parse(File.ReadAllText(RepositoryMetadataPath()))!; - Assert.Equal(2, metadata["formatVersion"]!.GetValue()); - Assert.Matches("^[0-9a-f]{32}$", metadata["repositoryId"]!.GetValue()); - } - - [Fact] - public async Task RunAsync_WhenJsonOutputIsRequested_WritesStructuredClearResult() - { - WriteUnsupportedRepository(); - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["repository", "clear", "--yes", "--format", "Json"], - TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - Assert.Equal(string.Empty, error.ToString()); - JsonNode envelope = JsonNode.Parse(output.ToString())!; - Assert.True(envelope["success"]!.GetValue()); - Assert.Equal("repository.clear", envelope["command"]!.GetValue()); - Assert.Equal(1, envelope["data"]!["previousFormatVersion"]!.GetValue()); - Assert.Equal(2, envelope["data"]!["formatVersion"]!.GetValue()); - } - - [Fact] - public async Task RunAsync_WhenRepositoryFormatIsCurrent_RejectsWithoutDeletingContents() - { - WriteFile( - "backup/repository.json", - "{\"formatVersion\":2,\"repositoryId\":\"current-repository\"}"); - string markerPath = WriteFile("backup/marker.txt", "keep"); - (CLIApplication application, StringWriter output, StringWriter error) = CreateApplication(); - - int exitCode = await application.RunAsync( - ["repository", "clear", "--yes"], - TestContext.Current.CancellationToken); - - Assert.Equal(1, exitCode); - Assert.Equal(string.Empty, output.ToString()); - Assert.Contains("Error [backup.clear_not_allowed]", error.ToString()); - Assert.Equal("keep", File.ReadAllText(markerPath)); - } - - public void Dispose() - { - _serviceProvider?.Dispose(); - Directory.Delete(_temporaryDirectory, true); - } - - private (CLIApplication Application, StringWriter Output, StringWriter Error) CreateApplication() - { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(provider => new FileSystemOperations( - provider.GetRequiredService>())); - services.AddUnityAssetsPatcherRepository(Path.Combine(_temporaryDirectory, "backup")); - services.AddUnityAssetsPatcherApplication(); - services.AddUnityAssetsPatcherOperations(); - services.AddSingleton(); - _serviceProvider = services.BuildServiceProvider(new ServiceProviderOptions - { - ValidateScopes = true - }); - - var output = new StringWriter(); - var error = new StringWriter(); - var command = new RepositoryCLICommand( - _serviceProvider.GetRequiredService(), - _serviceProvider.GetRequiredService()); - var application = new CLIApplication( - [command], - output, - error, - _serviceProvider.GetRequiredService()); - - return (application, output, error); - } - - private string WriteUnsupportedRepository() - { - return WriteFile("backup/repository.json", "{\"formatVersion\":1}"); - } - - private string RepositoryMetadataPath() - { - return Path.Combine(_temporaryDirectory, "backup", "repository.json"); - } - - private string WriteFile(string relativePath, string contents) - { - string path = Path.Combine(_temporaryDirectory, relativePath); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, contents); - - return path; - } -} diff --git a/tests/UnityAssetsPatcher.CLI.Tests/UninstallCLIOutputTests.cs b/tests/UnityAssetsPatcher.CLI.Tests/UninstallCLIOutputTests.cs deleted file mode 100644 index 2e312753..00000000 --- a/tests/UnityAssetsPatcher.CLI.Tests/UninstallCLIOutputTests.cs +++ /dev/null @@ -1,183 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using UnityAssetsPatcher.Application.Contracts; -using UnityAssetsPatcher.Application.Features.Uninstall; -using UnityAssetsPatcher.Application.Messaging; -using UnityAssetsPatcher.Application.Operations; -using UnityAssetsPatcher.Application.Patching; -using Xunit; - -namespace UnityAssetsPatcher.CLI.Tests; - -public sealed class UninstallCLIOutputTests -{ - [Fact] - public async Task RunAsync_WhenListingInstalledMods_WritesInstalledMods() - { - TestApplication test = CreateApplication(); - using (test.Services) - { - int exitCode = await test.Application.RunAsync( - ["uninstall", "list"], - TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - Assert.Contains("layer-1 | Test Mod 1.0.0 | Test Game", test.Output.ToString()); - } - } - - [Fact] - public async Task RunAsync_WhenUninstallCanProceed_ReportsRecompositionCount() - { - var preview = new UninstallPreviewResult( - "layer-1", - "Test Mod", - "1.0.0", - DateTimeOffset.UnixEpoch, - "C:\\Game", - true, - [], - [ - new UninstallChangedFileResult( - "Game_Data/sharedassets0.assets", - UninstallChangedFileAction.Rebuild, - FileIntegrityStatus.Matches), - new UninstallChangedFileResult( - "Game_Data/config.txt", - UninstallChangedFileAction.RestoreBase, - FileIntegrityStatus.Matches), - new UninstallChangedFileResult( - "Game_Data/old.dll", - UninstallChangedFileAction.Delete, - FileIntegrityStatus.Matches) - ]); - TestApplication test = CreateApplication(preview); - using (test.Services) - { - int exitCode = await test.Application.RunAsync( - ["uninstall", "preview", "--id", "layer-1", "--game-directory", "C:\\Game"], - TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - Assert.Contains("Can uninstall: yes; 3 file(s) will be recomposed or restored.", test.Output.ToString()); - } - } - - [Fact] - public async Task RunAsync_WhenUninstallHasDependency_ReportsDetailedDiagnostic() - { - var preview = new UninstallPreviewResult( - "layer-1", - "Target Mod", - "1.0.0", - DateTimeOffset.UnixEpoch, - "C:\\Game", - false, - [ - new UninstallDependencyFailureResult( - "Dependent Mod", - "2.0.0", - "Game_Data/sharedassets0.assets", - new PatchDiagnostic( - PatchDiagnosticCode.ValueMismatch, - "C:\\Game\\Game_Data\\sharedassets0.assets", - 42, - "m_Name", - "Layer One", - "External Value")) - ], - []); - TestApplication test = CreateApplication(preview); - using (test.Services) - { - int exitCode = await test.Application.RunAsync( - ["uninstall", "preview", "--id", "layer-1", "--game-directory", "C:\\Game"], - TestContext.Current.CancellationToken); - - string text = test.Output.ToString(); - Assert.Equal(0, exitCode); - Assert.Contains("Can uninstall: no; real patch dependencies were found.", text); - Assert.Contains("Real patch dependencies:", text); - Assert.Contains("Dependent Mod 2.0.0 at Game_Data/sharedassets0.assets", text); - Assert.Contains("valueMismatch", text); - Assert.Contains("field=m_Name", text); - Assert.Contains("expected=Layer One", text); - Assert.Contains("actual=External Value", text); - } - } - - private static TestApplication CreateApplication(UninstallPreviewResult? preview = null) - { - var output = new StringWriter(); - var options = new CLIOptions(); - var services = new ServiceCollection(); - services.AddScoped(); - if (preview is not null) - { - services.AddScoped< - IRequestHandler>>(_ => - new StubUninstallPreviewHandler(preview)); - } - - services.AddScoped< - IRequestHandler>>>(_ => - new StubInstalledModsHandler()); - ServiceProvider provider = services.BuildServiceProvider(); - var application = new CLIApplication( - [ - new UninstallCLICommand( - provider.GetRequiredService(), - options) - ], - output, - new StringWriter(), - options); - - return new TestApplication(application, output, provider); - } - - private sealed record TestApplication( - CLIApplication Application, - StringWriter Output, - ServiceProvider Services); - - private sealed class StubUninstallPreviewHandler : - IRequestHandler> - { - private readonly UninstallPreviewResult _preview; - - public StubUninstallPreviewHandler(UninstallPreviewResult preview) - { - _preview = preview; - } - - public Task> HandleAsync( - UninstallPreviewRequest request, - CancellationToken cancellationToken = default) - { - return Task.FromResult>( - new OperationSucceeded(_preview)); - } - } - - private sealed class StubInstalledModsHandler : - IRequestHandler>> - { - public Task>> HandleAsync( - ListInstalledModsRequest request, - CancellationToken cancellationToken = default) - { - IReadOnlyList installed = - [ - new( - "layer-1", - "Test Mod", - "1.0.0", - "Test Game", - DateTimeOffset.UnixEpoch) - ]; - - return Task.FromResult>>( - new OperationSucceeded>(installed)); - } - } -} diff --git a/tests/UnityAssetsPatcher.CLI.Tests/UnityAssetsPatcher.CLI.Tests.csproj b/tests/UnityAssetsPatcher.CLI.Tests/UnityAssetsPatcher.CLI.Tests.csproj deleted file mode 100644 index cfb63047..00000000 --- a/tests/UnityAssetsPatcher.CLI.Tests/UnityAssetsPatcher.CLI.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - diff --git a/tests/UnityAssetsPatcher.LocalizationGenerator.Tests/LocalizedStringsGeneratorTests.cs b/tests/UnityAssetsPatcher.LocalizationGenerator.Tests/LocalizedStringsGeneratorTests.cs deleted file mode 100644 index e3d82652..00000000 --- a/tests/UnityAssetsPatcher.LocalizationGenerator.Tests/LocalizedStringsGeneratorTests.cs +++ /dev/null @@ -1,340 +0,0 @@ -using System.Collections.Immutable; -using System.Globalization; -using System.Reflection; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Emit; -using Microsoft.CodeAnalysis.Text; -using Xunit; - -namespace UnityAssetsPatcher.LocalizationGenerator.Tests; - -public sealed class LocalizedStringsGeneratorTests -{ - [Fact] - public void Generate_WhenLocalesAreValid_GeneratesCatalogAndTypedMembers() - { - const string primary = - """ - { - "Greeting": "Hello", - "Welcome": "Welcome, {name}!", - "Range": "{start} to {end}", - "LiteralBraces": "Use {{braces}}", - "Quoted": "Quote: \"{value}\"" - } - """; - - const string secondary = - """ - { - "Greeting": "你好", - "Welcome": "欢迎,{name}!", - "Range": "{end} 到 {start}", - "LiteralBraces": "使用 {{大括号}}", - "Quoted": "引用:\"{value}\"" - } - """; - - GeneratorTestResult result = Run(("en-US.json", primary), ("zh-CN.json", secondary)); - - GeneratorRunResult generatorResult = Assert.Single(result.RunResult.Results); - Assert.Empty(generatorResult.Diagnostics); - GeneratedSourceResult generatedSource = Assert.Single(generatorResult.GeneratedSources); - string source = generatedSource.SourceText.ToString(); - - Assert.Contains("internal string Greeting =>", source); - - Assert.Contains("internal string Welcome(object? name)", source); - - Assert.Contains("\"Welcome, {0}!\"", source); - - Assert.Contains("\"{1} 到 {0}\"", source); - - Assert.Contains("\"Use {braces}\"", source); - - Assert.Contains("internal string Quoted(object? value)", source); - - Assert.Contains("\\\"{0}\\\"", source); - - Assert.DoesNotContain( - result.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken), - diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); - } - - [Fact] - public void Generate_WhenJsonHasTrailingContent_DoesNotValidateJsonSyntax() - { - GeneratorTestResult result = Run(("en-US.json", "{ \"Greeting\": \"Hello\" } trailing")); - - GeneratorRunResult generatorResult = Assert.Single(result.RunResult.Results); - - Assert.Empty(generatorResult.Diagnostics); - - Assert.Single(generatorResult.GeneratedSources); - } - - [Fact] - public void Generate_WhenLocaleKeysDiffer_ReportsMissingAndExtraKeys() - { - GeneratorTestResult result = Run( - ("en-US.json", "{ \"First\": \"First\" }"), - ("zh-CN.json", "{ \"Second\": \"第二\" }")); - - var diagnostics = Assert.Single(result.RunResult.Results).Diagnostics; - - Assert.Equal(["LOC004", "LOC005"], diagnostics.Select(diagnostic => diagnostic.Id).Order().ToArray()); - - Assert.All(diagnostics, diagnostic => Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity)); - } - - [Fact] - public void Generate_WhenLocaleContainsDuplicateKey_ReportsDuplicateKey() - { - GeneratorTestResult result = Run(("en-US.json", "{ \"Greeting\": \"One\", \"Greeting\": \"Two\" }")); - - var diagnostics = Assert.Single(result.RunResult.Results).Diagnostics; - - Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "LOC006"); - } - - [Fact] - public void Generate_WhenPlaceholdersDiffer_ReportsPlaceholderMismatch() - { - GeneratorTestResult result = Run( - ("en-US.json", "{ \"Welcome\": \"Welcome, {name}!\" }"), - ("zh-CN.json", "{ \"Welcome\": \"欢迎,{user}!\" }")); - - Diagnostic diagnostic = Assert.Single(Assert.Single(result.RunResult.Results).Diagnostics); - - Assert.Equal("LOC012", diagnostic.Id); - } - - [Fact] - public void Generate_WhenFormatIsInvalid_ReportsInvalidFormat() - { - GeneratorTestResult result = Run(("en-US.json", "{ \"Welcome\": \"Welcome, {name!\" }")); - - var diagnostics = Assert.Single(result.RunResult.Results).Diagnostics; - - Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "LOC009"); - } - - [Fact] - public void Generate_WhenPlaceholderIsKeyword_ReportsInvalidFormat() - { - GeneratorTestResult result = Run(("en-US.json", "{ \"Message\": \"Value: {class}\" }")); - - Diagnostic diagnostic = Assert.Single(Assert.Single(result.RunResult.Results).Diagnostics); - - Assert.Equal("LOC009", diagnostic.Id); - } - - [Fact] - public void Generate_WhenCultureIsDefinedTwice_ReportsDuplicateLocale() - { - GeneratorTestResult result = Run( - ("en-US.json", "{ \"Greeting\": \"Hello\" }"), - ("en-us.json", "{ \"Greeting\": \"Hello again\" }")); - - var diagnostics = Assert.Single(result.RunResult.Results).Diagnostics; - - Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "LOC010"); - } - - [Fact] - public void Generate_WhenPrimaryLocaleIsMissing_ReportsMissingPrimaryLocale() - { - GeneratorTestResult result = Run(("zh-CN.json", "{ \"Greeting\": \"你好\" }")); - - Diagnostic diagnostic = Assert.Single(Assert.Single(result.RunResult.Results).Diagnostics); - - Assert.Equal("LOC001", diagnostic.Id); - } - - [Fact] - public void Generate_WhenPrimaryLocaleIsEmpty_ReportsEmptyPrimaryLocale() - { - GeneratorTestResult result = Run(("en-US.json", "{}")); - - Diagnostic diagnostic = Assert.Single(Assert.Single(result.RunResult.Results).Diagnostics); - - Assert.Equal("LOC002", diagnostic.Id); - } - - [Fact] - public void Generate_WhenKeyIsNotValidIdentifier_ReportsInvalidKey() - { - GeneratorTestResult result = Run(("en-US.json", "{ \"invalid-key\": \"Value\" }")); - - Diagnostic diagnostic = Assert.Single(Assert.Single(result.RunResult.Results).Diagnostics); - - Assert.Equal("LOC007", diagnostic.Id); - } - - [Fact] - public void Generate_WhenLocaleFilenameIsNotCulture_ReportsInvalidCulture() - { - GeneratorTestResult result = Run( - ("en-US.json", "{ \"Greeting\": \"Hello\" }"), - ("not_a_culture.json", "{ \"Greeting\": \"Value\" }")); - - var diagnostics = Assert.Single(result.RunResult.Results).Diagnostics; - - Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "LOC014"); - } - - [Fact] - public void Generate_WhenPrimaryLocaleCannotBeRead_ReportsUnreadableLocale() - { - ImmutableArray additionalTexts = - [(AdditionalText)new UnreadableAdditionalText("C:/Localization/en-US.json")]; - - GeneratorTestResult result = Run(additionalTexts); - - Diagnostic diagnostic = Assert.Single(Assert.Single(result.RunResult.Results).Diagnostics); - - Assert.Equal("LOC013", diagnostic.Id); - } - - [Fact] - public void GeneratedCatalog_WhenCultureChanges_UsesLocaleAndPrimaryFallback() - { - const string primary = "{ \"Greeting\": \"Hello\", \"Range\": \"{start} to {end}\" }"; - const string secondary = "{ \"Greeting\": \"你好\", \"Range\": \"{end} 到 {start}\" }"; - GeneratorTestResult result = Run(("en-US.json", primary), ("zh-CN.json", secondary)); - using MemoryStream assemblyStream = new(); - - EmitResult emitResult = result.OutputCompilation.Emit( - assemblyStream, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.True(emitResult.Success, string.Join(Environment.NewLine, emitResult.Diagnostics)); - - Assembly assembly = Assembly.Load(assemblyStream.ToArray()); - Type localizedStringsType = assembly.GetRequiredType("UnityAssetsPatcher.Localization.LocalizedStrings"); - Assert.Null(assembly.GetType("UnityAssetsPatcher.Localization.LegacyLocalizedStrings")); - object chineseStrings = CreateLocalizedStrings(localizedStringsType, "zh-CN"); - object fallbackStrings = CreateLocalizedStrings(localizedStringsType, "fr-FR"); - PropertyInfo greeting = localizedStringsType.GetRequiredProperty("Greeting"); - MethodInfo range = localizedStringsType.GetRequiredMethod("Range"); - - Assert.Equal("你好", greeting.GetValue(chineseStrings)); - - Assert.Equal("Hello", greeting.GetValue(fallbackStrings)); - - Assert.Equal("结束 到 开始", range.Invoke(chineseStrings, ["开始", "结束"])); - } - - private static GeneratorTestResult Run(params (string Path, string Content)[] files) - { - var additionalTexts = files - .Select(file => (AdditionalText)new InMemoryAdditionalText( - $"C:/Localization/{file.Path}", - file.Content)) - .ToImmutableArray(); - - return Run(additionalTexts); - } - - private static GeneratorTestResult Run(ImmutableArray additionalTexts) - { - var compilation = CSharpCompilation.Create( - $"GeneratorTests_{Guid.NewGuid():N}", - references: GetFrameworkReferences(), - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create( - [new LocalizedStringsGenerator().AsSourceGenerator()], - additionalTexts, - CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp14)); - - driver = driver.RunGeneratorsAndUpdateCompilation( - compilation, - out Compilation outputCompilation, - out _); - - GeneratorDriverRunResult runResult = driver.GetRunResult(); - - return new GeneratorTestResult(runResult, outputCompilation); - } - - private static MetadataReference[] GetFrameworkReferences() - { - string trustedPlatformAssemblies = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? - throw new InvalidOperationException( - "Trusted platform assemblies are unavailable."); - - return trustedPlatformAssemblies - .Split(Path.PathSeparator) - .Select(path => MetadataReference.CreateFromFile(path)) - .ToArray(); - } - - private static object CreateLocalizedStrings(Type type, string cultureName) - { - object? instance = Activator.CreateInstance( - type, - BindingFlags.Instance | BindingFlags.NonPublic, - null, - [CultureInfo.GetCultureInfo(cultureName)], - null); - - return instance ?? throw new InvalidOperationException("Could not create generated LocalizedStrings instance."); - } - - private sealed class InMemoryAdditionalText : AdditionalText - { - private readonly SourceText _text; - - public InMemoryAdditionalText(string path, string content) - { - Path = path; - _text = SourceText.From(content); - } - - public override string Path { get; } - - public override SourceText GetText(CancellationToken cancellationToken = default) - { - return _text; - } - } - - private sealed class UnreadableAdditionalText : AdditionalText - { - public UnreadableAdditionalText(string path) - { - Path = path; - } - - public override string Path { get; } - - public override SourceText? GetText(CancellationToken cancellationToken = default) - { - return null; - } - } - - private sealed record GeneratorTestResult(GeneratorDriverRunResult RunResult, Compilation OutputCompilation); -} - -internal static class ReflectionExtensions -{ - public static Type GetRequiredType(this Assembly assembly, string name) - { - return assembly.GetType(name) ?? throw new InvalidOperationException($"Type '{name}' was not found."); - } - - public static PropertyInfo GetRequiredProperty(this Type type, string name) - { - return type.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic) ?? - throw new InvalidOperationException($"Property '{name}' was not found."); - } - - public static MethodInfo GetRequiredMethod(this Type type, string name) - { - return type.GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic) ?? - throw new InvalidOperationException($"Method '{name}' was not found."); - } -} diff --git a/tests/UnityAssetsPatcher.LocalizationGenerator.Tests/UnityAssetsPatcher.LocalizationGenerator.Tests.csproj b/tests/UnityAssetsPatcher.LocalizationGenerator.Tests/UnityAssetsPatcher.LocalizationGenerator.Tests.csproj deleted file mode 100644 index ffd74def..00000000 --- a/tests/UnityAssetsPatcher.LocalizationGenerator.Tests/UnityAssetsPatcher.LocalizationGenerator.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/tests/UnityAssetsPatcher.TUI.Tests/PlaceholderTests.cs b/tests/UnityAssetsPatcher.TUI.Tests/PlaceholderTests.cs deleted file mode 100644 index 4f61f12a..00000000 --- a/tests/UnityAssetsPatcher.TUI.Tests/PlaceholderTests.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Xunit; - -namespace UnityAssetsPatcher.TUI.Tests; - -public sealed class PlaceholderTests -{ - [Fact] - public void TUIAssembly_WhenLoaded_HasExpectedName() - { - string? assemblyName = typeof(TerminalApp).Assembly.GetName().Name; - - Console.WriteLine("Placeholder test: Ciallo~(∠・ω< )⌒★"); - - Assert.Equal("UnityAssetsPatcher.TUI", assemblyName); - } -} diff --git a/tests/UnityAssetsPatcher.TUI.Tests/UnityAssetsPatcher.TUI.Tests.csproj b/tests/UnityAssetsPatcher.TUI.Tests/UnityAssetsPatcher.TUI.Tests.csproj deleted file mode 100644 index 88651f33..00000000 --- a/tests/UnityAssetsPatcher.TUI.Tests/UnityAssetsPatcher.TUI.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - From cecf407e0a9cd9345aba1552a303cf7fad2f4a27 Mon Sep 17 00:00:00 2001 From: CnBarrier Date: Thu, 17 Sep 2026 21:59:35 +0800 Subject: [PATCH 02/18] feat(gui): add mod package preview workflow Add package selection and drag-and-drop support, display mod metadata, target directory and optional groups, and localize preview errors. --- localization/Strings.resx | 99 +++++ localization/Strings.zh-CN.resx | 99 +++++ .../Features/Install/InstallModContracts.cs | 4 + .../Installation/InstallResultMapper.cs | 7 +- .../Styles/DesignTokens.axaml | 17 +- .../ViewModels/MainWindowViewModel.cs | 7 +- .../Pages/InstallModPageViewModel.cs | 332 ++++++++++++++- .../Pages/InstallOptionalGroupViewModel.cs | 27 ++ .../Views/InstallModPage.axaml | 380 ++++++++++++++++-- .../Views/InstallModPage.axaml.cs | 146 +++++++ .../Views/MainWindow.axaml | 6 +- 11 files changed, 1091 insertions(+), 33 deletions(-) create mode 100644 src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallOptionalGroupViewModel.cs diff --git a/localization/Strings.resx b/localization/Strings.resx index 64ec21f5..e9a9ca9f 100644 --- a/localization/Strings.resx +++ b/localization/Strings.resx @@ -23,6 +23,12 @@ Drop a mod package here + + + Release to preview this mod package + + + Mod package drop area or click to select a file @@ -30,4 +36,97 @@ Supports .zip mod packages + + Review the mod details. + + + Author + + + Target game + + + Change + + + Optional content + + + Choose another + + + Start install + + + Select a mod package + + + Mod packages + + + Select the game directory + + + Reading mod package… + + + Custom game directory + + + Select a single .zip mod package. + + + The install service is not available. + + + The selected file is not a valid ZIP archive. + + + The Mod package does not contain a manifest. + + + The Mod package contains more than one manifest. + + + The Mod package contains an unsafe file path. + + + The Mod package contains duplicate files. + + + The Mod package is too large. + + + The Mod manifest is too large. + + + A file declared by the Mod package is missing. + + + The selected Mod package could not be found. + + + The selected file or game directory cannot be accessed. + + + The selected file or game directory could not be read. + + + The game directory could not be found. Choose it manually. + + + The Mod cannot be applied to this game installation. + + + Another installation operation is already running. + + + The previous installation needs recovery before continuing. + + + The package or game files changed. Preview the installation again. + + + The Mod operation could not be completed. + diff --git a/localization/Strings.zh-CN.resx b/localization/Strings.zh-CN.resx index f1b77fbc..232157ca 100644 --- a/localization/Strings.zh-CN.resx +++ b/localization/Strings.zh-CN.resx @@ -23,6 +23,12 @@ 拖拽 Mod 包到这里 + + + 松开鼠标以预览此 Mod 包 + + + Mod 包拖放区域 或点击选择文件 @@ -30,4 +36,97 @@ 支持 .zip 格式的 Mod 包 + + 确认 Mod 信息。 + + + 作者 + + + 目标游戏 + + + 更改 + + + 可选内容 + + + 重新选择 + + + 开始安装 + + + 选择 Mod 包 + + + Mod 包 + + + 选择游戏目录 + + + 正在读取 Mod 包… + + + 自定义游戏目录 + + + 请选择单个 .zip Mod 包。 + + + 安装服务不可用。 + + + 所选文件不是有效的 ZIP 压缩包。 + + + Mod 包中没有 manifest。 + + + Mod 包中包含多个 manifest。 + + + Mod 包中包含不安全的文件路径。 + + + Mod 包中包含重复文件。 + + + Mod 包过大。 + + + Mod manifest 过大。 + + + Mod 包声明的文件缺失。 + + + 找不到所选 Mod 包。 + + + 无法访问所选文件或游戏目录。 + + + 无法读取所选文件或游戏目录。 + + + 找不到游戏目录,请手动选择。 + + + 此 Mod 无法应用于当前游戏安装。 + + + 已有安装操作正在进行。 + + + 上一次安装需要恢复后才能继续。 + + + Mod 包或游戏文件已变化,请重新预览安装内容。 + + + 无法完成 Mod 操作。 + diff --git a/src/UnityAssetsPatcher.Application/Features/Install/InstallModContracts.cs b/src/UnityAssetsPatcher.Application/Features/Install/InstallModContracts.cs index bb1296ed..72627759 100644 --- a/src/UnityAssetsPatcher.Application/Features/Install/InstallModContracts.cs +++ b/src/UnityAssetsPatcher.Application/Features/Install/InstallModContracts.cs @@ -37,6 +37,10 @@ public sealed record InstallPreviewResult( IReadOnlyList<(string Name, string? Description)> OptionalGroups, TimingSnapshot Timing) { + public string? ModDescription { get; init; } + public string? TargetGameName { get; init; } + public string TargetGameDirectory { get; init; } = string.Empty; + [JsonIgnore] public PreparedInstall? PreparedInstall { get; init; } } diff --git a/src/UnityAssetsPatcher.Application/Installation/InstallResultMapper.cs b/src/UnityAssetsPatcher.Application/Installation/InstallResultMapper.cs index 82ceb0a1..2bd3b9ed 100644 --- a/src/UnityAssetsPatcher.Application/Installation/InstallResultMapper.cs +++ b/src/UnityAssetsPatcher.Application/Installation/InstallResultMapper.cs @@ -29,7 +29,12 @@ public static InstallPreviewResult ToPreviewResult( analysis.OptionalGroups .Select(group => (group.Name, group.Description)) .ToArray(), - timing); + timing) + { + ModDescription = analysis.Manifest.Description, + TargetGameName = analysis.Manifest.Game, + TargetGameDirectory = analysis.GameDirectory + }; } public static InstallModResult ToInstallResult( diff --git a/src/UnityAssetsPatcher.GUI/Styles/DesignTokens.axaml b/src/UnityAssetsPatcher.GUI/Styles/DesignTokens.axaml index 5b140768..9a9f3aa0 100644 --- a/src/UnityAssetsPatcher.GUI/Styles/DesignTokens.axaml +++ b/src/UnityAssetsPatcher.GUI/Styles/DesignTokens.axaml @@ -5,6 +5,7 @@ 4 6 8 + 10 12 16 20 @@ -19,12 +20,24 @@ 0,0,0,2 4,8 20 + 16 + 16,6 + 8,4 + 24,10 + 7,2 + 1 40 3 20 1 12 + 300 + 52 + 42 + 2 + 2 + 12 6 @@ -33,13 +46,13 @@ - + @@ -60,13 +73,13 @@ - + diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/MainWindowViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/MainWindowViewModel.cs index af273fd1..ff0e5ea9 100644 --- a/src/UnityAssetsPatcher.GUI/ViewModels/MainWindowViewModel.cs +++ b/src/UnityAssetsPatcher.GUI/ViewModels/MainWindowViewModel.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using UnityAssetsPatcher.Application; using UnityAssetsPatcher.GUI.Localization; using UnityAssetsPatcher.GUI.ViewModels.Pages; @@ -13,11 +14,13 @@ public sealed class MainWindowViewModel : ViewModelBase private NavigationItemViewModel _selected; - public MainWindowViewModel() + public MainWindowViewModel(IServiceScopeFactory? scopeFactory = null) { PrimaryItems = [ - new NavigationItemViewModel(StringsKeys.MainMenu_InstallMod_Title, new InstallModPageViewModel()), + new NavigationItemViewModel( + StringsKeys.MainMenu_InstallMod_Title, + new InstallModPageViewModel(scopeFactory)), new NavigationItemViewModel(StringsKeys.Navigation_ManageMods, new ManageModsPageViewModel()), new NavigationItemViewModel(StringsKeys.MainMenu_Settings_Title, new SettingsPageViewModel()) ]; diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallModPageViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallModPageViewModel.cs index 8e4f2d39..59d586fb 100644 --- a/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallModPageViewModel.cs +++ b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallModPageViewModel.cs @@ -1,3 +1,333 @@ +using System.Collections.ObjectModel; +using Microsoft.Extensions.DependencyInjection; +using RentADeveloper.ResXLocalization; +using UnityAssetsPatcher.Application.Features.Install; +using UnityAssetsPatcher.Application.Messaging; +using UnityAssetsPatcher.Application.Mods; +using UnityAssetsPatcher.Application.Operations; +using UnityAssetsPatcher.GUI.Localization; + namespace UnityAssetsPatcher.GUI.ViewModels.Pages; -public sealed class InstallModPageViewModel : ViewModelBase; +public sealed class InstallModPageViewModel : ViewModelBase +{ + private readonly IServiceScopeFactory? _scopeFactory; + private CancellationTokenSource? _operationCancellation; + private InstallPreviewResult? _preview; + private string? _packagePath; + private string? _requestedGameDirectory; + private bool _isBusy; + private string? _errorMessage; + + public ObservableCollection OptionalGroups { get; } = []; + + public bool IsDropZoneVisible => _preview is null; + public bool IsPreviewVisible => _preview is not null; + + public bool IsBusy + { + get => _isBusy; + private set + { + if (SetProperty(ref _isBusy, value)) + { + OnPropertyChanged(nameof(IsNotBusy)); + } + } + } + + public bool IsNotBusy => !IsBusy; + + public bool HasOptionalGroups => OptionalGroups.Count > 0; + + public string ModName => _preview?.ModName ?? string.Empty; + public string ModVersion => _preview?.ModVersion ?? string.Empty; + public string ModAuthor => _preview?.ModAuthor ?? string.Empty; + public string? ModDescription => _preview?.ModDescription; + public bool HasModDescription => !string.IsNullOrWhiteSpace(ModDescription); + + public string PackageFileName => + string.IsNullOrWhiteSpace(_packagePath) ? string.Empty : Path.GetFileName(_packagePath); + + public string TargetGameName => + string.IsNullOrWhiteSpace(_preview?.TargetGameName) + ? Localizer.Current.Get(StringsKeys.InstallPage_CustomGameDirectory) + : _preview.TargetGameName!; + + public string TargetGameDirectory => _preview?.TargetGameDirectory ?? string.Empty; + public string? ErrorMessage + { + get => _errorMessage; + private set + { + if (SetProperty(ref _errorMessage, value)) + { + OnPropertyChanged(nameof(HasError)); + } + } + } + + public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage); + + public InstallModPageViewModel(IServiceScopeFactory? scopeFactory = null) + { + _scopeFactory = scopeFactory; + } + + public Task LoadPackageAsync(string packagePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); + + if (!string.Equals(Path.GetExtension(packagePath), ".zip", StringComparison.OrdinalIgnoreCase)) + { + SetError(StringsKeys.InstallPage_ErrorInvalidFile); + return Task.CompletedTask; + } + + _packagePath = packagePath; + _requestedGameDirectory = null; + SetPreview(null); + OptionalGroups.Clear(); + OnPropertyChanged(nameof(HasOptionalGroups)); + + return PreviewPackageAsync( + packagePath, + null, + [], + true); + } + + public void ReportInvalidPackageSelection() + { + SetError(StringsKeys.InstallPage_ErrorInvalidFile); + } + + public Task SelectGameDirectoryAsync(string gameDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(gameDirectory); + + if (_preview is null || string.IsNullOrWhiteSpace(_packagePath) || IsBusy) + { + return Task.CompletedTask; + } + + return PreviewPackageAsync( + _packagePath, + gameDirectory, + GetSelectedOptionalGroups(), + false); + } + + public Task RefreshOptionalGroupsAsync() + { + if (_preview is null || string.IsNullOrWhiteSpace(_packagePath) || IsBusy) + { + return Task.CompletedTask; + } + + return PreviewPackageAsync( + _packagePath, + _requestedGameDirectory, + GetSelectedOptionalGroups(), + false); + } + + public void ResetSelection() + { + _operationCancellation?.Cancel(); + _operationCancellation = null; + + SetPreview(null); + _packagePath = null; + _requestedGameDirectory = null; + OptionalGroups.Clear(); + OnPropertyChanged(nameof(PackageFileName)); + OnPropertyChanged(nameof(HasOptionalGroups)); + IsBusy = false; + ErrorMessage = null; + } + + private async Task PreviewPackageAsync( + string packagePath, + string? gameDirectory, + IReadOnlyList selectedOptionalGroups, + bool clearPreviewOnFailure) + { + if (_scopeFactory is null) + { + SetError(StringsKeys.InstallPage_ErrorNoService); + return; + } + + CancellationTokenSource operation = BeginOperation(); + CancellationToken cancellationToken = operation.Token; + + try + { + var result = await DispatchAsync>( + new PreviewInstallRequest(new InstallRequest(packagePath, gameDirectory) + { + SelectedOptionalGroups = selectedOptionalGroups + }), + cancellationToken); + + if (!IsCurrentOperation(operation)) + { + return; + } + + switch (result) + { + case OperationSucceeded succeeded: + ApplyPreview(succeeded.Value, gameDirectory, selectedOptionalGroups); + break; + case OperationFailed failed: + if (clearPreviewOnFailure) + { + SetPreview(null); + } + + SetError(LocalizeError(failed.Error)); + break; + default: + throw new InvalidOperationException("The install preview returned an unknown result."); + } + } + catch (OperationCanceledException exception) + when (cancellationToken.IsCancellationRequested && exception.CancellationToken == cancellationToken) { } + finally + { + EndOperation(operation); + } + } + + private async Task DispatchAsync( + TRequest request, + CancellationToken cancellationToken) + where TRequest : IRequest + { + await using AsyncServiceScope scope = _scopeFactory!.CreateAsyncScope(); + var dispatcher = scope.ServiceProvider.GetRequiredService(); + + return await dispatcher.DispatchAsync(request, cancellationToken); + } + + private CancellationTokenSource BeginOperation() + { + _operationCancellation?.Cancel(); + + var operation = new CancellationTokenSource(); + _operationCancellation = operation; + IsBusy = true; + ErrorMessage = null; + + return operation; + } + + private void EndOperation(CancellationTokenSource operation) + { + if (IsCurrentOperation(operation)) + { + _operationCancellation = null; + IsBusy = false; + } + + operation.Dispose(); + } + + private bool IsCurrentOperation(CancellationTokenSource operation) + { + return ReferenceEquals(_operationCancellation, operation); + } + + private void ApplyPreview( + InstallPreviewResult preview, + string? gameDirectory, + IReadOnlyList selectedOptionalGroups) + { + _preview = preview; + _requestedGameDirectory = gameDirectory; + + var selected = selectedOptionalGroups.ToHashSet(StringComparer.OrdinalIgnoreCase); + OptionalGroups.Clear(); + foreach ((string name, string? description) in preview.OptionalGroups) + { + OptionalGroups.Add(new InstallOptionalGroupViewModel( + name, + description, + selected.Contains(name))); + } + + OnPropertyChanged(nameof(IsDropZoneVisible)); + OnPropertyChanged(nameof(IsPreviewVisible)); + OnPropertyChanged(nameof(HasOptionalGroups)); + OnPropertyChanged(nameof(ModName)); + OnPropertyChanged(nameof(ModVersion)); + OnPropertyChanged(nameof(ModAuthor)); + OnPropertyChanged(nameof(ModDescription)); + OnPropertyChanged(nameof(HasModDescription)); + OnPropertyChanged(nameof(PackageFileName)); + OnPropertyChanged(nameof(TargetGameName)); + OnPropertyChanged(nameof(TargetGameDirectory)); + } + + private void SetPreview(InstallPreviewResult? preview) + { + _preview = preview; + OnPropertyChanged(nameof(IsDropZoneVisible)); + OnPropertyChanged(nameof(IsPreviewVisible)); + OnPropertyChanged(nameof(ModName)); + OnPropertyChanged(nameof(ModVersion)); + OnPropertyChanged(nameof(ModAuthor)); + OnPropertyChanged(nameof(ModDescription)); + OnPropertyChanged(nameof(HasModDescription)); + OnPropertyChanged(nameof(PackageFileName)); + OnPropertyChanged(nameof(TargetGameName)); + OnPropertyChanged(nameof(TargetGameDirectory)); + } + + private IReadOnlyList GetSelectedOptionalGroups() + { + return OptionalGroups + .Where(group => group.IsSelected) + .Select(group => group.Name) + .ToArray(); + } + + private void SetError(ResourceKey key) + { + ErrorMessage = Localizer.Current.Get(key); + } + + private void SetError(string message) + { + ErrorMessage = message; + } + + private static string LocalizeError(OperationError error) + { + ResourceKey key = error.Code.Value switch + { + "mod_package.invalid_archive" => StringsKeys.InstallPage_ErrorInvalidArchive, + "mod_package.missing_manifest" => StringsKeys.InstallPage_ErrorMissingManifest, + "mod_package.multiple_manifests" => StringsKeys.InstallPage_ErrorMultipleManifests, + "mod_package.unsafe_entry_path" => StringsKeys.InstallPage_ErrorUnsafeEntry, + "mod_package.duplicate_entry" => StringsKeys.InstallPage_ErrorDuplicateEntry, + "mod_package.package_too_large" => StringsKeys.InstallPage_ErrorPackageTooLarge, + "mod_package.manifest_too_large" => StringsKeys.InstallPage_ErrorManifestTooLarge, + "mod_package.missing_entry" => StringsKeys.InstallPage_ErrorMissingEntry, + "file.not_found" => StringsKeys.InstallPage_ErrorFileNotFound, + "file.access_denied" => StringsKeys.InstallPage_ErrorAccessDenied, + "file.read_failed" or "file.system_failure" => StringsKeys.InstallPage_ErrorFileFailure, + "game_directory.required" or "game_directory.not_found" => StringsKeys.InstallPage_ErrorGameDirectory, + "patch.planning_failed" => StringsKeys.InstallPage_ErrorPatchPlanning, + "operation.already_running" => StringsKeys.InstallPage_ErrorOperationRunning, + "backup.recovery_required" => StringsKeys.InstallPage_ErrorRecoveryRequired, + "install.preview_stale" or "install.file_integrity_mismatch" => + StringsKeys.InstallPage_ErrorPreviewStale, + _ => StringsKeys.InstallPage_ErrorGeneric + }; + + return Localizer.Current.Get(key); + } +} diff --git a/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallOptionalGroupViewModel.cs b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallOptionalGroupViewModel.cs new file mode 100644 index 00000000..db8a3787 --- /dev/null +++ b/src/UnityAssetsPatcher.GUI/ViewModels/Pages/InstallOptionalGroupViewModel.cs @@ -0,0 +1,27 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace UnityAssetsPatcher.GUI.ViewModels.Pages; + +public sealed class InstallOptionalGroupViewModel : ViewModelBase +{ + public string Name { get; } + public string? Description { get; } + public bool HasDescription => !string.IsNullOrWhiteSpace(Description); + + private bool _isSelected; + + public bool IsSelected + { + get => _isSelected; + set => SetProperty(ref _isSelected, value); + } + + public InstallOptionalGroupViewModel(string name, string? description, bool isSelected) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + Description = description; + _isSelected = isSelected; + } +} diff --git a/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml b/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml index a89e60e9..34070f8f 100644 --- a/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml +++ b/src/UnityAssetsPatcher.GUI/Views/InstallModPage.axaml @@ -2,33 +2,361 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:l="clr-namespace:RentADeveloper.ResXLocalization.Avalonia;assembly=ResXLocalization.Avalonia" xmlns:res="using:UnityAssetsPatcher.GUI.Localization" - x:Class="UnityAssetsPatcher.GUI.Views.InstallModPage"> - - - - - - - - - - - - + xmlns:pages="using:UnityAssetsPatcher.GUI.ViewModels.Pages" + x:Class="UnityAssetsPatcher.GUI.Views.InstallModPage" + x:DataType="pages:InstallModPageViewModel"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + RowSpacing="{StaticResource ContentGroupSpacing}"> + @@ -224,136 +101,122 @@ Text="{l:Localize {x:Static res:StringsKeys.InstallPage_PreviewDescription}}" /> - - + + + - + - - + - - + - - + -