Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,8 @@ Windows only. A folder rule names the subfolder of the current user's Start Menu
| `package ignored list` | None | None | Lists ignored-update rules tracked by UniGetUI. |
| `package ignored add` | `--id <package-id>` | `--manager <id>`, `--version <version>`, `--source <source>` | Adds an ignored-update rule. |
| `package ignored remove` | `--id <package-id>` | `--manager <id>`, `--version <version>`, `--source <source>` | Removes an ignored-update rule. |
| `package update-all` | None | None | Queues updates for all currently upgradable packages. |
| `package update-manager` | `--manager <id>` | None | Queues updates for all upgradable packages handled by one manager. |
| `package update-all` | None | `--manager <id>`, `--wait true\|false`, `--timeout <seconds>`, `--delay <seconds>` | Queues an update for every available update and returns their operation ids. With `--wait true`, waits for all of them (default timeout 3600 seconds) and exits with `1` if any did not succeed or the wait timed out. Works in headless sessions. |
| `package update-manager` | `--manager <id>` | `--wait true\|false`, `--timeout <seconds>`, `--delay <seconds>` | Same as `package update-all --manager <id>`. |

## Headless behavior

Expand All @@ -323,7 +323,7 @@ When UniGetUI is started with `--headless`, it exposes the same automation API w
| `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.” |
| `package update-all`, `package update-manager` | Fully supported. The headless session queues the updates itself. |

## Headless IPC options

Expand Down
38 changes: 34 additions & 4 deletions docs/IPC.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,40 @@ packages this machine is waiting on a restart for. See
| `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. |
| `POST` | `/uniget/v1/packages/update-all` | Yes | Optional query `manager` | `package update-all` | Queues an update for every available update and returns their operation ids. See [Updating everything](#updating-everything). |
| `POST` | `/uniget/v1/packages/update-manager` | Yes | Query `manager` | `package update-manager` | Same as `update-all` with `manager` set. |

## Updating everything

`update-all` and `update-manager` queue one update operation per available update and return at
once; they never wait for the updates themselves. They update what the Software Updates page
lists, so ignored updates, skipped minor updates and the minimum update age apply, and a package
that already has an update queued or running is skipped. If the update list has not been loaded
yet, the request waits for that first load.

The response lists the operations it queued:

```json
{
"status": "success",
"command": "update-all",
"completed": false,
"message": "The updates were queued for background execution.",
"queuedCount": 2,
"succeededCount": 0,
"failedCount": 0,
"results": [
{ "status": "success", "operationId": "…", "operationStatus": "inqueue", "completed": false, "package": { "id": "Git.Git", "manager": "winget" } }
]
}
```

Follow each one through `/uniget/v1/operations/{operationId}`, or let the CLI do it with
`package update-all --wait true`. With no updates available, `queuedCount` is `0`, `completed` is
`true` and `results` is empty.

In a session with a window, the window queues the updates, so they also appear in its operations
list. A headless session queues them itself.

## Pending machine restarts

Expand All @@ -430,8 +462,6 @@ 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.

Expand Down
10 changes: 4 additions & 6 deletions src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,10 @@ private static async Task InitializeIpcApiAsync()
_ipcApi.ShowPackageHandler = request =>
Dispatcher.UIThread.InvokeAsync(() => ShowPackage(request)).GetAwaiter().GetResult();

_ipcApi.OnUpgradeAll += (_, _) =>
Dispatcher.UIThread.Post(() => _ = AvaloniaPackageOperationHelper.UpdateAllAsync());

_ipcApi.OnUpgradeAllForManager += (_, managerName) =>
Dispatcher.UIThread.Post(() =>
_ = AvaloniaPackageOperationHelper.UpdateAllForManagerAsync(managerName));
_ipcApi.UpdateAllHandler = managerName =>
Dispatcher.UIThread.InvokeAsync(() => managerName is null
? AvaloniaPackageOperationHelper.UpdateAllAsync()
: AvaloniaPackageOperationHelper.UpdateAllForManagerAsync(managerName));

await _ipcApi.Start();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,19 @@ namespace UniGetUI.Avalonia.Infrastructure;
/// </summary>
internal static class AvaloniaPackageOperationHelper
{
public static async Task UpdateAllAsync(bool background = false)
public static Task<IReadOnlyList<AbstractOperation>> UpdateAllAsync(bool background = false)
=> UpdatePackagesAsync(UpgradablePackagesLoader.Instance.Packages.ToList(), background);

public static Task<IReadOnlyList<AbstractOperation>> UpdateAllForManagerAsync(string managerName)
=> UpdatePackagesAsync(UpgradablePackagesLoader.Instance.Packages
.Where(p => p.Manager.Id.Equals(managerName, StringComparison.OrdinalIgnoreCase))
.ToList());

private static async Task<IReadOnlyList<AbstractOperation>> UpdatePackagesAsync(
IReadOnlyList<IPackage> packages, bool background = false)
{
foreach (var pkg in UpgradablePackagesLoader.Instance.Packages.ToList())
List<AbstractOperation> started = [];
foreach (var pkg in packages)
{
var opts = await InstallOptionsFactory.LoadApplicableAsync(pkg);
if (PackageOperation.HasPendingOperation(pkg, OperationType.Update)) continue;
Expand All @@ -41,23 +51,10 @@ public static async Task UpdateAllAsync(bool background = false)
op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED);
AvaloniaOperationRegistry.Add(op);
_ = op.MainThread();
started.Add(op);
}
}

public static async Task UpdateAllForManagerAsync(string managerName)
{
foreach (var pkg in UpgradablePackagesLoader.Instance.Packages
.Where(p => p.Manager.Id == managerName)
.ToList())
{
var opts = await InstallOptionsFactory.LoadApplicableAsync(pkg);
if (PackageOperation.HasPendingOperation(pkg, OperationType.Update)) continue;
var op = new UpdatePackageOperation(pkg, opts);
op.OperationSucceeded += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.SUCCESS);
op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED);
AvaloniaOperationRegistry.Add(op);
_ = op.MainThread();
}
return started;
}

public static async Task UpdateForIdAsync(string packageId)
Expand Down
34 changes: 32 additions & 2 deletions src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -531,9 +531,16 @@ await client.ShowPackageAsync(
)
)
),
"update-all" => await WriteJsonAsync(output, await client.UpdateAllAsync()),
"update-manager" => await WriteJsonAsync(
"update-all" => await WriteBulkOperationAsync(
output,
client,
args,
await client.UpdateAllAsync(GetOptionalArgument(args, "--manager"))
),
"update-manager" => await WriteBulkOperationAsync(
output,
client,
args,
await client.UpdateManagerAsync(
GetRequiredArgument(
args,
Expand Down Expand Up @@ -915,6 +922,29 @@ await output.WriteLineAsync(
return (int)IpcCliExitCode.Success;
}

private static async Task<int> WriteBulkOperationAsync(
TextWriter output,
IpcClient client,
IReadOnlyList<string> args,
IpcPackageBulkOperationResult queued
)
{
if (GetOptionalBoolArgument(args, "--wait") != true)
{
return await WriteJsonAsync(output, queued);
}

var result = await client.WaitForBulkOperationAsync(
queued,
GetOptionalIntArgument(args, "--timeout") ?? 3600,
(GetOptionalIntArgument(args, "--delay") ?? 1) * 1000
);
await output.WriteLineAsync(IpcJson.Serialize(result));
return result.Status == "success"
? (int)IpcCliExitCode.Success
: (int)IpcCliExitCode.Failed;
}

private static async Task<int> WriteWrappedJsonAsync<T>(
TextWriter output,
string propertyName,
Expand Down
5 changes: 3 additions & 2 deletions src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,16 @@ 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
package update-all [--manager <id>] [--wait true|false] [--timeout <seconds>]
package update-manager --manager <id> [--wait true|false] [--timeout <seconds>]

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 package update-all --wait true
unigetui backup local create
unigetui backup github login start --launch-browser
""";
Expand Down
110 changes: 106 additions & 4 deletions src/UniGetUI.Interface.IpcApi/IpcClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1090,19 +1090,104 @@ IpcPackageActionRequest request
);
}

public async Task<IpcCommandResult> UpdateAllAsync()
public async Task<IpcPackageBulkOperationResult> UpdateAllAsync(string? managerName = null)
{
return await SendCommandAsync(IpcHttpRoutes.Path("/packages/update-all"));
return await SendBulkOperationAsync(
IpcHttpRoutes.Path("/packages/update-all"),
string.IsNullOrWhiteSpace(managerName)
? null
: new Dictionary<string, string> { ["manager"] = managerName }
);
}

public async Task<IpcCommandResult> UpdateManagerAsync(string managerName)
public async Task<IpcPackageBulkOperationResult> UpdateManagerAsync(string managerName)
{
return await SendCommandAsync(
return await SendBulkOperationAsync(
IpcHttpRoutes.Path("/packages/update-manager"),
new Dictionary<string, string> { ["manager"] = managerName }
);
}

/// <summary>
/// Waits for every operation a bulk command queued. On timeout, returns the statuses reached
/// so far with <c>Completed</c> unset; the remaining operations keep running.
/// </summary>
public async Task<IpcPackageBulkOperationResult> WaitForBulkOperationAsync(
IpcPackageBulkOperationResult queued,
int timeoutSeconds = 3600,
int delayMilliseconds = 1000
)
{
if (queued.Completed || queued.Status != "success")
{
return queued;
}

timeoutSeconds = Math.Clamp(timeoutSeconds, 1, 86400);
delayMilliseconds = Math.Clamp(delayMilliseconds, 100, 10000);

DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (true)
{
var operations = (await ListOperationsAsync()).ToDictionary(operation => operation.Id);
var results = queued.Results
.Select(result => ToWaitedResult(
result,
operations.GetValueOrDefault(result.OperationId)
))
.ToArray();
bool completed = results.All(result => result.Completed);

if (completed || DateTime.UtcNow >= deadline)
{
int failed = results.Count(result => result.Completed && result.Status == "error");
return new IpcPackageBulkOperationResult
{
Status = completed && failed == 0 ? "success" : "error",
Command = queued.Command,
Completed = completed,
Message = completed
? failed == 0 ? null : $"{failed} of {results.Length} updates did not succeed."
: $"Timed out while waiting for {results.Count(result => !result.Completed)} of {results.Length} updates. They keep running in UniGetUI.",
QueuedCount = queued.QueuedCount,
SucceededCount = results.Count(result => result.Completed && result.Status == "success"),
FailedCount = failed,
Results = results,
};
}

await Task.Delay(delayMilliseconds);
}
}

internal static IpcPackageOperationResult ToWaitedResult(
IpcPackageOperationResult queued,
IpcOperationInfo? operation
)
{
// An operation drops out of the list when it is forgotten or pruned, which only
// happens once it has finished.
string status = operation?.Status ?? "unknown";
bool completed = status is "succeeded" or "failed" or "canceled" or "unknown";
return new IpcPackageOperationResult
{
Status = !completed || status == "succeeded" ? "success" : "error",
Command = queued.Command,
OperationId = queued.OperationId,
OperationStatus = status,
Completed = completed,
Message = status switch
{
"succeeded" => null,
"canceled" => "The operation was canceled.",
"unknown" => "UniGetUI no longer tracks this operation, so its result is unknown.",
_ when !completed => queued.Message,
_ => operation?.LiveLine,
},
Package = operation?.Package ?? queued.Package,
};
}

public async Task<IpcPackageOperationResult> InstallPackageAsync(
IpcPackageActionRequest request
)
Expand Down Expand Up @@ -1246,6 +1331,23 @@ IpcPackageActionRequest request
};
}

private async Task<IpcPackageBulkOperationResult> SendBulkOperationAsync(
string relativePath,
IReadOnlyDictionary<string, string>? queryParameters
)
{
return await ReadAuthenticatedJsonAsync<IpcPackageBulkOperationResult>(
HttpMethod.Post,
relativePath,
queryParameters
)
?? new IpcPackageBulkOperationResult
{
Status = "error",
Message = "The IPC API returned an empty response.",
};
}

private async Task<IpcSourceOperationResult> SendSourceOperationAsync(
string relativePath,
IpcSourceRequest request
Expand Down
1 change: 1 addition & 0 deletions src/UniGetUI.Interface.IpcApi/IpcJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ internal static Task WriteAsJsonAsync<TValue>(
[JsonSerializable(typeof(IpcPackageInfo))]
[JsonSerializable(typeof(IpcPackageActionRequest))]
[JsonSerializable(typeof(IpcPackageOperationResult))]
[JsonSerializable(typeof(IpcPackageBulkOperationResult))]
[JsonSerializable(typeof(IpcPackageDependencyInfo))]
[JsonSerializable(typeof(IpcPackageDetailsInfo))]
[JsonSerializable(typeof(IpcIgnoredUpdateInfo))]
Expand Down
Loading
Loading