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
1 change: 1 addition & 0 deletions src/main/ipc/localHandlers.remoteHttpRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function makeHandlers() {
updatePowerSaveBlocker: vi.fn<() => void>(),
autoUpdater: {
initialize: vi.fn<() => void>(),
getStatus: vi.fn<() => null>(() => null),
checkForUpdate: vi.fn<() => Promise<void>>(async () => {}),
startUpdateDownload: vi.fn<() => Promise<void>>(async () => {}),
installUpdate: vi.fn<() => void>(),
Expand Down
1 change: 1 addition & 0 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,7 @@ export function createLocalIpcHandlers(
upsertPrWatch: (watch) => options.prWatchService.upsert(watch),
deletePrWatch: ({ projectId, prNumber }) => options.prWatchService.delete(projectId, prNumber),
syncPrWatchAgent: (agent) => options.prWatchService.syncAgent(agent),
getUpdateStatus: () => options.autoUpdater.getStatus(),
checkForUpdate: () => options.autoUpdater.checkForUpdate(),
startUpdateDownload: () => options.autoUpdater.startUpdateDownload(),
installUpdate: () => options.autoUpdater.installUpdate(),
Expand Down
10 changes: 2 additions & 8 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ import {
type PrWatchStatusEvent,
type QuickComposerSubmission,
type SupervisorEvent,
type UpdateStatus,
} from "@/shared/ipc";
import type { SharedSettings } from "@/shared/settings";
import { readSharedSettingsFile, writeSharedSettingsFile } from "./sharedSettingsFile";
Expand Down Expand Up @@ -936,10 +935,6 @@ if (!hasSingleInstanceLock) {
mainWindow?.webContents.send(IPC_EVENT_CHANNELS.gitStateChanged, patch);
},
});
// Latest updater status, captured from the auto-updater's status stream so
// the app-controls `check_for_update` tool can report the most recent
// result (the check itself is fire-and-forget and event-driven).
let lastUpdateStatus: UpdateStatus | null = null;
appControlsMcpIngress = new AppControlsMcpIngress({
scheduleService,
getThread: dbGetThread,
Expand Down Expand Up @@ -982,7 +977,7 @@ if (!hasSingleInstanceLock) {
},
checkForUpdate: async () => {
await autoUpdaterController.checkForUpdate();
const status = lastUpdateStatus;
const status = autoUpdaterController.getStatus();
const availableVersion =
status && (status.type === "update-available" || status.type === "downloaded")
? status.version
Expand All @@ -999,7 +994,6 @@ if (!hasSingleInstanceLock) {

const autoUpdaterController = createAutoUpdaterController(
(status) => {
lastUpdateStatus = status;
mainWindow?.webContents.send(IPC_EVENT_CHANNELS.updateStatus, status);
},
channel,
Expand Down Expand Up @@ -1100,7 +1094,7 @@ if (!hasSingleInstanceLock) {
gitStateService,
updates: {
currentVersion: () => app.getVersion(),
status: () => lastUpdateStatus,
status: () => autoUpdaterController.getStatus(),
check: () => autoUpdaterController.checkForUpdate(),
install: () => autoUpdaterController.installUpdate(),
},
Expand Down
10 changes: 10 additions & 0 deletions src/main/updates/autoUpdater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ describe("createAutoUpdaterController", () => {
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true);
});

it("retains the latest status for a renderer that subscribes after the update finishes", () => {
const controller = createAutoUpdaterController(vi.fn(), "stable", false);
controller.initialize();

autoUpdaterMock.emit("update-available", { version: "1.2.3" });
autoUpdaterMock.emit("update-downloaded", { version: "1.2.3" });

expect(controller.getStatus()).toEqual({ type: "downloaded", version: "1.2.3" });
});

it("starts the controller-owned download when a check finds an update", async () => {
const controller = createAutoUpdaterController(vi.fn(), "stable", false);
controller.initialize();
Expand Down
10 changes: 9 additions & 1 deletion src/main/updates/autoUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,20 @@ const TRANSIENT_REPORT_COOLDOWN_MS = 6 * 60 * 60 * 1_000;

export interface AutoUpdaterController {
initialize(): void;
getStatus(): UpdateStatus | null;
checkForUpdate(): Promise<void>;
startUpdateDownload(): Promise<void>;
installUpdate(): void;
}

export function createAutoUpdaterController(
sendStatus: (status: UpdateStatus) => void,
onStatus: (status: UpdateStatus) => void,
channel: PoracodeChannel,
isDev: boolean,
reportError: (error: unknown, tags?: PoracodeDiagnosticTags) => void = () => {},
beforeInstall: () => void = () => {},
): AutoUpdaterController {
let lastStatus: UpdateStatus | null = null;
let initialized = false;
// True while a check or download is in flight; gates the periodic timer so a
// scheduled tick never stacks a redundant check on top of an active one.
Expand All @@ -59,6 +61,11 @@ export function createAutoUpdaterController(
let activeAttempt: { operation: UpdateOperation; eventError: unknown | null } | null = null;
const transientReportTimes = new Map<string, number>();

function sendStatus(status: UpdateStatus): void {
lastStatus = status;
onStatus(status);
}

function reportClassifiedFailure(operation: UpdateOperation, outcome: UpdateFailureKind): void {
if (outcome === "optional-manifest-missing") {
console.warn("[poracode] optional nightly update manifest is not available.");
Expand Down Expand Up @@ -318,6 +325,7 @@ export function createAutoUpdaterController(

return {
initialize,
getStatus: () => lastStatus,
checkForUpdate,
startUpdateDownload,
installUpdate,
Expand Down
102 changes: 101 additions & 1 deletion src/renderer/app.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Fragment, type ReactNode } from "react";
import { toast } from "@heroui/react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithI18n as render } from "@/renderer/testUtils/i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand All @@ -7,6 +8,7 @@ import type {
QuickComposerSubmission,
SupervisorEvent,
ThreadOpenRequestedEvent,
UpdateStatus,
} from "@/shared/ipc";
import { useAppStore } from "./state/appStore";
import { useGitStore } from "./state/gitStore";
Expand All @@ -16,6 +18,7 @@ import { useExperimentStore } from "./state/experimentStore";
import { useWorkspaceStore } from "./state/workspaceStore";
import { resetDevTerminalStore, useDevTerminalStore } from "./state/devTerminalStore";
import { useThreadOutputStore } from "./state/threadOutputStore";
import { useUpdateStore } from "./state/updateStore";
import { gitMergeAndRemove } from "@/renderer/actions/gitActions";
import { openThread, unloadThread } from "@/renderer/actions/threadActions";

Expand Down Expand Up @@ -186,6 +189,7 @@ const {
gitWatchWorktrees: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
gitUnwatchProject: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
checkForUpdate: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
getUpdateStatus: vi.fn<() => Promise<null>>().mockResolvedValue(null),
startUpdateDownload: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
installUpdate: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
relaunchApp: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
Expand Down Expand Up @@ -401,7 +405,7 @@ vi.mock("./state/sharedSettingsStore", () => ({
),
}));

import { App, STARTUP_RECOVERY_TIMEOUT_MS } from "./app";
import { App, installUpdateStatusSync, STARTUP_RECOVERY_TIMEOUT_MS } from "./app";

describe("App", () => {
const originalHasHydrated = useAppStore.persist.hasHydrated;
Expand Down Expand Up @@ -437,6 +441,15 @@ describe("App", () => {
}));
resetDevTerminalStore();
useThreadOutputStore.setState({ buffers: {} });
useUpdateStore.setState({
phase: "idle",
version: null,
downloadPercent: 0,
errorMessage: null,
downloadTransferred: null,
downloadTotal: null,
downloadBytesPerSecond: null,
});
useExperimentStore.setState({ experiments: {} });
useGitStore.setState({
statuses: {},
Expand Down Expand Up @@ -467,6 +480,93 @@ describe("App", () => {
vi.useRealTimers();
});

it("restores a completed update when the renderer subscribed after it finished", async () => {
const unsubscribe = installUpdateStatusSync({
getUpdateStatus: vi
.fn<() => Promise<UpdateStatus | null>>()
.mockResolvedValue({ type: "downloaded", version: "1.2.3" }),
onUpdateStatus: vi.fn<() => () => void>(() => () => undefined),
});

await waitFor(() => {
expect(useUpdateStore.getState()).toMatchObject({
phase: "downloaded",
version: "1.2.3",
downloadPercent: 100,
});
});
unsubscribe();
});

it("does not let an older snapshot replace a newly started download", async () => {
let resolveSnapshot!: (status: UpdateStatus | null) => void;
let statusListener!: (status: UpdateStatus) => void;
const unsubscribe = installUpdateStatusSync({
getUpdateStatus: vi.fn<() => Promise<UpdateStatus | null>>(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve;
}),
),
onUpdateStatus: vi.fn<(listener: (status: UpdateStatus) => void) => () => void>(
(listener) => {
statusListener = listener;
return () => undefined;
},
),
});

statusListener({ type: "update-available", version: "1.2.4" });
resolveSnapshot({ type: "downloaded", version: "1.2.3" });
await Promise.resolve();

expect(useUpdateStore.getState()).toMatchObject({
phase: "downloading",
version: "1.2.4",
downloadPercent: 0,
});
unsubscribe();
});

it("ignores a snapshot that resolves after its subscription was disposed", async () => {
let resolveSnapshot!: (status: UpdateStatus | null) => void;
const unsubscribe = installUpdateStatusSync({
getUpdateStatus: vi.fn<() => Promise<UpdateStatus | null>>(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve;
}),
),
onUpdateStatus: vi.fn<() => () => void>(() => () => undefined),
});

unsubscribe();
useUpdateStore.getState().beginUpdateDownload("1.2.4");
resolveSnapshot({ type: "downloaded", version: "1.2.3" });
await Promise.resolve();

expect(useUpdateStore.getState()).toMatchObject({ phase: "downloading", version: "1.2.4" });
});

it("restores an error snapshot without repeating its toast", async () => {
const danger = vi.spyOn(toast, "danger").mockImplementation(() => "toast-id");
const unsubscribe = installUpdateStatusSync({
getUpdateStatus: vi
.fn<() => Promise<UpdateStatus | null>>()
.mockResolvedValue({ type: "error", message: "Update failed" }),
onUpdateStatus: vi.fn<() => () => void>(() => () => undefined),
});

await waitFor(() => {
expect(useUpdateStore.getState()).toMatchObject({
phase: "error",
errorMessage: "Update failed",
});
});
expect(danger).not.toHaveBeenCalled();
unsubscribe();
});

it("offers recovery controls when initial hydration does not finish", async () => {
vi.useFakeTimers();
useAppStore.persist.hasHydrated = vi.fn<() => boolean>().mockReturnValue(false);
Expand Down
29 changes: 26 additions & 3 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ function installThreadOutputPruning(): () => void {
};
}

function handleUpdateStatus(status: UpdateStatus): void {
function handleUpdateStatus(status: UpdateStatus, notifyError = true): void {
const store = useUpdateStore.getState();
switch (status.type) {
case "checking":
Expand All @@ -315,20 +315,43 @@ function handleUpdateStatus(status: UpdateStatus): void {
case "error": {
const detail = status.messageKey ? msg(status.messageKey) : status.message;
store.setError(detail);
toast.danger(msg("update.error", { detail }));
if (notifyError) toast.danger(msg("update.error", { detail }));
break;
}
}
}

export function installUpdateStatusSync(
bridge: Pick<ReturnType<typeof readBridge>, "getUpdateStatus" | "onUpdateStatus"> = readBridge(),
): () => void {
let disposed = false;
let receivedLiveStatus = false;
const unsubscribe = bridge.onUpdateStatus((status) => {
receivedLiveStatus = true;
handleUpdateStatus(status);
});
void bridge
.getUpdateStatus()
.then((status) => {
if (!disposed && !receivedLiveStatus && status) handleUpdateStatus(status, false);
})
.catch((error: unknown) => {
if (!disposed) console.error("[poracode][updates] get-update-status failed", error);
});
return () => {
disposed = true;
unsubscribe();
};
}

// The browser-extract window renders a standalone BrowserPanel; it has no use
// for supervisor/update streams, remote-client bridges, or runtime persistence,
// so only the main window wires these up (and tears them down on HMR dispose).
const mainWindowCleanups: Array<() => void> = isMainWindow
? [
readBridge().onSupervisorEvent(handleSupervisorEvent),
installRuntimeEventScheduling(),
readBridge().onUpdateStatus(handleUpdateStatus),
installUpdateStatusSync(),
// Thread-metadata commands issued from paired remote clients (mobile PWA).
// They run through the same actions as local edits so persistence and
// side effects (unload on archive, …) stay identical.
Expand Down
1 change: 1 addition & 0 deletions src/shared/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe("ipcProcedureMap", () => {
updatePowerSaveBlocker: vi.fn<() => void>(),
autoUpdater: {
initialize: vi.fn<() => void>(),
getStatus: vi.fn<() => null>(() => null),
checkForUpdate: vi.fn<() => Promise<void>>(),
startUpdateDownload: vi.fn<() => Promise<void>>(),
installUpdate: vi.fn<() => void>(),
Expand Down
1 change: 1 addition & 0 deletions src/shared/ipc/procedureMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const MAIN_LOCAL_PROCEDURE_NAMES = [
"dbGetThreadContextUsage",
"dbGetProjectNotes",
"dbSetProjectNotes",
"getUpdateStatus",
"checkForUpdate",
"startUpdateDownload",
"installUpdate",
Expand Down
5 changes: 5 additions & 0 deletions src/shared/ipc/procedures/updates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { defineNoArgProcedure } from "../core";
import type { UpdateStatus } from "../events";

export const updatesProcedures = {
getUpdateStatus: defineNoArgProcedure<UpdateStatus | null, "main-local">(
"getUpdateStatus",
"main-local",
),
checkForUpdate: defineNoArgProcedure<void, "main-local">("checkForUpdate", "main-local"),
startUpdateDownload: defineNoArgProcedure<void, "main-local">(
"startUpdateDownload",
Expand Down