Skip to content
167 changes: 160 additions & 7 deletions electron/auto-updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,29 @@ const mocks = vi.hoisted(() => ({
quitAndInstall: vi.fn(),
},
app: { isPackaged: true, getVersion: vi.fn(() => "1.9.2") },
installWindowsUpdate: vi.fn(),
assertWindowsUpdateIdle: vi.fn(),
}));

vi.mock("electron", () => ({ app: mocks.app }));
vi.mock("electron-updater", () => ({ autoUpdater: mocks.autoUpdater }));
// `reg query` finds no install, so the handover below is the same on every platform.
vi.mock("node:child_process", () => ({ spawnSync: vi.fn(() => ({ status: 1 })) }));
vi.mock("./windows-update", () => ({
installWindowsUpdate: mocks.installWindowsUpdate,
assertWindowsUpdateIdle: mocks.assertWindowsUpdateIdle,
}));
vi.mock("node:child_process", () => ({ spawnSync: vi.fn() }));

function missingInstallQuery(_command: string, args?: readonly string[]) {
const root = args?.[1]?.startsWith("HKLM") ? "HKEY_LOCAL_MACHINE" : "HKEY_CURRENT_USER";
return {
status: args?.includes("/v") ? 1 : 0,
stdout: `\r\n${root}\\Software\\OtherApp\r\n`,
} as ReturnType<typeof spawnSync>;
}

beforeEach(() => {
vi.mocked(spawnSync).mockReset().mockImplementation(missingInstallQuery);
});

function state(overrides: Partial<InstallReadiness> = {}): InstallReadiness {
return { recording: false, inApplicationsFolder: true, platform: "linux", ...overrides };
Expand All @@ -50,6 +67,12 @@ describe("blockedFromInstalling", () => {
}
});

it("blocks a Windows handoff with unsaved editor changes", () => {
expect(blockedFromInstalling(state({ platform: "win32", editorHasUnsavedChanges: true }))).toBe(
"unsaved-changes",
);
});

// App Translocation: a quarantined app runs from a read-only image where Squirrel cannot
// replace the bundle. Pinned per-platform because CI is Linux-only and an unpinned branch
// would be green here and wrong on the one platform it applies to.
Expand Down Expand Up @@ -84,6 +107,7 @@ describe("self-update flow", () => {
mocks.autoUpdater.checkForUpdates.mockReset();
mocks.autoUpdater.downloadUpdate.mockReset();
mocks.autoUpdater.quitAndInstall.mockReset();
mocks.installWindowsUpdate.mockReset();
});

it("never touches the updater on a channel a package manager owns", async () => {
Expand Down Expand Up @@ -155,11 +179,20 @@ describe("self-update flow", () => {
expect(result).toMatchObject({ error: { message: "connection reset" } });
});

// isSilent=true so Windows shows only UAC, not the NSIS wizard (#1437). isForceRunAfter=true
// so the app comes back.
it("hands over to the installer silently and relaunches", async () => {
await installSelfUpdate();
// Other platforms retain electron-updater's own quit/relaunch handoff.
it("retains quitAndInstall on macOS and Linux", async () => {
const platform = Object.getOwnPropertyDescriptor(process, "platform");
try {
for (const value of ["darwin", "linux"]) {
Object.defineProperty(process, "platform", { value });
await installSelfUpdate();
}
} finally {
if (platform) Object.defineProperty(process, "platform", platform);
}
expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledTimes(2);
expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledWith(true, true);
expect(mocks.installWindowsUpdate).not.toHaveBeenCalled();
});

it("hands over to the wizard on Windows when both installs answer", async () => {
Expand All @@ -170,9 +203,129 @@ describe("self-update flow", () => {
await installSelfUpdate();
} finally {
if (platform) Object.defineProperty(process, "platform", platform);
vi.mocked(spawnSync).mockReturnValue({ status: 1 } as ReturnType<typeof spawnSync>);
vi.mocked(spawnSync).mockImplementation(missingInstallQuery);
}
expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledWith(false, true);
expect(mocks.installWindowsUpdate).not.toHaveBeenCalled();
});

it("waits for the Windows installer without quitting before the UAC decision", async () => {
const platform = Object.getOwnPropertyDescriptor(process, "platform");
const canInstall = vi.fn(() => true);
let finish: (() => void) | undefined;
mocks.installWindowsUpdate.mockImplementation(async (download: () => Promise<string[]>) => {
await download();
await new Promise<void>((resolve) => {
finish = resolve;
});
});
mocks.autoUpdater.downloadUpdate.mockResolvedValue(["verified-installer.exe"]);
vi.mocked(spawnSync).mockImplementation((command, args) =>
args?.[1]?.startsWith("HKLM")
? ({ status: 0 } as ReturnType<typeof spawnSync>)
: missingInstallQuery(command, args),
);
Object.defineProperty(process, "platform", { value: "win32" });
try {
let settled = false;
const install = installSelfUpdate(canInstall).then(() => {
settled = true;
});
await vi.waitFor(() => expect(mocks.autoUpdater.downloadUpdate).toHaveBeenCalledOnce());
expect(settled).toBe(false);
expect(mocks.installWindowsUpdate).toHaveBeenCalledWith(expect.any(Function), canInstall);
expect(mocks.autoUpdater.quitAndInstall).not.toHaveBeenCalled();
finish?.();
await install;
} finally {
finish?.();
if (platform) Object.defineProperty(process, "platform", platform);
vi.mocked(spawnSync).mockImplementation(missingInstallQuery);
}
});

it("does not request elevation for a per-user Windows update", async () => {
const platform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32" });
try {
await installSelfUpdate();
} finally {
if (platform) Object.defineProperty(process, "platform", platform);
}
expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledWith(true, true);
expect(mocks.installWindowsUpdate).not.toHaveBeenCalled();
});

it("does not enter a legacy Windows handoff while another elevation is pending", async () => {
const platform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32" });
mocks.assertWindowsUpdateIdle.mockImplementationOnce(() => {
throw new Error("A Windows update is pending");
});
try {
await expect(installSelfUpdate()).rejects.toThrow("Windows update is pending");
} finally {
if (platform) Object.defineProperty(process, "platform", platform);
}
expect(mocks.autoUpdater.quitAndInstall).not.toHaveBeenCalled();
expect(mocks.installWindowsUpdate).not.toHaveBeenCalled();
});

it.each(["HKLM", "HKCU"])("keeps the app open when the %s query fails", async (hive) => {
const platform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32" });
vi.mocked(spawnSync).mockImplementation((command, args) =>
args?.[1]?.startsWith(hive)
? ({ status: null } as ReturnType<typeof spawnSync>)
: missingInstallQuery(command, args),
);
try {
await expect(installSelfUpdate()).rejects.toThrow(
"Could not confirm the Windows installation type",
);
} finally {
if (platform) Object.defineProperty(process, "platform", platform);
vi.mocked(spawnSync).mockImplementation(missingInstallQuery);
}
expect(mocks.autoUpdater.quitAndInstall).not.toHaveBeenCalled();
expect(mocks.installWindowsUpdate).not.toHaveBeenCalled();
});

it.each([
"HKLM",
"HKCU",
])("does not mistake a failed %s value query for absence", async (hive) => {
const platform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32" });
vi.mocked(spawnSync).mockImplementation((command, args) => {
if (!args?.[1]?.startsWith(hive)) return missingInstallQuery(command, args);
if (args.includes("/v")) return { status: 1 } as ReturnType<typeof spawnSync>;
const root = hive === "HKLM" ? "HKEY_LOCAL_MACHINE" : "HKEY_CURRENT_USER";
return {
status: 0,
stdout: `${root}\\${NSIS_INSTALL_KEY}\r\n`,
} as ReturnType<typeof spawnSync>;
});
try {
await expect(installSelfUpdate()).rejects.toThrow("Could not confirm");
} finally {
if (platform) Object.defineProperty(process, "platform", platform);
}
expect(mocks.autoUpdater.quitAndInstall).not.toHaveBeenCalled();
expect(mocks.installWindowsUpdate).not.toHaveBeenCalled();
});

it("keeps the app open when parent enumeration also fails", async () => {
const platform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32" });
vi.mocked(spawnSync).mockReturnValue({ status: 1 } as ReturnType<typeof spawnSync>);
try {
await expect(installSelfUpdate()).rejects.toThrow("Could not confirm");
} finally {
if (platform) Object.defineProperty(process, "platform", platform);
}
expect(mocks.autoUpdater.quitAndInstall).not.toHaveBeenCalled();
expect(mocks.installWindowsUpdate).not.toHaveBeenCalled();
});
});

Expand Down
50 changes: 43 additions & 7 deletions electron/auto-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type UpdateOutcome =
* all" because these are transient — the answer changes as the user works. */
export interface InstallReadiness {
recording: boolean;
/** A Windows handoff cannot wait on the editor's close prompt with input disabled. */
editorHasUnsavedChanges?: boolean;
/** macOS only. Gatekeeper runs a quarantined app from a read-only image (App Translocation),
* where Squirrel cannot replace the bundle; worse, an app can BECOME translocated after an
* update and then never update again. */
Expand All @@ -33,6 +35,7 @@ export interface InstallReadiness {
/** Pure so the veto can be tested without an updater, a Mac, or a recording. */
export function blockedFromInstalling(state: InstallReadiness): string | null {
if (state.recording) return "recording";
if (state.platform === "win32" && state.editorHasUnsavedChanges) return "unsaved-changes";
if (state.platform === "darwin" && !state.inApplicationsFolder) return "not-in-applications";
return null;
}
Expand Down Expand Up @@ -87,12 +90,29 @@ export async function downloadSelfUpdate(): Promise<UpdateOutcome> {
* under HKLM per machine and HKCU per user. auto-updater.test.ts pins it to the appId. */
export const NSIS_INSTALL_KEY = "Software\\5b412b41-8d8b-5faf-b557-de4fe681e5fc";

function hasNsisInstall(hive: "HKLM" | "HKCU"): boolean {
function nsisInstallStatus(hive: "HKLM" | "HKCU"): number | null {
const key = `${hive}\\${NSIS_INSTALL_KEY}`;
const args = ["query", key, "/v", "InstallLocation", "/reg:64"];
// Exit 1 is reg's "key not found". Anything else, reg itself failing to run included, counts
// as present, so a doubt keeps the wizard rather than risking the wrong copy.
return spawnSync("reg", args, { windowsHide: true }).status !== 1;
const { status } = spawnSync("reg", args, { windowsHide: true });
if (status !== 1) return status;
// reg's exit 1 means any failure, including access denied. Confirm absence by
// successfully enumerating the parent, without parsing localized error text.
const parent = spawnSync("reg", ["query", `${hive}\\Software`, "/reg:64"], {
windowsHide: true,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
Comment thread
EtienneLescot marked this conversation as resolved.
Resolved
if (parent.status !== 0) return null;
const root = hive === "HKLM" ? "HKEY_LOCAL_MACHINE" : "HKEY_CURRENT_USER";
const keys = parent.stdout.split(/\r?\n/).map((line) => line.trim().toLowerCase());
return keys.includes(`${root}\\${NSIS_INSTALL_KEY}`.toLowerCase()) ? null : 1;
}

function hasNsisInstall(hive: "HKLM" | "HKCU"): boolean {
// Status 1 is returned only after confirming absence. Any uncertain result counts
// as present, so this selector never treats an uncertain copy as absent. The installer
// coordinator below refuses failed queries rather than quitting with an unknown target.
return nsisInstallStatus(hive) !== 1;
}

/** Whether the installer can run with /S. Without it the assisted NSIS installer shows its whole
Expand All @@ -107,9 +127,25 @@ export function installsSilently(
return platform !== "win32" || !(hasInstall("HKLM") && hasInstall("HKCU"));
}

/** Quit and hand over to the installer. Callers MUST have checked `blockedFromInstalling`. */
export async function installSelfUpdate(): Promise<void> {
/** Hand over to the installer. Callers MUST have checked `blockedFromInstalling`. */
export async function installSelfUpdate(canInstall: () => boolean = () => true): Promise<void> {
const autoUpdater = await getUpdater();
let silent = true;
if (process.platform === "win32") {
const { installWindowsUpdate, assertWindowsUpdateIdle } = await import("./windows-update");
// A concurrent check must not fall back to quitting while another UAC is pending.
assertWindowsUpdateIdle();
const installed = { HKLM: nsisInstallStatus("HKLM"), HKCU: nsisInstallStatus("HKCU") };
if (Object.values(installed).some((status) => status !== 0 && status !== 1)) {
throw new Error("Could not confirm the Windows installation type. Please retry the update.");
}
silent = installsSilently("win32", (hive) => installed[hive] === 0);
if (silent && installed.HKLM === 0) {
await installWindowsUpdate(() => autoUpdater.downloadUpdate(), canInstall);
return;
}
}
if (!canInstall()) return;
// isForceRunAfter=true so the app comes back up.
autoUpdater.quitAndInstall(installsSilently(), true);
autoUpdater.quitAndInstall(silent, true);
}
3 changes: 2 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ interface Window {
recording: boolean,
recordingId?: number,
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
) => Promise<void>;
reserveOnly?: boolean,
) => Promise<{ success: boolean; error?: string }>;
isNativeWindowsCaptureAvailable: () => Promise<{
success: boolean;
available: boolean;
Expand Down
Loading
Loading