From a1c853e6420bf660194cc24f10124837bd9014fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 4 May 2026 13:10:12 -0400 Subject: [PATCH 1/3] Add GUI-hosted CLI and IPC automation surface Expose the official automation entrypoints through the WinUI and Avalonia executables instead of a separate CLI wrapper. Add pre-UI and headless startup flows, verb-based CLI parsing and command execution, and Windows console handling that only activates for real CLI verbs. Replace the old BackgroundApi surface with the IpcApi contract, including local IPC transport discovery, the client/server automation endpoints, and versioned HTTP routes under /uniget/v1. Stabilize manager identifiers and related package metadata so the CLI and IPC contract no longer depends on GUI display names or internal implementation names. Document the public CLI and IPC contracts, add transport/parser/package-operation coverage, and add headless CLI E2E CI for the new automation flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/cli-headless-e2e.yml | 126 + AGENTS.md | 2 +- README.md | 7 +- cli-arguments.md | 54 - docs/CLI.md | 261 ++ docs/IPC.md | 397 +++ src/UniGetUI.Avalonia.slnx | 4 +- src/UniGetUI.Avalonia/App.axaml.cs | 36 +- src/UniGetUI.Avalonia/AvaloniaCliHandler.cs | 10 +- .../Infrastructure/AvaloniaBootstrapper.cs | 176 +- .../AvaloniaOperationRegistry.cs | 11 + .../AvaloniaPackageOperationHelper.cs | 2 +- .../Infrastructure/HeadlessDaemonHost.cs | 17 + .../Infrastructure/HeadlessModeOptions.cs | 11 + .../ProcessEnvironmentConfigurator.cs | 85 + src/UniGetUI.Avalonia/Program.cs | 25 + .../UniGetUI.Avalonia.csproj | 29 +- .../DialogPages/InstallOptionsViewModel.cs | 18 +- .../InstallOptionsPanelViewModel.cs | 14 +- .../ViewModels/SidebarViewModel.cs | 4 +- .../Views/MainWindow.axaml.cs | 7 + .../Views/SoftwarePages/PackageBundlesPage.cs | 2 +- .../SecureGHTokenManager.cs | 18 +- .../SecureSettings.cs | 50 +- .../BackgroundApi.cs | 321 --- .../HeadlessIpcHost.cs | 112 + .../InternalsVisibleTo.cs | 3 + src/UniGetUI.Interface.IpcApi/IpcAppApi.cs | 89 + src/UniGetUI.Interface.IpcApi/IpcBackupApi.cs | 594 +++++ src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs | 800 +++++++ .../IpcCliCommandRunner.cs | 962 ++++++++ src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs | 385 +++ src/UniGetUI.Interface.IpcApi/IpcClient.cs | 1558 ++++++++++++ .../IpcDesktopShortcutsApi.cs | 134 ++ .../IpcHttpRoutes.cs | 30 + src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs | 112 + .../IpcManagerMaintenanceApi.cs | 382 +++ .../IpcManagerSettingsApi.cs | 539 +++++ .../IpcOperationApi.cs | 504 ++++ .../IpcPackageApi.cs | 804 +++++++ .../IpcSecureSettingsApi.cs | 105 + src/UniGetUI.Interface.IpcApi/IpcServer.cs | 2125 +++++++++++++++++ src/UniGetUI.Interface.IpcApi/IpcTransport.cs | 423 ++++ src/UniGetUI.Interface.IpcApi/Secrets.cs | 12 + .../UniGetUI.Interface.IpcApi.csproj} | 12 + .../WindowsConsoleHost.cs | 94 + .../generate-secrets.ps1 | 22 + .../TelemetryHandler.cs | 22 +- .../IPackageManager.cs | 1 + .../ManagerProperties.cs | 1 + .../Apt.cs | 1 + .../Cargo.cs | 1 + .../Chocolatey.cs | 1 + .../Dnf.cs | 1 + .../DotNet.cs | 1 + .../Helpers/DotNetPkgOperationHelper.cs | 2 +- .../Flatpak.cs | 1 + .../Homebrew.cs | 1 + .../Npm.cs | 1 + .../Pacman.cs | 1 + .../Pip.cs | 1 + .../PowerShell.cs | 1 + .../PowerShell7.cs | 1 + .../Scoop.cs | 1 + .../Snap.cs | 1 + .../Vcpkg.cs | 1 + .../WinGet.cs | 1 + .../DownloadOperation.cs | 1 + .../PackageOperations.cs | 88 +- .../SourceOperations.cs | 1 + .../Manager/Classes/NullPackageManager.cs | 5 + .../Manager/PackageManager.cs | 4 + .../Packages/ImportedPackage.cs | 2 +- .../Packages/Package.cs | 2 +- .../DesktopShortcutsDatabaseTests.cs | 69 + .../PackageOperationsTests.cs | 136 ++ src/UniGetUI.Tests/IpcCliSyntaxTests.cs | 172 ++ src/UniGetUI.Tests/IpcTransportTests.cs | 186 ++ src/UniGetUI.Tests/UniGetUI.Tests.csproj | 1 + src/UniGetUI.Windows.slnx | 2 +- src/UniGetUI/App.xaml.cs | 190 +- src/UniGetUI/AppOperationHelper.cs | 11 +- src/UniGetUI/CLIHandler.cs | 20 +- .../OperationWidgets/OperationControl.cs | 5 + src/UniGetUI/Controls/SourceManager.xaml.cs | 3 +- src/UniGetUI/EntryPoint.cs | 69 +- src/UniGetUI/MainWindow.xaml.cs | 16 + src/UniGetUI/Pages/MainView.xaml.cs | 1 + .../Pages/SoftwarePages/PackageBundlesPage.cs | 3 +- src/UniGetUI/UniGetUI.csproj | 2 +- src/UniGetUI/WinUiHeadlessHost.cs | 22 + .../automation/cli-e2e.manifest.linux.json | 99 + .../automation/cli-e2e.manifest.windows.json | 105 + testing/automation/run-cli-e2e.ps1 | 1137 +++++++++ 94 files changed, 13308 insertions(+), 574 deletions(-) create mode 100644 .github/workflows/cli-headless-e2e.yml delete mode 100644 cli-arguments.md create mode 100644 docs/CLI.md create mode 100644 docs/IPC.md create mode 100644 src/UniGetUI.Avalonia/Infrastructure/HeadlessDaemonHost.cs create mode 100644 src/UniGetUI.Avalonia/Infrastructure/HeadlessModeOptions.cs create mode 100644 src/UniGetUI.Avalonia/Infrastructure/ProcessEnvironmentConfigurator.cs delete mode 100644 src/UniGetUI.Interface.BackgroundApi/BackgroundApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/HeadlessIpcHost.cs create mode 100644 src/UniGetUI.Interface.IpcApi/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcAppApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcBackupApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcClient.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcDesktopShortcutsApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcHttpRoutes.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcManagerMaintenanceApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcManagerSettingsApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcSecureSettingsApi.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcServer.cs create mode 100644 src/UniGetUI.Interface.IpcApi/IpcTransport.cs create mode 100644 src/UniGetUI.Interface.IpcApi/Secrets.cs rename src/{UniGetUI.Interface.BackgroundApi/UniGetUI.Interface.BackgroundApi.csproj => UniGetUI.Interface.IpcApi/UniGetUI.Interface.IpcApi.csproj} (66%) create mode 100644 src/UniGetUI.Interface.IpcApi/WindowsConsoleHost.cs create mode 100644 src/UniGetUI.Interface.IpcApi/generate-secrets.ps1 create mode 100644 src/UniGetUI.PackageEngine.Tests/DesktopShortcutsDatabaseTests.cs create mode 100644 src/UniGetUI.Tests/IpcCliSyntaxTests.cs create mode 100644 src/UniGetUI.Tests/IpcTransportTests.cs create mode 100644 src/UniGetUI/WinUiHeadlessHost.cs create mode 100644 testing/automation/cli-e2e.manifest.linux.json create mode 100644 testing/automation/cli-e2e.manifest.windows.json create mode 100644 testing/automation/run-cli-e2e.ps1 diff --git a/.github/workflows/cli-headless-e2e.yml b/.github/workflows/cli-headless-e2e.yml new file mode 100644 index 0000000000..180a2d50cc --- /dev/null +++ b/.github/workflows/cli-headless-e2e.yml @@ -0,0 +1,126 @@ +name: CLI Headless E2E + +on: + push: + paths: + - 'src/**/*.cs' + - 'src/**/*.csproj' + - 'src/**/*.props' + - 'src/**/*.targets' + - 'src/**/*.sln' + - 'src/**/*.slnx' + - 'testing/automation/**' + - '.github/workflows/cli-headless-e2e.yml' + - 'global.json' + pull_request: + branches: [ "main" ] + paths: + - 'src/**/*.cs' + - 'src/**/*.csproj' + - 'src/**/*.props' + - 'src/**/*.targets' + - 'src/**/*.sln' + - 'src/**/*.slnx' + - 'testing/automation/**' + - '.github/workflows/cli-headless-e2e.yml' + - 'global.json' + workflow_dispatch: + +jobs: + cli-headless-e2e: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + solution: UniGetUI.Windows.slnx + daemon_project: UniGetUI/UniGetUI.csproj + daemon_build_args: '-p:Platform=x64' + manifest: testing/automation/cli-e2e.manifest.windows.json + - os: ubuntu-latest + solution: UniGetUI.Avalonia.slnx + daemon_project: UniGetUI.Avalonia/UniGetUI.Avalonia.csproj + daemon_build_args: '' + manifest: testing/automation/cli-e2e.manifest.linux.json + + runs-on: ${{ matrix.os }} + env: + CONFIGURATION: Release + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ${{ env.NUGET_PACKAGES }} + key: ${{ runner.os }}-nuget-e2e-${{ hashFiles('global.json', 'src/**/*.csproj', 'src/**/*.props', 'src/**/*.targets', 'src/**/*.sln', 'src/**/*.slnx') }} + restore-keys: | + ${{ runner.os }}-nuget-e2e- + + - name: Restore solution + working-directory: src + shell: pwsh + run: dotnet restore ${{ matrix.solution }} + + - name: Build headless daemon + working-directory: src + shell: pwsh + run: | + $args = @( + 'build', + '${{ matrix.daemon_project }}', + '--no-restore', + '--configuration', + '${{ env.CONFIGURATION }}', + '--verbosity', + 'minimal' + ) + if ('${{ matrix.daemon_build_args }}') { + $args += '${{ matrix.daemon_build_args }}' + } + dotnet @args + + - name: Upgrade pip tooling + shell: pwsh + run: python -m pip install --upgrade pip setuptools wheel + + - name: Show package-manager inventory + shell: pwsh + run: | + dotnet --version + python --version + python -m pip --version + npm --version + + - name: Run headless CLI E2E + shell: pwsh + env: + UNIGETUI_CLI_E2E_MANIFEST: ${{ matrix.manifest }} + UNIGETUI_CLI_E2E_ARTIFACTS: ${{ github.workspace }}/artifacts/cli-headless-e2e/${{ runner.os }} + run: ./testing/automation/run-cli-e2e.ps1 + + - name: Upload CLI E2E artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-headless-e2e-${{ runner.os }} + path: artifacts/cli-headless-e2e/${{ runner.os }} + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md index cd7ddde57e..ba589756a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Use these rules when changing Avalonia diagnostics/devtools behavior: - Default policy: enabled in `Debug`, disabled in `Release`. - `src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj` must condition `AvaloniaUI.DiagnosticsSupport` on `$(EnableAvaloniaDiagnostics)`. - Compile-time diagnostics code in `src/UniGetUI.Avalonia/Program.cs` must be gated by `#if AVALONIA_DIAGNOSTICS_ENABLED` (not `#if DEBUG`). -- Runtime controls are developer-only and intentionally not listed in `cli-arguments.md`. +- Runtime controls are developer-only and intentionally not listed in `docs/CLI.md`. - Runtime precedence in `Program.cs`: CLI flags > `UNIGETUI_AVALONIA_DEVTOOLS` environment variable > `Auto` default. - Accepted runtime env/CLI values for mode parsing: `auto`, `enabled`, `disabled`, `on`, `off`, `true`, `false`, `1`, `0`. - `Auto` mode must remain WSL-safe (DevTools disabled by default on WSL). diff --git a/README.md b/README.md index 4148372742..d53d95b7ed 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ Read more in the [Devolutions announcement](https://devolutions.net/blog/2026/03 - [Contributors](#contributors) - [Screenshots](#screenshots) - [Frequently Asked Questions](#frequently-asked-questions) - - [Command-line Arguments](cli-arguments.md) + - [CLI reference](docs/CLI.md) + - [IPC reference](docs/IPC.md) ## Installation

There are multiple ways to install UniGetUI — choose whichever one you prefer!

@@ -185,6 +186,6 @@ Microsoft has implemented a few checks for the software available on Winget to m

Check out the Wiki for more information!

-## Command-line parameters: +## Command-line interface: -Check out the full list of parameters [here](cli-arguments.md) +Check out the CLI reference [here](docs/CLI.md) and the IPC reference [here](docs/IPC.md). diff --git a/cli-arguments.md b/cli-arguments.md deleted file mode 100644 index d463e4b777..0000000000 --- a/cli-arguments.md +++ /dev/null @@ -1,54 +0,0 @@ -# UniGetUI Command-line parameters - -| Parameter ____________________________________ | Description | Compatible versions ______________ | -| ---------------------- | ---------- | ------- | -| `--daemon` | Start UniGetUI without spawning a new window. UniGetUI will run minimized on the system tray. UniGetUI is called with this parameter when launched at startup. **Autostart UniGetUI in the notifications area must be enabled for this parameter to work.** | 1.0+ | -| `--welcome` | Shows the user the Setup Wizard | up to 2.2.0 | -| `--updateapps` | Force enable automatic installation of available updates | 1.6.0+ | -| `--report-all-errors` | Will force UniGetUI to show the error report page on any crash when loading | 3.0.0+ | -| `--uninstall-unigetui` | Will unregister UniGetUI from the notification panel, and silently quit | from 3.1.0 to 3.1.8 | -| `--migrate-wingetui-to-unigetui` | Will migrate WingetUI data folders and shortcuts to UniGetUI (if possible), and silently quit | 3.1.0+ | -| `UniGetUI.exe file` | Provided that the file is a valid bundle, will load the bundle into the Package Bundles page. Compatible bundle files include the following extensions: `.ubundle`, `.json`, `.yaml`, `.xml` | 3.1.2+ | -| `--help` | Opens this page | 3.2.0+ | -| `--import-settings file` | Imports UniGetUI settings from json file _file_. The file must exist. The old settings will be lost* | 3.2.0+ | -| `--export-settings file` | Exports UniGetUI settings to json file _file_. The file will be created or overwritten* | 3.2.0+ | -| `--[enable\|disable]-setting key` | Enables/disables the boolean setting _key1_ | 3.2.0+ | -| `--set-setting-value key value` | Sets the value _value_ to the non-boolean setting _key1_. To clear a non-boolean setting, `--disable-setting` can be used* | 3.2.0+ | -| `--no-corrupt-dialog` | Will show a verbose error message (the error report) instead of a simplified message dialog | 3.2.1+ | -| `--[enable\|disable]-secure-setting-for-user username key` | Enables/disables the given secure setting for the given key2 and username. Requires administrator rights. | 3.2.1+ | -| `--[enable\|disable]-secure-setting key` | Enables/disables the given secure setting2 for current user. This will generate a UAC prompt | 3.2.1+ | - -1. See the available list of setting keys [here](https://github.com/Devolutions/UniGetUI/blob/fc98f312a72b80e14a8ac10687f4fc506a5c9cc4/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs#L5) -2. See the available list of secure settings keys [here](https://github.com/Devolutions/UniGetUI/blob/fc98f312a72b80e14a8ac10687f4fc506a5c9cc4/src/UniGetUI.Core.SecureSettings/SecureSettings.cs#L10) - - -\*After modifying the settings, you must ensure that any running instance of UniGetUI is restarted for the changes to take effect - -

-# `unigetui://` deep link -On a system where UniGetUI 3.1.2+ is installed, the following deep links can be used to communicate with UniGetUI: - -| Parameter | Description | -| --------------------------------------------------- | ---------- | -| `unigetui://showPackage?id={}&managerName={}&sourceName={}` | Show the Package Details page with the provided package.
The parameters `id`, `managerName` and `sourceName` are
required and cannot be empty | -| `unigetui://showUniGetUI` | Shows UniGetUI and brings the window to the front | -| `unigetui://showDiscoverPage` | Shows UniGetUI and loads the Discover page | -| `unigetui://showUpdatesPage` | Shows UniGetUI and loads the Updates page | -| `unigetui://showInstalledPage` | Shows UniGetUI and loads the Installed page | - -

- -# Installer command-line parameters -The installer is inno-setup based. It supports [all Inno Setup command-line parameters](https://jrsoftware.org/ishelp/index.php?topic=setupcmdline), as well as the following custom ones: - -| Parameter | Description | -| --------------------------------------------------- | ---------- | -| `/NoAutoStart` | Will not launch UniGetUI after installation | -| `/NoRunOnStartup` | Will not register UniGetUI to start minimized at login (v3.1.6+) | -| `/NoVCRedist` | Will not install MS Visual C++ Redistributable x64 (v3.1.2+) | -| `/NoEdgeWebView` | Will not install Microsoft Edge WebView Runtime (v3.1.2+) | -| `/NoChocolatey` | Deprecated no-op kept for compatibility. UniGetUI no longer bundles Chocolatey. | -| `/EnableSystemChocolatey` | Deprecated no-op kept for compatibility. UniGetUI now uses system Chocolatey only. | -| `/NoWinGet` | Do NOT install WinGet and Microsoft.WinGet.Client if not installed **(not recommended)** | -| `/ALLUSERS` | Will force the installer to install per-machine (requires administrator privileges) | -| `/CURRENTUSER` | Will force the installer to install per-user | diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000000..d58c05f164 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,261 @@ +# UniGetUI command-line interface + +This file documents the **public command-line surface** exposed by UniGetUI in the 2026 CLI redesign. + +- For the background IPC API that powers these commands, see [IPC.md](IPC.md). +- For developer-only Avalonia diagnostics toggles, see the project source and build props; they are intentionally not documented here as public CLI arguments. + +## Quick start + +```powershell +unigetui status +unigetui app status +unigetui package search --manager dotnet-tool --query dotnetsay +unigetui package install --manager dotnet-tool --id dotnetsay --version 2.1.4 --scope Global +unigetui operation wait --id 123 --timeout 300 +``` + +## Global transport options + +These options select how the CLI connects to the local UniGetUI automation session. + +| Option | Meaning | +| --- | --- | +| `--transport {named-pipe\|tcp}` | Client-side transport override. Default is `named-pipe`. | +| `--tcp-port ` | Client-side TCP port override. Used only with `tcp`. | +| `--pipe-name ` | Client-side named-pipe override. On Windows this is a pipe name. On non-Windows a relative name resolves under `/tmp`, while an absolute path uses that exact Unix socket path. | + +Related environment variables: + +| Variable | Meaning | +| --- | --- | +| `UNIGETUI_IPC_API_TRANSPORT` | Same as `--transport`. | +| `UNIGETUI_IPC_API_PORT` | Same as `--tcp-port`. | +| `UNIGETUI_IPC_API_PIPE_NAME` | Same as `--pipe-name`. | + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Success | +| `1` | Command failed | +| `2` | Invalid parameter | +| `3` | IPC API unavailable | +| `4` | Unknown automation command | + +## Command grammar notes + +- Command nouns accept singular or plural forms: `operation`/`operations`, `package`/`packages`, `manager`/`managers`, and so on. +- Compatibility aliases are accepted for some flags: + - `--id` maps to `--package-id` or `--operation-id` where appropriate + - `--source` maps to `--package-source` +- Boolean options use explicit values such as `--enabled true` or `--wait false`. +- `--detach` is shorthand for asynchronous package operations (`--wait false`). +- `--manager` uses stable manager ids, not GUI labels. Current ids: `apt`, `cargo`, `chocolatey`, `dnf`, `dotnet-tool`, `flatpak`, `homebrew`, `npm`, `pacman`, `pip`, `pwsh`, `scoop`, `snap`, `vcpkg`, `winget`, and `winps`. + +## Command reference + +### Core + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `status` | None | None | Returns transport, endpoint, and build information for the selected automation session. | +| `version` | None | None | Returns the UniGetUI build number through the IPC API. | + +### App + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `app status` | None | None | Returns app/session state such as headless mode, page, and supported UI actions. | +| `app show` | None | None | Shows and focuses the window when a GUI session exists. | +| `app navigate` | `--page ` | `--manager `, `--help-attachment ` | Valid pages include `discover`, `updates`, `installed`, `bundles`, `settings`, `managers`, `own-log`, `manager-log`, `operation-history`, `help`, `release-notes`, and `about`. | +| `app quit` | None | None | Gracefully shuts down the selected session, including headless daemons. | + +### Operations + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `operation list` | None | None | Lists tracked live and completed operations. | +| `operation get` | `--id ` | None | Returns the full tracked payload for one operation. | +| `operation output` | `--id ` | `--tail ` | Reads captured output lines for one operation. | +| `operation wait` | `--id ` | `--timeout `, `--delay ` | Polls until the operation reaches a terminal state. | +| `operation cancel` | `--id ` | None | Cancels a queued or running operation. | +| `operation retry` | `--id ` | `--mode ` | Retry modes are defined by the operation payload. | +| `operation reorder` | `--id `, `--action ` | None | Reorders a queued operation. | +| `operation forget` | `--id ` | None | Removes a finished operation from the live tracked list. | + +### Managers + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `manager list` | None | None | Lists managers and their automation-relevant capability flags. | +| `manager maintenance` | `--manager ` | None | Returns maintenance metadata for one manager. | +| `manager reload` | `--manager ` | None | Reloads one manager. | +| `manager set-executable` | `--manager `, `--path ` | None | Sets a custom executable override, then reloads the manager. | +| `manager clear-executable` | `--manager ` | None | Clears the custom executable override, then reloads the manager. | +| `manager action` | `--manager `, `--action ` | `--confirm` | Runs a manager-specific maintenance action. | +| `manager enable` | `--manager ` | None | Enables the manager. | +| `manager disable` | `--manager ` | None | Disables the manager. | +| `manager notifications enable` | `--manager ` | None | Enables update notifications for the manager. | +| `manager notifications disable` | `--manager ` | None | Disables update notifications for the manager. | + +### Sources + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `source list` | None | `--manager ` | Lists sources, optionally filtered to one manager. | +| `source add` | `--manager `, `--name ` | `--url ` | Adds a source. | +| `source remove` | `--manager `, `--name ` | `--url ` | Removes a source. | + +### Settings + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `settings list` | None | None | Lists non-secure settings. | +| `settings get` | `--key ` | None | Reads one non-secure setting. | +| `settings set` | `--key ` | `--enabled true\|false`, `--value ` | Sets either the boolean or string form of a setting. | +| `settings clear` | `--key ` | None | Clears a string-backed setting. | +| `settings reset` | None | None | Resets non-secure settings. | +| `settings secure list` | None | `--user ` | Lists secure settings for the current or specified user. | +| `settings secure get` | `--key ` | `--user ` | Reads one secure setting. | +| `settings secure set` | `--key `, `--enabled true\|false` | `--user ` | Enables or disables one secure setting. | + +Available keys live in: + +- [`src/UniGetUI.Core.Settings/SettingsEngine_Names.cs`](src/UniGetUI.Core.Settings/SettingsEngine_Names.cs) +- [`src/UniGetUI.Core.SecureSettings/SecureSettings.cs`](src/UniGetUI.Core.SecureSettings/SecureSettings.cs) + +### Shortcuts + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `shortcut list` | None | None | Lists tracked desktop shortcuts and stored keep/delete verdicts. | +| `shortcut set` | `--path `, `--status ` | None | Marks a shortcut to keep or delete. | +| `shortcut reset` | `--path ` | None | Clears the stored verdict for one shortcut. | +| `shortcut reset-all` | None | None | Clears all stored shortcut verdicts. | + +### Logs + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `log app` | None | `--level ` | Returns structured application log entries. | +| `log operations` | None | None | Returns persisted operation history. | +| `log manager` | None | `--manager `, `--verbose` | Returns manager task logs. | + +### Backups + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `backup status` | None | None | Returns backup settings and cloud-auth state. | +| `backup local create` | None | None | Creates a local backup bundle. | +| `backup github login start` | None | `--launch-browser` | Starts the GitHub device flow. | +| `backup github login complete` | None | None | Completes the pending device flow. | +| `backup github logout` | None | None | Clears the stored GitHub auth token. | +| `backup cloud list` | None | None | Lists cloud backups in the authenticated GitHub backup store. | +| `backup cloud create` | None | None | Uploads the current backup to cloud storage. | +| `backup cloud download` | `--key ` | None | Downloads one cloud backup as bundle content. | +| `backup cloud restore` | `--key ` | `--append` | Imports one cloud backup into the current in-memory bundle. | + +### Bundles + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `bundle get` | None | None | Returns the current in-memory bundle. | +| `bundle reset` | None | None | Clears the current in-memory bundle. | +| `bundle import` | None | `--path `, `--content `, `--format `, `--append` | Imports bundle content from a file or raw content. | +| `bundle export` | None | `--path ` | Exports the current bundle, optionally to disk. | +| `bundle add` | `--id ` | `--manager `, `--source `, `--version `, `--scope `, `--pre-release`, `--selection ` | Resolves a package and adds it to the bundle. | +| `bundle remove` | `--id ` | `--manager `, `--source `, `--version `, `--scope `, `--pre-release`, `--selection ` | Removes matching package entries from the bundle. | +| `bundle install` | None | `--include-installed true\|false`, `--elevated true\|false`, `--interactive true\|false`, `--skip-hash true\|false` | Installs the bundle through UniGetUI’s shared operation pipeline. | + +### Packages + +| Command | Required options | Optional options | Notes | +| --- | --- | --- | --- | +| `package search` | `--query ` | `--manager `, `--max-results ` | Searches packages. | +| `package details` | `--id ` | `--manager `, `--source ` | Returns the package details payload. | +| `package versions` | `--id ` | `--manager `, `--source ` | Returns installable versions when supported by the manager. | +| `package installed` | None | `--manager ` | Lists installed packages. | +| `package updates` | None | `--manager ` | Lists available updates. | +| `package install` | `--id ` | `--manager `, `--source `, `--version `, `--scope `, `--pre-release`, `--elevated true\|false`, `--interactive true\|false`, `--skip-hash true\|false`, `--architecture `, `--location `, `--wait true\|false`, `--detach` | Installs a package. Async mode returns an operation id immediately. | +| `package download` | `--id ` | `--manager `, `--source `, `--version `, `--scope `, `--wait true\|false`, `--detach`, `--output ` | Downloads a package artifact. | +| `package reinstall` | `--id ` | Same options as `package install` | Re-runs installation for an installed package. | +| `package repair` | `--id ` | Same options as `package install`, plus `--remove-data true\|false` | Uninstalls then reinstalls the package. | +| `package update` | `--id ` | Same options as `package install` | Updates one package. | +| `package uninstall` | `--id ` | `--manager `, `--source `, `--scope `, `--remove-data true\|false`, `--elevated true\|false`, `--interactive true\|false`, `--wait true\|false`, `--detach` | Uninstalls a package. | +| `package show` | `--id `, `--source ` | None | Opens the package details UI flow. | +| `package ignored list` | None | None | Lists ignored-update rules tracked by UniGetUI. | +| `package ignored add` | `--id ` | `--manager `, `--version `, `--source ` | Adds an ignored-update rule. | +| `package ignored remove` | `--id ` | `--manager `, `--version `, `--source ` | Removes an ignored-update rule. | +| `package update-all` | None | None | Queues updates for all currently upgradable packages. | +| `package update-manager` | `--manager ` | None | Queues updates for all upgradable packages handled by one manager. | + +## Headless behavior + +When UniGetUI is started with `--headless`, it exposes the same automation API without opening a window. + +| Command | Headless behavior | +| --- | --- | +| `status`, `app status`, `app quit` | Fully supported. | +| `app show` | Fails with “the current UniGetUI session is running headless and has no window to show.” | +| `app navigate` | Fails with “the current UniGetUI session is running headless and cannot navigate UI pages.” | +| `package show` | UI-oriented; may fail or be meaningless in pure headless sessions. | +| `package update-all`, `package update-manager` | Require GUI-side upgrade handlers. Headless sessions may return “cannot update all packages” or “cannot update manager packages.” | + +## Headless IPC options + +When UniGetUI is started with `--headless`, these options control the IPC listener: + +| Option | Meaning | +| --- | --- | +| `--ipc-api-transport {named-pipe\|tcp}` | Selects the server-side IPC transport. Default is `named-pipe`. | +| `--ipc-api-port ` | Overrides the TCP port when TCP transport is selected. | +| `--ipc-api-pipe-name ` | Overrides the server-side pipe name or Unix socket path. | + +## Other application startup parameters + +These parameters are accepted by the app executables in addition to the automation verb tree. + +| Parameter | Meaning | Notes | +| --- | --- | --- | +| `--daemon` | Starts UniGetUI minimized to the notification area. | Requires the corresponding startup setting. | +| `--welcome` | Opens the setup wizard. | Historical compatibility flag. | +| `--updateapps` | Forces automatic installation of available updates. | Historical compatibility flag. | +| `--report-all-errors` | Opens the error report page for any crash while loading. | Troubleshooting flag. | +| `--uninstall-unigetui` | Unregisters UniGetUI from the notification panel and quits. | Historical; only valid for specific old versions. | +| `--migrate-wingetui-to-unigetui` | Migrates legacy WingetUI data and shortcuts, then quits. | Migration helper. | +| `--help` / `-h` | Prints CLI help. | For the direct verb-based CLI. | +| `--import-settings ` | Imports settings from a JSON file. | Existing settings are replaced. | +| `--export-settings ` | Exports settings to a JSON file. | Creates or overwrites the file. | +| `--enable-setting ` / `--disable-setting ` | Toggles one boolean setting. | Legacy setting flags. | +| `--set-setting-value ` | Sets one string-backed setting. | Legacy setting flag. | +| `--no-corrupt-dialog` | Shows the verbose crash report instead of the simplified dialog. | Troubleshooting flag. | +| `--enable-secure-setting ` / `--disable-secure-setting ` | Toggles one secure setting for the current user. | May require elevation. | +| `--enable-secure-setting-for-user ` / `--disable-secure-setting-for-user ` | Toggles one secure setting for a specified user. | May require elevation. | +| `` | Loads a valid bundle file into the Package Bundles page. | Supported extensions include `.ubundle`, `.json`, `.yaml`, and `.xml`. | + +## Deep links + +UniGetUI also accepts the following `unigetui://` links: + +| Deep link | Meaning | +| --- | --- | +| `unigetui://showPackage?id={id}&managerName={manager}&sourceName={source}` | Opens package details for the specified package. | +| `unigetui://showUniGetUI` | Shows UniGetUI and brings the window to the front. | +| `unigetui://showDiscoverPage` | Opens the Discover page. | +| `unigetui://showUpdatesPage` | Opens the Updates page. | +| `unigetui://showInstalledPage` | Opens the Installed page. | + +## Installer parameters + +The installer is Inno Setup based. It supports the standard [Inno Setup command-line parameters](https://jrsoftware.org/ishelp/index.php?topic=setupcmdline) plus these UniGetUI-specific switches: + +| Parameter | Meaning | +| --- | --- | +| `/NoAutoStart` | Do not launch UniGetUI after installation. | +| `/NoRunOnStartup` | Do not register UniGetUI to start minimized at login. | +| `/NoVCRedist` | Skip installation of the MSVC x64 runtime. | +| `/NoEdgeWebView` | Skip installation of the Microsoft Edge WebView runtime. | +| `/NoChocolatey` | Deprecated no-op kept for compatibility. | +| `/EnableSystemChocolatey` | Deprecated no-op kept for compatibility. | +| `/NoWinGet` | Do not install WinGet and Microsoft.WinGet.Client if they are missing. | diff --git a/docs/IPC.md b/docs/IPC.md new file mode 100644 index 0000000000..f3056b6f71 --- /dev/null +++ b/docs/IPC.md @@ -0,0 +1,397 @@ +# UniGetUI background IPC API + +This file documents the **local automation API** used by the UniGetUI CLI. + +- For the public command-line interface built on top of this API, see [CLI.md](CLI.md). +- This API is designed for **local automation**, not for remote exposure. + +## Overview + +UniGetUI exposes a local HTTP API over one of two transports: + +- **Named-pipe transport** (default) + - Windows: Windows named pipe + - Non-Windows: Unix domain socket +- **TCP transport** (optional) + - Localhost only + +All endpoints live under `/uniget/v1/...`. + +## Transport defaults + +| Setting | Value | +| --- | --- | +| Default transport | `named-pipe` | +| Default TCP port | `7058` | +| Default pipe name | `UniGetUI.IPC` | +| Default Unix socket directory | `/tmp` | + +On non-Windows, a relative named-pipe name such as `UniGetUI.IPC` resolves to: + +```text +/tmp/UniGetUI.IPC +``` + +An absolute path may also be supplied on non-Windows. On Windows, absolute pipe paths are rejected and UniGetUI falls back to the default pipe name. + +## Server-side configuration + +These options are read when UniGetUI starts its IPC API server. + +| Argument | Environment variable | Meaning | +| --- | --- | --- | +| `--ipc-api-transport {named-pipe\|tcp}` | `UNIGETUI_IPC_API_TRANSPORT` | Selects the server transport. | +| `--ipc-api-port ` | `UNIGETUI_IPC_API_PORT` | Selects the TCP port when TCP is enabled. | +| `--ipc-api-pipe-name ` | `UNIGETUI_IPC_API_PIPE_NAME` | Selects the pipe name or Unix socket path when named-pipe transport is enabled. | + +## Client-side configuration + +These options are read by the CLI and `IpcClient`. + +| Argument | Environment variable | Meaning | +| --- | --- | --- | +| `--transport {named-pipe\|tcp}` | `UNIGETUI_IPC_API_TRANSPORT` | Explicit client-side transport override. | +| `--tcp-port ` | `UNIGETUI_IPC_API_PORT` | Explicit client-side TCP port override. | +| `--pipe-name ` | `UNIGETUI_IPC_API_PIPE_NAME` | Explicit client-side pipe name or Unix socket override. | + +## Session discovery + +When the client does **not** receive an explicit transport override: + +1. UniGetUI loads persisted endpoint registrations from the user configuration directory. +2. Registrations are ordered with this preference: + 1. headless sessions first + 2. newest persisted session first +3. The client probes for a live session and uses its persisted token automatically. + +When the client **does** receive an explicit override: + +- it connects to that transport choice instead of auto-selecting the newest session +- it waits up to 5 seconds for a matching persisted token to appear + +## Authentication + +| Endpoint | Auth | +| --- | --- | +| `GET /uniget/v1/status` | No token required | +| All other `/uniget/v1/*` endpoints | `token` query parameter required | + +Authentication details: + +- UniGetUI generates a per-session token at API startup. +- That token is persisted with the endpoint registration metadata. +- `IpcClient` automatically appends `token=` to authenticated requests. + +## Security notes + +- The default design is **local-only automation**. +- TCP mode binds to `localhost`, not all interfaces. +- On non-Windows named-pipe transport, UniGetUI applies Unix socket mode: + +```text +user-read + user-write +``` + +That is effectively `0600`-style same-user access on the socket file. + +- On Windows named-pipe transport, UniGetUI uses Kestrel named-pipe hosting and does not expose a filesystem socket path. + +## Error model + +| Condition | Result | +| --- | --- | +| Missing or invalid token | HTTP `401` | +| Invalid query/body arguments | HTTP `400` with plain-text error message | +| Success | JSON response with camelCase property names | + +Most successful command endpoints return either: + +- a domain object wrapped in a `status: "success"` envelope, or +- a command/result JSON envelope, or +- another typed JSON payload documented by its fields rather than its CLR type name + +## Request conventions + +### Query-string endpoints + +Most endpoints use query parameters, including: + +- operations +- app navigation +- sources +- settings +- secure settings +- shortcuts +- logs +- package search/details/versions/installed/updates +- package actions + +### JSON-body endpoints + +These endpoint families consume JSON bodies: + +| Endpoint family | Request shape | +| --- | --- | +| manager maintenance actions | manager maintenance request body | +| GitHub device-flow start | GitHub device-flow start request body | +| cloud backup download/restore | cloud backup request body | +| bundle import | bundle import request body | +| bundle export | bundle export request body | +| bundle add/remove | bundle package request body | +| bundle install | bundle install request body | + +### JSON body field reference + +All request bodies use **camelCase** JSON. + +#### Manager maintenance request body + +| Field | Type | Meaning | +| --- | --- | --- | +| `managerName` | string | Required stable manager id | +| `action` | string | Manager action name for `/action` | +| `path` | string | Custom executable path for `/executable/set` | +| `confirm` | boolean | Confirmation flag for destructive actions | + +#### GitHub device-flow start request body + +| Field | Type | Meaning | +| --- | --- | --- | +| `launchBrowser` | boolean | Whether UniGetUI should try to open the verification URL automatically | + +#### Cloud backup request body + +| Field | Type | Meaning | +| --- | --- | --- | +| `key` | string | Backup identifier | +| `append` | boolean | Append instead of replace when restoring/importing | + +#### Bundle import request body + +| Field | Type | Meaning | +| --- | --- | --- | +| `path` | string | Source file path | +| `content` | string | Raw bundle content | +| `format` | string | Bundle format such as `ubundle`, `json`, `yaml`, or `xml` | +| `append` | boolean | Append imported items to the current bundle | + +#### Bundle export request body + +| Field | Type | Meaning | +| --- | --- | --- | +| `path` | string | Optional output path | + +#### Bundle package request body + +| Field | Type | Meaning | +| --- | --- | --- | +| `packageId` | string | Package identifier | +| `managerName` | string | Stable manager id | +| `packageSource` | string | Source/feed name | +| `version` | string | Requested version | +| `scope` | string | Requested scope | +| `preRelease` | boolean | Include prerelease package metadata | +| `selection` | string | Bundle selection mode | + +#### Bundle install request body + +| Field | Type | Meaning | +| --- | --- | --- | +| `includeInstalled` | boolean | Whether already-installed packages should still be processed | +| `elevated` | boolean | Request elevated execution | +| `interactive` | boolean | Request interactive execution | +| `skipHash` | boolean | Skip hash validation when supported | + +## Shared parameter sets + +### Package action query parameters + +These keys are used by package-related endpoints such as install, update, uninstall, details, versions, ignored updates, and download. + +| Query key | Meaning | +| --- | --- | +| `packageId` | Package identifier | +| `manager` | Stable manager id | +| `packageSource` | Source/feed name | +| `version` | Requested version | +| `scope` | Install scope | +| `preRelease` | Boolean | +| `elevated` | Boolean | +| `interactive` | Boolean | +| `skipHash` | Boolean | +| `removeData` | Boolean | +| `wait` | Boolean | +| `architecture` | Architecture override | +| `location` | Install location override | +| `outputPath` | Download output path | + +### App navigation query parameters + +| Query key | Meaning | +| --- | --- | +| `page` | Target page name | +| `manager` | Optional manager context | +| `helpAttachment` | Optional help-page attachment | + +### Operation query parameters + +| Query key | Meaning | +| --- | --- | +| `tailLines` | Used by `GET /uniget/v1/operations/{operationId}/output` | +| `mode` | Retry mode for `POST /uniget/v1/operations/{operationId}/retry` | +| `action` | Queue action for `POST /uniget/v1/operations/{operationId}/reorder` | + +## Endpoint reference + +### Session and app + +| Method | Path | Auth | Parameters/body | CLI equivalent | Notes | +| --- | --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/status` | No | None | `status`, `version` | Returns `running`, `transport`, `tcpPort`, `namedPipeName`, `namedPipePath`, `baseAddress`, `version`, and `buildNumber`. | +| `GET` | `/uniget/v1/app` | Yes | None | `app status` | Returns app/headless/window state. | +| `POST` | `/uniget/v1/app/show` | Yes | None | `app show` | UI-only in practice. | +| `POST` | `/uniget/v1/app/navigate` | Yes | Query: `page`, optional `manager`, optional `helpAttachment` | `app navigate` | UI-only in practice. | +| `POST` | `/uniget/v1/app/quit` | Yes | None | `app quit` | Shuts down the selected session. | + +### Operations + +| Method | Path | Auth | Parameters/body | CLI equivalent | +| --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/operations` | Yes | None | `operation list` | +| `GET` | `/uniget/v1/operations/{operationId}` | Yes | Route: `operationId` | `operation get` | +| `GET` | `/uniget/v1/operations/{operationId}/output` | Yes | Route: `operationId`, optional query `tailLines` | `operation output` | +| `POST` | `/uniget/v1/operations/{operationId}/cancel` | Yes | Route: `operationId` | `operation cancel` | +| `POST` | `/uniget/v1/operations/{operationId}/retry` | Yes | Route: `operationId`, optional query `mode` | `operation retry` | +| `POST` | `/uniget/v1/operations/{operationId}/reorder` | Yes | Route: `operationId`, query `action` | `operation reorder` | +| `POST` | `/uniget/v1/operations/{operationId}/forget` | Yes | Route: `operationId` | `operation forget` | + +### Managers + +| Method | Path | Auth | Parameters/body | CLI equivalent | +| --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/managers` | Yes | None | `manager list` | +| `GET` | `/uniget/v1/managers/maintenance` | Yes | Query `manager` | `manager maintenance` | +| `POST` | `/uniget/v1/managers/maintenance/reload` | Yes | JSON body: manager maintenance request body | `manager reload` | +| `POST` | `/uniget/v1/managers/maintenance/executable/set` | Yes | JSON body: manager maintenance request body | `manager set-executable` | +| `POST` | `/uniget/v1/managers/maintenance/executable/clear` | Yes | JSON body: manager maintenance request body | `manager clear-executable` | +| `POST` | `/uniget/v1/managers/maintenance/action` | Yes | JSON body: manager maintenance request body | `manager action` | +| `POST` | `/uniget/v1/managers/set-enabled` | Yes | Query `manager`, `enabled` | `manager enable`, `manager disable` | +| `POST` | `/uniget/v1/managers/set-update-notifications` | Yes | Query `manager`, `enabled` | `manager notifications enable`, `manager notifications disable` | + +### Sources + +| Method | Path | Auth | Parameters/body | CLI equivalent | +| --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/sources` | Yes | Optional query `manager` | `source list` | +| `POST` | `/uniget/v1/sources/add` | Yes | Query `manager`, `name`, optional `url` | `source add` | +| `POST` | `/uniget/v1/sources/remove` | Yes | Query `manager`, `name`, optional `url` | `source remove` | + +### Settings + +| Method | Path | Auth | Parameters/body | CLI equivalent | +| --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/settings` | Yes | None | `settings list` | +| `GET` | `/uniget/v1/settings/item` | Yes | Query `key` | `settings get` | +| `POST` | `/uniget/v1/settings/set` | Yes | Query `key`, optional `enabled`, optional `value` | `settings set` | +| `POST` | `/uniget/v1/settings/clear` | Yes | Query `key` | `settings clear` | +| `POST` | `/uniget/v1/settings/reset` | Yes | None | `settings reset` | + +### Secure settings + +| Method | Path | Auth | Parameters/body | CLI equivalent | +| --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/secure-settings` | Yes | Optional query `user` | `settings secure list` | +| `GET` | `/uniget/v1/secure-settings/item` | Yes | Query `key`, optional `user` | `settings secure get` | +| `POST` | `/uniget/v1/secure-settings/set` | Yes | Query `key`, `enabled`, optional `user` | `settings secure set` | + +### Desktop shortcuts + +| Method | Path | Auth | Parameters/body | CLI equivalent | +| --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/desktop-shortcuts` | Yes | None | `shortcut list` | +| `POST` | `/uniget/v1/desktop-shortcuts/set` | Yes | Query `path`, `status` | `shortcut set` | +| `POST` | `/uniget/v1/desktop-shortcuts/reset` | Yes | Query `path` | `shortcut reset` | +| `POST` | `/uniget/v1/desktop-shortcuts/reset-all` | Yes | None | `shortcut reset-all` | + +### Logs + +| Method | Path | Auth | Parameters/body | CLI equivalent | +| --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/logs/app` | Yes | Optional query `level` | `log app` | +| `GET` | `/uniget/v1/logs/history` | Yes | None | `log operations` | +| `GET` | `/uniget/v1/logs/manager` | Yes | Optional query `manager`, optional query `verbose` | `log manager` | + +### Backups + +| Method | Path | Auth | Parameters/body | CLI equivalent | Notes | +| --- | --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/backups/status` | Yes | None | `backup status` | Includes local backup settings and GitHub auth state. | +| `POST` | `/uniget/v1/backups/local/create` | Yes | None | `backup local create` | Creates a local backup bundle. | +| `POST` | `/uniget/v1/backups/github/sign-in/start` | Yes | JSON body: GitHub device-flow start request body | `backup github login start` | Starts GitHub device flow. | +| `POST` | `/uniget/v1/backups/github/sign-in/complete` | Yes | None | `backup github login complete` | Completes device flow. | +| `POST` | `/uniget/v1/backups/github/sign-out` | Yes | None | `backup github logout` | Signs out of GitHub backup integration. | +| `GET` | `/uniget/v1/backups/cloud` | Yes | None | `backup cloud list` | Lists cloud backups. | +| `POST` | `/uniget/v1/backups/cloud/create` | Yes | None | `backup cloud create` | Uploads a cloud backup. | +| `POST` | `/uniget/v1/backups/cloud/download` | Yes | JSON body: cloud backup request body | `backup cloud download` | Downloads backup content. | +| `POST` | `/uniget/v1/backups/cloud/restore` | Yes | JSON body: cloud backup request body | `backup cloud restore` | Restores/imports a cloud backup. | + +### Bundles + +| Method | Path | Auth | Parameters/body | CLI equivalent | +| --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/bundles` | Yes | None | `bundle get` | +| `POST` | `/uniget/v1/bundles/reset` | Yes | None | `bundle reset` | +| `POST` | `/uniget/v1/bundles/import` | Yes | JSON body: bundle import request body | `bundle import` | +| `POST` | `/uniget/v1/bundles/export` | Yes | JSON body: bundle export request body | `bundle export` | +| `POST` | `/uniget/v1/bundles/add` | Yes | JSON body: bundle package request body | `bundle add` | +| `POST` | `/uniget/v1/bundles/remove` | Yes | JSON body: bundle package request body | `bundle remove` | +| `POST` | `/uniget/v1/bundles/install` | Yes | JSON body: bundle install request body | `bundle install` | + +### Packages + +| Method | Path | Auth | Parameters/body | CLI equivalent | Notes | +| --- | --- | --- | --- | --- | --- | +| `GET` | `/uniget/v1/packages/search` | Yes | Query `query`, optional `manager`, optional `maxResults` | `package search` | Search endpoint. | +| `GET` | `/uniget/v1/packages/installed` | Yes | Optional query `manager` | `package installed` | Installed packages. | +| `GET` | `/uniget/v1/packages/updates` | Yes | Optional query `manager` | `package updates` | Upgradable packages. | +| `GET` | `/uniget/v1/packages/details` | Yes | Package action query set | `package details` | Details payload. | +| `GET` | `/uniget/v1/packages/versions` | Yes | Package action query set | `package versions` | Installable versions. | +| `GET` | `/uniget/v1/packages/ignored` | Yes | None | `package ignored list` | Ignored-update rules. | +| `POST` | `/uniget/v1/packages/ignore` | Yes | Package action query set | `package ignored add` | Adds ignored-update rule. | +| `POST` | `/uniget/v1/packages/unignore` | Yes | Package action query set | `package ignored remove` | Removes ignored-update rule. | +| `POST` | `/uniget/v1/packages/download` | Yes | Package action query set | `package download` | Starts or performs download. | +| `POST` | `/uniget/v1/packages/install` | Yes | Package action query set | `package install` | Starts or performs install. | +| `POST` | `/uniget/v1/packages/reinstall` | Yes | Package action query set | `package reinstall` | Reinstalls package. | +| `POST` | `/uniget/v1/packages/update` | Yes | Package action query set | `package update` | Updates one package. | +| `POST` | `/uniget/v1/packages/uninstall` | Yes | Package action query set | `package uninstall` | Uninstalls package. | +| `POST` | `/uniget/v1/packages/uninstall-then-reinstall` | Yes | Package action query set | `package repair` | Repair flow. | +| `POST` | `/uniget/v1/packages/show` | Yes | Query `packageId`, `packageSource` | `package show` | UI-oriented package-details flow. | +| `POST` | `/uniget/v1/packages/update-all` | Yes | None | `package update-all` | Requires `OnUpgradeAll` handler to be wired. | +| `POST` | `/uniget/v1/packages/update-manager` | Yes | Query `manager` | `package update-manager` | Requires `OnUpgradeAllForManager` handler to be wired. | + +## Headless-specific limitations + +In headless sessions: + +- `POST /uniget/v1/app/show` fails because there is no window to show. +- `POST /uniget/v1/app/navigate` fails because there is no UI page stack to navigate. +- `POST /uniget/v1/packages/update-all` fails unless a host wires `OnUpgradeAll`. +- `POST /uniget/v1/packages/update-manager` fails unless a host wires `OnUpgradeAllForManager`. + +These failures are intentional and surfaced as HTTP `400` with a descriptive message. + +## Practical testing tip + +If you want to inspect the IPC API manually with generic tools such as `curl`, the easiest route is to start UniGetUI in **TCP mode**: + +```powershell +UniGetUI.exe --headless --ipc-api-transport tcp --ipc-api-port 7058 +``` + +Then: + +```powershell +curl http://localhost:7058/uniget/v1/status +``` + +For authenticated endpoints, you must also supply the session token as the `token` query parameter. The built-in CLI and `IpcClient` resolve that automatically. diff --git a/src/UniGetUI.Avalonia.slnx b/src/UniGetUI.Avalonia.slnx index 75a1eba02e..da15199754 100644 --- a/src/UniGetUI.Avalonia.slnx +++ b/src/UniGetUI.Avalonia.slnx @@ -93,8 +93,8 @@ - - + + diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs index a80ddff526..5b748d6f67 100644 --- a/src/UniGetUI.Avalonia/App.axaml.cs +++ b/src/UniGetUI.Avalonia/App.axaml.cs @@ -7,6 +7,9 @@ using Avalonia.Platform; using Avalonia.Styling; using Avalonia.Threading; +#if AVALONIA_DIAGNOSTICS_ENABLED +using Avalonia.Diagnostics; +#endif using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.Views; using UniGetUI.Avalonia.Views.DialogPages; @@ -63,12 +66,16 @@ public override void OnFrameworkInitializationCompleted() { if (OperatingSystem.IsMacOS()) { - ExpandMacOSPath(); + ProcessEnvironmentConfigurator.PrepareForCurrentPlatform(); using var stream = AssetLoader.Open(new Uri("avares://UniGetUI.Avalonia/Assets/icon.png")); using var ms = new MemoryStream(); stream.CopyTo(ms); MacOsNotificationBridge.SetDockIcon(ms.ToArray()); } + else + { + ProcessEnvironmentConfigurator.ApplyProxySettingsToProcess(); + } PEInterface.LoadLoaders(); ApplyTheme(CoreSettings.GetValue(CoreSettings.K.PreferredTheme)); var mainWindow = new MainWindow(); @@ -95,33 +102,6 @@ void HideOnce(object? s, EventArgs e) base.OnFrameworkInitializationCompleted(); } - /// - /// macOS GUI apps start with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). - /// Ask the user's login shell for its full PATH so package managers (npm, pip, - /// cargo, brew-installed tools, …) can be found. - /// - private static void ExpandMacOSPath() - { - try - { - using var process = new Process - { - StartInfo = new ProcessStartInfo("zsh", ["-l", "-c", "printenv PATH"]) - { - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true, - }, - }; - process.Start(); - string shellPath = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - if (!string.IsNullOrEmpty(shellPath)) - Environment.SetEnvironmentVariable("PATH", shellPath); - } - catch { /* keep the existing PATH if the shell can't be launched */ } - } - private static async Task StartupAsync(MainWindow mainWindow) { // Show crash report from the previous session and wait for the user diff --git a/src/UniGetUI.Avalonia/AvaloniaCliHandler.cs b/src/UniGetUI.Avalonia/AvaloniaCliHandler.cs index ce7c5e729a..ad0d76d0d7 100644 --- a/src/UniGetUI.Avalonia/AvaloniaCliHandler.cs +++ b/src/UniGetUI.Avalonia/AvaloniaCliHandler.cs @@ -32,10 +32,10 @@ internal static class AvaloniaCliHandler private enum ExitCode { Success = 0, - Failed = -1, - InvalidParameter = -1073741811, - NoSuchFile = -1073741809, - UnknownSettingsKey = -2, + Failed = 1, + InvalidParameter = 2, + NoSuchFile = 3, + UnknownSettingsKey = 4, } /// @@ -47,7 +47,7 @@ private enum ExitCode { if (args.Contains(HELP)) { - CoreTools.Launch("https://github.com/Devolutions/UniGetUI/blob/main/cli-arguments.md#unigetui-command-line-parameters"); + CoreTools.Launch("https://github.com/Devolutions/UniGetUI/blob/main/docs/CLI.md#unigetui-command-line-interface"); return (int)ExitCode.Success; } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index 2aa4c57cf8..299dae90b1 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -12,6 +12,8 @@ using UniGetUI.Interface.Telemetry; using UniGetUI.PackageEngine; using UniGetUI.PackageEngine.Classes.Manager.Classes; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageOperations; namespace UniGetUI.Avalonia.Infrastructure; @@ -19,7 +21,7 @@ namespace UniGetUI.Avalonia.Infrastructure; internal static class AvaloniaBootstrapper { private static bool _hasStarted; - private static BackgroundApiRunner? _backgroundApi; + private static IpcServer? _ipcApi; public static async Task InitializeAsync() { @@ -97,14 +99,14 @@ private static async Task ShowMissingDependencyDialogAsync( private static Task InitializeSharedServicesAsync() { CoreTools.ReloadLanguageEngineInstance(); - MainWindow.ApplyProxyVariableToProcess(); + ProcessEnvironmentConfigurator.ApplyProxySettingsToProcess(); _ = Task.Run(AvaloniaAutoUpdater.UpdateCheckLoopAsync) .ContinueWith( t => Logger.Error(t.Exception!), CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); - _ = Task.Run(InitializeBackgroundApiAsync) + _ = Task.Run(InitializeIpcApiAsync) .ContinueWith( t => Logger.Error(t.Exception!), CancellationToken.None, @@ -147,46 +149,170 @@ private static async Task InitializePackageEngineAsync() await Task.Run(PEInterface.LoadManagers); } - private static async Task InitializeBackgroundApiAsync() + private static async Task InitializeIpcApiAsync() { try { if (Settings.Get(Settings.K.DisableApi)) return; - _backgroundApi = new BackgroundApiRunner(); - - _backgroundApi.OnOpenWindow += (_, _) => - Dispatcher.UIThread.Post(() => MainWindow.Instance?.ShowFromTray()); - - _backgroundApi.OnOpenUpdatesPage += (_, _) => - Dispatcher.UIThread.Post(() => - { - MainWindow.Instance?.Navigate(PageType.Updates); - MainWindow.Instance?.ShowFromTray(); - }); - - _backgroundApi.OnUpgradeAll += (_, _) => + _ipcApi = new IpcServer + { + SessionKind = IpcTransportOptions.GuiSessionKind, + }; + _ipcApi.AppInfoProvider = () => + Dispatcher.UIThread.InvokeAsync(GetAppInfo).GetAwaiter().GetResult(); + _ipcApi.ShowAppHandler = () => + Dispatcher.UIThread.InvokeAsync(ShowApp).GetAwaiter().GetResult(); + _ipcApi.NavigateAppHandler = request => + Dispatcher.UIThread.InvokeAsync(() => NavigateApp(request)).GetAwaiter().GetResult(); + _ipcApi.QuitAppHandler = () => + Dispatcher.UIThread.InvokeAsync(QuitApp).GetAwaiter().GetResult(); + _ipcApi.ShowPackageHandler = request => + Dispatcher.UIThread.InvokeAsync(() => ShowPackage(request)).GetAwaiter().GetResult(); + + _ipcApi.OnUpgradeAll += (_, _) => Dispatcher.UIThread.Post(() => _ = AvaloniaPackageOperationHelper.UpdateAllAsync()); - _backgroundApi.OnUpgradeAllForManager += (_, managerName) => + _ipcApi.OnUpgradeAllForManager += (_, managerName) => Dispatcher.UIThread.Post(() => _ = AvaloniaPackageOperationHelper.UpdateAllForManagerAsync(managerName)); - _backgroundApi.OnUpgradePackage += (_, packageId) => - Dispatcher.UIThread.Post(() => - _ = AvaloniaPackageOperationHelper.UpdateForIdAsync(packageId)); - - await _backgroundApi.Start(); + await _ipcApi.Start(); } catch (Exception ex) { - Logger.Error("Could not initialize Background API:"); + Logger.Error("Could not initialize IPC API:"); Logger.Error(ex); } } - public static void StopBackgroundApi() => _backgroundApi?.Stop(); + public static async Task StopIpcApiAsync() + { + if (_ipcApi is null) + { + return; + } + + IpcServer ipcApi = _ipcApi; + _ipcApi = null; + await ipcApi.Stop().ConfigureAwait(false); + } + + private static IpcAppInfo GetAppInfo() + { + MainWindow? window = MainWindow.Instance; + return new IpcAppInfo + { + Headless = false, + WindowAvailable = window is not null, + WindowVisible = window?.IsVisible ?? false, + CanShowWindow = window is not null, + CanNavigate = window is not null, + CanQuit = true, + CurrentPage = window is null ? "" : IpcAppPages.ToPageName(window.CurrentPage.ToString()), + SupportedPages = IpcAppPages.SupportedPages, + }; + } + + private static IpcCommandResult ShowApp() + { + MainWindow window = MainWindow.Instance + ?? throw new InvalidOperationException("The application window is not available."); + window.ShowFromTray(); + return IpcCommandResult.Success("show-app"); + } + + private static IpcCommandResult NavigateApp(IpcAppNavigateRequest request) + { + MainWindow window = MainWindow.Instance + ?? throw new InvalidOperationException("The application window is not available."); + string page = IpcAppPages.NormalizePageName(request.Page); + var manager = ResolveManager(request.ManagerName); + + switch (page) + { + case "discover": + window.Navigate(PageType.Discover); + break; + case "updates": + window.Navigate(PageType.Updates); + break; + case "installed": + window.Navigate(PageType.Installed); + break; + case "bundles": + window.Navigate(PageType.Bundles); + break; + case "settings": + window.Navigate(PageType.Settings); + break; + case "managers": + window.OpenManagerSettings(manager); + break; + case "own-log": + window.Navigate(PageType.OwnLog); + break; + case "manager-log": + window.OpenManagerLogs(manager); + break; + case "operation-history": + window.Navigate(PageType.OperationHistory); + break; + case "help": + window.ShowHelp(request.HelpAttachment ?? ""); + break; + case "release-notes": + window.Navigate(PageType.ReleaseNotes); + break; + case "about": + window.Navigate(PageType.About); + break; + default: + throw new InvalidOperationException( + $"Unsupported app page \"{request.Page}\"." + ); + } + + window.ShowFromTray(); + return IpcCommandResult.Success("navigate-app"); + } + + private static IpcCommandResult ShowPackage(IpcPackageActionRequest request) + { + MainWindow window = MainWindow.Instance + ?? throw new InvalidOperationException("The application window is not available."); + IPackage package = IpcPackageApi.ResolvePackage(request); + window.ShowFromTray(); + _ = new PackageDetailsWindow(package, OperationType.Install).ShowDialog(window); + return IpcCommandResult.Success("show-package"); + } + + private static IpcCommandResult QuitApp() + { + MainWindow window = MainWindow.Instance + ?? throw new InvalidOperationException("The application window is not available."); + _ = Task.Run(async () => + { + await Task.Delay(150); + await Dispatcher.UIThread.InvokeAsync(window.QuitApplication); + }); + return IpcCommandResult.Success("quit-app"); + } + + private static IPackageManager? ResolveManager(string? managerName) + { + if (string.IsNullOrWhiteSpace(managerName)) + { + return null; + } + + return PEInterface.Managers.FirstOrDefault(manager => + manager.Id.Equals(managerName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException( + $"Unknown manager \"{managerName}\"." + ); + } private static async Task LoadElevatorAsync() { diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs index a1fe74b164..77c160a0e1 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs @@ -10,6 +10,7 @@ using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; +using UniGetUI.Interface; using UniGetUI.PackageEngine.Classes.Packages.Classes; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageOperations; @@ -40,6 +41,7 @@ public static class AvaloniaOperationRegistry /// public static void Add(AbstractOperation op) { + IpcOperationApi.Track(op); var vm = new OperationViewModel(op); Dispatcher.UIThread.Post(() => @@ -143,6 +145,10 @@ public static void Remove(OperationViewModel vm) UpdateTrayStatus(); }); while (AbstractOperation.OperationQueue.Remove(vm.Operation)) ; + if (vm.Operation.Status is not (OperationStatus.InQueue or OperationStatus.Running)) + { + IpcOperationApi.ForgetTracking(vm.Operation.Metadata.Identifier); + } } private static async Task RemoveAfterDelayAsync(AbstractOperation op, int milliseconds) @@ -158,6 +164,11 @@ private static async Task RemoveAfterDelayAsync(AbstractOperation op, int millis if (vm is not null) OperationViewModels.Remove(vm); Operations.Remove(op); UpdateTrayStatus(); + if (op.Status is not (OperationStatus.InQueue or OperationStatus.Running)) + { + IpcOperationApi.ForgetTracking(op.Metadata.Identifier); + } + UpdateTrayStatus(); }); } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs index fdf25b8a89..29d7a8331c 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs @@ -42,7 +42,7 @@ public static async Task UpdateAllAsync() public static async Task UpdateAllForManagerAsync(string managerName) { foreach (var pkg in UpgradablePackagesLoader.Instance.Packages - .Where(p => p.Manager.Name == managerName || p.Manager.DisplayName == managerName) + .Where(p => p.Manager.Id == managerName) .ToList()) { if (pkg.Tag is PackageTag.BeingProcessed or PackageTag.OnQueue) continue; diff --git a/src/UniGetUI.Avalonia/Infrastructure/HeadlessDaemonHost.cs b/src/UniGetUI.Avalonia/Infrastructure/HeadlessDaemonHost.cs new file mode 100644 index 0000000000..22ae849822 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/HeadlessDaemonHost.cs @@ -0,0 +1,17 @@ +using UniGetUI.Interface; +using UniGetUI.PackageEngine; + +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class HeadlessDaemonHost +{ + public static async Task RunAsync() + { + return await HeadlessIpcHost.RunAsync(async () => + { + ProcessEnvironmentConfigurator.PrepareForCurrentPlatform(); + PEInterface.LoadLoaders(); + await Task.Run(PEInterface.LoadManagers); + }); + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/HeadlessModeOptions.cs b/src/UniGetUI.Avalonia/Infrastructure/HeadlessModeOptions.cs new file mode 100644 index 0000000000..f877a93745 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/HeadlessModeOptions.cs @@ -0,0 +1,11 @@ +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class HeadlessModeOptions +{ + public const string HeadlessArgument = "--headless"; + + public static bool IsHeadless(IReadOnlyList args) + { + return args.Contains(HeadlessArgument, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/ProcessEnvironmentConfigurator.cs b/src/UniGetUI.Avalonia/Infrastructure/ProcessEnvironmentConfigurator.cs new file mode 100644 index 0000000000..469b374798 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/ProcessEnvironmentConfigurator.cs @@ -0,0 +1,85 @@ +using System.Diagnostics; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; + +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class ProcessEnvironmentConfigurator +{ + public static void PrepareForCurrentPlatform() + { + if (OperatingSystem.IsMacOS()) + { + ExpandMacOSPath(); + } + + ApplyProxySettingsToProcess(); + } + + public static void ApplyProxySettingsToProcess() + { + try + { + var proxyUri = Settings.GetProxyUrl(); + if (proxyUri is null || !Settings.Get(Settings.K.EnableProxy)) + { + Environment.SetEnvironmentVariable("HTTP_PROXY", "", EnvironmentVariableTarget.Process); + return; + } + + string content; + if (!Settings.Get(Settings.K.EnableProxyAuth)) + { + content = proxyUri.ToString(); + } + else + { + var creds = Settings.GetProxyCredentials(); + if (creds is null) + { + content = proxyUri.ToString(); + } + else + { + content = $"{proxyUri.Scheme}://{Uri.EscapeDataString(creds.UserName)}" + + $":{Uri.EscapeDataString(creds.Password)}" + + $"@{proxyUri.AbsoluteUri.Replace($"{proxyUri.Scheme}://", "")}"; + } + } + + Environment.SetEnvironmentVariable("HTTP_PROXY", content, EnvironmentVariableTarget.Process); + } + catch (Exception ex) + { + Logger.Error("Failed to apply proxy settings:"); + Logger.Error(ex); + } + } + + private static void ExpandMacOSPath() + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo("zsh", ["-l", "-c", "printenv PATH"]) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }, + }; + process.Start(); + string shellPath = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + if (!string.IsNullOrEmpty(shellPath)) + { + Environment.SetEnvironmentVariable("PATH", shellPath); + } + } + catch + { + // Keep the existing PATH if the shell can't be launched. + } + } +} diff --git a/src/UniGetUI.Avalonia/Program.cs b/src/UniGetUI.Avalonia/Program.cs index 9a17876f1b..8f5d3e8561 100644 --- a/src/UniGetUI.Avalonia/Program.cs +++ b/src/UniGetUI.Avalonia/Program.cs @@ -3,6 +3,7 @@ using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; +using UniGetUI.Interface; namespace UniGetUI.Avalonia; @@ -21,6 +22,11 @@ public static void Main(string[] args) AppDomain.CurrentDomain.UnhandledException += (_, e) => CrashHandler.ReportFatalException((Exception)e.ExceptionObject); + if (ShouldPrepareCliConsole(args)) + { + WindowsConsoleHost.PrepareCliIO(); + } + // Handle pre-UI CLI arguments (settings manipulation, help, etc.) without // launching the Avalonia UI. Mirrors WinUI's EntryPoint.cs dispatch logic. if (AvaloniaCliHandler.HandlePreUiArgs(args) is { } exitCode) @@ -29,6 +35,20 @@ public static void Main(string[] args) return; } + if (IpcCliSyntax.IsIpcCommand(args)) + { + Environment.ExitCode = IpcCliCommandRunner.RunAsync(args, Console.Out, Console.Error) + .GetAwaiter() + .GetResult(); + return; + } + + if (HeadlessModeOptions.IsHeadless(args)) + { + Environment.ExitCode = HeadlessDaemonHost.RunAsync().GetAwaiter().GetResult(); + return; + } + CoreData.WasDaemon = CoreData.IsDaemon = args.Contains(AvaloniaCliHandler.DAEMON); if (!TryRegisterSingleInstance(args)) @@ -37,6 +57,11 @@ public static void Main(string[] args) BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } + private static bool ShouldPrepareCliConsole(IReadOnlyList args) + { + return IpcCliSyntax.HasVerbCommand(args); + } + private static bool TryRegisterSingleInstance(string[] args) { if (!OperatingSystem.IsWindows()) diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index da7a2189fc..000ac0fc9e 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -92,7 +92,7 @@ - + @@ -134,21 +134,22 @@ - - - - + + + + + - - - - - - - - App.axaml - + + + + + + + + App.axaml + UserAvatarControl.axaml Code diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs index 6abbca80a0..82d4ab383d 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs @@ -123,10 +123,10 @@ partial void OnSelectedProfileChanged(string? value) [ObservableProperty] private bool _skipMinorChecked; [ObservableProperty] private bool _autoUpdateChecked; - partial void OnAdminCheckedChanged(bool _) => Refresh(); - partial void OnInteractiveCheckedChanged(bool _) => Refresh(); - partial void OnSkipHashCheckedChanged(bool _) => Refresh(); - partial void OnSelectedVersionChanged(string? _) => Refresh(); + partial void OnAdminCheckedChanged(bool value) => Refresh(); + partial void OnInteractiveCheckedChanged(bool value) => Refresh(); + partial void OnSkipHashCheckedChanged(bool value) => Refresh(); + partial void OnSelectedVersionChanged(string? value) => Refresh(); // ── Architecture / Scope / Location tab ─────────────────────────────────── [ObservableProperty] private bool _archEnabled; @@ -140,17 +140,17 @@ partial void OnSelectedProfileChanged(string? value) [ObservableProperty] private string _locationText = ""; [ObservableProperty] private bool _locationEnabled; - partial void OnSelectedArchChanged(string? _) => Refresh(); - partial void OnSelectedScopeChanged(string? _) => Refresh(); + partial void OnSelectedArchChanged(string? value) => Refresh(); + partial void OnSelectedScopeChanged(string? value) => Refresh(); // ── CLI params tab ──────────────────────────────────────────────────────── [ObservableProperty] private string _paramsInstall = ""; [ObservableProperty] private string _paramsUpdate = ""; [ObservableProperty] private string _paramsUninstall = ""; - partial void OnParamsInstallChanged(string _) => Refresh(); - partial void OnParamsUpdateChanged(string _) => Refresh(); - partial void OnParamsUninstallChanged(string _) => Refresh(); + partial void OnParamsInstallChanged(string value) => Refresh(); + partial void OnParamsUpdateChanged(string value) => Refresh(); + partial void OnParamsUninstallChanged(string value) => Refresh(); // ── Pre/Post commands tab ───────────────────────────────────────────────── [ObservableProperty] private string _preInstallText = ""; diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InstallOptionsPanelViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InstallOptionsPanelViewModel.cs index ed73b36b5f..33a5bae1cd 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InstallOptionsPanelViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InstallOptionsPanelViewModel.cs @@ -97,13 +97,13 @@ public partial class InstallOptionsPanelViewModel : ViewModelBase partial void OnLocationSelectEnabledChanged(bool value) => OnPropertyChanged(nameof(LocationOpacity)); // Mark HasChanges when user edits options (guards against firing during load) - partial void OnAdminCheckedChanged(bool _) => HasChanges = !IsLoading; - partial void OnInteractiveCheckedChanged(bool _) => HasChanges = !IsLoading; - partial void OnSkipHashCheckedChanged(bool _) => HasChanges = !IsLoading; - partial void OnPreReleaseCheckedChanged(bool _) => HasChanges = !IsLoading; - partial void OnUninstallPreviousCheckedChanged(bool _) => HasChanges = !IsLoading; - partial void OnSelectedArchitectureChanged(string? _) => HasChanges = !IsLoading; - partial void OnSelectedScopeChanged(string? _) => HasChanges = !IsLoading; + partial void OnAdminCheckedChanged(bool value) => HasChanges = !IsLoading; + partial void OnInteractiveCheckedChanged(bool value) => HasChanges = !IsLoading; + partial void OnSkipHashCheckedChanged(bool value) => HasChanges = !IsLoading; + partial void OnPreReleaseCheckedChanged(bool value) => HasChanges = !IsLoading; + partial void OnUninstallPreviousCheckedChanged(bool value) => HasChanges = !IsLoading; + partial void OnSelectedArchitectureChanged(string? value) => HasChanges = !IsLoading; + partial void OnSelectedScopeChanged(string? value) => HasChanges = !IsLoading; public InstallOptionsPanelViewModel(IPackageManager manager) { diff --git a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs index 914d947adf..9704fa30af 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs @@ -23,13 +23,13 @@ public partial class SidebarViewModel : ViewModelBase partial void OnUpdatesBadgeCountChanged(int value) => UpdatesBadgeVisible = value > 0; - partial void OnUpdatesBadgeVisibleChanged(bool _) + partial void OnUpdatesBadgeVisibleChanged(bool value) { OnPropertyChanged(nameof(UpdatesBadgeExpandedVisible)); OnPropertyChanged(nameof(UpdatesBadgeCompactVisible)); } - partial void OnBundlesBadgeVisibleChanged(bool _) + partial void OnBundlesBadgeVisibleChanged(bool value) { OnPropertyChanged(nameof(BundlesBadgeExpandedVisible)); OnPropertyChanged(nameof(BundlesBadgeCompactVisible)); diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index 1df5a75e68..82842dc110 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -15,6 +15,7 @@ using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Interfaces; namespace UniGetUI.Avalonia.Views; @@ -62,6 +63,7 @@ public enum RuntimeNotificationLevel public static MainWindow? Instance { get; private set; } private MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!; + public PageType CurrentPage => ViewModel.CurrentPage_t; public MainWindow() { @@ -491,6 +493,10 @@ private void SearchBox_KeyDown(object? sender, KeyEventArgs e) // ─── Public navigation API ──────────────────────────────────────────────── public void Navigate(PageType type) => ViewModel.NavigateTo(type); + public void OpenManagerLogs(IPackageManager? manager = null) => ViewModel.OpenManagerLogs(manager); + public void OpenManagerSettings(IPackageManager? manager = null) => + ViewModel.OpenManagerSettings(manager); + public void ShowHelp(string uriAttachment = "") => ViewModel.ShowHelp(uriAttachment); /// /// Focuses the global search box and optionally pre-fills a character typed @@ -544,6 +550,7 @@ public void ShowFromTray() public void QuitApplication() { _allowClose = true; + AvaloniaBootstrapper.StopIpcApiAsync().GetAwaiter().GetResult(); (global::Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); } diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs index c9af77361d..18a2f2860c 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs @@ -466,7 +466,7 @@ public static IPackage DeserializePackage(SerializablePackage raw) IPackageManager? manager = null; foreach (var m in PEInterface.Managers) { - if (m.Name == raw.ManagerName || m.DisplayName == raw.ManagerName) + if (m.Id == raw.ManagerName || m.Name == raw.ManagerName || m.DisplayName == raw.ManagerName) { manager = m; break; } } diff --git a/src/UniGetUI.Core.SecureSettings/SecureGHTokenManager.cs b/src/UniGetUI.Core.SecureSettings/SecureGHTokenManager.cs index 1970bbbfee..d5aa7b4908 100644 --- a/src/UniGetUI.Core.SecureSettings/SecureGHTokenManager.cs +++ b/src/UniGetUI.Core.SecureSettings/SecureGHTokenManager.cs @@ -6,6 +6,7 @@ namespace UniGetUI.Core.SecureSettings public static class SecureGHTokenManager { private const string GitHubResourceName = "UniGetUI/GitHubAccessToken"; + private const string CredentialNamespaceEnvironmentVariable = "UNIGETUI_GITHUB_TOKEN_NAMESPACE"; private static readonly string UserName = Environment.UserName; public static void StoreToken(string token) @@ -21,7 +22,7 @@ public static void StoreToken(string token) if (GetToken() is not null) DeleteToken(); // Delete any old token(s) - CoreCredentialStore.SetSecret(GitHubResourceName, UserName, token); + CoreCredentialStore.SetSecret(GetScopedResourceName(), UserName, token); Logger.Info("GitHub access token stored/updated securely."); } catch (Exception ex) @@ -37,7 +38,7 @@ public static void StoreToken(string token) { try { - string? token = CoreCredentialStore.GetSecret(GitHubResourceName, UserName); + string? token = CoreCredentialStore.GetSecret(GetScopedResourceName(), UserName); if (token is null) { return null; @@ -57,7 +58,7 @@ public static void DeleteToken() { try { - CoreCredentialStore.DeleteSecret(GitHubResourceName, UserName); + CoreCredentialStore.DeleteSecret(GetScopedResourceName(), UserName); Logger.Info("GitHub access token deleted."); } catch (Exception ex) @@ -68,5 +69,16 @@ public static void DeleteToken() Logger.Error(ex); } } + + private static string GetScopedResourceName() + { + string? credentialNamespace = Environment.GetEnvironmentVariable( + CredentialNamespaceEnvironmentVariable + ); + + return string.IsNullOrWhiteSpace(credentialNamespace) + ? GitHubResourceName + : $"{GitHubResourceName}/{credentialNamespace.Trim()}"; + } } } diff --git a/src/UniGetUI.Core.SecureSettings/SecureSettings.cs b/src/UniGetUI.Core.SecureSettings/SecureSettings.cs index 03ddc7be44..e5c8480612 100644 --- a/src/UniGetUI.Core.SecureSettings/SecureSettings.cs +++ b/src/UniGetUI.Core.SecureSettings/SecureSettings.cs @@ -49,16 +49,43 @@ public static class Args public static bool Get(K key) { - string purifiedSetting = CoreTools.MakeValidFileName(ResolveKey(key)); - return _cache.GetOrAdd(purifiedSetting, ResolveSettingValue); + return GetForUser(Environment.UserName, key); + } + + public static bool GetForUser(string username, K key) + { + return GetForUser(username, ResolveKey(key)); + } + + public static bool GetForUser(string username, string setting) + { + string purifiedSetting = CoreTools.MakeValidFileName(setting); + string purifiedUser = CoreTools.MakeValidFileName(username); + string cacheKey = $"{purifiedUser}|{purifiedSetting}"; + if (_cache.TryGetValue(cacheKey, out var value)) + { + return value; + } + + var settingsLocation = Path.Join(GetSecureSettingsRoot(), purifiedUser); + var settingFile = Path.Join(settingsLocation, purifiedSetting); + + if (!Directory.Exists(settingsLocation)) + { + _cache[cacheKey] = false; + return false; + } + + bool exists = File.Exists(settingFile); + _cache[cacheKey] = exists; + return exists; } public static async Task TrySet(K key, bool enabled) { string purifiedSetting = CoreTools.MakeValidFileName(ResolveKey(key)); - _cache.TryRemove(purifiedSetting, out _); - string purifiedUser = CoreTools.MakeValidFileName(Environment.UserName); + _cache.TryRemove($"{purifiedUser}|{purifiedSetting}", out _); if (!OperatingSystem.IsWindows()) { @@ -90,9 +117,8 @@ public static int ApplyForUser(string username, string setting, bool enable) try { string purifiedSetting = CoreTools.MakeValidFileName(setting); - _cache.TryRemove(purifiedSetting, out _); - string purifiedUser = CoreTools.MakeValidFileName(username); + _cache.TryRemove($"{purifiedUser}|{purifiedSetting}", out _); var settingsLocation = Path.Join(GetSecureSettingsRoot(), purifiedUser); var settingFile = Path.Join(settingsLocation, purifiedSetting); @@ -142,16 +168,4 @@ private static string GetSecureSettingsRoot() return Path.Join(CoreData.UniGetUIDataDirectory, "SecureSettings"); } - private static bool ResolveSettingValue(string purifiedSetting) - { - string purifiedUser = CoreTools.MakeValidFileName(Environment.UserName); - var settingsLocation = Path.Join(GetSecureSettingsRoot(), purifiedUser); - if (!Directory.Exists(settingsLocation)) - { - return false; - } - - var settingFile = Path.Join(settingsLocation, purifiedSetting); - return File.Exists(settingFile); - } } diff --git a/src/UniGetUI.Interface.BackgroundApi/BackgroundApi.cs b/src/UniGetUI.Interface.BackgroundApi/BackgroundApi.cs deleted file mode 100644 index 1559cb7b13..0000000000 --- a/src/UniGetUI.Interface.BackgroundApi/BackgroundApi.cs +++ /dev/null @@ -1,321 +0,0 @@ -using System.Text; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using UniGetUI.Core.Data; -using UniGetUI.Core.IconEngine; -using UniGetUI.Core.Logging; -using UniGetUI.Core.SettingsEngine; -using UniGetUI.Core.Tools; -using UniGetUI.Interface.Enums; -using UniGetUI.PackageEngine; -using UniGetUI.PackageEngine.Interfaces; -using UniGetUI.PackageEngine.PackageLoader; - -namespace UniGetUI.Interface -{ - internal static class ApiTokenHolder - { - public static string Token = ""; - } - - public class BackgroundApiRunner - { - public event EventHandler? OnOpenWindow; - public event EventHandler? OnOpenUpdatesPage; - public event EventHandler? OnUpgradeAll; - public event EventHandler? OnUpgradeAllForManager; - public event EventHandler? OnUpgradePackage; - - private IHost? _host; - - public BackgroundApiRunner() { } - - public static bool AuthenticateToken(string? token) - { - return token == ApiTokenHolder.Token; - } - - public async Task Start() - { - ApiTokenHolder.Token = CoreTools.RandomString(64); - Settings.SetValue(Settings.K.CurrentSessionToken, ApiTokenHolder.Token); - Logger.Info("Randomly-generated background API auth token: " + ApiTokenHolder.Token); - - var builder = Host.CreateDefaultBuilder(); - builder.ConfigureServices(services => services.AddCors()); - builder.ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseKestrel(); -#if !DEBUG - webBuilder.SuppressStatusMessages(true); -#endif - webBuilder.Configure(app => - { - app.UseCors(policy => - policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() - ); - - app.UseRouting(); - app.UseEndpoints(endpoints => - { - endpoints.MapGet("/v2/show-package", V2_ShowPackage); - endpoints.MapGet("/is-running", API_IsRunning); - // Widgets v1 API - endpoints.MapGet( - "/widgets/v1/get_wingetui_version", - WIDGETS_V1_GetUniGetUIVersion - ); - endpoints.MapGet("/widgets/v1/get_updates", WIDGETS_V1_GetUpdates); - endpoints.MapGet("/widgets/v1/open_wingetui", WIDGETS_V1_OpenUniGetUI); - endpoints.MapGet("/widgets/v1/view_on_wingetui", WIDGETS_V1_ViewOnUniGetUI); - endpoints.MapGet("/widgets/v1/update_package", WIDGETS_V1_UpdatePackage); - endpoints.MapGet( - "/widgets/v1/update_all_packages", - WIDGETS_V1_UpdateAllPackages - ); - endpoints.MapGet( - "/widgets/v1/update_all_packages_for_source", - WIDGETS_V1_UpdateAllPackagesForSource - ); - // Widgets v2 API - endpoints.MapGet( - "/widgets/v2/get_icon_for_package", - WIDGETS_V2_GetIconForPackage - ); - }); - }); - webBuilder.UseUrls("http://localhost:7058"); - }); - _host = builder.Build(); - await _host.StartAsync(); - Logger.Info("Api running on http://localhost:7058"); - } - - private async Task V2_ShowPackage(HttpContext context) - { - context.Response.StatusCode = StatusCodes.Status410Gone; - await context.Response.WriteAsync( - "{\"status\": \"removed\", \"message\": \"package sharing has been removed\"}" - ); - } - - private async Task API_IsRunning(HttpContext context) - { - await context.Response.WriteAsync("{\"status\": \"success\"}"); - } - - private async Task WIDGETS_V1_GetUniGetUIVersion(HttpContext context) - { - if (!AuthenticateToken(context.Request.Query["token"])) - { - context.Response.StatusCode = 401; - return; - } - - await context.Response.WriteAsync(CoreData.BuildNumber.ToString()); - } - - private async Task WIDGETS_V1_GetUpdates(HttpContext context) - { - if (!AuthenticateToken(context.Request.Query["token"])) - { - context.Response.StatusCode = 401; - return; - } - - if ( - !UpgradablePackagesLoader.Instance.IsLoaded - && !UpgradablePackagesLoader.Instance.IsLoading - ) - { - _ = UpgradablePackagesLoader.Instance.ReloadPackages(); - } - - while (UpgradablePackagesLoader.Instance.IsLoading) - { - await Task.Delay(100); - } - - StringBuilder packages = new(); - foreach (IPackage package in UpgradablePackagesLoader.Instance.Packages) - { - if (package.Tag is PackageTag.OnQueue or PackageTag.BeingProcessed) - continue; - - string icon = - $"http://localhost:7058/widgets/v2/get_icon_for_package?packageId={Uri.EscapeDataString(package.Id)}&packageSource={Uri.EscapeDataString(package.Source.Name)}&token={ApiTokenHolder.Token}"; - packages.Append( - $"{package.Name.Replace('|', '-')}" - + $"|{package.Id}" - + $"|{package.VersionString}" - + $"|{package.NewVersionString}" - + $"|{package.Source.AsString_DisplayName}" - + $"|{package.Manager.Name}" - + $"|{icon}&&" - ); - } - - string result = packages.ToString(); - if (result.Length > 2) - result = result[..(result.Length - 2)]; - - await context.Response.WriteAsync(result); - } - - private async Task WIDGETS_V1_OpenUniGetUI(HttpContext context) - { - if (!AuthenticateToken(context.Request.Query["token"])) - { - context.Response.StatusCode = 401; - return; - } - - OnOpenWindow?.Invoke(null, EventArgs.Empty); - context.Response.StatusCode = 200; - } - - private async Task WIDGETS_V1_ViewOnUniGetUI(HttpContext context) - { - if (!AuthenticateToken(context.Request.Query["token"])) - { - context.Response.StatusCode = 401; - return; - } - - OnOpenUpdatesPage?.Invoke(null, EventArgs.Empty); - context.Response.StatusCode = 200; - } - - private async Task WIDGETS_V1_UpdatePackage(HttpContext context) - { - if (!AuthenticateToken(context.Request.Query["token"])) - { - context.Response.StatusCode = 401; - return; - } - - var id = context.Request.Query["id"]; - if (string.IsNullOrEmpty(id)) - { - context.Response.StatusCode = 400; - return; - } - - string packageId = id.ToString(); - OnUpgradePackage?.Invoke(null, packageId); - context.Response.StatusCode = 200; - } - - private async Task WIDGETS_V1_UpdateAllPackages(HttpContext context) - { - if (!AuthenticateToken(context.Request.Query["token"])) - { - context.Response.StatusCode = 401; - return; - } - - Logger.Info("[WIDGETS] Updating all packages"); - OnUpgradeAll?.Invoke(null, EventArgs.Empty); - context.Response.StatusCode = 200; - } - - private async Task WIDGETS_V1_UpdateAllPackagesForSource(HttpContext context) - { - if (!AuthenticateToken(context.Request.Query["token"])) - { - context.Response.StatusCode = 401; - return; - } - - var source = context.Request.Query["source"]; - if (string.IsNullOrEmpty(source)) - { - context.Response.StatusCode = 400; - return; - } - - string sourceName = source.ToString(); - Logger.Info($"[WIDGETS] Updating all packages for manager {sourceName}"); - OnUpgradeAllForManager?.Invoke(null, sourceName); - context.Response.StatusCode = 200; - } - - private async Task WIDGETS_V2_GetIconForPackage(HttpContext context) - { - if (!AuthenticateToken(context.Request.Query["token"])) - { - context.Response.StatusCode = 401; - return; - } - - var packageId = context.Request.Query["packageId"]; - var packageSource = context.Request.Query["packageSource"]; - if (string.IsNullOrEmpty(packageId) || string.IsNullOrEmpty(packageSource)) - { - context.Response.StatusCode = 400; - return; - } - - string iconPath = Path.Join( - CoreData.UniGetUIExecutableDirectory, - "Assets", - "Images", - "package_color.png" - ); - - string resolvedPackageId = packageId.ToString(); - IPackage? package = UpgradablePackagesLoader.Instance.GetPackageForId( - resolvedPackageId, - packageSource - ); - if (package != null) - { - var iconUrl = await Task.Run(package.GetIconUrl); - if (iconUrl.ToString() != "ms-appx:///Assets/Images/package_color.png") - { - string mimePath = Path.Join( - CoreData.UniGetUICacheDirectory_Icons, - package.Manager.Name, - package.Id, - "icon.mime" - ); - iconPath = Path.Join( - CoreData.UniGetUICacheDirectory_Icons, - package.Manager.Name, - package.Id, - $"icon.{IconCacheEngine.MimeToExtension[await File.ReadAllTextAsync(mimePath)]}" - ); - } - } - else - { - Logger.Warn($"[API] Package id={packageId} with source={packageSource} not found!"); - } - - var bytes = await File.ReadAllBytesAsync(iconPath); - var ext = Path.GetExtension(iconPath).TrimStart('.').ToLower(); - context.Response.ContentType = IconCacheEngine.ExtensionToMime.GetValueOrDefault( - ext, - "image/png" - ); - await context.Response.Body.WriteAsync(bytes.AsMemory()); - } - - public async Task Stop() - { - try - { - ArgumentNullException.ThrowIfNull(_host); - await _host.StopAsync(); - Logger.Info("Api was shut down"); - } - catch (Exception ex) - { - Logger.Error(ex); - } - } - } -} diff --git a/src/UniGetUI.Interface.IpcApi/HeadlessIpcHost.cs b/src/UniGetUI.Interface.IpcApi/HeadlessIpcHost.cs new file mode 100644 index 0000000000..212169cda9 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/HeadlessIpcHost.cs @@ -0,0 +1,112 @@ +using UniGetUI.Core.Logging; + +namespace UniGetUI.Interface; + +public static class HeadlessIpcHost +{ + public static async Task RunAsync(Func initializeAsync, string hostName = "UniGetUI") + { + ArgumentNullException.ThrowIfNull(initializeAsync); + + IpcServer? backgroundApi = null; + using var shutdown = new CancellationTokenSource(); + + void RequestShutdown() + { + if (!shutdown.IsCancellationRequested) + { + shutdown.Cancel(); + } + } + + ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + RequestShutdown(); + }; + Console.CancelKeyPress += cancelHandler; + + EventHandler processExitHandler = (_, _) => RequestShutdown(); + AppDomain.CurrentDomain.ProcessExit += processExitHandler; + + try + { + Logger.Info($"Starting {hostName} headless daemon"); + + await initializeAsync(); + + backgroundApi = CreateIpcServer(RequestShutdown); + await backgroundApi.Start(); + + Logger.Info($"{hostName} headless daemon is ready"); + await WaitForShutdownAsync(shutdown.Token); + return 0; + } + catch (Exception ex) + { + Logger.Error($"{hostName} headless daemon failed to start"); + Logger.Error(ex); + return ex.HResult != 0 ? ex.HResult : 1; + } + finally + { + AppDomain.CurrentDomain.ProcessExit -= processExitHandler; + Console.CancelKeyPress -= cancelHandler; + + if (backgroundApi is not null) + { + await backgroundApi.Stop(); + } + } + } + + private static IpcServer CreateIpcServer(Action requestShutdown) + { + var backgroundApi = new IpcServer + { + SessionKind = IpcTransportOptions.HeadlessSessionKind, + }; + backgroundApi.AppInfoProvider = () => + new IpcAppInfo + { + Headless = true, + WindowAvailable = false, + WindowVisible = false, + CanShowWindow = false, + CanNavigate = false, + CanQuit = true, + SupportedPages = IpcAppPages.SupportedPages, + }; + backgroundApi.ShowAppHandler = () => + throw new InvalidOperationException( + "The current UniGetUI session is running headless and has no window to show." + ); + backgroundApi.NavigateAppHandler = _ => + throw new InvalidOperationException( + "The current UniGetUI session is running headless and cannot navigate UI pages." + ); + backgroundApi.QuitAppHandler = () => + { + _ = Task.Run(async () => + { + await Task.Delay(150); + requestShutdown(); + }); + return IpcCommandResult.Success("quit-app"); + }; + + return backgroundApi; + } + + private static Task WaitForShutdownAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.CompletedTask; + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationToken.Register(() => completion.TrySetResult()); + return completion.Task; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/InternalsVisibleTo.cs b/src/UniGetUI.Interface.IpcApi/InternalsVisibleTo.cs new file mode 100644 index 0000000000..f5236ec50a --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.Tests")] diff --git a/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs b/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs new file mode 100644 index 0000000000..05ea7b5734 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs @@ -0,0 +1,89 @@ +namespace UniGetUI.Interface; + +public sealed class IpcAppInfo +{ + public bool Headless { get; set; } + public bool WindowAvailable { get; set; } + public bool WindowVisible { get; set; } + public bool CanShowWindow { get; set; } + public bool CanNavigate { get; set; } + public bool CanQuit { get; set; } + public string CurrentPage { get; set; } = ""; + public IReadOnlyList SupportedPages { get; set; } = IpcAppPages.SupportedPages; +} + +public sealed class IpcAppNavigateRequest +{ + public string Page { get; set; } = ""; + public string? ManagerName { get; set; } + public string? HelpAttachment { get; set; } +} + +public static class IpcAppPages +{ + public static readonly IReadOnlyList SupportedPages = + [ + "discover", + "updates", + "installed", + "bundles", + "settings", + "managers", + "own-log", + "manager-log", + "operation-history", + "help", + "release-notes", + "about", + ]; + + public static string NormalizePageName(string page) + { + ArgumentException.ThrowIfNullOrWhiteSpace(page); + + string normalized = page.Trim().ToLowerInvariant(); + return normalized switch + { + "discover" => normalized, + "updates" => normalized, + "installed" => normalized, + "bundles" => normalized, + "settings" => normalized, + "managers" => normalized, + "own-log" => normalized, + "manager-log" => normalized, + "operation-history" => normalized, + "help" => normalized, + "release-notes" => normalized, + "about" => normalized, + _ => throw new InvalidOperationException( + $"Unsupported page \"{page}\". Supported pages: {string.Join(", ", SupportedPages)}." + ), + }; + } + + public static string ToPageName(string? pageTypeName) + { + if (string.IsNullOrWhiteSpace(pageTypeName)) + { + return ""; + } + + return pageTypeName switch + { + "Discover" => "discover", + "Updates" => "updates", + "Installed" => "installed", + "Bundles" => "bundles", + "Settings" => "settings", + "Managers" => "managers", + "OwnLog" => "own-log", + "ManagerLog" => "manager-log", + "OperationHistory" => "operation-history", + "Help" => "help", + "ReleaseNotes" => "release-notes", + "About" => "about", + _ => pageTypeName.Trim(), + }; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcBackupApi.cs b/src/UniGetUI.Interface.IpcApi/IpcBackupApi.cs new file mode 100644 index 0000000000..13f39bbccf --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcBackupApi.cs @@ -0,0 +1,594 @@ +using Octokit; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SecureSettings; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.Interface; + +public sealed class IpcGitHubAuthInfo +{ + public bool ClientConfigured { get; set; } + public bool IsAuthenticated { get; set; } + public string? Login { get; set; } + public bool DeviceFlowPending { get; set; } + public string? UserCode { get; set; } + public string? VerificationUri { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public int? PollIntervalSeconds { get; set; } +} + +public sealed class IpcBackupStatus +{ + public bool LocalBackupEnabled { get; set; } + public bool CloudBackupEnabled { get; set; } + public string BackupDirectory { get; set; } = ""; + public string? CustomBackupDirectory { get; set; } + public string BackupFileName { get; set; } = ""; + public bool TimestampingEnabled { get; set; } + public string CurrentMachineBackupKey { get; set; } = ""; + public IpcGitHubAuthInfo Auth { get; set; } = new(); +} + +public class IpcBackupCommandResult +{ + public string Status { get; set; } = "success"; + public string Command { get; set; } = ""; + public string? Message { get; set; } +} + +public sealed class IpcLocalBackupResult : IpcBackupCommandResult +{ + public string Path { get; set; } = ""; + public string FileName { get; set; } = ""; + public int PackageCount { get; set; } +} + +public sealed class IpcCloudBackupEntry +{ + public string Key { get; set; } = ""; + public string Display { get; set; } = ""; + public bool IsCurrentMachine { get; set; } +} + +public sealed class IpcCloudBackupUploadResult : IpcBackupCommandResult +{ + public string Key { get; set; } = ""; + public int PackageCount { get; set; } +} + +public sealed class IpcCloudBackupRequest +{ + public string Key { get; set; } = ""; + public bool Append { get; set; } +} + +public sealed class IpcCloudBackupContentResult : IpcBackupCommandResult +{ + public string Key { get; set; } = ""; + public string Content { get; set; } = ""; +} + +public sealed class IpcCloudBackupRestoreResult : IpcBackupCommandResult +{ + public string Key { get; set; } = ""; + public double SchemaVersion { get; set; } + public IpcBundleInfo Bundle { get; set; } = new(); + public IReadOnlyList SecurityReport { get; set; } = []; +} + +public sealed class IpcGitHubDeviceFlowRequest +{ + public bool LaunchBrowser { get; set; } +} + +public sealed class IpcGitHubAuthResult : IpcBackupCommandResult +{ + public IpcGitHubAuthInfo Auth { get; set; } = new(); +} + +public static class IpcBackupApi +{ + private const string MissingClientId = "CLIENT_ID_UNSET"; + private const string GistDescriptionEndingKey = "@[UNIGETUI_BACKUP_V1]"; + private const string PackageBackupStartingKey = "@[PACKAGES]"; + private const string GistDescription = + "UniGetUI package backups - DO NOT RENAME OR MODIFY " + GistDescriptionEndingKey; + private const string ReadMeContents = + "This special Gist is used by UniGetUI to store your package backups.\n" + + "Please DO NOT EDIT the contents or the description of this gist, or unexpected behaviours may occur.\n" + + "Learn more about UniGetUI at https://github.com/Devolutions/UniGetUI\n"; + + private static readonly object GitHubAuthLock = new(); + private static PendingGitHubDeviceFlow? _pendingGitHubDeviceFlow; + + private sealed class PendingGitHubDeviceFlow + { + public required OauthDeviceFlowResponse DeviceFlow { get; init; } + public required DateTimeOffset ExpiresAtUtc { get; init; } + } + + public static async Task GetStatusAsync() + { + string? customBackupDirectory = Settings.Get(Settings.K.ChangeBackupOutputDirectory) + ? Settings.GetValue(Settings.K.ChangeBackupOutputDirectory) + : null; + string backupFileName = Settings.GetValue(Settings.K.ChangeBackupFileName); + if (string.IsNullOrWhiteSpace(backupFileName)) + { + backupFileName = CoreTools.Translate( + "{pcName} installed packages", + new Dictionary { ["pcName"] = Environment.MachineName } + ); + } + + return new IpcBackupStatus + { + LocalBackupEnabled = Settings.Get(Settings.K.EnablePackageBackup_LOCAL), + CloudBackupEnabled = Settings.Get(Settings.K.EnablePackageBackup_CLOUD), + BackupDirectory = ResolveBackupDirectory(), + CustomBackupDirectory = string.IsNullOrWhiteSpace(customBackupDirectory) + ? null + : customBackupDirectory, + BackupFileName = backupFileName, + TimestampingEnabled = Settings.Get(Settings.K.EnableBackupTimestamping), + CurrentMachineBackupKey = BuildGistFileKey().Split(' ')[^1], + Auth = await GetGitHubAuthInfoAsync(), + }; + } + + public static async Task CreateLocalBackupAsync() + { + var packages = GetInstalledPackagesForBackup(); + string fileName = BuildBackupFileName(); + string outputDirectory = ResolveBackupDirectory(); + Directory.CreateDirectory(outputDirectory); + + string filePath = Path.Combine(outputDirectory, fileName); + string content = await IpcBundleApi.CreateBundleAsync(packages); + await File.WriteAllTextAsync(filePath, content); + + Logger.ImportantInfo("Local backup saved to " + filePath); + return new IpcLocalBackupResult + { + Status = "success", + Command = "create-local-backup", + Path = filePath, + FileName = fileName, + PackageCount = packages.Count, + }; + } + + public static async Task StartGitHubDeviceFlowAsync( + IpcGitHubDeviceFlowRequest? request = null + ) + { + request ??= new IpcGitHubDeviceFlowRequest(); + EnsureGitHubClientConfigured(); + + var client = CreateAnonymousGitHubClient(); + var deviceFlow = await client.Oauth.InitiateDeviceFlow( + new OauthDeviceFlowRequest(Secrets.GetGitHubClientId()) + { + Scopes = { "read:user", "gist" }, + }, + CancellationToken.None + ); + + lock (GitHubAuthLock) + { + _pendingGitHubDeviceFlow = new PendingGitHubDeviceFlow + { + DeviceFlow = deviceFlow, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(deviceFlow.ExpiresIn), + }; + } + + if (request.LaunchBrowser) + { + CoreTools.Launch(deviceFlow.VerificationUri); + } + + return new IpcGitHubAuthResult + { + Status = "success", + Command = "start-github-sign-in", + Message = request.LaunchBrowser + ? "GitHub device flow started and the verification page was opened." + : "GitHub device flow started.", + Auth = await GetGitHubAuthInfoAsync(), + }; + } + + public static async Task CompleteGitHubDeviceFlowAsync() + { + EnsureGitHubClientConfigured(); + + PendingGitHubDeviceFlow pending = GetPendingGitHubDeviceFlow(); + if (DateTimeOffset.UtcNow >= pending.ExpiresAtUtc) + { + ClearPendingGitHubDeviceFlow(); + throw new InvalidOperationException( + "The pending GitHub device flow has expired. Start sign-in again." + ); + } + + try + { + var client = CreateAnonymousGitHubClient(); + var token = await client.Oauth.CreateAccessTokenForDeviceFlow( + Secrets.GetGitHubClientId(), + pending.DeviceFlow, + CancellationToken.None + ); + + if (string.IsNullOrWhiteSpace(token.AccessToken)) + { + throw new InvalidOperationException("GitHub did not return an access token."); + } + + SecureGHTokenManager.StoreToken(token.AccessToken); + var userClient = CreateAuthenticatedGitHubClient(token.AccessToken); + var user = await userClient.User.Current(); + Settings.SetValue(Settings.K.GitHubUserLogin, user.Login ?? string.Empty); + ClearPendingGitHubDeviceFlow(); + + return new IpcGitHubAuthResult + { + Status = "success", + Command = "complete-github-sign-in", + Message = string.IsNullOrWhiteSpace(user.Login) + ? "GitHub sign-in completed." + : $"GitHub sign-in completed for {user.Login}.", + Auth = await GetGitHubAuthInfoAsync(), + }; + } + catch (Exception ex) + { + Logger.Error("An error occurred while completing GitHub device flow sign-in:"); + Logger.Error(ex); + throw new InvalidOperationException( + "GitHub sign-in did not complete successfully. Finish the device authorization and try again." + ); + } + } + + public static async Task SignOutGitHubAsync() + { + Settings.SetValue(Settings.K.GitHubUserLogin, ""); + SecureGHTokenManager.DeleteToken(); + ClearPendingGitHubDeviceFlow(); + + return new IpcGitHubAuthResult + { + Status = "success", + Command = "sign-out-github", + Message = "GitHub sign-out complete.", + Auth = await GetGitHubAuthInfoAsync(), + }; + } + + public static async Task> ListCloudBackupsAsync() + { + var (client, user) = await GetAuthenticatedGitHubContextAsync(); + var backupGist = await GetBackupGistAsync(client, user.Login, createIfMissing: false); + if (backupGist is null) + { + return []; + } + + string currentMachineKey = BuildGistFileKey().Split(' ')[^1]; + return backupGist.Files + .Where(file => file.Key.StartsWith(PackageBackupStartingKey, StringComparison.Ordinal)) + .Select(file => new IpcCloudBackupEntry + { + Key = file.Key.Split(' ')[^1], + Display = file.Key.Split(' ')[^1] + " (" + CoreTools.FormatAsSize(file.Value.Size) + ")", + IsCurrentMachine = file.Key.Split(' ')[^1].Equals( + currentMachineKey, + StringComparison.OrdinalIgnoreCase + ), + }) + .OrderBy(file => file.Key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static async Task CreateCloudBackupAsync() + { + var packages = GetInstalledPackagesForBackup(); + string bundleContents = await IpcBundleApi.CreateBundleAsync(packages); + var (client, user) = await GetAuthenticatedGitHubContextAsync(); + var backupGist = await GetBackupGistAsync(client, user.Login, createIfMissing: true) + ?? throw new InvalidOperationException("The GitHub backup gist could not be created."); + + string fileKey = BuildGistFileKey(); + var update = new GistUpdate { Description = GistDescription }; + if (backupGist.Files.ContainsKey(fileKey)) + { + update.Files[fileKey] = new GistFileUpdate { Content = bundleContents }; + } + else + { + update.Files.Add(fileKey, new GistFileUpdate { Content = bundleContents }); + } + + await client.Gist.Edit(backupGist.Id, update); + return new IpcCloudBackupUploadResult + { + Status = "success", + Command = "create-cloud-backup", + Key = fileKey.Split(' ')[^1], + PackageCount = packages.Count, + }; + } + + public static async Task DownloadCloudBackupAsync( + IpcCloudBackupRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + string key = ValidateBackupKey(request.Key); + string content = await GetCloudBackupContentsAsync(key); + return new IpcCloudBackupContentResult + { + Status = "success", + Command = "download-cloud-backup", + Key = key, + Content = content, + }; + } + + public static async Task RestoreCloudBackupAsync( + IpcCloudBackupRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + string key = ValidateBackupKey(request.Key); + string content = await GetCloudBackupContentsAsync(key); + var importResult = await IpcBundleApi.ImportBundleAsync( + new IpcBundleImportRequest + { + Content = content, + Format = "ubundle", + Append = request.Append, + } + ); + + return new IpcCloudBackupRestoreResult + { + Status = importResult.Status, + Command = "restore-cloud-backup", + Message = importResult.Message, + Key = key, + SchemaVersion = importResult.SchemaVersion, + Bundle = importResult.Bundle, + SecurityReport = importResult.SecurityReport, + }; + } + + private static IReadOnlyList GetInstalledPackagesForBackup() + { + return InstalledPackagesLoader.Instance?.Packages.ToList() + ?? throw new InvalidOperationException("The installed packages loader is not available."); + } + + private static string ResolveBackupDirectory() + { + string directory = Settings.GetValue(Settings.K.ChangeBackupOutputDirectory); + return string.IsNullOrWhiteSpace(directory) + ? CoreData.UniGetUI_DefaultBackupDirectory + : directory; + } + + private static string BuildBackupFileName() + { + string fileName = Settings.GetValue(Settings.K.ChangeBackupFileName); + if (string.IsNullOrWhiteSpace(fileName)) + { + fileName = CoreTools.Translate( + "{pcName} installed packages", + new Dictionary { ["pcName"] = Environment.MachineName } + ); + } + + if (Settings.Get(Settings.K.EnableBackupTimestamping)) + { + fileName += " " + DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss"); + } + + return fileName + ".ubundle"; + } + + private static async Task GetGitHubAuthInfoAsync() + { + PendingGitHubDeviceFlow? pending; + lock (GitHubAuthLock) + { + pending = _pendingGitHubDeviceFlow; + } + + bool isAuthenticated = !string.IsNullOrWhiteSpace(SecureGHTokenManager.GetToken()); + string login = Settings.GetValue(Settings.K.GitHubUserLogin); + var auth = new IpcGitHubAuthInfo + { + ClientConfigured = HasConfiguredGitHubClient(), + IsAuthenticated = isAuthenticated, + Login = string.IsNullOrWhiteSpace(login) ? null : login, + DeviceFlowPending = pending is not null && DateTimeOffset.UtcNow < pending.ExpiresAtUtc, + UserCode = pending?.DeviceFlow.UserCode, + VerificationUri = pending?.DeviceFlow.VerificationUri, + ExpiresAt = pending?.ExpiresAtUtc, + PollIntervalSeconds = pending?.DeviceFlow.Interval, + }; + + if (pending is not null && DateTimeOffset.UtcNow >= pending.ExpiresAtUtc) + { + ClearPendingGitHubDeviceFlow(); + auth.DeviceFlowPending = false; + auth.UserCode = null; + auth.VerificationUri = null; + auth.ExpiresAt = null; + auth.PollIntervalSeconds = null; + } + + if (!isAuthenticated) + { + return auth; + } + + if (!string.IsNullOrWhiteSpace(auth.Login)) + { + return auth; + } + + try + { + var client = CreateAuthenticatedGitHubClient(); + var user = await client.User.Current(); + if (!string.IsNullOrWhiteSpace(user.Login)) + { + Settings.SetValue(Settings.K.GitHubUserLogin, user.Login); + auth.Login = user.Login; + } + } + catch (Exception ex) + { + Logger.Warn(ex); + } + + return auth; + } + + private static bool HasConfiguredGitHubClient() + { + string clientId = Secrets.GetGitHubClientId(); + return !string.IsNullOrWhiteSpace(clientId) + && !string.Equals(clientId, MissingClientId, StringComparison.Ordinal); + } + + private static void EnsureGitHubClientConfigured() + { + if (!HasConfiguredGitHubClient()) + { + throw new InvalidOperationException( + "GitHub sign-in is not configured for this build. UNIGETUI_GITHUB_CLIENT_ID is missing." + ); + } + } + + private static GitHubClient CreateAnonymousGitHubClient() + { + return new GitHubClient(new ProductHeaderValue("UniGetUI", CoreData.VersionName)); + } + + private static GitHubClient CreateAuthenticatedGitHubClient(string? token = null) + { + token ??= SecureGHTokenManager.GetToken(); + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("GitHub authentication is required for cloud backups."); + } + + return new GitHubClient(new ProductHeaderValue("UniGetUI", CoreData.VersionName)) + { + Credentials = new Credentials(token), + }; + } + + private static async Task<(GitHubClient Client, User User)> GetAuthenticatedGitHubContextAsync() + { + var client = CreateAuthenticatedGitHubClient(); + var user = await client.User.Current(); + if (!string.IsNullOrWhiteSpace(user.Login)) + { + Settings.SetValue(Settings.K.GitHubUserLogin, user.Login); + } + + return (client, user); + } + + private static async Task GetCloudBackupContentsAsync(string key) + { + var (client, user) = await GetAuthenticatedGitHubContextAsync(); + var backupGist = await GetBackupGistAsync(client, user.Login, createIfMissing: false); + if (backupGist is null) + { + throw new InvalidOperationException("No cloud backups are available for the current account."); + } + + var fullGist = await client.Gist.Get(backupGist.Id); + var file = fullGist.Files.FirstOrDefault(candidate => + candidate.Key.StartsWith(PackageBackupStartingKey, StringComparison.Ordinal) + && candidate.Key.EndsWith(key, StringComparison.Ordinal)); + + if (file.Value?.Content is null) + { + throw new InvalidOperationException($"The cloud backup \"{key}\" was not found."); + } + + return file.Value.Content; + } + + private static async Task GetBackupGistAsync( + GitHubClient client, + string userLogin, + bool createIfMissing + ) + { + var candidates = await client.Gist.GetAllForUser(userLogin); + var backupGist = candidates.FirstOrDefault(candidate => + candidate.Description?.EndsWith(GistDescriptionEndingKey, StringComparison.Ordinal) + == true + ); + + if (backupGist is not null || !createIfMissing) + { + return backupGist; + } + + var newGist = new NewGist { Description = GistDescription, Public = false }; + newGist.Files.Add("- UniGetUI Package Backups", ReadMeContents); + return await client.Gist.Create(newGist); + } + + private static string BuildGistFileKey() + { + string deviceUser = (Environment.MachineName + "\\" + Environment.UserName).Replace( + " ", + string.Empty + ); + return PackageBackupStartingKey + " " + deviceUser; + } + + private static string ValidateBackupKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new InvalidOperationException("The backup key is required."); + } + + return key; + } + + private static PendingGitHubDeviceFlow GetPendingGitHubDeviceFlow() + { + lock (GitHubAuthLock) + { + return _pendingGitHubDeviceFlow + ?? throw new InvalidOperationException( + "No GitHub device flow is pending. Start sign-in first." + ); + } + } + + private static void ClearPendingGitHubDeviceFlow() + { + lock (GitHubAuthLock) + { + _pendingGitHubDeviceFlow = null; + } + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs b/src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs new file mode 100644 index 0000000000..1a4907ddb1 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs @@ -0,0 +1,800 @@ +using System.Text.Json.Nodes; +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.SettingsEngine.SecureSettings; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Classes.Serializable; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.PackageLoader; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.Interface; + +public sealed class IpcBundlePackageInfo +{ + public string Name { get; set; } = ""; + public string Id { get; set; } = ""; + public string Version { get; set; } = ""; + public string DisplayVersion { get; set; } = ""; + public string? SelectedVersion { get; set; } + public string? Scope { get; set; } + public bool PreRelease { get; set; } + public string Source { get; set; } = ""; + public string Manager { get; set; } = ""; + public bool IsCompatible { get; set; } + public bool IsInstalled { get; set; } + public bool IsUpgradable { get; set; } +} + +public sealed class IpcBundleInfo +{ + public int PackageCount { get; set; } + public IReadOnlyList Packages { get; set; } = []; +} + +public sealed class IpcBundleImportRequest +{ + public string? Content { get; set; } + public string? Path { get; set; } + public string? Format { get; set; } + public bool Append { get; set; } +} + +public sealed class IpcBundleExportRequest +{ + public string? Path { get; set; } +} + +public sealed class IpcBundlePackageRequest +{ + public string PackageId { get; set; } = ""; + public string? ManagerName { get; set; } + public string? PackageSource { get; set; } + public string? Version { get; set; } + public string? Scope { get; set; } + public bool? PreRelease { get; set; } + public string? Selection { get; set; } +} + +public sealed class IpcBundleInstallRequest +{ + public bool? IncludeInstalled { get; set; } + public bool? Elevated { get; set; } + public bool? Interactive { get; set; } + public bool? SkipHash { get; set; } +} + +public sealed class IpcBundleSecurityEntry +{ + public string PackageId { get; set; } = ""; + public string Line { get; set; } = ""; + public bool Allowed { get; set; } +} + +public class IpcBundleCommandResult +{ + public string Status { get; set; } = "success"; + public string Command { get; set; } = ""; + public string? Message { get; set; } +} + +public sealed class IpcBundleImportResult : IpcBundleCommandResult +{ + public double SchemaVersion { get; set; } + public string Format { get; set; } = ""; + public IpcBundleInfo Bundle { get; set; } = new(); + public IReadOnlyList SecurityReport { get; set; } = []; +} + +public sealed class IpcBundleExportResult : IpcBundleCommandResult +{ + public string Format { get; set; } = ""; + public string Content { get; set; } = ""; + public string? Path { get; set; } + public IpcBundleInfo Bundle { get; set; } = new(); +} + +public sealed class IpcBundlePackageOperationResult : IpcBundleCommandResult +{ + public IpcBundlePackageInfo? Package { get; set; } + public int RemovedCount { get; set; } + public IpcBundleInfo Bundle { get; set; } = new(); +} + +public sealed class IpcBundleInstallResult : IpcBundleCommandResult +{ + public int RequestedCount { get; set; } + public int SucceededCount { get; set; } + public int FailedCount { get; set; } + public int SkippedCount { get; set; } + public IpcBundleInfo Bundle { get; set; } = new(); + public IReadOnlyList Results { get; set; } = []; +} + +public static class IpcBundleApi +{ + public static async Task GetCurrentBundleAsync() + { + return await BuildBundleInfoAsync(GetLoader().Packages); + } + + public static IpcCommandResult ResetBundle() + { + GetLoader().ClearPackages(); + return IpcCommandResult.Success("reset-bundle"); + } + + public static async Task ImportBundleAsync( + IpcBundleImportRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var loader = GetLoader(); + var format = ResolveImportFormat(request); + var content = await ReadBundleContentAsync(request); + + if (!request.Append) + { + loader.ClearPackages(); + } + + var (schemaVersion, report) = await AddFromBundleAsync(content, format); + return new IpcBundleImportResult + { + Status = "success", + Command = "import-bundle", + SchemaVersion = schemaVersion, + Format = format.ToString().ToLowerInvariant(), + Bundle = await BuildBundleInfoAsync(loader.Packages), + SecurityReport = FlattenReport(report), + }; + } + + public static async Task ExportBundleAsync( + IpcBundleExportRequest? request = null + ) + { + request ??= new IpcBundleExportRequest(); + var packages = GetLoader().Packages; + var content = await CreateBundleAsync(packages); + var format = ResolveExportFormat(request.Path); + + if (!string.IsNullOrWhiteSpace(request.Path)) + { + await File.WriteAllTextAsync(request.Path, content); + } + + return new IpcBundleExportResult + { + Status = "success", + Command = "export-bundle", + Format = format.ToString().ToLowerInvariant(), + Path = request.Path, + Content = content, + Bundle = await BuildBundleInfoAsync(packages), + }; + } + + public static async Task AddPackageAsync( + IpcBundlePackageRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var loader = GetLoader(); + var package = await CreateBundlePackageAsync(request); + await loader.AddPackagesAsync([package]); + + return new IpcBundlePackageOperationResult + { + Status = "success", + Command = "add-bundle-package", + Package = await ToBundlePackageInfoAsync(package), + Bundle = await BuildBundleInfoAsync(loader.Packages), + }; + } + + public static async Task RemovePackageAsync( + IpcBundlePackageRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var loader = GetLoader(); + var packages = loader.Packages; + var toRemove = new List(); + foreach (var package in packages) + { + if (await MatchesBundleRequestAsync(package, request)) + { + toRemove.Add(package); + } + } + + loader.RemoveRange(toRemove); + + return new IpcBundlePackageOperationResult + { + Status = "success", + Command = "remove-bundle-package", + RemovedCount = toRemove.Count, + Bundle = await BuildBundleInfoAsync(loader.Packages), + }; + } + + public static async Task InstallBundleAsync( + IpcBundleInstallRequest? request = null + ) + { + request ??= new IpcBundleInstallRequest(); + + var packages = GetLoader().Packages; + bool includeInstalled = + request.IncludeInstalled ?? Settings.Get(Settings.K.InstallInstalledPackagesBundlesPage); + List results = []; + + foreach (var package in packages) + { + if (package is not ImportedPackage imported) + { + results.Add( + new IpcPackageOperationResult + { + Status = "error", + Command = "install-bundle", + OperationStatus = "invalid", + Message = "The bundle entry is incompatible and cannot be installed.", + Package = IpcPackageApi.CreateIpcPackageInfo(package), + } + ); + continue; + } + + if (!includeInstalled && package.Tag == PackageTag.AlreadyInstalled) + { + results.Add( + new IpcPackageOperationResult + { + Status = "success", + Command = "install-bundle", + OperationStatus = "skipped", + Message = "The package is already installed and include-installed is disabled.", + Package = IpcPackageApi.CreateIpcPackageInfo(package), + } + ); + continue; + } + + var registeredPackage = await imported.RegisterAndGetPackageAsync(); + var bundleOptions = await imported.GetInstallOptions(); + var options = await InstallOptionsFactory.LoadApplicableAsync( + registeredPackage, + elevated: request.Elevated, + interactive: request.Interactive, + no_integrity: request.SkipHash, + overridePackageOptions: bundleOptions + ); + + using var operation = new InstallPackageOperation(registeredPackage, options); + await operation.MainThread(); + if (operation.Status == OperationStatus.Succeeded) + { + imported.SetTag(PackageTag.AlreadyInstalled); + } + results.Add( + IpcPackageApi.CreateOperationResult( + "install-bundle", + imported, + operation + ) + ); + } + + return new IpcBundleInstallResult + { + Status = results.Any(result => result.Status == "error") ? "error" : "success", + Command = "install-bundle", + RequestedCount = packages.Count, + SucceededCount = results.Count(result => + result.Status == "success" && result.OperationStatus != "skipped" + ), + FailedCount = results.Count(result => result.Status == "error"), + SkippedCount = results.Count(result => result.OperationStatus == "skipped"), + Bundle = await BuildBundleInfoAsync(GetLoader().Packages), + Results = results, + }; + } + + private static PackageBundlesLoader GetLoader() + { + return PackageBundlesLoader.Instance + ?? throw new InvalidOperationException("The package bundle loader is not available."); + } + + private static async Task BuildBundleInfoAsync( + IReadOnlyList packages + ) + { + var bundlePackages = await Task.WhenAll(packages.Select(ToBundlePackageInfoAsync)); + var sortedPackages = bundlePackages + .OrderBy(package => package.Manager, StringComparer.OrdinalIgnoreCase) + .ThenBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .ThenBy(package => package.Version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new IpcBundleInfo + { + PackageCount = sortedPackages.Length, + Packages = sortedPackages, + }; + } + + private static async Task ToBundlePackageInfoAsync(IPackage package) + { + if (package is ImportedPackage imported) + { + var serialized = await imported.AsSerializableAsync(); + return new IpcBundlePackageInfo + { + Name = imported.Name, + Id = imported.Id, + Version = serialized.Version, + DisplayVersion = imported.VersionString, + SelectedVersion = string.IsNullOrWhiteSpace(serialized.InstallationOptions.Version) + ? null + : serialized.InstallationOptions.Version, + Scope = string.IsNullOrWhiteSpace(serialized.InstallationOptions.InstallationScope) + ? null + : serialized.InstallationOptions.InstallationScope, + PreRelease = serialized.InstallationOptions.PreRelease, + Source = imported.Source.AsString_DisplayName, + Manager = IpcManagerSettingsApi.GetPublicManagerId(imported.Manager), + IsCompatible = true, + IsInstalled = imported.Tag == PackageTag.AlreadyInstalled, + IsUpgradable = imported.Tag == PackageTag.IsUpgradable || imported.IsUpgradable, + }; + } + + if (package is InvalidImportedPackage invalid) + { + var serialized = invalid.AsSerializable_Incompatible(); + return new IpcBundlePackageInfo + { + Name = invalid.Name, + Id = invalid.Id, + Version = serialized.Version, + DisplayVersion = invalid.VersionString, + Source = invalid.SourceAsString, + Manager = IpcManagerSettingsApi.GetPublicManagerId(invalid.Manager), + IsCompatible = false, + IsInstalled = false, + IsUpgradable = false, + }; + } + + return new IpcBundlePackageInfo + { + Name = package.Name, + Id = package.Id, + Version = package.VersionString, + DisplayVersion = package.VersionString, + Source = package.Source.AsString_DisplayName, + Manager = IpcManagerSettingsApi.GetPublicManagerId(package.Manager), + IsCompatible = !package.Source.IsVirtualManager, + IsInstalled = package.Tag == PackageTag.AlreadyInstalled, + IsUpgradable = package.Tag == PackageTag.IsUpgradable || package.IsUpgradable, + }; + } + + private static async Task CreateBundlePackageAsync( + IpcBundlePackageRequest request + ) + { + var packageRequest = new IpcPackageActionRequest + { + PackageId = request.PackageId, + ManagerName = request.ManagerName, + PackageSource = request.PackageSource, + Version = request.Version, + Scope = request.Scope, + PreRelease = request.PreRelease, + }; + var package = IpcPackageApi.ResolvePackage( + packageRequest, + ParseLookupMode(request.Selection) + ); + + if (package.Source.IsVirtualManager) + { + return new InvalidImportedPackage(package.AsSerializable_Incompatible(), NullSource.Instance); + } + + var serialized = await package.AsSerializableAsync(); + IpcPackageApi.ApplyRequestedOptions(serialized.InstallationOptions, packageRequest); + return new ImportedPackage(serialized, package.Manager, package.Source); + } + + private static async Task MatchesBundleRequestAsync( + IPackage package, + IpcBundlePackageRequest request + ) + { + if (!package.Id.Equals(request.PackageId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!IpcManagerSettingsApi.MatchesManagerId(package.Manager, request.ManagerName)) + { + return false; + } + + if ( + !string.IsNullOrWhiteSpace(request.PackageSource) + && !package.Source.Name.Equals(request.PackageSource, StringComparison.OrdinalIgnoreCase) + && !package.Source.AsString_DisplayName.Equals( + request.PackageSource, + StringComparison.OrdinalIgnoreCase + ) + ) + { + return false; + } + + if (string.IsNullOrWhiteSpace(request.Version)) + { + return true; + } + + return request.Version.Equals( + await GetBundlePackageVersionAsync(package), + StringComparison.OrdinalIgnoreCase + ); + } + + private static async Task GetBundlePackageVersionAsync(IPackage package) + { + if (package is ImportedPackage imported) + { + return (await imported.AsSerializableAsync()).Version; + } + + if (package is InvalidImportedPackage invalid) + { + return invalid.AsSerializable_Incompatible().Version; + } + + return package.VersionString; + } + + private static async Task ReadBundleContentAsync(IpcBundleImportRequest request) + { + bool hasContent = !string.IsNullOrWhiteSpace(request.Content); + bool hasPath = !string.IsNullOrWhiteSpace(request.Path); + + if (hasContent == hasPath) + { + throw new InvalidOperationException( + "Exactly one of content or path must be supplied when importing a bundle." + ); + } + + if (hasContent) + { + return request.Content!; + } + + return await File.ReadAllTextAsync(request.Path!); + } + + private static BundleFormatType ResolveImportFormat(IpcBundleImportRequest request) + { + if (!string.IsNullOrWhiteSpace(request.Format)) + { + return ParseFormat(request.Format); + } + + if (string.IsNullOrWhiteSpace(request.Path)) + { + return BundleFormatType.UBUNDLE; + } + + return ParseFormat(Path.GetExtension(request.Path)); + } + + private static BundleFormatType ResolveExportFormat(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return BundleFormatType.UBUNDLE; + } + + var extension = Path.GetExtension(path); + return extension.ToLowerInvariant() switch + { + ".json" => BundleFormatType.JSON, + ".ubundle" or "" => BundleFormatType.UBUNDLE, + _ => throw new InvalidOperationException( + "Bundle export only supports .ubundle and .json output files." + ), + }; + } + + private static BundleFormatType ParseFormat(string? format) + { + return format?.Trim().TrimStart('.').ToLowerInvariant() switch + { + null or "" or "ubundle" => BundleFormatType.UBUNDLE, + "json" => BundleFormatType.JSON, + "yaml" or "yml" => BundleFormatType.YAML, + "xml" => BundleFormatType.XML, + _ => throw new InvalidOperationException( + $"The bundle format \"{format}\" is not supported." + ), + }; + } + + private static IpcPackageLookupMode ParseLookupMode(string? selection) + { + return selection?.Trim().ToLowerInvariant() switch + { + null or "" or "search" => IpcPackageLookupMode.Search, + "installed" => IpcPackageLookupMode.Installed, + "updates" or "upgradable" => IpcPackageLookupMode.Upgradable, + "auto" => IpcPackageLookupMode.Any, + _ => throw new InvalidOperationException( + $"The bundle selection mode \"{selection}\" is not supported." + ), + }; + } + + internal static async Task CreateBundleAsync(IReadOnlyList unsortedPackages) + { + var exportableData = new SerializableBundle(); + var packages = unsortedPackages.ToList(); + packages.Sort((x, y) => + { + if (x.Id != y.Id) + { + return string.Compare(x.Id, y.Id, StringComparison.Ordinal); + } + + if (x.Name != y.Name) + { + return string.Compare(x.Name, y.Name, StringComparison.Ordinal); + } + + return x.NormalizedVersion > y.NormalizedVersion ? -1 : 1; + }); + + foreach (var package in packages) + { + if (package is ImportedPackage imported) + { + exportableData.packages.Add(await imported.AsSerializableAsync()); + } + else + { + exportableData.incompatible_packages.Add(package.AsSerializable_Incompatible()); + } + } + + return exportableData.AsJsonString(); + } + + internal static async Task<(double SchemaVersion, BundleReport Report)> AddFromBundleAsync( + string content, + BundleFormatType format + ) + { + if (format == BundleFormatType.YAML) + { + content = await SerializationHelpers.YAML_to_JSON(content); + } + else if (format == BundleFormatType.XML) + { + content = await SerializationHelpers.XML_to_JSON(content); + } + + var deserializedData = await Task.Run(() => + new SerializableBundle( + JsonNode.Parse(content) + ?? throw new InvalidOperationException("The bundle content could not be parsed.") + ) + ); + + var report = new BundleReport { IsEmpty = true }; + bool allowCliArguments = + SecureSettings.Get(SecureSettings.K.AllowCLIArguments) + && SecureSettings.Get(SecureSettings.K.AllowImportingCLIArguments); + bool allowPrePostCommands = + SecureSettings.Get(SecureSettings.K.AllowPrePostOpCommand) + && SecureSettings.Get(SecureSettings.K.AllowImportPrePostOpCommands); + + List packages = []; + foreach (var package in deserializedData.packages) + { + var options = package.InstallationOptions; + ReportList( + ref report, + package.Id, + options.CustomParameters_Install, + "Custom install arguments", + allowCliArguments + ); + ReportList( + ref report, + package.Id, + options.CustomParameters_Update, + "Custom update arguments", + allowCliArguments + ); + ReportList( + ref report, + package.Id, + options.CustomParameters_Uninstall, + "Custom uninstall arguments", + allowCliArguments + ); + options.PreInstallCommand = ReportString( + ref report, + package.Id, + options.PreInstallCommand, + "Pre-install command", + allowPrePostCommands + ); + options.PostInstallCommand = ReportString( + ref report, + package.Id, + options.PostInstallCommand, + "Post-install command", + allowPrePostCommands + ); + options.PreUpdateCommand = ReportString( + ref report, + package.Id, + options.PreUpdateCommand, + "Pre-update command", + allowPrePostCommands + ); + options.PostUpdateCommand = ReportString( + ref report, + package.Id, + options.PostUpdateCommand, + "Post-update command", + allowPrePostCommands + ); + options.PreUninstallCommand = ReportString( + ref report, + package.Id, + options.PreUninstallCommand, + "Pre-uninstall command", + allowPrePostCommands + ); + options.PostUninstallCommand = ReportString( + ref report, + package.Id, + options.PostUninstallCommand, + "Post-uninstall command", + allowPrePostCommands + ); + package.InstallationOptions = options; + packages.Add(DeserializePackage(package)); + } + + foreach (var incompatiblePackage in deserializedData.incompatible_packages) + { + packages.Add(new InvalidImportedPackage(incompatiblePackage, NullSource.Instance)); + } + + await GetLoader().AddPackagesAsync(packages); + return (deserializedData.export_version, report); + } + + private static IPackage DeserializePackage(SerializablePackage raw) + { + IPackageManager? manager = IpcManagerSettingsApi.ResolveImportedManager(raw.ManagerName); + + IManagerSource? source; + if (manager?.Capabilities.SupportsCustomSources == true) + { + if (raw.Source.Contains(": ")) + { + raw.Source = raw.Source.Split(": ")[^1]; + } + + source = manager.SourcesHelper?.Factory.GetSourceIfExists(raw.Source); + } + else + { + source = manager?.DefaultSource; + } + + if (manager is null || source is null) + { + return new InvalidImportedPackage(raw.GetInvalidEquivalent(), NullSource.Instance); + } + + return new ImportedPackage(raw, manager, source); + } + + private static void ReportList( + ref BundleReport report, + string packageId, + List values, + string label, + bool allowed + ) + { + if (!values.Any(value => value.Any())) + { + return; + } + + if (!report.Contents.TryGetValue(packageId, out List? packageEntries)) + { + packageEntries = []; + report.Contents[packageId] = packageEntries; + } + + packageEntries.Add( + new BundleReportEntry($"{label}: [{string.Join(", ", values)}]", allowed) + ); + report.IsEmpty = false; + if (!allowed) + { + values.Clear(); + } + } + + private static string ReportString( + ref BundleReport report, + string packageId, + string value, + string label, + bool allowed + ) + { + if (!value.Any()) + { + return value; + } + + if (!report.Contents.TryGetValue(packageId, out List? packageEntries)) + { + packageEntries = []; + report.Contents[packageId] = packageEntries; + } + + packageEntries.Add(new BundleReportEntry($"{label}: {value}", allowed)); + report.IsEmpty = false; + return allowed ? value : ""; + } + + private static IReadOnlyList FlattenReport(BundleReport report) + { + if (report.IsEmpty) + { + return []; + } + + return report + .Contents.SelectMany(pair => + pair.Value.Select(entry => new IpcBundleSecurityEntry + { + PackageId = pair.Key, + Line = entry.Line, + Allowed = entry.Allowed, + }) + ) + .OrderBy(entry => entry.PackageId, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.Line, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs b/src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs new file mode 100644 index 0000000000..7a0bf6d1c0 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs @@ -0,0 +1,962 @@ +using System.Text.Json; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; + +namespace UniGetUI.Interface; + +public enum IpcCliExitCode +{ + Success = 0, + Failed = 1, + InvalidParameter = 2, + IpcUnavailable = 3, + UnknownCommand = 4, +} + +public static class IpcCliCommandRunner +{ + public static async Task RunAsync( + IReadOnlyList args, + TextWriter output, + TextWriter error + ) + { + IpcCliParseResult parseResult = IpcCliSyntax.Parse(args); + if (parseResult.Status == IpcCliParseStatus.Help) + { + await output.WriteLineAsync(IpcCliSyntax.GetHelpText()); + return (int)IpcCliExitCode.Success; + } + + if ( + parseResult.Status != IpcCliParseStatus.Success + || parseResult.Command is null + || parseResult.EffectiveArgs is null + ) + { + return await WriteErrorAsync( + output, + parseResult.Message ?? "A valid command was not provided.", + IpcCliExitCode.InvalidParameter + ); + } + + args = parseResult.EffectiveArgs; + string subcommand = parseResult.Command.Trim().ToLowerInvariant(); + + try + { + using var client = IpcClient.CreateForCli(args); + return subcommand switch + { + "status" => await WriteJsonAsync(output, await client.GetStatusAsync()), + "get-app-state" => await WriteJsonAsync( + output, + new + { + status = "success", + app = await client.GetAppInfoAsync(), + } + ), + "show-app" => await WriteJsonAsync(output, await client.ShowAppAsync()), + "navigate-app" => await WriteJsonAsync( + output, + await client.NavigateAppAsync(BuildAppNavigateRequest(args)) + ), + "quit-app" => await WriteJsonAsync(output, await client.QuitAppAsync()), + "list-operations" => await WriteJsonAsync( + output, + new + { + status = "success", + operations = await client.ListOperationsAsync(), + } + ), + "get-operation" => await WriteJsonAsync( + output, + new + { + status = "success", + operation = await client.GetOperationAsync( + GetRequiredArgument( + args, + "--operation-id", + "operation get requires --id." + ) + ), + } + ), + "get-operation-output" => await WriteJsonAsync( + output, + new + { + status = "success", + output = await client.GetOperationOutputAsync( + GetRequiredArgument( + args, + "--operation-id", + "operation output requires --id." + ), + GetOptionalIntArgument(args, "--tail") + ), + } + ), + "wait-operation" => await WriteJsonAsync( + output, + new + { + status = "success", + operation = await client.WaitForOperationAsync( + GetRequiredArgument( + args, + "--operation-id", + "operation wait requires --id." + ), + GetOptionalIntArgument(args, "--timeout") ?? 300, + ((GetOptionalIntArgument(args, "--delay") ?? 1) * 1000) + ), + } + ), + "cancel-operation" => await WriteJsonAsync( + output, + await client.CancelOperationAsync( + GetRequiredArgument( + args, + "--operation-id", + "operation cancel requires --id." + ) + ) + ), + "retry-operation" => await WriteJsonAsync( + output, + await client.RetryOperationAsync( + GetRequiredArgument( + args, + "--operation-id", + "operation retry requires --id." + ), + GetOptionalArgument(args, "--mode") + ) + ), + "reorder-operation" => await WriteJsonAsync( + output, + await client.ReorderOperationAsync( + GetRequiredArgument( + args, + "--operation-id", + "operation reorder requires --id." + ), + GetRequiredArgument( + args, + "--action", + "operation reorder requires --action." + ) + ) + ), + "forget-operation" => await WriteJsonAsync( + output, + await client.ForgetOperationAsync( + GetRequiredArgument( + args, + "--operation-id", + "operation forget requires --id." + ) + ) + ), + "list-managers" => await WriteJsonAsync( + output, + new + { + status = "success", + managers = await client.ListManagersAsync(), + } + ), + "get-manager-maintenance" => await WriteJsonAsync( + output, + new + { + status = "success", + maintenance = await client.GetManagerMaintenanceAsync( + GetRequiredArgument( + args, + "--manager", + "manager maintenance requires --manager." + ) + ), + } + ), + "reload-manager" => await WriteJsonAsync( + output, + await client.ReloadManagerAsync(BuildManagerMaintenanceRequest(args)) + ), + "set-manager-executable" => await WriteJsonAsync( + output, + await client.SetManagerExecutablePathAsync( + BuildManagerMaintenanceRequest(args, requirePath: true) + ) + ), + "clear-manager-executable" => await WriteJsonAsync( + output, + await client.ClearManagerExecutablePathAsync(BuildManagerMaintenanceRequest(args)) + ), + "run-manager-action" => await WriteJsonAsync( + output, + await client.RunManagerActionAsync( + BuildManagerMaintenanceRequest(args, requireAction: true) + ) + ), + "list-sources" => await WriteJsonAsync( + output, + new + { + status = "success", + sources = await client.ListSourcesAsync(GetOptionalArgument(args, "--manager")), + } + ), + "add-source" => await WriteJsonAsync( + output, + await client.AddSourceAsync(BuildSourceRequest(args)) + ), + "remove-source" => await WriteJsonAsync( + output, + await client.RemoveSourceAsync(BuildSourceRequest(args)) + ), + "list-settings" => await WriteJsonAsync( + output, + new + { + status = "success", + settings = await client.ListSettingsAsync(), + } + ), + "list-secure-settings" => await WriteJsonAsync( + output, + new + { + status = "success", + settings = await client.ListSecureSettingsAsync( + GetOptionalArgument(args, "--user") + ), + } + ), + "get-secure-setting" => await WriteJsonAsync( + output, + new + { + status = "success", + setting = await client.GetSecureSettingAsync( + GetRequiredArgument( + args, + "--key", + "settings secure get requires --key." + ), + GetOptionalArgument(args, "--user") + ), + } + ), + "set-secure-setting" => await WriteJsonAsync( + output, + new + { + status = "success", + setting = await client.SetSecureSettingAsync( + BuildSecureSettingRequest(args) + ), + } + ), + "get-setting" => await WriteJsonAsync( + output, + new + { + status = "success", + setting = await client.GetSettingAsync( + GetRequiredArgument( + args, + "--key", + "settings get requires --key." + ) + ), + } + ), + "set-setting" => await WriteJsonAsync( + output, + new + { + status = "success", + setting = await client.SetSettingAsync(BuildSettingRequest(args)), + } + ), + "clear-setting" => await WriteJsonAsync( + output, + new + { + status = "success", + setting = await client.ClearSettingAsync( + GetRequiredArgument( + args, + "--key", + "settings clear requires --key." + ) + ), + } + ), + "set-manager-enabled" => await WriteJsonAsync( + output, + new + { + status = "success", + manager = await client.SetManagerEnabledAsync(BuildManagerToggleRequest(args)), + } + ), + "set-manager-update-notifications" => await WriteJsonAsync( + output, + new + { + status = "success", + manager = await client.SetManagerUpdateNotificationsAsync( + BuildManagerToggleRequest(args) + ), + } + ), + "reset-settings" => await WriteJsonAsync( + output, + await client.ResetSettingsAsync() + ), + "list-desktop-shortcuts" => await WriteJsonAsync( + output, + new + { + status = "success", + shortcuts = await client.ListDesktopShortcutsAsync(), + } + ), + "set-desktop-shortcut" => await WriteJsonAsync( + output, + await client.SetDesktopShortcutAsync(BuildDesktopShortcutRequest(args, requireStatus: true)) + ), + "reset-desktop-shortcut" => await WriteJsonAsync( + output, + await client.ResetDesktopShortcutAsync( + GetRequiredArgument( + args, + "--path", + "shortcut reset requires --path." + ) + ) + ), + "reset-desktop-shortcuts" => await WriteJsonAsync( + output, + await client.ResetDesktopShortcutsAsync() + ), + "get-app-log" => await WriteJsonAsync( + output, + new + { + status = "success", + entries = await client.GetAppLogAsync(GetOptionalIntArgument(args, "--level") ?? 4), + } + ), + "get-operation-history" => await WriteJsonAsync( + output, + new + { + status = "success", + history = await client.GetOperationHistoryAsync(), + } + ), + "get-manager-log" => await WriteJsonAsync( + output, + new + { + status = "success", + managers = await client.GetManagerLogAsync( + GetOptionalArgument(args, "--manager"), + args.Contains("--verbose") + ), + } + ), + "get-backup-status" => await WriteJsonAsync( + output, + new + { + status = "success", + backup = await client.GetBackupStatusAsync(), + } + ), + "create-local-backup" => await WriteJsonAsync( + output, + await client.CreateLocalBackupAsync() + ), + "start-github-sign-in" => await WriteJsonAsync( + output, + await client.StartGitHubDeviceFlowAsync(BuildGitHubDeviceFlowRequest(args)) + ), + "complete-github-sign-in" => await WriteJsonAsync( + output, + await client.CompleteGitHubDeviceFlowAsync() + ), + "sign-out-github" => await WriteJsonAsync( + output, + await client.SignOutGitHubAsync() + ), + "list-cloud-backups" => await WriteJsonAsync( + output, + new + { + status = "success", + backups = await client.ListCloudBackupsAsync(), + } + ), + "create-cloud-backup" => await WriteJsonAsync( + output, + await client.CreateCloudBackupAsync() + ), + "download-cloud-backup" => await WriteJsonAsync( + output, + await client.DownloadCloudBackupAsync(BuildCloudBackupRequest(args)) + ), + "restore-cloud-backup" => await WriteJsonAsync( + output, + await client.RestoreCloudBackupAsync(BuildCloudBackupRequest(args)) + ), + "get-bundle" => await WriteJsonAsync( + output, + new + { + status = "success", + bundle = await client.GetBundleAsync(), + } + ), + "reset-bundle" => await WriteJsonAsync( + output, + await client.ResetBundleAsync() + ), + "import-bundle" => await WriteJsonAsync( + output, + await client.ImportBundleAsync(BuildBundleImportRequest(args)) + ), + "export-bundle" => await WriteJsonAsync( + output, + await client.ExportBundleAsync(BuildBundleExportRequest(args)) + ), + "add-bundle-package" => await WriteJsonAsync( + output, + await client.AddBundlePackageAsync(BuildBundlePackageRequest(args)) + ), + "remove-bundle-package" => await WriteJsonAsync( + output, + await client.RemoveBundlePackageAsync(BuildBundlePackageRequest(args)) + ), + "install-bundle" => await WriteJsonAsync( + output, + await client.InstallBundleAsync(BuildBundleInstallRequest(args)) + ), + "get-version" => await WriteJsonAsync( + output, + new + { + status = "success", + build = await client.GetVersionAsync(), + } + ), + "get-updates" => await WriteJsonAsync( + output, + new + { + status = "success", + updates = await client.ListUpgradablePackagesAsync( + GetOptionalArgument(args, "--manager") + ), + } + ), + "list-installed" => await WriteJsonAsync( + output, + new + { + status = "success", + packages = await client.ListInstalledPackagesAsync( + GetOptionalArgument(args, "--manager") + ), + } + ), + "search-packages" => await WriteJsonAsync( + output, + new + { + status = "success", + packages = await client.SearchPackagesAsync( + GetRequiredArgument( + args, + "--query", + "package search requires --query." + ), + GetOptionalArgument(args, "--manager"), + GetOptionalIntArgument(args, "--max-results") + ), + } + ), + "package-details" => await WriteJsonAsync( + output, + new + { + status = "success", + package = await client.GetPackageDetailsAsync(BuildPackageActionRequest(args)), + } + ), + "package-versions" => await WriteJsonAsync( + output, + new + { + status = "success", + versions = await client.GetPackageVersionsAsync(BuildPackageActionRequest(args)), + } + ), + "list-ignored-updates" => await WriteJsonAsync( + output, + new + { + status = "success", + ignoredUpdates = await client.ListIgnoredUpdatesAsync(), + } + ), + "ignore-package" => await WriteJsonAsync( + output, + await client.IgnorePackageUpdateAsync(BuildPackageActionRequest(args)) + ), + "unignore-package" => await WriteJsonAsync( + output, + await client.RemoveIgnoredUpdateAsync(BuildPackageActionRequest(args)) + ), + "install-package" => await WriteJsonAsync( + output, + await client.InstallPackageAsync(BuildPackageActionRequest(args)) + ), + "download-package" => await WriteJsonAsync( + output, + await client.DownloadPackageAsync(BuildPackageActionRequest(args)) + ), + "reinstall-package" => await WriteJsonAsync( + output, + await client.ReinstallPackageAsync(BuildPackageActionRequest(args)) + ), + "update-package" => await WriteJsonAsync( + output, + await client.UpdatePackageAsync(BuildPackageActionRequest(args)) + ), + "uninstall-package" => await WriteJsonAsync( + output, + await client.UninstallPackageAsync(BuildPackageActionRequest(args)) + ), + "uninstall-then-reinstall-package" => await WriteJsonAsync( + output, + await client.UninstallThenReinstallPackageAsync(BuildPackageActionRequest(args)) + ), + "open-window" => await WriteJsonAsync(output, await client.OpenWindowAsync()), + "open-updates" => await WriteJsonAsync(output, await client.OpenUpdatesAsync()), + "show-package" => await WriteJsonAsync( + output, + await client.ShowPackageAsync( + GetRequiredArgument( + args, + "--package-id", + "package show requires --id." + ), + GetRequiredArgument( + args, + "--package-source", + "package show requires --source." + ) + ) + ), + "update-all" => await WriteJsonAsync(output, await client.UpdateAllAsync()), + "update-manager" => await WriteJsonAsync( + output, + await client.UpdateManagerAsync( + GetRequiredArgument( + args, + "--manager", + "package update-manager requires --manager." + ) + ) + ), + _ => await WriteErrorAsync( + output, + $"Unknown command \"{subcommand}\".", + IpcCliExitCode.UnknownCommand + ), + }; + } + catch (InvalidOperationException ex) + { + return await WriteErrorAsync(output, ex.Message, IpcCliExitCode.InvalidParameter); + } + catch (HttpRequestException ex) + { + return await WriteErrorAsync( + output, + ex.Message, + IpcCliExitCode.IpcUnavailable + ); + } + catch (IOException ex) + { + return await WriteErrorAsync( + output, + ex.Message, + IpcCliExitCode.IpcUnavailable + ); + } + catch (Exception ex) + { + Logger.Error(ex); + return await WriteErrorAsync(output, ex.Message, IpcCliExitCode.Failed); + } + } + + private static IpcPackageActionRequest BuildPackageActionRequest(IReadOnlyList args) + { + return new IpcPackageActionRequest + { + PackageId = GetRequiredArgument( + args, + "--package-id", + "This command requires --id." + ), + ManagerName = GetOptionalArgument(args, "--manager"), + PackageSource = GetOptionalArgument(args, "--package-source"), + Version = GetOptionalArgument(args, "--version"), + Scope = GetOptionalArgument(args, "--scope"), + PreRelease = args.Contains("--pre-release") ? true : null, + Elevated = GetOptionalBoolArgument(args, "--elevated"), + Interactive = GetOptionalBoolArgument(args, "--interactive"), + SkipHash = GetOptionalBoolArgument(args, "--skip-hash"), + RemoveData = GetOptionalBoolArgument(args, "--remove-data"), + WaitForCompletion = args.Contains("--detach") + ? false + : GetOptionalBoolArgument(args, "--wait"), + Architecture = GetOptionalArgument(args, "--architecture"), + InstallLocation = GetOptionalArgument(args, "--location"), + OutputPath = GetOptionalArgument(args, "--output"), + }; + } + + private static IpcAppNavigateRequest BuildAppNavigateRequest(IReadOnlyList args) + { + return new IpcAppNavigateRequest + { + Page = GetRequiredArgument( + args, + "--page", + "app navigate requires --page." + ), + ManagerName = GetOptionalArgument(args, "--manager"), + HelpAttachment = GetOptionalArgument(args, "--help-attachment"), + }; + } + + private static IpcSourceRequest BuildSourceRequest(IReadOnlyList args) + { + return new IpcSourceRequest + { + ManagerName = GetRequiredArgument( + args, + "--manager", + "This command requires --manager." + ), + SourceName = GetRequiredArgument( + args, + "--name", + "This command requires --name." + ), + SourceUrl = GetOptionalArgument(args, "--url"), + }; + } + + private static IpcManagerMaintenanceRequest BuildManagerMaintenanceRequest( + IReadOnlyList args, + bool requireAction = false, + bool requirePath = false + ) + { + return new IpcManagerMaintenanceRequest + { + ManagerName = GetRequiredArgument( + args, + "--manager", + "This command requires --manager." + ), + Action = requireAction + ? GetRequiredArgument(args, "--action", "This command requires --action.") + : GetOptionalArgument(args, "--action"), + Path = requirePath + ? GetRequiredArgument(args, "--path", "This command requires --path.") + : GetOptionalArgument(args, "--path"), + Confirm = args.Contains("--confirm"), + }; + } + + private static IpcSecureSettingRequest BuildSecureSettingRequest( + IReadOnlyList args + ) + { + return new IpcSecureSettingRequest + { + SettingKey = GetRequiredArgument(args, "--key", "This command requires --key."), + UserName = GetOptionalArgument(args, "--user"), + Enabled = GetRequiredBoolArgument(args, "--enabled"), + }; + } + + private static IpcManagerToggleRequest BuildManagerToggleRequest(IReadOnlyList args) + { + return new IpcManagerToggleRequest + { + ManagerName = GetRequiredArgument( + args, + "--manager", + "This command requires --manager." + ), + Enabled = GetRequiredBoolArgument(args, "--enabled"), + }; + } + + private static IpcDesktopShortcutRequest BuildDesktopShortcutRequest( + IReadOnlyList args, + bool requireStatus + ) + { + return new IpcDesktopShortcutRequest + { + Path = GetRequiredArgument(args, "--path", "This command requires --path."), + Status = requireStatus + ? GetRequiredArgument( + args, + "--status", + "This command requires --status." + ) + : GetOptionalArgument(args, "--status"), + }; + } + + private static IpcBundleImportRequest BuildBundleImportRequest( + IReadOnlyList args + ) + { + return new IpcBundleImportRequest + { + Path = GetOptionalArgument(args, "--path"), + Content = GetOptionalArgument(args, "--content"), + Format = GetOptionalArgument(args, "--format"), + Append = args.Contains("--append"), + }; + } + + private static IpcGitHubDeviceFlowRequest BuildGitHubDeviceFlowRequest( + IReadOnlyList args + ) + { + return new IpcGitHubDeviceFlowRequest + { + LaunchBrowser = args.Contains("--launch-browser"), + }; + } + + private static IpcCloudBackupRequest BuildCloudBackupRequest(IReadOnlyList args) + { + return new IpcCloudBackupRequest + { + Key = GetRequiredArgument( + args, + "--key", + "This command requires --key." + ), + Append = args.Contains("--append"), + }; + } + + private static IpcBundleExportRequest BuildBundleExportRequest( + IReadOnlyList args + ) + { + return new IpcBundleExportRequest { Path = GetOptionalArgument(args, "--path") }; + } + + private static IpcBundlePackageRequest BuildBundlePackageRequest( + IReadOnlyList args + ) + { + return new IpcBundlePackageRequest + { + PackageId = GetRequiredArgument( + args, + "--package-id", + "This command requires --id." + ), + ManagerName = GetOptionalArgument(args, "--manager"), + PackageSource = GetOptionalArgument(args, "--package-source"), + Version = GetOptionalArgument(args, "--version"), + Scope = GetOptionalArgument(args, "--scope"), + PreRelease = args.Contains("--pre-release") ? true : null, + Selection = GetOptionalArgument(args, "--selection"), + }; + } + + private static IpcBundleInstallRequest BuildBundleInstallRequest( + IReadOnlyList args + ) + { + return new IpcBundleInstallRequest + { + IncludeInstalled = GetOptionalBoolArgument(args, "--include-installed"), + Elevated = GetOptionalBoolArgument(args, "--elevated"), + Interactive = GetOptionalBoolArgument(args, "--interactive"), + SkipHash = GetOptionalBoolArgument(args, "--skip-hash"), + }; + } + + private static IpcSettingValueRequest BuildSettingRequest(IReadOnlyList args) + { + bool? enabled = null; + string? enabledValue = GetOptionalArgument(args, "--enabled"); + if (enabledValue is not null) + { + if (!bool.TryParse(enabledValue, out bool parsedEnabled)) + { + throw new InvalidOperationException( + "The value supplied to --enabled must be either true or false." + ); + } + + enabled = parsedEnabled; + } + + return new IpcSettingValueRequest + { + SettingKey = GetRequiredArgument( + args, + "--key", + "This command requires --key." + ), + Enabled = enabled, + Value = GetOptionalArgument(args, "--value"), + }; + } + + private static string GetRequiredArgument( + IReadOnlyList arguments, + string argumentName, + string errorMessage + ) + { + int index = arguments.ToList().IndexOf(argumentName); + if (index < 0 || index + 1 >= arguments.Count) + { + throw new InvalidOperationException(errorMessage); + } + + return arguments[index + 1].Trim('"').Trim('\''); + } + + private static string? GetOptionalArgument( + IReadOnlyList arguments, + string argumentName + ) + { + int index = arguments.ToList().IndexOf(argumentName); + if (index < 0 || index + 1 >= arguments.Count) + { + return null; + } + + return arguments[index + 1].Trim('"').Trim('\''); + } + + private static int? GetOptionalIntArgument( + IReadOnlyList arguments, + string argumentName + ) + { + string? value = GetOptionalArgument(arguments, argumentName); + if (value is null) + { + return null; + } + + if (int.TryParse(value, out int result)) + { + return result; + } + + throw new InvalidOperationException( + $"The value supplied to {argumentName} must be an integer." + ); + } + + private static bool? GetOptionalBoolArgument( + IReadOnlyList arguments, + string argumentName + ) + { + string? value = GetOptionalArgument(arguments, argumentName); + if (value is null) + { + return null; + } + + if (bool.TryParse(value, out bool result)) + { + return result; + } + + throw new InvalidOperationException( + $"The value supplied to {argumentName} must be either true or false." + ); + } + + private static bool GetRequiredBoolArgument(IReadOnlyList arguments, string argumentName) + { + bool? value = GetOptionalBoolArgument(arguments, argumentName); + if (!value.HasValue) + { + throw new InvalidOperationException( + $"This command requires {argumentName} with a value of true or false." + ); + } + + return value.Value; + } + + private static async Task WriteJsonAsync(TextWriter output, T value) + { + await output.WriteLineAsync( + JsonSerializer.Serialize( + value, + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ) + ); + return (int)IpcCliExitCode.Success; + } + + private static async Task WriteErrorAsync( + TextWriter output, + string message, + IpcCliExitCode exitCode + ) + { + await output.WriteLineAsync( + JsonSerializer.Serialize( + new { status = "error", message }, + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ) + ); + return (int)exitCode; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs b/src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs new file mode 100644 index 0000000000..48f19982be --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs @@ -0,0 +1,385 @@ +namespace UniGetUI.Interface; + +internal enum IpcCliParseStatus +{ + NotIpcCommand, + Success, + Help, + Error, +} + +internal sealed record IpcCliParseResult( + IpcCliParseStatus Status, + string? Command = null, + string[]? EffectiveArgs = null, + string? Message = null +); + +public static class IpcCliSyntax +{ + private static readonly HashSet GlobalOptionsWithValue = new(StringComparer.OrdinalIgnoreCase) + { + IpcTransportOptions.CliTransportArgument, + IpcTransportOptions.CliTcpPortArgument, + IpcTransportOptions.CliNamedPipeArgument, + }; + + internal static IpcCliParseResult Parse(IReadOnlyList args) + { + if (args.Count == 0) + { + return new(IpcCliParseStatus.NotIpcCommand); + } + + if (args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase)) + || args.Any(arg => string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase))) + { + return new(IpcCliParseStatus.Help); + } + + List commandIndexes = []; + HashSet consumedIndexes = []; + List leadingGlobalArgs = []; + bool commandStarted = false; + + for (int i = 0; i < args.Count; i++) + { + string arg = args[i]; + if (!commandStarted) + { + if (GlobalOptionsWithValue.Contains(arg)) + { + leadingGlobalArgs.Add(arg); + consumedIndexes.Add(i); + if (i + 1 < args.Count) + { + leadingGlobalArgs.Add(args[i + 1]); + consumedIndexes.Add(i + 1); + i++; + } + + continue; + } + + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + return new(IpcCliParseStatus.NotIpcCommand); + } + + commandStarted = true; + } + + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + break; + } + + commandIndexes.Add(i); + consumedIndexes.Add(i); + } + + if (commandIndexes.Count == 0) + { + return new(IpcCliParseStatus.NotIpcCommand); + } + + string[] path = commandIndexes + .Select(index => NormalizeToken(args[index])) + .ToArray(); + + if (path is ["help"]) + { + return new(IpcCliParseStatus.Help); + } + + string? command = TryMapCommand(path, out List injectedArgs); + if (command is null) + { + return new( + IpcCliParseStatus.NotIpcCommand, + Message: $"Unknown command path \"{string.Join(" ", path)}\"." + ); + } + + List remainingArgs = []; + for (int i = 0; i < args.Count; i++) + { + if (!consumedIndexes.Contains(i)) + { + remainingArgs.Add(args[i]); + } + } + + RewriteArgumentAliases(command, remainingArgs); + + return new( + IpcCliParseStatus.Success, + Command: command, + EffectiveArgs: [.. leadingGlobalArgs, .. injectedArgs, .. remainingArgs] + ); + } + + public static bool IsIpcCommand(IReadOnlyList args) + { + return Parse(args).Status is IpcCliParseStatus.Success or IpcCliParseStatus.Help; + } + + public static bool HasVerbCommand(IReadOnlyList args) + { + int firstArgumentIndex = GetFirstNonGlobalArgumentIndex(args); + if (firstArgumentIndex < 0) + { + return false; + } + + string firstArgument = args[firstArgumentIndex]; + if (firstArgument.StartsWith("-", StringComparison.Ordinal)) + { + return false; + } + + return Parse(args).Status is IpcCliParseStatus.Success or IpcCliParseStatus.Help; + } + + public static string GetHelpText() + { + return """ +Usage: + unigetui [global-options] [subcommand] [options] + +Global options: + --transport {tcp|named-pipe} + --tcp-port + --pipe-name + +Core commands: + status + version + app status|show|navigate|quit + operation list|get|output|wait|cancel|retry|reorder|forget + manager list|maintenance|reload|set-executable|clear-executable|action|enable|disable + manager notifications enable|disable + source list|add|remove + settings list|get|set|clear|reset + settings secure list|get|set + shortcut list|set|reset|reset-all + log app|operations|manager + backup status + backup local create + backup cloud list|create|download|restore + backup github login start|complete + backup github logout + bundle get|reset|import|export|add|remove|install + package search|details|versions|installed|updates|install|download|reinstall|repair|update|uninstall|show + package ignored list|add|remove + package update-all + package update-manager + +Examples: + unigetui status + unigetui app status + unigetui package search --manager dotnet-tool --query dotnetsay + unigetui package install --manager dotnet-tool --id dotnetsay --version 2.1.4 --scope Global + unigetui operation wait --id 123 --timeout 300 + unigetui backup local create + unigetui backup github login start --launch-browser +"""; + } + + private static string NormalizeToken(string token) + { + return token.Trim().ToLowerInvariant() switch + { + "operations" => "operation", + "packages" => "package", + "managers" => "manager", + "sources" => "source", + "shortcuts" => "shortcut", + "logs" => "log", + "backups" => "backup", + "bundles" => "bundle", + _ => token.Trim().ToLowerInvariant(), + }; + } + + private static int GetFirstNonGlobalArgumentIndex(IReadOnlyList args) + { + for (int i = 0; i < args.Count; i++) + { + if (GlobalOptionsWithValue.Contains(args[i])) + { + if (i + 1 < args.Count) + { + i++; + } + + continue; + } + + return i; + } + + return -1; + } + + private static string? TryMapCommand(string[] path, out List injectedArgs) + { + injectedArgs = []; + + return path switch + { + ["status"] => "status", + ["version"] => "get-version", + + ["app", "status"] => "get-app-state", + ["app", "show"] => "show-app", + ["app", "navigate"] => "navigate-app", + ["app", "quit"] => "quit-app", + + ["operation", "list"] => "list-operations", + ["operation", "get"] => "get-operation", + ["operation", "output"] => "get-operation-output", + ["operation", "wait"] => "wait-operation", + ["operation", "cancel"] => "cancel-operation", + ["operation", "retry"] => "retry-operation", + ["operation", "reorder"] => "reorder-operation", + ["operation", "forget"] => "forget-operation", + + ["manager", "list"] => "list-managers", + ["manager", "maintenance"] => "get-manager-maintenance", + ["manager", "reload"] => "reload-manager", + ["manager", "set-executable"] => "set-manager-executable", + ["manager", "clear-executable"] => "clear-manager-executable", + ["manager", "action"] => "run-manager-action", + ["manager", "enable"] => Inject("set-manager-enabled", injectedArgs, "--enabled", "true"), + ["manager", "disable"] => Inject("set-manager-enabled", injectedArgs, "--enabled", "false"), + ["manager", "notifications", "enable"] => Inject( + "set-manager-update-notifications", + injectedArgs, + "--enabled", + "true" + ), + ["manager", "notifications", "disable"] => Inject( + "set-manager-update-notifications", + injectedArgs, + "--enabled", + "false" + ), + + ["source", "list"] => "list-sources", + ["source", "add"] => "add-source", + ["source", "remove"] => "remove-source", + + ["settings", "list"] => "list-settings", + ["settings", "get"] => "get-setting", + ["settings", "set"] => "set-setting", + ["settings", "clear"] => "clear-setting", + ["settings", "reset"] => "reset-settings", + ["settings", "secure", "list"] => "list-secure-settings", + ["settings", "secure", "get"] => "get-secure-setting", + ["settings", "secure", "set"] => "set-secure-setting", + + ["shortcut", "list"] => "list-desktop-shortcuts", + ["shortcut", "set"] => "set-desktop-shortcut", + ["shortcut", "reset"] => "reset-desktop-shortcut", + ["shortcut", "reset-all"] => "reset-desktop-shortcuts", + + ["log", "app"] => "get-app-log", + ["log", "operation"] => "get-operation-history", + ["log", "operations"] => "get-operation-history", + ["log", "manager"] => "get-manager-log", + + ["backup", "status"] => "get-backup-status", + ["backup", "local", "create"] => "create-local-backup", + ["backup", "cloud", "list"] => "list-cloud-backups", + ["backup", "cloud", "create"] => "create-cloud-backup", + ["backup", "cloud", "download"] => "download-cloud-backup", + ["backup", "cloud", "restore"] => "restore-cloud-backup", + ["backup", "github", "login", "start"] => "start-github-sign-in", + ["backup", "github", "login", "complete"] => "complete-github-sign-in", + ["backup", "github", "logout"] => "sign-out-github", + + ["bundle", "get"] => "get-bundle", + ["bundle", "reset"] => "reset-bundle", + ["bundle", "import"] => "import-bundle", + ["bundle", "export"] => "export-bundle", + ["bundle", "add"] => "add-bundle-package", + ["bundle", "remove"] => "remove-bundle-package", + ["bundle", "install"] => "install-bundle", + + ["package", "search"] => "search-packages", + ["package", "details"] => "package-details", + ["package", "versions"] => "package-versions", + ["package", "installed"] => "list-installed", + ["package", "updates"] => "get-updates", + ["package", "install"] => "install-package", + ["package", "download"] => "download-package", + ["package", "reinstall"] => "reinstall-package", + ["package", "repair"] => "uninstall-then-reinstall-package", + ["package", "update"] => "update-package", + ["package", "uninstall"] => "uninstall-package", + ["package", "show"] => "show-package", + ["package", "ignored", "list"] => "list-ignored-updates", + ["package", "ignored", "add"] => "ignore-package", + ["package", "ignored", "remove"] => "unignore-package", + ["package", "update-all"] => "update-all", + ["package", "update-manager"] => "update-manager", + + _ => null, + }; + } + + private static string Inject( + string command, + List injectedArgs, + params string[] args + ) + { + injectedArgs.AddRange(args); + return command; + } + + private static void RewriteArgumentAliases(string command, List args) + { + for (int i = 0; i < args.Count; i++) + { + args[i] = command switch + { + "get-operation" or "get-operation-output" or "wait-operation" or "cancel-operation" + or "retry-operation" or "reorder-operation" or "forget-operation" + when string.Equals(args[i], "--id", StringComparison.OrdinalIgnoreCase) + => "--operation-id", + + "package-details" or "package-versions" or "install-package" or "download-package" + or "reinstall-package" or "update-package" or "uninstall-package" + or "uninstall-then-reinstall-package" or "ignore-package" + or "unignore-package" or "show-package" or "add-bundle-package" + or "remove-bundle-package" + when string.Equals(args[i], "--id", StringComparison.OrdinalIgnoreCase) + => "--package-id", + + "package-details" or "package-versions" or "install-package" or "download-package" + or "reinstall-package" or "update-package" or "uninstall-package" + or "uninstall-then-reinstall-package" or "ignore-package" + or "unignore-package" or "show-package" or "add-bundle-package" + or "remove-bundle-package" + when string.Equals(args[i], "--source", StringComparison.OrdinalIgnoreCase) + => "--package-source", + + "add-source" or "remove-source" + when string.Equals(args[i], "--source-name", StringComparison.OrdinalIgnoreCase) + => "--name", + + "add-source" or "remove-source" + when string.Equals(args[i], "--source-url", StringComparison.OrdinalIgnoreCase) + => "--url", + + "download-cloud-backup" or "restore-cloud-backup" + when string.Equals(args[i], "--name", StringComparison.OrdinalIgnoreCase) + => "--key", + + _ => args[i], + }; + } + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcClient.cs b/src/UniGetUI.Interface.IpcApi/IpcClient.cs new file mode 100644 index 0000000000..8a06b6a310 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcClient.cs @@ -0,0 +1,1558 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Net.Http.Json; +using System.Net.Sockets; +using System.Text.Json; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; + +namespace UniGetUI.Interface; + +public sealed class IpcClient : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly string _token; + + public IpcTransportOptions TransportOptions { get; } + + private IpcClient(IpcTransportOptions transportOptions, string? token = null) + { + TransportOptions = transportOptions; + _token = token ?? string.Empty; + _httpClient = CreateHttpClient(transportOptions); + } + + public static IpcClient CreateForCli(IReadOnlyList? args = null) + { + args ??= Environment.GetCommandLineArgs(); + IpcTransportOptions requestedOptions = IpcTransportOptions.LoadForClient(args); + + if (IpcTransportOptions.HasExplicitClientOverride(args)) + { + return new IpcClient( + requestedOptions, + WaitForExplicitSessionToken(requestedOptions) + ); + } + + var preferredRegistration = SelectLiveRegistration( + IpcTransportOptions.OrderRegistrationsForCliSelection( + IpcTransportOptions.LoadPersistedRegistrations() + ) + ); + + return preferredRegistration is not null + ? new IpcClient( + preferredRegistration.ToTransportOptions(), + preferredRegistration.Token + ) + : new IpcClient(requestedOptions); + } + + private static string? WaitForExplicitSessionToken(IpcTransportOptions requestedOptions) + { + Stopwatch timeout = Stopwatch.StartNew(); + + while (timeout.Elapsed < TimeSpan.FromSeconds(5)) + { + var matchingRegistrations = IpcTransportOptions.LoadPersistedRegistrations() + .Where(candidate => candidate.Matches(requestedOptions)) + .ToArray(); + var registration = SelectLiveRegistration(matchingRegistrations); + string? token = registration?.Token + ?? matchingRegistrations.FirstOrDefault(candidate => + !string.IsNullOrWhiteSpace(candidate.Token) + )?.Token; + + if (!string.IsNullOrWhiteSpace(token)) + { + return token; + } + + Thread.Sleep(100); + } + + return null; + } + + public async Task GetStatusAsync() + { + try + { + string json = await SendAsync(HttpMethod.Get, IpcHttpRoutes.Path("/status")); + var status = JsonSerializer.Deserialize( + json, + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + if (status is not null) + { + return status; + } + } + catch (Exception ex) when (IsConnectivityException(ex)) + { + Logger.Debug($"IPC API status probe failed: {ex.Message}"); + } + + return new IpcStatus + { + Running = false, + Transport = TransportOptions.TransportKind switch + { + IpcTransportKind.NamedPipe => "named-pipe", + _ => "tcp", + }, + TcpPort = TransportOptions.TcpPort, + NamedPipeName = TransportOptions.NamedPipeName, + NamedPipePath = TransportOptions.NamedPipePath ?? "", + BaseAddress = TransportOptions.BaseAddressString, + }; + } + + public async Task GetAppInfoAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path("/app") + ) + ?? new IpcAppInfo(); + } + + public async Task> ListOperationsAsync() + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/operations") + ) + ?? []; + } + + public async Task GetOperationAsync(string operationId) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path($"/operations/{Uri.EscapeDataString(operationId)}") + ); + } + + public async Task GetOperationOutputAsync( + string operationId, + int? tailLines = null + ) + { + Dictionary? parameters = null; + if (tailLines.HasValue) + { + parameters = new Dictionary + { + ["tailLines"] = tailLines.Value.ToString(), + }; + } + + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path($"/operations/{Uri.EscapeDataString(operationId)}/output"), + parameters + ) + ?? new IpcOperationOutputResult + { + OperationId = operationId, + }; + } + + public async Task WaitForOperationAsync( + string operationId, + int timeoutSeconds = 300, + int delayMilliseconds = 1000 + ) + { + timeoutSeconds = Math.Clamp(timeoutSeconds, 1, 3600); + delayMilliseconds = Math.Clamp(delayMilliseconds, 100, 10000); + + DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (true) + { + var operation = await GetOperationAsync(operationId) + ?? throw new InvalidOperationException( + $"No tracked operation with id \"{operationId}\" was found." + ); + + if ( + operation.Status is "succeeded" or "failed" or "canceled" + ) + { + return operation; + } + + if (DateTime.UtcNow >= deadline) + { + throw new InvalidOperationException( + $"Timed out while waiting for operation {operationId}." + ); + } + + await Task.Delay(delayMilliseconds); + } + } + + public async Task CancelOperationAsync(string operationId) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path($"/operations/{Uri.EscapeDataString(operationId)}/cancel") + ) + ?? new IpcCommandResult + { + Status = "error", + Command = "cancel-operation", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task RetryOperationAsync( + string operationId, + string? mode = null + ) + { + Dictionary? parameters = null; + if (!string.IsNullOrWhiteSpace(mode)) + { + parameters = new Dictionary + { + ["mode"] = mode, + }; + } + + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path($"/operations/{Uri.EscapeDataString(operationId)}/retry"), + parameters + ) + ?? new IpcCommandResult + { + Status = "error", + Command = "retry-operation", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task ReorderOperationAsync( + string operationId, + string action + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path($"/operations/{Uri.EscapeDataString(operationId)}/reorder"), + new Dictionary { ["action"] = action } + ) + ?? new IpcCommandResult + { + Status = "error", + Command = "reorder-operation", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task ForgetOperationAsync(string operationId) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path($"/operations/{Uri.EscapeDataString(operationId)}/forget") + ) + ?? new IpcCommandResult + { + Status = "error", + Command = "forget-operation", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task ShowAppAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/app/show") + ) + ?? new IpcCommandResult + { + Status = "error", + Command = "show-app", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task NavigateAppAsync( + IpcAppNavigateRequest request + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/app/navigate"), + BuildAppNavigateParameters(request) + ) + ?? new IpcCommandResult + { + Status = "error", + Command = "navigate-app", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task QuitAppAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/app/quit") + ) + ?? new IpcCommandResult + { + Status = "error", + Command = "quit-app", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task> ListManagersAsync() + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/managers") + ) ?? []; + } + + public async Task GetManagerMaintenanceAsync( + string managerName + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path("/managers/maintenance"), + new Dictionary { ["manager"] = managerName } + ); + } + + public async Task ReloadManagerAsync( + IpcManagerMaintenanceRequest request + ) + { + return await SendManagerMaintenanceActionAsync( + IpcHttpRoutes.Path("/managers/maintenance/reload"), + request + ); + } + + public async Task SetManagerExecutablePathAsync( + IpcManagerMaintenanceRequest request + ) + { + return await SendManagerMaintenanceActionAsync( + IpcHttpRoutes.Path("/managers/maintenance/executable/set"), + request + ); + } + + public async Task ClearManagerExecutablePathAsync( + IpcManagerMaintenanceRequest request + ) + { + return await SendManagerMaintenanceActionAsync( + IpcHttpRoutes.Path("/managers/maintenance/executable/clear"), + request + ); + } + + public async Task RunManagerActionAsync( + IpcManagerMaintenanceRequest request + ) + { + return await SendManagerMaintenanceActionAsync( + IpcHttpRoutes.Path("/managers/maintenance/action"), + request + ); + } + + public async Task> ListSourcesAsync(string? managerName = null) + { + Dictionary? parameters = null; + if (!string.IsNullOrWhiteSpace(managerName)) + { + parameters = new Dictionary { ["manager"] = managerName }; + } + + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/sources"), + parameters + ) ?? []; + } + + public async Task AddSourceAsync(IpcSourceRequest request) + { + return await SendSourceOperationAsync(IpcHttpRoutes.Path("/sources/add"), request); + } + + public async Task RemoveSourceAsync(IpcSourceRequest request) + { + return await SendSourceOperationAsync(IpcHttpRoutes.Path("/sources/remove"), request); + } + + public async Task> ListSettingsAsync() + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/settings") + ) ?? []; + } + + public async Task> ListSecureSettingsAsync( + string? userName = null + ) + { + Dictionary? parameters = null; + if (!string.IsNullOrWhiteSpace(userName)) + { + parameters = new Dictionary { ["user"] = userName }; + } + + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/secure-settings"), + parameters + ) ?? []; + } + + public async Task GetSecureSettingAsync( + string key, + string? userName = null + ) + { + Dictionary parameters = new() { ["key"] = key }; + if (!string.IsNullOrWhiteSpace(userName)) + { + parameters["user"] = userName; + } + + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path("/secure-settings/item"), + parameters + ); + } + + public async Task SetSecureSettingAsync( + IpcSecureSettingRequest request + ) + { + Dictionary parameters = new() + { + ["key"] = request.SettingKey, + ["enabled"] = request.Enabled ? "true" : "false", + }; + if (!string.IsNullOrWhiteSpace(request.UserName)) + { + parameters["user"] = request.UserName; + } + + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/secure-settings/set"), + parameters + ); + } + + public async Task GetSettingAsync(string key) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path("/settings/item"), + new Dictionary { ["key"] = key } + ); + } + + public async Task SetSettingAsync(IpcSettingValueRequest request) + { + Dictionary parameters = new() { ["key"] = request.SettingKey }; + if (request.Enabled.HasValue) + { + parameters["enabled"] = request.Enabled.Value ? "true" : "false"; + } + + if (request.Value is not null) + { + parameters["value"] = request.Value; + } + + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/settings/set"), + parameters + ); + } + + public async Task ClearSettingAsync(string key) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/settings/clear"), + new Dictionary { ["key"] = key } + ); + } + + public async Task SetManagerEnabledAsync( + IpcManagerToggleRequest request + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/managers/set-enabled"), + new Dictionary + { + ["manager"] = request.ManagerName, + ["enabled"] = request.Enabled ? "true" : "false", + } + ); + } + + public async Task SetManagerUpdateNotificationsAsync( + IpcManagerToggleRequest request + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/managers/set-update-notifications"), + new Dictionary + { + ["manager"] = request.ManagerName, + ["enabled"] = request.Enabled ? "true" : "false", + } + ); + } + + public async Task ResetSettingsAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/settings/reset") + ) + ?? new IpcCommandResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task> ListDesktopShortcutsAsync() + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/desktop-shortcuts") + ) ?? []; + } + + public async Task SetDesktopShortcutAsync( + IpcDesktopShortcutRequest request + ) + { + return await SendDesktopShortcutOperationAsync( + IpcHttpRoutes.Path("/desktop-shortcuts/set"), + request + ); + } + + public async Task ResetDesktopShortcutAsync( + string shortcutPath + ) + { + return await SendDesktopShortcutOperationAsync( + IpcHttpRoutes.Path("/desktop-shortcuts/reset"), + new IpcDesktopShortcutRequest { Path = shortcutPath } + ); + } + + public async Task ResetDesktopShortcutsAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/desktop-shortcuts/reset-all") + ) + ?? new IpcCommandResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task> GetAppLogAsync(int level = 4) + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/logs/app"), + new Dictionary { ["level"] = level.ToString() } + ) ?? []; + } + + public async Task> GetOperationHistoryAsync() + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/logs/history") + ) ?? []; + } + + public async Task> GetManagerLogAsync( + string? managerName = null, + bool verbose = false + ) + { + Dictionary? parameters = null; + if (!string.IsNullOrWhiteSpace(managerName) || verbose) + { + parameters = new Dictionary(); + if (!string.IsNullOrWhiteSpace(managerName)) + { + parameters["manager"] = managerName; + } + + if (verbose) + { + parameters["verbose"] = "true"; + } + } + + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/logs/manager"), + parameters + ) ?? []; + } + + public async Task GetBackupStatusAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path("/backups/status") + ); + } + + public async Task CreateLocalBackupAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/backups/local/create") + ) + ?? new IpcLocalBackupResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task StartGitHubDeviceFlowAsync( + IpcGitHubDeviceFlowRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcGitHubAuthResult, + IpcGitHubDeviceFlowRequest + >(IpcHttpRoutes.Path("/backups/github/sign-in/start"), request) + ?? new IpcGitHubAuthResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task CompleteGitHubDeviceFlowAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/backups/github/sign-in/complete") + ) + ?? new IpcGitHubAuthResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task SignOutGitHubAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/backups/github/sign-out") + ) + ?? new IpcGitHubAuthResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task> ListCloudBackupsAsync() + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/backups/cloud") + ) ?? []; + } + + public async Task CreateCloudBackupAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/backups/cloud/create") + ) + ?? new IpcCloudBackupUploadResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task DownloadCloudBackupAsync( + IpcCloudBackupRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcCloudBackupContentResult, + IpcCloudBackupRequest + >(IpcHttpRoutes.Path("/backups/cloud/download"), request) + ?? new IpcCloudBackupContentResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task RestoreCloudBackupAsync( + IpcCloudBackupRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcCloudBackupRestoreResult, + IpcCloudBackupRequest + >(IpcHttpRoutes.Path("/backups/cloud/restore"), request) + ?? new IpcCloudBackupRestoreResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task GetBundleAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path("/bundles") + ) + ?? new IpcBundleInfo(); + } + + public async Task ResetBundleAsync() + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + IpcHttpRoutes.Path("/bundles/reset") + ) + ?? new IpcCommandResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task ImportBundleAsync( + IpcBundleImportRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcBundleImportResult, + IpcBundleImportRequest + >(IpcHttpRoutes.Path("/bundles/import"), request) + ?? new IpcBundleImportResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task ExportBundleAsync( + IpcBundleExportRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcBundleExportResult, + IpcBundleExportRequest + >(IpcHttpRoutes.Path("/bundles/export"), request) + ?? new IpcBundleExportResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task AddBundlePackageAsync( + IpcBundlePackageRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcBundlePackageOperationResult, + IpcBundlePackageRequest + >(IpcHttpRoutes.Path("/bundles/add"), request) + ?? new IpcBundlePackageOperationResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task RemoveBundlePackageAsync( + IpcBundlePackageRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcBundlePackageOperationResult, + IpcBundlePackageRequest + >(IpcHttpRoutes.Path("/bundles/remove"), request) + ?? new IpcBundlePackageOperationResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task InstallBundleAsync( + IpcBundleInstallRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcBundleInstallResult, + IpcBundleInstallRequest + >(IpcHttpRoutes.Path("/bundles/install"), request) + ?? new IpcBundleInstallResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + public async Task OpenWindowAsync() + { + return await ShowAppAsync(); + } + + public async Task OpenUpdatesAsync() + { + return await NavigateAppAsync( + new IpcAppNavigateRequest + { + Page = "updates", + } + ); + } + + public async Task ShowPackageAsync( + string packageId, + string packageSource + ) + { + return await SendCommandAsync( + IpcHttpRoutes.Path("/packages/show"), + new Dictionary + { + ["packageId"] = packageId, + ["packageSource"] = packageSource, + } + ); + } + + public async Task GetVersionAsync() + { + return (await GetStatusAsync()).BuildNumber; + } + + public async Task> SearchPackagesAsync( + string query, + string? managerName = null, + int? maxResults = null + ) + { + Dictionary parameters = new() { ["query"] = query }; + if (!string.IsNullOrWhiteSpace(managerName)) + { + parameters["manager"] = managerName; + } + + if (maxResults.HasValue) + { + parameters["maxResults"] = maxResults.Value.ToString(); + } + + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/packages/search"), + parameters + ) ?? []; + } + + public async Task> ListInstalledPackagesAsync( + string? managerName = null + ) + { + Dictionary? parameters = null; + if (!string.IsNullOrWhiteSpace(managerName)) + { + parameters = new Dictionary { ["manager"] = managerName }; + } + + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/packages/installed"), + parameters + ) ?? []; + } + + public async Task> ListUpgradablePackagesAsync( + string? managerName = null + ) + { + Dictionary? parameters = null; + if (!string.IsNullOrWhiteSpace(managerName)) + { + parameters = new Dictionary { ["manager"] = managerName }; + } + + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/packages/updates"), + parameters + ) ?? []; + } + + public async Task GetPackageDetailsAsync( + IpcPackageActionRequest request + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Get, + IpcHttpRoutes.Path("/packages/details"), + BuildPackageQueryParameters(request) + ); + } + + public async Task> GetPackageVersionsAsync( + IpcPackageActionRequest request + ) + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/packages/versions"), + BuildPackageQueryParameters(request) + ) ?? []; + } + + public async Task> ListIgnoredUpdatesAsync() + { + return await ReadAuthenticatedJsonAsync>( + HttpMethod.Get, + IpcHttpRoutes.Path("/packages/ignored") + ) ?? []; + } + + public async Task IgnorePackageUpdateAsync( + IpcPackageActionRequest request + ) + { + return await SendCommandAsync( + IpcHttpRoutes.Path("/packages/ignore"), + BuildPackageQueryParameters(request) + ); + } + + public async Task RemoveIgnoredUpdateAsync( + IpcPackageActionRequest request + ) + { + return await SendCommandAsync( + IpcHttpRoutes.Path("/packages/unignore"), + BuildPackageQueryParameters(request) + ); + } + + public async Task UpdateAllAsync() + { + return await SendCommandAsync(IpcHttpRoutes.Path("/packages/update-all")); + } + + public async Task UpdateManagerAsync(string managerName) + { + return await SendCommandAsync( + IpcHttpRoutes.Path("/packages/update-manager"), + new Dictionary { ["manager"] = managerName } + ); + } + + public async Task InstallPackageAsync( + IpcPackageActionRequest request + ) + { + return await SendPackageOperationAsync(IpcHttpRoutes.Path("/packages/install"), request); + } + + public async Task DownloadPackageAsync( + IpcPackageActionRequest request + ) + { + return await SendPackageOperationAsync( + IpcHttpRoutes.Path("/packages/download"), + request + ); + } + + public async Task ReinstallPackageAsync( + IpcPackageActionRequest request + ) + { + return await SendPackageOperationAsync( + IpcHttpRoutes.Path("/packages/reinstall"), + request + ); + } + + public async Task UpdatePackageAsync( + IpcPackageActionRequest request + ) + { + return await SendPackageOperationAsync(IpcHttpRoutes.Path("/packages/update"), request); + } + + public async Task UninstallPackageAsync( + IpcPackageActionRequest request + ) + { + return await SendPackageOperationAsync( + IpcHttpRoutes.Path("/packages/uninstall"), + request + ); + } + + public async Task UninstallThenReinstallPackageAsync( + IpcPackageActionRequest request + ) + { + return await SendPackageOperationAsync( + IpcHttpRoutes.Path("/packages/uninstall-then-reinstall"), + request + ); + } + + private async Task SendAuthenticatedAsync( + HttpMethod method, + string relativePath, + IReadOnlyDictionary? queryParameters = null, + HttpContent? requestContent = null + ) + { + EnsureTokenAvailable(); + + Dictionary parameters = new(queryParameters ?? new Dictionary()) + { + ["token"] = _token, + }; + + return await SendAsync(method, relativePath, parameters, requestContent); + } + + private async Task SendAsync( + HttpMethod method, + string relativePath, + IReadOnlyDictionary? queryParameters = null, + HttpContent? requestContent = null + ) + { + using var timeout = new CancellationTokenSource(GetRequestTimeout(method, relativePath)); + using var request = new HttpRequestMessage(method, BuildRelativeUri(relativePath, queryParameters)); + request.Content = requestContent; + using var response = await _httpClient.SendAsync(request, timeout.Token); + string content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(content) ? response.ReasonPhrase : content + ); + } + + return content; + } + + private async Task ReadAuthenticatedJsonAsync( + HttpMethod method, + string relativePath, + IReadOnlyDictionary? queryParameters = null + ) + { + string json = await SendAuthenticatedAsync(method, relativePath, queryParameters); + return JsonSerializer.Deserialize( + json, + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task ReadAuthenticatedJsonWithBodyAsync( + string relativePath, + TBody body, + IReadOnlyDictionary? queryParameters = null + ) + { + using var content = JsonContent.Create( + body, + options: new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + string json = await SendAuthenticatedAsync(HttpMethod.Post, relativePath, queryParameters, content); + return JsonSerializer.Deserialize( + json, + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task SendPackageOperationAsync( + string relativePath, + IpcPackageActionRequest request + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + relativePath, + BuildPackageQueryParameters(request) + ) + ?? new IpcPackageOperationResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + private async Task SendSourceOperationAsync( + string relativePath, + IpcSourceRequest request + ) + { + Dictionary parameters = new() + { + ["manager"] = request.ManagerName, + ["name"] = request.SourceName, + }; + + if (!string.IsNullOrWhiteSpace(request.SourceUrl)) + { + parameters["url"] = request.SourceUrl; + } + + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + relativePath, + parameters + ) + ?? new IpcSourceOperationResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + private async Task SendDesktopShortcutOperationAsync( + string relativePath, + IpcDesktopShortcutRequest request + ) + { + Dictionary parameters = new() { ["path"] = request.Path }; + if (!string.IsNullOrWhiteSpace(request.Status)) + { + parameters["status"] = request.Status; + } + + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + relativePath, + parameters + ) + ?? new IpcDesktopShortcutOperationResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + private async Task SendManagerMaintenanceActionAsync( + string relativePath, + IpcManagerMaintenanceRequest request + ) + { + return await ReadAuthenticatedJsonWithBodyAsync< + IpcManagerMaintenanceActionResult, + IpcManagerMaintenanceRequest + >(relativePath, request) + ?? new IpcManagerMaintenanceActionResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + private async Task SendCommandAsync( + string relativePath, + IReadOnlyDictionary? queryParameters = null + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + relativePath, + queryParameters + ) + ?? new IpcCommandResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + + private static Dictionary BuildPackageQueryParameters( + IpcPackageActionRequest request + ) + { + Dictionary parameters = new() { ["packageId"] = request.PackageId }; + + if (!string.IsNullOrWhiteSpace(request.ManagerName)) + { + parameters["manager"] = request.ManagerName; + } + + if (!string.IsNullOrWhiteSpace(request.PackageSource)) + { + parameters["packageSource"] = request.PackageSource; + } + + if (!string.IsNullOrWhiteSpace(request.Version)) + { + parameters["version"] = request.Version; + } + + if (!string.IsNullOrWhiteSpace(request.Scope)) + { + parameters["scope"] = request.Scope; + } + + if (request.PreRelease.HasValue) + { + parameters["preRelease"] = request.PreRelease.Value ? "true" : "false"; + } + + if (request.Elevated.HasValue) + { + parameters["elevated"] = request.Elevated.Value ? "true" : "false"; + } + + if (request.Interactive.HasValue) + { + parameters["interactive"] = request.Interactive.Value ? "true" : "false"; + } + + if (request.SkipHash.HasValue) + { + parameters["skipHash"] = request.SkipHash.Value ? "true" : "false"; + } + + if (request.RemoveData.HasValue) + { + parameters["removeData"] = request.RemoveData.Value ? "true" : "false"; + } + + if (request.WaitForCompletion.HasValue) + { + parameters["wait"] = request.WaitForCompletion.Value ? "true" : "false"; + } + + if (!string.IsNullOrWhiteSpace(request.Architecture)) + { + parameters["architecture"] = request.Architecture; + } + + if (!string.IsNullOrWhiteSpace(request.InstallLocation)) + { + parameters["location"] = request.InstallLocation; + } + + if (!string.IsNullOrWhiteSpace(request.OutputPath)) + { + parameters["outputPath"] = request.OutputPath; + } + + return parameters; + } + + private static Dictionary BuildAppNavigateParameters( + IpcAppNavigateRequest request + ) + { + Dictionary parameters = new() + { + ["page"] = IpcAppPages.NormalizePageName(request.Page), + }; + + if (!string.IsNullOrWhiteSpace(request.ManagerName)) + { + parameters["manager"] = request.ManagerName; + } + + if (!string.IsNullOrWhiteSpace(request.HelpAttachment)) + { + parameters["helpAttachment"] = request.HelpAttachment; + } + + return parameters; + } + + private static HttpClient CreateHttpClient(IpcTransportOptions options) + { + if (options.TransportKind == IpcTransportKind.NamedPipe) + { + var handler = new SocketsHttpHandler + { + UseProxy = false, + ConnectCallback = async (_, cancellationToken) => + { + if (OperatingSystem.IsWindows()) + { + var pipeClient = new NamedPipeClientStream( + ".", + options.NamedPipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous + ); + await pipeClient.ConnectAsync(cancellationToken); + return pipeClient; + } + + string socketPath = options.NamedPipePath + ?? throw new InvalidOperationException( + "The Unix socket path is not available for the named-pipe transport." + ); + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + await socket.ConnectAsync( + new UnixDomainSocketEndPoint(socketPath), + cancellationToken + ); + return new NetworkStream(socket, ownsSocket: true); + }, + }; + + return new HttpClient(handler) + { + BaseAddress = options.BaseAddress, + Timeout = Timeout.InfiniteTimeSpan, + }; + } + + return new HttpClient + { + BaseAddress = options.BaseAddress, + Timeout = Timeout.InfiniteTimeSpan, + }; + } + + private static TimeSpan GetRequestTimeout(HttpMethod method, string relativePath) + { + if (IpcHttpRoutes.Matches(relativePath, "/status")) + { + return TimeSpan.FromSeconds(5); + } + + if (IpcHttpRoutes.StartsWith(relativePath, "/packages/")) + { + return method == HttpMethod.Post + ? TimeSpan.FromMinutes(5) + : TimeSpan.FromSeconds(30); + } + + if (IpcHttpRoutes.StartsWith(relativePath, "/bundles/install")) + { + return TimeSpan.FromMinutes(5); + } + + if (IpcHttpRoutes.StartsWith(relativePath, "/managers/maintenance/action")) + { + return TimeSpan.FromMinutes(10); + } + + if (IpcHttpRoutes.StartsWith(relativePath, "/backups/github/sign-in/complete")) + { + return TimeSpan.FromMinutes(5); + } + + if ( + IpcHttpRoutes.StartsWith(relativePath, "/backups/local/create") + || IpcHttpRoutes.StartsWith(relativePath, "/backups/cloud/create") + ) + { + return TimeSpan.FromMinutes(2); + } + + if (IpcHttpRoutes.StartsWith(relativePath, "/backups/")) + { + return method == HttpMethod.Post ? TimeSpan.FromMinutes(1) : TimeSpan.FromSeconds(30); + } + + if (IpcHttpRoutes.StartsWith(relativePath, "/bundles/")) + { + return method == HttpMethod.Post ? TimeSpan.FromMinutes(1) : TimeSpan.FromSeconds(15); + } + + if (IpcHttpRoutes.StartsWith(relativePath, "/sources/")) + { + return method == HttpMethod.Post + ? TimeSpan.FromMinutes(2) + : TimeSpan.FromSeconds(30); + } + + if ( + IpcHttpRoutes.StartsWith(relativePath, "/managers") + || IpcHttpRoutes.StartsWith(relativePath, "/settings") + || IpcHttpRoutes.StartsWith(relativePath, "/secure-settings") + || IpcHttpRoutes.StartsWith(relativePath, "/desktop-shortcuts") + || IpcHttpRoutes.StartsWith(relativePath, "/logs/") + ) + { + return TimeSpan.FromSeconds(15); + } + + return TimeSpan.FromSeconds(5); + } + + private static bool IsConnectivityException(Exception exception) + { + return exception is HttpRequestException + or IOException + or TaskCanceledException + or OperationCanceledException; + } + + private static IpcEndpointRegistration? SelectLiveRegistration( + IEnumerable candidates + ) + { + foreach (IpcEndpointRegistration candidate in candidates) + { + if (candidate.ProcessId > 0 && !IsProcessRunning(candidate.ProcessId)) + { + IpcTransportOptions.DeletePersistedMetadata(candidate.SessionId); + continue; + } + + if (IsEndpointAlive(candidate)) + { + return candidate; + } + + if (candidate.ProcessId <= 0) + { + IpcTransportOptions.DeletePersistedMetadata(candidate.SessionId); + } + } + + return null; + } + + private static bool IsEndpointAlive(IpcEndpointRegistration candidate) + { + try + { + using var client = new IpcClient(candidate.ToTransportOptions(), candidate.Token); + return client.GetStatusAsync().GetAwaiter().GetResult().Running; + } + catch (Exception ex) when (IsConnectivityException(ex) || ex is InvalidOperationException) + { + Logger.Debug( + $"IPC API registration {candidate.SessionId} probe failed: {ex.Message}" + ); + return false; + } + } + + private static bool IsProcessRunning(int processId) + { + try + { + using Process process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (Exception) + { + return false; + } + } + + private void EnsureTokenAvailable() + { + if (string.IsNullOrWhiteSpace(_token)) + { + throw new InvalidOperationException( + "The IPC API token is not available. Start UniGetUI and try again." + ); + } + } + + private static string BuildRelativeUri( + string relativePath, + IReadOnlyDictionary? queryParameters + ) + { + if (queryParameters is null || queryParameters.Count == 0) + { + return relativePath; + } + + string query = string.Join( + "&", + queryParameters.Select(pair => + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}" + ) + ); + + return $"{relativePath}?{query}"; + } + + public void Dispose() + { + _httpClient.Dispose(); + } +} + +public sealed class IpcCommandResult +{ + public string Status { get; set; } = "success"; + public string Command { get; set; } = ""; + public string? Message { get; set; } + + public static IpcCommandResult Success(string command) + { + return new IpcCommandResult { Command = command }; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcDesktopShortcutsApi.cs b/src/UniGetUI.Interface.IpcApi/IpcDesktopShortcutsApi.cs new file mode 100644 index 0000000000..6573e117b3 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcDesktopShortcutsApi.cs @@ -0,0 +1,134 @@ +using UniGetUI.PackageEngine.Classes.Packages.Classes; + +namespace UniGetUI.Interface; + +public sealed class IpcDesktopShortcutInfo +{ + public string Path { get; set; } = ""; + public string Name { get; set; } = ""; + public string Status { get; set; } = ""; + public bool ExistsOnDisk { get; set; } + public bool IsTracked { get; set; } + public bool IsPendingReview { get; set; } +} + +public sealed class IpcDesktopShortcutRequest +{ + public string Path { get; set; } = ""; + public string? Status { get; set; } +} + +public sealed class IpcDesktopShortcutOperationResult +{ + public string Status { get; set; } = "success"; + public string Command { get; set; } = ""; + public string? Message { get; set; } + public IpcDesktopShortcutInfo? Shortcut { get; set; } +} + +public static class IpcDesktopShortcutsApi +{ + public static IReadOnlyList ListShortcuts() + { + var trackedShortcuts = DesktopShortcutsDatabase.GetDatabase(); + HashSet allShortcuts = + [ + .. DesktopShortcutsDatabase.GetAllShortcuts(), + .. DesktopShortcutsDatabase.GetUnknownShortcuts(), + ]; + + return allShortcuts + .OrderBy(path => System.IO.Path.GetFileName(path), StringComparer.OrdinalIgnoreCase) + .ThenBy(path => path, StringComparer.OrdinalIgnoreCase) + .Select(path => ToShortcutInfo(path, trackedShortcuts)) + .ToArray(); + } + + public static IpcDesktopShortcutOperationResult SetShortcut( + IpcDesktopShortcutRequest request + ) + { + string shortcutPath = NormalizeShortcutPath(request.Path); + string shortcutStatus = request.Status?.Trim().ToLowerInvariant() ?? ""; + + DesktopShortcutsDatabase.Status status = shortcutStatus switch + { + "delete" => DesktopShortcutsDatabase.Status.Delete, + "keep" => DesktopShortcutsDatabase.Status.Maintain, + _ => throw new InvalidOperationException( + "The status parameter must be either keep or delete." + ), + }; + + DesktopShortcutsDatabase.AddToDatabase(shortcutPath, status); + DesktopShortcutsDatabase.RemoveFromUnknownShortcuts(shortcutPath); + + if (status is DesktopShortcutsDatabase.Status.Delete && File.Exists(shortcutPath)) + { + DesktopShortcutsDatabase.DeleteFromDisk(shortcutPath); + } + + return new IpcDesktopShortcutOperationResult + { + Command = "set-desktop-shortcut", + Shortcut = ToShortcutInfo(shortcutPath), + }; + } + + public static IpcDesktopShortcutOperationResult ResetShortcut( + IpcDesktopShortcutRequest request + ) + { + string shortcutPath = NormalizeShortcutPath(request.Path); + DesktopShortcutsDatabase.AddToDatabase(shortcutPath, DesktopShortcutsDatabase.Status.Unknown); + + return new IpcDesktopShortcutOperationResult + { + Command = "reset-desktop-shortcut", + Shortcut = ToShortcutInfo(shortcutPath), + }; + } + + public static IpcCommandResult ResetAllShortcuts() + { + DesktopShortcutsDatabase.ResetDatabase(); + return IpcCommandResult.Success("reset-desktop-shortcuts"); + } + + private static IpcDesktopShortcutInfo ToShortcutInfo( + string shortcutPath, + IReadOnlyDictionary? trackedShortcuts = null + ) + { + trackedShortcuts ??= DesktopShortcutsDatabase.GetDatabase(); + string fileName = System.IO.Path.GetFileName(shortcutPath); + + return new IpcDesktopShortcutInfo + { + Path = shortcutPath, + Name = string.IsNullOrWhiteSpace(fileName) + ? shortcutPath + : System.IO.Path.GetFileNameWithoutExtension(fileName), + Status = DesktopShortcutsDatabase.GetStatus(shortcutPath) switch + { + DesktopShortcutsDatabase.Status.Delete => "delete", + DesktopShortcutsDatabase.Status.Maintain => "keep", + _ => "unknown", + }, + ExistsOnDisk = File.Exists(shortcutPath), + IsTracked = trackedShortcuts.ContainsKey(shortcutPath), + IsPendingReview = DesktopShortcutsDatabase.GetUnknownShortcuts().Contains(shortcutPath), + }; + } + + private static string NormalizeShortcutPath(string shortcutPath) + { + string normalizedPath = shortcutPath.Trim().Trim('"').Trim('\''); + if (string.IsNullOrWhiteSpace(normalizedPath)) + { + throw new InvalidOperationException("The path parameter is required."); + } + + return normalizedPath; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcHttpRoutes.cs b/src/UniGetUI.Interface.IpcApi/IpcHttpRoutes.cs new file mode 100644 index 0000000000..cb1a6ba732 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcHttpRoutes.cs @@ -0,0 +1,30 @@ +namespace UniGetUI.Interface; + +internal static class IpcHttpRoutes +{ + public const string Prefix = "/uniget"; + public const string Version = "/v1"; + public const string ApiRoot = Prefix + Version; + + public static string Path(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + return relativePath.StartsWith("/", StringComparison.Ordinal) + ? ApiRoot + relativePath + : throw new ArgumentException( + "IPC route fragments must start with '/'.", + nameof(relativePath) + ); + } + + public static bool Matches(string path, string relativePath) + { + return path.Equals(Path(relativePath), StringComparison.OrdinalIgnoreCase); + } + + public static bool StartsWith(string path, string relativePathPrefix) + { + return path.StartsWith(Path(relativePathPrefix), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs b/src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs new file mode 100644 index 0000000000..7caa9b9b7a --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs @@ -0,0 +1,112 @@ +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.Interface; + +public sealed class IpcAppLogEntry +{ + public string Time { get; set; } = ""; + public string Severity { get; set; } = ""; + public string Content { get; set; } = ""; +} + +public sealed class IpcOperationHistoryEntry +{ + public string Content { get; set; } = ""; +} + +public sealed class IpcManagerLogTask +{ + public int Index { get; set; } + public string[] Lines { get; set; } = []; +} + +public sealed class IpcManagerLogInfo +{ + public string Name { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Version { get; set; } = ""; + public IpcManagerLogTask[] Tasks { get; set; } = []; +} + +public static class IpcLogsApi +{ + public static IReadOnlyList ListAppLog(int level = 4) + { + return Logger.GetLogs() + .Where(entry => !string.IsNullOrWhiteSpace(entry.Content) && !ShouldSkip(entry.Severity, level)) + .Select(entry => new IpcAppLogEntry + { + Time = entry.Time.ToString("O"), + Severity = entry.Severity.ToString().ToLowerInvariant(), + Content = entry.Content, + }) + .ToArray(); + } + + public static IReadOnlyList ListOperationHistory() + { + return Settings.GetValue(Settings.K.OperationHistory) + .Split('\n') + .Select(line => line.Replace("\r", "").Replace("\n", "").Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => new IpcOperationHistoryEntry { Content = line }) + .ToArray(); + } + + public static IReadOnlyList ListManagerLogs( + string? managerName = null, + bool verbose = false + ) + { + return ResolveManagers(managerName) + .Select(manager => new IpcManagerLogInfo + { + Name = IpcManagerSettingsApi.GetPublicManagerId(manager), + DisplayName = manager.DisplayName, + Version = manager.Status.Version, + Tasks = manager.TaskLogger.Operations + .Select((operation, index) => new IpcManagerLogTask + { + Index = index, + Lines = operation + .AsColoredString(verbose) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(StripColorCode) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToArray(), + }) + .Where(task => task.Lines.Length > 0) + .ToArray(), + }) + .ToArray(); + } + + private static IReadOnlyList ResolveManagers(string? managerName) + { + var managers = IpcManagerSettingsApi.ResolveManagers(managerName) + .OrderBy(manager => manager.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return managers; + } + + private static bool ShouldSkip(LogEntry.SeverityLevel severity, int level) => + level switch + { + <= 1 => severity != LogEntry.SeverityLevel.Error, + 2 => severity is LogEntry.SeverityLevel.Debug + or LogEntry.SeverityLevel.Info + or LogEntry.SeverityLevel.Success, + 3 => severity is LogEntry.SeverityLevel.Debug or LogEntry.SeverityLevel.Info, + 4 => severity == LogEntry.SeverityLevel.Debug, + _ => false, + }; + + private static string StripColorCode(string line) + { + return line.Length > 1 && char.IsDigit(line[0]) ? line[1..] : line; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcManagerMaintenanceApi.cs b/src/UniGetUI.Interface.IpcApi/IpcManagerMaintenanceApi.cs new file mode 100644 index 0000000000..bef951b3a4 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcManagerMaintenanceApi.cs @@ -0,0 +1,382 @@ +using System.Diagnostics; +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.SettingsEngine.SecureSettings; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.VcpkgManager; + +namespace UniGetUI.Interface; + +public sealed class IpcManagerMaintenanceInfo +{ + public string Manager { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public bool Enabled { get; set; } + public bool Ready { get; set; } + public bool CustomExecutablePathsAllowed { get; set; } + public string? ConfiguredExecutablePath { get; set; } + public string EffectiveExecutablePath { get; set; } = ""; + public IReadOnlyList CandidateExecutablePaths { get; set; } = []; + public IReadOnlyList SupportedActions { get; set; } = []; + public bool? UseBundledWinGet { get; set; } + public bool? UseSystemChocolatey { get; set; } + public bool? ScoopCleanupOnLaunch { get; set; } + public bool UpdateNotificationsSuppressed { get; set; } + public string? DefaultVcpkgTriplet { get; set; } + public IReadOnlyList AvailableVcpkgTriplets { get; set; } = []; + public string? CustomVcpkgRoot { get; set; } +} + +public sealed class IpcManagerMaintenanceRequest +{ + public string ManagerName { get; set; } = ""; + public string? Action { get; set; } + public string? Path { get; set; } + public bool Confirm { get; set; } +} + +public sealed class IpcManagerMaintenanceActionResult +{ + public string Status { get; set; } = "success"; + public string Command { get; set; } = ""; + public string Manager { get; set; } = ""; + public string Action { get; set; } = ""; + public string OperationStatus { get; set; } = ""; + public string? Message { get; set; } + public IpcManagerMaintenanceInfo Maintenance { get; set; } = new(); +} + +public static class IpcManagerMaintenanceApi +{ + public static IpcManagerMaintenanceInfo GetMaintenanceInfo(string managerName) + { + return ToMaintenanceInfo(IpcManagerSettingsApi.ResolveManager(managerName)); + } + + public static async Task ReloadManagerAsync( + IpcManagerMaintenanceRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + var manager = IpcManagerSettingsApi.ResolveManager(request.ManagerName); + await ReloadManagerAsync(manager); + return Success("reload-manager", manager, "reload", "completed"); + } + + public static async Task SetExecutablePathAsync( + IpcManagerMaintenanceRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + var manager = IpcManagerSettingsApi.ResolveManager(request.ManagerName); + if (!SecureSettings.Get(SecureSettings.K.AllowCustomManagerPaths)) + { + throw new InvalidOperationException( + "Custom manager paths are disabled by secure settings." + ); + } + + if (string.IsNullOrWhiteSpace(request.Path)) + { + throw new InvalidOperationException("The path field is required."); + } + + Settings.SetDictionaryItem(Settings.K.ManagerPaths, manager.Name, request.Path); + await ReloadManagerAsync(manager); + return Success( + "set-manager-executable", + manager, + "set-executable", + "completed", + $"Configured {manager.DisplayName} to use {request.Path}." + ); + } + + public static async Task ClearExecutablePathAsync( + IpcManagerMaintenanceRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + var manager = IpcManagerSettingsApi.ResolveManager(request.ManagerName); + Settings.RemoveDictionaryKey(Settings.K.ManagerPaths, manager.Name); + await ReloadManagerAsync(manager); + return Success( + "clear-manager-executable", + manager, + "clear-executable", + "completed", + $"Cleared the custom executable override for {manager.DisplayName}." + ); + } + + public static async Task RunActionAsync( + IpcManagerMaintenanceRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + var manager = IpcManagerSettingsApi.ResolveManager(request.ManagerName); + string action = request.Action?.Trim().ToLowerInvariant() + ?? throw new InvalidOperationException("The action field is required."); + + switch (action) + { + case "repair-winget": + EnsureConfirmed(request, action); + EnsureManager(manager, "WinGet"); + EnsureWindowsOnly(action); + await RunWindowsProcessAsync( + CoreData.PowerShell5, + "-ExecutionPolicy Bypass -NoLogo -NoProfile -Command \"& {" + + "cmd.exe /C \"\"rmdir /Q /S `\"%temp%\\WinGet`\"\"\"; " + + "cmd.exe /C \"\"`\"%localappdata%\\Microsoft\\WindowsApps\\winget.exe`\" source reset --force\"\"; " + + "taskkill /im winget.exe /f; " + + "taskkill /im WindowsPackageManagerServer.exe /f; " + + "Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force; " + + "Install-Module Microsoft.WinGet.Client -Force -AllowClobber; " + + "Import-Module Microsoft.WinGet.Client; " + + "Repair-WinGetPackageManager -Force -Latest; " + + "Get-AppxPackage -Name 'Microsoft.DesktopAppInstaller' | Reset-AppxPackage; " + + "}\"", + runAsAdmin: true + ); + Settings.SetValue(Settings.K.WinGetCliToolPreference, "default"); + await ReloadManagerAsync(manager); + return Success( + "run-manager-action", + manager, + action, + "completed", + "WinGet repair completed." + ); + + case "install-scoop": + EnsureConfirmed(request, action); + EnsureManager(manager, "Scoop"); + EnsureWindowsOnly(action); + string installScriptPath = Path.Join( + CoreData.UniGetUIExecutableDirectory, + "Assets", + "Utilities", + "install_scoop.ps1" + ); + await RunWindowsProcessAsync( + CoreData.PowerShell5, + $"-ExecutionPolicy Bypass -File \"{installScriptPath}\"", + runAsAdmin: true + ); + await ReloadManagerAsync(manager); + return Success( + "run-manager-action", + manager, + action, + "completed", + "Scoop installation completed." + ); + + case "uninstall-scoop": + EnsureConfirmed(request, action); + EnsureManager(manager, "Scoop"); + EnsureWindowsOnly(action); + await RunWindowsProcessAsync( + CoreData.PowerShell5, + "-ExecutionPolicy Bypass -Command \"scoop uninstall -p scoop\"" + ); + await ReloadManagerAsync(manager); + return Success( + "run-manager-action", + manager, + action, + "completed", + "Scoop uninstall completed." + ); + + case "cleanup-scoop": + EnsureConfirmed(request, action); + EnsureManager(manager, "Scoop"); + EnsureWindowsOnly(action); + if (string.IsNullOrWhiteSpace(manager.Status.ExecutablePath)) + { + throw new InvalidOperationException("Scoop is not ready."); + } + + await RunWindowsProcessAsync( + manager.Status.ExecutablePath, + manager.Status.ExecutableCallArgs + " cache rm *" + ); + await RunWindowsProcessAsync( + manager.Status.ExecutablePath, + manager.Status.ExecutableCallArgs + " cleanup --all --cache" + ); + await RunWindowsProcessAsync( + manager.Status.ExecutablePath, + manager.Status.ExecutableCallArgs + " cleanup --all --global --cache", + runAsAdmin: true + ); + await ReloadManagerAsync(manager); + return Success( + "run-manager-action", + manager, + action, + "completed", + "Scoop cleanup completed." + ); + + default: + throw new InvalidOperationException( + $"The manager action \"{request.Action}\" is not supported." + ); + } + } + + private static IpcManagerMaintenanceInfo ToMaintenanceInfo(IPackageManager manager) + { + string? configuredExecutablePath = Settings.GetDictionaryItem( + Settings.K.ManagerPaths, + manager.Name + ); + + List supportedActions = + [ + "reload", + ]; + + if (manager.Name.Equals("WinGet", StringComparison.OrdinalIgnoreCase)) + { + supportedActions.Add("repair-winget"); + } + else if (manager.Name.Equals("Scoop", StringComparison.OrdinalIgnoreCase)) + { + supportedActions.Add("install-scoop"); + supportedActions.Add("uninstall-scoop"); + supportedActions.Add("cleanup-scoop"); + } + + IReadOnlyList triplets = manager.Name.Equals("vcpkg", StringComparison.OrdinalIgnoreCase) + ? Vcpkg.GetSystemTriplets().ToArray() + : []; + + string? customVcpkgRoot = manager.Name.Equals("vcpkg", StringComparison.OrdinalIgnoreCase) + && Settings.Get(Settings.K.CustomVcpkgRoot) + ? Settings.GetValue(Settings.K.CustomVcpkgRoot) + : null; + + return new IpcManagerMaintenanceInfo + { + Manager = IpcManagerSettingsApi.GetPublicManagerId(manager), + DisplayName = manager.DisplayName, + Enabled = manager.IsEnabled(), + Ready = manager.IsReady(), + CustomExecutablePathsAllowed = SecureSettings.Get(SecureSettings.K.AllowCustomManagerPaths), + ConfiguredExecutablePath = string.IsNullOrWhiteSpace(configuredExecutablePath) + ? null + : configuredExecutablePath, + EffectiveExecutablePath = manager.Status.ExecutablePath, + CandidateExecutablePaths = manager.FindCandidateExecutableFiles().ToArray(), + SupportedActions = supportedActions, + UseBundledWinGet = manager.Name.Equals("WinGet", StringComparison.OrdinalIgnoreCase) + ? string.Equals( + Settings.GetValue(Settings.K.WinGetCliToolPreference), + "pinget", + StringComparison.OrdinalIgnoreCase + ) + : null, + UseSystemChocolatey = manager.Name.Equals("Chocolatey", StringComparison.OrdinalIgnoreCase) + ? true + : null, + ScoopCleanupOnLaunch = manager.Name.Equals("Scoop", StringComparison.OrdinalIgnoreCase) + ? Settings.Get(Settings.K.EnableScoopCleanup) + : null, + UpdateNotificationsSuppressed = Settings.GetDictionaryItem( + Settings.K.DisabledPackageManagerNotifications, + manager.Name + ), + DefaultVcpkgTriplet = manager.Name.Equals("vcpkg", StringComparison.OrdinalIgnoreCase) + ? Settings.GetValue(Settings.K.DefaultVcpkgTriplet) + : null, + AvailableVcpkgTriplets = triplets, + CustomVcpkgRoot = string.IsNullOrWhiteSpace(customVcpkgRoot) ? null : customVcpkgRoot, + }; + } + + private static async Task ReloadManagerAsync(IPackageManager manager) + { + await Task.Run(manager.Initialize); + } + + private static async Task RunWindowsProcessAsync( + string fileName, + string arguments, + bool runAsAdmin = false + ) + { + using Process process = new() + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = runAsAdmin, + Verb = runAsAdmin ? "runas" : string.Empty, + CreateNoWindow = !runAsAdmin, + }, + }; + process.Start(); + await process.WaitForExitAsync(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"The maintenance command exited with code {process.ExitCode}." + ); + } + } + + private static void EnsureConfirmed(IpcManagerMaintenanceRequest request, string action) + { + if (!request.Confirm) + { + throw new InvalidOperationException( + $"The manager action \"{action}\" requires confirm=true." + ); + } + } + + private static void EnsureManager(IPackageManager manager, string expectedName) + { + if (!manager.Name.Equals(expectedName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{expectedName} maintenance actions can only run against the {expectedName} manager." + ); + } + } + + private static void EnsureWindowsOnly(string action) + { + if (!OperatingSystem.IsWindows()) + { + throw new InvalidOperationException( + $"The manager action \"{action}\" is only supported on Windows." + ); + } + } + + private static IpcManagerMaintenanceActionResult Success( + string command, + IPackageManager manager, + string action, + string operationStatus, + string? message = null + ) + { + return new IpcManagerMaintenanceActionResult + { + Status = "success", + Command = command, + Manager = IpcManagerSettingsApi.GetPublicManagerId(manager), + Action = action, + OperationStatus = operationStatus, + Message = message, + Maintenance = ToMaintenanceInfo(manager), + }; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcManagerSettingsApi.cs b/src/UniGetUI.Interface.IpcApi/IpcManagerSettingsApi.cs new file mode 100644 index 0000000000..11071a2a52 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcManagerSettingsApi.cs @@ -0,0 +1,539 @@ +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Interface; + +public sealed class IpcManagerCapabilitiesInfo +{ + public bool CanRunAsAdmin { get; set; } + public bool CanSkipIntegrityChecks { get; set; } + public bool CanRunInteractively { get; set; } + public bool CanRemoveDataOnUninstall { get; set; } + public bool CanDownloadInstaller { get; set; } + public bool SupportsCustomVersions { get; set; } + public bool SupportsCustomArchitectures { get; set; } + public bool SupportsCustomScopes { get; set; } + public bool SupportsPreRelease { get; set; } + public bool SupportsCustomLocations { get; set; } + public bool SupportsCustomSources { get; set; } + public bool MustInstallSourcesAsAdmin { get; set; } +} + +public sealed class IpcManagerInfo +{ + public string Name { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public bool Enabled { get; set; } + public bool Ready { get; set; } + public bool NotificationsSuppressed { get; set; } + public string ExecutablePath { get; set; } = ""; + public string ExecutableArguments { get; set; } = ""; + public IpcManagerCapabilitiesInfo Capabilities { get; set; } = new(); +} + +public sealed class IpcSourceInfo +{ + public string Manager { get; set; } = ""; + public string Name { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Url { get; set; } = ""; + public int? PackageCount { get; set; } + public string UpdateDate { get; set; } = ""; + public bool IsKnown { get; set; } + public bool IsConfigured { get; set; } +} + +public sealed class IpcSourceRequest +{ + public string ManagerName { get; set; } = ""; + public string SourceName { get; set; } = ""; + public string? SourceUrl { get; set; } +} + +public sealed class IpcSourceOperationResult +{ + public string Status { get; set; } = "success"; + public string Command { get; set; } = ""; + public string OperationStatus { get; set; } = ""; + public string? Message { get; set; } + public IpcSourceInfo? Source { get; set; } +} + +public sealed class IpcSettingInfo +{ + public string Name { get; set; } = ""; + public string Key { get; set; } = ""; + public bool IsSet { get; set; } + public bool BoolValue { get; set; } + public string StringValue { get; set; } = ""; + public bool HasStringValue { get; set; } +} + +public sealed class IpcSettingValueRequest +{ + public string SettingKey { get; set; } = ""; + public bool? Enabled { get; set; } + public string? Value { get; set; } +} + +public sealed class IpcManagerToggleRequest +{ + public string ManagerName { get; set; } = ""; + public bool Enabled { get; set; } +} + +public static class IpcManagerSettingsApi +{ + private static readonly HashSet HiddenSettings = + [ + Settings.K.CurrentSessionToken, + Settings.K.TelemetryClientToken, + ]; + + public static IReadOnlyList ListManagers() + { + return PEInterface.Managers + .OrderBy(manager => manager.DisplayName, StringComparer.OrdinalIgnoreCase) + .Select(ToManagerInfo) + .ToArray(); + } + + public static IReadOnlyList ListSources(string? managerName = null) + { + return ResolveManagers(managerName) + .SelectMany(GetMergedSources) + .OrderBy(source => source.Manager, StringComparer.OrdinalIgnoreCase) + .ThenBy(source => source.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static async Task AddSourceAsync( + IpcSourceRequest request + ) + { + var manager = ResolveManager(request.ManagerName); + var source = ResolveSourceForAdd(manager, request); + + using var operation = new AddSourceOperation(source); + await operation.MainThread(); + + return ToSourceOperationResult("add-source", operation, source); + } + + public static async Task RemoveSourceAsync( + IpcSourceRequest request + ) + { + var manager = ResolveManager(request.ManagerName); + var source = ResolveSourceForRemove(manager, request); + + using var operation = new RemoveSourceOperation(source); + await operation.MainThread(); + + return ToSourceOperationResult("remove-source", operation, source); + } + + public static IReadOnlyList ListSettings() + { + return Enum.GetValues() + .Where(IsVisibleSetting) + .OrderBy(setting => setting.ToString(), StringComparer.OrdinalIgnoreCase) + .Select(ToSettingInfo) + .ToArray(); + } + + public static IpcSettingInfo GetSetting(string settingKey) + { + return ToSettingInfo(ResolveSettingKey(settingKey)); + } + + public static IpcSettingInfo SetSetting(IpcSettingValueRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var key = ResolveSettingKey(request.SettingKey); + var hasEnabled = request.Enabled.HasValue; + var hasValue = request.Value is not null; + + if (hasEnabled == hasValue) + { + throw new InvalidOperationException( + "Provide exactly one of enabled or value when setting a setting." + ); + } + + if (hasValue) + { + Settings.SetValue(key, request.Value ?? ""); + } + else + { + Settings.Set(key, request.Enabled!.Value); + } + + return ToSettingInfo(key); + } + + public static IpcSettingInfo ClearSetting(string settingKey) + { + var key = ResolveSettingKey(settingKey); + Settings.SetValue(key, string.Empty); + return ToSettingInfo(key); + } + + public static void ResetSettingsPreservingSession() + { + Settings.ResetSettings(); + } + + public static async Task SetManagerEnabledAsync( + IpcManagerToggleRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + var manager = ResolveManager(request.ManagerName); + Settings.SetDictionaryItem(Settings.K.DisabledManagers, manager.Name, !request.Enabled); + await Task.Run(manager.Initialize); + return ToManagerInfo(manager); + } + + public static IpcManagerInfo SetManagerNotifications( + IpcManagerToggleRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + var manager = ResolveManager(request.ManagerName); + Settings.SetDictionaryItem( + Settings.K.DisabledPackageManagerNotifications, + manager.Name, + !request.Enabled + ); + return ToManagerInfo(manager); + } + + private static IpcManagerInfo ToManagerInfo(IPackageManager manager) + { + return new IpcManagerInfo + { + Name = GetPublicManagerId(manager), + DisplayName = manager.DisplayName, + Enabled = manager.IsEnabled(), + Ready = manager.IsReady(), + NotificationsSuppressed = Settings.GetDictionaryItem( + Settings.K.DisabledPackageManagerNotifications, + manager.Name + ), + ExecutablePath = manager.Status.ExecutablePath, + ExecutableArguments = manager.Status.ExecutableCallArgs, + Capabilities = new IpcManagerCapabilitiesInfo + { + CanRunAsAdmin = manager.Capabilities.CanRunAsAdmin, + CanSkipIntegrityChecks = manager.Capabilities.CanSkipIntegrityChecks, + CanRunInteractively = manager.Capabilities.CanRunInteractively, + CanRemoveDataOnUninstall = manager.Capabilities.CanRemoveDataOnUninstall, + CanDownloadInstaller = manager.Capabilities.CanDownloadInstaller, + SupportsCustomVersions = manager.Capabilities.SupportsCustomVersions, + SupportsCustomArchitectures = manager.Capabilities.SupportsCustomArchitectures, + SupportsCustomScopes = manager.Capabilities.SupportsCustomScopes, + SupportsPreRelease = manager.Capabilities.SupportsPreRelease, + SupportsCustomLocations = manager.Capabilities.SupportsCustomLocations, + SupportsCustomSources = manager.Capabilities.SupportsCustomSources, + MustInstallSourcesAsAdmin = manager.Capabilities.Sources.MustBeInstalledAsAdmin, + }, + }; + } + + private static IReadOnlyList GetMergedSources(IPackageManager manager) + { + Dictionary sources = new(StringComparer.OrdinalIgnoreCase); + + foreach (var knownSource in manager.Properties.KnownSources) + { + sources[GetSourceIdentity(knownSource)] = ToSourceInfo( + knownSource, + isKnown: true, + isConfigured: false + ); + } + + foreach (var configuredSource in GetConfiguredSources(manager)) + { + var sourceKey = GetSourceIdentity(configuredSource); + if (sources.TryGetValue(sourceKey, out var existing)) + { + existing.IsConfigured = true; + existing.PackageCount = configuredSource.PackageCount; + existing.UpdateDate = configuredSource.UpdateDate ?? ""; + existing.Url = configuredSource.Url.ToString(); + } + else + { + sources[sourceKey] = ToSourceInfo( + configuredSource, + isKnown: false, + isConfigured: true + ); + } + } + + return sources.Values.ToArray(); + } + + private static string GetSourceIdentity(IManagerSource source) + { + return $"{source.Manager.Name}|{source.Name}|{source.Url}"; + } + + private static IpcSourceInfo ToSourceInfo( + IManagerSource source, + bool isKnown, + bool isConfigured + ) + { + return new IpcSourceInfo + { + Manager = GetPublicManagerId(source.Manager), + Name = source.Name, + DisplayName = source.AsString_DisplayName, + Url = source.Url.ToString(), + PackageCount = source.PackageCount, + UpdateDate = source.UpdateDate ?? "", + IsKnown = isKnown, + IsConfigured = isConfigured, + }; + } + + private static IpcSourceOperationResult ToSourceOperationResult( + string command, + AbstractOperation operation, + IManagerSource source + ) + { + return new IpcSourceOperationResult + { + Status = operation.Status == OperationStatus.Succeeded ? "success" : "error", + Command = command, + OperationStatus = operation.Status.ToString().ToLowerInvariant(), + Message = operation.Status switch + { + OperationStatus.Succeeded => null, + OperationStatus.Canceled => "The operation was canceled.", + _ => operation.GetOutput().LastOrDefault().Item1, + }, + Source = ToSourceInfo(source, isKnown: true, isConfigured: true), + }; + } + + internal static IReadOnlyList ResolveManagers(string? managerName) + { + var managers = PEInterface.Managers + .Where(manager => MatchesManagerId(manager, managerName)) + .ToArray(); + + if (managers.Length == 0) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(managerName) + ? "No package managers are available." + : $"No package manager matching \"{managerName}\" was found." + ); + } + + return managers; + } + + internal static IPackageManager ResolveManager(string managerName) + { + if (string.IsNullOrWhiteSpace(managerName)) + { + throw new InvalidOperationException("The manager parameter is required."); + } + + return ResolveManagers(managerName).First(); + } + + internal static bool MatchesManagerId(IPackageManager manager, string? managerName) + { + return string.IsNullOrWhiteSpace(managerName) + || manager.Id.Equals(managerName, StringComparison.OrdinalIgnoreCase); + } + + internal static string GetPublicManagerId(IPackageManager manager) + { + return manager.Id; + } + + internal static string GetPublicManagerId(string? managerName) + { + if (string.IsNullOrWhiteSpace(managerName)) + { + return ""; + } + + return PEInterface.Managers.FirstOrDefault(manager => + manager.Id.Equals(managerName, StringComparison.OrdinalIgnoreCase) + || manager.Name.Equals(managerName, StringComparison.OrdinalIgnoreCase) + )?.Id + ?? managerName; + } + + internal static IPackageManager? ResolveImportedManager(string? managerName) + { + if (string.IsNullOrWhiteSpace(managerName)) + { + return null; + } + + return PEInterface.Managers.FirstOrDefault(manager => + manager.Id.Equals(managerName, StringComparison.OrdinalIgnoreCase) + || manager.Name.Equals(managerName, StringComparison.OrdinalIgnoreCase) + || manager.DisplayName.Equals(managerName, StringComparison.OrdinalIgnoreCase) + ); + } + + private static IManagerSource ResolveSourceForAdd( + IPackageManager manager, + IpcSourceRequest request + ) + { + if (string.IsNullOrWhiteSpace(request.SourceName)) + { + throw new InvalidOperationException("The source name is required."); + } + + if (!string.IsNullOrWhiteSpace(request.SourceUrl)) + { + return new ManagerSource( + manager, + request.SourceName, + new Uri(request.SourceUrl, UriKind.Absolute) + ); + } + + return manager.Properties.KnownSources.FirstOrDefault(source => + SourceMatches(source, request.SourceName, null) + ) + ?? throw new InvalidOperationException( + $"No known source matching \"{request.SourceName}\" was found for manager \"{manager.Name}\"." + ); + } + + private static IManagerSource ResolveSourceForRemove( + IPackageManager manager, + IpcSourceRequest request + ) + { + if (string.IsNullOrWhiteSpace(request.SourceName)) + { + throw new InvalidOperationException("The source name is required."); + } + + var configuredSource = GetConfiguredSources(manager).FirstOrDefault(source => + SourceMatches(source, request.SourceName, request.SourceUrl) + ); + if (configuredSource is not null) + { + return configuredSource; + } + + if (!string.IsNullOrWhiteSpace(request.SourceUrl)) + { + return new ManagerSource( + manager, + request.SourceName, + new Uri(request.SourceUrl, UriKind.Absolute) + ); + } + + var knownSource = manager.Properties.KnownSources.FirstOrDefault(source => + SourceMatches(source, request.SourceName, null) + ); + if (knownSource is not null) + { + return knownSource; + } + + return new ManagerSource(manager, request.SourceName, new Uri("https://localhost/")); + } + + private static bool SourceMatches( + IManagerSource source, + string sourceName, + string? sourceUrl + ) + { + return source.Name.Equals(sourceName, StringComparison.OrdinalIgnoreCase) + || source.AsString_DisplayName.Equals(sourceName, StringComparison.OrdinalIgnoreCase) + || ( + !string.IsNullOrWhiteSpace(sourceUrl) + && source.Url.ToString().Equals(sourceUrl, StringComparison.OrdinalIgnoreCase) + ); + } + + private static IReadOnlyList GetConfiguredSources(IPackageManager manager) + { + try + { + return manager.SourcesHelper.GetSources(); + } + catch (NotImplementedException) + { + return []; + } + } + + private static bool IsVisibleSetting(Settings.K setting) + { + return setting != Settings.K.Unset && !HiddenSettings.Contains(setting); + } + + private static Settings.K ResolveSettingKey(string settingKey) + { + if (string.IsNullOrWhiteSpace(settingKey)) + { + throw new InvalidOperationException("The setting key is required."); + } + + foreach (var candidate in Enum.GetValues()) + { + if (!IsVisibleSetting(candidate)) + { + continue; + } + + if ( + candidate.ToString().Equals(settingKey, StringComparison.OrdinalIgnoreCase) + || Settings.ResolveKey(candidate).Equals( + settingKey, + StringComparison.OrdinalIgnoreCase + ) + ) + { + return candidate; + } + } + + throw new InvalidOperationException($"No setting matching \"{settingKey}\" was found."); + } + + private static IpcSettingInfo ToSettingInfo(Settings.K setting) + { + string stringValue = Settings.GetValue(setting); + bool isSet = Settings.Get(setting); + + return new IpcSettingInfo + { + Name = setting.ToString(), + Key = Settings.ResolveKey(setting), + IsSet = isSet, + BoolValue = isSet, + StringValue = stringValue, + HasStringValue = !string.IsNullOrWhiteSpace(stringValue), + }; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs new file mode 100644 index 0000000000..782d833843 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs @@ -0,0 +1,504 @@ +using System.Collections.Concurrent; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Interface; + +public sealed class IpcOperationOutputLine +{ + public string Text { get; set; } = ""; + public string Type { get; set; } = ""; +} + +public class IpcOperationInfo +{ + public string Id { get; set; } = ""; + public string Kind { get; set; } = ""; + public string Title { get; set; } = ""; + public string Status { get; set; } = ""; + public bool Started { get; set; } + public string LiveLine { get; set; } = ""; + public string LiveLineType { get; set; } = ""; + public int? QueuePosition { get; set; } + public int OutputLineCount { get; set; } + public bool CanCancel { get; set; } + public bool CanForget { get; set; } + public IReadOnlyList AvailableQueueActions { get; set; } = []; + public IReadOnlyList AvailableRetryModes { get; set; } = []; + public IpcPackageInfo? Package { get; set; } + public string ManagerName { get; set; } = ""; + public string SourceName { get; set; } = ""; +} + +public sealed class IpcOperationDetails : IpcOperationInfo +{ + public IReadOnlyList Output { get; set; } = []; +} + +public sealed class IpcOperationOutputResult +{ + public string OperationId { get; set; } = ""; + public int LineCount { get; set; } + public IReadOnlyList Output { get; set; } = []; +} + +public static class IpcOperationApi +{ + private sealed class TrackedOperation + { + private readonly List _output = []; + private readonly object _syncRoot = new(); + + public AbstractOperation Operation { get; } + public DateTime CreatedAtUtc { get; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; private set; } = DateTime.UtcNow; + public string LiveLine { get; private set; } = CoreTools.Translate("Please wait..."); + public string LiveLineType { get; private set; } = ToLineTypeName( + AbstractOperation.LineType.ProgressIndicator + ); + + public TrackedOperation(AbstractOperation operation) + { + Operation = operation; + + foreach (var (text, type) in operation.GetOutput()) + { + AddLine(text, type); + } + } + + public void AddLine(string text, AbstractOperation.LineType type) + { + lock (_syncRoot) + { + var line = new IpcOperationOutputLine + { + Text = text, + Type = ToLineTypeName(type), + }; + _output.Add(line); + LiveLine = text; + LiveLineType = line.Type; + UpdatedAtUtc = DateTime.UtcNow; + } + } + + public void Touch() + { + lock (_syncRoot) + { + UpdatedAtUtc = DateTime.UtcNow; + } + } + + public int GetOutputCount() + { + lock (_syncRoot) + { + return _output.Count; + } + } + + public IReadOnlyList GetOutputSnapshot(int? tailLines = null) + { + lock (_syncRoot) + { + if (!tailLines.HasValue || tailLines.Value <= 0 || tailLines.Value >= _output.Count) + { + return _output.ToArray(); + } + + return _output.Skip(_output.Count - tailLines.Value).ToArray(); + } + } + } + + private static readonly ConcurrentDictionary Operations = new(); + private const int MaxTrackedOperations = 200; + + public static string Track(AbstractOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + string operationId = operation.Metadata.Identifier; + Operations.GetOrAdd( + operationId, + _ => + { + var tracked = new TrackedOperation(operation); + operation.LogLineAdded += (_, line) => tracked.AddLine(line.Item1, line.Item2); + operation.StatusChanged += (_, _) => tracked.Touch(); + operation.OperationFinished += (_, _) => tracked.Touch(); + return tracked; + } + ); + + PruneCompletedOperations(); + return operationId; + } + + public static IReadOnlyList ListOperations() + { + return Operations + .Values.OrderBy(entry => IsActive(entry.Operation.Status) ? 0 : 1) + .ThenBy(entry => entry.CreatedAtUtc) + .Select(CreateOperationInfo) + .ToArray(); + } + + public static IpcOperationDetails GetOperation(string operationId) + { + var tracked = GetTrackedOperation(operationId); + var info = CreateOperationInfo(tracked); + return new IpcOperationDetails + { + Id = info.Id, + Kind = info.Kind, + Title = info.Title, + Status = info.Status, + Started = info.Started, + LiveLine = info.LiveLine, + LiveLineType = info.LiveLineType, + QueuePosition = info.QueuePosition, + OutputLineCount = info.OutputLineCount, + CanCancel = info.CanCancel, + CanForget = info.CanForget, + AvailableQueueActions = info.AvailableQueueActions, + AvailableRetryModes = info.AvailableRetryModes, + Package = info.Package, + ManagerName = info.ManagerName, + SourceName = info.SourceName, + Output = tracked.GetOutputSnapshot(), + }; + } + + public static IpcOperationOutputResult GetOperationOutput( + string operationId, + int? tailLines = null + ) + { + if (tailLines.HasValue && tailLines.Value < 0) + { + throw new InvalidOperationException("tailLines must be greater than or equal to zero."); + } + + var tracked = GetTrackedOperation(operationId); + return new IpcOperationOutputResult + { + OperationId = tracked.Operation.Metadata.Identifier, + LineCount = tracked.GetOutputCount(), + Output = tracked.GetOutputSnapshot(tailLines), + }; + } + + public static IpcCommandResult CancelOperation(string operationId) + { + var tracked = GetTrackedOperation(operationId); + if (!IsActive(tracked.Operation.Status)) + { + throw new InvalidOperationException( + "Only queued or running operations can be canceled." + ); + } + + tracked.Operation.Cancel(); + return IpcCommandResult.Success("cancel-operation"); + } + + public static IpcCommandResult RetryOperation(string operationId, string? retryMode) + { + var tracked = GetTrackedOperation(operationId); + if (IsActive(tracked.Operation.Status)) + { + throw new InvalidOperationException( + "Running or queued operations cannot be retried." + ); + } + + string normalizedRetryMode = NormalizeRetryMode(retryMode); + var availableRetryModes = GetRetryModes(tracked.Operation); + if (!availableRetryModes.Contains(ToRetryModeName(normalizedRetryMode))) + { + throw new InvalidOperationException( + $"Retry mode \"{retryMode}\" is not supported for operation {operationId}." + ); + } + + tracked.Operation.Retry(normalizedRetryMode); + return IpcCommandResult.Success("retry-operation"); + } + + public static IpcCommandResult ReorderOperation(string operationId, string action) + { + var tracked = GetTrackedOperation(operationId); + if (tracked.Operation.Status != OperationStatus.InQueue) + { + throw new InvalidOperationException( + "Only queued operations can be reordered." + ); + } + + switch (NormalizeQueueAction(action)) + { + case "run-now": + tracked.Operation.SkipQueue(); + break; + case "run-next": + tracked.Operation.RunNext(); + break; + case "run-last": + tracked.Operation.BackOfTheQueue(); + break; + default: + throw new InvalidOperationException($"Unsupported queue action \"{action}\"."); + } + + return IpcCommandResult.Success("reorder-operation"); + } + + public static IpcCommandResult ForgetOperation(string operationId) + { + var tracked = GetTrackedOperation(operationId); + if (IsActive(tracked.Operation.Status)) + { + throw new InvalidOperationException( + "Running or queued operations cannot be forgotten." + ); + } + + ForgetTracking(operationId); + return IpcCommandResult.Success("forget-operation"); + } + + public static void ForgetTracking(string operationId) + { + Operations.TryRemove(operationId, out _); + } + + private static TrackedOperation GetTrackedOperation(string operationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + + return Operations.TryGetValue(operationId.Trim(), out var tracked) + ? tracked + : throw new InvalidOperationException( + $"No tracked operation with id \"{operationId}\" was found." + ); + } + + private static IpcOperationInfo CreateOperationInfo(TrackedOperation tracked) + { + var operation = tracked.Operation; + return new IpcOperationInfo + { + Id = operation.Metadata.Identifier, + Kind = GetOperationKind(operation), + Title = operation.Metadata.Title, + Status = operation.Status.ToString().ToLowerInvariant(), + Started = operation.Started, + LiveLine = tracked.LiveLine, + LiveLineType = tracked.LiveLineType, + QueuePosition = GetQueuePosition(operation), + OutputLineCount = tracked.GetOutputCount(), + CanCancel = IsActive(operation.Status), + CanForget = !IsActive(operation.Status), + AvailableQueueActions = operation.Status == OperationStatus.InQueue + ? ["run-now", "run-next", "run-last"] + : [], + AvailableRetryModes = !IsActive(operation.Status) ? GetRetryModes(operation) : [], + Package = GetOperationPackage(operation), + ManagerName = GetManagerName(operation), + SourceName = GetSourceName(operation), + }; + } + + private static IpcPackageInfo? GetOperationPackage(AbstractOperation operation) + { + return operation switch + { + PackageOperation packageOperation => IpcPackageApi.CreateIpcPackageInfo( + packageOperation.Package + ), + DownloadOperation downloadOperation => IpcPackageApi.CreateIpcPackageInfo( + downloadOperation.Package + ), + _ => null, + }; + } + + private static string GetManagerName(AbstractOperation operation) + { + return operation switch + { + PackageOperation packageOperation => IpcManagerSettingsApi.GetPublicManagerId( + packageOperation.Package.Manager + ), + DownloadOperation downloadOperation => IpcManagerSettingsApi.GetPublicManagerId( + downloadOperation.Package.Manager + ), + SourceOperation sourceOperation => IpcManagerSettingsApi.GetPublicManagerId( + sourceOperation.ManagerSource.Manager + ), + _ => "", + }; + } + + private static string GetSourceName(AbstractOperation operation) + { + return operation is SourceOperation sourceOperation ? sourceOperation.ManagerSource.Name : ""; + } + + private static string GetOperationKind(AbstractOperation operation) + { + return operation switch + { + InstallPackageOperation => "install-package", + UpdatePackageOperation => "update-package", + UninstallPackageOperation => "uninstall-package", + DownloadOperation => "download-package", + AddSourceOperation => "add-source", + RemoveSourceOperation => "remove-source", + _ => operation.GetType().Name, + }; + } + + private static int? GetQueuePosition(AbstractOperation operation) + { + if (operation.Status != OperationStatus.InQueue) + { + return null; + } + + int index = AbstractOperation.OperationQueue.IndexOf(operation); + if (index < 0) + { + return null; + } + + return Math.Max(index - AbstractOperation.MAX_OPERATIONS + 1, 0); + } + + private static IReadOnlyList GetRetryModes(AbstractOperation operation) + { + List retryModes = ["retry"]; + + switch (operation) + { + case PackageOperation packageOperation: + if ( + !packageOperation.Options.RunAsAdministrator + && packageOperation.Package.Manager.Capabilities.CanRunAsAdmin + ) + { + retryModes.Add("retry-as-admin"); + } + + if ( + !packageOperation.Options.InteractiveInstallation + && packageOperation.Package.Manager.Capabilities.CanRunInteractively + ) + { + retryModes.Add("retry-interactive"); + } + + if ( + !packageOperation.Options.SkipHashCheck + && packageOperation.Package.Manager.Capabilities.CanSkipIntegrityChecks + ) + { + retryModes.Add("retry-no-hash-check"); + } + + break; + case SourceOperation sourceOperation when !sourceOperation.ForceAsAdministrator: + retryModes.Add("retry-as-admin"); + break; + } + + return retryModes; + } + + private static string NormalizeRetryMode(string? retryMode) + { + if (string.IsNullOrWhiteSpace(retryMode)) + { + return AbstractOperation.RetryMode.Retry; + } + + return retryMode.Trim().ToLowerInvariant() switch + { + "retry" => AbstractOperation.RetryMode.Retry, + "retry-as-admin" => AbstractOperation.RetryMode.Retry_AsAdmin, + "retry-interactive" => AbstractOperation.RetryMode.Retry_Interactive, + "retry-no-hash-check" => AbstractOperation.RetryMode.Retry_SkipIntegrity, + _ => throw new InvalidOperationException( + $"Unsupported retry mode \"{retryMode}\"." + ), + }; + } + + private static string ToRetryModeName(string retryMode) + { + return retryMode switch + { + var mode when mode == AbstractOperation.RetryMode.Retry => "retry", + var mode when mode == AbstractOperation.RetryMode.Retry_AsAdmin => "retry-as-admin", + var mode when mode == AbstractOperation.RetryMode.Retry_Interactive + => "retry-interactive", + var mode when mode == AbstractOperation.RetryMode.Retry_SkipIntegrity + => "retry-no-hash-check", + _ => retryMode, + }; + } + + private static string NormalizeQueueAction(string action) + { + ArgumentException.ThrowIfNullOrWhiteSpace(action); + + return action.Trim().ToLowerInvariant() switch + { + "run-now" => "run-now", + "run-next" => "run-next", + "run-last" => "run-last", + _ => throw new InvalidOperationException($"Unsupported queue action \"{action}\"."), + }; + } + + private static bool IsActive(OperationStatus status) + { + return status is OperationStatus.InQueue or OperationStatus.Running; + } + + private static string ToLineTypeName(AbstractOperation.LineType type) + { + return type switch + { + AbstractOperation.LineType.VerboseDetails => "verbose", + AbstractOperation.LineType.ProgressIndicator => "progress", + AbstractOperation.LineType.Information => "information", + AbstractOperation.LineType.Error => "error", + _ => type.ToString().ToLowerInvariant(), + }; + } + + private static void PruneCompletedOperations() + { + if (Operations.Count <= MaxTrackedOperations) + { + return; + } + + foreach ( + var completedOperation in Operations + .Values.Where(entry => !IsActive(entry.Operation.Status)) + .OrderBy(entry => entry.UpdatedAtUtc) + .Take(Operations.Count - MaxTrackedOperations) + .ToArray() + ) + { + ForgetTracking(completedOperation.Operation.Metadata.Identifier); + } + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs new file mode 100644 index 0000000000..0ec762f460 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs @@ -0,0 +1,804 @@ +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Classes.Packages.Classes; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.PackageLoader; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Interface; + +public sealed class IpcPackageInfo +{ + public string Name { get; set; } = ""; + public string Id { get; set; } = ""; + public string Version { get; set; } = ""; + public string NewVersion { get; set; } = ""; + public string Source { get; set; } = ""; + public string Manager { get; set; } = ""; + public bool IsUpgradable { get; set; } +} + +public sealed class IpcPackageActionRequest +{ + public string PackageId { get; set; } = ""; + public string? ManagerName { get; set; } + public string? PackageSource { get; set; } + public string? Version { get; set; } + public string? Scope { get; set; } + public bool? PreRelease { get; set; } + public bool? Elevated { get; set; } + public bool? Interactive { get; set; } + public bool? SkipHash { get; set; } + public bool? RemoveData { get; set; } + public bool? WaitForCompletion { get; set; } + public string? Architecture { get; set; } + public string? InstallLocation { get; set; } + public string? OutputPath { get; set; } +} + +public sealed class IpcPackageOperationResult +{ + public string Status { get; set; } = "success"; + public string Command { get; set; } = ""; + public string OperationId { get; set; } = ""; + public string OperationStatus { get; set; } = ""; + public bool Completed { get; set; } = true; + public string? Message { get; set; } + public IpcPackageInfo? Package { get; set; } + public string? OutputPath { get; set; } + public IReadOnlyList Output { get; set; } = []; +} + +public sealed class IpcPackageDependencyInfo +{ + public string Name { get; set; } = ""; + public string Version { get; set; } = ""; + public bool Mandatory { get; set; } +} + +public sealed class IpcPackageDetailsInfo +{ + public string Name { get; set; } = ""; + public string Id { get; set; } = ""; + public string Version { get; set; } = ""; + public string NewVersion { get; set; } = ""; + public string Source { get; set; } = ""; + public string Manager { get; set; } = ""; + public string Description { get; set; } = ""; + public string Publisher { get; set; } = ""; + public string Author { get; set; } = ""; + public string HomepageUrl { get; set; } = ""; + public string License { get; set; } = ""; + public string LicenseUrl { get; set; } = ""; + public string InstallerUrl { get; set; } = ""; + public string InstallerHash { get; set; } = ""; + public string InstallerType { get; set; } = ""; + public long InstallerSize { get; set; } + public string ManifestUrl { get; set; } = ""; + public string UpdateDate { get; set; } = ""; + public string ReleaseNotes { get; set; } = ""; + public string ReleaseNotesUrl { get; set; } = ""; + public string IconUrl { get; set; } = ""; + public string InstallLocation { get; set; } = ""; + public string? IgnoredVersion { get; set; } + public IReadOnlyList Tags { get; set; } = []; + public IReadOnlyList Versions { get; set; } = []; + public IReadOnlyList Screenshots { get; set; } = []; + public IReadOnlyList Dependencies { get; set; } = []; +} + +public sealed class IpcIgnoredUpdateInfo +{ + public string IgnoredId { get; set; } = ""; + public string Manager { get; set; } = ""; + public string PackageId { get; set; } = ""; + public string Version { get; set; } = ""; + public bool IgnoreAllVersions { get; set; } + public bool IsPauseUntilDate { get; set; } + public string PauseUntil { get; set; } = ""; +} + +internal enum IpcPackageLookupMode +{ + Search, + Installed, + Upgradable, + InstalledOrUpgradable, + Any, +} + +public static class IpcPackageApi +{ + public static IReadOnlyList SearchPackages( + string query, + string? managerName = null, + int maxResults = 50 + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(query); + + maxResults = Math.Clamp(maxResults, 1, 500); + + return GetManagers(managerName) + .SelectMany(manager => manager.FindPackages(query)) + .DistinctBy(GetPackageIdentity) + .Select(ToIpcPackageInfo) + .OrderBy(package => package.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .Take(maxResults) + .ToArray(); + } + + public static IReadOnlyList ListInstalledPackages(string? managerName = null) + { + return GetInstalledPackagesSnapshot(managerName) + .DistinctBy(GetPackageIdentity) + .Select(ToIpcPackageInfo) + .OrderBy(package => package.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static IReadOnlyList ListUpgradablePackages(string? managerName = null) + { + return GetUpgradablePackagesSnapshot(managerName) + .DistinctBy(GetPackageIdentity) + .Select(ToIpcPackageInfo) + .OrderBy(package => package.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static Task InstallPackageAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var package = FindSearchResult(request); + return ExecuteOperationAsync( + "install-package", + package, + request, + (pkg, options) => new InstallPackageOperation(pkg, options) + ); + } + + public static Task UpdatePackageAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var package = FindUpgradablePackageOrInstalledPackage(request); + return ExecuteOperationAsync( + "update-package", + package, + request, + (pkg, options) => new UpdatePackageOperation(pkg, options) + ); + } + + public static Task UninstallPackageAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var package = FindInstalledPackage(request); + return ExecuteOperationAsync( + "uninstall-package", + package, + request, + (pkg, options) => new UninstallPackageOperation(pkg, options) + ); + } + + public static async Task DownloadPackageAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.OutputPath)) + { + throw new InvalidOperationException( + "The outputPath parameter is required when downloading a package." + ); + } + + var package = FindAnyPackage(request); + if (!package.Manager.Capabilities.CanDownloadInstaller) + { + throw new InvalidOperationException( + $"The manager \"{IpcManagerSettingsApi.GetPublicManagerId(package.Manager)}\" does not support installer downloads." + ); + } + + var operation = new DownloadOperation(package, request.OutputPath); + string operationId = IpcOperationApi.Track(operation); + + if (request.WaitForCompletion == false) + { + _ = Task.Run(() => RunTrackedOperationAsync(operation)); + return CreateOperationResult( + "download-package", + package, + operation, + operation.DownloadLocation, + completed: false, + operationId: operationId + ); + } + + await RunTrackedOperationAsync(operation); + + return CreateOperationResult( + "download-package", + package, + operation, + operation.DownloadLocation, + operationId: operationId + ); + } + + public static Task ReinstallPackageAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var package = FindInstalledPackage(request); + return ExecuteOperationAsync( + "reinstall-package", + package, + request, + (pkg, options) => new InstallPackageOperation(pkg, options) + ); + } + + public static async Task UninstallThenReinstallPackageAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var package = FindInstalledPackage(request); + var options = await InstallOptionsFactory.LoadApplicableAsync(package); + ApplyRequestOptions(options, request); + + var uninstallOperation = new UninstallPackageOperation(package, options); + var installOperation = new InstallPackageOperation( + package, + options, + req: uninstallOperation + ); + string operationId = IpcOperationApi.Track(installOperation); + + if (request.WaitForCompletion == false) + { + _ = Task.Run(() => RunTrackedOperationAsync(installOperation)); + return CreateOperationResult( + "uninstall-then-reinstall-package", + package, + installOperation, + completed: false, + operationId: operationId + ); + } + + await RunTrackedOperationAsync(installOperation); + + return CreateOperationResult( + "uninstall-then-reinstall-package", + package, + installOperation, + operationId: operationId + ); + } + + public static async Task GetPackageDetailsAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var package = FindAnyPackage(request); + await package.Details.Load(); + + return new IpcPackageDetailsInfo + { + Name = package.Name, + Id = package.Id, + Version = package.VersionString, + NewVersion = package.IsUpgradable ? package.NewVersionString : "", + Source = package.Source.AsString_DisplayName, + Manager = IpcManagerSettingsApi.GetPublicManagerId(package.Manager), + Description = package.Details.Description ?? "", + Publisher = package.Details.Publisher ?? "", + Author = package.Details.Author ?? "", + HomepageUrl = package.Details.HomepageUrl?.ToString() ?? "", + License = package.Details.License ?? "", + LicenseUrl = package.Details.LicenseUrl?.ToString() ?? "", + InstallerUrl = package.Details.InstallerUrl?.ToString() ?? "", + InstallerHash = package.Details.InstallerHash ?? "", + InstallerType = package.Details.InstallerType ?? "", + InstallerSize = package.Details.InstallerSize, + ManifestUrl = package.Details.ManifestUrl?.ToString() ?? "", + UpdateDate = package.Details.UpdateDate ?? "", + ReleaseNotes = package.Details.ReleaseNotes ?? "", + ReleaseNotesUrl = package.Details.ReleaseNotesUrl?.ToString() ?? "", + IconUrl = package.GetIconUrl().ToString(), + InstallLocation = package.Manager.DetailsHelper.GetInstallLocation(package) ?? "", + IgnoredVersion = await package.GetIgnoredUpdatesVersionAsync(), + Tags = package.Details.Tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).ToArray(), + Versions = package.Manager.DetailsHelper.GetVersions(package), + Screenshots = package + .GetScreenshots() + .Select(screenshot => screenshot.ToString()) + .ToArray(), + Dependencies = package.Details.Dependencies + .Select(dependency => new IpcPackageDependencyInfo + { + Name = dependency.Name, + Version = dependency.Version, + Mandatory = dependency.Mandatory, + }) + .ToArray(), + }; + } + + public static IPackage ResolvePackage(IpcPackageActionRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return FindAnyPackage(request); + } + + public static IReadOnlyList GetPackageVersions(IpcPackageActionRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var package = FindAnyPackage(request); + return package.Manager.DetailsHelper.GetVersions(package); + } + + public static IReadOnlyList ListIgnoredUpdates() + { + return IgnoredUpdatesDatabase.GetDatabase() + .Select(entry => + { + string[] parts = entry.Key.Split('\\', 2); + string version = entry.Value; + + return new IpcIgnoredUpdateInfo + { + IgnoredId = entry.Key, + Manager = parts.Length > 0 + ? IpcManagerSettingsApi.GetPublicManagerId(parts[0]) + : "", + PackageId = parts.Length > 1 ? parts[1] : "", + Version = version, + IgnoreAllVersions = version == "*", + IsPauseUntilDate = version.StartsWith("<", StringComparison.Ordinal), + PauseUntil = version.StartsWith("<", StringComparison.Ordinal) + ? version[1..] + : "", + }; + }) + .OrderBy(entry => entry.Manager, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.PackageId, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static async Task IgnorePackageUpdateAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var package = FindPackageForStateMutation(request); + await package.AddToIgnoredUpdatesAsync( + string.IsNullOrWhiteSpace(request.Version) ? "*" : request.Version + ); + await RefreshUpgradablePackagesSnapshotAsync(); + + return new IpcCommandResult + { + Command = "ignore-package", + Message = $"Ignored updates for {package.Id}.", + }; + } + + public static async Task RemoveIgnoredUpdateAsync( + IpcPackageActionRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + + var package = TryFindPackageForStateMutation(request); + if (package is not null) + { + await package.RemoveFromIgnoredUpdatesAsync(); + } + else + { + var manager = GetManagers(request.ManagerName).FirstOrDefault() + ?? throw new InvalidOperationException( + "The manager parameter is required when removing an ignored update for a package that is not currently discoverable." + ); + IgnoredUpdatesDatabase.Remove($"{manager.Properties.Name.ToLowerInvariant()}\\{request.PackageId}"); + } + + await RefreshUpgradablePackagesSnapshotAsync(); + return IpcCommandResult.Success("unignore-package"); + } + + private static async Task ExecuteOperationAsync( + string command, + IPackage package, + IpcPackageActionRequest request, + Func operationFactory + ) + { + var options = await InstallOptionsFactory.LoadApplicableAsync(package); + ApplyRequestOptions(options, request); + + var operation = operationFactory(package, options); + string operationId = IpcOperationApi.Track(operation); + + if (request.WaitForCompletion == false) + { + _ = Task.Run(() => RunTrackedOperationAsync(operation)); + return CreateOperationResult( + command, + package, + operation, + completed: false, + operationId: operationId + ); + } + + await RunTrackedOperationAsync(operation); + + return CreateOperationResult(command, package, operation, operationId: operationId); + } + + private static async Task RunTrackedOperationAsync(AbstractOperation operation) + { + try + { + await operation.MainThread(); + } + finally + { + operation.Dispose(); + } + } + + private static void ApplyRequestOptions( + InstallOptions options, + IpcPackageActionRequest request + ) + { + if (!string.IsNullOrWhiteSpace(request.Version)) + { + options.Version = request.Version; + } + + if (!string.IsNullOrWhiteSpace(request.Scope)) + { + options.InstallationScope = request.Scope; + } + + if (request.PreRelease.HasValue) + { + options.PreRelease = request.PreRelease.Value; + } + + if (request.Elevated.HasValue) + { + options.RunAsAdministrator = request.Elevated.Value; + } + + if (request.Interactive.HasValue) + { + options.InteractiveInstallation = request.Interactive.Value; + } + + if (request.SkipHash.HasValue) + { + options.SkipHashCheck = request.SkipHash.Value; + } + + if (request.RemoveData.HasValue) + { + options.RemoveDataOnUninstall = request.RemoveData.Value; + } + + if (!string.IsNullOrWhiteSpace(request.Architecture)) + { + options.Architecture = request.Architecture; + } + + if (!string.IsNullOrWhiteSpace(request.InstallLocation)) + { + options.CustomInstallLocation = request.InstallLocation; + } + } + + internal static void ApplyRequestedOptions( + InstallOptions options, + IpcPackageActionRequest request + ) + { + ApplyRequestOptions(options, request); + } + + internal static IPackage ResolvePackage( + IpcPackageActionRequest request, + IpcPackageLookupMode lookupMode = IpcPackageLookupMode.Any + ) + { + return lookupMode switch + { + IpcPackageLookupMode.Search => FindSearchResult(request), + IpcPackageLookupMode.Installed => FindInstalledPackage(request), + IpcPackageLookupMode.Upgradable => FindUpgradablePackage(request), + IpcPackageLookupMode.InstalledOrUpgradable => FindUpgradablePackageOrInstalledPackage( + request + ), + _ => FindAnyPackage(request), + }; + } + + internal static IpcPackageInfo CreateIpcPackageInfo(IPackage package) + { + return ToIpcPackageInfo(package); + } + + internal static IpcPackageOperationResult CreateOperationResult( + string command, + IPackage package, + AbstractOperation operation, + string? outputPath = null, + bool completed = true, + string? operationId = null + ) + { + return new IpcPackageOperationResult + { + Status = completed && operation.Status != OperationStatus.Succeeded ? "error" : "success", + Command = command, + OperationId = operationId ?? operation.Metadata.Identifier, + OperationStatus = operation.Status.ToString().ToLowerInvariant(), + Completed = completed, + Message = operation.Status switch + { + _ when !completed => "The operation was queued for background execution.", + OperationStatus.Succeeded => null, + OperationStatus.Canceled => "The operation was canceled.", + _ => operation.GetOutput().LastOrDefault().Item1, + }, + Package = ToIpcPackageInfo(package), + OutputPath = outputPath, + Output = operation.GetOutput().Select(line => line.Item1).ToArray(), + }; + } + + private static IPackage FindSearchResult(IpcPackageActionRequest request) + { + foreach (var manager in GetManagers(request.ManagerName)) + { + var package = manager.FindPackages(request.PackageId).FirstOrDefault(candidate => + MatchesIdentity(candidate, request) + ); + if (package is not null) + { + return package; + } + } + + throw new InvalidOperationException( + $"No package matching id \"{request.PackageId}\" was found." + ); + } + + private static IPackage FindAnyPackage(IpcPackageActionRequest request) + { + return TryFindPackageForStateMutation(request) + ?? FindSearchResult(request); + } + + private static IPackage FindInstalledPackage(IpcPackageActionRequest request) + { + var package = GetInstalledPackagesSnapshot(request.ManagerName).FirstOrDefault(candidate => + MatchesIdentity(candidate, request) + ); + if (package is not null) + { + return package; + } + + throw new InvalidOperationException( + $"No installed package matching id \"{request.PackageId}\" was found." + ); + } + + private static IPackage FindUpgradablePackage(IpcPackageActionRequest request) + { + var package = GetUpgradablePackagesSnapshot(request.ManagerName).FirstOrDefault(candidate => + MatchesIdentity(candidate, request) + ); + if (package is not null) + { + return package; + } + + throw new InvalidOperationException( + $"No upgradable package matching id \"{request.PackageId}\" was found." + ); + } + + private static IPackage FindUpgradablePackageOrInstalledPackage( + IpcPackageActionRequest request + ) + { + try + { + return FindUpgradablePackage(request); + } + catch (InvalidOperationException) + { + return FindInstalledPackage(request); + } + } + + private static IPackage FindPackageForStateMutation(IpcPackageActionRequest request) + { + return TryFindPackageForStateMutation(request) + ?? throw new InvalidOperationException( + $"No package matching id \"{request.PackageId}\" was found." + ); + } + + private static IPackage? TryFindPackageForStateMutation(IpcPackageActionRequest request) + { + try + { + return FindUpgradablePackageOrInstalledPackage(request); + } + catch (InvalidOperationException) + { + return null; + } + } + + private static IReadOnlyList GetInstalledPackagesSnapshot(string? managerName) + { + var loaderPackages = GetLoaderPackages( + InstalledPackagesLoader.Instance, + managerName, + loader => loader.ReloadPackages() + ); + if (loaderPackages.Count > 0) + { + return loaderPackages; + } + + return GetManagers(managerName).SelectMany(manager => manager.GetInstalledPackages()).ToArray(); + } + + private static async Task RefreshUpgradablePackagesSnapshotAsync() + { + if (UpgradablePackagesLoader.Instance is null || UpgradablePackagesLoader.Instance.IsLoading) + { + return; + } + + await UpgradablePackagesLoader.Instance.ReloadPackages(); + } + + private static IReadOnlyList GetUpgradablePackagesSnapshot(string? managerName) + { + var loaderPackages = GetLoaderPackages( + UpgradablePackagesLoader.Instance, + managerName, + loader => loader.ReloadPackages() + ); + if (loaderPackages.Count > 0) + { + return loaderPackages; + } + + return GetManagers(managerName).SelectMany(manager => manager.GetAvailableUpdates()).ToArray(); + } + + private static IReadOnlyList GetLoaderPackages( + AbstractPackageLoader? loader, + string? managerName, + Func reload + ) + { + if (loader is null) + { + return []; + } + + if (loader.Packages.Count > 0) + { + return loader.Packages.Where(package => MatchesManager(package.Manager, managerName)).ToArray(); + } + + if (!loader.IsLoaded && !loader.IsLoading) + { + reload(loader).GetAwaiter().GetResult(); + } + + return loader.Packages.Where(package => MatchesManager(package.Manager, managerName)).ToArray(); + } + + private static IReadOnlyList GetManagers(string? managerName) + { + var managers = PEInterface.Managers + .Where(manager => manager.IsEnabled() && manager.IsReady()) + .Where(manager => MatchesManager(manager, managerName)) + .ToArray(); + + if (managers.Length == 0) + { + if (string.IsNullOrWhiteSpace(managerName)) + { + throw new InvalidOperationException("No ready package managers are available."); + } + + throw new InvalidOperationException( + $"No ready package manager matching \"{managerName}\" is available." + ); + } + + return managers; + } + + private static bool MatchesManager(IPackageManager manager, string? requestedManager) + { + return IpcManagerSettingsApi.MatchesManagerId(manager, requestedManager); + } + + private static bool MatchesIdentity(IPackage package, IpcPackageActionRequest request) + { + if (!package.Id.Equals(request.PackageId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return string.IsNullOrWhiteSpace(request.PackageSource) + || package.Source.Name.Equals(request.PackageSource, StringComparison.OrdinalIgnoreCase) + || package.Source.AsString_DisplayName.Equals( + request.PackageSource, + StringComparison.OrdinalIgnoreCase + ); + } + + private static string GetPackageIdentity(IPackage package) + { + return $"{package.Manager.Id}|{package.Source.Name}|{package.Id}|{package.VersionString}|{package.NewVersionString}"; + } + + private static IpcPackageInfo ToIpcPackageInfo(IPackage package) + { + return new IpcPackageInfo + { + Name = package.Name, + Id = package.Id, + Version = package.VersionString, + NewVersion = package.IsUpgradable ? package.NewVersionString : "", + Source = package.Source.AsString_DisplayName, + Manager = IpcManagerSettingsApi.GetPublicManagerId(package.Manager), + IsUpgradable = package.IsUpgradable, + }; + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcSecureSettingsApi.cs b/src/UniGetUI.Interface.IpcApi/IpcSecureSettingsApi.cs new file mode 100644 index 0000000000..6fe0ff16ab --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcSecureSettingsApi.cs @@ -0,0 +1,105 @@ +using UniGetUI.Core.SettingsEngine.SecureSettings; + +namespace UniGetUI.Interface; + +public sealed class IpcSecureSettingInfo +{ + public string Key { get; set; } = ""; + public string Name { get; set; } = ""; + public string UserName { get; set; } = ""; + public bool IsCurrentUser { get; set; } + public bool Enabled { get; set; } +} + +public sealed class IpcSecureSettingRequest +{ + public string SettingKey { get; set; } = ""; + public string? UserName { get; set; } + public bool Enabled { get; set; } +} + +public static class IpcSecureSettingsApi +{ + public static IReadOnlyList ListSettings(string? userName = null) + { + string resolvedUser = ResolveUserName(userName); + return Enum.GetValues() + .Where(key => key != SecureSettings.K.Unset) + .OrderBy(key => key.ToString(), StringComparer.OrdinalIgnoreCase) + .Select(key => ToSecureSettingInfo(key, resolvedUser)) + .ToArray(); + } + + public static IpcSecureSettingInfo GetSetting(string settingKey, string? userName = null) + { + return ToSecureSettingInfo(ResolveSettingKey(settingKey), ResolveUserName(userName)); + } + + public static async Task SetSettingAsync( + IpcSecureSettingRequest request + ) + { + ArgumentNullException.ThrowIfNull(request); + var key = ResolveSettingKey(request.SettingKey); + string userName = ResolveUserName(request.UserName); + + bool success = userName.Equals(Environment.UserName, StringComparison.OrdinalIgnoreCase) + ? await SecureSettings.TrySet(key, request.Enabled) + : SecureSettings.ApplyForUser(userName, SecureSettings.ResolveKey(key), request.Enabled) == 0; + + if (!success) + { + throw new InvalidOperationException( + $"Could not update secure setting \"{SecureSettings.ResolveKey(key)}\" for user \"{userName}\"." + ); + } + + return ToSecureSettingInfo(key, userName); + } + + private static IpcSecureSettingInfo ToSecureSettingInfo( + SecureSettings.K key, + string userName + ) + { + return new IpcSecureSettingInfo + { + Key = key.ToString(), + Name = SecureSettings.ResolveKey(key), + UserName = userName, + IsCurrentUser = userName.Equals(Environment.UserName, StringComparison.OrdinalIgnoreCase), + Enabled = SecureSettings.GetForUser(userName, key), + }; + } + + private static SecureSettings.K ResolveSettingKey(string settingKey) + { + if (string.IsNullOrWhiteSpace(settingKey)) + { + throw new InvalidOperationException("The secure setting key parameter is required."); + } + + if (Enum.TryParse(settingKey, true, out SecureSettings.K enumKey) && enumKey != SecureSettings.K.Unset) + { + return enumKey; + } + + foreach (var key in Enum.GetValues()) + { + if ( + key != SecureSettings.K.Unset + && SecureSettings.ResolveKey(key).Equals(settingKey, StringComparison.OrdinalIgnoreCase) + ) + { + return key; + } + } + + throw new InvalidOperationException($"No secure setting matching \"{settingKey}\" was found."); + } + + private static string ResolveUserName(string? userName) + { + return string.IsNullOrWhiteSpace(userName) ? Environment.UserName : userName.Trim(); + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcServer.cs b/src/UniGetUI.Interface.IpcApi/IpcServer.cs new file mode 100644 index 0000000000..d8137e625a --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcServer.cs @@ -0,0 +1,2125 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using UniGetUI.Core.Data; +using UniGetUI.Core.IconEngine; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.Interface +{ + internal static class ApiTokenHolder + { + public static string Token = ""; + } + + public class IpcServer + { + public string SessionId { get; } = Guid.NewGuid().ToString("N"); + public string SessionKind { get; init; } = IpcTransportOptions.GuiSessionKind; + public event EventHandler? OnUpgradeAll; + public event EventHandler? OnUpgradeAllForManager; + public Func? AppInfoProvider; + public Func? ShowAppHandler; + public Func? NavigateAppHandler; + public Func? QuitAppHandler; + public Func? ShowPackageHandler; + + private IHost? _host; + private IpcTransportOptions _transportOptions = + IpcTransportOptions.LoadForServer(); + private string? _namedPipePath; + private int _stopRequested; + + public IpcServer() { } + + public static bool AuthenticateToken(string? token) + { + return token == ApiTokenHolder.Token; + } + + public async Task Start() + { + _transportOptions = IpcTransportOptions.LoadForServer(); + _namedPipePath = _transportOptions.NamedPipePath; + PrepareTransportEndpoint(); + ApiTokenHolder.Token = CoreTools.RandomString(64); + Logger.Info("Generated a IPC API auth token for the current session"); + + var builder = Host.CreateDefaultBuilder(); + builder.ConfigureServices(services => services.AddCors()); + builder.ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseKestrel(serverOptions => ConfigureTransport(serverOptions)); +#if !DEBUG + webBuilder.SuppressStatusMessages(true); +#endif + webBuilder.Configure(app => + { + app.UseCors(policy => + policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() + ); + + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet(IpcHttpRoutes.Path("/status"), V3_Status); + endpoints.MapGet(IpcHttpRoutes.Path("/app"), V3_GetAppInfo); + endpoints.MapPost(IpcHttpRoutes.Path("/app/show"), V3_ShowApp); + endpoints.MapPost(IpcHttpRoutes.Path("/app/navigate"), V3_NavigateApp); + endpoints.MapPost(IpcHttpRoutes.Path("/app/quit"), V3_QuitApp); + endpoints.MapGet(IpcHttpRoutes.Path("/operations"), V3_ListOperations); + endpoints.MapGet( + IpcHttpRoutes.Path("/operations/{operationId}"), + V3_GetOperation + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/operations/{operationId}/output"), + V3_GetOperationOutput + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/operations/{operationId}/cancel"), + V3_CancelOperation + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/operations/{operationId}/retry"), + V3_RetryOperation + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/operations/{operationId}/reorder"), + V3_ReorderOperation + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/operations/{operationId}/forget"), + V3_ForgetOperation + ); + endpoints.MapGet(IpcHttpRoutes.Path("/managers"), V3_ListManagers); + endpoints.MapGet( + IpcHttpRoutes.Path("/managers/maintenance"), + V3_GetManagerMaintenance + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/managers/maintenance/reload"), + V3_ReloadManager + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/managers/maintenance/executable/set"), + V3_SetManagerExecutable + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/managers/maintenance/executable/clear"), + V3_ClearManagerExecutable + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/managers/maintenance/action"), + V3_RunManagerAction + ); + endpoints.MapGet(IpcHttpRoutes.Path("/sources"), V3_ListSources); + endpoints.MapPost(IpcHttpRoutes.Path("/sources/add"), V3_AddSource); + endpoints.MapPost(IpcHttpRoutes.Path("/sources/remove"), V3_RemoveSource); + endpoints.MapGet(IpcHttpRoutes.Path("/settings"), V3_ListSettings); + endpoints.MapGet(IpcHttpRoutes.Path("/settings/item"), V3_GetSetting); + endpoints.MapPost(IpcHttpRoutes.Path("/settings/set"), V3_SetSetting); + endpoints.MapPost(IpcHttpRoutes.Path("/settings/clear"), V3_ClearSetting); + endpoints.MapPost(IpcHttpRoutes.Path("/settings/reset"), V3_ResetSettings); + endpoints.MapGet( + IpcHttpRoutes.Path("/secure-settings"), + V3_ListSecureSettings + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/secure-settings/item"), + V3_GetSecureSetting + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/secure-settings/set"), + V3_SetSecureSetting + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/managers/set-enabled"), + V3_SetManagerEnabled + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/managers/set-update-notifications"), + V3_SetManagerUpdateNotifications + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/desktop-shortcuts"), + V3_ListDesktopShortcuts + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/desktop-shortcuts/set"), + V3_SetDesktopShortcut + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/desktop-shortcuts/reset"), + V3_ResetDesktopShortcut + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/desktop-shortcuts/reset-all"), + V3_ResetDesktopShortcuts + ); + endpoints.MapGet(IpcHttpRoutes.Path("/logs/app"), V3_GetAppLog); + endpoints.MapGet( + IpcHttpRoutes.Path("/logs/history"), + V3_GetOperationHistory + ); + endpoints.MapGet(IpcHttpRoutes.Path("/logs/manager"), V3_GetManagerLog); + endpoints.MapGet(IpcHttpRoutes.Path("/backups/status"), V3_GetBackupStatus); + endpoints.MapPost( + IpcHttpRoutes.Path("/backups/local/create"), + V3_CreateLocalBackup + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/backups/github/sign-in/start"), + V3_StartGitHubDeviceFlow + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/backups/github/sign-in/complete"), + V3_CompleteGitHubDeviceFlow + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/backups/github/sign-out"), + V3_SignOutGitHub + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/backups/cloud"), + V3_ListCloudBackups + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/backups/cloud/create"), + V3_CreateCloudBackup + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/backups/cloud/download"), + V3_DownloadCloudBackup + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/backups/cloud/restore"), + V3_RestoreCloudBackup + ); + endpoints.MapGet(IpcHttpRoutes.Path("/bundles"), V3_GetBundle); + endpoints.MapPost(IpcHttpRoutes.Path("/bundles/reset"), V3_ResetBundle); + endpoints.MapPost(IpcHttpRoutes.Path("/bundles/import"), V3_ImportBundle); + endpoints.MapPost(IpcHttpRoutes.Path("/bundles/export"), V3_ExportBundle); + endpoints.MapPost(IpcHttpRoutes.Path("/bundles/add"), V3_AddBundlePackage); + endpoints.MapPost( + IpcHttpRoutes.Path("/bundles/remove"), + V3_RemoveBundlePackage + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/bundles/install"), + V3_InstallBundle + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/packages/search"), + V3_SearchPackages + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/packages/installed"), + V3_ListInstalledPackages + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/packages/updates"), + V3_ListUpgradablePackages + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/packages/details"), + V3_GetPackageDetails + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/packages/versions"), + V3_GetPackageVersions + ); + endpoints.MapGet( + IpcHttpRoutes.Path("/packages/ignored"), + V3_ListIgnoredUpdates + ); + endpoints.MapPost(IpcHttpRoutes.Path("/packages/ignore"), V3_IgnorePackage); + endpoints.MapPost( + IpcHttpRoutes.Path("/packages/unignore"), + V3_UnignorePackage + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/packages/download"), + V3_DownloadPackage + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/packages/install"), + V3_InstallPackage + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/packages/reinstall"), + V3_ReinstallPackage + ); + endpoints.MapPost(IpcHttpRoutes.Path("/packages/update"), V3_UpdatePackage); + endpoints.MapPost( + IpcHttpRoutes.Path("/packages/uninstall"), + V3_UninstallPackage + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/packages/uninstall-then-reinstall"), + V3_UninstallThenReinstallPackage + ); + endpoints.MapPost(IpcHttpRoutes.Path("/packages/show"), V3_ShowPackage); + endpoints.MapPost( + IpcHttpRoutes.Path("/packages/update-all"), + V3_UpdateAllPackages + ); + endpoints.MapPost( + IpcHttpRoutes.Path("/packages/update-manager"), + V3_UpdateAllPackagesForManager + ); + }); + }); + }); + _host = builder.Build(); + try + { + await _host.StartAsync(); + ApplyTransportSecurity(); + _transportOptions.Persist( + SessionId, + ApiTokenHolder.Token, + SessionKind, + Environment.ProcessId + ); + } + catch + { + IpcTransportOptions.DeletePersistedMetadata(SessionId); + CleanupTransportEndpoint(); + _host.Dispose(); + _host = null; + throw; + } + Logger.Info( + _transportOptions.TransportKind == IpcTransportKind.NamedPipe + ? OperatingSystem.IsWindows() + ? $"Api running on named pipe {_transportOptions.NamedPipeName}" + : $"Api running on unix socket {_transportOptions.NamedPipeDisplayName}" + : $"Api running on {_transportOptions.BaseAddressString}" + ); + } + + private void ConfigureTransport(KestrelServerOptions serverOptions) + { + if (_transportOptions.TransportKind == IpcTransportKind.NamedPipe) + { + if (OperatingSystem.IsWindows()) + { + serverOptions.ListenNamedPipe( + _transportOptions.NamedPipeName, + listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http1; + } + ); + } + else + { + serverOptions.ListenUnixSocket( + _namedPipePath + ?? throw new InvalidOperationException( + "The Unix socket path is not available for the current transport." + ), + listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http1; + } + ); + } + } + else + { + serverOptions.ListenLocalhost(_transportOptions.TcpPort); + } + } + + private void PrepareTransportEndpoint() + { + if (_transportOptions.TransportKind != IpcTransportKind.NamedPipe + || OperatingSystem.IsWindows()) + { + return; + } + + if (string.IsNullOrWhiteSpace(_namedPipePath)) + { + throw new InvalidOperationException( + "The Unix socket path is required for the named-pipe transport." + ); + } + + string? directory = Path.GetDirectoryName(_namedPipePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + if (File.Exists(_namedPipePath)) + { + if (HasExplicitUnixSocketPath()) + { + throw new InvalidOperationException( + $"Cannot bind the IPC API Unix socket because the explicit path \"{_namedPipePath}\" already exists." + ); + } + + DeleteUnixSocketFile(_namedPipePath); + } + } + + private void CleanupTransportEndpoint() + { + if (_transportOptions.TransportKind != IpcTransportKind.NamedPipe + || OperatingSystem.IsWindows() + || string.IsNullOrWhiteSpace(_namedPipePath)) + { + return; + } + + DeleteUnixSocketFile(_namedPipePath); + } + + private void ApplyTransportSecurity() + { + if (_transportOptions.TransportKind != IpcTransportKind.NamedPipe + || OperatingSystem.IsWindows() + || string.IsNullOrWhiteSpace(_namedPipePath)) + { + return; + } + + if (!File.Exists(_namedPipePath)) + { + throw new InvalidOperationException( + $"The IPC API Unix socket \"{_namedPipePath}\" was not created." + ); + } + + File.SetUnixFileMode( + _namedPipePath, + IpcTransportOptions.SameUserUnixSocketMode + ); + } + + private bool HasExplicitUnixSocketPath() + { + return _transportOptions.NamedPipeName.StartsWith("/", StringComparison.Ordinal); + } + + private static void DeleteUnixSocketFile(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + private async Task V3_Status(HttpContext context) + { + await context.Response.WriteAsJsonAsync( + new IpcStatus + { + Running = true, + Transport = _transportOptions.TransportKind switch + { + IpcTransportKind.NamedPipe => "named-pipe", + _ => "tcp", + }, + TcpPort = _transportOptions.TcpPort, + NamedPipeName = _transportOptions.NamedPipeName, + NamedPipePath = _transportOptions.NamedPipePath ?? "", + BaseAddress = _transportOptions.BaseAddressString, + Version = CoreData.VersionName, + BuildNumber = CoreData.BuildNumber, + }, + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_ListManagers(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + IpcManagerSettingsApi.ListManagers(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_GetAppInfo(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + AppInfoProvider?.Invoke() + ?? throw new InvalidOperationException( + "The application did not register an app-state provider." + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ShowApp(HttpContext context) + { + await HandleCommandAsync( + context, + () => + ShowAppHandler?.Invoke() + ?? throw new InvalidOperationException( + "The current UniGetUI session cannot show a window." + ) + ); + } + + private async Task V3_NavigateApp(HttpContext context) + { + await HandleCommandAsync( + context, + () => + NavigateAppHandler?.Invoke(BuildAppNavigateRequest(context.Request)) + ?? throw new InvalidOperationException( + "The current UniGetUI session cannot navigate application pages." + ) + ); + } + + private async Task V3_QuitApp(HttpContext context) + { + await HandleCommandAsync( + context, + () => + QuitAppHandler?.Invoke() + ?? throw new InvalidOperationException( + "The current UniGetUI session cannot be shut down through automation." + ) + ); + } + + private async Task V3_ListOperations(HttpContext context) + { + await HandleReadAsync(context, IpcOperationApi.ListOperations); + } + + private async Task V3_GetOperation(HttpContext context) + { + await HandleReadAsync( + context, + () => IpcOperationApi.GetOperation(GetRequiredRouteValue(context, "operationId")) + ); + } + + private async Task V3_GetOperationOutput(HttpContext context) + { + await HandleReadAsync( + context, + () => IpcOperationApi.GetOperationOutput( + GetRequiredRouteValue(context, "operationId"), + int.TryParse(context.Request.Query["tailLines"], out int tailLines) + ? tailLines + : null + ) + ); + } + + private async Task V3_CancelOperation(HttpContext context) + { + await HandleCommandAsync( + context, + () => IpcOperationApi.CancelOperation(GetRequiredRouteValue(context, "operationId")) + ); + } + + private async Task V3_RetryOperation(HttpContext context) + { + await HandleCommandAsync( + context, + () => IpcOperationApi.RetryOperation( + GetRequiredRouteValue(context, "operationId"), + context.Request.Query.TryGetValue("mode", out var mode) ? mode.ToString() : null + ) + ); + } + + private async Task V3_ReorderOperation(HttpContext context) + { + await HandleCommandAsync( + context, + () => IpcOperationApi.ReorderOperation( + GetRequiredRouteValue(context, "operationId"), + GetRequiredQueryValue(context, "action") + ) + ); + } + + private async Task V3_ForgetOperation(HttpContext context) + { + await HandleCommandAsync( + context, + () => IpcOperationApi.ForgetOperation(GetRequiredRouteValue(context, "operationId")) + ); + } + + private async Task V3_ListSources(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcManagerSettingsApi.ListSources(context.Request.Query["manager"]), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_GetManagerMaintenance(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string managerName = context.Request.Query["manager"].ToString(); + if (string.IsNullOrWhiteSpace(managerName)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The manager parameter is required."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcManagerMaintenanceApi.GetMaintenanceInfo(managerName), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ReloadManager(HttpContext context) + { + await HandleManagerMaintenanceActionAsync< + IpcManagerMaintenanceRequest, + IpcManagerMaintenanceActionResult + >(context, IpcManagerMaintenanceApi.ReloadManagerAsync); + } + + private async Task V3_SetManagerExecutable(HttpContext context) + { + await HandleManagerMaintenanceActionAsync< + IpcManagerMaintenanceRequest, + IpcManagerMaintenanceActionResult + >(context, IpcManagerMaintenanceApi.SetExecutablePathAsync); + } + + private async Task V3_ClearManagerExecutable(HttpContext context) + { + await HandleManagerMaintenanceActionAsync< + IpcManagerMaintenanceRequest, + IpcManagerMaintenanceActionResult + >(context, IpcManagerMaintenanceApi.ClearExecutablePathAsync); + } + + private async Task V3_RunManagerAction(HttpContext context) + { + await HandleManagerMaintenanceActionAsync< + IpcManagerMaintenanceRequest, + IpcManagerMaintenanceActionResult + >(context, IpcManagerMaintenanceApi.RunActionAsync); + } + + private async Task V3_AddSource(HttpContext context) + { + await HandleSourceActionAsync(context, IpcManagerSettingsApi.AddSourceAsync); + } + + private async Task V3_RemoveSource(HttpContext context) + { + await HandleSourceActionAsync(context, IpcManagerSettingsApi.RemoveSourceAsync); + } + + private async Task V3_ListSettings(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + IpcManagerSettingsApi.ListSettings(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_GetSetting(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string key = context.Request.Query["key"].ToString(); + if (string.IsNullOrWhiteSpace(key)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The key parameter is required."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcManagerSettingsApi.GetSetting(key), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_SetSetting(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcManagerSettingsApi.SetSetting( + new IpcSettingValueRequest + { + SettingKey = GetRequiredQueryValue(context, "key"), + Enabled = bool.TryParse(context.Request.Query["enabled"], out bool enabled) + ? enabled + : null, + Value = GetOptionalQueryValue(context.Request, "value"), + } + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ClearSetting(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string key = context.Request.Query["key"].ToString(); + if (string.IsNullOrWhiteSpace(key)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The key parameter is required."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcManagerSettingsApi.ClearSetting(key), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ResetSettings(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + IpcManagerSettingsApi.ResetSettingsPreservingSession(); + await context.Response.WriteAsJsonAsync( + IpcCommandResult.Success("reset-settings"), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_ListSecureSettings(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + IpcSecureSettingsApi.ListSettings(context.Request.Query["user"]), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_GetSecureSetting(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcSecureSettingsApi.GetSetting( + GetRequiredQueryValue(context, "key"), + GetOptionalQueryValue(context.Request, "user") + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_SetSecureSetting(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + if (!bool.TryParse(context.Request.Query["enabled"], out bool enabled)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The enabled parameter must be either true or false."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await IpcSecureSettingsApi.SetSettingAsync( + new IpcSecureSettingRequest + { + SettingKey = GetRequiredQueryValue(context, "key"), + UserName = GetOptionalQueryValue(context.Request, "user"), + Enabled = enabled, + } + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_SetManagerEnabled(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + if (!bool.TryParse(context.Request.Query["enabled"], out bool enabled)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The enabled parameter must be either true or false."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await IpcManagerSettingsApi.SetManagerEnabledAsync( + new IpcManagerToggleRequest + { + ManagerName = GetRequiredQueryValue(context, "manager"), + Enabled = enabled, + } + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_SetManagerUpdateNotifications(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + if (!bool.TryParse(context.Request.Query["enabled"], out bool enabled)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The enabled parameter must be either true or false."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcManagerSettingsApi.SetManagerNotifications( + new IpcManagerToggleRequest + { + ManagerName = GetRequiredQueryValue(context, "manager"), + Enabled = enabled, + } + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ListDesktopShortcuts(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + IpcDesktopShortcutsApi.ListShortcuts(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_SetDesktopShortcut(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcDesktopShortcutsApi.SetShortcut( + new IpcDesktopShortcutRequest + { + Path = GetRequiredQueryValue(context, "path"), + Status = GetOptionalQueryValue(context.Request, "status"), + } + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ResetDesktopShortcut(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcDesktopShortcutsApi.ResetShortcut( + new IpcDesktopShortcutRequest + { + Path = GetRequiredQueryValue(context, "path"), + } + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ResetDesktopShortcuts(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + IpcDesktopShortcutsApi.ResetAllShortcuts(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_GetAppLog(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + int level = int.TryParse(context.Request.Query["level"], out int parsedLevel) + ? parsedLevel + : 4; + await context.Response.WriteAsJsonAsync( + IpcLogsApi.ListAppLog(level), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_GetOperationHistory(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + IpcLogsApi.ListOperationHistory(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_GetManagerLog(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcLogsApi.ListManagerLogs( + context.Request.Query["manager"], + bool.TryParse(context.Request.Query["verbose"], out bool verbose) && verbose + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_GetBackupStatus(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + await IpcBackupApi.GetStatusAsync(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_CreateLocalBackup(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await IpcBackupApi.CreateLocalBackupAsync(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_StartGitHubDeviceFlow(HttpContext context) + { + await HandleBackupActionAsync< + IpcGitHubDeviceFlowRequest, + IpcGitHubAuthResult + >(context, IpcBackupApi.StartGitHubDeviceFlowAsync); + } + + private async Task V3_CompleteGitHubDeviceFlow(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await IpcBackupApi.CompleteGitHubDeviceFlowAsync(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_SignOutGitHub(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + await IpcBackupApi.SignOutGitHubAsync(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_ListCloudBackups(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await IpcBackupApi.ListCloudBackupsAsync(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_CreateCloudBackup(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await IpcBackupApi.CreateCloudBackupAsync(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_DownloadCloudBackup(HttpContext context) + { + await HandleBackupActionAsync< + IpcCloudBackupRequest, + IpcCloudBackupContentResult + >(context, IpcBackupApi.DownloadCloudBackupAsync); + } + + private async Task V3_RestoreCloudBackup(HttpContext context) + { + await HandleBackupActionAsync< + IpcCloudBackupRequest, + IpcCloudBackupRestoreResult + >(context, IpcBackupApi.RestoreCloudBackupAsync); + } + + private async Task V3_GetBundle(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await IpcBundleApi.GetCurrentBundleAsync(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ResetBundle(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcBundleApi.ResetBundle(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ImportBundle(HttpContext context) + { + await HandleBundleActionAsync( + context, + IpcBundleApi.ImportBundleAsync + ); + } + + private async Task V3_ExportBundle(HttpContext context) + { + await HandleBundleActionAsync( + context, + IpcBundleApi.ExportBundleAsync + ); + } + + private async Task V3_AddBundlePackage(HttpContext context) + { + await HandleBundleActionAsync< + IpcBundlePackageRequest, + IpcBundlePackageOperationResult + >(context, IpcBundleApi.AddPackageAsync); + } + + private async Task V3_RemoveBundlePackage(HttpContext context) + { + await HandleBundleActionAsync< + IpcBundlePackageRequest, + IpcBundlePackageOperationResult + >(context, IpcBundleApi.RemovePackageAsync); + } + + private async Task V3_InstallBundle(HttpContext context) + { + await HandleBundleActionAsync< + IpcBundleInstallRequest, + IpcBundleInstallResult + >(context, IpcBundleApi.InstallBundleAsync); + } + + private async Task V3_SearchPackages(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string query = context.Request.Query["query"].ToString(); + if (string.IsNullOrWhiteSpace(query)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The query parameter is required."); + return; + } + + string? manager = context.Request.Query["manager"]; + int maxResults = 50; + if ( + int.TryParse(context.Request.Query["maxResults"], out int parsedMaxResults) + && parsedMaxResults > 0 + ) + { + maxResults = parsedMaxResults; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcPackageApi.SearchPackages(query, manager, maxResults), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ListInstalledPackages(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcPackageApi.ListInstalledPackages(context.Request.Query["manager"]), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ListUpgradablePackages(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcPackageApi.ListUpgradablePackages(context.Request.Query["manager"]), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_GetPackageDetails(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string packageId = context.Request.Query["packageId"].ToString(); + if (string.IsNullOrWhiteSpace(packageId)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The packageId parameter is required."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await IpcPackageApi.GetPackageDetailsAsync( + BuildPackageActionRequest(context.Request) + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_GetPackageVersions(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string packageId = context.Request.Query["packageId"].ToString(); + if (string.IsNullOrWhiteSpace(packageId)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The packageId parameter is required."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + IpcPackageApi.GetPackageVersions(BuildPackageActionRequest(context.Request)), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_ListIgnoredUpdates(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + await context.Response.WriteAsJsonAsync( + IpcPackageApi.ListIgnoredUpdates(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + + private async Task V3_IgnorePackage(HttpContext context) + { + await HandleCommandActionAsync(context, IpcPackageApi.IgnorePackageUpdateAsync); + } + + private async Task V3_UnignorePackage(HttpContext context) + { + await HandleCommandActionAsync(context, IpcPackageApi.RemoveIgnoredUpdateAsync); + } + + private async Task V3_InstallPackage(HttpContext context) + { + await HandlePackageActionAsync( + context, + IpcPackageApi.InstallPackageAsync + ); + } + + private async Task V3_DownloadPackage(HttpContext context) + { + await HandlePackageActionAsync( + context, + IpcPackageApi.DownloadPackageAsync + ); + } + + private async Task V3_ReinstallPackage(HttpContext context) + { + await HandlePackageActionAsync( + context, + IpcPackageApi.ReinstallPackageAsync + ); + } + + private async Task V3_UpdatePackage(HttpContext context) + { + await HandlePackageActionAsync( + context, + IpcPackageApi.UpdatePackageAsync + ); + } + + private async Task V3_UninstallPackage(HttpContext context) + { + await HandlePackageActionAsync( + context, + IpcPackageApi.UninstallPackageAsync + ); + } + + private async Task V3_UninstallThenReinstallPackage(HttpContext context) + { + await HandlePackageActionAsync( + context, + IpcPackageApi.UninstallThenReinstallPackageAsync + ); + } + + private async Task V3_ShowPackage(HttpContext context) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string packageId = context.Request.Query["packageId"].ToString(); + if (string.IsNullOrWhiteSpace(packageId)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The packageId parameter is required."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + ShowPackageHandler?.Invoke(BuildPackageActionRequest(context.Request)) + ?? throw new InvalidOperationException( + "The current UniGetUI session cannot open package details." + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private async Task V3_UpdateAllPackages(HttpContext context) + { + await HandleCommandAsync( + context, + () => + { + if (OnUpgradeAll is null) + { + throw new InvalidOperationException( + "The current UniGetUI session cannot update all packages." + ); + } + + OnUpgradeAll.Invoke(null, EventArgs.Empty); + return IpcCommandResult.Success("update-all"); + } + ); + } + + private async Task V3_UpdateAllPackagesForManager(HttpContext context) + { + await HandleCommandAsync( + context, + () => + { + if (OnUpgradeAllForManager is null) + { + throw new InvalidOperationException( + "The current UniGetUI session cannot update manager packages." + ); + } + + string managerName = GetRequiredQueryValue(context, "manager"); + OnUpgradeAllForManager.Invoke(null, managerName); + return IpcCommandResult.Success("update-manager"); + } + ); + } + + private static async Task HandlePackageActionAsync( + HttpContext context, + Func> action + ) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string packageId = context.Request.Query["packageId"].ToString(); + if (string.IsNullOrWhiteSpace(packageId)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The packageId parameter is required."); + return; + } + + try + { + var request = BuildPackageActionRequest(context.Request); + + await context.Response.WriteAsJsonAsync( + await action(request), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private static async Task HandleReadAsync(HttpContext context, Func action) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + action(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private static async Task HandleCommandAsync( + HttpContext context, + Func action + ) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + action(), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private static async Task HandleCommandActionAsync( + HttpContext context, + Func> action + ) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + string packageId = context.Request.Query["packageId"].ToString(); + if (string.IsNullOrWhiteSpace(packageId)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("The packageId parameter is required."); + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await action(BuildPackageActionRequest(context.Request)), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private static async Task HandleSourceActionAsync( + HttpContext context, + Func> action + ) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await action( + new IpcSourceRequest + { + ManagerName = GetRequiredQueryValue(context, "manager"), + SourceName = GetRequiredQueryValue(context, "name"), + SourceUrl = GetOptionalQueryValue(context.Request, "url"), + } + ), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private static async Task HandleBundleActionAsync( + HttpContext context, + Func> action + ) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await action(await ReadJsonBodyAsync(context)), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private static async Task HandleBackupActionAsync( + HttpContext context, + Func> action + ) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await action(await ReadJsonBodyAsync(context)), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private static async Task HandleManagerMaintenanceActionAsync( + HttpContext context, + Func> action + ) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + await context.Response.WriteAsJsonAsync( + await action(await ReadJsonBodyAsync(context)), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } + } + + private static async Task ReadJsonBodyAsync(HttpContext context) + { + var request = await context.Request.ReadFromJsonAsync( + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + return request + ?? throw new InvalidOperationException("The request body is required."); + } + + private static IpcPackageActionRequest BuildPackageActionRequest(HttpRequest request) + { + return new IpcPackageActionRequest + { + PackageId = GetRequiredQueryValue(request, "packageId"), + ManagerName = GetOptionalQueryValue(request, "manager"), + PackageSource = GetOptionalQueryValue(request, "packageSource"), + Version = GetOptionalQueryValue(request, "version"), + Scope = GetOptionalQueryValue(request, "scope"), + PreRelease = bool.TryParse(request.Query["preRelease"], out bool preRelease) + ? preRelease + : null, + Elevated = bool.TryParse(request.Query["elevated"], out bool elevated) + ? elevated + : null, + Interactive = bool.TryParse(request.Query["interactive"], out bool interactive) + ? interactive + : null, + SkipHash = bool.TryParse(request.Query["skipHash"], out bool skipHash) + ? skipHash + : null, + RemoveData = bool.TryParse(request.Query["removeData"], out bool removeData) + ? removeData + : null, + WaitForCompletion = bool.TryParse(request.Query["wait"], out bool waitForCompletion) + ? waitForCompletion + : null, + Architecture = GetOptionalQueryValue(request, "architecture"), + InstallLocation = GetOptionalQueryValue(request, "location"), + OutputPath = GetOptionalQueryValue(request, "outputPath"), + }; + } + + private static IpcAppNavigateRequest BuildAppNavigateRequest(HttpRequest request) + { + return new IpcAppNavigateRequest + { + Page = GetRequiredQueryValue(request, "page"), + ManagerName = GetOptionalQueryValue(request, "manager"), + HelpAttachment = GetOptionalQueryValue(request, "helpAttachment"), + }; + } + + private static string GetRequiredRouteValue(HttpContext context, string key) + { + return context.Request.RouteValues.TryGetValue(key, out object? value) + && value is string stringValue + && !string.IsNullOrWhiteSpace(stringValue) + ? stringValue + : throw new InvalidOperationException($"The route value \"{key}\" is required."); + } + + private static string GetRequiredQueryValue(HttpContext context, string key) + { + string? value = context.Request.Query[key].ToString(); + return !string.IsNullOrWhiteSpace(value) + ? value + : throw new InvalidOperationException($"The query value \"{key}\" is required."); + } + + private static string GetRequiredQueryValue(HttpRequest request, string key) + { + string? value = request.Query[key].ToString(); + return !string.IsNullOrWhiteSpace(value) + ? value + : throw new InvalidOperationException($"The query value \"{key}\" is required."); + } + + private static string? GetOptionalQueryValue(HttpRequest request, string key) + { + if (!request.Query.TryGetValue(key, out var value)) + { + return null; + } + + string? stringValue = value.ToString(); + return string.IsNullOrWhiteSpace(stringValue) ? null : stringValue; + } + + public async Task Stop() + { + if (Interlocked.Exchange(ref _stopRequested, 1) == 1) + { + return; + } + + try + { + if (_host is not null) + { + await _host.StopAsync().ConfigureAwait(false); + _host.Dispose(); + _host = null; + } + + Logger.Info("Api was shut down"); + } + catch (Exception ex) + { + Logger.Error(ex); + } + finally + { + CleanupTransportEndpoint(); + IpcTransportOptions.DeletePersistedMetadata(SessionId); + } + } + } +} diff --git a/src/UniGetUI.Interface.IpcApi/IpcTransport.cs b/src/UniGetUI.Interface.IpcApi/IpcTransport.cs new file mode 100644 index 0000000000..d15870a121 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/IpcTransport.cs @@ -0,0 +1,423 @@ +using System.Text.Json; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; + +namespace UniGetUI.Interface; + +public enum IpcTransportKind +{ + Tcp, + NamedPipe, +} + +public sealed record IpcTransportOptions( + IpcTransportKind TransportKind, + int TcpPort, + string NamedPipeName +) +{ + public const string GuiSessionKind = "gui"; + public const string HeadlessSessionKind = "headless"; + public const int DefaultTcpPort = 7058; + public const string DefaultNamedPipeName = "UniGetUI.IPC"; + public const string DefaultUnixSocketDirectory = "/tmp"; + internal const int MaxUnixSocketPathLength = 104; + internal const UnixFileMode SameUserUnixSocketMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite; + + public const string TransportArgument = "--ipc-api-transport"; + public const string TcpPortArgument = "--ipc-api-port"; + public const string NamedPipeArgument = "--ipc-api-pipe-name"; + public const string CliTransportArgument = "--transport"; + public const string CliTcpPortArgument = "--tcp-port"; + public const string CliNamedPipeArgument = "--pipe-name"; + + public const string TransportEnvironmentVariable = "UNIGETUI_IPC_API_TRANSPORT"; + public const string TcpPortEnvironmentVariable = "UNIGETUI_IPC_API_PORT"; + public const string NamedPipeEnvironmentVariable = "UNIGETUI_IPC_API_PIPE_NAME"; + + private const string EndpointMetadataDirectoryName = "IpcApiEndpoints"; + + public Uri BaseAddress => + TransportKind == IpcTransportKind.NamedPipe + ? new Uri("http://localhost/") + : new Uri($"http://localhost:{TcpPort}/"); + + public string BaseAddressString => BaseAddress.ToString().TrimEnd('/'); + public string? NamedPipePath => + TransportKind == IpcTransportKind.NamedPipe && !OperatingSystem.IsWindows() + ? ResolveUnixSocketPath(NamedPipeName) + : null; + public string NamedPipeDisplayName => + TransportKind != IpcTransportKind.NamedPipe + ? BaseAddressString + : OperatingSystem.IsWindows() + ? NamedPipeName + : NamedPipePath ?? NamedPipeName; + + public static IpcTransportOptions Default { get; } = new( + IpcTransportKind.NamedPipe, + DefaultTcpPort, + DefaultNamedPipeName + ); + + public static string EndpointMetadataDirectoryPath => + Path.Join(CoreData.UniGetUIUserConfigurationDirectory, EndpointMetadataDirectoryName); + + public static IpcTransportOptions LoadForServer(IReadOnlyList? args = null) + { + args ??= Environment.GetCommandLineArgs(); + return Parse( + args, + includeCliAliases: false, + fallback: Default + ); + } + + public static IpcTransportOptions LoadForClient(IReadOnlyList? args = null) + { + args ??= Environment.GetCommandLineArgs(); + + if (HasExplicitClientOverride(args)) + { + return Parse( + args, + includeCliAliases: true, + fallback: TryLoadPersisted()?.ToTransportOptions() ?? Default + ); + } + + return TryLoadPersisted()?.ToTransportOptions() ?? Default; + } + + public void Persist(string sessionId, string token, string sessionKind, int processId) + { + Directory.CreateDirectory(EndpointMetadataDirectoryPath); + + var metadata = new IpcEndpointRegistration + { + SessionId = sessionId, + SessionKind = sessionKind, + Token = token, + ProcessId = processId, + PersistedAtUtc = DateTimeOffset.UtcNow, + Transport = TransportKind, + TcpPort = TcpPort, + NamedPipeName = NamedPipeName, + }; + + File.WriteAllText( + GetEndpointMetadataPath(sessionId), + JsonSerializer.Serialize( + metadata, + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ) + ); + } + + public static void DeletePersistedMetadata(string? sessionId = null) + { + try + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + if (Directory.Exists(EndpointMetadataDirectoryPath)) + { + Directory.Delete(EndpointMetadataDirectoryPath, recursive: true); + } + return; + } + + string metadataPath = GetEndpointMetadataPath(sessionId); + if (File.Exists(metadataPath)) + { + File.Delete(metadataPath); + } + } + catch (Exception ex) + { + Logger.Warn("Could not delete IPC API endpoint metadata"); + Logger.Warn(ex); + } + } + + internal static IReadOnlyList LoadPersistedRegistrations() + { + List registrations = []; + + try + { + if (Directory.Exists(EndpointMetadataDirectoryPath)) + { + foreach (string file in Directory.GetFiles(EndpointMetadataDirectoryPath, "*.json")) + { + var registration = JsonSerializer.Deserialize( + File.ReadAllText(file), + new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + } + ); + + if (registration is not null) + { + registrations.Add(registration); + } + } + } + } + catch (Exception ex) + { + Logger.Warn("Could not load persisted IPC API endpoint metadata"); + Logger.Warn(ex); + } + + return registrations; + } + + internal static IReadOnlyList OrderRegistrationsForCliSelection( + IReadOnlyList registrations + ) + { + return registrations + .OrderByDescending(registration => registration.SessionKind == HeadlessSessionKind) + .ThenByDescending(registration => registration.PersistedAtUtc) + .ThenBy(registration => registration.SessionId, StringComparer.Ordinal) + .ToArray(); + } + + internal static IpcEndpointRegistration? FindRegistration( + IpcTransportOptions options + ) + { + return LoadPersistedRegistrations() + .Where(registration => registration.Matches(options)) + .OrderByDescending(registration => registration.PersistedAtUtc) + .FirstOrDefault(); + } + + private static IpcEndpointRegistration? TryLoadPersisted() + { + return OrderRegistrationsForCliSelection(LoadPersistedRegistrations()).FirstOrDefault(); + } + + internal static bool HasExplicitClientOverride(IReadOnlyList args) + { + return args.Contains(CliTransportArgument) + || args.Contains(CliTcpPortArgument) + || args.Contains(CliNamedPipeArgument) + || args.Contains(TransportArgument) + || args.Contains(TcpPortArgument) + || args.Contains(NamedPipeArgument) + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(TransportEnvironmentVariable)) + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(TcpPortEnvironmentVariable)) + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(NamedPipeEnvironmentVariable)); + } + + private static string GetEndpointMetadataPath(string sessionId) + { + string safeSessionId = string.Concat( + sessionId.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_') + ); + if (string.IsNullOrWhiteSpace(safeSessionId)) + { + safeSessionId = "session"; + } + + return Path.Join(EndpointMetadataDirectoryPath, safeSessionId + ".json"); + } + + private static IpcTransportOptions Parse( + IReadOnlyList args, + bool includeCliAliases, + IpcTransportOptions fallback + ) + { + string? transportValue = GetArgumentValue( + args, + includeCliAliases + ? [CliTransportArgument, TransportArgument] + : [TransportArgument] + ); + transportValue ??= Environment.GetEnvironmentVariable(TransportEnvironmentVariable); + + string? portValue = GetArgumentValue( + args, + includeCliAliases + ? [CliTcpPortArgument, TcpPortArgument] + : [TcpPortArgument] + ); + portValue ??= Environment.GetEnvironmentVariable(TcpPortEnvironmentVariable); + + string? pipeValue = GetArgumentValue( + args, + includeCliAliases + ? [CliNamedPipeArgument, NamedPipeArgument] + : [NamedPipeArgument] + ); + pipeValue ??= Environment.GetEnvironmentVariable(NamedPipeEnvironmentVariable); + + var transport = ParseTransport(transportValue, fallback.TransportKind); + int tcpPort = ParseTcpPort(portValue, fallback.TcpPort); + string pipeName = ParseNamedPipeName(pipeValue, fallback.NamedPipeName); + + return new IpcTransportOptions(transport, tcpPort, pipeName); + } + + private static IpcTransportKind ParseTransport( + string? value, + IpcTransportKind fallback + ) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + return value.Trim().ToLowerInvariant() switch + { + "tcp" => IpcTransportKind.Tcp, + "named-pipe" or "namedpipe" or "pipe" => IpcTransportKind.NamedPipe, + _ => + LogInvalidTransport(value, fallback), + }; + } + + private static IpcTransportKind LogInvalidTransport( + string value, + IpcTransportKind fallback + ) + { + Logger.Warn( + $"Invalid IPC API transport \"{value}\". Falling back to {fallback}." + ); + return fallback; + } + + private static int ParseTcpPort(string? value, int fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + if (int.TryParse(value, out int port) && port is > 0 and <= 65535) + { + return port; + } + + Logger.Warn($"Invalid IPC API TCP port \"{value}\". Falling back to {fallback}."); + return fallback; + } + + private static string ParseNamedPipeName(string? value, string fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + string pipeName = value.Trim(); + if (pipeName.Length == 0) + { + Logger.Warn( + $"Invalid IPC API named pipe name \"{value}\". Falling back to {fallback}." + ); + return fallback; + } + + if (OperatingSystem.IsWindows() && Path.IsPathRooted(pipeName)) + { + Logger.Warn( + $"Absolute IPC API named pipe paths are not supported on Windows. Falling back to {fallback}." + ); + return fallback; + } + + if (!OperatingSystem.IsWindows()) + { + string resolvedPath = ResolveUnixSocketPath(pipeName); + if (resolvedPath.Length > MaxUnixSocketPathLength) + { + Logger.Warn( + $"IPC API Unix socket path \"{resolvedPath}\" exceeds the supported length limit. Falling back to {fallback}." + ); + return fallback; + } + } + + return pipeName; + } + + internal static string ResolveUnixSocketPath(string pipeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pipeName); + + string trimmed = pipeName.Trim(); + if (trimmed.StartsWith("/", StringComparison.Ordinal)) + { + return trimmed; + } + + return $"{DefaultUnixSocketDirectory.TrimEnd('/')}/{trimmed.TrimStart('/')}"; + } + + private static string? GetArgumentValue( + IReadOnlyList args, + IReadOnlyList argumentNames + ) + { + for (int i = 0; i < args.Count; i++) + { + if (!argumentNames.Contains(args[i]) || i + 1 >= args.Count) + { + continue; + } + + return args[i + 1].Trim('"').Trim('\''); + } + + return null; + } +} + +public sealed class IpcStatus +{ + public bool Running { get; set; } + public string Transport { get; set; } = "tcp"; + public int TcpPort { get; set; } + public string NamedPipeName { get; set; } = IpcTransportOptions.DefaultNamedPipeName; + public string NamedPipePath { get; set; } = ""; + public string BaseAddress { get; set; } = "http://localhost:7058"; + public string Version { get; set; } = CoreData.VersionName; + public int BuildNumber { get; set; } = CoreData.BuildNumber; +} + +internal sealed class IpcEndpointRegistration +{ + public string SessionId { get; set; } = ""; + public string SessionKind { get; set; } = IpcTransportOptions.GuiSessionKind; + public string Token { get; set; } = ""; + public int ProcessId { get; set; } + public DateTimeOffset PersistedAtUtc { get; set; } + public IpcTransportKind Transport { get; set; } = IpcTransportKind.Tcp; + public int TcpPort { get; set; } = IpcTransportOptions.DefaultTcpPort; + public string NamedPipeName { get; set; } = IpcTransportOptions.DefaultNamedPipeName; + + public IpcTransportOptions ToTransportOptions() + { + return new IpcTransportOptions(Transport, TcpPort, NamedPipeName); + } + + public bool Matches(IpcTransportOptions options) + { + return Transport == options.TransportKind + && TcpPort == options.TcpPort + && string.Equals(NamedPipeName, options.NamedPipeName, StringComparison.Ordinal); + } +} diff --git a/src/UniGetUI.Interface.IpcApi/Secrets.cs b/src/UniGetUI.Interface.IpcApi/Secrets.cs new file mode 100644 index 0000000000..61c93242f7 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/Secrets.cs @@ -0,0 +1,12 @@ +namespace UniGetUI.Interface; + +internal static partial class Secrets +{ + /* ---------------------------------------------------------------- + * W A R N I N G !!! + * + * Seeing errors? Build the project (maybe twice) + */ + public static partial string GetGitHubClientId(); + /* ------------------------------------------------------------------------ */ +} diff --git a/src/UniGetUI.Interface.BackgroundApi/UniGetUI.Interface.BackgroundApi.csproj b/src/UniGetUI.Interface.IpcApi/UniGetUI.Interface.IpcApi.csproj similarity index 66% rename from src/UniGetUI.Interface.BackgroundApi/UniGetUI.Interface.BackgroundApi.csproj rename to src/UniGetUI.Interface.IpcApi/UniGetUI.Interface.IpcApi.csproj index a2690ce025..88db876348 100644 --- a/src/UniGetUI.Interface.BackgroundApi/UniGetUI.Interface.BackgroundApi.csproj +++ b/src/UniGetUI.Interface.IpcApi/UniGetUI.Interface.IpcApi.csproj @@ -1,10 +1,19 @@ + + + + + + + $(SharedTargetFrameworks) + + @@ -14,11 +23,14 @@ + + + diff --git a/src/UniGetUI.Interface.IpcApi/WindowsConsoleHost.cs b/src/UniGetUI.Interface.IpcApi/WindowsConsoleHost.cs new file mode 100644 index 0000000000..d0a3b4d0ba --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/WindowsConsoleHost.cs @@ -0,0 +1,94 @@ +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace UniGetUI.Interface; + +public static class WindowsConsoleHost +{ + private const uint AttachParentProcess = 0xFFFFFFFF; + private const int StdInputHandle = -10; + private const int StdOutputHandle = -11; + private const int StdErrorHandle = -12; + private const uint FileTypeDisk = 0x0001; + private const uint FileTypePipe = 0x0003; + private static readonly IntPtr InvalidHandleValue = new(-1); + + public static bool PrepareCliIO(bool allowAllocateIfNoParent = false) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + if (HasConsoleWindow() || HasRedirectedStandardHandles()) + { + RebindStandardStreams(); + return true; + } + + if (AttachConsole(AttachParentProcess) || (allowAllocateIfNoParent && AllocConsole())) + { + RebindStandardStreams(); + return true; + } + + return false; + } + + private static bool HasConsoleWindow() + { + return GetConsoleWindow() != IntPtr.Zero; + } + + private static bool HasRedirectedStandardHandles() + { + return HasRedirectedHandle(StdInputHandle) + || HasRedirectedHandle(StdOutputHandle) + || HasRedirectedHandle(StdErrorHandle); + } + + private static bool HasRedirectedHandle(int standardHandle) + { + IntPtr handle = GetStdHandle(standardHandle); + if (handle == IntPtr.Zero || handle == InvalidHandleValue) + { + return false; + } + + uint fileType = GetFileType(handle); + return fileType is FileTypeDisk or FileTypePipe; + } + + private static void RebindStandardStreams() + { + Encoding utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = utf8; + Console.OutputEncoding = utf8; + + Console.SetIn( + new StreamReader( + Console.OpenStandardInput(), + utf8, + detectEncodingFromByteOrderMarks: false + ) + ); + Console.SetOut(new StreamWriter(Console.OpenStandardOutput(), utf8) { AutoFlush = true }); + Console.SetError(new StreamWriter(Console.OpenStandardError(), utf8) { AutoFlush = true }); + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(uint dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AllocConsole(); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetConsoleWindow(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetFileType(IntPtr hFile); +} diff --git a/src/UniGetUI.Interface.IpcApi/generate-secrets.ps1 b/src/UniGetUI.Interface.IpcApi/generate-secrets.ps1 new file mode 100644 index 0000000000..13edc811a0 --- /dev/null +++ b/src/UniGetUI.Interface.IpcApi/generate-secrets.ps1 @@ -0,0 +1,22 @@ +param ( + [string]$OutputPath = "obj" +) + +$generatedDir = [System.IO.Path]::Combine($OutputPath, "Generated Files") +if (-not (Test-Path -Path $generatedDir)) { + New-Item -ItemType Directory -Path $generatedDir -Force | Out-Null +} + +$clientId = $env:UNIGETUI_GITHUB_CLIENT_ID +if (-not $clientId) { $clientId = "CLIENT_ID_UNSET" } + +@" +// Auto-generated file - do not modify +namespace UniGetUI.Interface +{ + internal static partial class Secrets + { + public static partial string GetGitHubClientId() => `"$clientId`"; + } +} +"@ | Set-Content -Encoding UTF8 ([System.IO.Path]::Combine($generatedDir, "Secrets.Generated.cs")) diff --git a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs index 39b5df9148..8d85cbf806 100644 --- a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs +++ b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs @@ -73,19 +73,7 @@ private static bool CredentialsConfigured() #endif private static readonly HttpClient _httpClient = CreateHttpClient(); - private static readonly ConcurrentQueue _pendingPackageEvents = new(); - - private static HttpClient CreateHttpClient() - { - var httpClient = new HttpClient(CoreTools.GenericHttpClientParameters) - { - Timeout = TimeSpan.FromSeconds(30), - }; - httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); - return httpClient; - } - private static readonly Settings.K[] SettingsToSend = [ Settings.K.DisableAutoUpdateWingetUI, @@ -102,6 +90,16 @@ private static HttpClient CreateHttpClient() // ------------------------------------------------------------------------- + private static HttpClient CreateHttpClient() + { + var httpClient = new HttpClient(CoreTools.GenericHttpClientParameters) + { + Timeout = TimeSpan.FromSeconds(30), + }; + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); + return httpClient; + } + public static async Task InitializeAsync() { try diff --git a/src/UniGetUI.PackageEngine.Interfaces/IPackageManager.cs b/src/UniGetUI.PackageEngine.Interfaces/IPackageManager.cs index b7d16a8337..cfea3855b7 100644 --- a/src/UniGetUI.PackageEngine.Interfaces/IPackageManager.cs +++ b/src/UniGetUI.PackageEngine.Interfaces/IPackageManager.cs @@ -11,6 +11,7 @@ public interface IPackageManager public ManagerProperties Properties { get; } public ManagerCapabilities Capabilities { get; } public ManagerStatus Status { get; } + public string Id { get; } public string Name { get; } public string DisplayName { get; } public IManagerSource DefaultSource { get; } diff --git a/src/UniGetUI.PackageEngine.Interfaces/ManagerProperties.cs b/src/UniGetUI.PackageEngine.Interfaces/ManagerProperties.cs index 079af4e68e..188330c55d 100644 --- a/src/UniGetUI.PackageEngine.Interfaces/ManagerProperties.cs +++ b/src/UniGetUI.PackageEngine.Interfaces/ManagerProperties.cs @@ -8,6 +8,7 @@ public struct ManagerProperties private const IconType DefaultIconId = (IconType)'\uE916'; public bool IsDummy = false; + public string Id { get; set; } = ""; public string Name { get; set; } = "Unset"; public string? DisplayName { get; set; } public string Description { get; set; } = "Unset"; diff --git a/src/UniGetUI.PackageEngine.Managers.Apt/Apt.cs b/src/UniGetUI.PackageEngine.Managers.Apt/Apt.cs index a4ddf11c9e..1a7e2f87ea 100644 --- a/src/UniGetUI.PackageEngine.Managers.Apt/Apt.cs +++ b/src/UniGetUI.PackageEngine.Managers.Apt/Apt.cs @@ -30,6 +30,7 @@ public Apt() Properties = new ManagerProperties { + Id = "apt", Name = "Apt", Description = CoreTools.Translate( "The default package manager for Debian/Ubuntu-based Linux distributions.
Contains: Debian/Ubuntu packages" diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs index 20807eb9ec..71d1e7b26b 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs @@ -73,6 +73,7 @@ public Cargo() Properties = new ManagerProperties { + Id = "cargo", Name = "Cargo", Description = CoreTools.Translate( "The Rust package manager.
Contains: Rust libraries and programs written in Rust" diff --git a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs index 44c02007f2..3ac963c1b4 100644 --- a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs +++ b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs @@ -97,6 +97,7 @@ public Chocolatey() Properties = new ManagerProperties { + Id = "chocolatey", Name = "Chocolatey", Description = CoreTools.Translate( "The classical package manager for windows. You'll find everything there.
Contains: General Software" diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs b/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs index f54325fbd9..0cca78226a 100644 --- a/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs @@ -33,6 +33,7 @@ public Dnf() Properties = new ManagerProperties { + Id = "dnf", Name = "Dnf", Description = CoreTools.Translate( "The default package manager for RHEL/Fedora-based Linux distributions.
Contains: RPM packages" diff --git a/src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs b/src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs index f8ad3a5c4a..b3a307e194 100644 --- a/src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs +++ b/src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs @@ -47,6 +47,7 @@ public DotNet() Properties = new ManagerProperties { + Id = "dotnet-tool", Name = ".NET Tool", Description = CoreTools.Translate( "A repository full of tools and executables designed with Microsoft's .NET ecosystem in mind.
Contains: .NET related tools and scripts" diff --git a/src/UniGetUI.PackageEngine.Managers.Dotnet/Helpers/DotNetPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Dotnet/Helpers/DotNetPkgOperationHelper.cs index 9a77610214..0c9e7c8971 100644 --- a/src/UniGetUI.PackageEngine.Managers.Dotnet/Helpers/DotNetPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Dotnet/Helpers/DotNetPkgOperationHelper.cs @@ -55,7 +55,7 @@ package.OverridenOptions.Scope is null ); } - if (operation is OperationType.Install) + if (operation is OperationType.Install or OperationType.Update) { if (options.Version != "") { diff --git a/src/UniGetUI.PackageEngine.Managers.Flatpak/Flatpak.cs b/src/UniGetUI.PackageEngine.Managers.Flatpak/Flatpak.cs index 39c4fdea8e..16b794373c 100644 --- a/src/UniGetUI.PackageEngine.Managers.Flatpak/Flatpak.cs +++ b/src/UniGetUI.PackageEngine.Managers.Flatpak/Flatpak.cs @@ -39,6 +39,7 @@ public Flatpak() Properties = new ManagerProperties { + Id = "flatpak", Name = "Flatpak", Description = CoreTools.Translate( "The universal Linux package manager for desktop applications.
Contains: Flatpak applications from configured remotes" diff --git a/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs b/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs index e500766314..d762679800 100644 --- a/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs +++ b/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs @@ -57,6 +57,7 @@ public Homebrew() Properties = new ManagerProperties { + Id = "homebrew", Name = "Homebrew", Description = CoreTools.Translate( "The Missing Package Manager for macOS (or Linux).
Contains: Formulae, Casks" diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs index 6532237b35..d7e6b43db8 100644 --- a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs +++ b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs @@ -33,6 +33,7 @@ public Npm() Properties = new ManagerProperties { + Id = "npm", Name = "Npm", Description = CoreTools.Translate( "Node JS's package manager. Full of libraries and other utilities that orbit the javascript world
Contains: Node javascript libraries and other related utilities" diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs b/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs index 862166710d..27401a9a1b 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs @@ -29,6 +29,7 @@ public Pacman() Properties = new ManagerProperties { + Id = "pacman", Name = "Pacman", Description = CoreTools.Translate( "The default package manager for Arch Linux and its derivatives.
Contains: Arch Linux packages" diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs b/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs index a0cb271eda..680a9191d4 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs @@ -67,6 +67,7 @@ public Pip() Properties = new ManagerProperties { + Id = "pip", Name = "Pip", Description = CoreTools.Translate( "Python's library manager. Full of python libraries and other python-related utilities
Contains: Python libraries and related utilities" diff --git a/src/UniGetUI.PackageEngine.Managers.PowerShell/PowerShell.cs b/src/UniGetUI.PackageEngine.Managers.PowerShell/PowerShell.cs index e2b4d30ce2..31fc95f9b9 100644 --- a/src/UniGetUI.PackageEngine.Managers.PowerShell/PowerShell.cs +++ b/src/UniGetUI.PackageEngine.Managers.PowerShell/PowerShell.cs @@ -40,6 +40,7 @@ public PowerShell() Properties = new ManagerProperties { + Id = "winps", Name = "PowerShell", DisplayName = "PowerShell 5.x", Description = CoreTools.Translate( diff --git a/src/UniGetUI.PackageEngine.Managers.PowerShell7/PowerShell7.cs b/src/UniGetUI.PackageEngine.Managers.PowerShell7/PowerShell7.cs index fbd91163f0..b24a792244 100644 --- a/src/UniGetUI.PackageEngine.Managers.PowerShell7/PowerShell7.cs +++ b/src/UniGetUI.PackageEngine.Managers.PowerShell7/PowerShell7.cs @@ -41,6 +41,7 @@ public PowerShell7() Properties = new ManagerProperties { + Id = "pwsh", Name = "PowerShell7", DisplayName = "PowerShell 7.x", Description = CoreTools.Translate( diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs index df2bd2e591..0b29ab9cd8 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs @@ -84,6 +84,7 @@ public Scoop() Properties = new ManagerProperties { + Id = "scoop", Name = "Scoop", Description = CoreTools.Translate( "Great repository of unknown but useful utilities and other interesting packages.
Contains: Utilities, Command-line programs, General Software (extras bucket required)" diff --git a/src/UniGetUI.PackageEngine.Managers.Snap/Snap.cs b/src/UniGetUI.PackageEngine.Managers.Snap/Snap.cs index 46f1e154b1..4ec1baad68 100644 --- a/src/UniGetUI.PackageEngine.Managers.Snap/Snap.cs +++ b/src/UniGetUI.PackageEngine.Managers.Snap/Snap.cs @@ -38,6 +38,7 @@ public Snap() Properties = new ManagerProperties { + Id = "snap", Name = "Snap", Description = CoreTools.Translate( "The universal Linux package manager by Canonical.
Contains: Snap packages from the Snapcraft store" diff --git a/src/UniGetUI.PackageEngine.Managers.Vcpkg/Vcpkg.cs b/src/UniGetUI.PackageEngine.Managers.Vcpkg/Vcpkg.cs index 14f20a9aa5..245bffd433 100644 --- a/src/UniGetUI.PackageEngine.Managers.Vcpkg/Vcpkg.cs +++ b/src/UniGetUI.PackageEngine.Managers.Vcpkg/Vcpkg.cs @@ -69,6 +69,7 @@ public Vcpkg() Properties = new ManagerProperties { + Id = "vcpkg", Name = "vcpkg", Description = CoreTools.Translate( "A popular C/C++ library manager. Full of C/C++ libraries and other C/C++-related utilities
Contains: C/C++ libraries and related utilities" diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs index e2782bc20b..ab77f9e8a7 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs @@ -93,6 +93,7 @@ public WinGet() Properties = new ManagerProperties { + Id = "winget", Name = "Winget", DisplayName = "WinGet", Description = CoreTools.Translate( diff --git a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs index 5b34cb6988..e139f391c4 100644 --- a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs +++ b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs @@ -8,6 +8,7 @@ namespace UniGetUI.PackageEngine.Operations; public class DownloadOperation : AbstractOperation { private readonly IPackage _package; + public IPackage Package => _package; private string downloadLocation; public string DownloadLocation { diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index cb5aa15dda..56741694d2 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -167,6 +167,69 @@ private static bool IsWinGetManager(IPackageManager manager) #endif } + protected async Task ResolveInstalledPackageSnapshotAsync( + string fallbackVersion, + bool preferFallbackVersionWhenMissing = false + ) + { + try + { + var installedMatches = await Task.Run(() => + Package + .Manager.GetInstalledPackages() + .Where(candidate => candidate.IsEquivalentTo(Package)) + .ToArray() + ); + + if (installedMatches.Length > 0) + { + if (!string.IsNullOrWhiteSpace(fallbackVersion)) + { + var exactMatch = installedMatches.FirstOrDefault(candidate => + candidate.VersionString.Equals( + fallbackVersion, + StringComparison.OrdinalIgnoreCase + ) + ); + if (exactMatch is not null) + { + return exactMatch; + } + + if (preferFallbackVersionWhenMissing) + { + return CreateSyntheticInstalledPackage(fallbackVersion); + } + } + + return installedMatches + .OrderByDescending(candidate => candidate.NormalizedVersion) + .First(); + } + } + catch (Exception ex) + { + Logger.Warn( + $"Could not resolve the installed snapshot for package {Package.Id}; falling back to synthetic state" + ); + Logger.Warn(ex); + } + + return CreateSyntheticInstalledPackage(fallbackVersion); + } + + private IPackage CreateSyntheticInstalledPackage(string version) + { + return new Package( + Package.Name, + Package.Id, + version, + Package.Source, + Package.Manager, + Package.OverridenOptions + ); + } + public override Task GetOperationIcon() { return TaskRecycler.RunOrAttachAsync(Package.GetIconUrl); @@ -263,15 +326,12 @@ protected override Task HandleFailure() protected override async Task HandleSuccess() { Package.SetTag(PackageTag.AlreadyInstalled); - var copy = new Package( - Package.Name, - Package.Id, - Package.VersionString, - Package.Source, - Package.Manager, - Package.OverridenOptions + bool explicitVersionRequested = !string.IsNullOrWhiteSpace(Options.Version); + var installedPackage = await ResolveInstalledPackageSnapshotAsync( + explicitVersionRequested ? Options.Version : Package.VersionString, + preferFallbackVersionWhenMissing: explicitVersionRequested ); - await InstalledPackagesLoader.Instance.AddForeign(copy); + await InstalledPackagesLoader.Instance.AddForeign(installedPackage); if (Settings.Get(Settings.K.AskToDeleteNewDesktopShortcuts)) { @@ -342,6 +402,18 @@ protected override async Task HandleSuccess() p.SetTag(PackageTag.Default); UpgradablePackagesLoader.Instance.Remove(Package); + InstalledPackagesLoader.Instance.Remove(Package); + + bool explicitVersionRequested = !string.IsNullOrWhiteSpace(Options.Version); + var installedPackage = await ResolveInstalledPackageSnapshotAsync( + explicitVersionRequested + ? Options.Version + : string.IsNullOrWhiteSpace(Package.NewVersionString) + ? Package.VersionString + : Package.NewVersionString, + preferFallbackVersionWhenMissing: explicitVersionRequested + ); + await InstalledPackagesLoader.Instance.AddForeign(installedPackage); if (Settings.Get(Settings.K.AskToDeleteNewDesktopShortcuts)) { diff --git a/src/UniGetUI.PackageEngine.Operations/SourceOperations.cs b/src/UniGetUI.PackageEngine.Operations/SourceOperations.cs index d70600fbfb..9be166adc0 100644 --- a/src/UniGetUI.PackageEngine.Operations/SourceOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/SourceOperations.cs @@ -12,6 +12,7 @@ public abstract class SourceOperation : AbstractProcessOperation protected abstract void Initialize(); protected IManagerSource Source; + public IManagerSource ManagerSource => Source; public bool ForceAsAdministrator { get; private set; } public SourceOperation(IManagerSource source, IReadOnlyList? preOps) diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/NullPackageManager.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/NullPackageManager.cs index bbe18012a8..1d6999022c 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/NullPackageManager.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/NullPackageManager.cs @@ -19,6 +19,10 @@ public class NullPackageManager : IPackageManager public ManagerProperties Properties { get; } public ManagerCapabilities Capabilities { get; } public ManagerStatus Status { get; } + public string Id + { + get => string.IsNullOrWhiteSpace(Properties.Id) ? Properties.Name : Properties.Id; + } public string Name { get => Properties.Name; @@ -51,6 +55,7 @@ public NullPackageManager() Properties = new ManagerProperties { IsDummy = true, + Id = "unknown", Name = CoreTools.Translate("Unknown"), Description = "Unset", IconId = IconType.Help, diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs index 0461a0a688..40b861ef77 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs @@ -20,6 +20,10 @@ public abstract class PackageManager : IPackageManager public ManagerProperties Properties { get; set; } = new(IsDummy: true); public ManagerCapabilities Capabilities { get; set; } = new(IsDummy: true); public ManagerStatus Status { get; set; } = new() { Found = false }; + public string Id + { + get => string.IsNullOrWhiteSpace(Properties.Id) ? Name : Properties.Id; + } public string Name { get => Properties.Name; diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/ImportedPackage.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/ImportedPackage.cs index 14b87461f8..4c92f3a3cb 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/ImportedPackage.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/ImportedPackage.cs @@ -61,7 +61,7 @@ public override Task AsSerializableAsync() Name = Name, Version = _version, Source = Source.Name, - ManagerName = Manager.Name, + ManagerName = Manager.Id, InstallationOptions = installation_options.Copy(), Updates = updates_options.Copy(), } diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Package.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Package.cs index 215e78a56b..b3dffe9362 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Package.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Package.cs @@ -348,7 +348,7 @@ public virtual async Task AsSerializableAsync() Name = Name, Version = VersionString, Source = Source.Name, - ManagerName = Manager.Name, + ManagerName = Manager.Id, InstallationOptions = await InstallOptionsFactory.LoadForPackageAsync(this), Updates = new SerializableUpdatesOptions { diff --git a/src/UniGetUI.PackageEngine.Tests/DesktopShortcutsDatabaseTests.cs b/src/UniGetUI.PackageEngine.Tests/DesktopShortcutsDatabaseTests.cs new file mode 100644 index 0000000000..c668d8df52 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/DesktopShortcutsDatabaseTests.cs @@ -0,0 +1,69 @@ +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Classes.Packages.Classes; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class DesktopShortcutsDatabaseTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), + nameof(DesktopShortcutsDatabaseTests), + Guid.NewGuid().ToString("N") + ); + + public DesktopShortcutsDatabaseTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + DesktopShortcutsDatabase.ResetDatabase(); + DesktopShortcutsDatabase.GetUnknownShortcuts().Clear(); + } + + public void Dispose() + { + DesktopShortcutsDatabase.ResetDatabase(); + DesktopShortcutsDatabase.GetUnknownShortcuts().Clear(); + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void AddStatusRoundTripAndResetDatabaseWorkForTrackedShortcuts() + { + string shortcutPath = Path.Combine(_testRoot, "SyntheticShortcut.lnk"); + + DesktopShortcutsDatabase.AddToDatabase(shortcutPath, DesktopShortcutsDatabase.Status.Maintain); + Assert.Equal(DesktopShortcutsDatabase.Status.Maintain, DesktopShortcutsDatabase.GetStatus(shortcutPath)); + Assert.Contains(shortcutPath, DesktopShortcutsDatabase.GetAllShortcuts()); + + DesktopShortcutsDatabase.AddToDatabase(shortcutPath, DesktopShortcutsDatabase.Status.Delete); + Assert.Equal(DesktopShortcutsDatabase.Status.Delete, DesktopShortcutsDatabase.GetStatus(shortcutPath)); + + DesktopShortcutsDatabase.AddToDatabase(shortcutPath, DesktopShortcutsDatabase.Status.Unknown); + Assert.Equal(DesktopShortcutsDatabase.Status.Unknown, DesktopShortcutsDatabase.GetStatus(shortcutPath)); + Assert.DoesNotContain(shortcutPath, DesktopShortcutsDatabase.GetDatabase().Keys); + + DesktopShortcutsDatabase.AddToDatabase(shortcutPath, DesktopShortcutsDatabase.Status.Delete); + DesktopShortcutsDatabase.ResetDatabase(); + Assert.Equal(DesktopShortcutsDatabase.Status.Unknown, DesktopShortcutsDatabase.GetStatus(shortcutPath)); + } + + [Fact] + public void DeleteFromDiskRemovesExistingShortcutFile() + { + string shortcutPath = Path.Combine(_testRoot, "DeleteMe.lnk"); + File.WriteAllText(shortcutPath, "synthetic shortcut"); + + bool deleted = DesktopShortcutsDatabase.DeleteFromDisk(shortcutPath); + + Assert.True(deleted); + Assert.False(File.Exists(shortcutPath)); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs index a2ca9ec9c1..477e54ce8b 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs @@ -5,10 +5,12 @@ using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.PackageClasses; using UniGetUI.PackageEngine.PackageLoader; using UniGetUI.PackageEngine.Serializable; using UniGetUI.PackageEngine.Structs; using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; using UniGetUI.PackageOperations; namespace UniGetUI.PackageEngine.Tests; @@ -216,6 +218,120 @@ public async Task InstallOperationSuccessfulRunSetsPackageTagAndAddsInstalledCop Assert.NotNull(InstalledPackagesLoader.Instance.GetEquivalentPackage(package)); } + [Fact] + public async Task InstallOperationSuccessfulRunPrefersAuthoritativeInstalledVersion() + { + TestPackageManager? manager = null; + Package? installedPackage = null; + manager = new PackageManagerBuilder() + .WithInstalledPackages(_ => [Assert.IsType(installedPackage)]) + .Build(); + var searchResult = new PackageBuilder() + .WithManager(manager) + .WithId("dotnetsay") + .WithVersion("3.0.3") + .Build(); + installedPackage = new PackageBuilder() + .WithManager(manager) + .WithId("dotnetsay") + .WithVersion("2.1.4") + .Build(); + InitializeLoaders(); + using var operation = new SimulatedInstallPackageOperation( + searchResult, + new InstallOptions { Version = "2.1.4" }, + OperationVeredict.Success + ); + + await operation.MainThread(); + await WaitForAsync(() => + InstalledPackagesLoader.Instance.GetEquivalentPackages(searchResult) + .Any(package => package.VersionString == "2.1.4") + ); + + Assert.DoesNotContain( + InstalledPackagesLoader.Instance.GetEquivalentPackages(searchResult), + package => package.VersionString == "3.0.3" + ); + } + + [Fact] + public async Task UpdateOperationSuccessfulRunPrefersAuthoritativeInstalledVersion() + { + TestPackageManager? manager = null; + Package? installedPackage = null; + manager = new PackageManagerBuilder() + .WithInstalledPackages(_ => [Assert.IsType(installedPackage)]) + .Build(); + var upgradablePackage = new PackageBuilder() + .WithManager(manager) + .WithId("dotnetsay") + .WithVersion("2.1.4") + .WithNewVersion("3.0.0") + .Build(); + installedPackage = new PackageBuilder() + .WithManager(manager) + .WithId("dotnetsay") + .WithVersion("3.0.3") + .Build(); + InitializeLoaders(); + await InstalledPackagesLoader.Instance.AddForeign(upgradablePackage); + using var operation = new SimulatedUpdatePackageOperation( + upgradablePackage, + new InstallOptions(), + OperationVeredict.Success + ); + + await operation.MainThread(); + await WaitForAsync(() => + InstalledPackagesLoader.Instance.GetEquivalentPackages(upgradablePackage) + .Any(package => package.VersionString == "3.0.3") + ); + + Assert.DoesNotContain( + InstalledPackagesLoader.Instance.GetEquivalentPackages(upgradablePackage), + package => package.VersionString == "3.0.0" + ); + } + + [Fact] + public async Task UpdateOperationSuccessfulRunPrefersRequestedVersionWhenSnapshotLags() + { + TestPackageManager? manager = null; + Package? installedPackage = null; + manager = new PackageManagerBuilder() + .WithInstalledPackages(_ => [Assert.IsType(installedPackage)]) + .Build(); + var installedBeforeUpdate = new PackageBuilder() + .WithManager(manager) + .WithId("dotnetsay") + .WithVersion("2.1.4") + .Build(); + installedPackage = new PackageBuilder() + .WithManager(manager) + .WithId("dotnetsay") + .WithVersion("2.1.4") + .Build(); + InitializeLoaders(); + await InstalledPackagesLoader.Instance.AddForeign(installedBeforeUpdate); + using var operation = new SimulatedUpdatePackageOperation( + installedBeforeUpdate, + new InstallOptions { Version = "3.0.3" }, + OperationVeredict.Success + ); + + await operation.MainThread(); + await WaitForAsync(() => + InstalledPackagesLoader.Instance.GetEquivalentPackages(installedBeforeUpdate) + .Any(package => package.VersionString == "3.0.3") + ); + + Assert.DoesNotContain( + InstalledPackagesLoader.Instance.GetEquivalentPackages(installedBeforeUpdate), + package => package.VersionString == "2.1.4" + ); + } + private static IReadOnlyList GetInnerOperations( AbstractOperation operation, string fieldName @@ -327,6 +443,26 @@ protected override Task PerformOperation() } } + private sealed class SimulatedUpdatePackageOperation : UpdatePackageOperation + { + private readonly OperationVeredict _veredict; + + public SimulatedUpdatePackageOperation( + IPackage package, + InstallOptions options, + OperationVeredict veredict + ) + : base(package, options) + { + _veredict = veredict; + } + + protected override Task PerformOperation() + { + return Task.FromResult(_veredict); + } + } + private sealed class StubOperation : AbstractOperation { public StubOperation() diff --git a/src/UniGetUI.Tests/IpcCliSyntaxTests.cs b/src/UniGetUI.Tests/IpcCliSyntaxTests.cs new file mode 100644 index 0000000000..8128122d0c --- /dev/null +++ b/src/UniGetUI.Tests/IpcCliSyntaxTests.cs @@ -0,0 +1,172 @@ +using UniGetUI.Interface; + +namespace UniGetUI.Tests; + +public sealed class IpcCliSyntaxTests +{ + private static string GetCommand(IpcCliParseResult result) + { + return Assert.IsType(result.Command); + } + + private static string[] GetEffectiveArgs(IpcCliParseResult result) + { + return Assert.IsType(result.EffectiveArgs); + } + + [Fact] + public void ParseMapsTopLevelStatusCommand() + { + IpcCliParseResult result = IpcCliSyntax.Parse(["status"]); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("status", GetCommand(result)); + Assert.Equal([], GetEffectiveArgs(result)); + } + + [Fact] + public void ParsePreservesLeadingTransportOverrides() + { + IpcCliParseResult result = IpcCliSyntax.Parse( + ["--transport", "named-pipe", "--pipe-name", "probe-1", "status"] + ); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("status", GetCommand(result)); + Assert.Equal(["--transport", "named-pipe", "--pipe-name", "probe-1"], GetEffectiveArgs(result)); + } + + [Fact] + public void ParseMapsOperationIdAlias() + { + IpcCliParseResult result = IpcCliSyntax.Parse( + ["operation", "wait", "--id", "op-123", "--timeout", "30"] + ); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("wait-operation", GetCommand(result)); + Assert.Equal(["--operation-id", "op-123", "--timeout", "30"], GetEffectiveArgs(result)); + } + + [Fact] + public void ParseMapsPackageAliases() + { + IpcCliParseResult result = IpcCliSyntax.Parse( + ["package", "details", "--manager", "dotnet-tool", "--id", "dotnetsay", "--source", "nuget.org"] + ); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("package-details", GetCommand(result)); + Assert.Equal( + ["--manager", "dotnet-tool", "--package-id", "dotnetsay", "--package-source", "nuget.org"], + GetEffectiveArgs(result) + ); + } + + [Fact] + public void ParseMapsNestedBackupCommands() + { + IpcCliParseResult result = IpcCliSyntax.Parse( + ["backup", "github", "login", "start", "--launch-browser"] + ); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("start-github-sign-in", GetCommand(result)); + Assert.Equal(["--launch-browser"], GetEffectiveArgs(result)); + } + + [Fact] + public void ParseMapsManagerNotificationSubcommands() + { + IpcCliParseResult result = IpcCliSyntax.Parse( + ["manager", "notifications", "disable", "--manager", "dotnet-tool"] + ); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("set-manager-update-notifications", GetCommand(result)); + Assert.Equal(["--enabled", "false", "--manager", "dotnet-tool"], GetEffectiveArgs(result)); + } + + [Fact] + public void ParseMapsSecureSettingsDomain() + { + IpcCliParseResult result = IpcCliSyntax.Parse( + ["settings", "secure", "list", "--user", "alice"] + ); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("list-secure-settings", GetCommand(result)); + Assert.Equal(["--user", "alice"], GetEffectiveArgs(result)); + } + + [Fact] + public void ParseMapsSourceDocumentationAliases() + { + IpcCliParseResult result = IpcCliSyntax.Parse( + ["source", "add", "--manager", "dotnet-tool", "--source-name", "nuget.org", "--source-url", "https://api.nuget.org/v3/index.json"] + ); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("add-source", GetCommand(result)); + Assert.Equal( + ["--manager", "dotnet-tool", "--name", "nuget.org", "--url", "https://api.nuget.org/v3/index.json"], + GetEffectiveArgs(result) + ); + } + + [Fact] + public void ParseTreatsHelpAsCliHelp() + { + IpcCliParseResult result = IpcCliSyntax.Parse(["help"]); + + Assert.Equal(IpcCliParseStatus.Help, result.Status); + } + + [Fact] + public void HasVerbCommandReturnsTrueForVerbInvocation() + { + Assert.True(IpcCliSyntax.HasVerbCommand(["package", "search", "--manager", "npm"])); + } + + [Fact] + public void HasVerbCommandReturnsTrueForHelpVerb() + { + Assert.True(IpcCliSyntax.HasVerbCommand(["help"])); + } + + [Fact] + public void HasVerbCommandReturnsTrueAfterLeadingTransportOverride() + { + Assert.True(IpcCliSyntax.HasVerbCommand(["--transport", "named-pipe", "status"])); + } + + [Fact] + public void HasVerbCommandReturnsFalseForStartupParameter() + { + Assert.False(IpcCliSyntax.HasVerbCommand(["--daemon"])); + } + + [Fact] + public void HasVerbCommandReturnsFalseForGlobalHelpFlag() + { + Assert.False(IpcCliSyntax.HasVerbCommand(["--help"])); + } + + [Fact] + public void HasVerbCommandReturnsFalseForShortHelpFlag() + { + Assert.False(IpcCliSyntax.HasVerbCommand(["-h"])); + } + + [Fact] + public void HasVerbCommandReturnsFalseForHeadlessStartup() + { + Assert.False(IpcCliSyntax.HasVerbCommand(["--headless"])); + } + + [Fact] + public void HasVerbCommandReturnsFalseForUnknownBareToken() + { + Assert.False(IpcCliSyntax.HasVerbCommand(["foo"])); + } +} diff --git a/src/UniGetUI.Tests/IpcTransportTests.cs b/src/UniGetUI.Tests/IpcTransportTests.cs new file mode 100644 index 0000000000..de1f0c0018 --- /dev/null +++ b/src/UniGetUI.Tests/IpcTransportTests.cs @@ -0,0 +1,186 @@ +using UniGetUI.Core.Data; +using UniGetUI.Interface; + +namespace UniGetUI.Tests; + +public sealed class IpcTransportTests : IDisposable +{ + private readonly string _dataDirectory = Path.Join( + Path.GetTempPath(), + "UniGetUI.Tests", + Guid.NewGuid().ToString("N") + ); + + public IpcTransportTests() + { + CoreData.TEST_DataDirectoryOverride = _dataDirectory; + Directory.CreateDirectory(_dataDirectory); + } + + [Fact] + public void DefaultTransportUsesNamedPipeOnAllPlatforms() + { + Assert.Equal(IpcTransportKind.NamedPipe, IpcTransportOptions.Default.TransportKind); + Assert.Equal(IpcTransportOptions.DefaultTcpPort, IpcTransportOptions.Default.TcpPort); + Assert.Equal( + IpcTransportOptions.DefaultNamedPipeName, + IpcTransportOptions.Default.NamedPipeName + ); + } + + [Fact] + public void LoadForServerParsesNamedPipeOverrides() + { + var options = IpcTransportOptions.LoadForServer( + [ + "UniGetUI.exe", + IpcTransportOptions.TransportArgument, + "named-pipe", + IpcTransportOptions.NamedPipeArgument, + "Contoso.Pipe", + IpcTransportOptions.TcpPortArgument, + "7258", + ] + ); + + Assert.Equal(IpcTransportKind.NamedPipe, options.TransportKind); + Assert.Equal("Contoso.Pipe", options.NamedPipeName); + Assert.Equal(7258, options.TcpPort); + } + + [Fact] + public void ResolveUnixSocketPathUsesTmpForRelativePipeNames() + { + string socketPath = IpcTransportOptions.ResolveUnixSocketPath( + IpcTransportOptions.DefaultNamedPipeName + ); + + Assert.Equal("/tmp/UniGetUI.IPC", socketPath); + } + + [Fact] + public void ResolveUnixSocketPathPreservesAbsolutePaths() + { + const string socketPath = "/tmp/custom-unigetui.sock"; + + Assert.Equal(socketPath, IpcTransportOptions.ResolveUnixSocketPath(socketPath)); + } + + [Fact] + public void SameUserUnixSocketModeUsesOwnerOnlyPermissions() + { + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite, + IpcTransportOptions.SameUserUnixSocketMode + ); + } + + [Fact] + public void LoadForServerRejectsAbsolutePipePathOnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var options = IpcTransportOptions.LoadForServer( + [ + "UniGetUI.exe", + IpcTransportOptions.TransportArgument, + "named-pipe", + IpcTransportOptions.NamedPipeArgument, + "/tmp/custom-unigetui.sock", + ] + ); + + Assert.Equal(IpcTransportOptions.DefaultNamedPipeName, options.NamedPipeName); + } + + [Fact] + public void LoadForServerAcceptsAbsolutePipePathOnUnix() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + const string socketPath = "/tmp/custom-unigetui.sock"; + var options = IpcTransportOptions.LoadForServer( + [ + "UniGetUI.exe", + IpcTransportOptions.TransportArgument, + "named-pipe", + IpcTransportOptions.NamedPipeArgument, + socketPath, + ] + ); + + Assert.Equal(socketPath, options.NamedPipeName); + Assert.Equal(socketPath, options.NamedPipePath); + } + + [Fact] + public void LoadForClientUsesPersistedEndpointMetadataWhenNoOverridesExist() + { + var persisted = new IpcTransportOptions( + IpcTransportKind.NamedPipe, + 7058, + "Persisted.Pipe" + ); + persisted.Persist( + sessionId: "gui-session", + token: "gui-token", + sessionKind: IpcTransportOptions.GuiSessionKind, + processId: Environment.ProcessId + ); + + var options = IpcTransportOptions.LoadForClient(["UniGetUI.exe"]); + + Assert.Equal(IpcTransportKind.NamedPipe, options.TransportKind); + Assert.Equal("Persisted.Pipe", options.NamedPipeName); + } + + [Fact] + public void LoadForClientPrefersHeadlessPersistedSessionWhenMultipleSessionsExist() + { + var guiOptions = new IpcTransportOptions( + IpcTransportKind.Tcp, + 7058, + IpcTransportOptions.DefaultNamedPipeName + ); + guiOptions.Persist( + sessionId: "gui-session", + token: "gui-token", + sessionKind: IpcTransportOptions.GuiSessionKind, + processId: Environment.ProcessId + ); + + var headlessOptions = new IpcTransportOptions( + IpcTransportKind.NamedPipe, + 7058, + "Headless.Pipe" + ); + headlessOptions.Persist( + sessionId: "headless-session", + token: "headless-token", + sessionKind: IpcTransportOptions.HeadlessSessionKind, + processId: Environment.ProcessId + ); + + var options = IpcTransportOptions.LoadForClient(["UniGetUI.exe"]); + + Assert.Equal(IpcTransportKind.NamedPipe, options.TransportKind); + Assert.Equal("Headless.Pipe", options.NamedPipeName); + } + + public void Dispose() + { + IpcTransportOptions.DeletePersistedMetadata(); + CoreData.TEST_DataDirectoryOverride = null; + + if (Directory.Exists(_dataDirectory)) + { + Directory.Delete(_dataDirectory, recursive: true); + } + } +} diff --git a/src/UniGetUI.Tests/UniGetUI.Tests.csproj b/src/UniGetUI.Tests/UniGetUI.Tests.csproj index 36dd1d92f2..1b8678ef7a 100644 --- a/src/UniGetUI.Tests/UniGetUI.Tests.csproj +++ b/src/UniGetUI.Tests/UniGetUI.Tests.csproj @@ -30,6 +30,7 @@
+ diff --git a/src/UniGetUI.Windows.slnx b/src/UniGetUI.Windows.slnx index adb1f10eed..2b18d78c8c 100644 --- a/src/UniGetUI.Windows.slnx +++ b/src/UniGetUI.Windows.slnx @@ -87,7 +87,7 @@
- + diff --git a/src/UniGetUI/App.xaml.cs b/src/UniGetUI/App.xaml.cs index 10b5f89c7d..78720ed539 100644 --- a/src/UniGetUI/App.xaml.cs +++ b/src/UniGetUI/App.xaml.cs @@ -16,6 +16,7 @@ using UniGetUI.Interface.Telemetry; using UniGetUI.PackageEngine; using UniGetUI.PackageEngine.Classes.Manager.Classes; +using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageOperations; using UniGetUI.Pages.DialogPages; @@ -66,11 +67,15 @@ public static int AvailableUpdates } public bool RaiseExceptionAsFatal = true; + private int _isQuitting; public MainWindow MainWindow = null!; public ThemeListener ThemeListener = null!; - private readonly BackgroundApiRunner BackgroundApi = new(); + private readonly IpcServer IpcApi = new() + { + SessionKind = IpcTransportOptions.GuiSessionKind, + }; public static MainApp Instance = null!; public MainApp() @@ -107,7 +112,7 @@ private void ApplyThemeToApp() ThemeListener = new ThemeListener(); } - private static async Task LoadGSudo() + internal static async Task LoadGSudoAsync() { try { @@ -374,7 +379,7 @@ private async Task LoadComponentsAsync() Task.Run(SetUpWebViewUserDataFolder), Task.Run(IconDatabase.Instance.LoadFromCacheAsync), Task.Run(RegisterNotificationService), - Task.Run(LoadGSudo), + Task.Run(LoadGSudoAsync), Task.Run(InitializeBackgroundAPI), ]; @@ -420,49 +425,181 @@ private async Task LoadComponentsAsync() private async Task InitializeBackgroundAPI() { - // Bind the background api to the main interface + // Bind the IPC API to the main interface try { if (Settings.Get(Settings.K.DisableApi)) return; - BackgroundApi.OnOpenWindow += (_, _) => - MainWindow.DispatcherQueue.TryEnqueue(() => MainWindow.Activate()); - - BackgroundApi.OnOpenUpdatesPage += (_, _) => - MainWindow.DispatcherQueue.TryEnqueue(() => - { - MainWindow?.NavigationPage?.NavigateTo(PageType.Updates); - MainWindow?.Activate(); - }); + IpcApi.AppInfoProvider = () => RunOnUiThread(GetAppInfo); + IpcApi.ShowAppHandler = () => RunOnUiThread(ShowApp); + IpcApi.NavigateAppHandler = request => RunOnUiThread(() => NavigateApp(request)); + IpcApi.QuitAppHandler = () => RunOnUiThread(QuitApp); + IpcApi.ShowPackageHandler = request => RunOnUiThread(() => ShowPackage(request)); - BackgroundApi.OnUpgradeAll += (_, _) => + IpcApi.OnUpgradeAll += (_, _) => MainWindow.DispatcherQueue.TryEnqueue(() => { _ = Operations.UpdateAll(); }); - BackgroundApi.OnUpgradeAllForManager += (s, managerName) => + IpcApi.OnUpgradeAllForManager += (s, managerName) => MainWindow.DispatcherQueue.TryEnqueue(() => { _ = Operations.UpdateAllForManager(managerName); }); - BackgroundApi.OnUpgradePackage += (s, packageId) => - MainWindow.DispatcherQueue.TryEnqueue(() => - { - _ = Operations.UpdateForId(packageId); - }); - - await BackgroundApi.Start(); + await IpcApi.Start(); } catch (Exception ex) { - Logger.Error("Could not initialize Background API:"); + Logger.Error("Could not initialize IPC API:"); Logger.Error(ex); } } + private static T RunOnUiThread(Func action) + { + if (Instance.MainWindow.DispatcherQueue.HasThreadAccess) + { + return action(); + } + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + if (!Instance.MainWindow.DispatcherQueue.TryEnqueue(() => + { + try + { + completion.SetResult(action()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + })) + { + throw new InvalidOperationException("Failed to dispatch the app automation request."); + } + + return completion.Task.GetAwaiter().GetResult(); + } + + private IpcAppInfo GetAppInfo() + { + return new IpcAppInfo + { + Headless = false, + WindowAvailable = MainWindow is not null, + WindowVisible = MainWindow?.IsInterfaceVisible ?? false, + CanShowWindow = MainWindow is not null, + CanNavigate = MainWindow?.NavigationPage is not null, + CanQuit = true, + CurrentPage = MainWindow?.NavigationPage is null + ? "" + : IpcAppPages.ToPageName(MainWindow.NavigationPage.CurrentPage.ToString()), + SupportedPages = IpcAppPages.SupportedPages, + }; + } + + private IpcCommandResult ShowApp() + { + MainWindow.ShowFromTray(); + return IpcCommandResult.Success("show-app"); + } + + private IpcCommandResult NavigateApp(IpcAppNavigateRequest request) + { + string page = IpcAppPages.NormalizePageName(request.Page); + IPackageManager? manager = ResolveManager(request.ManagerName); + + switch (page) + { + case "discover": + MainWindow.NavigationPage.NavigateTo(PageType.Discover); + break; + case "updates": + MainWindow.NavigationPage.NavigateTo(PageType.Updates); + break; + case "installed": + MainWindow.NavigationPage.NavigateTo(PageType.Installed); + break; + case "bundles": + MainWindow.NavigationPage.NavigateTo(PageType.Bundles); + break; + case "settings": + MainWindow.NavigationPage.NavigateTo(PageType.Settings); + break; + case "managers": + MainWindow.NavigationPage.OpenManagerSettings(manager); + break; + case "own-log": + MainWindow.NavigationPage.NavigateTo(PageType.OwnLog); + break; + case "manager-log": + MainWindow.NavigationPage.OpenManagerLogs(manager); + break; + case "operation-history": + MainWindow.NavigationPage.NavigateTo(PageType.OperationHistory); + break; + case "help": + MainWindow.NavigationPage.ShowHelp(request.HelpAttachment ?? ""); + break; + case "release-notes": + _ = DialogHelper.ShowReleaseNotes(); + break; + case "about": + _ = DialogHelper.ShowAboutUniGetUI(); + break; + default: + throw new InvalidOperationException( + $"Unsupported app page \"{request.Page}\"." + ); + } + + MainWindow.ShowFromTray(); + return IpcCommandResult.Success("navigate-app"); + } + + private IpcCommandResult ShowPackage(IpcPackageActionRequest request) + { + IPackage package = IpcPackageApi.ResolvePackage(request); + MainWindow.ShowFromTray(); + _ = DialogHelper.ShowPackageDetails( + package, + OperationType.Install, + TEL_InstallReferral.DIRECT_SEARCH + ); + return IpcCommandResult.Success("show-package"); + } + + private IpcCommandResult QuitApp() + { + _ = Task.Run(async () => + { + await Task.Delay(150); + RunOnUiThread(() => + { + DisposeAndQuit(); + return true; + }); + }); + return IpcCommandResult.Success("quit-app"); + } + + private static IPackageManager? ResolveManager(string? managerName) + { + if (string.IsNullOrWhiteSpace(managerName)) + { + return null; + } + + return PEInterface.Managers.FirstOrDefault(manager => + manager.Id.Equals(managerName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Unknown manager \"{managerName}\"."); + } + private async Task CheckForMissingDependencies() { // Check for missing dependencies on package managers @@ -569,11 +706,16 @@ Match argument in Regex.Matches( public void DisposeAndQuit(int outputCode = 0) { + if (Interlocked.Exchange(ref _isQuitting, 1) == 1) + { + return; + } + Logger.Warn("Quitting UniGetUI"); DWMThreadHelper.ChangeState_DWM(false); DWMThreadHelper.ChangeState_XAML(false); MainWindow?.Close(); - BackgroundApi?.Stop(); + IpcApi.Stop().GetAwaiter().GetResult(); Exit(); // await Task.Delay(100); // Environment.Exit(outputCode); diff --git a/src/UniGetUI/AppOperationHelper.cs b/src/UniGetUI/AppOperationHelper.cs index fcf0d57306..01348477a8 100644 --- a/src/UniGetUI/AppOperationHelper.cs +++ b/src/UniGetUI/AppOperationHelper.cs @@ -35,7 +35,13 @@ x.Operation.Status is OperationStatus.Running or OperationStatus.InQueue public static ObservableCollection _operationList = new(); - public static void Add(AbstractOperation op) => _operationList.Add(new(op)); + public static OperationControl Add(AbstractOperation op) + { + IpcOperationApi.Track(op); + var control = new OperationControl(op); + _operationList.Add(control); + return control; + } public static void Remove(OperationControl control) => _operationList.Remove(control); @@ -360,8 +366,7 @@ public static async Task UpdateAllForManager(string managerName) foreach (IPackage package in UpgradablePackagesLoader.Instance.Packages) { if ( - package.Manager.Name != managerName - && package.Manager.DisplayName != managerName + package.Manager.Id != managerName ) continue; // Package not from the desired package manager diff --git a/src/UniGetUI/CLIHandler.cs b/src/UniGetUI/CLIHandler.cs index e600ab177c..48597c72bb 100644 --- a/src/UniGetUI/CLIHandler.cs +++ b/src/UniGetUI/CLIHandler.cs @@ -1,7 +1,10 @@ +using System.Text.Json; +using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.SettingsEngine.SecureSettings; using UniGetUI.Core.Tools; +using UniGetUI.Interface; namespace UniGetUI; @@ -25,6 +28,7 @@ public static class CLIHandler public const string DISABLE_SECURE_SETTING = "--disable-secure-setting"; public const string ENABLE_SECURE_SETTING_FOR_USER = SecureSettings.Args.ENABLE_FOR_USER; public const string DISABLE_SECURE_SETTING_FOR_USER = SecureSettings.Args.DISABLE_FOR_USER; + public const string HEADLESS = "--headless"; private enum HRESULT { @@ -33,12 +37,14 @@ private enum HRESULT STATUS_INVALID_PARAMETER = -1073741811, STATUS_NO_SUCH_FILE = -1073741809, STATUS_UNKNOWN__SETTINGS_KEY = -2, + STATUS_BACKGROUND_API_UNAVAILABLE = -3, + STATUS_UNKNOWN_AUTOMATION_COMMAND = -4, } public static int Help() { var url = - "https://github.com/Devolutions/UniGetUI/blob/main/cli-arguments.md#unigetui-command-line-parameters"; + "https://github.com/Devolutions/UniGetUI/blob/main/docs/CLI.md#unigetui-command-line-interface"; CoreTools.Launch(url); return 0; } @@ -394,4 +400,16 @@ internal static int DisableSecureSettingForUser(IReadOnlyList args) return ex.HResult; } } + + public static int Automation() + { + return Automation(Environment.GetCommandLineArgs()); + } + + internal static int Automation(IReadOnlyList args) + { + return IpcCliCommandRunner.RunAsync(args, Console.Out, Console.Error) + .GetAwaiter() + .GetResult(); + } } diff --git a/src/UniGetUI/Controls/OperationWidgets/OperationControl.cs b/src/UniGetUI/Controls/OperationWidgets/OperationControl.cs index 0e08db769e..c895bb1db0 100644 --- a/src/UniGetUI/Controls/OperationWidgets/OperationControl.cs +++ b/src/UniGetUI/Controls/OperationWidgets/OperationControl.cs @@ -11,6 +11,7 @@ using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; +using UniGetUI.Interface; using UniGetUI.Interface.Enums; using UniGetUI.Interface.Telemetry; using UniGetUI.Interface.Widgets; @@ -396,6 +397,10 @@ public void Close() MainApp.Operations._operationList.Remove(this); while (AbstractOperation.OperationQueue.Remove(Operation)) ; + if (Operation.Status is not (OperationStatus.InQueue or OperationStatus.Running)) + { + IpcOperationApi.ForgetTracking(Operation.Metadata.Identifier); + } } private string _buttonText; diff --git a/src/UniGetUI/Controls/SourceManager.xaml.cs b/src/UniGetUI/Controls/SourceManager.xaml.cs index aca311e74e..ffeaee3f9b 100644 --- a/src/UniGetUI/Controls/SourceManager.xaml.cs +++ b/src/UniGetUI/Controls/SourceManager.xaml.cs @@ -27,8 +27,7 @@ public SourceItem(SourceManager Parent, IManagerSource Source) public void Remove(object sender, RoutedEventArgs e) { - var op = new OperationControl(new RemoveSourceOperation(Source)); - MainApp.Operations._operationList.Add(op); + var op = MainApp.Operations.Add(new RemoveSourceOperation(Source)); op.Operation.OperationSucceeded += (_, _) => { Parent.RemoveSourceItem(this); diff --git a/src/UniGetUI/EntryPoint.cs b/src/UniGetUI/EntryPoint.cs index b392c19d6e..3df9a068c5 100644 --- a/src/UniGetUI/EntryPoint.cs +++ b/src/UniGetUI/EntryPoint.cs @@ -3,6 +3,7 @@ using Microsoft.Windows.AppLifecycle; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; +using UniGetUI.Interface; namespace UniGetUI { @@ -14,68 +15,83 @@ private static void Main(string[] args) // Having an async main method breaks WebView2 try { + if (ShouldPrepareCliConsole(args)) + { + WindowsConsoleHost.PrepareCliIO(); + } + if (args.Contains(CLIHandler.HELP)) { - CLIHandler.Help(); - Environment.Exit(0); + Environment.ExitCode = CLIHandler.Help(); + return; } else if (args.Contains(CLIHandler.MIGRATE_WINGETUI_TO_UNIGETUI)) { - int ret = CLIHandler.WingetUIToUniGetUIMigrator(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.WingetUIToUniGetUIMigrator(); + return; } else if ( args.Contains(CLIHandler.UNINSTALL_UNIGETUI) || args.Contains(CLIHandler.UNINSTALL_WINGETUI) ) { - int ret = CLIHandler.UninstallUniGetUI(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.UninstallUniGetUI(); + return; } else if (args.Contains(CLIHandler.IMPORT_SETTINGS)) { - int ret = CLIHandler.ImportSettings(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.ImportSettings(); + return; } else if (args.Contains(CLIHandler.EXPORT_SETTINGS)) { - int ret = CLIHandler.ExportSettings(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.ExportSettings(); + return; } else if (args.Contains(CLIHandler.ENABLE_SETTING)) { - int ret = CLIHandler.EnableSetting(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.EnableSetting(); + return; } else if (args.Contains(CLIHandler.DISABLE_SETTING)) { - int ret = CLIHandler.DisableSetting(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.DisableSetting(); + return; } else if (args.Contains(CLIHandler.SET_SETTING_VAL)) { - int ret = CLIHandler.SetSettingsValue(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.SetSettingsValue(); + return; } else if (args.Contains(CLIHandler.ENABLE_SECURE_SETTING)) { - int ret = CLIHandler.EnableSecureSetting(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.EnableSecureSetting(); + return; } else if (args.Contains(CLIHandler.DISABLE_SECURE_SETTING)) { - int ret = CLIHandler.DisableSecureSetting(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.DisableSecureSetting(); + return; } else if (args.Contains(CLIHandler.ENABLE_SECURE_SETTING_FOR_USER)) { - int ret = CLIHandler.EnableSecureSettingForUser(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.EnableSecureSettingForUser(); + return; } else if (args.Contains(CLIHandler.DISABLE_SECURE_SETTING_FOR_USER)) { - int ret = CLIHandler.DisableSecureSettingForUser(); - Environment.Exit(ret); + Environment.ExitCode = CLIHandler.DisableSecureSettingForUser(); + return; + } + else if (IpcCliSyntax.IsIpcCommand(args)) + { + Environment.ExitCode = CLIHandler.Automation(args); + return; + } + else if (args.Contains(CLIHandler.HEADLESS)) + { + Environment.ExitCode = WinUiHeadlessHost.RunAsync(args).GetAwaiter().GetResult(); + return; } else if (!ModernAppLauncher.IsClassicModeEnabled()) { @@ -93,6 +109,11 @@ private static void Main(string[] args) } } + private static bool ShouldPrepareCliConsole(IReadOnlyList args) + { + return IpcCliSyntax.HasVerbCommand(args); + } + /// /// UniGetUI app main entry point /// diff --git a/src/UniGetUI/MainWindow.xaml.cs b/src/UniGetUI/MainWindow.xaml.cs index a622bc888e..0f3434714a 100644 --- a/src/UniGetUI/MainWindow.xaml.cs +++ b/src/UniGetUI/MainWindow.xaml.cs @@ -41,6 +41,7 @@ public XamlRoot XamlRoot private bool HasLoadedLastGeometry; public MainView NavigationPage = null!; + public bool IsInterfaceVisible => MainContentFrame.Content is not null; public bool BlockLoading; private string _currentSubtitle = ""; private int _currentSubtitlePxLength; @@ -368,6 +369,15 @@ public void ProcessCommandLineParameters() { /* Skip */ } + else if ( + param + is IpcTransportOptions.TransportArgument + or IpcTransportOptions.TcpPortArgument + or IpcTransportOptions.NamedPipeArgument + ) + { + _ = ParametersToProcess.Count > 0 ? ParametersToProcess.Dequeue() : null; + } else { Logger.Warn("Unknown parameter " + param); @@ -682,6 +692,12 @@ or WindowActivationState.PointerActivated SetMinimizable(true); } + public void ShowFromTray() + { + AppWindow.Show(); + Activate(); + } + public void ApplyTheme() { string preferredTheme = Settings.GetValue(Settings.K.PreferredTheme); diff --git a/src/UniGetUI/Pages/MainView.xaml.cs b/src/UniGetUI/Pages/MainView.xaml.cs index 94161ad8d7..8bbd0346f2 100644 --- a/src/UniGetUI/Pages/MainView.xaml.cs +++ b/src/UniGetUI/Pages/MainView.xaml.cs @@ -58,6 +58,7 @@ public sealed partial class MainView : UserControl private PageType OldPage_t = PageType.Null; private PageType CurrentPage_t = PageType.Null; private readonly List NavigationHistory = new(); + public PageType CurrentPage => CurrentPage_t; private readonly AutoSuggestBox MainTextBlock; public event EventHandler? CanGoBackChanged; diff --git a/src/UniGetUI/Pages/SoftwarePages/PackageBundlesPage.cs b/src/UniGetUI/Pages/SoftwarePages/PackageBundlesPage.cs index 13ae6dfc76..932bdb072d 100644 --- a/src/UniGetUI/Pages/SoftwarePages/PackageBundlesPage.cs +++ b/src/UniGetUI/Pages/SoftwarePages/PackageBundlesPage.cs @@ -843,7 +843,8 @@ public static IPackage DeserializePackage(SerializablePackage raw_package) foreach (var possible_manager in PEInterface.Managers) { if ( - possible_manager.Name == raw_package.ManagerName + possible_manager.Id == raw_package.ManagerName + || possible_manager.Name == raw_package.ManagerName || possible_manager.DisplayName == raw_package.ManagerName ) { diff --git a/src/UniGetUI/UniGetUI.csproj b/src/UniGetUI/UniGetUI.csproj index 515bc9956f..9397f4a2ce 100644 --- a/src/UniGetUI/UniGetUI.csproj +++ b/src/UniGetUI/UniGetUI.csproj @@ -368,7 +368,7 @@ - + diff --git a/src/UniGetUI/WinUiHeadlessHost.cs b/src/UniGetUI/WinUiHeadlessHost.cs new file mode 100644 index 0000000000..3f658bcbf1 --- /dev/null +++ b/src/UniGetUI/WinUiHeadlessHost.cs @@ -0,0 +1,22 @@ +using UniGetUI.Core.Tools; +using UniGetUI.Interface; +using UniGetUI.PackageEngine; + +namespace UniGetUI; + +internal static class WinUiHeadlessHost +{ + public static Task RunAsync(string[] args) + { + return HeadlessIpcHost.RunAsync(async () => + { + CoreTools.ReloadLanguageEngineInstance(); + UniGetUI.Interface.MainWindow.ApplyProxyVariableToProcess(); + PEInterface.LoadLoaders(); + await Task.WhenAll( + Task.Run(PEInterface.LoadManagers), + MainApp.LoadGSudoAsync() + ); + }); + } +} diff --git a/testing/automation/cli-e2e.manifest.linux.json b/testing/automation/cli-e2e.manifest.linux.json new file mode 100644 index 0000000000..a4a5f0db67 --- /dev/null +++ b/testing/automation/cli-e2e.manifest.linux.json @@ -0,0 +1,99 @@ +{ + "name": "linux-headless-ipc", + "daemon": { + "kind": "avalonia-dll", + "project": "UniGetUI.Avalonia\\UniGetUI.Avalonia.csproj", + "assemblyName": "UniGetUI.Avalonia" + }, + "transport": { + "kind": "named-pipe", + "verifyNoTcpListener": false + }, + "secureSettings": { + "allowSet": true, + "toggleKey": "AllowCustomManagerPaths", + "managerForExecutableOverride": "npm" + }, + "uninstallValidationManagers": [ + "pip" + ], + "packageManagers": [ + { + "manager": "pip", + "sourceName": "pip", + "query": "cowsay", + "packageId": "cowsay", + "installVersion": "5.0", + "scope": "User", + "roles": [ + "specific-update" + ] + }, + { + "manager": "npm", + "sourceName": "npm", + "query": "cowsay", + "packageId": "cowsay", + "installVersion": "1.5.0", + "scope": "Global", + "roles": [ + "bundle", + "update-manager", + "reinstall", + "repair", + "toggle-manager", + "update-all" + ] + } + ], + "queueOperations": [ + { + "manager": "pip", + "sourceName": "pip", + "packageId": "awscli", + "target": "download", + "query": "awscli" + }, + { + "manager": "npm", + "sourceName": "npm", + "packageId": "typescript", + "target": "download", + "query": "typescript" + } + ], + "excludedCommands": [ + { + "command": "manager action", + "reason": "Current manager actions are Windows-specific and system-changing, so they are intentionally excluded from Linux CI." + }, + { + "command": "source add", + "reason": "The deterministic Linux manager matrix does not include a manager with reliable CI-safe custom source mutation." + }, + { + "command": "source remove", + "reason": "The deterministic Linux manager matrix does not include a manager with reliable CI-safe custom source mutation." + }, + { + "command": "operation cancel", + "reason": "Queued package downloads complete too quickly on current Linux CI runners to guarantee a stable cancellable operation window." + }, + { + "command": "operation retry", + "reason": "A deterministic retry scenario depends on first forcing a stable failed or canceled operation, which is not yet reliable in CI." + }, + { + "command": "operation reorder", + "reason": "Queued package downloads complete too quickly on current Linux CI runners to guarantee a stable reorderable operation window." + }, + { + "command": "backup github *", + "reason": "GitHub device-flow authentication is intentionally excluded from deterministic CI." + }, + { + "command": "backup cloud *", + "reason": "Cloud backup flows depend on external authenticated GitHub state and are intentionally excluded from deterministic CI." + } + ] +} diff --git a/testing/automation/cli-e2e.manifest.windows.json b/testing/automation/cli-e2e.manifest.windows.json new file mode 100644 index 0000000000..d561d42b40 --- /dev/null +++ b/testing/automation/cli-e2e.manifest.windows.json @@ -0,0 +1,105 @@ +{ + "name": "windows-headless-ipc", + "daemon": { + "kind": "winui-exe", + "project": "UniGetUI\\UniGetUI.csproj", + "assemblyName": "UniGetUI" + }, + "transport": { + "kind": "named-pipe", + "verifyNoTcpListener": true + }, + "secureSettings": { + "allowSet": false, + "toggleKey": "AllowCustomManagerPaths", + "managerForExecutableOverride": "npm" + }, + "packageManagers": [ + { + "manager": "dotnet-tool", + "sourceName": "nuget.org", + "query": "dotnetsay", + "packageId": "dotnetsay", + "installVersion": "2.1.4", + "scope": "Global", + "roles": [ + "bundle", + "update-manager", + "reinstall", + "repair" + ] + }, + { + "manager": "npm", + "sourceName": "npm", + "query": "cowsay", + "packageId": "cowsay", + "installVersion": "1.5.0", + "scope": "Global", + "roles": [ + "specific-update", + "toggle-manager", + "update-all", + "update-discovery" + ] + } + ], + "queueOperations": [ + { + "manager": "npm", + "sourceName": "npm", + "packageId": "typescript", + "target": "download", + "query": "typescript" + } + ], + "uninstallValidationManagers": [ + "dotnet-tool" + ], + "excludedCommands": [ + { + "command": "manager action", + "reason": "Windows manager actions require elevated system changes that are not deterministic on hosted runners." + }, + { + "command": "settings secure set", + "reason": "Secure setting writes on Windows elevate via runas, which is not deterministic in GitHub-hosted CI." + }, + { + "command": "source add", + "reason": "The deterministic Windows manager matrix does not include a manager with reliable CI-safe custom source mutation." + }, + { + "command": "source remove", + "reason": "The deterministic Windows manager matrix does not include a manager with reliable CI-safe custom source mutation." + }, + { + "command": "operation cancel", + "reason": "Queued package downloads complete too quickly on current Windows CI runners to guarantee a stable cancellable operation window." + }, + { + "command": "operation retry", + "reason": "A deterministic retry scenario depends on first forcing a stable failed or canceled operation, which is not yet reliable in CI." + }, + { + "command": "operation reorder", + "reason": "Queued package downloads complete too quickly on current Windows CI runners to guarantee a stable reorderable operation window." + }, + { + "command": "manager set-executable", + "reason": "Custom manager path coverage depends on secure setting writes, which are skipped on Windows CI." + }, + { + "command": "manager clear-executable", + "reason": "Custom manager path coverage depends on secure setting writes, which are skipped on Windows CI." + }, + { + "command": "backup github *", + "reason": "GitHub device-flow authentication is intentionally excluded from deterministic CI." + }, + { + "command": "backup cloud *", + "reason": "Cloud backup flows depend on external authenticated GitHub state and are intentionally excluded from deterministic CI." + } + ] +} diff --git a/testing/automation/run-cli-e2e.ps1 b/testing/automation/run-cli-e2e.ps1 new file mode 100644 index 0000000000..63962cd73f --- /dev/null +++ b/testing/automation/run-cli-e2e.ps1 @@ -0,0 +1,1137 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$runningOnWindows = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows +) + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$srcRoot = Join-Path $repoRoot 'src' +$configuration = if ($env:CONFIGURATION) { $env:CONFIGURATION } else { 'Release' } +$defaultManifest = if ($runningOnWindows) { + Join-Path $PSScriptRoot 'cli-e2e.manifest.windows.json' +} +else { + Join-Path $PSScriptRoot 'cli-e2e.manifest.linux.json' +} +$manifestPath = if ($env:UNIGETUI_CLI_E2E_MANIFEST) { + $env:UNIGETUI_CLI_E2E_MANIFEST +} +else { + $defaultManifest +} + +if (-not (Test-Path $manifestPath)) { + throw "CLI E2E manifest not found at $manifestPath" +} + +$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json -AsHashtable +if ($null -eq $manifest) { + throw "Could not parse CLI E2E manifest at $manifestPath" +} + +$artifactRoot = if ($env:UNIGETUI_CLI_E2E_ARTIFACTS) { + $env:UNIGETUI_CLI_E2E_ARTIFACTS +} +else { + Join-Path ([System.IO.Path]::GetTempPath()) ("unigetui-cli-e2e-" + [Guid]::NewGuid().ToString('N')) +} +if (Test-Path $artifactRoot) { + Remove-Item -Recurse -Force $artifactRoot +} +New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null +$artifactRoot = (Resolve-Path $artifactRoot).Path + +$daemonRoot = $artifactRoot +$downloadRoot = Join-Path $daemonRoot 'downloads' +$coveragePath = Join-Path $daemonRoot 'coverage.json' +$inventoryPath = Join-Path $daemonRoot 'environment.json' +$daemonStdOutLog = Join-Path $daemonRoot 'headless-daemon.stdout.log' +$daemonStdErrLog = Join-Path $daemonRoot 'headless-daemon.stderr.log' +$preserveArtifacts = $true + +$localDataRoot = if ($runningOnWindows) { + Join-Path $daemonRoot 'AppData\Local' +} +else { + Join-Path $daemonRoot '.local\share' +} +$roamingDataRoot = if ($runningOnWindows) { + Join-Path $daemonRoot 'AppData\Roaming' +} +else { + $null +} +$dotnetHomeRoot = Join-Path $daemonRoot '.dotnet-home' +$npmGlobalRoot = Join-Path $daemonRoot 'npm-global' + +New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null +New-Item -ItemType Directory -Path $localDataRoot -Force | Out-Null +New-Item -ItemType Directory -Path $dotnetHomeRoot -Force | Out-Null +New-Item -ItemType Directory -Path $npmGlobalRoot -Force | Out-Null +if (-not [string]::IsNullOrWhiteSpace($roamingDataRoot)) { + New-Item -ItemType Directory -Path $roamingDataRoot -Force | Out-Null +} + +$env:DOTNET_CLI_HOME = $dotnetHomeRoot +$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1' +$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' +$env:UNIGETUI_GITHUB_TOKEN_NAMESPACE = "cli-e2e-$([Guid]::NewGuid().ToString('N'))" +$env:UNIGETUI_AVALONIA_DEVTOOLS = 'disabled' +$env:npm_config_prefix = $npmGlobalRoot + +if (-not $runningOnWindows) { + $env:HOME = $daemonRoot + $env:USERPROFILE = $daemonRoot + $env:XDG_DATA_HOME = $localDataRoot +} + +$coverage = [ordered]@{ + manifest = $manifest.name + tested = @() + excluded = @($manifest.excludedCommands) +} + +function Add-Tested { + param( + [Parameter(Mandatory = $true)] + [string] $Command, + [string] $Mode = 'success' + ) + + $script:coverage.tested += [ordered]@{ + command = $Command + mode = $Mode + } +} + +function Write-Stage { + param( + [Parameter(Mandatory = $true)] + [string] $Name + ) + + Write-Host "== $Name ==" +} + +function Find-BuiltArtifact { + param( + [Parameter(Mandatory = $true)] + [string] $ProjectDirectory, + [Parameter(Mandatory = $true)] + [string] $FileName + ) + + $outputRoot = Join-Path $ProjectDirectory 'bin' + if (-not (Test-Path $outputRoot)) { + return $null + } + + return Get-ChildItem -Path $outputRoot -Recurse -File -Filter $FileName | + Sort-Object @{ + Expression = { if ($_.FullName -like "*\bin\*\$configuration\*") { 0 } else { 1 } } + }, @{ + Expression = { $_.FullName } + } | + Select-Object -First 1 -ExpandProperty FullName +} + +function Get-ManifestManagerByRole { + param( + [Parameter(Mandatory = $true)] + [string] $Role + ) + + $matches = @($manifest.packageManagers | Where-Object { @($_.roles) -contains $Role }) + if ($matches.Count -eq 0) { + return $null + } + + return $matches[0] +} + +function Get-PackageArguments { + param( + [Parameter(Mandatory = $true)] + [hashtable] $Fixture, + [switch] $IncludeVersion, + [switch] $IncludeSource + ) + + $arguments = @( + '--manager', [string]$Fixture.manager, + '--id', [string]$Fixture.packageId + ) + if ($IncludeVersion -and $Fixture.ContainsKey('installVersion')) { + $arguments += @('--version', [string]$Fixture.installVersion) + } + if ($IncludeSource -and $Fixture.ContainsKey('sourceName')) { + $arguments += @('--source', [string]$Fixture.sourceName) + } + if ($Fixture.ContainsKey('scope')) { + $arguments += @('--scope', [string]$Fixture.scope) + } + return $arguments +} + +function Find-PackageMatch { + param( + [Parameter(Mandatory = $true)] + [object[]] $Packages, + [Parameter(Mandatory = $true)] + [hashtable] $Fixture, + [string] $Version + ) + + return @($Packages | Where-Object { + $_.id -eq $Fixture.packageId -and ( + -not $PSBoundParameters.ContainsKey('Version') -or $_.version -eq $Version + ) + })[0] +} + +function Resolve-QueueOutputPath { + param( + [Parameter(Mandatory = $true)] + [hashtable] $QueueFixture + ) + + $safeManager = ([string]$QueueFixture.manager) -replace '[^A-Za-z0-9._-]', '_' + $safePackage = ([string]$QueueFixture.packageId) -replace '[^A-Za-z0-9._-]', '_' + $targetDirectory = Join-Path $downloadRoot "$safeManager-$safePackage" + New-Item -ItemType Directory -Path $targetDirectory -Force | Out-Null + return $targetDirectory +} + +function Get-DaemonCommand { + $daemonProject = Join-Path $srcRoot ([string]$manifest.daemon.project) + if (-not (Test-Path $daemonProject)) { + throw "Daemon project not found at $daemonProject" + } + + switch ([string]$manifest.daemon.kind) { + 'winui-exe' { + $daemonExe = if ($env:UNIGETUI_DAEMON_EXE) { + $env:UNIGETUI_DAEMON_EXE + } + else { + Find-BuiltArtifact -ProjectDirectory (Split-Path $daemonProject -Parent) -FileName "$($manifest.daemon.assemblyName).exe" + } + if ([string]::IsNullOrWhiteSpace($daemonExe) -or -not (Test-Path $daemonExe)) { + throw "WinUI headless executable was not found. Expected $($manifest.daemon.assemblyName).exe under $(Split-Path $daemonProject -Parent)\bin\$configuration" + } + + return @{ + FilePath = (Resolve-Path $daemonExe).Path + WorkingDirectory = Split-Path (Resolve-Path $daemonExe).Path -Parent + } + } + 'avalonia-dll' { + $daemonDll = if ($env:UNIGETUI_DAEMON_DLL) { + $env:UNIGETUI_DAEMON_DLL + } + else { + Find-BuiltArtifact -ProjectDirectory (Split-Path $daemonProject -Parent) -FileName "$($manifest.daemon.assemblyName).dll" + } + if ([string]::IsNullOrWhiteSpace($daemonDll) -or -not (Test-Path $daemonDll)) { + throw "Avalonia headless daemon DLL was not found. Expected $($manifest.daemon.assemblyName).dll under $(Split-Path $daemonProject -Parent)\bin\$configuration" + } + + $resolvedDll = (Resolve-Path $daemonDll).Path + return @{ + FilePath = 'dotnet' + WorkingDirectory = Split-Path $resolvedDll -Parent + PrefixArguments = @($resolvedDll) + } + } + default { + throw "Unsupported daemon kind $($manifest.daemon.kind)" + } + } +} + +$pipeName = "UniGetUI.CI.$([Guid]::NewGuid().ToString('N'))" +$transportArgs = @('--transport', 'named-pipe', '--pipe-name', $pipeName) +$daemonExtraArgs = @('--headless', '--ipc-api-transport', 'named-pipe', '--ipc-api-pipe-name', $pipeName) +$daemonCommand = Get-DaemonCommand +$cliCommand = $daemonCommand +$process = $null +$gracefulShutdown = $false + +function Get-DaemonLog { + $stdout = if (Test-Path $daemonStdOutLog) { Get-Content $daemonStdOutLog -Raw } else { '' } + $stderr = if (Test-Path $daemonStdErrLog) { Get-Content $daemonStdErrLog -Raw } else { '' } + return ($stdout, $stderr -join [Environment]::NewLine).Trim() +} + +function Stop-Daemon { + if ($null -ne $script:process -and -not $script:process.HasExited) { + Stop-Process -Id $script:process.Id + } +} + +function Invoke-CliRaw { + param( + [Parameter(Mandatory = $true)] + [string[]] $Arguments + ) + + $commandArguments = @() + if ($cliCommand.ContainsKey('PrefixArguments')) { + $commandArguments += $cliCommand.PrefixArguments + } + $commandArguments += $transportArgs + $Arguments + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $cliCommand.FilePath + $startInfo.WorkingDirectory = $cliCommand.WorkingDirectory + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $commandArguments) { + [void]$startInfo.ArgumentList.Add([string]$argument) + } + + $commandProcess = [System.Diagnostics.Process]::Start($startInfo) + if ($null -eq $commandProcess) { + throw "Failed to start CLI command: $($cliCommand.FilePath) $($commandArguments -join ' ')" + } + + $stdout = $commandProcess.StandardOutput.ReadToEnd() + $stderr = $commandProcess.StandardError.ReadToEnd() + $commandProcess.WaitForExit() + $text = (@($stdout, $stderr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { + $_.TrimEnd() + }) -join [Environment]::NewLine + return @{ + ExitCode = $commandProcess.ExitCode + Text = $text + } +} + +function Invoke-CliJson { + param( + [Parameter(Mandatory = $true)] + [string[]] $Arguments + ) + + $result = Invoke-CliRaw -Arguments $Arguments + if ($result.ExitCode -ne 0) { + throw "CLI command failed ($($result.ExitCode)): $($Arguments -join ' ')`n$($result.Text)" + } + if ([string]::IsNullOrWhiteSpace($result.Text)) { + throw "CLI command returned empty output: $($Arguments -join ' ')" + } + return $result.Text | ConvertFrom-Json +} + +function Invoke-CliFailure { + param( + [Parameter(Mandatory = $true)] + [string[]] $Arguments + ) + + $result = Invoke-CliRaw -Arguments $Arguments + if ($result.ExitCode -eq 0) { + throw "CLI command unexpectedly succeeded: $($Arguments -join ' ')`n$($result.Text)" + } + return $result +} + +function Wait-ForCliCondition { + param( + [Parameter(Mandatory = $true)] + [string[]] $Arguments, + [Parameter(Mandatory = $true)] + [scriptblock] $Condition, + [Parameter(Mandatory = $true)] + [string] $FailureMessage, + [int] $TimeoutSeconds = 120, + [int] $DelaySeconds = 2 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastResponse = $null + $lastError = $null + + do { + try { + $lastResponse = Invoke-CliJson -Arguments $Arguments + $lastError = $null + if (& $Condition $lastResponse) { + return $lastResponse + } + } + catch { + $lastError = $_.Exception.Message + } + + Start-Sleep -Seconds $DelaySeconds + } while ((Get-Date) -lt $deadline) + + if ($null -ne $lastResponse) { + throw "$FailureMessage`nLast payload: $($lastResponse | ConvertTo-Json -Depth 10)" + } + + throw "$FailureMessage`nLast error: $lastError" +} + +function Wait-ForInstalledPackage { + param( + [Parameter(Mandatory = $true)] + [hashtable] $Fixture, + [string] $ExpectedVersion, + [int] $TimeoutSeconds = 180 + ) + + return Wait-ForCliCondition ` + -Arguments @('package', 'installed', '--manager', [string]$Fixture.manager) ` + -Condition { + param($response) + @($response.packages | Where-Object { + $_.id -eq $Fixture.packageId -and ( + [string]::IsNullOrWhiteSpace($ExpectedVersion) -or $_.version -eq $ExpectedVersion + ) + }).Count -gt 0 + } ` + -FailureMessage "package installed did not report $($Fixture.packageId) for manager $($Fixture.manager)" ` + -TimeoutSeconds $TimeoutSeconds ` + -DelaySeconds 3 +} + +function Wait-ForPackageRemoval { + param( + [Parameter(Mandatory = $true)] + [hashtable] $Fixture, + [int] $TimeoutSeconds = 180 + ) + + return Wait-ForCliCondition ` + -Arguments @('package', 'installed', '--manager', [string]$Fixture.manager) ` + -Condition { + param($response) + @($response.packages | Where-Object { $_.id -eq $Fixture.packageId }).Count -eq 0 + } ` + -FailureMessage "$($Fixture.packageId) still appears in package installed for manager $($Fixture.manager)" ` + -TimeoutSeconds $TimeoutSeconds ` + -DelaySeconds 3 +} + +function Wait-ForPackageUpdateVisibility { + param( + [Parameter(Mandatory = $true)] + [hashtable] $Fixture, + [int] $TimeoutSeconds = 120 + ) + + return Wait-ForCliCondition ` + -Arguments @('package', 'updates', '--manager', [string]$Fixture.manager) ` + -Condition { + param($response) + @($response.updates | Where-Object { $_.id -eq $Fixture.packageId }).Count -gt 0 + } ` + -FailureMessage "package updates did not report $($Fixture.packageId) for manager $($Fixture.manager)" ` + -TimeoutSeconds $TimeoutSeconds ` + -DelaySeconds 3 +} + +function Get-LatestFixtureVersion { + param( + [Parameter(Mandatory = $true)] + [hashtable] $Fixture, + [Parameter(Mandatory = $true)] + [object] $SearchResponse, + [Parameter(Mandatory = $true)] + [object] $VersionResponse + ) + + $searchMatch = @($SearchResponse.packages | Where-Object { $_.id -eq $Fixture.packageId })[0] + if ($null -eq $searchMatch) { + throw "package search did not return $($Fixture.packageId) for manager $($Fixture.manager)" + } + + $candidateVersions = @() + if (-not [string]::IsNullOrWhiteSpace($searchMatch.version)) { + $candidateVersions += [string]$searchMatch.version + } + $candidateVersions += @($VersionResponse.versions) + + $latestVersion = @($candidateVersions | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and $_ -ne $Fixture.installVersion + })[0] + + if ([string]::IsNullOrWhiteSpace($latestVersion)) { + throw "Could not resolve a newer version for $($Fixture.packageId) on manager $($Fixture.manager)" + } + + return [string]$latestVersion +} + +function Assert-JsonCommandSucceeded { + param( + [Parameter(Mandatory = $true)] + [object] $Response, + [Parameter(Mandatory = $true)] + [string] $Label + ) + + if ($Response.status -ne 'success') { + throw "$Label failed: $($Response | ConvertTo-Json -Depth 10)" + } +} + +function Write-EnvironmentInventory { + $inventory = [ordered]@{ + manifest = $manifest.name + os = if ($runningOnWindows) { 'windows' } else { 'linux' } + dotnet = (& dotnet --version) + python = '' + pip = '' + npm = '' + } + + try { + $inventory.python = (& python --version 2>&1 | Out-String).Trim() + } + catch { + $inventory.python = $_.Exception.Message + } + + try { + $inventory.pip = (& python -m pip --version 2>&1 | Out-String).Trim() + } + catch { + $inventory.pip = $_.Exception.Message + } + + try { + $inventory.npm = (& npm --version 2>&1 | Out-String).Trim() + } + catch { + $inventory.npm = $_.Exception.Message + } + + Set-Content -Path $inventoryPath -Value ($inventory | ConvertTo-Json -Depth 8) -Encoding UTF8 +} + +Write-EnvironmentInventory + +try { + $daemonArguments = @() + if ($daemonCommand.ContainsKey('PrefixArguments')) { + $daemonArguments += $daemonCommand.PrefixArguments + } + $daemonArguments += $daemonExtraArgs + + $process = Start-Process ` + -FilePath $daemonCommand.FilePath ` + -ArgumentList $daemonArguments ` + -WorkingDirectory $daemonCommand.WorkingDirectory ` + -RedirectStandardOutput $daemonStdOutLog ` + -RedirectStandardError $daemonStdErrLog ` + -PassThru + + $status = Wait-ForCliCondition ` + -Arguments @('status') ` + -Condition { param($response) $response.running -and $response.transport -eq 'named-pipe' } ` + -FailureMessage 'Headless daemon never became ready over named-pipe IPC.' ` + -TimeoutSeconds 120 ` + -DelaySeconds 2 + + Write-Stage 'Status and headless transport' + if ($status.namedPipeName -ne $pipeName) { + throw "status did not report the expected named pipe name. Expected $pipeName, got $($status.namedPipeName)" + } + if (-not $runningOnWindows) { + $expectedSocketPath = "/tmp/$pipeName" + if ($status.namedPipePath -ne $expectedSocketPath) { + throw "status did not report the expected Unix socket path. Expected $expectedSocketPath, got $($status.namedPipePath)" + } + } + Add-Tested 'status' + + $version = Invoke-CliJson -Arguments @('version') + if ($version.build -le 0) { + throw "version did not return a positive build number" + } + Add-Tested 'version' + + $appState = Invoke-CliJson -Arguments @('app', 'status') + if (-not $appState.app.headless -or $appState.app.windowAvailable -or $appState.app.canNavigate -or -not $appState.app.canQuit) { + throw "app status did not report the expected headless state: $($appState | ConvertTo-Json -Depth 8)" + } + Add-Tested 'app status' + + Invoke-CliFailure -Arguments @('app', 'show') | Out-Null + Add-Tested 'app show' 'expected-failure' + + Invoke-CliFailure -Arguments @('app', 'navigate', '--page', 'settings') | Out-Null + Add-Tested 'app navigate' 'expected-failure' + + $bundleFixture = Get-ManifestManagerByRole -Role 'bundle' + Invoke-CliFailure -Arguments @('package', 'show', '--id', [string]$bundleFixture.packageId, '--source', [string]$bundleFixture.sourceName) | Out-Null + Add-Tested 'package show' 'expected-failure' + + if ($runningOnWindows -and $manifest.transport.verifyNoTcpListener) { + $connections = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -eq $process.Id } + if (@($connections).Count -ne 0) { + throw "Headless named-pipe session opened a TCP listener unexpectedly: $(@($connections | Select-Object -ExpandProperty LocalPort) -join ', ')" + } + } + + Write-Stage 'Manager inspection' + $managers = Invoke-CliJson -Arguments @('manager', 'list') + foreach ($fixture in @($manifest.packageManagers)) { + if (@($managers.managers | Where-Object { $_.name -eq $fixture.manager }).Count -eq 0) { + throw "manager list did not report $($fixture.manager)" + } + } + Add-Tested 'manager list' + + foreach ($fixture in @($manifest.packageManagers)) { + $maintenance = Invoke-CliJson -Arguments @('manager', 'maintenance', '--manager', [string]$fixture.manager) + if ($maintenance.maintenance.manager -ne $fixture.manager) { + throw "manager maintenance did not return the expected manager payload for $($fixture.manager)" + } + if (@($maintenance.maintenance.supportedActions | Where-Object { $_ -eq 'reload' }).Count -eq 0) { + throw "manager maintenance did not expose reload for $($fixture.manager)" + } + + $reloadManager = Invoke-CliJson -Arguments @('manager', 'reload', '--manager', [string]$fixture.manager) + if ($reloadManager.operationStatus -ne 'completed') { + throw "manager reload did not complete successfully for $($fixture.manager): $($reloadManager | ConvertTo-Json -Depth 8)" + } + } + Add-Tested 'manager maintenance' + Add-Tested 'manager reload' + + $toggleManagerFixture = Get-ManifestManagerByRole -Role 'toggle-manager' + $disableManager = Invoke-CliJson -Arguments @('manager', 'disable', '--manager', [string]$toggleManagerFixture.manager) + if ($disableManager.manager.enabled) { + throw "manager disable did not disable $($toggleManagerFixture.manager)" + } + $enableManager = Invoke-CliJson -Arguments @('manager', 'enable', '--manager', [string]$toggleManagerFixture.manager) + if (-not $enableManager.manager.enabled) { + throw "manager enable did not re-enable $($toggleManagerFixture.manager)" + } + Add-Tested 'manager enable' + Add-Tested 'manager disable' + + $disableNotifications = Invoke-CliJson -Arguments @('manager', 'notifications', 'disable', '--manager', [string]$bundleFixture.manager) + if (-not $disableNotifications.manager.notificationsSuppressed) { + throw "manager notifications disable did not suppress notifications for $($bundleFixture.manager)" + } + $enableNotifications = Invoke-CliJson -Arguments @('manager', 'notifications', 'enable', '--manager', [string]$bundleFixture.manager) + if ($enableNotifications.manager.notificationsSuppressed) { + throw "manager notifications enable did not re-enable notifications for $($bundleFixture.manager)" + } + Add-Tested 'manager notifications disable' + Add-Tested 'manager notifications enable' + + foreach ($fixture in @($manifest.packageManagers)) { + $sources = Invoke-CliJson -Arguments @('source', 'list', '--manager', [string]$fixture.manager) + if (@($sources.sources | Where-Object { $_.name -eq $fixture.sourceName }).Count -eq 0) { + throw "source list did not report $($fixture.sourceName) for $($fixture.manager)" + } + } + Add-Tested 'source list' + + $sourceFixture = Get-ManifestManagerByRole -Role 'source' + if ($null -ne $sourceFixture) { + Write-Stage 'Source add/remove' + $sourceDirectory = Join-Path $daemonRoot 'LocalFeed' + New-Item -ItemType Directory -Path $sourceDirectory -Force | Out-Null + $sourceUri = ([System.Uri](Resolve-Path $sourceDirectory).Path).AbsoluteUri + $customSourceName = 'ci-local-feed' + $addedSource = Invoke-CliJson -Arguments @( + 'source', 'add', + '--manager', [string]$sourceFixture.manager, + '--name', $customSourceName, + '--url', $sourceUri + ) + Assert-JsonCommandSucceeded -Response $addedSource -Label 'source add' + $sourcesAfterAdd = Invoke-CliJson -Arguments @('source', 'list', '--manager', [string]$sourceFixture.manager) + if (@($sourcesAfterAdd.sources | Where-Object { $_.name -eq $customSourceName -and $_.url -eq $sourceUri }).Count -eq 0) { + throw "source add did not expose the configured custom source" + } + $removedSource = Invoke-CliJson -Arguments @( + 'source', 'remove', + '--manager', [string]$sourceFixture.manager, + '--name', $customSourceName, + '--url', $sourceUri + ) + Assert-JsonCommandSucceeded -Response $removedSource -Label 'source remove' + $sourcesAfterRemove = Invoke-CliJson -Arguments @('source', 'list', '--manager', [string]$sourceFixture.manager) + if (@($sourcesAfterRemove.sources | Where-Object { $_.name -eq $customSourceName }).Count -ne 0) { + throw "source remove did not remove the configured custom source" + } + Add-Tested 'source add' + Add-Tested 'source remove' + } + + Write-Stage 'Settings and secure settings' + $settings = Invoke-CliJson -Arguments @('settings', 'list') + if (@($settings.settings | Where-Object { $_.name -eq 'FreshValue' }).Count -eq 0) { + throw "settings list did not report FreshValue" + } + Add-Tested 'settings list' + + $setFreshValue = Invoke-CliJson -Arguments @('settings', 'set', '--key', 'FreshValue', '--value', 'cli-smoke') + if ($setFreshValue.setting.stringValue -ne 'cli-smoke') { + throw "settings set did not persist FreshValue" + } + $getFreshValue = Invoke-CliJson -Arguments @('settings', 'get', '--key', 'FreshValue') + if ($getFreshValue.setting.stringValue -ne 'cli-smoke') { + throw "settings get did not return FreshValue" + } + Add-Tested 'settings set' + Add-Tested 'settings get' + + $setFreshBool = Invoke-CliJson -Arguments @('settings', 'set', '--key', 'FreshBoolSetting', '--enabled', 'true') + if (-not $setFreshBool.setting.boolValue) { + throw "settings set did not enable FreshBoolSetting" + } + + $secureList = Invoke-CliJson -Arguments @('settings', 'secure', 'list') + if (@($secureList.settings | Where-Object { $_.key -eq $manifest.secureSettings.toggleKey }).Count -eq 0) { + throw "settings secure list did not report $($manifest.secureSettings.toggleKey)" + } + $secureGet = Invoke-CliJson -Arguments @('settings', 'secure', 'get', '--key', [string]$manifest.secureSettings.toggleKey) + if ($secureGet.setting.key -ne $manifest.secureSettings.toggleKey) { + throw "settings secure get did not return the requested key" + } + Add-Tested 'settings secure list' + Add-Tested 'settings secure get' + + if ($manifest.secureSettings.allowSet) { + $secureSetOn = Invoke-CliJson -Arguments @( + 'settings', 'secure', 'set', + '--key', [string]$manifest.secureSettings.toggleKey, + '--enabled', 'true' + ) + if (-not $secureSetOn.setting.enabled) { + throw "settings secure set did not enable $($manifest.secureSettings.toggleKey)" + } + Add-Tested 'settings secure set' + + $maintenanceWithCustomPaths = Invoke-CliJson -Arguments @('manager', 'maintenance', '--manager', [string]$manifest.secureSettings.managerForExecutableOverride) + if (-not $maintenanceWithCustomPaths.maintenance.customExecutablePathsAllowed) { + throw "manager maintenance did not reflect enabled custom executable paths" + } + + $setExecutable = Invoke-CliJson -Arguments @( + 'manager', 'set-executable', + '--manager', [string]$manifest.secureSettings.managerForExecutableOverride, + '--path', [string]$maintenanceWithCustomPaths.maintenance.effectiveExecutablePath + ) + if ($setExecutable.maintenance.configuredExecutablePath -ne $maintenanceWithCustomPaths.maintenance.effectiveExecutablePath) { + throw "manager set-executable did not persist the configured executable path" + } + $clearExecutable = Invoke-CliJson -Arguments @( + 'manager', 'clear-executable', + '--manager', [string]$manifest.secureSettings.managerForExecutableOverride + ) + if (-not [string]::IsNullOrWhiteSpace($clearExecutable.maintenance.configuredExecutablePath)) { + throw "manager clear-executable did not clear the custom executable path" + } + Add-Tested 'manager set-executable' + Add-Tested 'manager clear-executable' + + $secureSetOff = Invoke-CliJson -Arguments @( + 'settings', 'secure', 'set', + '--key', [string]$manifest.secureSettings.toggleKey, + '--enabled', 'false' + ) + if ($secureSetOff.setting.enabled) { + throw "settings secure set did not disable $($manifest.secureSettings.toggleKey)" + } + } + + Write-Stage 'Shortcut and backup' + $syntheticShortcut = Join-Path $daemonRoot 'SyntheticShortcut.lnk' + New-Item -ItemType File -Path $syntheticShortcut | Out-Null + + $keepShortcut = Invoke-CliJson -Arguments @('shortcut', 'set', '--path', $syntheticShortcut, '--status', 'keep') + if ($keepShortcut.shortcut.status -ne 'keep') { + throw "shortcut set did not persist keep" + } + $shortcuts = Invoke-CliJson -Arguments @('shortcut', 'list') + if (@($shortcuts.shortcuts | Where-Object { $_.path -eq $syntheticShortcut -and $_.status -eq 'keep' -and $_.existsOnDisk }).Count -eq 0) { + throw "shortcut list did not report the kept shortcut" + } + $deleteShortcut = Invoke-CliJson -Arguments @('shortcut', 'set', '--path', $syntheticShortcut, '--status', 'delete') + if ($deleteShortcut.shortcut.status -ne 'delete' -or (Test-Path $syntheticShortcut)) { + throw "shortcut set --status delete did not delete the shortcut" + } + $resetShortcut = Invoke-CliJson -Arguments @('shortcut', 'reset', '--path', $syntheticShortcut) + if ($resetShortcut.shortcut.status -ne 'unknown') { + throw "shortcut reset did not clear the shortcut verdict" + } + $resetAllShortcuts = Invoke-CliJson -Arguments @('shortcut', 'reset-all') + Assert-JsonCommandSucceeded -Response $resetAllShortcuts -Label 'shortcut reset-all' + Add-Tested 'shortcut list' + Add-Tested 'shortcut set' + Add-Tested 'shortcut reset' + Add-Tested 'shortcut reset-all' + + $appLog = Invoke-CliJson -Arguments @('log', 'app', '--level', '5') + if (@($appLog.entries).Count -eq 0) { + throw "log app returned no entries" + } + Add-Tested 'log app' + + $backupStatus = Invoke-CliJson -Arguments @('backup', 'status') + if ([string]::IsNullOrWhiteSpace($backupStatus.backup.backupDirectory)) { + throw "backup status did not report the backup directory" + } + Add-Tested 'backup status' + + $backupDirectory = Join-Path $daemonRoot 'backups' + $setBackupDirectory = Invoke-CliJson -Arguments @('settings', 'set', '--key', 'ChangeBackupOutputDirectory', '--value', $backupDirectory) + $setBackupFileName = Invoke-CliJson -Arguments @('settings', 'set', '--key', 'ChangeBackupFileName', '--value', 'cli-e2e-backup') + $disableBackupTimestamping = Invoke-CliJson -Arguments @('settings', 'set', '--key', 'EnableBackupTimestamping', '--enabled', 'false') + if ($setBackupDirectory.setting.stringValue -ne $backupDirectory -or $setBackupFileName.setting.stringValue -ne 'cli-e2e-backup' -or $disableBackupTimestamping.setting.boolValue) { + throw "backup settings did not persist correctly" + } + + $localBackup = Invoke-CliJson -Arguments @('backup', 'local', 'create') + Assert-JsonCommandSucceeded -Response $localBackup -Label 'backup local create' + if (-not (Test-Path $localBackup.path)) { + throw "backup local create did not write the reported backup path" + } + Add-Tested 'backup local create' + + Write-Stage 'Package discovery' + $fixtureState = @{} + foreach ($fixture in @($manifest.packageManagers)) { + $search = Invoke-CliJson -Arguments @('package', 'search', '--manager', [string]$fixture.manager, '--query', [string]$fixture.query, '--max-results', '20') + $details = Invoke-CliJson -Arguments (@('package', 'details') + (Get-PackageArguments -Fixture $fixture -IncludeSource)) + $versions = Invoke-CliJson -Arguments (@('package', 'versions') + (Get-PackageArguments -Fixture $fixture -IncludeSource)) + if (@($versions.versions | Where-Object { $_ -eq $fixture.installVersion }).Count -eq 0) { + throw "package versions did not include $($fixture.installVersion) for $($fixture.packageId) on $($fixture.manager)" + } + if ($details.package.id -ne $fixture.packageId) { + throw "package details did not return $($fixture.packageId) for $($fixture.manager)" + } + $latestVersion = Get-LatestFixtureVersion -Fixture $fixture -SearchResponse $search -VersionResponse $versions + $downloadDirectory = Join-Path $downloadRoot (([string]$fixture.manager -replace '[^A-Za-z0-9._-]', '_') + '-sync') + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + $download = Invoke-CliJson -Arguments (@('package', 'download') + (Get-PackageArguments -Fixture $fixture -IncludeSource) + @('--output', $downloadDirectory)) + Assert-JsonCommandSucceeded -Response $download -Label "package download $($fixture.packageId)" + if ([string]::IsNullOrWhiteSpace($download.outputPath) -or -not (Test-Path $download.outputPath)) { + throw "package download did not create an artifact for $($fixture.packageId) on $($fixture.manager)" + } + + $fixtureState[[string]$fixture.manager] = [ordered]@{ + fixture = $fixture + latestVersion = $latestVersion + } + } + Add-Tested 'package search' + Add-Tested 'package details' + Add-Tested 'package versions' + Add-Tested 'package download' + + Write-Stage 'Bundle roundtrip and bundle install' + $resetBundle = Invoke-CliJson -Arguments @('bundle', 'reset') + Assert-JsonCommandSucceeded -Response $resetBundle -Label 'bundle reset' + $bundleAfterReset = Invoke-CliJson -Arguments @('bundle', 'get') + if ($bundleAfterReset.bundle.packageCount -ne 0) { + throw "bundle get did not return an empty bundle after reset" + } + $addBundlePackage = Invoke-CliJson -Arguments (@('bundle', 'add') + (Get-PackageArguments -Fixture $bundleFixture -IncludeVersion -IncludeSource) + @('--selection', 'search')) + if ($addBundlePackage.package.id -ne $bundleFixture.packageId) { + throw "bundle add did not add $($bundleFixture.packageId)" + } + $bundle = Invoke-CliJson -Arguments @('bundle', 'get') + if (@($bundle.bundle.packages | Where-Object { $_.id -eq $bundleFixture.packageId -and $_.selectedVersion -eq $bundleFixture.installVersion }).Count -eq 0) { + throw "bundle get did not report the selected bundle package" + } + $exportedBundle = Invoke-CliJson -Arguments @('bundle', 'export') + if ([string]::IsNullOrWhiteSpace($exportedBundle.content)) { + throw "bundle export returned no content" + } + $bundleRoundtripPath = Join-Path $daemonRoot 'BundleRoundtrip.json' + Set-Content -Path $bundleRoundtripPath -Value $exportedBundle.content -Encoding UTF8 + $removeBundlePackage = Invoke-CliJson -Arguments (@('bundle', 'remove') + (Get-PackageArguments -Fixture $bundleFixture -IncludeSource)) + if ($removeBundlePackage.removedCount -lt 1) { + throw "bundle remove did not remove $($bundleFixture.packageId)" + } + $importBundle = Invoke-CliJson -Arguments @('bundle', 'import', '--path', $bundleRoundtripPath) + Assert-JsonCommandSucceeded -Response $importBundle -Label 'bundle import' + $bundleInstall = Invoke-CliJson -Arguments @('bundle', 'install') + if ($bundleInstall.status -ne 'success' -or @($bundleInstall.results | Where-Object { $_.package.id -eq $bundleFixture.packageId }).Count -eq 0) { + throw "bundle install did not report a successful package result: $($bundleInstall | ConvertTo-Json -Depth 10)" + } + Wait-ForInstalledPackage -Fixture $bundleFixture | Out-Null + Add-Tested 'bundle get' + Add-Tested 'bundle reset' + Add-Tested 'bundle add' + Add-Tested 'bundle remove' + Add-Tested 'bundle export' + Add-Tested 'bundle import' + Add-Tested 'bundle install' + + Write-Stage 'Operation queue control' + $queuedOperationIds = @() + foreach ($queueFixture in @($manifest.queueOperations)) { + $queueDownload = Invoke-CliJson -Arguments @( + 'package', 'download', + '--manager', [string]$queueFixture.manager, + '--id', [string]$queueFixture.packageId, + '--source', [string]$queueFixture.sourceName, + '--output', (Resolve-QueueOutputPath -QueueFixture $queueFixture), + '--wait', 'false' + ) + if ($queueDownload.status -ne 'success' -or $queueDownload.completed -or [string]::IsNullOrWhiteSpace($queueDownload.operationId)) { + throw "package download --wait false did not return an in-progress operation payload for $($queueFixture.packageId): $($queueDownload | ConvertTo-Json -Depth 10)" + } + $queuedOperationIds += [string]$queueDownload.operationId + } + + $queuedOperations = Wait-ForCliCondition ` + -Arguments @('operation', 'list') ` + -Condition { + param($response) + $targeted = @($response.operations | Where-Object { $queuedOperationIds -contains $_.id }) + $targeted.Count -eq $queuedOperationIds.Count + } ` + -FailureMessage 'operation list never reported the queued download operations.' ` + -TimeoutSeconds 180 ` + -DelaySeconds 2 + + $queuedOperation = @($queuedOperations.operations | Where-Object { + $queuedOperationIds -contains $_.id + })[0] + + $operationDetails = Invoke-CliJson -Arguments @('operation', 'get', '--id', $queuedOperation.id) + if ($operationDetails.operation.id -ne $queuedOperation.id) { + throw "operation get did not return the requested queued operation id" + } + $operationOutput = Invoke-CliJson -Arguments @('operation', 'output', '--id', $queuedOperation.id, '--tail', '10') + if ($operationOutput.output.operationId -ne $queuedOperation.id) { + throw "operation output did not return the requested queued operation id" + } + + foreach ($operationId in $queuedOperationIds) { + $waitedOperation = Invoke-CliJson -Arguments @('operation', 'wait', '--id', $operationId, '--timeout', '300', '--delay', '1') + if ($waitedOperation.operation.status -ne 'succeeded') { + throw "operation wait did not report success for operation ${operationId}: $($waitedOperation | ConvertTo-Json -Depth 10)" + } + } + + foreach ($operationId in $queuedOperationIds) { + $completedOutput = Invoke-CliJson -Arguments @('operation', 'output', '--id', $operationId) + if ($completedOutput.output.lineCount -lt 0) { + throw "operation output reported an invalid line count for operation $operationId" + } + $forget = Invoke-CliJson -Arguments @('operation', 'forget', '--id', $operationId) + Assert-JsonCommandSucceeded -Response $forget -Label "operation forget $operationId" + } + $operationsAfterForget = Invoke-CliJson -Arguments @('operation', 'list') + if (@($operationsAfterForget.operations | Where-Object { $queuedOperationIds -contains $_.id }).Count -ne 0) { + throw "operation forget did not remove all queued download operations" + } + Add-Tested 'operation list' + Add-Tested 'operation get' + Add-Tested 'operation output' + Add-Tested 'operation wait' + Add-Tested 'operation forget' + + Write-Stage 'Package lifecycle and updates' + $specificUpdateFixture = @($manifest.packageManagers | Where-Object { @($_.roles) -contains 'specific-update' })[0] + if ($null -eq $specificUpdateFixture) { + throw 'The CLI E2E manifest must define a package manager fixture with the specific-update role.' + } + $directInstallFixtures = @($manifest.packageManagers | Where-Object { + $null -ne $specificUpdateFixture -and [string]$_.manager -eq [string]$specificUpdateFixture.manager + }) + $otherInstallFixtures = @($manifest.packageManagers | Where-Object { + [string]$_.manager -ne [string]$bundleFixture.manager -and + [string]$_.manager -ne [string]$specificUpdateFixture.manager + }) + $directInstallFixtures += $otherInstallFixtures + $bundleLatestVersion = [string]$fixtureState[$bundleFixture.manager].latestVersion + + foreach ($installFixture in $directInstallFixtures) { + $installResult = Invoke-CliJson -Arguments (@('package', 'install') + (Get-PackageArguments -Fixture $installFixture -IncludeVersion -IncludeSource)) + Assert-JsonCommandSucceeded -Response $installResult -Label "package install $($installFixture.manager)" + Wait-ForInstalledPackage -Fixture $installFixture -ExpectedVersion $installFixture.installVersion | Out-Null + } + Add-Tested 'package install' + Add-Tested 'package installed' + + $updateDiscoveryFixtures = @($manifest.packageManagers | Where-Object { @($_.roles) -contains 'update-discovery' }) + foreach ($updateDiscoveryFixture in $updateDiscoveryFixtures) { + $reloadManager = Invoke-CliJson -Arguments @('manager', 'reload', '--manager', [string]$updateDiscoveryFixture.manager) + if ($reloadManager.operationStatus -ne 'completed') { + throw "manager reload before package updates did not complete successfully for $($updateDiscoveryFixture.manager): $($reloadManager | ConvertTo-Json -Depth 8)" + } + } + foreach ($updateDiscoveryFixture in $updateDiscoveryFixtures) { + Wait-ForPackageUpdateVisibility -Fixture $updateDiscoveryFixture | Out-Null + } + $allUpdates = Invoke-CliJson -Arguments @('package', 'updates') + if ($null -eq $allUpdates.updates) { + throw "package updates did not return an updates payload" + } + Add-Tested 'package updates' + + $ignoredAdd = Invoke-CliJson -Arguments @('package', 'ignored', 'add', '--manager', [string]$bundleFixture.manager, '--id', [string]$bundleFixture.packageId) + Assert-JsonCommandSucceeded -Response $ignoredAdd -Label 'package ignored add' + $ignoredList = Invoke-CliJson -Arguments @('package', 'ignored', 'list') + if (@($ignoredList.ignoredUpdates | Where-Object { $_.packageId -eq $bundleFixture.packageId }).Count -eq 0) { + throw "package ignored list did not report the ignored dotnet-tool fixture" + } + $ignoredRemove = Invoke-CliJson -Arguments @('package', 'ignored', 'remove', '--manager', [string]$bundleFixture.manager, '--id', [string]$bundleFixture.packageId) + Assert-JsonCommandSucceeded -Response $ignoredRemove -Label 'package ignored remove' + Add-Tested 'package ignored list' + Add-Tested 'package ignored add' + Add-Tested 'package ignored remove' + + $specificUpdate = Invoke-CliJson -Arguments (@( + 'package', 'update' + ) + (Get-PackageArguments -Fixture $specificUpdateFixture -IncludeSource) + @( + '--version', [string]$fixtureState[$specificUpdateFixture.manager].latestVersion + )) + Assert-JsonCommandSucceeded -Response $specificUpdate -Label "package update $($specificUpdateFixture.manager)" + Wait-ForInstalledPackage -Fixture $specificUpdateFixture -ExpectedVersion ([string]$fixtureState[$specificUpdateFixture.manager].latestVersion) | Out-Null + Add-Tested 'package update' + + $updateManager = Invoke-CliFailure -Arguments @('package', 'update-manager', '--manager', [string]$bundleFixture.manager) + if ($updateManager.Text -notmatch 'cannot update manager packages') { + throw "package update-manager did not report the expected headless limitation: $($updateManager.Text)" + } + Add-Tested 'package update-manager' 'expected-failure' + + $updateAll = Invoke-CliFailure -Arguments @('package', 'update-all') + if ($updateAll.Text -notmatch 'cannot update all packages') { + throw "package update-all did not report the expected headless limitation: $($updateAll.Text)" + } + Add-Tested 'package update-all' 'expected-failure' + + $reinstall = Invoke-CliJson -Arguments (@('package', 'reinstall') + (Get-PackageArguments -Fixture $bundleFixture -IncludeSource)) + Assert-JsonCommandSucceeded -Response $reinstall -Label 'package reinstall' + Wait-ForInstalledPackage -Fixture $bundleFixture -ExpectedVersion $bundleLatestVersion | Out-Null + Add-Tested 'package reinstall' + + $repair = Invoke-CliJson -Arguments (@('package', 'repair') + (Get-PackageArguments -Fixture $bundleFixture -IncludeSource)) + Assert-JsonCommandSucceeded -Response $repair -Label 'package repair' + Wait-ForInstalledPackage -Fixture $bundleFixture -ExpectedVersion $bundleLatestVersion | Out-Null + Add-Tested 'package repair' + + $installedAll = Invoke-CliJson -Arguments @('package', 'installed') + foreach ($fixture in @($manifest.packageManagers)) { + if (@($installedAll.packages | Where-Object { $_.id -eq $fixture.packageId }).Count -eq 0) { + throw "package installed did not report $($fixture.packageId) after lifecycle operations" + } + } + + Write-Stage 'Logs' + $operationHistory = Invoke-CliJson -Arguments @('log', 'operations') + if ($null -eq $operationHistory.history) { + throw "log operations did not return a history payload" + } + $managerLog = Wait-ForCliCondition ` + -Arguments @('log', 'manager', '--manager', [string]$bundleFixture.manager, '--verbose') ` + -Condition { + param($response) + @( + $response.managers | + Where-Object { + $_.name -eq $bundleFixture.manager -and + @($_.tasks | Where-Object { @($_.lines | Where-Object { $_ -match $bundleFixture.packageId }).Count -gt 0 }).Count -gt 0 + } + ).Count -gt 0 + } ` + -FailureMessage "log manager did not capture package activity for $($bundleFixture.packageId)" ` + -TimeoutSeconds 180 ` + -DelaySeconds 3 + Add-Tested 'log operations' + Add-Tested 'log manager' + + Write-Stage 'Package uninstall' + $uninstallValidationFixtures = @($manifest.packageManagers) + if ($null -ne $manifest.PSObject.Properties['uninstallValidationManagers']) { + $uninstallValidationManagerNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($managerName in @($manifest.uninstallValidationManagers)) { + if (-not [string]::IsNullOrWhiteSpace([string]$managerName)) { + [void]$uninstallValidationManagerNames.Add([string]$managerName) + } + } + + $uninstallValidationFixtures = @($manifest.packageManagers | Where-Object { + $uninstallValidationManagerNames.Contains([string]$_.manager) + }) + } + foreach ($fixture in $uninstallValidationFixtures) { + $uninstall = Invoke-CliJson -Arguments (@('package', 'uninstall') + (Get-PackageArguments -Fixture $fixture -IncludeSource)) + Assert-JsonCommandSucceeded -Response $uninstall -Label "package uninstall $($fixture.packageId)" + if (-not [string]::IsNullOrWhiteSpace($uninstall.operationId)) { + $waitedUninstall = Invoke-CliJson -Arguments @('operation', 'wait', '--id', [string]$uninstall.operationId, '--timeout', '300', '--delay', '1') + if ($waitedUninstall.operation.status -ne 'succeeded') { + throw "package uninstall did not complete successfully for $($fixture.packageId): $($waitedUninstall | ConvertTo-Json -Depth 10)" + } + } + $reloadManager = Invoke-CliJson -Arguments @('manager', 'reload', '--manager', [string]$fixture.manager) + if ($reloadManager.operationStatus -ne 'completed') { + throw "manager reload after uninstall did not complete successfully for $($fixture.manager): $($reloadManager | ConvertTo-Json -Depth 8)" + } + Wait-ForPackageRemoval -Fixture $fixture | Out-Null + } + if ($uninstallValidationFixtures.Count -gt 0) { + Add-Tested 'package uninstall' + } + + Write-Stage 'Settings reset and shutdown' + $clearFreshValue = Invoke-CliJson -Arguments @('settings', 'clear', '--key', 'FreshValue') + if ($clearFreshValue.setting.isSet) { + throw "settings clear did not clear FreshValue" + } + $disableFreshBool = Invoke-CliJson -Arguments @('settings', 'set', '--key', 'FreshBoolSetting', '--enabled', 'false') + if ($disableFreshBool.setting.boolValue) { + throw "settings set did not disable FreshBoolSetting" + } + Add-Tested 'settings clear' + + $resetSettings = Invoke-CliJson -Arguments @('settings', 'reset') + Assert-JsonCommandSucceeded -Response $resetSettings -Label 'settings reset' + Add-Tested 'settings reset' + + $postResetStatus = Invoke-CliJson -Arguments @('status') + if (-not $postResetStatus.running) { + throw "settings reset broke the active IPC session" + } + + $quitApp = Invoke-CliJson -Arguments @('app', 'quit') + Assert-JsonCommandSucceeded -Response $quitApp -Label 'app quit' + Add-Tested 'app quit' + + $quitDeadline = (Get-Date).AddSeconds(30) + while (-not $process.HasExited -and (Get-Date) -lt $quitDeadline) { + Start-Sleep -Seconds 1 + } + + if (-not $process.HasExited) { + throw "app quit did not stop the headless daemon" + } + + $gracefulShutdown = $true +} +finally { + $coverage.status = if ($gracefulShutdown) { 'success' } else { 'failed' } + Set-Content -Path $coveragePath -Value ($coverage | ConvertTo-Json -Depth 12) -Encoding UTF8 + + if (-not $gracefulShutdown) { + Stop-Daemon + } + + $daemonLog = Get-DaemonLog + if (-not [string]::IsNullOrWhiteSpace($daemonLog)) { + Write-Host '--- Headless daemon log ---' + Write-Host $daemonLog + } + + if (-not $preserveArtifacts) { + Remove-Item -Recurse -Force $daemonRoot -ErrorAction SilentlyContinue + } +} From 3e304a20c203fee5e7250e96c6003358300802a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 7 May 2026 09:35:54 -0400 Subject: [PATCH 2/3] ci: remove push trigger from cli-headless-e2e to avoid duplicate runs on PRs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/cli-headless-e2e.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/cli-headless-e2e.yml b/.github/workflows/cli-headless-e2e.yml index 180a2d50cc..ad41b0f8ab 100644 --- a/.github/workflows/cli-headless-e2e.yml +++ b/.github/workflows/cli-headless-e2e.yml @@ -1,17 +1,6 @@ name: CLI Headless E2E on: - push: - paths: - - 'src/**/*.cs' - - 'src/**/*.csproj' - - 'src/**/*.props' - - 'src/**/*.targets' - - 'src/**/*.sln' - - 'src/**/*.slnx' - - 'testing/automation/**' - - '.github/workflows/cli-headless-e2e.yml' - - 'global.json' pull_request: branches: [ "main" ] paths: From 450260e1323d7f259429b14efba6bf4167c49f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 7 May 2026 09:37:08 -0400 Subject: [PATCH 3/3] ci: improve check names for cli-headless-e2e matrix jobs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/cli-headless-e2e.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli-headless-e2e.yml b/.github/workflows/cli-headless-e2e.yml index ad41b0f8ab..9e382a68ee 100644 --- a/.github/workflows/cli-headless-e2e.yml +++ b/.github/workflows/cli-headless-e2e.yml @@ -21,17 +21,20 @@ jobs: fail-fast: false matrix: include: - - os: windows-latest + - name: Windows (WinUI 3) + os: windows-latest solution: UniGetUI.Windows.slnx daemon_project: UniGetUI/UniGetUI.csproj daemon_build_args: '-p:Platform=x64' manifest: testing/automation/cli-e2e.manifest.windows.json - - os: ubuntu-latest + - name: Linux (Avalonia) + os: ubuntu-latest solution: UniGetUI.Avalonia.slnx daemon_project: UniGetUI.Avalonia/UniGetUI.Avalonia.csproj daemon_build_args: '' manifest: testing/automation/cli-e2e.manifest.linux.json + name: ${{ matrix.name }} runs-on: ${{ matrix.os }} env: CONFIGURATION: Release @@ -110,6 +113,6 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: cli-headless-e2e-${{ runner.os }} + name: cli-headless-e2e-${{ matrix.name }} path: artifacts/cli-headless-e2e/${{ runner.os }} if-no-files-found: warn