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