Skip to content
Open
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
56 changes: 41 additions & 15 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { NetService } from "@t3tools/shared/Net";
import { RotatingFileSink } from "@t3tools/shared/logging";
import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
import { showDesktopConfirmDialog } from "./confirmDialog";
import { normalizeDesktopUpdateError } from "./updateErrors";
import { syncShellEnvironment } from "./syncShellEnvironment";
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
import {
Expand Down Expand Up @@ -806,11 +807,16 @@ async function checkForUpdates(reason: string): Promise<boolean> {
await autoUpdater.checkForUpdates();
return true;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const normalizedError = normalizeDesktopUpdateError(error, "check");
setUpdateState(
reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
reduceDesktopUpdateStateOnCheckFailure(
updateState,
normalizedError.message,
new Date().toISOString(),
normalizedError.toastAction,
),
);
console.error(`[desktop-updater] Failed to check for updates: ${message}`);
console.error(`[desktop-updater] Failed to check for updates: ${normalizedError.rawMessage}`);
return true;
} finally {
updateCheckInFlight = false;
Expand All @@ -830,9 +836,15 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed
await autoUpdater.downloadUpdate();
return { accepted: true, completed: true };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message));
console.error(`[desktop-updater] Failed to download update: ${message}`);
const normalizedError = normalizeDesktopUpdateError(error, "download");
setUpdateState(
reduceDesktopUpdateStateOnDownloadFailure(
updateState,
normalizedError.message,
normalizedError.toastAction,
),
);
console.error(`[desktop-updater] Failed to download update: ${normalizedError.rawMessage}`);
return { accepted: true, completed: false };
} finally {
updateDownloadInFlight = false;
Expand All @@ -859,11 +871,17 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed
autoUpdater.quitAndInstall(true, true);
return { accepted: true, completed: false };
} catch (error: unknown) {
const message = formatErrorMessage(error);
const normalizedError = normalizeDesktopUpdateError(error, "install");
updateInstallInFlight = false;
isQuitting = false;
setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message));
console.error(`[desktop-updater] Failed to install update: ${message}`);
setUpdateState(
reduceDesktopUpdateStateOnInstallFailure(
updateState,
normalizedError.message,
normalizedError.toastAction,
),
);
console.error(`[desktop-updater] Failed to install update: ${normalizedError.rawMessage}`);
return { accepted: true, completed: false };
}
}
Expand Down Expand Up @@ -939,25 +957,33 @@ function configureAutoUpdater(): void {
console.info("[desktop-updater] No updates available.");
});
autoUpdater.on("error", (error) => {
const message = formatErrorMessage(error);
const errorContext = resolveUpdaterErrorContext();
const normalizedError = normalizeDesktopUpdateError(error, errorContext);
if (updateInstallInFlight) {
updateInstallInFlight = false;
isQuitting = false;
setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message));
console.error(`[desktop-updater] Updater error: ${message}`);
setUpdateState(
reduceDesktopUpdateStateOnInstallFailure(
updateState,
normalizedError.message,
normalizedError.toastAction,
),
);
console.error(`[desktop-updater] Updater error: ${normalizedError.rawMessage}`);
return;
}
if (!updateCheckInFlight && !updateDownloadInFlight) {
setUpdateState({
status: "error",
message,
message: normalizedError.message,
checkedAt: new Date().toISOString(),
downloadPercent: null,
errorContext: resolveUpdaterErrorContext(),
errorContext,
canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null,
toastAction: normalizedError.toastAction,
});
}
console.error(`[desktop-updater] Updater error: ${message}`);
console.error(`[desktop-updater] Updater error: ${normalizedError.rawMessage}`);
});
autoUpdater.on("download-progress", (progress) => {
const percent = Math.floor(progress.percent);
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/src/updateErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";

import { normalizeDesktopUpdateError } from "./updateErrors";

describe("normalizeDesktopUpdateError", () => {
it("maps network-style check errors to a friendly message", () => {
expect(normalizeDesktopUpdateError(new Error("net::ERR_CONNECTION_REFUSED"), "check")).toEqual({
message: "Couldn't reach the update server. Check your connection and try again.",
rawMessage: "net::ERR_CONNECTION_REFUSED",
toastAction: null,
});
});

it("maps checksum download failures to a retryable message", () => {
expect(
normalizeDesktopUpdateError(
new Error("sha512 checksum mismatch, expected abc but got def"),
"download",
),
).toEqual({
message: "The downloaded update could not be verified. Try downloading it again.",
rawMessage: "sha512 checksum mismatch, expected abc but got def",
toastAction: {
kind: "desktop-update.retry-download",
label: "Retry download",
},
});
});

it("falls back to the raw message when it is already useful", () => {
expect(
normalizeDesktopUpdateError(new Error("Release feed returned malformed JSON"), "check"),
).toEqual({
message: "Release feed returned malformed JSON",
rawMessage: "Release feed returned malformed JSON",
toastAction: null,
});
});
});
76 changes: 76 additions & 0 deletions apps/desktop/src/updateErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { DesktopToastAction, DesktopUpdateState } from "@t3tools/contracts";

type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];

const RETRY_DOWNLOAD_TOAST_ACTION: DesktopToastAction = {
kind: "desktop-update.retry-download",
label: "Retry download",
};

const NETWORK_ERROR_PATTERN =
/\b(EAI_AGAIN|ECONNABORTED|ECONNREFUSED|ECONNRESET|ENETUNREACH|ENOTFOUND|ERR_CONNECTION_(?:CLOSED|REFUSED|RESET|TIMED_OUT)|ERR_INTERNET_DISCONNECTED|ERR_NAME_NOT_RESOLVED|ETIMEDOUT|socket hang up)\b/i;
const CHECKSUM_ERROR_PATTERN =
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit over-engineered, as upstream Electron Updater should only ever have one error message, but this does make our implementation (hopefully) future-proof if they change that error message. We may also be able to key off their ERR_CHECKSUM_MISMATCH code, if we desire.

/\b(checksum|sha(?:256|512)?|hash)\b.*\b(mismatch|invalid|failed|different)\b|\b(mismatch|invalid|failed|different)\b.*\b(checksum|sha(?:256|512)?|hash)\b/i;

function formatDesktopUpdateRawError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

function getFallbackDesktopUpdateErrorMessage(context: DesktopUpdateErrorContext): string {
if (context === "check") return "Couldn't check for updates.";
if (context === "download") return "Couldn't download the update.";
if (context === "install") return "Couldn't install the update.";
return "Update failed.";
}

function isNetworkErrorMessage(message: string): boolean {
return NETWORK_ERROR_PATTERN.test(message);
}

function isChecksumErrorMessage(message: string): boolean {
return CHECKSUM_ERROR_PATTERN.test(message);
}

export function normalizeDesktopUpdateError(
error: unknown,
context: DesktopUpdateErrorContext,
): {
message: string;
rawMessage: string;
toastAction: DesktopToastAction | null;
} {
const rawMessage = formatDesktopUpdateRawError(error).trim();

if (context === "download" && isChecksumErrorMessage(rawMessage)) {
return {
message: "The downloaded update could not be verified. Try downloading it again.",
rawMessage,
toastAction: RETRY_DOWNLOAD_TOAST_ACTION,
};
}

if (isNetworkErrorMessage(rawMessage)) {
if (context === "download") {
return {
message:
"Couldn't download the update because the update server is unavailable. Try again in a moment.",
rawMessage,
toastAction: null,
};
}
return {
message: "Couldn't reach the update server. Check your connection and try again.",
rawMessage,
toastAction: null,
};
}

return {
message: rawMessage.length > 0 ? rawMessage : getFallbackDesktopUpdateErrorMessage(context),
rawMessage,
toastAction: null,
};
}
11 changes: 11 additions & 0 deletions apps/desktop/src/updateMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe("updateMachine", () => {
expect(state.status).toBe("error");
expect(state.errorContext).toBe("check");
expect(state.canRetry).toBe(true);
expect(state.toastAction).toBeNull();
});

it("preserves available version on download failure for retry", () => {
Expand All @@ -65,12 +66,20 @@ describe("updateMachine", () => {
downloadPercent: 43,
},
"checksum mismatch",
{
kind: "desktop-update.retry-download",
label: "Retry download",
},
);

expect(state.status).toBe("available");
expect(state.availableVersion).toBe("1.1.0");
expect(state.errorContext).toBe("download");
expect(state.canRetry).toBe(true);
expect(state.toastAction).toEqual({
kind: "desktop-update.retry-download",
label: "Retry download",
});
});

it("transitions to downloaded and then preserves install retry state", () => {
Expand Down Expand Up @@ -133,7 +142,9 @@ describe("updateMachine", () => {
expect(available.status).toBe("available");
expect(downloading.status).toBe("downloading");
expect(downloading.downloadPercent).toBe(0);
expect(downloading.toastAction).toBeNull();
expect(progress.downloadPercent).toBe(55.5);
expect(progress.errorContext).toBeNull();
expect(progress.toastAction).toBeNull();
});
});
19 changes: 18 additions & 1 deletion apps/desktop/src/updateMachine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts";
import type {
DesktopRuntimeInfo,
DesktopToastAction,
DesktopUpdateState,
} from "@t3tools/contracts";

import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";

Expand All @@ -20,6 +24,7 @@ export function createInitialDesktopUpdateState(
message: null,
errorContext: null,
canRetry: false,
toastAction: null,
};
}

Expand All @@ -35,13 +40,15 @@ export function reduceDesktopUpdateStateOnCheckStart(
downloadPercent: null,
errorContext: null,
canRetry: false,
toastAction: null,
};
}

export function reduceDesktopUpdateStateOnCheckFailure(
state: DesktopUpdateState,
message: string,
checkedAt: string,
toastAction: DesktopToastAction | null = null,
): DesktopUpdateState {
return {
...state,
Expand All @@ -51,6 +58,7 @@ export function reduceDesktopUpdateStateOnCheckFailure(
downloadPercent: null,
errorContext: "check",
canRetry: true,
toastAction,
};
}

Expand All @@ -69,6 +77,7 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(
message: null,
errorContext: null,
canRetry: false,
toastAction: null,
};
}

Expand All @@ -86,6 +95,7 @@ export function reduceDesktopUpdateStateOnNoUpdate(
message: null,
errorContext: null,
canRetry: false,
toastAction: null,
};
}

Expand All @@ -99,12 +109,14 @@ export function reduceDesktopUpdateStateOnDownloadStart(
message: null,
errorContext: null,
canRetry: false,
toastAction: null,
};
}

export function reduceDesktopUpdateStateOnDownloadFailure(
state: DesktopUpdateState,
message: string,
toastAction: DesktopToastAction | null = null,
): DesktopUpdateState {
return {
...state,
Expand All @@ -113,6 +125,7 @@ export function reduceDesktopUpdateStateOnDownloadFailure(
downloadPercent: null,
errorContext: "download",
canRetry: getCanRetryAfterDownloadFailure(state),
toastAction,
};
}

Expand All @@ -127,6 +140,7 @@ export function reduceDesktopUpdateStateOnDownloadProgress(
message: null,
errorContext: null,
canRetry: false,
toastAction: null,
};
}

Expand All @@ -143,18 +157,21 @@ export function reduceDesktopUpdateStateOnDownloadComplete(
message: null,
errorContext: null,
canRetry: true,
toastAction: null,
};
}

export function reduceDesktopUpdateStateOnInstallFailure(
state: DesktopUpdateState,
message: string,
toastAction: DesktopToastAction | null = null,
): DesktopUpdateState {
return {
...state,
status: "downloaded",
message,
errorContext: "install",
canRetry: true,
toastAction,
};
}
1 change: 1 addition & 0 deletions apps/desktop/src/updateState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const baseState: DesktopUpdateState = {
message: null,
errorContext: null,
canRetry: false,
toastAction: null,
};

describe("shouldBroadcastDownloadProgress", () => {
Expand Down
Loading
Loading