Public Access
On a distribution without rpm, a locally built package that mirrors the spec puts the nightly tree at /opt/DodoSSH.Nightly, and that path was the whole test for "the RPM installed this". The channel then asked rpm for the installed version, got nothing because there is no rpm, read the silence as "0", and offered every nightly as newer, fetching a 56 MB RPM into the profile directory that the machine could never install, every six hours. UpdateChannels.LinuxChannel still checks the path first, then asks rpm through the new LinuxUpdateChannel.ForInstall. A copy rpm has no record of gets an UnavailableUpdateChannel that says it was installed outside the dodossh-nightly (or dodossh) RPM and should be updated with the package manager that installed it. UnavailableUpdateChannel gains an optional Reason for that, UpdateViewModel exposes it as UnsupportedReason, and the desktop preferences page shows it in place of the sentence about checkouts and unzipped copies. That costs one rpm launch at start, and only where the path already matched. CheckAsync no longer falls back to "0". If rpm stops answering after composition, the check throws instead, so CHECK NOW reports it and the timer stays quiet, and nothing is offered or fetched. The comment that called the fallback a deliberate choice is replaced with the reason it was wrong. LinuxUpdateChannelTests: an unknown installed version throws and downloads nothing (replacing the test that asserted the old behaviour), a copy rpm does not own is unavailable and says why, and a copy rpm owns gets the channel. The newer, equal and older comparisons were already covered. UpdateFlowTests covers the reason reaching the view model. Manual check 19.13. App tests 744/752 with 8 skips, layout tests 202/203 with 1 skip; the full solution passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
559 lines
25 KiB
C#
559 lines
25 KiB
C#
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Runtime.Versioning;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.Json.Serialization.Metadata;
|
|
using DodoSSH.Client.Session;
|
|
|
|
namespace DodoSSH.Client.App.Platform;
|
|
|
|
/// <summary>What a channel's release says it is publishing.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Written by <c>scripts/ci-linux-rpm.sh</c> and read here, and it is a small file this repository
|
|
/// controls rather than the forge's own release JSON for the reason the android manifest gives: this is
|
|
/// fetched on a six-hourly timer, and a document whose shape changes when the forge is upgraded is not a
|
|
/// contract.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The comparison is <see cref="RpmVersion"/> and never <see cref="Version"/>.</b> That is the string
|
|
/// <c>rpm</c> will order the install against, so comparing anything else would let this offer an update
|
|
/// PackageKit then refuses — the same argument <c>AndroidChannelManifest</c> makes for <c>versionCode</c>.
|
|
/// The two differ exactly where it matters: <c>0.0.1-alpha.0.143</c> and <c>0.0.1~alpha.0.143</c> are one
|
|
/// build, and only the second sorts below <c>0.0.1</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="RpmVersion">The version-release string rpm knows the package by.</param>
|
|
/// <param name="Version">What that build calls itself, for a human.</param>
|
|
/// <param name="Rpm">The asset on the same release that holds it.</param>
|
|
/// <param name="Sha256">The package's digest, lower-case hex.</param>
|
|
internal sealed record LinuxChannelManifest(
|
|
[property: JsonPropertyName("rpmVersion")] string RpmVersion,
|
|
[property: JsonPropertyName("version")] string Version,
|
|
[property: JsonPropertyName("rpm")] string Rpm,
|
|
[property: JsonPropertyName("sha256")] string Sha256);
|
|
|
|
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
|
|
internal sealed record LinuxForgeRelease(
|
|
[property: JsonPropertyName("assets")] IReadOnlyList<LinuxForgeAsset>? Assets);
|
|
|
|
/// <summary>One file attached to a release.</summary>
|
|
internal sealed record LinuxForgeAsset(
|
|
[property: JsonPropertyName("name")] string? Name,
|
|
[property: JsonPropertyName("browser_download_url")] string? DownloadUrl);
|
|
|
|
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
|
[JsonSerializable(typeof(LinuxChannelManifest))]
|
|
[JsonSerializable(typeof(LinuxForgeRelease))]
|
|
internal sealed partial class LinuxForgeJsonContext : JsonSerializerContext;
|
|
|
|
/// <summary>
|
|
/// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Nothing here replaces anything.</b> That is the difference from
|
|
/// <see cref="VelopackUpdateChannel"/> and it is the decision ADR 0013 rule 11 records rather than an
|
|
/// unfinished half: on this platform the package manager is already an updater the user trusts, audits and
|
|
/// controls, so this class does the two things the package manager cannot — notice that a build exists and
|
|
/// fetch it — and then hands the file to PackageKit, which raises polkit and asks. The shape is the
|
|
/// phone's, not the desktop's: <see cref="ApplyingEndsTheProcess"/> is false, and the answer may be no.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, carried unchanged
|
|
/// from both other channels: an operator who could answer the update check could pin a chosen user to a
|
|
/// build with a known hole, without holding any key at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The digest is checked and is not a signature.</b> Velopack verifies what it downloads and Android
|
|
/// refuses a package whose signature does not match the installed one; an RPM fetched over HTTPS and
|
|
/// handed to PackageKit has neither. So the manifest names a digest and this refuses anything else —
|
|
/// which catches a truncated download, a caching proxy and a corrupted mirror, and catches nothing at all
|
|
/// about who built the file, because the same job wrote both. Provenance here is the constant address; a
|
|
/// signed repository is what would improve on it, and ADR 0013 rule 11 says why there is not one yet.
|
|
/// </para>
|
|
/// </remarks>
|
|
[SupportedOSPlatform("linux")]
|
|
internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|
{
|
|
/// <summary>The project's own forge, and the one address in this file.</summary>
|
|
/// <remarks>
|
|
/// The owner half of the path matters for the reason <c>AndroidUpdateChannel</c> records: the
|
|
/// repository moved between organisations and Gitea leaves a 301 behind, so a client naming the old
|
|
/// one works until somebody deletes the redirect.
|
|
/// </remarks>
|
|
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH";
|
|
|
|
private readonly string channel;
|
|
private readonly string packageName;
|
|
private readonly string dataDirectory;
|
|
private readonly HttpClient http;
|
|
private readonly Func<string, string?> installedVersion;
|
|
private readonly Action<string> handToInstaller;
|
|
|
|
private (string Version, string RpmVersion, string Url, string Sha256)? found;
|
|
private string? fetched;
|
|
|
|
/// <summary>Creates a channel for the running build.</summary>
|
|
internal LinuxUpdateChannel(string channel)
|
|
: this(
|
|
channel,
|
|
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
|
|
new HttpClient(),
|
|
InstalledRpmVersion,
|
|
HandToPackageKit)
|
|
{
|
|
}
|
|
|
|
/// <summary>Creates a channel with its outside world supplied, which is how the tests reach it.</summary>
|
|
internal LinuxUpdateChannel(
|
|
string channel,
|
|
string dataDirectory,
|
|
HttpClient http,
|
|
Func<string, string?> installedVersion,
|
|
Action<string> handToInstaller)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(channel);
|
|
ArgumentException.ThrowIfNullOrEmpty(dataDirectory);
|
|
ArgumentNullException.ThrowIfNull(http);
|
|
ArgumentNullException.ThrowIfNull(installedVersion);
|
|
ArgumentNullException.ThrowIfNull(handToInstaller);
|
|
|
|
this.channel = channel;
|
|
this.dataDirectory = dataDirectory;
|
|
this.http = http;
|
|
this.installedVersion = installedVersion;
|
|
this.handToInstaller = handToInstaller;
|
|
|
|
packageName = PackageFor(channel);
|
|
}
|
|
|
|
/// <summary>The channel for a copy under the RPM's install root, if rpm says the package is there.</summary>
|
|
internal static IUpdateChannel ForInstall(string channel) =>
|
|
ForInstall(channel, InstalledRpmVersion, () => new LinuxUpdateChannel(channel));
|
|
|
|
/// <summary>
|
|
/// The channel for a copy under the RPM's install root, with rpm supplied, which is how the tests
|
|
/// reach it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The path says where this copy is and only rpm says who put it there.</b>
|
|
/// <c>UpdateChannels.LinuxChannel</c> has already established the first, and on an RPM distribution
|
|
/// that is the whole story. It is not on any other: a locally built pacman or dpkg package that mirrors
|
|
/// the spec puts the same tree at the same path, and this channel then offered every nightly as newer
|
|
/// than a version nobody could read, fetched a package that machine has no way to install, and did it
|
|
/// again every six hours.
|
|
/// </para>
|
|
/// <para>
|
|
/// Asked once, at composition, for the reason the rest of this decision is: who installed this copy
|
|
/// does not change while it runs. It costs one <c>rpm</c> launch at start on a machine whose path
|
|
/// already matched, and nothing at all anywhere else.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="channel">The build's channel, which names the package.</param>
|
|
/// <param name="installedVersion">What rpm says is installed under a package name, or null.</param>
|
|
/// <param name="whenOwned">The channel to use when rpm does own this copy.</param>
|
|
internal static IUpdateChannel ForInstall(
|
|
string channel,
|
|
Func<string, string?> installedVersion,
|
|
Func<IUpdateChannel> whenOwned)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(channel);
|
|
ArgumentNullException.ThrowIfNull(installedVersion);
|
|
ArgumentNullException.ThrowIfNull(whenOwned);
|
|
|
|
var package = PackageFor(channel);
|
|
|
|
return installedVersion(package) is null
|
|
? new UnavailableUpdateChannel(
|
|
$"This copy of DodoSSH was installed outside the {package} RPM — rpm has no record of that "
|
|
+ "package here — and an RPM is the only kind of update it can fetch. Update it with the "
|
|
+ "package manager that installed it.")
|
|
: whenOwned();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
|
/// <see cref="UpdateChannels.ForThisMachine"/> and <see cref="ForInstall(string)"/> — a build run from
|
|
/// a checkout, or installed by something other than rpm, never reaches this class at all. The same
|
|
/// split Velopack's side uses, where <c>IsInstalled</c> is checked once at composition rather than at
|
|
/// every call site.
|
|
/// </remarks>
|
|
public bool IsSupported => true;
|
|
|
|
/// <inheritdoc />
|
|
public string CurrentVersion => ClientVersion.Current;
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// False, and for the phone's reason rather than the desktop's. Applying hands a file to PackageKit
|
|
/// and returns; polkit then asks, and the user may say no. A caller that had already torn the vault
|
|
/// down would answer "not now" with a locked vault and every shell gone.
|
|
/// </remarks>
|
|
public bool ApplyingEndsTheProcess => false;
|
|
|
|
/// <inheritdoc />
|
|
/// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
|
|
/// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
|
|
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
|
{
|
|
var release = await ReadAsync(
|
|
ReleaseUrl(),
|
|
LinuxForgeJsonContext.Default.LinuxForgeRelease,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var manifestName = $"linux-{channel}.json";
|
|
|
|
if (Asset(release, manifestName) is not { } manifestAsset)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"The {channel} release carries no {manifestName}, so there is nothing saying what it "
|
|
+ "publishes.");
|
|
}
|
|
|
|
var manifest = await ReadAsync(
|
|
manifestAsset,
|
|
LinuxForgeJsonContext.Default.LinuxChannelManifest,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (manifest is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
|
}
|
|
|
|
// ◆ No answer from rpm is a failure, never a version. ForInstall only built this class because
|
|
// rpm answered, so silence now is a package removed under a running process or an rpm that
|
|
// stopped working — and any stand-in number would make every published build look newer and
|
|
// fetch a package for a machine that cannot install it, which is what this once did.
|
|
if (installedVersion(packageName) is not { } installed)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"rpm has no record of {packageName}, so there is no installed version to compare the "
|
|
+ $"{channel} release against.");
|
|
}
|
|
|
|
// The one place null is returned, and it means what null is documented to mean: this build is
|
|
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
|
if (RpmVersion.Compare(manifest.RpmVersion, installed) <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (Asset(release, manifest.Rpm) is not { } rpm)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"The {channel} release advertises {manifest.Version} but carries no {manifest.Rpm}.");
|
|
}
|
|
|
|
found = (manifest.Version, manifest.RpmVersion, rpm, manifest.Sha256);
|
|
|
|
return new AvailableUpdate(manifest.Version);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DownloadAsync(
|
|
AvailableUpdate update,
|
|
IProgress<int> progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(update);
|
|
ArgumentNullException.ThrowIfNull(progress);
|
|
|
|
if (found is not { } target
|
|
|| !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"That update did not come from this channel's last check. Check again before downloading.");
|
|
}
|
|
|
|
// Under the profile directory rather than /tmp, because a package this size on a tmpfs is a
|
|
// machine that runs out of memory downloading an update. One name, overwritten, so a client that
|
|
// fetched three updates it never installed is not holding three hundred megabytes.
|
|
var directory = Path.Combine(dataDirectory, "updates");
|
|
|
|
Directory.CreateDirectory(directory);
|
|
|
|
var path = Path.Combine(directory, $"{packageName}-update.rpm");
|
|
|
|
await FetchAsync(target.Url, path, progress, cancellationToken).ConfigureAwait(false);
|
|
|
|
// ◆ Verified before it is ever named to the installer, and the file is removed if it does not
|
|
// match. Leaving a package that failed its digest on disk would put a file somebody could install
|
|
// by hand next to a filename that says it is an update.
|
|
var digest = Convert.ToHexStringLower(await Sha256OfAsync(path, cancellationToken).ConfigureAwait(false));
|
|
|
|
if (!string.Equals(digest, target.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
File.Delete(path);
|
|
|
|
throw new InvalidOperationException(
|
|
$"The downloaded package is not the one {channel} published: expected {target.Sha256}, "
|
|
+ $"got {digest}.");
|
|
}
|
|
|
|
fetched = path;
|
|
}
|
|
|
|
/// <summary>Streams one URL to one path, reporting progress as it goes.</summary>
|
|
private async Task FetchAsync(
|
|
string url,
|
|
string path,
|
|
IProgress<int> progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var response = await http
|
|
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var total = response.Content.Headers.ContentLength ?? 0;
|
|
|
|
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var destination = File.Create(path);
|
|
|
|
await using (stream.ConfigureAwait(false))
|
|
await using (destination.ConfigureAwait(false))
|
|
{
|
|
var buffer = new byte[81920];
|
|
long copied = 0;
|
|
int read;
|
|
|
|
while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
|
{
|
|
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
copied += read;
|
|
|
|
if (total > 0)
|
|
{
|
|
progress.Report((int)(copied * 100 / total));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>This installs nothing and returns immediately.</b> It asks PackageKit — the freedesktop service
|
|
/// every RPM distribution runs — to install a local file, which raises a polkit prompt drawn by the
|
|
/// desktop rather than by this application. The user may refuse, and refusing is not a failure.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The process is not restarted and must not be.</b> Replacing files under a running process is
|
|
/// ordinary on Linux: the old inodes stay mapped and this build keeps running until somebody closes
|
|
/// it. That is the right behaviour for an application that deliberately keeps shells open across a
|
|
/// lock — the same argument ADR 0013 makes for waiting for a restart the user asks for — and it is why
|
|
/// the name inherited from the interface reads oddly here. There is no equivalent of Android's
|
|
/// "the platform ends the process", so nothing ends it.
|
|
/// </para>
|
|
/// </remarks>
|
|
public void ApplyAndRestart(AvailableUpdate update)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(update);
|
|
|
|
if (fetched is not { } path || !File.Exists(path))
|
|
{
|
|
throw new InvalidOperationException("There is no downloaded update to install.");
|
|
}
|
|
|
|
handToInstaller(path);
|
|
}
|
|
|
|
/// <summary>Which release this build's channel reads.</summary>
|
|
/// <remarks>
|
|
/// <c>releases/latest</c> skips prereleases, which keeps the nightly — published as one — out of the
|
|
/// release channel's answer even though both live on the same forge.
|
|
/// </remarks>
|
|
private string ReleaseUrl() =>
|
|
string.Equals(channel, "nightly", StringComparison.Ordinal)
|
|
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
|
: $"{RepositoryApi}/releases/latest";
|
|
|
|
/// <summary>The package a channel's builds are published as.</summary>
|
|
private static string PackageFor(string channel) =>
|
|
string.Equals(channel, "nightly", StringComparison.Ordinal)
|
|
? "dodossh-nightly"
|
|
: "dodossh";
|
|
|
|
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
|
release?.Assets?.FirstOrDefault(
|
|
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
|
|
|
private async Task<T?> ReadAsync<T>(
|
|
string url,
|
|
JsonTypeInfo<T> shape,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
|
|
|
|
await using (stream.ConfigureAwait(false))
|
|
{
|
|
return await JsonSerializer
|
|
.DeserializeAsync(stream, shape, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static async Task<byte[]> Sha256OfAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
var stream = File.OpenRead(path);
|
|
|
|
await using (stream.ConfigureAwait(false))
|
|
{
|
|
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// What rpm records for the installed package, which is the number a newer build must beat.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Read from the package database rather than from the assembly, for the reason
|
|
/// <c>AndroidUpdateChannel.ReadInstalledVersionCode</c> gives about reading it from the platform: the
|
|
/// assembly's version is a SemVer string and this comparison has to be the one the installer will
|
|
/// make. Deriving the rpm version from the assembly here would mean a second copy of the translation
|
|
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
|
/// </para>
|
|
/// <para>
|
|
/// A machine that cannot answer gets null — no <c>rpm</c> at all, or one with no such package — and
|
|
/// null means "rpm does not own this copy", never a version. It used to be read as <c>0</c>, on the
|
|
/// argument that the worst case was an install PackageKit declined. That holds only where PackageKit
|
|
/// installs RPMs: on any other distribution it made every published build look newer, forever, and
|
|
/// fetched a package that machine could not install. See <see cref="ForInstall(string)"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static string? InstalledRpmVersion(string packageName)
|
|
{
|
|
try
|
|
{
|
|
using var process = Process.Start(new ProcessStartInfo("rpm")
|
|
{
|
|
ArgumentList = { "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName },
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
});
|
|
|
|
if (process is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var answer = process.StandardOutput.ReadToEnd().Trim();
|
|
|
|
process.WaitForExit(TimeSpan.FromSeconds(10));
|
|
|
|
return process.ExitCode == 0 && answer.Length > 0 ? answer : null;
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Asks PackageKit to install a local file, and lets polkit do the asking.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Over <c>gdbus</c> rather than a D-Bus library, and that is a dependency argument rather than a
|
|
/// stylistic one.</b> <c>gdbus</c> ships in glib2, which this package already depends on twice over —
|
|
/// GTK and WebKitGTK are the terminal — so it is present on every machine that can run this at all.
|
|
/// Adding a managed D-Bus stack to reach two method calls would be a package, a lock file entry and a
|
|
/// second thing to keep current, for no capability. <c>BrowserLauncher</c> settles the same trade the
|
|
/// same way one layer down.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Two calls, because PackageKit's interface is a factory.</b> <c>CreateTransaction</c> returns an
|
|
/// object path and the work is asked of that; a transaction is single-use, so this cannot be cached.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The flags word is zero, and zero is load-bearing.</b> Bit 0 is <c>ONLY_TRUSTED</c>. This
|
|
/// package is not signed — ADR 0013 rule 11 says why, and why that is not decision 8's "unsigned for
|
|
/// now" — so requiring trust would make every update fail at the last step. Clearing it is what routes
|
|
/// the request to polkit's <c>package-install-untrusted</c> action, which asks for an administrator
|
|
/// rather than proceeding quietly. The user sees what they are agreeing to.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void HandToPackageKit(string path)
|
|
{
|
|
var transaction = Gdbus(
|
|
"--system",
|
|
"--dest", "org.freedesktop.PackageKit",
|
|
"--object-path", "/org/freedesktop/PackageKit",
|
|
"--method", "org.freedesktop.PackageKit.CreateTransaction");
|
|
|
|
// gdbus prints a GVariant tuple — (objectpath '/17_beadcfae',) — and the path is the only thing
|
|
// in it. Parsed by taking what is between the quotes rather than with a regular expression over a
|
|
// format nobody has promised to keep.
|
|
var opened = transaction.IndexOf('\'', StringComparison.Ordinal);
|
|
var closed = transaction.LastIndexOf('\'');
|
|
|
|
if (opened < 0 || closed <= opened)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"PackageKit did not return a transaction to install into: {transaction}");
|
|
}
|
|
|
|
var objectPath = transaction[(opened + 1)..closed];
|
|
|
|
Gdbus(
|
|
"--system",
|
|
"--dest", "org.freedesktop.PackageKit",
|
|
"--object-path", objectPath,
|
|
"--method", "org.freedesktop.PackageKit.Transaction.InstallFiles",
|
|
"0",
|
|
$"['{path}']");
|
|
}
|
|
|
|
/// <summary>Runs one gdbus call and answers with its output.</summary>
|
|
private static string Gdbus(params string[] arguments)
|
|
{
|
|
var start = new ProcessStartInfo("gdbus")
|
|
{
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
};
|
|
|
|
foreach (var argument in arguments)
|
|
{
|
|
start.ArgumentList.Add(argument);
|
|
}
|
|
|
|
using var process = Process.Start(start)
|
|
?? throw new InvalidOperationException(
|
|
"gdbus could not be started, so the package could not be handed to PackageKit.");
|
|
|
|
var output = process.StandardOutput.ReadToEnd();
|
|
var error = process.StandardError.ReadToEnd();
|
|
|
|
process.WaitForExit(TimeSpan.FromSeconds(30));
|
|
|
|
if (process.ExitCode != 0)
|
|
{
|
|
// The message is shown to the user by UpdateViewModel, so it says what to do rather than what
|
|
// failed. A missing PackageKit is the likely cause on a minimal install and is the one thing
|
|
// they can fix.
|
|
throw new InvalidOperationException(
|
|
string.Format(
|
|
CultureInfo.CurrentCulture,
|
|
"PackageKit would not take the package. Install it with `sudo dnf install PackageKit`, "
|
|
+ "or install the file yourself: {0}. ({1})",
|
|
arguments[^1],
|
|
error.Trim()));
|
|
}
|
|
|
|
return output;
|
|
}
|
|
}
|