Skip to content

Commit ff0823e

Browse files
Add headless automation daemon and CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 13f338c commit ff0823e

25 files changed

+2215
-37
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: CLI Headless E2E
2+
3+
on:
4+
push:
5+
paths:
6+
- 'src/**/*.cs'
7+
- 'src/**/*.csproj'
8+
- 'src/**/*.props'
9+
- 'src/**/*.targets'
10+
- 'src/**/*.sln'
11+
- 'src/**/*.slnx'
12+
- 'testing/automation/**'
13+
- '.github/workflows/cli-headless-e2e.yml'
14+
- 'global.json'
15+
pull_request:
16+
branches: [ "main" ]
17+
paths:
18+
- 'src/**/*.cs'
19+
- 'src/**/*.csproj'
20+
- 'src/**/*.props'
21+
- 'src/**/*.targets'
22+
- 'src/**/*.sln'
23+
- 'src/**/*.slnx'
24+
- 'testing/automation/**'
25+
- '.github/workflows/cli-headless-e2e.yml'
26+
- 'global.json'
27+
workflow_dispatch:
28+
29+
jobs:
30+
cli-headless-e2e:
31+
strategy:
32+
fail-fast: false
33+
matrix:
34+
os: [windows-latest, macos-latest, ubuntu-latest]
35+
36+
runs-on: ${{ matrix.os }}
37+
env:
38+
CONFIGURATION: Release
39+
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
40+
41+
steps:
42+
- name: Checkout repository
43+
uses: actions/checkout@v6
44+
45+
- name: Setup .NET SDK
46+
uses: actions/setup-dotnet@v5
47+
with:
48+
global-json-file: global.json
49+
50+
- name: Cache NuGet packages
51+
uses: actions/cache@v5
52+
with:
53+
path: ${{ env.NUGET_PACKAGES }}
54+
key: ${{ runner.os }}-nuget-e2e-${{ hashFiles('global.json', 'src/**/*.csproj', 'src/**/*.props', 'src/**/*.targets', 'src/**/*.sln', 'src/**/*.slnx') }}
55+
restore-keys: |
56+
${{ runner.os }}-nuget-e2e-
57+
58+
- name: Restore Avalonia solution
59+
working-directory: src
60+
run: dotnet restore UniGetUI.Avalonia.slnx
61+
62+
- name: Build Avalonia solution
63+
working-directory: src
64+
run: dotnet build UniGetUI.Avalonia.slnx --no-restore --configuration ${{ env.CONFIGURATION }} --verbosity minimal
65+
66+
- name: Run headless CLI E2E
67+
shell: pwsh
68+
run: ./testing/automation/run-cli-e2e.ps1

cli-arguments.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,38 @@
1717
| `--no-corrupt-dialog` | Will show a verbose error message (the error report) instead of a simplified message dialog | 3.2.1+ |
1818
| `--[enable\|disable]-secure-setting-for-user username key` | Enables/disables the given secure setting for the given key<sup>2</sup> and username. Requires administrator rights. | 3.2.1+ |
1919
| `--[enable\|disable]-secure-setting key` | Enables/disables the given secure setting<sup>2</sup> for current user. This will generate a UAC prompt | 3.2.1+ |
20+
| `--headless` | Starts the Avalonia host as a pure automation daemon with **no UI** and no requirement for a working graphical environment. Compatible with `--background-api-*` transport arguments. | 2026.1+ |
21+
| `--automation status` | Queries the local automation service and returns machine-readable status, including the configured background API transport | 2026.1+ |
22+
| `--automation get-version` | Reads the local automation service build number through the background API | 2026.1+ |
23+
| `--automation get-updates` | Reads the currently available updates through the local automation service and returns structured JSON | 2026.1+ |
24+
| `--automation list-installed --manager name` | Lists installed packages for the selected manager through the automation service and returns structured JSON | 2026.1+ |
25+
| `--automation search-packages --manager name --query text [--max-results n]` | Searches packages through the automation service and returns structured JSON | 2026.1+ |
26+
| `--automation install-package --manager name --package-id id [--version v] [--scope scope] [--pre-release]` | Installs a package through the automation service and waits for completion | 2026.1+ |
27+
| `--automation open-window` | Asks the running UniGetUI instance to show the main window | 2026.1+ |
28+
| `--automation open-updates` | Asks the running UniGetUI instance to show the Updates page | 2026.1+ |
29+
| `--automation show-package --package-id id --package-source source` | Opens the package details flow for the specified package | 2026.1+ |
30+
| `--automation update-all` | Queues updates for all packages currently shown as upgradable | 2026.1+ |
31+
| `--automation update-manager --manager name` | Queues updates for all packages handled by the specified manager | 2026.1+ |
32+
| `--automation update-package --manager name --package-id id` | Updates a specific package through the automation service and waits for completion | 2026.1+ |
33+
| `--automation uninstall-package --manager name --package-id id [--scope scope]` | Uninstalls a package through the automation service and waits for completion | 2026.1+ |
34+
| `--background-api-transport {tcp\|named-pipe}` | Selects which local HTTP transport UniGetUI uses for the background API when the app starts | 2026.1+ |
35+
| `--background-api-port port` | Overrides the localhost TCP port used by the background API when `--background-api-transport tcp` is active | 2026.1+ |
36+
| `--background-api-pipe-name name` | Overrides the Windows named pipe name used by the background API when `--background-api-transport named-pipe` is active | 2026.1+ |
37+
| `--transport {tcp\|named-pipe}` | Overrides the client-side automation transport used by `--automation ...` commands | 2026.1+ |
38+
| `--tcp-port port` | Overrides the client-side localhost TCP port used by `--automation ...` commands | 2026.1+ |
39+
| `--pipe-name name` | Overrides the client-side named pipe used by `--automation ...` commands | 2026.1+ |
2040

2141
1. See the available list of setting keys [here](https://github.com/Devolutions/UniGetUI/blob/fc98f312a72b80e14a8ac10687f4fc506a5c9cc4/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs#L5)
2242
2. See the available list of secure settings keys [here](https://github.com/Devolutions/UniGetUI/blob/fc98f312a72b80e14a8ac10687f4fc506a5c9cc4/src/UniGetUI.Core.SecureSettings/SecureSettings.cs#L10)
2343

2444

2545
\*After modifying the settings, you must ensure that any running instance of UniGetUI is restarted for the changes to take effect
2646

47+
## Headless automation daemon and cross-platform CLI
48+
49+
- `dotnet src\UniGetUI.Avalonia\bin\Release\net10.0\UniGetUI.Avalonia.dll --headless` starts the local automation daemon without opening any window or requiring a graphical desktop session.
50+
- `dotnet src\UniGetUI.Cli\bin\Release\net10.0\UniGetUI.Cli.dll <command>` is the cross-platform CLI wrapper for the automation service. It automatically prepends `--automation`, so `UniGetUI.Cli status` and `UniGetUI.Cli search-packages --manager ".NET Tool" --query dotnetsay` work directly.
51+
2752
<br><br>
2853
# `unigetui://` deep link
2954
On a system where UniGetUI 3.1.2+ is installed, the following deep links can be used to communicate with UniGetUI:

src/UniGetUI.Avalonia.slnx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,8 @@
213213
<Platform Solution="*|x64" Project="x64" />
214214
</Project>
215215
</Folder>
216+
<Project Path="UniGetUI.Cli/UniGetUI.Cli.csproj">
217+
<Platform Solution="*|arm64" Project="arm64" />
218+
<Platform Solution="*|x64" Project="x64" />
219+
</Project>
216220
</Solution>

src/UniGetUI.Avalonia/App.axaml.cs

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Diagnostics;
21
using Avalonia;
32
using Avalonia.Controls.ApplicationLifetimes;
43
using Avalonia.Markup.Xaml;
@@ -37,12 +36,16 @@ public override void OnFrameworkInitializationCompleted()
3736
{
3837
if (OperatingSystem.IsMacOS())
3938
{
40-
ExpandMacOSPath();
39+
ProcessEnvironmentConfigurator.PrepareForCurrentPlatform();
4140
using var stream = AssetLoader.Open(new Uri("avares://UniGetUI.Avalonia/Assets/icon.png"));
4241
using var ms = new MemoryStream();
4342
stream.CopyTo(ms);
4443
MacOsNotificationBridge.SetDockIcon(ms.ToArray());
4544
}
45+
else
46+
{
47+
ProcessEnvironmentConfigurator.ApplyProxySettingsToProcess();
48+
}
4649
PEInterface.LoadLoaders();
4750
ApplyTheme(CoreSettings.GetValue(CoreSettings.K.PreferredTheme));
4851
var mainWindow = new MainWindow();
@@ -53,33 +56,6 @@ public override void OnFrameworkInitializationCompleted()
5356
base.OnFrameworkInitializationCompleted();
5457
}
5558

56-
/// <summary>
57-
/// macOS GUI apps start with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin).
58-
/// Ask the user's login shell for its full PATH so package managers (npm, pip,
59-
/// cargo, brew-installed tools, …) can be found.
60-
/// </summary>
61-
private static void ExpandMacOSPath()
62-
{
63-
try
64-
{
65-
using var process = new Process
66-
{
67-
StartInfo = new ProcessStartInfo("zsh", ["-l", "-c", "printenv PATH"])
68-
{
69-
UseShellExecute = false,
70-
RedirectStandardOutput = true,
71-
CreateNoWindow = true,
72-
},
73-
};
74-
process.Start();
75-
string shellPath = process.StandardOutput.ReadToEnd().Trim();
76-
process.WaitForExit(5000);
77-
if (!string.IsNullOrEmpty(shellPath))
78-
Environment.SetEnvironmentVariable("PATH", shellPath);
79-
}
80-
catch { /* keep the existing PATH if the shell can't be launched */ }
81-
}
82-
8359
public static void ApplyTheme(string value)
8460
{
8561
Current!.RequestedThemeVariant = value switch

src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ await Task.WhenAll(
4040
private static Task InitializeSharedServicesAsync()
4141
{
4242
CoreTools.ReloadLanguageEngineInstance();
43-
MainWindow.ApplyProxyVariableToProcess();
43+
ProcessEnvironmentConfigurator.ApplyProxySettingsToProcess();
4444
_ = Task.Run(AvaloniaAutoUpdater.UpdateCheckLoopAsync)
4545
.ContinueWith(
4646
t => Logger.Error(t.Exception!),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using UniGetUI.Core.Logging;
2+
using UniGetUI.Interface;
3+
using UniGetUI.PackageEngine;
4+
5+
namespace UniGetUI.Avalonia.Infrastructure;
6+
7+
internal static class HeadlessDaemonHost
8+
{
9+
public static async Task<int> RunAsync(string[] args)
10+
{
11+
BackgroundApiRunner? backgroundApi = null;
12+
using var shutdown = new CancellationTokenSource();
13+
14+
Console.CancelKeyPress += (_, eventArgs) =>
15+
{
16+
eventArgs.Cancel = true;
17+
shutdown.Cancel();
18+
};
19+
20+
AppDomain.CurrentDomain.ProcessExit += (_, _) => shutdown.Cancel();
21+
22+
try
23+
{
24+
Logger.Info("Starting UniGetUI headless daemon");
25+
26+
ProcessEnvironmentConfigurator.PrepareForCurrentPlatform();
27+
PEInterface.LoadLoaders();
28+
await Task.Run(PEInterface.LoadManagers);
29+
30+
backgroundApi = new BackgroundApiRunner();
31+
await backgroundApi.Start();
32+
33+
Logger.Info("UniGetUI headless daemon is ready");
34+
await WaitForShutdownAsync(shutdown.Token);
35+
return 0;
36+
}
37+
catch (Exception ex)
38+
{
39+
Logger.Error("UniGetUI headless daemon failed to start");
40+
Logger.Error(ex);
41+
return ex.HResult != 0 ? ex.HResult : 1;
42+
}
43+
finally
44+
{
45+
if (backgroundApi is not null)
46+
{
47+
await backgroundApi.Stop();
48+
}
49+
}
50+
}
51+
52+
private static Task WaitForShutdownAsync(CancellationToken cancellationToken)
53+
{
54+
if (cancellationToken.IsCancellationRequested)
55+
{
56+
return Task.CompletedTask;
57+
}
58+
59+
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
60+
cancellationToken.Register(() => completion.TrySetResult());
61+
return completion.Task;
62+
}
63+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace UniGetUI.Avalonia.Infrastructure;
2+
3+
internal static class HeadlessModeOptions
4+
{
5+
public const string HeadlessArgument = "--headless";
6+
public const string DaemonArgument = "--daemon";
7+
8+
public static bool IsHeadless(IReadOnlyList<string> args)
9+
{
10+
return args.Contains(HeadlessArgument, StringComparer.OrdinalIgnoreCase)
11+
|| args.Contains(DaemonArgument, StringComparer.OrdinalIgnoreCase);
12+
}
13+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using System.Diagnostics;
2+
using UniGetUI.Core.Logging;
3+
using UniGetUI.Core.SettingsEngine;
4+
5+
namespace UniGetUI.Avalonia.Infrastructure;
6+
7+
internal static class ProcessEnvironmentConfigurator
8+
{
9+
public static void PrepareForCurrentPlatform()
10+
{
11+
if (OperatingSystem.IsMacOS())
12+
{
13+
ExpandMacOSPath();
14+
}
15+
16+
ApplyProxySettingsToProcess();
17+
}
18+
19+
public static void ApplyProxySettingsToProcess()
20+
{
21+
try
22+
{
23+
var proxyUri = Settings.GetProxyUrl();
24+
if (proxyUri is null || !Settings.Get(Settings.K.EnableProxy))
25+
{
26+
Environment.SetEnvironmentVariable("HTTP_PROXY", "", EnvironmentVariableTarget.Process);
27+
return;
28+
}
29+
30+
string content;
31+
if (!Settings.Get(Settings.K.EnableProxyAuth))
32+
{
33+
content = proxyUri.ToString();
34+
}
35+
else
36+
{
37+
var creds = Settings.GetProxyCredentials();
38+
if (creds is null)
39+
{
40+
content = proxyUri.ToString();
41+
}
42+
else
43+
{
44+
content = $"{proxyUri.Scheme}://{Uri.EscapeDataString(creds.UserName)}"
45+
+ $":{Uri.EscapeDataString(creds.Password)}"
46+
+ $"@{proxyUri.AbsoluteUri.Replace($"{proxyUri.Scheme}://", "")}";
47+
}
48+
}
49+
50+
Environment.SetEnvironmentVariable("HTTP_PROXY", content, EnvironmentVariableTarget.Process);
51+
}
52+
catch (Exception ex)
53+
{
54+
Logger.Error("Failed to apply proxy settings:");
55+
Logger.Error(ex);
56+
}
57+
}
58+
59+
private static void ExpandMacOSPath()
60+
{
61+
try
62+
{
63+
using var process = new Process
64+
{
65+
StartInfo = new ProcessStartInfo("zsh", ["-l", "-c", "printenv PATH"])
66+
{
67+
UseShellExecute = false,
68+
RedirectStandardOutput = true,
69+
CreateNoWindow = true,
70+
},
71+
};
72+
process.Start();
73+
string shellPath = process.StandardOutput.ReadToEnd().Trim();
74+
process.WaitForExit(5000);
75+
if (!string.IsNullOrEmpty(shellPath))
76+
{
77+
Environment.SetEnvironmentVariable("PATH", shellPath);
78+
}
79+
}
80+
catch
81+
{
82+
// Keep the existing PATH if the shell can't be launched.
83+
}
84+
}
85+
}

src/UniGetUI.Avalonia/Program.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using Avalonia;
3+
using UniGetUI.Avalonia.Infrastructure;
34

45
namespace UniGetUI.Avalonia;
56

@@ -9,8 +10,16 @@ sealed class Program
910
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
1011
// yet and stuff might break.
1112
[STAThread]
12-
public static void Main(string[] args) => BuildAvaloniaApp()
13-
.StartWithClassicDesktopLifetime(args);
13+
public static void Main(string[] args)
14+
{
15+
if (HeadlessModeOptions.IsHeadless(args))
16+
{
17+
Environment.ExitCode = HeadlessDaemonHost.RunAsync(args).GetAwaiter().GetResult();
18+
return;
19+
}
20+
21+
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
22+
}
1423

1524
// Avalonia configuration, don't remove; also used by visual designer.
1625
public static AppBuilder BuildAvaloniaApp()

src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@
9494
<Compile Include="Infrastructure\AvaloniaPackageOperationHelper.cs" />
9595
<Compile Include="Infrastructure\GitHubAuthService.cs" />
9696
<Compile Include="Infrastructure\GitHubCloudBackupService.cs" />
97+
<Compile Include="Infrastructure\HeadlessDaemonHost.cs" />
98+
<Compile Include="Infrastructure\HeadlessModeOptions.cs" />
99+
<Compile Include="Infrastructure\ProcessEnvironmentConfigurator.cs" />
97100
<Compile Include="Infrastructure\RelayCommand.cs" />
98101
<Compile Include="Infrastructure\SingleInstanceRedirector.cs" />
99102
<Compile Include="Infrastructure\UninstallConfirmationDialog.cs" />

0 commit comments

Comments
 (0)