diff --git a/apps/code/src/main/services/settingsStore.ts b/apps/code/src/main/services/settingsStore.ts index 2acf4a02f..774b75b78 100644 --- a/apps/code/src/main/services/settingsStore.ts +++ b/apps/code/src/main/services/settingsStore.ts @@ -11,6 +11,7 @@ interface SettingsSchema { autoSuspendEnabled: boolean; maxActiveWorktrees: number; autoSuspendAfterDays: number; + autoDownloadUpdates: boolean; } function getDefaultWorktreeLocation(): string { @@ -84,6 +85,10 @@ const schema = { minimum: 1, maximum: 365, }, + autoDownloadUpdates: { + type: "boolean" as const, + default: true, + }, }; export const settingsStore = new Store({ @@ -96,6 +101,7 @@ export const settingsStore = new Store({ autoSuspendEnabled: true, maxActiveWorktrees: 5, autoSuspendAfterDays: 7, + autoDownloadUpdates: true, }, }); @@ -166,3 +172,11 @@ export function getAutoSuspendAfterDays(): number { export function setAutoSuspendAfterDays(value: number): void { settingsStore.set("autoSuspendAfterDays", value); } + +export function getAutoDownloadUpdatesEnabled(): boolean { + return settingsStore.get("autoDownloadUpdates", true); +} + +export function setAutoDownloadUpdatesEnabled(value: boolean): void { + settingsStore.set("autoDownloadUpdates", value); +} diff --git a/apps/code/src/main/services/updates/schemas.ts b/apps/code/src/main/services/updates/schemas.ts index dbdef957a..63c975d18 100644 --- a/apps/code/src/main/services/updates/schemas.ts +++ b/apps/code/src/main/services/updates/schemas.ts @@ -17,10 +17,24 @@ export const installUpdateOutput = z.object({ installed: z.boolean(), }); +export const autoDownloadUpdatesOutput = z.object({ + enabled: z.boolean(), +}); + +export const setAutoDownloadUpdatesInput = z.object({ + enabled: z.boolean(), +}); + export type IsEnabledOutput = z.infer; export type CheckForUpdatesOutput = z.infer; export type InstallUpdateOutput = z.infer; +export type AutoDownloadUpdatesOutput = z.infer< + typeof autoDownloadUpdatesOutput +>; +export type SetAutoDownloadUpdatesInput = z.infer< + typeof setAutoDownloadUpdatesInput +>; export const UpdatesEvent = { Ready: "ready", diff --git a/apps/code/src/main/services/updates/service.test.ts b/apps/code/src/main/services/updates/service.test.ts index a91bae898..ecf05eae0 100644 --- a/apps/code/src/main/services/updates/service.test.ts +++ b/apps/code/src/main/services/updates/service.test.ts @@ -98,6 +98,14 @@ vi.mock("../../utils/env.js", () => ({ isDevBuild: () => !mockAppMeta.isProduction, })); +const mockAutoDownloadUpdatesEnabled = vi.hoisted(() => vi.fn(() => true)); +const mockSetAutoDownloadUpdatesEnabled = vi.hoisted(() => vi.fn()); + +vi.mock("../settingsStore.js", () => ({ + getAutoDownloadUpdatesEnabled: mockAutoDownloadUpdatesEnabled, + setAutoDownloadUpdatesEnabled: mockSetAutoDownloadUpdatesEnabled, +})); + // Import the service after mocks are set up import { UpdatesService } from "./service"; @@ -135,6 +143,7 @@ describe("UpdatesService", () => { mockAppMeta.version = "1.0.0"; mockUpdater.isSupported.mockReturnValue(true); mockAppLifecycle.whenReady.mockResolvedValue(undefined); + mockAutoDownloadUpdatesEnabled.mockReturnValue(true); // Set default platform to darwin (macOS) Object.defineProperty(process, "platform", { @@ -257,6 +266,16 @@ describe("UpdatesService", () => { expect(mockAppLifecycle.whenReady).not.toHaveBeenCalled(); }); + it("skips startup and periodic checks when auto-download is disabled", async () => { + mockAutoDownloadUpdatesEnabled.mockReturnValue(false); + + await initializeService(service); + + expect(mockUpdater.check).not.toHaveBeenCalled(); + vi.advanceTimersByTime(60 * 60 * 1000); + expect(mockUpdater.check).not.toHaveBeenCalled(); + }); + it("prevents multiple initializations", async () => { await initializeService(service); @@ -404,6 +423,49 @@ describe("UpdatesService", () => { }); }); + describe("settings", () => { + it.each([true, false])( + "reads auto-download toggle from settings store (%s)", + (value) => { + mockAutoDownloadUpdatesEnabled.mockReturnValue(value); + expect(service.autoDownloadUpdatesEnabled).toBe(value); + }, + ); + + it.each([true, false])( + "persists auto-download toggle changes (%s)", + (value) => { + service.setAutoDownloadUpdatesEnabled(value); + expect(mockSetAutoDownloadUpdatesEnabled).toHaveBeenCalledWith(value); + }, + ); + + it("stops periodic checks when auto-download is disabled at runtime", async () => { + await initializeService(service); + updaterHandlers.noUpdate?.(); + mockUpdater.check.mockClear(); + + service.setAutoDownloadUpdatesEnabled(false); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + + expect(mockUpdater.check).not.toHaveBeenCalled(); + }); + + it("starts periodic checks when auto-download is enabled at runtime", async () => { + mockAutoDownloadUpdatesEnabled.mockReturnValue(false); + await initializeService(service); + mockUpdater.check.mockClear(); + + service.setAutoDownloadUpdatesEnabled(true); + expect(mockUpdater.check).toHaveBeenCalledTimes(1); + + updaterHandlers.noUpdate?.(); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + + expect(mockUpdater.check).toHaveBeenCalledTimes(2); + }); + }); + describe("installUpdate", () => { it("returns false when no update is ready", async () => { const result = await service.installUpdate(); diff --git a/apps/code/src/main/services/updates/service.ts b/apps/code/src/main/services/updates/service.ts index fdbec5d1d..497c7c3d7 100644 --- a/apps/code/src/main/services/updates/service.ts +++ b/apps/code/src/main/services/updates/service.ts @@ -8,6 +8,10 @@ import { isDevBuild } from "../../utils/env"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; import type { AppLifecycleService } from "../app-lifecycle/service"; +import { + getAutoDownloadUpdatesEnabled, + setAutoDownloadUpdatesEnabled, +} from "../settingsStore"; import { type CheckForUpdatesOutput, type InstallUpdateOutput, @@ -91,6 +95,28 @@ export class UpdatesService extends TypedEventEmitter { this.appLifecycle.whenReady().then(() => this.setupAutoUpdater()); } + get autoDownloadUpdatesEnabled(): boolean { + return getAutoDownloadUpdatesEnabled(); + } + + setAutoDownloadUpdatesEnabled(enabled: boolean): void { + const previous = this.autoDownloadUpdatesEnabled; + setAutoDownloadUpdatesEnabled(enabled); + + if (this.initialized) { + if (enabled) { + this.startAutoDownloadChecks(); + } else { + this.stopAutoDownloadChecks(); + } + } + + log.info("Auto-download updates setting changed", { + previous, + current: enabled, + }); + } + triggerMenuCheck(): void { this.emit(UpdatesEvent.CheckFromMenu, true); } @@ -191,13 +217,13 @@ export class UpdatesService extends TypedEventEmitter { ), ); - // Perform initial check (periodic source — not user-initiated) - this.checkForUpdates("periodic"); + if (this.autoDownloadUpdatesEnabled) { + this.startAutoDownloadChecks(); + return; + } - // Set up periodic checks - this.checkIntervalId = setInterval( - () => this.checkForUpdates("periodic"), - UpdatesService.CHECK_INTERVAL_MS, + log.info( + "Auto-download updates disabled; skipping startup and periodic checks", ); } @@ -329,13 +355,31 @@ export class UpdatesService extends TypedEventEmitter { } } + private startAutoDownloadChecks(): void { + if (this.checkIntervalId) { + return; + } + + this.checkForUpdates("periodic"); + this.checkIntervalId = setInterval( + () => this.checkForUpdates("periodic"), + UpdatesService.CHECK_INTERVAL_MS, + ); + } + + private stopAutoDownloadChecks(): void { + if (!this.checkIntervalId) { + return; + } + + clearInterval(this.checkIntervalId); + this.checkIntervalId = null; + } + @preDestroy() shutdown(): void { this.clearCheckTimeout(); - if (this.checkIntervalId) { - clearInterval(this.checkIntervalId); - this.checkIntervalId = null; - } + this.stopAutoDownloadChecks(); for (const unsub of this.unsubscribes) unsub(); this.unsubscribes = []; } diff --git a/apps/code/src/main/trpc/routers/updates.ts b/apps/code/src/main/trpc/routers/updates.ts index 7cabefb5e..3d3b20c6e 100644 --- a/apps/code/src/main/trpc/routers/updates.ts +++ b/apps/code/src/main/trpc/routers/updates.ts @@ -1,9 +1,11 @@ import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; import { + autoDownloadUpdatesOutput, checkForUpdatesOutput, installUpdateOutput, isEnabledOutput, + setAutoDownloadUpdatesInput, UpdatesEvent, type UpdatesEvents, } from "../../services/updates/schemas"; @@ -39,6 +41,19 @@ export const updatesRouter = router({ return service.installUpdate(); }), + getAutoDownload: publicProcedure + .output(autoDownloadUpdatesOutput) + .query(() => ({ enabled: getService().autoDownloadUpdatesEnabled })), + + setAutoDownload: publicProcedure + .input(setAutoDownloadUpdatesInput) + .output(autoDownloadUpdatesOutput) + .mutation(({ input }) => { + const service = getService(); + service.setAutoDownloadUpdatesEnabled(input.enabled); + return { enabled: service.autoDownloadUpdatesEnabled }; + }), + onReady: subscribe(UpdatesEvent.Ready), onStatus: subscribe(UpdatesEvent.Status), onCheckFromMenu: subscribe(UpdatesEvent.CheckFromMenu), diff --git a/apps/code/src/renderer/features/settings/components/sections/UpdatesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/UpdatesSettings.tsx index c83f0ce19..60508a4c4 100644 --- a/apps/code/src/renderer/features/settings/components/sections/UpdatesSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/UpdatesSettings.tsx @@ -1,9 +1,11 @@ import { SettingRow } from "@features/settings/components/SettingRow"; import { CheckCircle, XCircle } from "@phosphor-icons/react"; -import { Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes"; +import { Badge, Button, Flex, Spinner, Switch, Text } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; +import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -11,11 +13,18 @@ const log = logger.scope("updates-settings"); export function UpdatesSettings() { const trpcReact = useTRPC(); + const queryClient = useQueryClient(); const { data: appVersion } = useQuery( trpcReact.os.getAppVersion.queryOptions(), ); const [checkingForUpdates, setCheckingForUpdates] = useState(false); const [updatesDisabled, setUpdatesDisabled] = useState(false); + const { data: autoDownload } = useQuery( + trpcReact.updates.getAutoDownload.queryOptions(), + ); + const [autoDownloadEnabled, setAutoDownloadEnabled] = useState< + boolean | undefined + >(undefined); const [updateStatus, setUpdateStatus] = useState<{ message?: string; type?: "info" | "success" | "error"; @@ -25,6 +34,9 @@ export function UpdatesSettings() { const checkUpdatesMutation = useMutation( trpcReact.updates.check.mutationOptions(), ); + const setAutoDownloadMutation = useMutation( + trpcReact.updates.setAutoDownload.mutationOptions(), + ); const handleCheckForUpdates = useCallback(async () => { setCheckingForUpdates(true); @@ -62,11 +74,21 @@ export function UpdatesSettings() { }, [checkUpdatesMutation]); useEffect(() => { - if (!hasCheckedRef.current) { - hasCheckedRef.current = true; + if (typeof autoDownload?.enabled !== "boolean" || hasCheckedRef.current) { + return; + } + + hasCheckedRef.current = true; + if (autoDownload.enabled) { handleCheckForUpdates(); } - }, [handleCheckForUpdates]); + }, [autoDownload?.enabled, handleCheckForUpdates]); + + useEffect(() => { + if (typeof autoDownload?.enabled === "boolean") { + setAutoDownloadEnabled(autoDownload.enabled); + } + }, [autoDownload?.enabled]); useSubscription( trpcReact.updates.onStatus.subscriptionOptions(undefined, { @@ -87,6 +109,12 @@ export function UpdatesSettings() { type: "success", }); setCheckingForUpdates(false); + } else if (status.checking === false && status.error) { + setUpdateStatus({ + message: status.error, + type: "error", + }); + setCheckingForUpdates(false); } else if (status.checking === false) { setCheckingForUpdates(false); } @@ -102,9 +130,46 @@ export function UpdatesSettings() { + + {typeof autoDownloadEnabled === "boolean" && ( + { + const previous = autoDownloadEnabled; + if (previous === checked) return; + + setAutoDownloadEnabled(checked); + setAutoDownloadMutation.mutate( + { enabled: checked }, + { + onError: () => { + setAutoDownloadEnabled(previous); + }, + onSuccess: (result) => { + setAutoDownloadEnabled(result.enabled); + void queryClient.invalidateQueries( + trpcReact.updates.getAutoDownload.queryFilter(), + ); + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "auto_download_updates", + old_value: previous, + new_value: result.enabled, + }); + }, + }, + ); + }} + disabled={setAutoDownloadMutation.isPending} + /> + )} + +