* fix(desktop): disable auto-install on quit for AppImage only
electron-updater's AppImageUpdater.doInstall() uses execFileSync with APPIMAGE_EXIT_AFTER_INSTALL when autoInstallOnAppQuit is true. For AppImages, this blocks the old process indefinitely because the new process has no installer step to exit from — the mv already completed the install.
Scoped to AppImage only (process.platform === 'linux' && process.env.APPIMAGE). .deb/.rpm/Windows/macOS keep auto-install-on-quit working as before.
* fix(desktop): keep AppImage filename stable across updates
electron-updater renames a versioned AppImage to a new path on update and
unlinks the old one. That orphaned the previous binary, broke desktop
shortcuts, and dangled the ~/.local/bin/paseo CLI symlink, which points at
$APPIMAGE. Dropping ${version} from the AppImage artifactName makes the
updater overwrite the file in place instead; deb/rpm keep versioned names.
Also documents why AppImage must not auto-install on quit (the blocking
execFileSync gated on APPIMAGE_EXIT_AFTER_INSTALL, honored only by
AppImageLauncher) and extracts that gate into a unit-tested helper.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
200 lines
5.8 KiB
TypeScript
200 lines
5.8 KiB
TypeScript
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { UUID } from "builder-util-runtime";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("electron", () => ({
|
|
app: {
|
|
getPath: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("electron-updater", () => ({
|
|
autoUpdater: {},
|
|
}));
|
|
|
|
import {
|
|
bucketFromStagingUserId,
|
|
resolveStagingUserId,
|
|
rolloutManifestSchema,
|
|
shouldAdmitToRollout,
|
|
shouldAutoInstallOnQuit,
|
|
} from "./auto-updater";
|
|
|
|
describe("shouldAutoInstallOnQuit", () => {
|
|
it("auto-installs on quit everywhere except Linux AppImage", () => {
|
|
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
|
|
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: false })).toBe(true);
|
|
expect(shouldAutoInstallOnQuit({ platform: "darwin", isAppImage: false })).toBe(true);
|
|
expect(shouldAutoInstallOnQuit({ platform: "win32", isAppImage: false })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("shouldAdmitToRollout", () => {
|
|
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "beta",
|
|
rolloutHours: 24,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
|
bucket: 0.99,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: undefined,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
|
bucket: 0.99,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 0,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
|
bucket: 0.99,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: undefined,
|
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
|
bucket: 0.99,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("blocks future releases and respects the linear threshold mid-rollout", () => {
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: "2026-04-28T02:00:00.000Z",
|
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
|
bucket: 0,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
|
bucket: 0.49,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
|
bucket: 0.51,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("blocks the bucket-zero client at exact release time, admits as soon as time advances", () => {
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2026-04-28T00:00:00.000Z"),
|
|
bucket: 0,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2026-04-28T00:00:00.001Z"),
|
|
bucket: 0,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("admits the highest-bucket client at and past the rollout end", () => {
|
|
const maxBucket = (0x100000000 - 1) / 0x100000000;
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2026-04-29T00:00:00.000Z"),
|
|
bucket: maxBucket,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
|
now: Date.parse("2027-04-28T00:00:00.000Z"),
|
|
bucket: maxBucket,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("admits when releaseDate is unparseable", () => {
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: 24,
|
|
releaseDate: "not a date",
|
|
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
|
bucket: 0.99,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("treats garbage manifest rollout fields as missing and admits", () => {
|
|
const parsed = rolloutManifestSchema.parse({
|
|
rolloutHours: "not a number",
|
|
releaseDate: 12345,
|
|
});
|
|
|
|
expect(
|
|
shouldAdmitToRollout({
|
|
channel: "stable",
|
|
rolloutHours: parsed.rolloutHours,
|
|
releaseDate: parsed.releaseDate,
|
|
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
|
bucket: 0.99,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("maps the maximum 32-bit slot to a bucket strictly less than 1", () => {
|
|
const allOnes = "ffffffff-ffff-ffff-ffff-ffffffffffff";
|
|
const allZeros = "00000000-0000-0000-0000-000000000000";
|
|
expect(bucketFromStagingUserId(allOnes)).toBeLessThan(1);
|
|
expect(bucketFromStagingUserId(allOnes)).toBeGreaterThan(0.999);
|
|
expect(bucketFromStagingUserId(allZeros)).toBe(0);
|
|
});
|
|
|
|
it("creates and then reuses the on-disk staging user id", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paseo-updater-id-"));
|
|
const filePath = path.join(tempDir, ".updaterId");
|
|
|
|
try {
|
|
const first = await resolveStagingUserId(filePath);
|
|
const stored = (await readFile(filePath, "utf8")).trim();
|
|
const second = await resolveStagingUserId(filePath);
|
|
|
|
expect(UUID.check(stored)).toBeTruthy();
|
|
expect(second).toBe(first);
|
|
} finally {
|
|
await rm(tempDir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
});
|