Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .github/workflows/desktop-acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ jobs:
name: ${{ needs.build-assets.outputs.complete_artifact }}
path: release/desktop-acceptance

- name: Carry forward previous Shell blockmap for differential updates
- name: Carry forward previous Shell blockmap for legacy updater fallback
if: needs.prepare.outputs.has_previous_desktop == 'true' && needs.prepare.outputs.release_kind == 'full'
shell: bash
env:
Expand All @@ -193,6 +193,10 @@ jobs:
--dir release/desktop-acceptance \
--clobber

- name: Force full Shell installer download
if: needs.prepare.outputs.release_kind == 'full'
run: pnpm desktop:force-full-download -- --directory release/desktop-acceptance

- name: Download acceptance public key
uses: actions/download-artifact@v4
with:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ jobs:
path: release/desktop-release-final
merge-multiple: true

- name: Carry forward previous Shell blockmap for differential updates
- name: Carry forward previous Shell blockmap for legacy updater fallback
if: needs.prepare.outputs.has_previous_desktop == 'true' && needs.prepare.outputs.release_kind == 'full'
shell: bash
env:
Expand All @@ -368,6 +368,10 @@ jobs:
--dir release/desktop-release-final \
--clobber

- name: Force full Shell installer download
if: needs.prepare.outputs.release_kind == 'full'
run: pnpm desktop:force-full-download -- --directory release/desktop-release-final

- name: Build signed Desktop channel
shell: bash
run: |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"smoke:desktop": "tsx scripts/smoke-desktop-package.ts",
"desktop:channel": "tsx scripts/build-desktop-channel.ts",
"desktop:artifacts": "tsx scripts/desktop-release-artifacts.ts",
"desktop:force-full-download": "tsx scripts/force-desktop-full-download.ts",
"changeset": "changeset",
"changeset:validate": "tsx scripts/validate-changesets.ts",
"version-packages": "changeset version",
Expand Down
34 changes: 34 additions & 0 deletions packages/desktop/src/update-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ function createUpdater() {
autoDownload: true,
autoInstallOnAppQuit: true,
allowPrerelease: false,
disableDifferentialDownload: false,
disableWebInstaller: false,
on: (event: string, listener: (value: unknown) => void) => {
emitter.on(event, listener);
return updater;
Expand Down Expand Up @@ -49,6 +51,8 @@ describe("DesktopShellUpdateAdapter", () => {
});
expect(updater.autoDownload).toBe(false);
expect(updater.autoInstallOnAppQuit).toBe(false);
expect(updater.disableDifferentialDownload).toBe(true);
expect(updater.disableWebInstaller).toBe(true);
expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
});

Expand Down Expand Up @@ -126,4 +130,34 @@ describe("DesktopShellUpdateAdapter", () => {
recoveryAction: "https://releases.example/desktop",
});
});

it("cancels a Shell download that stops reporting progress", async () => {
vi.useFakeTimers();
try {
const updater = createUpdater();
const token = { cancel: vi.fn() };
const adapter = new DesktopShellUpdateAdapter({
updater,
currentVersion: "0.2.0",
isPackaged: true,
createCancellationToken: () => token,
downloadInactivityTimeoutMs: 1_000,
});
const metadata = await adapter.checkMetadata(expectedShell);
const download = adapter.download(metadata, vi.fn());
const rejected = expect(download).rejects.toMatchObject({ name: "TimeoutError" });

await vi.advanceTimersByTimeAsync(900);
updater.emit("download-progress", { percent: 1 });
await vi.advanceTimersByTimeAsync(900);
expect(token.cancel).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);

await rejected;
expect(token.cancel).toHaveBeenCalledTimes(1);
expect(adapter.cancelDownload()).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
56 changes: 48 additions & 8 deletions packages/desktop/src/update-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface ShellUpdaterPort {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
allowPrerelease: boolean;
disableDifferentialDownload: boolean;
disableWebInstaller: boolean;
on(event: "download-progress", listener: (value: { percent?: number }) => void): unknown;
on(event: "update-downloaded", listener: (value: { version?: string }) => void): unknown;
on(event: "error", listener: (value: unknown) => void): unknown;
Expand Down Expand Up @@ -38,6 +40,7 @@ export interface DesktopShellUpdateAdapterOptions {
createCancellationToken?: () => ShellCancellationToken;
logLocations?: string[];
manualInstallerUrl?: string | null;
downloadInactivityTimeoutMs?: number;
}

interface ActiveDownload {
Expand All @@ -46,8 +49,11 @@ interface ActiveDownload {
onProgress: (percent: number) => void;
resolve: () => void;
reject: (error: Error) => void;
inactivityTimer: ReturnType<typeof setTimeout> | null;
}

const DEFAULT_DOWNLOAD_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1_000;

class LocalCancellationToken extends EventEmitter implements ShellCancellationToken {
cancelled = false;

Expand All @@ -70,6 +76,13 @@ export class ShellCancellationError extends Error {
}
}

export class ShellDownloadTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`Desktop Shell download made no progress for ${Math.ceil(timeoutMs / 1_000)} seconds`);
this.name = "TimeoutError";
}
}

export class DesktopShellUpdateAdapter {
private started = false;
private activeDownload: ActiveDownload | null = null;
Expand All @@ -82,30 +95,56 @@ export class DesktopShellUpdateAdapter {
this.started = true;
this.options.updater.autoDownload = false;
this.options.updater.autoInstallOnAppQuit = false;
this.options.updater.disableDifferentialDownload = true;
this.options.updater.disableWebInstaller = true;
this.options.updater.allowPrerelease =
this.options.allowPrerelease === true || this.options.currentVersion.includes("-");
this.options.updater.on("download-progress", (progress: { percent?: number }) => {
if (!this.activeDownload || typeof progress.percent !== "number") return;
this.resetDownloadInactivityTimer(this.activeDownload);
this.activeDownload.onProgress(Math.max(0, Math.min(100, progress.percent)));
});
this.options.updater.on("update-downloaded", (info: { version?: string }) => {
const active = this.activeDownload;
const active = this.takeActiveDownload();
if (!active) return;
this.activeDownload = null;
if (info.version !== active.expectedVersion) {
active.reject(new Error("Downloaded Desktop Shell does not match signed Desktop channel"));
return;
}
active.resolve();
});
this.options.updater.on("error", (error: unknown) => {
const active = this.activeDownload;
const active = this.takeActiveDownload();
if (!active) return;
this.activeDownload = null;
active.reject(error instanceof Error ? error : new Error(String(error)));
});
}

private takeActiveDownload(): ActiveDownload | null {
const active = this.activeDownload;
if (!active) return null;
this.activeDownload = null;
if (active.inactivityTimer) clearTimeout(active.inactivityTimer);
active.inactivityTimer = null;
return active;
}

private resetDownloadInactivityTimer(active: ActiveDownload): void {
if (active.inactivityTimer) clearTimeout(active.inactivityTimer);
const timeoutMs =
this.options.downloadInactivityTimeoutMs ?? DEFAULT_DOWNLOAD_INACTIVITY_TIMEOUT_MS;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
active.inactivityTimer = null;
return;
}
active.inactivityTimer = setTimeout(() => {
if (this.activeDownload !== active) return;
this.takeActiveDownload();
active.token.cancel();
active.reject(new ShellDownloadTimeoutError(timeoutMs));
}, timeoutMs);
}

async checkMetadata(expected: DesktopChannel["shell"]): Promise<ShellUpdateMetadata> {
this.start();
const versionOrder = compareVersions(expected.version, this.options.currentVersion);
Expand Down Expand Up @@ -149,20 +188,21 @@ export class DesktopShellUpdateAdapter {
onProgress,
resolve,
reject,
inactivityTimer: null,
};
this.resetDownloadInactivityTimer(this.activeDownload);
void this.options.updater.downloadUpdate(token).catch((error: unknown) => {
const active = this.activeDownload;
if (!active || active.token !== token) return;
this.activeDownload = null;
active.reject(error instanceof Error ? error : new Error(String(error)));
const failed = this.takeActiveDownload();
failed?.reject(error instanceof Error ? error : new Error(String(error)));
});
});
}

cancelDownload(): boolean {
const active = this.activeDownload;
const active = this.takeActiveDownload();
if (!active) return false;
this.activeDownload = null;
active.token.cancel();
active.reject(new ShellCancellationError());
return true;
Expand Down
62 changes: 62 additions & 0 deletions scripts/force-desktop-full-download.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gunzipSync, gzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import { forceDesktopFullDownload } from "./force-desktop-full-download.js";

const roots: string[] = [];

async function createFixture() {
const root = await mkdtemp(join(tmpdir(), "coder-studio-full-download-"));
roots.push(root);
const installer = "Coder-Studio-Setup-0.1.2.exe";
await writeFile(join(root, "latest.yml"), `version: 0.1.2\npath: ${installer}\n`);
await writeFile(
join(root, `${installer}.blockmap`),
gzipSync(
JSON.stringify({
version: "2",
files: [{ name: "file", offsets: [0], checksums: ["checksum"], sizes: [123] }],
})
)
);
return { root, installer };
}

afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});

describe("force-desktop-full-download", () => {
it("marks the target blockmap as incompatible so legacy clients fall back to the full installer", async () => {
const fixture = await createFixture();

await expect(forceDesktopFullDownload(fixture.root)).resolves.toMatchObject({
originalVersion: "2",
forcedVersion: "2-coder-studio-full-download-0.1.2",
changed: true,
});
const blockmap = JSON.parse(
gunzipSync(await readFile(join(fixture.root, `${fixture.installer}.blockmap`))).toString(
"utf8"
)
);
expect(blockmap).toEqual({
version: "2-coder-studio-full-download-0.1.2",
files: [{ name: "file", offsets: [0], checksums: ["checksum"], sizes: [123] }],
});

await expect(forceDesktopFullDownload(fixture.root)).resolves.toMatchObject({
forcedVersion: "2-coder-studio-full-download-0.1.2",
changed: false,
});
});

it("rejects updater paths that escape the release directory", async () => {
const fixture = await createFixture();
await writeFile(join(fixture.root, "latest.yml"), "version: 0.1.2\npath: ../outside.exe\n");

await expect(forceDesktopFullDownload(fixture.root)).rejects.toThrow("inside");
});
});
Loading
Loading