Updater: distinguish 'no newer version' from 'check failed' on all three paths
Tech Singer's Windows 7 log from 2026-05-28 had this pattern: updater: GET https://api.github.com/repos/... updater: check failed: HttpRequestException: The SSL connection could not be established ... updater: startup check — up to date (v3.0.1) i.e. the SSL handshake to GitHub failed (Win7's TLS stack missing KB3140245 / KB4474419) but the updater logged 'up to date' and the user-facing message in CheckForUpdatesManually said the same. So a user with a broken update check has no way to distinguish that from genuinely having the latest version. Fix: * CheckForUpdateAsync now returns a discriminated UpdateCheckResult (UpdateAvailable / UpToDate / UpdateCheckFailed) instead of the old UpdateInfo?. Each return-null site is replaced with the appropriate concrete type. * The catch-all 'try { ... } catch (Exception ex) { return null; }' becomes 'return new UpdateCheckFailed(ClassifyFailure(ex), ...)'. ClassifyFailure walks the exception chain and maps to a coarse FailureKind enum: SecureConnection (TLS/auth), Timeout, HttpError, NetworkUnreachable. SecureConnection is broken out separately so the manual-check UI can point Win7 users at the specific Microsoft KBs that fix the issue. * MainForm.CheckForUpdatesManually pattern-matches on the result: UpToDate -> existing 'you're running the latest' message; UpdateAvailable -> existing install confirmation; UpdateCheckFailed -> NEW dialog (ShowUpdateCheckFailedDialog) whose wording is tailored to the FailureKind. The SecureConnection branch explicitly names KB3140245 and KB4474419 and offers the manual zip-install URL as a fallback. All branches keep the technical detail out of the dialog and route it to the log instead. * Background and startup polls stay silent on UpToDate and UpdateCheckFailed (no point nagging the user about something they can't act on from a timer tick), but the startup-poll log now records the failure kind and detail instead of mislabelling the outcome as 'up to date'. No version bump - this rides along with the next feature release (planned v3.2 with the Reaper ReaStream integration). The bug is silent on the affected users today, and shipping a v3.1.2 just for the error-message improvement would mean another update cycle for everyone for marginal benefit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
aa099eb555
commit
6cbde0da12
@@ -1840,19 +1840,27 @@ public sealed class MainForm : Form
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>User pressed "Check for updates" (Help menu or Preferences button). Always
|
/// <summary>User pressed "Check for updates" (Help menu or Preferences button). Always
|
||||||
/// runs the check noisily — i.e. surfaces "you're up to date" / "v1.x available" via a
|
/// runs the check noisily — i.e. surfaces "you're up to date", "vX.Y is available", or
|
||||||
/// MessageBox, regardless of the Silently-install setting. Silent install only applies
|
/// "couldn't reach the server" via a MessageBox, regardless of the Silently-install
|
||||||
/// to background polls. Caller is on the UI thread.</summary>
|
/// setting. Silent install only applies to background polls. Caller is on the UI thread.
|
||||||
|
/// </summary>
|
||||||
private async void CheckForUpdatesManually()
|
private async void CheckForUpdatesManually()
|
||||||
{
|
{
|
||||||
var info = await updater.CheckForUpdateAsync().ConfigureAwait(true);
|
var result = await updater.CheckForUpdateAsync().ConfigureAwait(true);
|
||||||
if (info is null)
|
switch (result)
|
||||||
{
|
{
|
||||||
|
case UpToDate:
|
||||||
MessageBox.Show(this,
|
MessageBox.Show(this,
|
||||||
$"You are running the latest version (v{updater.CurrentVersion}).",
|
$"You are running the latest version (v{updater.CurrentVersion}).",
|
||||||
"Check for updates", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
"Check for updates", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
case UpdateCheckFailed failure:
|
||||||
|
ShowUpdateCheckFailedDialog(failure);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case UpdateAvailable available:
|
||||||
|
var info = available.Info;
|
||||||
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
|
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
|
||||||
? $"RemSound {info.Tag} is available. Install now?"
|
? $"RemSound {info.Tag} is available. Install now?"
|
||||||
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
|
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
|
||||||
@@ -1860,6 +1868,46 @@ public sealed class MainForm : Form
|
|||||||
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
|
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
|
||||||
if (choice != DialogResult.Yes) return;
|
if (choice != DialogResult.Yes) return;
|
||||||
await InstallUpdateAsync(info).ConfigureAwait(true);
|
await InstallUpdateAsync(info).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Show a plain-English MessageBox explaining why an update check couldn't
|
||||||
|
/// complete. The wording is tailored to the <see cref="FailureKind"/>: the SecureConnection
|
||||||
|
/// case (which is most often Windows 7 lacking TLS 1.2 / SHA-2 updates) gets pointers to
|
||||||
|
/// the specific Microsoft KBs that fix it; other failures get a friendlier "check your
|
||||||
|
/// internet" message. The user is also offered the manual zip-install fallback URL so they
|
||||||
|
/// can recover without us debugging their machine. The technical detail goes to the log,
|
||||||
|
/// not the dialog. 2026-05-28.</summary>
|
||||||
|
private void ShowUpdateCheckFailedDialog(UpdateCheckFailed failure)
|
||||||
|
{
|
||||||
|
var (heading, body) = failure.Kind switch
|
||||||
|
{
|
||||||
|
FailureKind.SecureConnection => (
|
||||||
|
"Couldn't reach the update server (secure connection failed)",
|
||||||
|
"RemSound couldn't make a secure connection to GitHub to check for an update.\n\n"
|
||||||
|
+ "This is almost always because the Windows install is missing one or both of these official Microsoft updates that enable modern secure connections:\n\n"
|
||||||
|
+ " • KB3140245 — turns on TLS 1.2 support.\n"
|
||||||
|
+ " • KB4474419 — updates the trusted certificate list (SHA-2 support).\n\n"
|
||||||
|
+ "Both are free and won't break anything else. Run Windows Update (Control Panel → Windows Update) and install whatever it offers. Once those are in, Check for updates should work normally.\n\n"
|
||||||
|
+ "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder. That works regardless of the secure-connection issue."),
|
||||||
|
|
||||||
|
FailureKind.NetworkUnreachable => (
|
||||||
|
"Couldn't reach the update server",
|
||||||
|
"RemSound couldn't reach GitHub to check for an update. This is usually a network problem — check your internet connection, then try Check for updates again.\n\n"
|
||||||
|
+ "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."),
|
||||||
|
|
||||||
|
FailureKind.Timeout => (
|
||||||
|
"Update check timed out",
|
||||||
|
"RemSound's request to GitHub took too long to respond. This is usually a slow or congested network — try Check for updates again in a minute or two.\n\n"
|
||||||
|
+ "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."),
|
||||||
|
|
||||||
|
_ => (
|
||||||
|
"Couldn't check for updates",
|
||||||
|
"RemSound's request to GitHub didn't get the response it expected, so it can't tell whether a newer version is available. Try Check for updates again later.\n\n"
|
||||||
|
+ "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."),
|
||||||
|
};
|
||||||
|
MessageBox.Show(this, body, heading, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Background-poll path. Runs on a timer tick; surfaces nothing unless an update
|
/// <summary>Background-poll path. Runs on a timer tick; surfaces nothing unless an update
|
||||||
@@ -1868,7 +1916,7 @@ public sealed class MainForm : Form
|
|||||||
/// silent no-op — the user already chose to delegate scheduling to the timer.</summary>
|
/// silent no-op — the user already chose to delegate scheduling to the timer.</summary>
|
||||||
private async void CheckForUpdatesInBackground()
|
private async void CheckForUpdatesInBackground()
|
||||||
{
|
{
|
||||||
var info = await updater.CheckForUpdateAsync().ConfigureAwait(true);
|
var result = await updater.CheckForUpdateAsync().ConfigureAwait(true);
|
||||||
// Persist the timestamp so cross-launch scheduling can space the next poll out.
|
// Persist the timestamp so cross-launch scheduling can space the next poll out.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1877,7 +1925,12 @@ public sealed class MainForm : Form
|
|||||||
cfg.Save();
|
cfg.Save();
|
||||||
}
|
}
|
||||||
catch { /* timestamp persistence is best-effort */ }
|
catch { /* timestamp persistence is best-effort */ }
|
||||||
if (info is null) return;
|
// Background polls stay silent on both UpToDate and UpdateCheckFailed — the user
|
||||||
|
// delegated scheduling to the timer and a failure here isn't actionable from where
|
||||||
|
// they are. The next poll re-checks. The failure detail has already gone to the log
|
||||||
|
// via the updater's Log callback.
|
||||||
|
if (result is not UpdateAvailable available) return;
|
||||||
|
var info = available.Info;
|
||||||
if (AppConfig.Load().SilentlyInstallUpdates)
|
if (AppConfig.Load().SilentlyInstallUpdates)
|
||||||
{
|
{
|
||||||
// Notice the user before the app vanishes and the helper takes over. Hidden from
|
// Notice the user before the app vanishes and the helper takes over. Hidden from
|
||||||
@@ -1904,14 +1957,14 @@ public sealed class MainForm : Form
|
|||||||
/// stays consistent.</summary>
|
/// stays consistent.</summary>
|
||||||
private async void CheckForUpdatesOnStartup()
|
private async void CheckForUpdatesOnStartup()
|
||||||
{
|
{
|
||||||
UpdateInfo? info;
|
UpdateCheckResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
info = await updater.CheckForUpdateAsync().ConfigureAwait(true);
|
result = await updater.CheckForUpdateAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logFile.Event($"updater: startup check failed: {ex.GetType().Name}: {ex.Message}");
|
logFile.Event($"updater: startup check threw unexpectedly: {ex.GetType().Name}: {ex.Message}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
@@ -1921,11 +1974,22 @@ public sealed class MainForm : Form
|
|||||||
cfg.Save();
|
cfg.Save();
|
||||||
}
|
}
|
||||||
catch { /* harmless */ }
|
catch { /* harmless */ }
|
||||||
if (info is null)
|
// Log each outcome distinctly so a failed check (TLS error, network down, GitHub
|
||||||
|
// 5xx) doesn't get filed as "up to date" — that's the misclassification Tech Singer's
|
||||||
|
// log from 2026-05-28 demonstrated, where a Win7 SSL handshake failure quietly logged
|
||||||
|
// as "up to date" instead of the real cause.
|
||||||
|
if (result is UpdateCheckFailed failure)
|
||||||
|
{
|
||||||
|
logFile.Event($"updater: startup check failed ({failure.Kind}): {failure.TechnicalDetail}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result is UpToDate)
|
||||||
{
|
{
|
||||||
logFile.Event($"updater: startup check — up to date (v{updater.CurrentVersion})");
|
logFile.Event($"updater: startup check — up to date (v{updater.CurrentVersion})");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result is not UpdateAvailable available) return;
|
||||||
|
var info = available.Info;
|
||||||
logFile.Event($"updater: startup check found {info.Tag}");
|
logFile.Event($"updater: startup check found {info.Tag}");
|
||||||
if (AppConfig.Load().SilentlyInstallUpdates)
|
if (AppConfig.Load().SilentlyInstallUpdates)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ internal sealed class RemSoundUpdater : IDisposable
|
|||||||
/// limited, repo not found) or if the latest version is not newer than the running
|
/// limited, repo not found) or if the latest version is not newer than the running
|
||||||
/// assembly. Caller decides whether to surface "you're up to date" vs silently doing
|
/// assembly. Caller decides whether to surface "you're up to date" vs silently doing
|
||||||
/// nothing — both paths get null back.</summary>
|
/// nothing — both paths get null back.</summary>
|
||||||
public async Task<UpdateInfo?> CheckForUpdateAsync(CancellationToken token = default)
|
public async Task<UpdateCheckResult> CheckForUpdateAsync(CancellationToken token = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -81,14 +81,14 @@ internal sealed class RemSoundUpdater : IDisposable
|
|||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
Log?.Invoke($"updater: HTTP {(int)resp.StatusCode} from GitHub");
|
Log?.Invoke($"updater: HTTP {(int)resp.StatusCode} from GitHub");
|
||||||
return null;
|
return new UpdateCheckFailed(FailureKind.HttpError, $"GitHub responded with HTTP {(int)resp.StatusCode}.");
|
||||||
}
|
}
|
||||||
await using var stream = await resp.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
await using var stream = await resp.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||||
var releases = await JsonSerializer.DeserializeAsync<List<GitHubRelease>>(stream, JsonOpts, token).ConfigureAwait(false);
|
var releases = await JsonSerializer.DeserializeAsync<List<GitHubRelease>>(stream, JsonOpts, token).ConfigureAwait(false);
|
||||||
if (releases is null || releases.Count == 0)
|
if (releases is null || releases.Count == 0)
|
||||||
{
|
{
|
||||||
Log?.Invoke("updater: releases list was empty");
|
Log?.Invoke("updater: releases list was empty");
|
||||||
return null;
|
return new UpdateCheckFailed(FailureKind.HttpError, "GitHub returned an empty release list.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highest-versioned RemSound client release. Skip drafts, prereleases, and any
|
// Highest-versioned RemSound client release. Skip drafts, prereleases, and any
|
||||||
@@ -105,35 +105,61 @@ internal sealed class RemSoundUpdater : IDisposable
|
|||||||
if (release?.TagName is null)
|
if (release?.TagName is null)
|
||||||
{
|
{
|
||||||
Log?.Invoke("updater: no RemSound client release found in the releases list");
|
Log?.Invoke("updater: no RemSound client release found in the releases list");
|
||||||
return null;
|
return new UpdateCheckFailed(FailureKind.HttpError, "GitHub returned releases but none looked like a RemSound client release.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var current = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
|
var current = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
|
||||||
Log?.Invoke($"updater: current={current.ToString(3)} latest={latest.ToString(3)} ({release.TagName})");
|
Log?.Invoke($"updater: current={current.ToString(3)} latest={latest.ToString(3)} ({release.TagName})");
|
||||||
if (latest <= current) return null;
|
if (latest <= current) return UpToDate.Instance;
|
||||||
|
|
||||||
var expectedAsset = AssetNameTemplate.Replace("{tag}", release.TagName);
|
var expectedAsset = AssetNameTemplate.Replace("{tag}", release.TagName);
|
||||||
var asset = release.Assets?.FirstOrDefault(a => string.Equals(a.Name, expectedAsset, StringComparison.OrdinalIgnoreCase));
|
var asset = release.Assets?.FirstOrDefault(a => string.Equals(a.Name, expectedAsset, StringComparison.OrdinalIgnoreCase));
|
||||||
if (asset?.BrowserDownloadUrl is null)
|
if (asset?.BrowserDownloadUrl is null)
|
||||||
{
|
{
|
||||||
Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'");
|
Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'");
|
||||||
return null;
|
return new UpdateCheckFailed(FailureKind.HttpError, $"The latest release page is missing the expected file '{expectedAsset}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new UpdateInfo(
|
return new UpdateAvailable(new UpdateInfo(
|
||||||
Tag: release.TagName,
|
Tag: release.TagName,
|
||||||
Version: latest,
|
Version: latest,
|
||||||
DownloadUrl: asset.BrowserDownloadUrl,
|
DownloadUrl: asset.BrowserDownloadUrl,
|
||||||
ReleaseNotes: release.Body ?? "",
|
ReleaseNotes: release.Body ?? "",
|
||||||
ReleaseUrl: release.HtmlUrl ?? "");
|
ReleaseUrl: release.HtmlUrl ?? ""));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log?.Invoke($"updater: check failed: {ex.GetType().Name}: {ex.Message}");
|
Log?.Invoke($"updater: check failed: {ex.GetType().Name}: {ex.Message}");
|
||||||
return null;
|
return new UpdateCheckFailed(ClassifyFailure(ex), ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a thrown exception from the GitHub HTTP call to a coarse-grained
|
||||||
|
/// <see cref="FailureKind"/> the UI can hang an honest plain-English message off without
|
||||||
|
/// quoting the underlying .NET exception type. Most "couldn't reach the server" errors
|
||||||
|
/// fall into the network bucket; the SSL bucket is broken out separately because it has
|
||||||
|
/// a specific cause and fix on Windows 7 (TLS 1.2 / SHA-2 Windows updates) that we want
|
||||||
|
/// to point users at when we see it. 2026-05-28.</summary>
|
||||||
|
private static FailureKind ClassifyFailure(Exception ex)
|
||||||
|
{
|
||||||
|
// Walk the exception chain — HttpRequestException is the outer wrapper; the actual
|
||||||
|
// cause (System.Net.Security.AuthenticationException, IOException, SocketException,
|
||||||
|
// etc) is in InnerException. Either layer might carry the diagnostic clue.
|
||||||
|
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||||||
|
{
|
||||||
|
var typeName = e.GetType().Name;
|
||||||
|
var msg = e.Message ?? "";
|
||||||
|
if (typeName.Contains("Authentication", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| msg.Contains("SSL", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| msg.Contains("TLS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return FailureKind.SecureConnection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ex is TaskCanceledException) return FailureKind.Timeout;
|
||||||
|
return FailureKind.NetworkUnreachable;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Filename of the one-shot "after the update restart, silently load this profile"
|
/// <summary>Filename of the one-shot "after the update restart, silently load this profile"
|
||||||
/// sentinel. Written next to RemSound.exe by <see cref="DownloadAndStageInstallAsync"/>
|
/// sentinel. Written next to RemSound.exe by <see cref="DownloadAndStageInstallAsync"/>
|
||||||
/// when the caller supplies a non-empty <c>activeProfileTitle</c>; read and deleted by
|
/// when the caller supplies a non-empty <c>activeProfileTitle</c>; read and deleted by
|
||||||
@@ -445,3 +471,49 @@ internal sealed record UpdateInfo(
|
|||||||
string DownloadUrl,
|
string DownloadUrl,
|
||||||
string ReleaseNotes,
|
string ReleaseNotes,
|
||||||
string ReleaseUrl);
|
string ReleaseUrl);
|
||||||
|
|
||||||
|
/// <summary>Discriminated result of an update check. Replaces the v3.1.x-and-earlier
|
||||||
|
/// "UpdateInfo?" return type, which conflated "no newer version available" with "couldn't
|
||||||
|
/// reach the server" — the user saw "you are running the latest version" in both cases,
|
||||||
|
/// even when the check had actually failed because (e.g.) the OS couldn't establish a
|
||||||
|
/// secure connection to GitHub. The caller pattern-matches on this and shows an honest
|
||||||
|
/// message for each outcome. 2026-05-28.</summary>
|
||||||
|
internal abstract record UpdateCheckResult;
|
||||||
|
|
||||||
|
/// <summary>A newer release is available. Carries the parsed <see cref="UpdateInfo"/> the
|
||||||
|
/// caller passes to <see cref="RemSoundUpdater.DownloadAndStageInstallAsync"/>.</summary>
|
||||||
|
internal sealed record UpdateAvailable(UpdateInfo Info) : UpdateCheckResult;
|
||||||
|
|
||||||
|
/// <summary>The check completed and the installed version is at or above the latest
|
||||||
|
/// release. Singleton — there's nothing to carry beyond the result type itself.</summary>
|
||||||
|
internal sealed record UpToDate : UpdateCheckResult
|
||||||
|
{
|
||||||
|
public static readonly UpToDate Instance = new();
|
||||||
|
private UpToDate() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The check could not complete. <see cref="Kind"/> is a coarse classifier the UI
|
||||||
|
/// uses to pick a plain-English message; <see cref="TechnicalDetail"/> is the raw exception
|
||||||
|
/// or HTTP-status message intended for log output and "what to send the developer" cases —
|
||||||
|
/// never put it in a user-facing dialog verbatim.</summary>
|
||||||
|
internal sealed record UpdateCheckFailed(FailureKind Kind, string TechnicalDetail) : UpdateCheckResult;
|
||||||
|
|
||||||
|
/// <summary>Why the update check couldn't complete. Lets the UI distinguish "your TLS stack
|
||||||
|
/// is too old to reach modern HTTPS servers" (a known and fixable Windows 7 issue) from
|
||||||
|
/// "your internet is down" so the message and any pointers we offer match the actual
|
||||||
|
/// problem.</summary>
|
||||||
|
internal enum FailureKind
|
||||||
|
{
|
||||||
|
/// <summary>The HTTPS handshake itself failed — usually means the OS's TLS or
|
||||||
|
/// certificate stack is too old. Most commonly seen on Windows 7 installs without
|
||||||
|
/// the TLS 1.2 enablement update (KB3140245) and SHA-2 code signing support
|
||||||
|
/// (KB4474419).</summary>
|
||||||
|
SecureConnection,
|
||||||
|
/// <summary>The HTTP call reached GitHub but got back an unexpected response (4xx /
|
||||||
|
/// 5xx HTTP status, malformed JSON, empty release list, etc).</summary>
|
||||||
|
HttpError,
|
||||||
|
/// <summary>The HTTP call timed out.</summary>
|
||||||
|
Timeout,
|
||||||
|
/// <summary>Generic "couldn't reach the server" — DNS, socket, no internet.</summary>
|
||||||
|
NetworkUnreachable,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user