Skip to content
Closed
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
87 changes: 87 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -58,6 +60,91 @@ describe("DesktopUpdates", () => {
);
});

describe("getAutoUpdateDisabledReason", () => {
const linuxPackaged = {
isDevelopment: false,
isPackaged: true,
platform: "linux" as const,
disabledByEnv: false,
hasUpdateFeedConfig: true,
};

it("tells Linux package installs how to update by package-type marker", () => {
const reasonFor = (linuxPackageType: string | null) =>
DesktopUpdates.getAutoUpdateDisabledReason({ ...linuxPackaged, linuxPackageType });

assert.equal(
reasonFor("deb"),
"T3 Code was installed with apt. Update it with: sudo apt update && sudo apt upgrade",
);
assert.equal(
reasonFor("rpm"),
"T3 Code was installed with dnf. Update it with: sudo dnf upgrade",
);
assert.equal(
reasonFor("pacman"),
"T3 Code was installed from the AUR. Update it with your AUR helper, for example: yay -Syu",
);
assert.equal(
reasonFor("snap"),
"T3 Code was installed by your system package manager. Update it with that package manager.",
);
assert.equal(
reasonFor(null),
"Automatic updates on Linux require running the AppImage build.",
);
});

it("ignores the marker when running as an AppImage or when an earlier reason applies", () => {
assert.isNull(
DesktopUpdates.getAutoUpdateDisabledReason({
...linuxPackaged,
linuxPackageType: "deb",
appImage: "/tmp/T3-Code.AppImage",
}),
);
assert.equal(
DesktopUpdates.getAutoUpdateDisabledReason({
...linuxPackaged,
linuxPackageType: "deb",
disabledByEnv: true,
}),
"Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.",
);
});
});

it.effect("reads the package-type marker and exposes the reason in the disabled state", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const resourcesPath = yield* fileSystem.makeTempDirectoryScoped();
yield* fileSystem.writeFileString(`${resourcesPath}/package-type`, "deb\n");
const harness = makeHarness({ platform: "linux", resourcesPath });

yield* Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;

const expected =
"T3 Code was installed with apt. Update it with: sudo apt update && sudo apt upgrade";
const state = yield* updates.getState;
assert.equal(state.enabled, false);
assert.equal(state.status, "disabled");
assert.equal(state.message, expected);
assert.equal(harness.sentStates.at(-1)?.message, expected);
// Remote requests and the app menu read this instead of the state.
assert.equal(Option.getOrNull(yield* updates.disabledReason), expected);
assert.equal(harness.listenerCount(), 0);

const nightly = yield* updates.setChannel("nightly");
assert.equal(nightly.status, "disabled");
assert.equal(nightly.message, expected);
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);

it.effect("configures the updater and runs startup checks on the test clock", () => {
const harness = makeHarness();

Expand Down
64 changes: 53 additions & 11 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,19 @@ function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYm
);
}

/** Idle state when updates run, or a disabled state that carries the reason
in `message` so clients can show it instead of "up to date". */
function createBaseUpdateState(
channel: DesktopUpdateChannel,
enabled: boolean,
disabledReason: Option.Option<string>,
environment: DesktopEnvironment.DesktopEnvironment["Service"],
): DesktopUpdateState {
const enabled = Option.isNone(disabledReason);
return {
...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel),
enabled,
status: enabled ? "idle" : "disabled",
message: Option.getOrNull(disabledReason),
};
}

Expand All @@ -244,11 +248,36 @@ function shouldBroadcastDownloadProgress(
return nextStep !== previousStep || nextPercent === 100;
}

function getAutoUpdateDisabledReason(args: {
/**
* How a Linux install that is not running as an AppImage gets updates. The
* marker is the electron-builder `package-type` file next to the app
* resources; the deb and rpm builds write it, and the AUR PKGBUILDs write
* `pacman`. No marker means an extracted AppImage or an unknown layout.
*/
function getLinuxPackageUpdateReason(packageType: string | null): string {
switch (packageType) {
case null:
return "Automatic updates on Linux require running the AppImage build.";
case "deb":
return "T3 Code was installed with apt. Update it with: sudo apt update && sudo apt upgrade";
case "rpm":
return "T3 Code was installed with dnf. Update it with: sudo dnf upgrade";
case "pacman":
return "T3 Code was installed from the AUR. Update it with your AUR helper, for example: yay -Syu";
default:
return "T3 Code was installed by your system package manager. Update it with that package manager.";
}
}

/** Why automatic updates are off, or null when they can run. Reasons are
checked in priority order; `linuxPackageType` only matters on Linux
without `APPIMAGE`. Exported for tests. */
export function getAutoUpdateDisabledReason(args: {
isDevelopment: boolean;
isPackaged: boolean;
platform: NodeJS.Platform;
appImage?: string | undefined;
linuxPackageType: string | null;
disabledByEnv: boolean;
hasUpdateFeedConfig: boolean;
}): string | null {
Expand All @@ -262,7 +291,7 @@ function getAutoUpdateDisabledReason(args: {
return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
}
if (args.platform === "linux" && !args.appImage) {
return "Automatic updates on Linux require running the AppImage build.";
return getLinuxPackageUpdateReason(args.linuxPackageType);
}
return null;
}
Expand Down Expand Up @@ -335,14 +364,29 @@ export const make = Effect.gen(function* () {
Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),
);

// Only Linux package installs carry the marker. A missing, unreadable, or
// blank file means no marker.
const readLinuxPackageType: Effect.Effect<Option.Option<string>> =
environment.platform === "linux" && environment.isPackaged
? fileSystem
.readFileString(environment.path.join(environment.resourcesPath, "package-type"))
.pipe(
Effect.map((raw) => raw.trim()),
Effect.option,
Effect.map(Option.filter((packageType) => packageType.length > 0)),
)
: Effect.succeedNone;

const resolveDisabledReason = Effect.gen(function* () {
const hasFeedConfig = yield* hasUpdateFeedConfig;
const linuxPackageType = yield* readLinuxPackageType;
return Option.fromNullishOr(
getAutoUpdateDisabledReason({
isDevelopment: environment.isDevelopment,
isPackaged: environment.isPackaged,
platform: environment.platform,
appImage: Option.getOrUndefined(config.appImagePath),
linuxPackageType: Option.getOrNull(linuxPackageType),
disabledByEnv: config.disableAutoUpdate,
hasUpdateFeedConfig: hasFeedConfig,
}),
Expand Down Expand Up @@ -389,8 +433,6 @@ export const make = Effect.gen(function* () {
});
});

const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone));

const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (
reason: string,
actionReservation: "acquire" | "held" = "acquire",
Expand Down Expand Up @@ -878,9 +920,9 @@ export const make = Effect.gen(function* () {
}

const settings = yield* desktopSettings.get;
const enabled = yield* shouldEnableAutoUpdates;
yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment));
if (!enabled) {
const disabledReason = yield* resolveDisabledReason;
yield* setState(createBaseUpdateState(settings.updateChannel, disabledReason, environment));
if (Option.isSome(disabledReason)) {
return;
}
yield* Ref.set(updaterConfiguredRef, true);
Expand Down Expand Up @@ -949,10 +991,10 @@ export const make = Effect.gen(function* () {
),
);

const enabled = yield* shouldEnableAutoUpdates;
yield* setState(createBaseUpdateState(nextChannel, enabled, environment));
const disabledReason = yield* resolveDisabledReason;
yield* setState(createBaseUpdateState(nextChannel, disabledReason, environment));

if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {
if (Option.isSome(disabledReason) || !(yield* Ref.get(updaterConfiguredRef))) {
return yield* Ref.get(updateStateRef);
}

Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/updates/updatesTestHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export interface UpdatesHarnessOptions {
readonly stopBackend?: Effect.Effect<void>;
readonly startBackend?: Effect.Effect<void>;
readonly env?: Record<string, string | undefined>;
readonly platform?: NodeJS.Platform;
/** Directory the updater reads `package-type` from on Linux. */
readonly resourcesPath?: string;
}

export function makeHarness(options: UpdatesHarnessOptions = {}) {
Expand Down Expand Up @@ -145,12 +148,12 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
const environmentLayer = DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/src",
homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
platform: "darwin",
platform: options.platform ?? "darwin",
processArch: "x64",
appVersion: "1.2.3",
appPath: "/repo",
isPackaged: true,
resourcesPath: "/missing/resources",
resourcesPath: options.resourcesPath ?? "/missing/resources",
runningUnderArm64Translation: false,
}).pipe(
Layer.provide(
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,4 +346,15 @@ describe("getDesktopUpdateButtonTooltip", () => {
"Up to date",
);
});

it("shows the disabled reason instead of 'Up to date' when updates are off", () => {
const disabled: DesktopUpdateState = { ...baseState, enabled: false, status: "disabled" };
expect(
getDesktopUpdateButtonTooltip({
...disabled,
message: "T3 Code was installed with dnf. Update it with: sudo dnf upgrade",
}),
).toBe("T3 Code was installed with dnf. Update it with: sudo dnf upgrade");
expect(getDesktopUpdateButtonTooltip(disabled)).toBe("Automatic updates are off.");
});
});
5 changes: 5 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
}
return state.message ?? "Update failed";
}
if (state.status === "disabled") {
// The main process puts the disabled reason in `message`, for example how
// a Linux package install gets updates.
return state.message ?? "Automatic updates are off.";
}
return "Up to date";
}

Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ function SidebarUpdateControl() {
: "Update available"
: showCheckIcon
? "Checking for updates…"
: "Check for updates";
: state?.status === "disabled"
? getDesktopUpdateButtonTooltip(state)
: "Check for updates";
const disabled = showCheckIcon
? true
: showUpdateDetails
Expand Down
3 changes: 3 additions & 0 deletions packaging/aur/t3code-bin/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ package() {
cp -a --no-preserve=ownership "$srcdir/squashfs-root/." "$pkgdir/opt/$pkgname/"
chmod -R u=rwX,go=rX "$pkgdir/opt/$pkgname"
chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox"
# electron-updater package-type marker; the app reads it to explain how this install gets updates.
printf 'pacman\n' > "$pkgdir/opt/$pkgname/resources/package-type"
chmod 644 "$pkgdir/opt/$pkgname/resources/package-type"

install -Dm755 /dev/stdin "$pkgdir/usr/bin/t3code" <<'EOF'
#!/bin/sh
Expand Down
3 changes: 3 additions & 0 deletions packaging/aur/t3code-nightly-bin/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ package() {
cp -a --no-preserve=ownership "$srcdir/squashfs-root/." "$pkgdir/opt/$pkgname/"
chmod -R u=rwX,go=rX "$pkgdir/opt/$pkgname"
chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox"
# electron-updater package-type marker; the app reads it to explain how this install gets updates.
printf 'pacman\n' > "$pkgdir/opt/$pkgname/resources/package-type"
chmod 644 "$pkgdir/opt/$pkgname/resources/package-type"

install -Dm755 /dev/stdin "$pkgdir/usr/bin/t3code-nightly" <<'EOF'
#!/bin/sh
Expand Down
Loading