Skip to content

Commit 6f7727e

Browse files
Add app lifecycle automation parity
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5a04cb5 commit 6f7727e

File tree

12 files changed

+664
-13
lines changed

12 files changed

+664
-13
lines changed

cli-arguments.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
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+ |
2020
| `--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+ |
2121
| `--automation status` | Queries the local automation service and returns machine-readable status, including the configured background API transport | 2026.1+ |
22+
| `--automation get-app-state` | Returns app/session automation state including headless mode, current page when a window exists, and which app-level actions are supported by the current host | 2026.1+ |
23+
| `--automation show-app` | Asks the running UniGetUI instance to show and focus its main window when a UI session exists | 2026.1+ |
24+
| `--automation navigate-app --page {discover\|updates\|installed\|bundles\|settings\|managers\|own-log\|manager-log\|operation-history\|help\|release-notes\|about} [--manager name] [--help-attachment path]` | Navigates the running UI session to a top-level destination, with optional manager-specific or help-page context where supported | 2026.1+ |
25+
| `--automation quit-app` | Gracefully shuts down the current UniGetUI session, including the headless automation daemon | 2026.1+ |
2226
| `--automation get-version` | Reads the local automation service build number through the background API | 2026.1+ |
2327
| `--automation get-updates` | Reads the currently available updates through the local automation service and returns structured JSON | 2026.1+ |
2428
| `--automation list-managers` | Lists package managers, readiness, executable metadata, and automation-relevant capability flags | 2026.1+ |
@@ -70,8 +74,8 @@
7074
| `--automation install-package --manager name --package-id id [--version v] [--scope scope] [--pre-release] [--elevated true\|false] [--interactive true\|false] [--skip-hash true\|false] [--architecture value] [--location path]` | Installs a package through the automation service and waits for completion, honoring the same core install options exposed by the UI | 2026.1+ |
7175
| `--automation download-package --manager name --package-id id --output path` | Downloads a package installer or artifact to the specified file or directory and returns the resolved saved path | 2026.1+ |
7276
| `--automation reinstall-package --manager name --package-id id [--version v] [--scope scope] [--pre-release] [--elevated true\|false] [--interactive true\|false] [--skip-hash true\|false] [--architecture value] [--location path]` | Re-runs package installation for an installed package using the requested install options | 2026.1+ |
73-
| `--automation open-window` | Asks the running UniGetUI instance to show the main window | 2026.1+ |
74-
| `--automation open-updates` | Asks the running UniGetUI instance to show the Updates page | 2026.1+ |
77+
| `--automation open-window` | Legacy alias for `--automation show-app` | 2026.1+ |
78+
| `--automation open-updates` | Legacy alias for `--automation navigate-app --page updates` | 2026.1+ |
7579
| `--automation show-package --package-id id --package-source source` | Opens the package details flow for the specified package | 2026.1+ |
7680
| `--automation list-ignored-updates` | Lists ignored update rules tracked by UniGetUI | 2026.1+ |
7781
| `--automation ignore-package --manager name --package-id id [--version v]` | Adds an ignored-update rule for a package and refreshes the updates view | 2026.1+ |
@@ -98,7 +102,7 @@
98102

99103
- `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.
100104
- `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.
101-
- Current agent-oriented command coverage includes status/version, manager/source inspection plus manager enablement, notification suppression, manager-maintenance and executable-path control, settings and secure-settings inspection/mutation, desktop-shortcut state management, app/history/manager log inspection, local backup creation and GitHub cloud-backup/auth flows, current bundle inspection/import/export/add/remove/install flows, package search/details/version listing, ignored-update management, and package install/update/uninstall flows.
105+
- Current agent-oriented command coverage includes app/session lifecycle inspection and shutdown, manager/source inspection plus manager enablement, notification suppression, manager-maintenance and executable-path control, settings and secure-settings inspection/mutation, desktop-shortcut state management, app/history/manager log inspection, local backup creation and GitHub cloud-backup/auth flows, current bundle inspection/import/export/add/remove/install flows, package search/details/version listing, ignored-update management, and package install/update/uninstall flows.
102106

103107
<br><br>
104108
# `unigetui://` deep link

src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using UniGetUI.Interface.Telemetry;
1212
using UniGetUI.PackageEngine;
1313
using UniGetUI.PackageEngine.Classes.Manager.Classes;
14+
using UniGetUI.PackageEngine.Interfaces;
1415

1516
namespace UniGetUI.Avalonia.Infrastructure;
1617

@@ -97,6 +98,14 @@ private static async Task InitializeBackgroundApiAsync()
9798
return;
9899

99100
_backgroundApi = new BackgroundApiRunner();
101+
_backgroundApi.AppInfoProvider = () =>
102+
Dispatcher.UIThread.InvokeAsync(GetAppInfo).GetAwaiter().GetResult();
103+
_backgroundApi.ShowAppHandler = () =>
104+
Dispatcher.UIThread.InvokeAsync(ShowApp).GetAwaiter().GetResult();
105+
_backgroundApi.NavigateAppHandler = request =>
106+
Dispatcher.UIThread.InvokeAsync(() => NavigateApp(request)).GetAwaiter().GetResult();
107+
_backgroundApi.QuitAppHandler = () =>
108+
Dispatcher.UIThread.InvokeAsync(QuitApp).GetAwaiter().GetResult();
100109

101110
_backgroundApi.OnOpenWindow += (_, _) =>
102111
Dispatcher.UIThread.Post(() => MainWindow.Instance?.ShowFromTray());
@@ -138,6 +147,111 @@ private static async Task InitializeBackgroundApiAsync()
138147

139148
public static void StopBackgroundApi() => _backgroundApi?.Stop();
140149

150+
private static AutomationAppInfo GetAppInfo()
151+
{
152+
MainWindow? window = MainWindow.Instance;
153+
return new AutomationAppInfo
154+
{
155+
Headless = false,
156+
WindowAvailable = window is not null,
157+
WindowVisible = window?.IsVisible ?? false,
158+
CanShowWindow = window is not null,
159+
CanNavigate = window is not null,
160+
CanQuit = true,
161+
CurrentPage = window is null ? "" : AutomationAppPages.ToPageName(window.CurrentPage.ToString()),
162+
SupportedPages = AutomationAppPages.SupportedPages,
163+
};
164+
}
165+
166+
private static BackgroundApiCommandResult ShowApp()
167+
{
168+
MainWindow window = MainWindow.Instance
169+
?? throw new InvalidOperationException("The application window is not available.");
170+
window.ShowFromTray();
171+
return BackgroundApiCommandResult.Success("show-app");
172+
}
173+
174+
private static BackgroundApiCommandResult NavigateApp(AutomationAppNavigateRequest request)
175+
{
176+
MainWindow window = MainWindow.Instance
177+
?? throw new InvalidOperationException("The application window is not available.");
178+
string page = AutomationAppPages.NormalizePageName(request.Page);
179+
var manager = ResolveManager(request.ManagerName);
180+
181+
switch (page)
182+
{
183+
case "discover":
184+
window.Navigate(PageType.Discover);
185+
break;
186+
case "updates":
187+
window.Navigate(PageType.Updates);
188+
break;
189+
case "installed":
190+
window.Navigate(PageType.Installed);
191+
break;
192+
case "bundles":
193+
window.Navigate(PageType.Bundles);
194+
break;
195+
case "settings":
196+
window.Navigate(PageType.Settings);
197+
break;
198+
case "managers":
199+
window.OpenManagerSettings(manager);
200+
break;
201+
case "own-log":
202+
window.Navigate(PageType.OwnLog);
203+
break;
204+
case "manager-log":
205+
window.OpenManagerLogs(manager);
206+
break;
207+
case "operation-history":
208+
window.Navigate(PageType.OperationHistory);
209+
break;
210+
case "help":
211+
window.ShowHelp(request.HelpAttachment ?? "");
212+
break;
213+
case "release-notes":
214+
window.Navigate(PageType.ReleaseNotes);
215+
break;
216+
case "about":
217+
window.Navigate(PageType.About);
218+
break;
219+
default:
220+
throw new InvalidOperationException(
221+
$"Unsupported app page \"{request.Page}\"."
222+
);
223+
}
224+
225+
window.ShowFromTray();
226+
return BackgroundApiCommandResult.Success("navigate-app");
227+
}
228+
229+
private static BackgroundApiCommandResult QuitApp()
230+
{
231+
MainWindow window = MainWindow.Instance
232+
?? throw new InvalidOperationException("The application window is not available.");
233+
_ = Task.Run(async () =>
234+
{
235+
await Task.Delay(150);
236+
await Dispatcher.UIThread.InvokeAsync(window.QuitApplication);
237+
});
238+
return BackgroundApiCommandResult.Success("quit-app");
239+
}
240+
241+
private static IPackageManager? ResolveManager(string? managerName)
242+
{
243+
if (string.IsNullOrWhiteSpace(managerName))
244+
{
245+
return null;
246+
}
247+
248+
return PEInterface.Managers.FirstOrDefault(manager =>
249+
manager.Name.Equals(managerName, StringComparison.OrdinalIgnoreCase))
250+
?? throw new InvalidOperationException(
251+
$"Unknown manager \"{managerName}\"."
252+
);
253+
}
254+
141255
private static async Task LoadElevatorAsync()
142256
{
143257
try

src/UniGetUI.Avalonia/Infrastructure/HeadlessDaemonHost.cs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,23 @@ public static async Task<int> RunAsync(string[] args)
1010
{
1111
BackgroundApiRunner? backgroundApi = null;
1212
using var shutdown = new CancellationTokenSource();
13+
void RequestShutdown()
14+
{
15+
if (!shutdown.IsCancellationRequested)
16+
{
17+
shutdown.Cancel();
18+
}
19+
}
1320

14-
Console.CancelKeyPress += (_, eventArgs) =>
21+
ConsoleCancelEventHandler cancelHandler = (_, eventArgs) =>
1522
{
1623
eventArgs.Cancel = true;
17-
shutdown.Cancel();
24+
RequestShutdown();
1825
};
26+
Console.CancelKeyPress += cancelHandler;
1927

20-
AppDomain.CurrentDomain.ProcessExit += (_, _) => shutdown.Cancel();
28+
EventHandler processExitHandler = (_, _) => RequestShutdown();
29+
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
2130

2231
try
2332
{
@@ -28,6 +37,34 @@ public static async Task<int> RunAsync(string[] args)
2837
await Task.Run(PEInterface.LoadManagers);
2938

3039
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+
};
3168
await backgroundApi.Start();
3269

3370
Logger.Info("UniGetUI headless daemon is ready");
@@ -42,6 +79,9 @@ public static async Task<int> RunAsync(string[] args)
4279
}
4380
finally
4481
{
82+
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
83+
Console.CancelKeyPress -= cancelHandler;
84+
4585
if (backgroundApi is not null)
4686
{
4787
await backgroundApi.Stop();

src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using UniGetUI.Core.Logging;
1010
using UniGetUI.Core.SettingsEngine;
1111
using UniGetUI.Core.Tools;
12+
using UniGetUI.PackageEngine.Interfaces;
1213

1314
namespace UniGetUI.Avalonia.Views;
1415

@@ -42,6 +43,7 @@ public enum RuntimeNotificationLevel
4243
public static MainWindow? Instance { get; private set; }
4344

4445
private MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!;
46+
public PageType CurrentPage => ViewModel.CurrentPage_t;
4547

4648
public MainWindow()
4749
{
@@ -148,6 +150,10 @@ private void SearchBox_KeyDown(object? sender, KeyEventArgs e)
148150

149151
// ─── Public navigation API ────────────────────────────────────────────────
150152
public void Navigate(PageType type) => ViewModel.NavigateTo(type);
153+
public void OpenManagerLogs(IPackageManager? manager = null) => ViewModel.OpenManagerLogs(manager);
154+
public void OpenManagerSettings(IPackageManager? manager = null) =>
155+
ViewModel.OpenManagerSettings(manager);
156+
public void ShowHelp(string uriAttachment = "") => ViewModel.ShowHelp(uriAttachment);
151157

152158
// ─── Public API (legacy compat) ───────────────────────────────────────────
153159
public void ShowBanner(string title, string message, RuntimeNotificationLevel level)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
namespace UniGetUI.Interface;
2+
3+
public sealed class AutomationAppInfo
4+
{
5+
public bool Headless { get; set; }
6+
public bool WindowAvailable { get; set; }
7+
public bool WindowVisible { get; set; }
8+
public bool CanShowWindow { get; set; }
9+
public bool CanNavigate { get; set; }
10+
public bool CanQuit { get; set; }
11+
public string CurrentPage { get; set; } = "";
12+
public IReadOnlyList<string> SupportedPages { get; set; } = AutomationAppPages.SupportedPages;
13+
}
14+
15+
public sealed class AutomationAppNavigateRequest
16+
{
17+
public string Page { get; set; } = "";
18+
public string? ManagerName { get; set; }
19+
public string? HelpAttachment { get; set; }
20+
}
21+
22+
public static class AutomationAppPages
23+
{
24+
public static readonly IReadOnlyList<string> SupportedPages =
25+
[
26+
"discover",
27+
"updates",
28+
"installed",
29+
"bundles",
30+
"settings",
31+
"managers",
32+
"own-log",
33+
"manager-log",
34+
"operation-history",
35+
"help",
36+
"release-notes",
37+
"about",
38+
];
39+
40+
public static string NormalizePageName(string page)
41+
{
42+
ArgumentException.ThrowIfNullOrWhiteSpace(page);
43+
44+
string normalized = page.Trim().ToLowerInvariant();
45+
return normalized switch
46+
{
47+
"discover" => normalized,
48+
"updates" => normalized,
49+
"installed" => normalized,
50+
"bundles" => normalized,
51+
"settings" => normalized,
52+
"managers" => normalized,
53+
"own-log" => normalized,
54+
"manager-log" => normalized,
55+
"operation-history" => normalized,
56+
"help" => normalized,
57+
"release-notes" => normalized,
58+
"about" => normalized,
59+
_ => throw new InvalidOperationException(
60+
$"Unsupported page \"{page}\". Supported pages: {string.Join(", ", SupportedPages)}."
61+
),
62+
};
63+
}
64+
65+
public static string ToPageName(string? pageTypeName)
66+
{
67+
if (string.IsNullOrWhiteSpace(pageTypeName))
68+
{
69+
return "";
70+
}
71+
72+
return pageTypeName switch
73+
{
74+
"Discover" => "discover",
75+
"Updates" => "updates",
76+
"Installed" => "installed",
77+
"Bundles" => "bundles",
78+
"Settings" => "settings",
79+
"Managers" => "managers",
80+
"OwnLog" => "own-log",
81+
"ManagerLog" => "manager-log",
82+
"OperationHistory" => "operation-history",
83+
"Help" => "help",
84+
"ReleaseNotes" => "release-notes",
85+
"About" => "about",
86+
_ => pageTypeName.Trim(),
87+
};
88+
}
89+
}

src/UniGetUI.Interface.BackgroundApi/AutomationCliCommandRunner.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ TextWriter error
4141
return subcommand switch
4242
{
4343
"status" => await WriteJsonAsync(output, await client.GetStatusAsync()),
44+
"get-app-state" => await WriteJsonAsync(
45+
output,
46+
new
47+
{
48+
status = "success",
49+
app = await client.GetAppInfoAsync(),
50+
}
51+
),
52+
"show-app" => await WriteJsonAsync(output, await client.ShowAppAsync()),
53+
"navigate-app" => await WriteJsonAsync(
54+
output,
55+
await client.NavigateAppAsync(BuildAppNavigateRequest(args))
56+
),
57+
"quit-app" => await WriteJsonAsync(output, await client.QuitAppAsync()),
4458
"list-managers" => await WriteJsonAsync(
4559
output,
4660
new
@@ -515,6 +529,20 @@ private static AutomationPackageActionRequest BuildPackageActionRequest(IReadOnl
515529
};
516530
}
517531

532+
private static AutomationAppNavigateRequest BuildAppNavigateRequest(IReadOnlyList<string> args)
533+
{
534+
return new AutomationAppNavigateRequest
535+
{
536+
Page = GetRequiredArgument(
537+
args,
538+
"--page",
539+
"The navigate-app automation command requires --page."
540+
),
541+
ManagerName = GetOptionalArgument(args, "--manager"),
542+
HelpAttachment = GetOptionalArgument(args, "--help-attachment"),
543+
};
544+
}
545+
518546
private static AutomationSourceRequest BuildSourceRequest(IReadOnlyList<string> args)
519547
{
520548
return new AutomationSourceRequest

0 commit comments

Comments
 (0)