diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 509778521511..29df2b900bbe 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -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"; @@ -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(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index c35b52e8343d..cce77167ee15 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -210,15 +210,19 @@ function parseAppUpdateYml(raw: string): Effect.Effect, 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), }; } @@ -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 { @@ -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; } @@ -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> = + 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, }), @@ -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", @@ -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); @@ -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); } diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index fbcbb349f9e7..c98430fceb68 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -32,6 +32,9 @@ export interface UpdatesHarnessOptions { readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; readonly env?: Record; + readonly platform?: NodeJS.Platform; + /** Directory the updater reads `package-type` from on Linux. */ + readonly resourcesPath?: string; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -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( diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index fcc97825b681..cb0c8ce16440 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -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."); + }); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 4a169cb3ef40..730f2d576b21 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -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"; } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index e1609d4d3bc4..9342159b67b8 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -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 diff --git a/packaging/aur/t3code-bin/PKGBUILD b/packaging/aur/t3code-bin/PKGBUILD index c5219666bf08..5de625c7fe74 100644 --- a/packaging/aur/t3code-bin/PKGBUILD +++ b/packaging/aur/t3code-bin/PKGBUILD @@ -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 diff --git a/packaging/aur/t3code-nightly-bin/PKGBUILD b/packaging/aur/t3code-nightly-bin/PKGBUILD index f3b61c7d5223..fc544515e554 100644 --- a/packaging/aur/t3code-nightly-bin/PKGBUILD +++ b/packaging/aur/t3code-nightly-bin/PKGBUILD @@ -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