Skip to content

Commit 9c84b35

Browse files
Port CLI improvements to latest main
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b934313 commit 9c84b35

54 files changed

Lines changed: 10426 additions & 60 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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 headless daemon and CLI
63+
working-directory: src
64+
run: |
65+
dotnet build UniGetUI.Avalonia/UniGetUI.Avalonia.csproj --no-restore --configuration ${{ env.CONFIGURATION }} --verbosity minimal
66+
dotnet build UniGetUI.Cli/UniGetUI.Cli.csproj --no-restore --configuration ${{ env.CONFIGURATION }} --verbosity minimal
67+
68+
- name: Run headless CLI E2E
69+
shell: pwsh
70+
run: ./testing/automation/run-cli-e2e.ps1

cli-arguments.md

Lines changed: 88 additions & 0 deletions
Large diffs are not rendered by default.

src/UniGetUI.Avalonia.slnx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@
225225
<Platform Solution="*|x64" Project="x64" />
226226
</Project>
227227
</Folder>
228+
<Project Path="UniGetUI.Cli/UniGetUI.Cli.csproj">
229+
<Platform Solution="*|arm64" Project="arm64" />
230+
<Platform Solution="*|x64" Project="x64" />
231+
</Project>
228232
<Folder Name="/UniGetUI.PackageEngine.Tests/">
229233
<Project Path="UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj">
230234
<Platform Solution="*|arm64" Project="arm64" />

src/UniGetUI.Avalonia/App.axaml.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
using Avalonia.Platform;
77
using Avalonia.Styling;
88
using Avalonia.Threading;
9+
#if AVALONIA_DIAGNOSTICS_ENABLED
10+
using Avalonia.Diagnostics;
11+
#endif
912
using UniGetUI.Avalonia.Infrastructure;
1013
using UniGetUI.Avalonia.Views;
1114
using UniGetUI.Avalonia.Views.DialogPages;
@@ -55,12 +58,16 @@ public override void OnFrameworkInitializationCompleted()
5558
{
5659
if (OperatingSystem.IsMacOS())
5760
{
58-
ExpandMacOSPath();
61+
ProcessEnvironmentConfigurator.PrepareForCurrentPlatform();
5962
using var stream = AssetLoader.Open(new Uri("avares://UniGetUI.Avalonia/Assets/icon.png"));
6063
using var ms = new MemoryStream();
6164
stream.CopyTo(ms);
6265
MacOsNotificationBridge.SetDockIcon(ms.ToArray());
6366
}
67+
else
68+
{
69+
ProcessEnvironmentConfigurator.ApplyProxySettingsToProcess();
70+
}
6471
PEInterface.LoadLoaders();
6572
ApplyTheme(CoreSettings.GetValue(CoreSettings.K.PreferredTheme));
6673
var mainWindow = new MainWindow();

src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using UniGetUI.Interface.Telemetry;
1313
using UniGetUI.PackageEngine;
1414
using UniGetUI.PackageEngine.Classes.Manager.Classes;
15+
using UniGetUI.PackageEngine.Interfaces;
1516

1617
namespace UniGetUI.Avalonia.Infrastructure;
1718

@@ -96,7 +97,7 @@ private static async Task ShowMissingDependencyDialogAsync(
9697
private static Task InitializeSharedServicesAsync()
9798
{
9899
CoreTools.ReloadLanguageEngineInstance();
99-
MainWindow.ApplyProxyVariableToProcess();
100+
ProcessEnvironmentConfigurator.ApplyProxySettingsToProcess();
100101
_ = Task.Run(AvaloniaAutoUpdater.UpdateCheckLoopAsync)
101102
.ContinueWith(
102103
t => Logger.Error(t.Exception!),
@@ -153,6 +154,14 @@ private static async Task InitializeBackgroundApiAsync()
153154
return;
154155

155156
_backgroundApi = new BackgroundApiRunner();
157+
_backgroundApi.AppInfoProvider = () =>
158+
Dispatcher.UIThread.InvokeAsync(GetAppInfo).GetAwaiter().GetResult();
159+
_backgroundApi.ShowAppHandler = () =>
160+
Dispatcher.UIThread.InvokeAsync(ShowApp).GetAwaiter().GetResult();
161+
_backgroundApi.NavigateAppHandler = request =>
162+
Dispatcher.UIThread.InvokeAsync(() => NavigateApp(request)).GetAwaiter().GetResult();
163+
_backgroundApi.QuitAppHandler = () =>
164+
Dispatcher.UIThread.InvokeAsync(QuitApp).GetAwaiter().GetResult();
156165

157166
_backgroundApi.OnOpenWindow += (_, _) =>
158167
Dispatcher.UIThread.Post(() => MainWindow.Instance?.ShowFromTray());
@@ -186,6 +195,111 @@ private static async Task InitializeBackgroundApiAsync()
186195

187196
public static void StopBackgroundApi() => _backgroundApi?.Stop();
188197

198+
private static AutomationAppInfo GetAppInfo()
199+
{
200+
MainWindow? window = MainWindow.Instance;
201+
return new AutomationAppInfo
202+
{
203+
Headless = false,
204+
WindowAvailable = window is not null,
205+
WindowVisible = window?.IsVisible ?? false,
206+
CanShowWindow = window is not null,
207+
CanNavigate = window is not null,
208+
CanQuit = true,
209+
CurrentPage = window is null ? "" : AutomationAppPages.ToPageName(window.CurrentPage.ToString()),
210+
SupportedPages = AutomationAppPages.SupportedPages,
211+
};
212+
}
213+
214+
private static BackgroundApiCommandResult ShowApp()
215+
{
216+
MainWindow window = MainWindow.Instance
217+
?? throw new InvalidOperationException("The application window is not available.");
218+
window.ShowFromTray();
219+
return BackgroundApiCommandResult.Success("show-app");
220+
}
221+
222+
private static BackgroundApiCommandResult NavigateApp(AutomationAppNavigateRequest request)
223+
{
224+
MainWindow window = MainWindow.Instance
225+
?? throw new InvalidOperationException("The application window is not available.");
226+
string page = AutomationAppPages.NormalizePageName(request.Page);
227+
var manager = ResolveManager(request.ManagerName);
228+
229+
switch (page)
230+
{
231+
case "discover":
232+
window.Navigate(PageType.Discover);
233+
break;
234+
case "updates":
235+
window.Navigate(PageType.Updates);
236+
break;
237+
case "installed":
238+
window.Navigate(PageType.Installed);
239+
break;
240+
case "bundles":
241+
window.Navigate(PageType.Bundles);
242+
break;
243+
case "settings":
244+
window.Navigate(PageType.Settings);
245+
break;
246+
case "managers":
247+
window.OpenManagerSettings(manager);
248+
break;
249+
case "own-log":
250+
window.Navigate(PageType.OwnLog);
251+
break;
252+
case "manager-log":
253+
window.OpenManagerLogs(manager);
254+
break;
255+
case "operation-history":
256+
window.Navigate(PageType.OperationHistory);
257+
break;
258+
case "help":
259+
window.ShowHelp(request.HelpAttachment ?? "");
260+
break;
261+
case "release-notes":
262+
window.Navigate(PageType.ReleaseNotes);
263+
break;
264+
case "about":
265+
window.Navigate(PageType.About);
266+
break;
267+
default:
268+
throw new InvalidOperationException(
269+
$"Unsupported app page \"{request.Page}\"."
270+
);
271+
}
272+
273+
window.ShowFromTray();
274+
return BackgroundApiCommandResult.Success("navigate-app");
275+
}
276+
277+
private static BackgroundApiCommandResult QuitApp()
278+
{
279+
MainWindow window = MainWindow.Instance
280+
?? throw new InvalidOperationException("The application window is not available.");
281+
_ = Task.Run(async () =>
282+
{
283+
await Task.Delay(150);
284+
await Dispatcher.UIThread.InvokeAsync(window.QuitApplication);
285+
});
286+
return BackgroundApiCommandResult.Success("quit-app");
287+
}
288+
289+
private static IPackageManager? ResolveManager(string? managerName)
290+
{
291+
if (string.IsNullOrWhiteSpace(managerName))
292+
{
293+
return null;
294+
}
295+
296+
return PEInterface.Managers.FirstOrDefault(manager =>
297+
manager.Name.Equals(managerName, StringComparison.OrdinalIgnoreCase))
298+
?? throw new InvalidOperationException(
299+
$"Unknown manager \"{managerName}\"."
300+
);
301+
}
302+
189303
private static async Task LoadElevatorAsync()
190304
{
191305
try

src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using UniGetUI.Core.Logging;
1111
using UniGetUI.Core.SettingsEngine;
1212
using UniGetUI.Core.Tools;
13+
using UniGetUI.Interface;
1314
using UniGetUI.PackageEngine.Classes.Packages.Classes;
1415
using UniGetUI.PackageEngine.Enums;
1516
using UniGetUI.PackageOperations;
@@ -40,6 +41,7 @@ public static class AvaloniaOperationRegistry
4041
/// </summary>
4142
public static void Add(AbstractOperation op)
4243
{
44+
AutomationOperationApi.Track(op);
4345
var vm = new OperationViewModel(op);
4446

4547
Dispatcher.UIThread.Post(() =>
@@ -143,6 +145,10 @@ public static void Remove(OperationViewModel vm)
143145
UpdateTrayStatus();
144146
});
145147
while (AbstractOperation.OperationQueue.Remove(vm.Operation)) ;
148+
if (vm.Operation.Status is not (OperationStatus.InQueue or OperationStatus.Running))
149+
{
150+
AutomationOperationApi.ForgetTracking(vm.Operation.Metadata.Identifier);
151+
}
146152
}
147153

148154
private static async Task RemoveAfterDelayAsync(AbstractOperation op, int milliseconds)
@@ -158,6 +164,11 @@ private static async Task RemoveAfterDelayAsync(AbstractOperation op, int millis
158164
if (vm is not null) OperationViewModels.Remove(vm);
159165
Operations.Remove(op);
160166
UpdateTrayStatus();
167+
if (op.Status is not (OperationStatus.InQueue or OperationStatus.Running))
168+
{
169+
AutomationOperationApi.ForgetTracking(op.Metadata.Identifier);
170+
}
171+
UpdateTrayStatus();
161172
});
162173
}
163174

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
void RequestShutdown()
14+
{
15+
if (!shutdown.IsCancellationRequested)
16+
{
17+
shutdown.Cancel();
18+
}
19+
}
20+
21+
ConsoleCancelEventHandler cancelHandler = (_, eventArgs) =>
22+
{
23+
eventArgs.Cancel = true;
24+
RequestShutdown();
25+
};
26+
Console.CancelKeyPress += cancelHandler;
27+
28+
EventHandler processExitHandler = (_, _) => RequestShutdown();
29+
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
30+
31+
try
32+
{
33+
Logger.Info("Starting UniGetUI headless daemon");
34+
35+
ProcessEnvironmentConfigurator.PrepareForCurrentPlatform();
36+
PEInterface.LoadLoaders();
37+
await Task.Run(PEInterface.LoadManagers);
38+
39+
backgroundApi = new BackgroundApiRunner();
40+
backgroundApi.AppInfoProvider = () =>
41+
new AutomationAppInfo
42+
{
43+
Headless = true,
44+
WindowAvailable = false,
45+
WindowVisible = false,
46+
CanShowWindow = false,
47+
CanNavigate = false,
48+
CanQuit = true,
49+
SupportedPages = AutomationAppPages.SupportedPages,
50+
};
51+
backgroundApi.ShowAppHandler = () =>
52+
throw new InvalidOperationException(
53+
"The current UniGetUI session is running headless and has no window to show."
54+
);
55+
backgroundApi.NavigateAppHandler = _ =>
56+
throw new InvalidOperationException(
57+
"The current UniGetUI session is running headless and cannot navigate UI pages."
58+
);
59+
backgroundApi.QuitAppHandler = () =>
60+
{
61+
_ = Task.Run(async () =>
62+
{
63+
await Task.Delay(150);
64+
shutdown.Cancel();
65+
});
66+
return BackgroundApiCommandResult.Success("quit-app");
67+
};
68+
await backgroundApi.Start();
69+
70+
Logger.Info("UniGetUI headless daemon is ready");
71+
await WaitForShutdownAsync(shutdown.Token);
72+
return 0;
73+
}
74+
catch (Exception ex)
75+
{
76+
Logger.Error("UniGetUI headless daemon failed to start");
77+
Logger.Error(ex);
78+
return ex.HResult != 0 ? ex.HResult : 1;
79+
}
80+
finally
81+
{
82+
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
83+
Console.CancelKeyPress -= cancelHandler;
84+
85+
if (backgroundApi is not null)
86+
{
87+
await backgroundApi.Stop();
88+
}
89+
}
90+
}
91+
92+
private static Task WaitForShutdownAsync(CancellationToken cancellationToken)
93+
{
94+
if (cancellationToken.IsCancellationRequested)
95+
{
96+
return Task.CompletedTask;
97+
}
98+
99+
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
100+
cancellationToken.Register(() => completion.TrySetResult());
101+
return completion.Task;
102+
}
103+
}

0 commit comments

Comments
 (0)