diff --git a/.github/workflows/cli-headless-e2e.yml b/.github/workflows/cli-headless-e2e.yml new file mode 100644 index 0000000000..9e382a68ee --- /dev/null +++ b/.github/workflows/cli-headless-e2e.yml @@ -0,0 +1,118 @@ +name: CLI Headless E2E + +on: + 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: + - 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 + - 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 + 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-${{ matrix.name }} + 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 + } +}