diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6833fd8a..e0d6734f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,12 +37,12 @@ jobs: run: dotnet --info - name: Restore dependencies - run: dotnet restore ${{ env.SOLUTION_PATH }} --runtime ${{ env.RUNTIME_IDENTIFIER }} -p:PublishAot=true + run: dotnet restore ${{ env.SOLUTION_PATH }} --runtime ${{ env.RUNTIME_IDENTIFIER }} - name: Run tests run: dotnet test ${{ env.SOLUTION_PATH }} --configuration Release --no-restore - - name: Verify NativeAOT publish + - name: Verify publish shell: pwsh run: | dotnet publish $env:PROJECT_PATH ` @@ -51,9 +51,14 @@ jobs: --self-contained true ` --no-restore ` --output artifacts/ci-publish ` - -p:JsonSerializerIsReflectionEnabledByDefault=false ` - -p:WarningsAsErrors=IL2026%3BIL3050 ` - -p:PublishAot=true ` -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` -p:DebugType=none ` -p:DebugSymbols=false + + - name: Upload CI build artifact + uses: actions/upload-artifact@v7 + with: + name: ci-build-${{ github.run_number }} + path: artifacts/ci-publish + if-no-files-found: error diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 112b80cb..61cce93e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -16,7 +16,7 @@ on: workflow_dispatch: concurrency: - group: pages-${{ github.event.pull_request.number || github.ref }} + group: pages-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'deployment' }} cancel-in-progress: false jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32aca799..0296aa6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,44 +93,12 @@ jobs: "executable_path=$executablePath" >> $env:GITHUB_OUTPUT - name: Generate checksum - id: checksum shell: pwsh run: | $executablePath = "${{ steps.executable.outputs.executable_path }}" $hash = (Get-FileHash -Path $executablePath -Algorithm SHA256).Hash.ToLowerInvariant() $checksumFile = "artifacts/SHA256" "$hash $(Split-Path $executablePath -Leaf)" | Set-Content -Path $checksumFile -Encoding utf8 - "sha256=$hash" >> $env:GITHUB_OUTPUT - "checksum_path=$checksumFile" >> $env:GITHUB_OUTPUT - - - name: Generate update manifest - shell: pwsh - run: | - $tag = "$env:TAG_NAME" - $executableFilename = "${{ steps.release.outputs.artifact_name }}.exe" - $sha256 = "${{ steps.checksum.outputs.sha256 }}" - $repositoryUrl = "https://github.com/${{ github.repository }}" - $manifestPath = "artifacts/update.json" - - $manifest = [ordered]@{ - schemaVersion = 1 - version = $tag - releaseUrl = "$repositoryUrl/releases/tag/$tag" - downloadUrl = "$repositoryUrl/releases/download/$tag/$executableFilename" - sha256 = $sha256 - } - - $manifest | ConvertTo-Json | Set-Content -Path $manifestPath -Encoding utf8 - - $parsed = Get-Content -Raw -Path $manifestPath | ConvertFrom-Json - - if ($parsed.schemaVersion -ne 1 -or - $parsed.version -ne $tag -or - $parsed.downloadUrl -notlike "*/$executableFilename" -or - $parsed.sha256 -ne $sha256 -or - $parsed.sha256 -notmatch '^[0-9a-f]{64}$') { - throw "Generated update manifest failed validation." - } - name: Prepare release notes id: notes @@ -204,7 +172,6 @@ jobs: path: | artifacts/${{ steps.release.outputs.artifact_name }}.exe artifacts/SHA256 - artifacts/update.json artifacts/release-notes.md if-no-files-found: error @@ -232,4 +199,3 @@ jobs: files: | artifacts/${{ needs.build.outputs.executable_filename }} artifacts/SHA256 - artifacts/update.json diff --git a/.gitignore b/.gitignore index ced8730d..5ba3bd76 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ x86/ bld/ [Bb]in/ [Oo]bj/ +/localization/*.Designer.cs [Ll]og/ [Ll]ogs/ @@ -43,6 +44,7 @@ ScaffoldingReadMe.txt ~$* *~ CodeCoverage/ +*.DotSettings.user # MSBuild Binary and Structured Log *.binlog @@ -60,5 +62,6 @@ nunit-*.xml .idea/ # Agent tool files +.agents/ .codex/ .claude/ diff --git a/AGENTS.md b/AGENTS.md index 82c795dd..681607bc 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 and Avalonia desktop application for managing Unity assets mods. ## Commands @@ -11,35 +9,36 @@ It provides a Terminal.Gui-based interactive UI and a non-interactive CLI. | Build | `dotnet build UnityAssetsPatcher.slnx` | | Test | `dotnet test UnityAssetsPatcher.slnx` | +Existing tests may be updated to reflect authorized implementation changes. Do not add 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, dependency composition, concrete logging setup, and Avalonia UI; owns presentation, localization, and user interaction | +| `src/UnityAssetsPatcher.Application/` | Use cases, business workflow orchestration, transaction and recovery policies, DTOs, and infrastructure interfaces | +| `src/UnityAssetsPatcher.Domain/` | Business models, value objects, validation rules, and domain errors independent of infrastructure | +| `src/UnityAssetsPatcher.Infrastructure/` | File system, compression, persistence, package parsing, and external integrations implementing Application interfaces | +| `tests/` | Unit and integration tests | + +## Architecture -## Project Architecture +### Application Configuration -[ARCHITECTURE.md](./ARCHITECTURE.md) +- `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. -## Workflows +### Application Boundaries -### 1. Implementing a Feature +- Prefer direct, use-case-oriented application APIs. +- Return business data directly by default. Use use-case-specific results when callers need to distinguish multiple normal business outcomes; allow null or empty collections when their meaning is unambiguous. Do not require a common result wrapper. +- Infrastructure propagates original platform and third-party exceptions without wrapping, translating, or replacing them. Application interprets known failures according to the use-case contract and may expose specific application exceptions. UI owns their presentation. -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. +### Exception Policy -### 2. Fixing a Bug +- `Program.Main` is the single process-level unexpected-exception boundary, covering startup, dependency composition, GUI execution, and asynchronous disposal. It logs full diagnostics once, shows generic output without internal details, and terminates with a non-zero exit code. +- Boundary logging must outlive the dependency container. Unexpected failures from UI and background work must reach this boundary promptly, rather than waiting for disposal; lower layers must not log or convert them into expected failures. +- Only the creator of a `CancellationTokenSource`, or the lifecycle boundary explicitly given control of it, may consume cancellation after verifying a cancellation request and matching exception token identity. Integrations that cannot preserve identity require a narrowly scoped, documented adapter. An unmatched cancellation escaping to `Program.Main` is treated as unexpected. -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. 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/README.md b/README.md index a47902b2..65ada5d3 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,17 @@ [Documentation](https://uap.cnbarrier.com) · [简体中文](README_ZH.md) -Unity Assets Patcher is a terminal application for inspecting Unity assets files and installing or uninstalling assets mods. It is intended for games where runtime mod frameworks such as `BepInEx` are impractical, using a `manifest.json` inside each mod package to describe file copies and assets modifications. +Unity Assets Patcher is a .NET 10 and Avalonia desktop application for installing and managing Unity assets mods. It is intended for games where runtime mod frameworks such as `BepInEx` are impractical, using a `manifest.json` inside each mod package to describe file copies and assets modifications. ## Features -- An interactive terminal UI for inspecting assets files and installing or uninstalling zip-based mod packages. -- A non-interactive CLI with text and JSON output. +- A desktop interface for installing ZIP mod packages by dragging a file into the window or selecting it with a file picker. +- Installation preview with mod metadata, the target game directory, and selectable optional content. +- Installed mod management with refresh, dependency checks, and uninstall confirmation. - Manifest validation with JSON Schema and semantic rules. -- Backups and install records for safe uninstallation and interrupted-operation recovery. +- Layered backups and install records for safe uninstallation, with interrupted-transaction checks before changes. - Validation of mod ZIP entries, file paths, directory traversal, and unsafe file operations. +- Startup and manual update checks, plus settings for verbose logging and opening the log directory. ## Documentation @@ -20,11 +22,7 @@ Unity Assets Patcher is a terminal application for inspecting Unity assets files ## Download -Download the latest Windows executable from [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases). The current release supports `win-x64` and is distributed as a self-contained single file. - -```powershell -.\UnityAssetsPatcher.exe -``` +Download the Windows executable from [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases). Windows builds support `win-x64` and are distributed as a self-contained single file, with no separate .NET runtime required. Double-click the executable to open the desktop interface. ## Development and Contributing diff --git a/README_ZH.md b/README_ZH.md index 8d2f5706..f3dae399 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -2,15 +2,17 @@ [文档](https://uap.cnbarrier.com/zh-cn) · [English](README.md) -Unity Assets Patcher 是一款用于浏览 Unity assets 文件及安装、卸载 assets Mod 的终端工具。它适用于不便接入 `BepInEx` 等运行时 Mod 框架的游戏,通过 Mod 包中的 `manifest.json` 描述文件复制与 assets 修改。 +Unity Assets Patcher 是一款基于 .NET 10 和 Avalonia 的桌面应用,用于安装和管理 Unity assets Mod。它适用于不便接入 `BepInEx` 等运行时 Mod 框架的游戏,通过 Mod 包中的 `manifest.json` 描述文件复制与 assets 修改。 ## 功能 -- 提供交互式终端界面,用于浏览 assets 文件以及安装和卸载 zip 格式的 Mod 包。 -- 提供支持文本和 JSON 输出的非交互式 CLI。 +- 提供桌面界面,可通过拖放文件或文件选择器安装 ZIP Mod 包。 +- 安装预览展示 Mod 信息、目标游戏目录,并支持勾选可选内容。 +- 管理已安装的 Mod,支持刷新列表、依赖检查和卸载确认。 - 使用 JSON Schema 和语义规则校验 manifest。 -- 通过备份和安装记录支持安全卸载与中断操作恢复。 +- 通过分层备份和安装记录支持安全卸载,并在变更前检查中断事务。 - 对 Mod ZIP 条目、文件路径、目录穿越和不安全文件操作进行校验。 +- 支持启动时和手动检查更新,可在设置中切换详细日志并打开日志目录。 ## 文档 @@ -20,11 +22,7 @@ Unity Assets Patcher 是一款用于浏览 Unity assets 文件及安装、卸载 ## 下载 -从 [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases) 下载最新的 Windows 可执行文件。当前发布包支持 `win-x64`,采用自包含单文件形式。 - -```powershell -.\UnityAssetsPatcher.exe -``` +从 [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases) 下载 Windows 可执行文件。Windows 构建支持 `win-x64`,采用自包含单文件形式,无需另外安装 .NET 运行时。双击可执行文件即可打开桌面界面。 ## 开发和贡献 diff --git a/UnityAssetsPatcher.slnx b/UnityAssetsPatcher.slnx index ec953850..01125d0a 100644 --- a/UnityAssetsPatcher.slnx +++ b/UnityAssetsPatcher.slnx @@ -2,19 +2,12 @@ - - - - - - diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 6fd17e6d..14442b6f 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -7,7 +7,7 @@ export default defineConfig({ integrations: [ starlight({ title: 'Unity Assets Patcher', - description: 'Inspect Unity assets files and install or uninstall mods.', + description: 'Install and manage Unity assets mods.', defaultLocale: 'root', locales: { root: { label: 'English', lang: 'en' }, diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index 2c827a64..2bc90122 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -5,14 +5,28 @@ sidebar: order: 2 --- -## Why do I see an “old Windows console” warning? +## Why does a mod package fail to load? -The interactive UI needs the character rendering and layout support provided by a modern terminal. Borders, text, or layout may render incorrectly in the legacy Windows Console Host. +Select or drag in one `.zip` file containing exactly one `manifest.json`. The manifest must use the supported `$schema` URL, and its patches and payload files must satisfy the [manifest rules](/mod-manifest-guide). -Install and use [Windows Terminal](https://aka.ms/terminal): +The initial preview also needs the target game and assets files. Check that the manifest's `game` matches the name in Steam's installation information and resolves to one installed game. Each target assets file name must match exactly one file under that game directory. -```powershell -winget install --id Microsoft.WindowsTerminal -e -``` +If the notification does not explain the problem, enable **Verbose logging** in **Settings**, load the package again, and click **Open folder** to read the latest log. -In Windows Terminal, open **Settings → Startup**, set **Default terminal application** to **Windows Terminal**, and restart the application in a new terminal window. Existing console windows do not migrate after changing this setting. +## Why is uninstall blocked? + +Before uninstalling, the application checks whether the game files still match the recorded state and whether the remaining mods can be rebuilt. External changes to game files, damaged backups, or a dependency in another mod can block the operation. + +If the notification names dependent mods, uninstall those first. For other failures, check the latest log and keep the backup directory intact. + +## Where are backups and logs stored? + +Backups and install records are stored in `%LOCALAPPDATA%\UnityAssetsPatcher\backup`. The `base/` directory holds base snapshots, and `layers//` holds each install record and original mod package. These files are required for uninstallation. + +Logs are stored in `%LOCALAPPDATA%\UnityAssetsPatcher\logs`, with up to five recent files retained. **Settings → Open folder** opens this directory. **Verbose logging** adds debug information for the current session; enable it again after restarting if needed. + +## Why did the update check fail? + +Update checks request the latest stable release from GitHub. Network failures, API rate limits, or invalid release data can cause a check to fail. Startup check failures are silent; manual checks in **Settings** show a notification. + +Try again later or download the executable directly from [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases). When a newer version is found, **Open release page** opens the download page in your browser. diff --git a/docs/src/content/docs/index.md b/docs/src/content/docs/index.md index 3996bd72..35c3554f 100644 --- a/docs/src/content/docs/index.md +++ b/docs/src/content/docs/index.md @@ -1,39 +1,43 @@ --- title: Get started -description: Download and run Unity Assets Patcher. +description: Download Unity Assets Patcher and install or manage mods in the desktop interface. sidebar: order: 1 --- ## Requirements -The current release supports `win-x64`. It is distributed as a self-contained, single-file application, so you do not need to install the .NET runtime. +Windows builds support `win-x64`. They are distributed as a self-contained, single-file application, so you do not need to install the .NET runtime. ## Download -Download the latest executable from [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases). +Download the executable from [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases), then double-click it to open the desktop interface. -## Open the terminal UI +## Install a mod -Run the application without arguments: +1. Open **Install Mod**. Drag one `.zip` mod package into the window, or click **Select file**. +2. After the preview loads, review the mod's name, version, author, description, and target game directory. Click **Change** to select another game directory if needed. +3. Select any optional content you want to install. Each checkbox updates the installation preview; optional groups are initially unchecked. +4. Click **Start install**. The completion page shows the installed mod and any selected optional groups. -```powershell -.\UnityAssetsPatcher.exe -``` +Loading a package and changing its preview do not modify game files. Click **Choose another** to return to package selection before installation, or **Install another mod** after it completes. -The interactive UI can inspect assets files and guide you through installing, uninstalling, and recovering mods. A modern terminal such as [Windows Terminal](https://aka.ms/terminal) is recommended. +The initial preview uses the manifest's `game` field to locate a Steam installation. It must resolve one game directory and find the target assets files before the preview can open. See the [FAQ](/faq) if a package cannot be loaded. -## Use the CLI +## Manage installed mods -Pass a command to use the non-interactive interface: +Open **Manage Mods** to see installed mods, their versions, target games, and installation times. Use **Refresh** to reload the list. -```powershell -.\UnityAssetsPatcher.exe --help -.\UnityAssetsPatcher.exe install --help -``` +Click **Uninstall** beside a mod. The application checks file integrity and whether the remaining mods can be rebuilt, then asks for confirmation. A successful uninstall refreshes the list. If another mod depends on it, the notification identifies the affected mod. + +## Settings and updates + +In **Settings**, enable **Verbose logging** when troubleshooting or click **Open folder** to view the log directory. Verbose logging takes effect immediately for the current session and resets when the application restarts. + +The application checks the latest stable GitHub release at startup. You can also click **Check for updates** in **Settings**. When a newer version is available, **Open release page** opens it in your browser so you can download the executable. ## Data and backups Install records and layered backups are stored in `%LOCALAPPDATA%\UnityAssetsPatcher\backup` by default. Logs are stored in `%LOCALAPPDATA%\UnityAssetsPatcher\logs`, with up to five recent files retained. -Keep the backup directory available. It is required to uninstall mods or recover interrupted operations safely. +Keep the backup directory available. It is required to uninstall mods safely. diff --git a/docs/src/content/docs/mod-manifest-guide.md b/docs/src/content/docs/mod-manifest-guide.md index 00698ac0..3b9ebd5f 100644 --- a/docs/src/content/docs/mod-manifest-guide.md +++ b/docs/src/content/docs/mod-manifest-guide.md @@ -83,13 +83,13 @@ The manifest may be at the ZIP root or in a subdirectory, although the root is r } ``` -Validate the JSON file or complete package before publishing: +### Check before publishing -```powershell -.\UnityAssetsPatcher.exe check --config .\manifest.json -.\UnityAssetsPatcher.exe check --config .\Mod.zip -.\UnityAssetsPatcher.exe install preview --package .\Mod.zip --game-directory "C:\Games\Game" -``` +Validate the manifest in an editor with JSON Schema support, then load the ZIP in **Install Mod**. Loading the package validates the manifest and prepares an installation preview without modifying game files. + +Review the mod information and target game directory, then select any optional content to validate the selected combination. The initial preview resolves a Steam installation using `game`; once the preview opens, **Change** lets you select another game directory. + +Use UABEA to check asset matches and field values, and verify installation results on a test copy of the game before publishing. Click **Start install** to apply the mod, or **Choose another** to return to package selection. ## Top-level fields @@ -105,7 +105,7 @@ Validate the JSON file or complete package before publishing: | `targets` | Yes | Groups of target `.assets` files and patches. | | `optional` | No | Independently selectable optional-content groups. | -The old top-level `schemaVersion: 1` may remain in existing manifests, but the current runtime does not use it. The `schemaVersion` in CLI JSON responses belongs to the output protocol and is unrelated. +The old top-level `schemaVersion: 1` may remain in existing manifests, but the current runtime does not use it. New manifests do not need this field. ## Payload files @@ -267,9 +267,11 @@ Selected groups are merged with the main content before preview and installation ## Installation and uninstall behavior -Installation first validates the package, resolves the game and target files, and produces a dry-run preview. After confirmation, it prepares patched outputs, payloads, rollback snapshots, and hashes in the backup repository’s `.temp` directory. It then replaces assets atomically, creates payloads without overwriting existing files, verifies the results, and commits an immutable layer record. +Installation first validates the package, resolves the game and target files, and prepares a preview. The desktop preview shows mod metadata, the game directory, and optional-content checkboxes. Optional groups are initially unchecked and are merged with the main content when selected. + +After **Start install**, the application prepares patched outputs, payloads, rollback snapshots, and hashes in the backup repository’s `.temp` directory. It then replaces assets atomically, creates payloads without overwriting existing files, verifies the results, and commits an immutable layer record. Selected optional-group names are saved in `layer.json` and shown on the completion page. -Uninstall reconstructs modified assets by replaying remaining layers over base snapshots. It also restores or removes payloads according to their snapshots. Missing or damaged layer records, original packages, or snapshots stop the operation instead of risking an incorrect result. +In **Manage Mods**, **Uninstall** checks file integrity and dependencies before asking for confirmation. Uninstall reconstructs modified assets by replaying remaining layers over base snapshots. It also restores or removes payloads according to their snapshots. Missing or damaged layer records, original packages, or snapshots stop the operation instead of risking an incorrect result. ## Safety limits diff --git a/docs/src/content/docs/zh-cn/faq.md b/docs/src/content/docs/zh-cn/faq.md index 41bd0b06..30a069ff 100644 --- a/docs/src/content/docs/zh-cn/faq.md +++ b/docs/src/content/docs/zh-cn/faq.md @@ -5,25 +5,28 @@ sidebar: order: 2 --- -## Q1: 为什么提示“你正在使用旧版 Windows 控制台”? +## Q1: 为什么 Mod 包加载失败? -Unity Assets Patcher 的交互式界面需要现代终端提供的字符显示和布局能力。如果程序在旧版 Windows 控制台主机(Console Host)中运行,部分边框、文字或界面布局可能无法正确显示,因此会出现此警告。 +请选择或拖入一个 `.zip` 文件,包内必须只有一个 `manifest.json`。manifest 需要使用受支持的 `$schema` 地址,补丁和 payload 文件也必须符合 [manifest 规则](/zh-cn/mod-manifest-guide)。 -建议安装并使用 **Windows Terminal**: +首次预览还需要找到目标游戏和 assets 文件。请确认 manifest 的 `game` 与 Steam 安装信息中的游戏名称一致,且只能定位到一个已安装游戏。每个目标 assets 文件名也必须在该游戏目录下唯一匹配。 -1. 打开 Microsoft Store,搜索并安装 [Windows Terminal](https://aka.ms/terminal)。也可以在 PowerShell 中运行: +如果提示不足以确定原因,可以在**设置**中开启**详细日志**,重新加载 Mod 包,再点击**打开文件夹**查看最新日志。 - ```powershell - winget install --id Microsoft.WindowsTerminal -e - ``` +## Q2: 为什么无法卸载 Mod? -2. 打开 Windows Terminal,进入“设置” > “启动”,将“默认终端应用程序”切换为“Windows Terminal”,然后保存设置。 -3. 关闭当前的旧版控制台窗口,再重新运行 Unity Assets Patcher。已经打开的窗口不会在切换设置后自动迁移到 Windows Terminal。 +卸载前,程序会检查游戏文件是否仍与安装记录一致,以及剩余 Mod 能否重新合成。游戏文件被其他程序修改、备份损坏或其他 Mod 存在依赖,都可能阻止卸载。 -如果设置中没有“默认终端应用程序”选项,请先更新 Windows 和 Windows Terminal。你也可以直接打开 Windows Terminal,在其中进入程序所在目录并运行: +如果提示列出了依赖该 Mod 的其他 Mod,请先卸载这些 Mod。其他错误可以通过最新日志排查,并保留完整的备份目录。 -```powershell -.\UnityAssetsPatcher.exe -``` +## Q3: 备份和日志保存在哪里? -当程序在 Windows Terminal 的标签页中运行时,该警告应不再出现。 +备份和安装记录保存在 `%LOCALAPPDATA%\UnityAssetsPatcher\backup`。`base/` 保存基础快照,`layers//` 保存每次安装的记录和原始 Mod 包;卸载需要这些文件完整可用。 + +日志保存在 `%LOCALAPPDATA%\UnityAssetsPatcher\logs`,最多保留最近五个文件。在**设置**中点击**打开文件夹**即可打开该目录。**详细日志**会为本次运行增加调试信息,重启后如有需要,请重新开启。 + +## Q4: 为什么检查更新失败? + +检查更新会从 GitHub 获取最新稳定版本。网络故障、API 请求限额或无效的发布数据都可能导致检查失败。启动检查失败时不会弹出提示,设置中的手动检查会显示通知。 + +可以稍后重试,或直接从 [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases) 下载可执行文件。发现新版本后,点击**前往发布页**会在浏览器中打开下载页面。 diff --git a/docs/src/content/docs/zh-cn/index.md b/docs/src/content/docs/zh-cn/index.md index 46be1677..02e2412a 100644 --- a/docs/src/content/docs/zh-cn/index.md +++ b/docs/src/content/docs/zh-cn/index.md @@ -1,39 +1,43 @@ --- title: 开始使用 -description: 下载并运行 Unity Assets Patcher。 +description: 下载 Unity Assets Patcher,通过桌面界面安装和管理 Mod。 sidebar: order: 1 --- ## 系统要求 -当前发布包支持 `win-x64`,采用包含运行组件的自包含单文件形式,无需另外安装 .NET 运行时。 +Windows 构建支持 `win-x64`,采用包含运行组件的自包含单文件形式,无需另外安装 .NET 运行时。 ## 下载 -从 [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases) 下载最新版本。 +从 [GitHub Releases](https://github.com/CnBarrier404/UnityAssetsPatcher/releases) 下载可执行文件,双击即可打开桌面界面。 -## 打开交互式界面 +## 安装 Mod -直接运行程序: +1. 打开**安装 Mod**页面,将一个 `.zip` Mod 包拖入窗口,或点击**选择文件**。 +2. 预览加载成功后,确认 Mod 名称、版本、作者、说明和目标游戏目录。如需选择其他游戏目录,点击**更改**。 +3. 勾选需要安装的可选内容。每次勾选都会更新安装预览;可选内容默认不勾选。 +4. 点击**开始安装**。完成页面会显示已安装的 Mod 和本次选择的可选内容。 -```powershell -.\UnityAssetsPatcher.exe -``` +加载 Mod 包和调整预览不会修改游戏文件。安装前可以点击**重新选择**返回文件选择页面;安装完成后,点击**继续安装**即可选择下一个 Mod 包。 -交互式界面可用于浏览 assets 文件,并引导完成 Mod 安装、卸载和恢复。建议使用 [Windows Terminal](https://aka.ms/terminal) 等现代终端。 +首次预览会根据 manifest 的 `game` 字段查找 Steam 安装目录,需要唯一定位游戏目录并找到目标 assets 文件才能打开预览。Mod 包无法加载时,请参阅[常见问题](/zh-cn/faq)。 -## 使用 CLI +## 管理已安装的 Mod -传入命令即可使用非交互式界面: +打开**管理 Mod**页面,可查看已安装 Mod 的名称、版本、目标游戏和安装时间。点击**刷新**可以重新加载列表。 -```powershell -.\UnityAssetsPatcher.exe --help -.\UnityAssetsPatcher.exe install --help -``` +点击 Mod 旁的**卸载**后,程序会先检查文件完整性以及剩余 Mod 能否重新合成,再请求确认。卸载成功后会刷新列表。如果存在依赖该 Mod 的其他 Mod,提示中会列出受影响的 Mod。 + +## 设置与更新 + +遇到问题时,可以在**设置**中开启**详细日志**,或点击**打开文件夹**查看日志目录。详细日志开关会立即对本次运行生效,重启程序后恢复默认状态。 + +程序启动时会检查 GitHub 上最新的稳定版本,也可以在**设置**中点击**检查更新**。发现新版本后,点击**前往发布页**会在浏览器中打开对应页面,由你下载可执行文件。 ## 数据与备份 安装记录和分层备份默认保存在 `%LOCALAPPDATA%\UnityAssetsPatcher\backup`。日志保存在 `%LOCALAPPDATA%\UnityAssetsPatcher\logs`,最多保留最近五个文件。 -请保留备份目录,以便安全卸载 Mod 或恢复中断的操作。 +请保留备份目录,以便安全卸载 Mod。 diff --git a/docs/src/content/docs/zh-cn/mod-manifest-guide.md b/docs/src/content/docs/zh-cn/mod-manifest-guide.md index 24c72e27..6fde9ac0 100644 --- a/docs/src/content/docs/zh-cn/mod-manifest-guide.md +++ b/docs/src/content/docs/zh-cn/mod-manifest-guide.md @@ -23,7 +23,7 @@ Manifest 用来描述 Mod 元数据、目标游戏、需要复制的 payload 文 } ``` -Schema 主要用于结构、类型和基础值的校验;为保持兼容性,当前 Schema 允许额外字段,因此不要依赖它发现所有拼写错误。路径安全、操作组合、文件是否存在、需要唯一匹配的 asset 以及可选内容冲突等规则由 Unity Assets Patcher 的运行时验证,因此发布 Mod 前仍应使用 `check` 命令验证,并执行安装预览。 +Schema 主要用于结构、类型和基础值的校验;为保持兼容性,当前 Schema 允许额外字段,因此不要依赖它发现所有拼写错误。路径安全、操作组合、文件是否存在、需要唯一匹配的 asset 以及可选内容冲突等规则由 Unity Assets Patcher 的运行时验证,因此发布 Mod 前仍应在安装页面加载 Mod 包进行验证。 ### Mod 包结构 @@ -112,26 +112,11 @@ Mod.zip ### 发布前检查 -可以直接检查 JSON manifest,也可以检查 ZIP Mod 包中的 manifest: +先在支持 JSON Schema 的编辑器中校验 manifest,再打开**安装 Mod**页面,通过拖放或**选择文件**加载 ZIP 包。加载时会验证 manifest 并准备安装预览,不会修改游戏文件。 -```powershell -.\UnityAssetsPatcher.exe check --config .\manifest.json -.\UnityAssetsPatcher.exe check --config .\Mod.zip -``` - -检查通过后,建议先执行安装预览。CLI 中 `install preview` 不会修改游戏文件,`install apply` 必须显式传入 `--yes` 才会执行安装: - -```powershell -.\UnityAssetsPatcher.exe install preview --package .\Mod.zip --game-directory "C:\Games\Game" -.\UnityAssetsPatcher.exe install apply --package .\Mod.zip --game-directory "C:\Games\Game" --yes -``` - -如果要启用可选内容,重复传入 `--optional-group`;交互式界面则会逐组询问是否应用: +确认 Mod 信息和目标游戏目录,勾选需要的可选内容以验证对应组合。首次预览会根据 `game` 查找 Steam 安装目录;预览打开后,可以点击**更改**选择其他游戏目录。 -```powershell -.\UnityAssetsPatcher.exe install preview --package .\Mod.zip --game-directory "C:\Games\Game" ` - --optional-group "高清贴图" --optional-group "额外音效" -``` +发布前,请用 UABEA 检查 asset 匹配范围和字段值,并在测试用游戏副本上验证安装结果。点击**开始安装**才会应用 Mod,点击**重新选择**则返回文件选择页面。 ## Manifest 结构 @@ -149,7 +134,7 @@ Mod.zip | `targets` | 是 | 要处理的目标 `.assets` 文件分组。 | | `optional` | 否 | 附加内容分组。用户安装时可逐个选择是否应用,互不依赖、可自由组合。 | -旧 manifest 中的 `schemaVersion: 1` 可以暂时保留,但当前运行时不会读取它,也不会用它选择格式;新 manifest 不需要再添加该字段。CLI JSON 响应中的 `schemaVersion` 是输出协议版本,与 manifest 字段无关。 +旧 manifest 中的 `schemaVersion: 1` 可以暂时保留,但当前运行时不会读取它,也不会用它选择格式;新 manifest 不需要再添加该字段。 ### copyFiles @@ -169,7 +154,7 @@ Mod.zip - 安装时只使用 `source` 的文件名部分(如 `resources/modassets.resource` → `modassets.resource`)。 - payload 文件会复制到目标 assets 文件所在目录。 - 如果声明了 `copyFiles`,所有目标 assets 文件必须位于同一个目录。 -- payload 目标文件已存在时,安装不会覆盖该文件并会拒绝继续;预览只列出计划复制的目标,不会写入文件。 +- payload 目标文件已存在时,安装不会覆盖该文件并会拒绝继续;预览不会写入文件。 ### targets @@ -281,7 +266,7 @@ Mod.zip 如果需要 OR 关系,写多条结构相同的 patch,每条用不同的 `match` 值。匹配值支持字符串、数字、布尔值、对象和数组;数字按数值比较,字符串区分大小写,数组要求长度和元素都匹配。 -`match` 至少要包含一个字段。字段级 `set`/`add` 可以应用到所有匹配的 asset;`copyAsset` 要求来源和目标各自唯一,`replaceAsset` 还要求用于对应的 `matchField` 值在目标和源文件中都能唯一对应。发布前应通过安装预览确认匹配范围。 +`match` 至少要包含一个字段。字段级 `set`/`add` 可以应用到所有匹配的 asset;`copyAsset` 要求来源和目标各自唯一,`replaceAsset` 还要求用于对应的 `matchField` 值在目标和源文件中都能唯一对应。发布前应使用 UABEA 检查匹配范围,并在测试用游戏副本上验证安装结果。 #### componentType @@ -330,7 +315,7 @@ Mod.zip - `set` 的 key 是要写入的字段路径。 - 每个字段必须包含 `from` 和 `to`。 -- 写入前,当前字段值必须匹配 `from`;不匹配时预览会标记为 skipped,安装会拒绝继续。 +- 写入前,当前字段值必须匹配 `from`;不匹配时安装会拒绝继续。 - `to` 支持字符串、数字、布尔值。 - `to` 可以是标量数组,用于写入数组字段。 - `to` 可以是对象,此时会写入目标字段的直接子字段。 @@ -534,25 +519,25 @@ Mod.zip 1. 读取 zip 中唯一的 `manifest.json`。 2. 如果没有手动选择游戏目录,尝试使用 `game` 从 Steam 安装信息中解析游戏目录。 -3. 如果声明了 `optional`,逐个展示分组的 `name` 和 `description`,由用户选择是否应用;被选中的分组会与主体内容合并。在该确认环节按 Esc 会放弃整个安装。 -4. 根据 `targets[].file`(含被选中分组的目标)在游戏目录下定位目标 assets 文件。 -5. 生成 dry run 预览,展示目标文件、命中的 asset、将执行的变更和 payload 文件状态。 -6. 用户确认后,在备份仓库的 `.temp` 中预生成全部补丁输出、payload 和回滚快照,并记录变更前后的 SHA-256。 +3. 根据 `targets[].file` 在游戏目录下定位目标 assets 文件,准备安装预览。 +4. 桌面预览展示 Mod 信息、游戏目录和可选内容。如果声明了 `optional`,使用复选框展示各分组的 `name` 和 `description`,默认不勾选。 +5. 用户更改游戏目录或勾选可选内容时,重新生成安装预览;被选中的分组会与主体内容合并,并定位对应的目标 assets 文件。 +6. 用户点击**开始安装**后,在备份仓库的 `.temp` 中预生成全部补丁输出、payload 和回滚快照,并记录变更前后的 SHA-256。 7. 所有目标仍符合预期状态后,使用同目录临时文件原子替换 assets,并以无覆盖方式创建 payload。 8. 验证全部结果后,将层记录和原始 Mod 包原子提交到 `layers/`。 预览不会写入 assets 文件,也不会复制 payload 文件。 -本次应用的附加内容分组名会写入层记录 `layer.json`(未选择时不写该字段),安装结果输出中也会列出,便于事后确认这次安装包含了哪些附加内容。 +本次应用的附加内容分组名会写入层记录 `layer.json`(未选择时不写该字段),安装完成页面也会列出,便于事后确认这次安装包含了哪些附加内容。 ### Mod 卸载 -工具支持 Mod 卸载功能。备份仓库默认位于 `%LocalAppData%\UnityAssetsPatcher\backup`,其中 `base/` 保存首次触碰路径的基础快照,`layers//` 保存层记录和不可变的原始 Mod 包;`.temp` 仅在安装、卸载或中断恢复期间存在。旧程序目录中的 `backup` 不受支持且不会自动迁移。记录包含游戏目录实例指纹和该实例内的安装序号。多个 Mod 修改同一个 assets 文件时,工具会重放剩余层并恢复正确结果;卸载预览会列出需要重建、恢复基础或删除的文件,以及真实的补丁依赖。格式不受支持的仓库不会被读取、写入或自动迁移;用户只能在明确确认后清空仓库并初始化当前格式。清空不会还原游戏文件,并会永久失去原仓库提供的卸载和恢复能力。卸载时,工具会: +在**管理 Mod**页面点击**卸载**,程序会先检查文件完整性和剩余 Mod 的依赖,再通过对话框请求确认。备份仓库默认位于 `%LocalAppData%\UnityAssetsPatcher\backup`,其中 `base/` 保存首次触碰路径的基础快照,`layers//` 保存层记录和不可变的原始 Mod 包;`.temp` 用于保存事务的临时文件。记录包含游戏目录实例指纹和该实例内的安装序号。多个 Mod 修改同一个 assets 文件时,工具会重放剩余层并恢复正确结果。卸载时,工具会: 1. 按基础快照和剩余层重新合成被修改的 assets 文件。 2. 按基础快照恢复或删除安装时复制的 payload 文件。 -卸载功能需要层记录、原始 Mod 包和基础快照完整。如果其中任一项损坏或丢失,合成将停止并提示用户处理。恢复多个 assets 文件时,工具会在 `.temp` 创建临时回滚快照;卸载提交时,层目录会被原子移入 `.temp` 后清理。程序启动或下一次变更前会根据事务记录的前后哈希恢复中断操作;遇到无法判定的文件状态时不会覆盖或删除文件,并将仓库保持为只读。 +卸载功能需要层记录、原始 Mod 包和基础快照完整。如果其中任一项损坏或丢失,合成将停止并提示用户处理。恢复多个 assets 文件时,工具会在 `.temp` 创建临时回滚快照;卸载提交时,层目录会被原子移入 `.temp` 后清理。卸载成功后,管理页面会刷新已安装 Mod 列表。 ## 安全限制 @@ -591,7 +576,7 @@ Unity 不同版本、不同游戏版本、不同导出方式下的字段名和 ### 用多条 patch 表达 OR -如果多个目标 asset 需要同样修改,写多条 patch。这样预览输出和错误定位更清楚。 +如果多个目标 asset 需要同样修改,写多条 patch。这样规则和错误定位更清楚。 ### 显式声明 payload diff --git a/localization/Strings.resx b/localization/Strings.resx new file mode 100644 index 00000000..6ba2296b --- /dev/null +++ b/localization/Strings.resx @@ -0,0 +1,261 @@ + + + + text/microsoft-resx + + + 2.0 + + + Install Mod + + + Settings + + + Manage Mods + + + Navigation menu items + + + Drag a mod package into the area below, or use Select file. + + + Drop a mod package here + + + Release to preview this mod package + + + Mod package drop area + + + Select file + + + Drop a single .zip mod package + + + Unsupported drop + + + Supports .zip mod packages + + + Review the mod details. + + + Mod information + + + 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 + + + Could not read mod package + + + The package has no manifest.json file + + + The ZIP archive is invalid or damaged + + + The mod manifest uses an unsupported schema version + + + The mod package or its manifest is invalid + + + The mod package file could not be found + + + Access to the mod package was denied + + + Check the mod package and try again + + + Installing the mod. Please keep the application open + + + Installation complete + + + The mod has been installed successfully. You can now start the game + + + Installed optional content + + + Install another mod + + + Installation failed + + + Check the mod package, game directory and file access permissions, then try again + + + The mod package or game files have changed since the preview. Select the mod package again to refresh the preview + + + The game files require recovery. Restore them from a backup before trying again + + + View installed mods and uninstall those you no longer need. + + + Refresh + + + Uninstall + + + Installed on + + + Loading installed mods… + + + Checking whether the mod can be uninstalled… + + + Uninstalling the mod. Please keep the application open + + + No installed mods + + + Mods installed with this application will appear here. + + + Could not refresh installed mods + + + The list could not be updated. Resolve the issue and select Refresh to try again. + + + Could not uninstall the mod + + + Mod uninstalled + + + Uninstalling this mod would prevent the following mods from working. Uninstall the dependent mods first: + + + The game files have changed, are missing or cannot be read. Restore the expected files and check access permissions before trying again + + + Another operation is using the mod repository. Wait for it to finish and try again + + + An interrupted operation requires recovery. Restore the game files from a backup before trying again + + + The mod repository uses an unsupported format. Use a compatible version of the application + + + This mod is no longer in the installed list. Select Refresh to update the list + + + The game files or saved mod data do not match the installation, or another mod depends on it. Check the files and mod dependencies before trying again + + + Access to the game files or mod repository was denied. Check file access permissions and try again + + + The game directory could not be found. Restore it to its original location and try again + + + The operation could not be completed. Check the game files and mod repository, then try again + + + Close notification + + + Logging + + + Verbose logging + + + Enable verbose logging; recommended only when assisting developers with debugging + + + Log path + + + Open folder + + + Uninstall mod? + + + Uninstall “{0}” ({1})? The game files will be updated to remove this mod’s changes. This action cannot be undone! + + + Waiting for confirmation + + + Cancel + + + Updates + + + Check for a new version + + + Check for updates + + + Update checks are unavailable in development builds. + + + Could not check for updates. Please try again later + + + You are using the latest version + + + Update available + + + Version {0} is available. Open the release page to download it? + + + Open release page + + diff --git a/localization/Strings.zh-CN.resx b/localization/Strings.zh-CN.resx new file mode 100644 index 00000000..5bb1f7d5 --- /dev/null +++ b/localization/Strings.zh-CN.resx @@ -0,0 +1,261 @@ + + + + text/microsoft-resx + + + 2.0 + + + 安装 Mod + + + 设置 + + + 管理 Mod + + + 导航菜单项 + + + 拖拽 Mod 包到下方区域,或使用“选择文件”按钮。 + + + 拖拽 Mod 包到这里 + + + 松开鼠标以预览此 Mod 包 + + + Mod 包拖放区域 + + + 选择文件 + + + 请拖入单个 .zip Mod 包 + + + 无法接收拖放内容 + + + 支持 .zip 格式的 Mod 包 + + + 确认 Mod 信息。 + + + Mod 信息 + + + 作者 + + + 目标游戏 + + + 更改 + + + 可选内容 + + + 重新选择 + + + 开始安装 + + + 选择 Mod 包 + + + Mod 包 + + + 选择游戏目录 + + + 正在读取 Mod 包… + + + 自定义游戏目录 + + + 无法读取 Mod 包 + + + Mod 包中缺少 manifest.json 文件 + + + ZIP 压缩包无效或已损坏 + + + Mod 清单使用了不支持的架构版本 + + + Mod 包或清单内容无效 + + + 找不到 Mod 包文件 + + + 没有权限访问 Mod 包 + + + 请检查 Mod 包后重试 + + + 正在安装 Mod,请保持应用程序开启 + + + 安装完成 + + + Mod 已成功安装,现在可以启动游戏 + + + 已安装的可选内容 + + + 继续安装 + + + 安装失败 + + + 请检查 Mod 包、游戏目录及文件的访问权限,然后重试 + + + Mod 包或游戏文件在预览后发生了变化,请重新选择模组包以刷新预览 + + + 游戏文件需要恢复,请先从备份恢复文件,再尝试安装 + + + 查看已安装的 Mod,卸载不再需要的内容。 + + + 刷新 + + + 卸载 + + + 安装时间 + + + 正在读取已安装的 Mod… + + + 正在检查是否可以卸载此 Mod… + + + 正在卸载 Mod,请保持应用程序开启 + + + 暂无已安装的 Mod + + + 通过本应用安装的 Mod 将显示在这里。 + + + 无法刷新已安装的 Mod + + + 未能更新列表,请解决问题后点击“刷新”重试。 + + + 无法卸载 Mod + + + Mod 已卸载 + + + 卸载此 Mod 会导致以下 Mod 无法正常工作,请先卸载依赖它的 Mod: + + + 游戏文件已被修改、缺失或无法读取,请恢复文件并检查访问权限后重试 + + + 其他操作正在使用 Mod 仓库,请等待操作完成后重试 + + + 中断的操作需要恢复,请先从备份恢复游戏文件,再重试 + + + Mod 仓库使用了不支持的格式,请使用兼容的应用版本 + + + 此 Mod 的安装记录已不存在,请点击“刷新”更新列表 + + + 游戏文件或保存的 Mod 数据与安装记录不一致,或其他 Mod 依赖此 Mod,请检查文件和 Mod 依赖关系后重试 + + + 没有权限访问游戏文件或 Mod 仓库,请检查文件访问权限后重试 + + + 找不到游戏目录,请将其恢复到原来的位置后重试 + + + 无法完成操作,请检查游戏文件及 Mod 仓库后重试 + + + 关闭通知 + + + 日志 + + + 详细日志 + + + 开启详细日志,建议仅在协助开发者调试时开启 + + + 日志路径 + + + 打开文件夹 + + + 卸载 Mod? + + + 确定卸载“{0}”({1})吗?游戏文件将更新,以移除此 Mod 的修改。该操作无法撤销! + + + 等待确认 + + + 取消 + + + 更新 + + + 检查新版本 + + + 检查更新 + + + 开发版本不支持检查更新。 + + + 检查更新失败,请稍后重试 + + + 当前已是最新版本 + + + 发现新版本 + + + 发现新版本 {0},是否前往发布页下载? + + + 前往发布页 + + 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/ApplicationServiceCollectionExtensions.cs b/src/UnityAssetsPatcher.Application/ApplicationServiceCollectionExtensions.cs index 798967fb..5020e1b7 100644 --- a/src/UnityAssetsPatcher.Application/ApplicationServiceCollectionExtensions.cs +++ b/src/UnityAssetsPatcher.Application/ApplicationServiceCollectionExtensions.cs @@ -70,7 +70,10 @@ public IServiceCollection AddUnityAssetsPatcherUpdates() { ArgumentNullException.ThrowIfNull(services); - services.AddSingleton(); + services.AddSingleton(provider => new UpdateCheckService( + provider.GetRequiredService(), + AppConfig.DisplayVersion, + provider.GetRequiredService>())); return services; } 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.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.Application/Updates/SemanticVersion.cs b/src/UnityAssetsPatcher.Application/Updates/SemanticVersion.cs new file mode 100644 index 00000000..ab83e643 --- /dev/null +++ b/src/UnityAssetsPatcher.Application/Updates/SemanticVersion.cs @@ -0,0 +1,51 @@ +using System.Globalization; + +namespace UnityAssetsPatcher.Application.Updates; + +internal readonly record struct SemanticVersion(int Major, int Minor, int Patch) + : IComparable +{ + public static bool TryParse(string? value, out SemanticVersion version) + { + version = default; + + if (string.IsNullOrWhiteSpace(value) || !value.StartsWith('v')) + { + return false; + } + + string versionText = value[1..]; + int metadataIndex = versionText.IndexOf('+', StringComparison.Ordinal); + + if (metadataIndex >= 0) + { + versionText = versionText[..metadataIndex]; + } + + string[] coreParts = versionText.Split('.'); + + if (coreParts.Length != 3 || + !TryParsePart(coreParts[0], out int major) || + !TryParsePart(coreParts[1], out int minor) || + !TryParsePart(coreParts[2], out int patch)) + { + return false; + } + + version = new SemanticVersion(major, minor, patch); + + return true; + } + + public int CompareTo(SemanticVersion other) + { + int comparison = Major.CompareTo(other.Major); + comparison = comparison != 0 ? comparison : Minor.CompareTo(other.Minor); + return comparison != 0 ? comparison : Patch.CompareTo(other.Patch); + } + + private static bool TryParsePart(string value, out int part) + { + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out part); + } +} diff --git a/src/UnityAssetsPatcher.Application/Updates/UpdateCheckException.cs b/src/UnityAssetsPatcher.Application/Updates/UpdateCheckException.cs new file mode 100644 index 00000000..26e48c18 --- /dev/null +++ b/src/UnityAssetsPatcher.Application/Updates/UpdateCheckException.cs @@ -0,0 +1,9 @@ +namespace UnityAssetsPatcher.Application.Updates; + +public sealed class UpdateCheckException : Exception +{ + public UpdateCheckException(Exception innerException) : base("The update check failed.", innerException) + { + ArgumentNullException.ThrowIfNull(innerException); + } +} diff --git a/src/UnityAssetsPatcher.Application/Updates/UpdateCheckModule.cs b/src/UnityAssetsPatcher.Application/Updates/UpdateCheckModule.cs deleted file mode 100644 index f4b61524..00000000 --- a/src/UnityAssetsPatcher.Application/Updates/UpdateCheckModule.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; -using UnityAssetsPatcher.Application.Operations; - -namespace UnityAssetsPatcher.Application.Updates; - -public sealed class UpdateCheckModule -{ - private readonly IUpdateChecker _updateChecker; - private readonly ILogger _logger; - - public UpdateCheckModule(IUpdateChecker updateChecker, ILogger logger) - { - ArgumentNullException.ThrowIfNull(updateChecker); - ArgumentNullException.ThrowIfNull(logger); - - _updateChecker = updateChecker; - _logger = logger; - } - - public async Task> CheckForUpdateAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - UpdateInfo? update = await _updateChecker - .CheckForUpdateAsync(cancellationToken) - .ConfigureAwait(false); - - return new OperationSucceeded(update); - } - catch (HttpRequestException exception) - { - if (exception.StatusCode is { } statusCode) - { - UpdateLog.UpdateRequestRejected(_logger, exception, (int)statusCode); - } - else - { - UpdateLog.UpdateRequestFailed(_logger, exception, exception.Message); - } - - return Failure(exception.Message); - } - catch (JsonException exception) - { - UpdateLog.UpdateManifestRejectedAsInvalidJson(_logger, exception); - - return Failure(exception.Message); - } - catch (InvalidDataException exception) - { - UpdateLog.UpdateManifestRejected(_logger, exception, exception.Message); - - return Failure(exception.Message); - } - catch (IOException exception) - { - UpdateLog.UpdateRequestFailed(_logger, exception, exception.Message); - - return Failure(exception.Message); - } - catch (OperationCanceledException exception) when (exception.InnerException is TimeoutException) - { - UpdateLog.UpdateRequestFailed(_logger, exception, exception.Message); - - return Failure(exception.Message); - } - } - - private static OperationFailed Failure(string? detail) - { - IReadOnlyDictionary? parameters = string.IsNullOrWhiteSpace(detail) - ? null - : new Dictionary { ["detail"] = detail }; - - return new OperationFailed(new OperationError(UpdateErrorCodes.CheckFailed, parameters)); - } -} diff --git a/src/UnityAssetsPatcher.Application/Updates/UpdateCheckService.cs b/src/UnityAssetsPatcher.Application/Updates/UpdateCheckService.cs new file mode 100644 index 00000000..fa8522ac --- /dev/null +++ b/src/UnityAssetsPatcher.Application/Updates/UpdateCheckService.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace UnityAssetsPatcher.Application.Updates; + +public sealed class UpdateCheckService +{ + private readonly IUpdateChecker _releaseClient; + private readonly string _currentVersion; + private readonly ILogger _logger; + + public UpdateCheckService(IUpdateChecker releaseClient, string currentVersion, ILogger logger) + { + ArgumentNullException.ThrowIfNull(releaseClient); + ArgumentException.ThrowIfNullOrWhiteSpace(currentVersion); + ArgumentNullException.ThrowIfNull(logger); + + _releaseClient = releaseClient; + _currentVersion = currentVersion; + _logger = logger; + } + + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!SemanticVersion.TryParse(_currentVersion, out SemanticVersion currentVersion)) + { + UpdateLog.UpdateCheckSkipped(_logger); + + return null; + } + + UpdateLog.UpdateCheckStarted(_logger); + + try + { + UpdateInfo release = await _releaseClient + .CheckForUpdateAsync(cancellationToken) + .ConfigureAwait(false); + + if (!SemanticVersion.TryParse(release.Version, out SemanticVersion latestVersion)) + { + throw new InvalidDataException("The update release contains an invalid version."); + } + + if (latestVersion.CompareTo(currentVersion) <= 0) + { + UpdateLog.UpdateCheckCompletedWithoutUpdate(_logger); + + return null; + } + + UpdateLog.UpdateCheckCompletedWithUpdate( + _logger, + _currentVersion, + release.Version); + + return release; + } + catch (HttpRequestException exception) + { + if (exception.StatusCode is { } statusCode) + { + string reason = statusCode switch + { + HttpStatusCode.TooManyRequests => "API rate limit exceeded", + HttpStatusCode.Forbidden => "request forbidden; HTTP 403 alone does not identify the cause", + HttpStatusCode.Unauthorized => "authentication required or rejected", + HttpStatusCode.NotFound => "repository or latest release not found or not accessible", + >= HttpStatusCode.InternalServerError => "server error", + _ => "unsuccessful HTTP response" + }; + UpdateLog.UpdateRequestRejected(_logger, exception, (int)statusCode, reason); + } + else + { + UpdateLog.UpdateRequestFailed(_logger, exception, exception.HttpRequestError, exception.Message); + } + + throw new UpdateCheckException(exception); + } + catch (JsonException exception) + { + UpdateLog.UpdateResponseRejectedAsInvalidJson(_logger, exception); + + throw new UpdateCheckException(exception); + } + catch (InvalidDataException exception) + { + UpdateLog.UpdateResponseRejected(_logger, exception, exception.Message); + + throw new UpdateCheckException(exception); + } + catch (IOException exception) + { + UpdateLog.UpdateResponseReadFailed(_logger, exception, exception.Message); + + throw new UpdateCheckException(exception); + } + catch (OperationCanceledException exception) when (exception.InnerException is TimeoutException) + { + UpdateLog.UpdateRequestTimedOut(_logger, exception); + + throw new UpdateCheckException(exception); + } + } +} diff --git a/src/UnityAssetsPatcher.Application/Updates/UpdateContracts.cs b/src/UnityAssetsPatcher.Application/Updates/UpdateContracts.cs index a49930a0..e56f8865 100644 --- a/src/UnityAssetsPatcher.Application/Updates/UpdateContracts.cs +++ b/src/UnityAssetsPatcher.Application/Updates/UpdateContracts.cs @@ -1,21 +1,8 @@ namespace UnityAssetsPatcher.Application.Updates; -public sealed record UpdateInfo(string Version, Uri ReleaseUrl, Uri DownloadUrl, string Sha256); +public sealed record UpdateInfo(string Version, Uri ReleaseUrl); public interface IUpdateChecker { - /// Checks the remote manifest for a release newer than the current version. - /// Retrieving the manifest failed. - /// Reading the manifest failed. - /// The manifest is not valid JSON. - /// - /// The manifest exceeds the size limit or contains invalid fields or an invalid version. - /// - /// - /// The check was canceled, or the HTTP request timed out. HTTP timeouts carry a - /// as the direct , - /// following the HttpClient contract. - /// Other cancellation exceptions must propagate to the lifetime owner unchanged. - /// - public Task CheckForUpdateAsync(CancellationToken cancellationToken = default); + public Task CheckForUpdateAsync(CancellationToken cancellationToken = default); } diff --git a/src/UnityAssetsPatcher.Application/Updates/UpdateErrorCodes.cs b/src/UnityAssetsPatcher.Application/Updates/UpdateErrorCodes.cs deleted file mode 100644 index 39adfa35..00000000 --- a/src/UnityAssetsPatcher.Application/Updates/UpdateErrorCodes.cs +++ /dev/null @@ -1,8 +0,0 @@ -using UnityAssetsPatcher.Application.Operations; - -namespace UnityAssetsPatcher.Application.Updates; - -public static class UpdateErrorCodes -{ - public static OperationErrorCode CheckFailed { get; } = new("update.check_failed"); -} diff --git a/src/UnityAssetsPatcher.Application/Updates/UpdateLog.cs b/src/UnityAssetsPatcher.Application/Updates/UpdateLog.cs index 54eb27c4..8abf115c 100644 --- a/src/UnityAssetsPatcher.Application/Updates/UpdateLog.cs +++ b/src/UnityAssetsPatcher.Application/Updates/UpdateLog.cs @@ -4,31 +4,80 @@ namespace UnityAssetsPatcher.Application.Updates; internal static partial class UpdateLog { + [LoggerMessage( + EventId = 3000, + EventName = nameof(UpdateCheckSkipped), + Level = LogLevel.Information, + Message = "Skipping update check because current version is a development version")] + public static partial void UpdateCheckSkipped(ILogger logger); + + [LoggerMessage( + EventId = 3001, + EventName = nameof(UpdateCheckStarted), + Level = LogLevel.Information, + Message = "Checking update...")] + public static partial void UpdateCheckStarted(ILogger logger); + + [LoggerMessage( + EventId = 3002, + EventName = nameof(UpdateCheckCompletedWithoutUpdate), + Level = LogLevel.Information, + Message = + "Update check completed: no update available")] + public static partial void UpdateCheckCompletedWithoutUpdate(ILogger logger); + + [LoggerMessage( + EventId = 3003, + EventName = nameof(UpdateCheckCompletedWithUpdate), + Level = LogLevel.Information, + Message = + "Update check completed: new version is available: {CurrentVersion} -> {LatestVersion}")] + public static partial void UpdateCheckCompletedWithUpdate( + ILogger logger, + string currentVersion, + string latestVersion); + [LoggerMessage( EventId = 3004, EventName = nameof(UpdateRequestFailed), Level = LogLevel.Warning, - Message = "Update check failed: {ErrorMessage}")] - public static partial void UpdateRequestFailed(ILogger logger, Exception exception, string errorMessage); + Message = "Update check failed: GitHub API network request failed ({RequestError}): {ErrorMessage}")] + public static partial void + UpdateRequestFailed(ILogger logger, Exception exception, HttpRequestError requestError, string errorMessage); [LoggerMessage( EventId = 3005, EventName = nameof(UpdateRequestRejected), Level = LogLevel.Warning, - Message = "Update check failed: the request returned HTTP status {StatusCode}.")] - public static partial void UpdateRequestRejected(ILogger logger, Exception exception, int statusCode); + Message = "Update check failed: GitHub API returned HTTP {StatusCode} ({Reason})")] + public static partial void + UpdateRequestRejected(ILogger logger, Exception exception, int statusCode, string reason); [LoggerMessage( EventId = 3006, - EventName = nameof(UpdateManifestRejected), + EventName = nameof(UpdateResponseRejected), Level = LogLevel.Warning, - Message = "Update check failed: manifest was rejected: {ErrorMessage}")] - public static partial void UpdateManifestRejected(ILogger logger, Exception exception, string errorMessage); + Message = "Update check failed: GitHub API response failed validation: {ErrorMessage}")] + public static partial void UpdateResponseRejected(ILogger logger, Exception exception, string errorMessage); [LoggerMessage( EventId = 3007, - EventName = nameof(UpdateManifestRejectedAsInvalidJson), + EventName = nameof(UpdateResponseRejectedAsInvalidJson), + Level = LogLevel.Warning, + Message = "Update check failed: GitHub API response is not valid JSON")] + public static partial void UpdateResponseRejectedAsInvalidJson(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 3008, + EventName = nameof(UpdateRequestTimedOut), + Level = LogLevel.Warning, + Message = "Update check failed: GitHub API request timed out")] + public static partial void UpdateRequestTimedOut(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 3009, + EventName = nameof(UpdateResponseReadFailed), Level = LogLevel.Warning, - Message = "Update check failed: manifest was rejected because it is not valid JSON.")] - public static partial void UpdateManifestRejectedAsInvalidJson(ILogger logger, Exception exception); + Message = "Update check failed: reading the GitHub API response failed: {ErrorMessage}")] + public static partial void UpdateResponseReadFailed(ILogger logger, Exception exception, string errorMessage); } 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.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/UnityAssetsPatcher.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 85f450e6..21f4a3fa 100644 --- a/src/UnityAssetsPatcher.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/UnityAssetsPatcher.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -23,11 +23,9 @@ public static IServiceCollection AddUnityAssetsPatcherGitHubUpdates(this IServic { ArgumentNullException.ThrowIfNull(services); - services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(10); }); - services.AddTransient(provider => new GitHubUpdateChecker( - provider.GetRequiredService(), - AppConfig.DisplayVersion, - provider.GetRequiredService>())); + services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(10); }); + services.AddTransient(provider => + provider.GetRequiredService()); return services; } diff --git a/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateChecker.cs b/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateChecker.cs deleted file mode 100644 index 9da757c6..00000000 --- a/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateChecker.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Microsoft.Extensions.Logging; -using UnityAssetsPatcher.Application.Updates; - -namespace UnityAssetsPatcher.Infrastructure.Updates; - -internal sealed class GitHubUpdateChecker : IUpdateChecker -{ - private readonly GitHubUpdateManifestClient _manifestClient; - private readonly string _currentVersion; - private readonly ILogger _logger; - - public GitHubUpdateChecker( - GitHubUpdateManifestClient manifestClient, - string currentVersion, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(manifestClient); - ArgumentException.ThrowIfNullOrWhiteSpace(currentVersion); - ArgumentNullException.ThrowIfNull(logger); - - _manifestClient = manifestClient; - _currentVersion = currentVersion; - _logger = logger; - } - - public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (!SemanticVersion.TryParse(_currentVersion, out SemanticVersion currentVersion)) - { - UpdateLog.UpdateCheckSkipped(_logger); - - return null; - } - - UpdateInfo manifest = await _manifestClient - .FetchAsync(cancellationToken) - .ConfigureAwait(false); - - if (!SemanticVersion.TryParse(manifest.Version, out SemanticVersion latestVersion)) - { - throw new InvalidDataException("The update manifest contains an invalid version."); - } - - if (latestVersion.CompareTo(currentVersion) <= 0) - { - UpdateLog.UpdateCheckCompletedWithoutUpdate( - _logger, - _currentVersion, - manifest.Version); - - return null; - } - - UpdateLog.UpdateCheckCompletedWithUpdate( - _logger, - _currentVersion, - manifest.Version); - - return manifest; - } -} diff --git a/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateClient.cs b/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateClient.cs new file mode 100644 index 00000000..71108dbd --- /dev/null +++ b/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateClient.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Headers; +using UnityAssetsPatcher.Application; +using UnityAssetsPatcher.Application.Updates; + +namespace UnityAssetsPatcher.Infrastructure.Updates; + +internal sealed class GitHubUpdateClient : IUpdateChecker +{ + internal const string LatestReleaseUrl = + "https://api.github.com/repos/CnBarrier404/UnityAssetsPatcher/releases/latest"; + + internal const int MaximumResponseSize = 1024 * 1024; + + private readonly HttpClient _httpClient; + + public GitHubUpdateClient(HttpClient httpClient) + { + ArgumentNullException.ThrowIfNull(httpClient); + + _httpClient = httpClient; + } + + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + using HttpRequestMessage request = CreateRequest(); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength is > MaximumResponseSize) + { + throw new InvalidDataException( + $"The GitHub release response exceeds the maximum size of {MaximumResponseSize} bytes."); + } + + await using Stream contentStream = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + + GitHubUpdateReadResult readResult = await GitHubUpdateReader.ReadAsync( + contentStream, + MaximumResponseSize, + cancellationToken).ConfigureAwait(false); + + switch (readResult.Status) + { + case GitHubUpdateReadStatus.TooLarge: + throw new InvalidDataException( + $"The GitHub release response exceeds the maximum size of {MaximumResponseSize} bytes."); + case GitHubUpdateReadStatus.Invalid: + throw new InvalidDataException("The GitHub release response does not match the expected format."); + case GitHubUpdateReadStatus.Success: + break; + default: + throw new ArgumentOutOfRangeException(); + } + + return readResult.Release ?? throw new InvalidOperationException( + "The GitHub release response reader returned no release for a successful result."); + } + + private static HttpRequestMessage CreateRequest() + { + var request = new HttpRequestMessage(HttpMethod.Get, LatestReleaseUrl); + + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + request.Headers.UserAgent.ParseAdd(AppConfig.Name); + + return request; + } +} diff --git a/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateManifestClient.cs b/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateManifestClient.cs deleted file mode 100644 index bb684910..00000000 --- a/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateManifestClient.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Net.Http.Headers; -using Microsoft.Extensions.Logging; -using UnityAssetsPatcher.Application; -using UnityAssetsPatcher.Application.Updates; - -namespace UnityAssetsPatcher.Infrastructure.Updates; - -internal sealed class GitHubUpdateManifestClient -{ - internal const string UpdateManifestUrl = - "https://github.com/CnBarrier404/UnityAssetsPatcher/releases/latest/download/update.json"; - - internal const int MaximumManifestSize = 64 * 1024; - - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - - public GitHubUpdateManifestClient(HttpClient httpClient, ILogger logger) - { - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(logger); - - _httpClient = httpClient; - _logger = logger; - } - - public async Task FetchAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - UpdateLog.UpdateCheckStarted(_logger); - - return await FetchLatestManifestAsync(cancellationToken).ConfigureAwait(false); - } - - private async Task FetchLatestManifestAsync(CancellationToken cancellationToken) - { - using HttpRequestMessage request = CreateRequest(); - using HttpResponseMessage response = await _httpClient.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken).ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - - if (response.Content.Headers.ContentLength is > MaximumManifestSize) - { - throw new InvalidDataException( - $"The update manifest exceeds the maximum size of {MaximumManifestSize} bytes."); - } - - await using Stream contentStream = await response.Content - .ReadAsStreamAsync(cancellationToken) - .ConfigureAwait(false); - - UpdateManifestReadResult readResult = await UpdateManifestReader.ReadAsync( - contentStream, - MaximumManifestSize, - cancellationToken).ConfigureAwait(false); - - switch (readResult.Status) - { - case UpdateManifestReadStatus.TooLarge: - throw new InvalidDataException( - $"The update manifest exceeds the maximum size of {MaximumManifestSize} bytes."); - case UpdateManifestReadStatus.Invalid: - throw new InvalidDataException("The update manifest does not match the expected format."); - case UpdateManifestReadStatus.Success: - break; - default: - throw new ArgumentOutOfRangeException(); - } - - return readResult.Manifest ?? throw new InvalidOperationException( - "The update manifest reader returned no manifest for a successful result."); - } - - private static HttpRequestMessage CreateRequest() - { - var request = new HttpRequestMessage(HttpMethod.Get, UpdateManifestUrl); - - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - request.Headers.UserAgent.ParseAdd(AppConfig.Identifier); - - return request; - } -} diff --git a/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateReader.cs b/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateReader.cs new file mode 100644 index 00000000..cc8029c6 --- /dev/null +++ b/src/UnityAssetsPatcher.Infrastructure/Updates/GitHubUpdateReader.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using UnityAssetsPatcher.Application.Updates; + +namespace UnityAssetsPatcher.Infrastructure.Updates; + +internal enum GitHubUpdateReadStatus +{ + Success, + TooLarge, + Invalid +} + +internal sealed record GitHubUpdateReadResult(UpdateInfo? Release, GitHubUpdateReadStatus Status); + +internal static class GitHubUpdateReader +{ + public static async Task ReadAsync( + Stream contentStream, + int maximumSize, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(contentStream); + + using MemoryStream releaseBuffer = new(); + + byte[] buffer = new byte[8192]; + + while (true) + { + int bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + + if (bytesRead == 0) + { + break; + } + + if (releaseBuffer.Length + bytesRead > maximumSize) + { + return new GitHubUpdateReadResult(null, GitHubUpdateReadStatus.TooLarge); + } + + releaseBuffer.Write(buffer, 0, bytesRead); + } + + releaseBuffer.Position = 0; + + using JsonDocument document = await JsonDocument.ParseAsync( + releaseBuffer, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return TryReadRelease(document.RootElement, out UpdateInfo? release) + ? new GitHubUpdateReadResult(release, GitHubUpdateReadStatus.Success) + : new GitHubUpdateReadResult(null, GitHubUpdateReadStatus.Invalid); + } + + private static bool TryReadRelease(JsonElement root, out UpdateInfo? release) + { + release = null; + + if (root.ValueKind != JsonValueKind.Object || + !TryReadString(root, "tag_name", out string? version) || + !TryReadHttpsUri(root, "html_url", out Uri? releaseUrl)) + { + return false; + } + + release = new UpdateInfo( + version!, + releaseUrl!); + + return true; + } + + private static bool TryReadString(JsonElement root, string propertyName, out string? value) + { + value = null; + + if (!root.TryGetProperty(propertyName, out JsonElement element) || + element.ValueKind != JsonValueKind.String) + { + return false; + } + + value = element.GetString(); + + return !string.IsNullOrWhiteSpace(value); + } + + private static bool TryReadHttpsUri(JsonElement root, string propertyName, out Uri? uri) + { + uri = null; + + return TryReadString(root, propertyName, out string? value) && + Uri.TryCreate(value, UriKind.Absolute, out uri) && + uri.Scheme == Uri.UriSchemeHttps; + } +} diff --git a/src/UnityAssetsPatcher.Infrastructure/Updates/SemanticVersion.cs b/src/UnityAssetsPatcher.Infrastructure/Updates/SemanticVersion.cs deleted file mode 100644 index 6fa61eae..00000000 --- a/src/UnityAssetsPatcher.Infrastructure/Updates/SemanticVersion.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Globalization; - -namespace UnityAssetsPatcher.Infrastructure.Updates; - -internal readonly record struct SemanticVersion(int Major, int Minor, int Patch, string[] Prerelease) - : IComparable -{ - public static bool TryParse(string? value, out SemanticVersion version) - { - version = default; - - if (string.IsNullOrWhiteSpace(value) || !value.StartsWith('v')) - { - return false; - } - - string versionText = value[1..]; - int metadataIndex = versionText.IndexOf('+', StringComparison.Ordinal); - - if (metadataIndex >= 0) - { - versionText = versionText[..metadataIndex]; - } - - string[] versionAndPrerelease = versionText.Split('-', 2); - string[] coreParts = versionAndPrerelease[0].Split('.'); - - if (coreParts.Length != 3 || - !TryParsePart(coreParts[0], out int major) || - !TryParsePart(coreParts[1], out int minor) || - !TryParsePart(coreParts[2], out int patch)) - { - return false; - } - - string[] prerelease = versionAndPrerelease.Length == 1 - ? [] - : versionAndPrerelease[1].Split('.'); - - if (prerelease.Any(string.IsNullOrWhiteSpace)) - { - return false; - } - - version = new SemanticVersion(major, minor, patch, prerelease); - - return true; - } - - public int CompareTo(SemanticVersion other) - { - int coreComparison = Major.CompareTo(other.Major); - coreComparison = coreComparison != 0 ? coreComparison : Minor.CompareTo(other.Minor); - coreComparison = coreComparison != 0 ? coreComparison : Patch.CompareTo(other.Patch); - - if (coreComparison != 0) - { - return coreComparison; - } - - if (Prerelease.Length == 0 || other.Prerelease.Length == 0) - { - return other.Prerelease.Length.CompareTo(Prerelease.Length); - } - - for (int index = 0; index < Math.Min(Prerelease.Length, other.Prerelease.Length); index++) - { - int identifierComparison = ComparePrereleaseIdentifier(Prerelease[index], other.Prerelease[index]); - - if (identifierComparison != 0) - { - return identifierComparison; - } - } - - return Prerelease.Length.CompareTo(other.Prerelease.Length); - } - - private static bool TryParsePart(string value, out int part) - { - return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out part); - } - - private static int ComparePrereleaseIdentifier(string left, string right) - { - bool leftIsNumber = int.TryParse(left, NumberStyles.None, CultureInfo.InvariantCulture, out int leftNumber); - bool rightIsNumber = int.TryParse(right, NumberStyles.None, CultureInfo.InvariantCulture, out int rightNumber); - - if (leftIsNumber && rightIsNumber) - { - return leftNumber.CompareTo(rightNumber); - } - - if (leftIsNumber != rightIsNumber) - { - return leftIsNumber ? -1 : 1; - } - - return string.Compare(left, right, StringComparison.Ordinal); - } -} diff --git a/src/UnityAssetsPatcher.Infrastructure/Updates/UpdateLog.cs b/src/UnityAssetsPatcher.Infrastructure/Updates/UpdateLog.cs deleted file mode 100644 index a7c27ca8..00000000 --- a/src/UnityAssetsPatcher.Infrastructure/Updates/UpdateLog.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace UnityAssetsPatcher.Infrastructure.Updates; - -internal static partial class UpdateLog -{ - [LoggerMessage( - EventId = 3000, - EventName = nameof(UpdateCheckSkipped), - Level = LogLevel.Information, - Message = "Skipping update check because current version is a development version.")] - public static partial void UpdateCheckSkipped(ILogger logger); - - [LoggerMessage( - EventId = 3001, - EventName = nameof(UpdateCheckStarted), - Level = LogLevel.Information, - Message = "Checking for updates...")] - public static partial void UpdateCheckStarted(ILogger logger); - - [LoggerMessage( - EventId = 3002, - EventName = nameof(UpdateCheckCompletedWithoutUpdate), - Level = LogLevel.Information, - Message = - "Update check completed: no update available (current version {CurrentVersion}, latest version {LatestVersion}).")] - public static partial void UpdateCheckCompletedWithoutUpdate( - ILogger logger, - string currentVersion, - string latestVersion); - - [LoggerMessage( - EventId = 3003, - EventName = nameof(UpdateCheckCompletedWithUpdate), - Level = LogLevel.Information, - Message = - "Update check completed: new version {LatestVersion} is available (current version {CurrentVersion}).")] - public static partial void UpdateCheckCompletedWithUpdate( - ILogger logger, - string currentVersion, - string latestVersion); -} diff --git a/src/UnityAssetsPatcher.Infrastructure/Updates/UpdateManifestReader.cs b/src/UnityAssetsPatcher.Infrastructure/Updates/UpdateManifestReader.cs deleted file mode 100644 index 14dc3cf1..00000000 --- a/src/UnityAssetsPatcher.Infrastructure/Updates/UpdateManifestReader.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Text.Json; -using System.Text.RegularExpressions; -using UnityAssetsPatcher.Application.Updates; - -namespace UnityAssetsPatcher.Infrastructure.Updates; - -internal enum UpdateManifestReadStatus -{ - Success, - TooLarge, - Invalid -} - -internal sealed record UpdateManifestReadResult( - UpdateInfo? Manifest, - UpdateManifestReadStatus Status); - -internal static partial class UpdateManifestReader -{ - private static readonly Regex Sha256Pattern = CreateSha256Pattern(); - - public static async Task ReadAsync( - Stream contentStream, - int maximumSize, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(contentStream); - - using MemoryStream manifestBuffer = new(); - - byte[] buffer = new byte[8192]; - - while (true) - { - int bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - - if (bytesRead == 0) - { - break; - } - - if (manifestBuffer.Length + bytesRead > maximumSize) - { - return new UpdateManifestReadResult(null, UpdateManifestReadStatus.TooLarge); - } - - manifestBuffer.Write(buffer, 0, bytesRead); - } - - manifestBuffer.Position = 0; - - using JsonDocument document = await JsonDocument.ParseAsync( - manifestBuffer, - cancellationToken: cancellationToken).ConfigureAwait(false); - - return TryReadManifest(document.RootElement, out UpdateInfo? manifest) - ? new UpdateManifestReadResult(manifest, UpdateManifestReadStatus.Success) - : new UpdateManifestReadResult(null, UpdateManifestReadStatus.Invalid); - } - - private static bool TryReadManifest(JsonElement root, out UpdateInfo? manifest) - { - manifest = null; - - if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("schemaVersion", out JsonElement schemaElement) || - !schemaElement.TryGetInt32(out int schemaVersion) || - schemaVersion != 1 || - !TryReadString(root, "version", out string? version) || - !TryReadHttpsUri(root, "releaseUrl", out Uri? releaseUrl) || - !TryReadHttpsUri(root, "downloadUrl", out Uri? downloadUrl) || - !TryReadString(root, "sha256", out string? sha256) || - !Sha256Pattern.IsMatch(sha256!)) - { - return false; - } - - manifest = new UpdateInfo( - version!, - releaseUrl!, - downloadUrl!, - sha256!.ToLowerInvariant()); - - return true; - } - - private static bool TryReadString(JsonElement root, string propertyName, out string? value) - { - value = null; - - if (!root.TryGetProperty(propertyName, out JsonElement element) || - element.ValueKind != JsonValueKind.String) - { - return false; - } - - value = element.GetString(); - - return !string.IsNullOrWhiteSpace(value); - } - - private static bool TryReadHttpsUri(JsonElement root, string propertyName, out Uri? uri) - { - uri = null; - - return TryReadString(root, propertyName, out string? value) && - Uri.TryCreate(value, UriKind.Absolute, out uri) && - uri.Scheme == Uri.UriSchemeHttps; - } - - [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)] - private static partial Regex CreateSha256Pattern(); -} 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/App.axaml b/src/UnityAssetsPatcher/App.axaml new file mode 100644 index 00000000..b63c93eb --- /dev/null +++ b/src/UnityAssetsPatcher/App.axaml @@ -0,0 +1,19 @@ + + + avares://UnityAssetsPatcher/Assets/Fonts/MiSans-Regular.otf#MiSans + avares://UnityAssetsPatcher/Assets/Fonts/MiSans-Medium.otf#MiSans + + + + + + + + + + + + diff --git a/src/UnityAssetsPatcher/App.axaml.cs b/src/UnityAssetsPatcher/App.axaml.cs new file mode 100644 index 00000000..0574fd4e --- /dev/null +++ b/src/UnityAssetsPatcher/App.axaml.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using RentADeveloper.ResXLocalization; +using UnityAssetsPatcher.Application.Updates; +using UnityAssetsPatcher.Notifications; +using UnityAssetsPatcher.ViewModels; +using UnityAssetsPatcher.Views; + +namespace UnityAssetsPatcher; + +public partial class App : Avalonia.Application +{ + private readonly MainWindowViewModel? _mainWindowViewModel; + private readonly UpdateCheckService? _updateCheckService; + private readonly INotificationService? _notifications; + + public App() { } + + public App(MainWindowViewModel mainWindowViewModel, UpdateCheckService updateCheckService, + INotificationService notifications) + { + _mainWindowViewModel = mainWindowViewModel ?? throw new ArgumentNullException(nameof(mainWindowViewModel)); + _updateCheckService = updateCheckService ?? throw new ArgumentNullException(nameof(updateCheckService)); + _notifications = notifications ?? throw new ArgumentNullException(nameof(notifications)); + } + + 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( + _updateCheckService ?? throw new InvalidOperationException( + "The application must be created by dependency injection."), + _notifications ?? throw new InvalidOperationException( + "The application must be created by dependency injection.")) + { + DataContext = _mainWindowViewModel ?? + throw new InvalidOperationException( + "The application must be created by dependency injection.") + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/UnityAssetsPatcher/Assets/Fonts/MiSans-Medium.otf b/src/UnityAssetsPatcher/Assets/Fonts/MiSans-Medium.otf new file mode 100644 index 00000000..8a1600f5 Binary files /dev/null and b/src/UnityAssetsPatcher/Assets/Fonts/MiSans-Medium.otf differ diff --git a/src/UnityAssetsPatcher/Assets/Fonts/MiSans-Regular.otf b/src/UnityAssetsPatcher/Assets/Fonts/MiSans-Regular.otf new file mode 100644 index 00000000..149f52bf Binary files /dev/null and b/src/UnityAssetsPatcher/Assets/Fonts/MiSans-Regular.otf differ diff --git a/src/UnityAssetsPatcher/Assets/icon.ico b/src/UnityAssetsPatcher/Assets/icon.ico new file mode 100644 index 00000000..00c0ade4 Binary files /dev/null and b/src/UnityAssetsPatcher/Assets/icon.ico differ diff --git a/src/UnityAssetsPatcher/Controls/ContentDialog.axaml b/src/UnityAssetsPatcher/Controls/ContentDialog.axaml new file mode 100644 index 00000000..ecb2bcd3 --- /dev/null +++ b/src/UnityAssetsPatcher/Controls/ContentDialog.axaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + +