diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 54ab5b18ee..9f1d431eca 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -163,6 +163,7 @@ import type { ExternalAppsPreferences } from "@posthog/workspace-server/services import { foldersModule } from "@posthog/workspace-server/services/folders/folders.module"; import { GitService } from "@posthog/workspace-server/services/git/service"; import { TaskPrStatusService } from "@posthog/workspace-server/services/git/task-pr-status"; +import { githubReleasesModule } from "@posthog/workspace-server/services/github-releases/github-releases.module"; import { HANDOFF_GIT_GATEWAY, HANDOFF_LOG_GATEWAY, @@ -584,6 +585,7 @@ container.load(posthogPluginModule); container.bind(MAIN_POSTHOG_PLUGIN_SERVICE).toService(POSTHOG_PLUGIN_SERVICE); container.load(skillsModule); container.load(skillsMarketplaceModule); +container.load(githubReleasesModule); container.load(onboardingImportModule); container.load(additionalDirectoriesModule); container.bind(MAIN_SLEEP_SERVICE).to(SleepService); diff --git a/apps/code/src/main/platform-adapters/electron-updater.ts b/apps/code/src/main/platform-adapters/electron-updater.ts index e4c9cbf9be..c8d9ff3052 100644 --- a/apps/code/src/main/platform-adapters/electron-updater.ts +++ b/apps/code/src/main/platform-adapters/electron-updater.ts @@ -1,14 +1,39 @@ -import type { IUpdater } from "@posthog/platform/updater"; +import type { + IUpdater, + UpdateAvailableInfo, + UpdateDownloadProgress, +} from "@posthog/platform/updater"; import { app } from "electron"; import log from "electron-log/main"; -import { autoUpdater, type UpdateInfo } from "electron-updater"; +import { + autoUpdater, + type ProgressInfo, + type UpdateInfo, +} from "electron-updater"; import { injectable } from "inversify"; +function normalizeReleaseNotes( + notes: UpdateInfo["releaseNotes"], +): string | null { + if (!notes) return null; + if (typeof notes === "string") return notes; + const joined = notes + .map((n) => n.note ?? "") + .filter((n) => n.length > 0) + .join("\n\n"); + return joined.length > 0 ? joined : null; +} + @injectable() export class ElectronUpdater implements IUpdater { constructor() { autoUpdater.logger = log; autoUpdater.disableDifferentialDownload = true; + // Default to manual download; the "Download updates automatically" setting + // flips this via setAutoDownload(). A downloaded update always installs on the + // next quit, with an in-app Restart button for immediate install. + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; } public isSupported(): boolean { @@ -20,24 +45,54 @@ export class ElectronUpdater implements IUpdater { } public check(): void { - void autoUpdater.checkForUpdates(); + void autoUpdater.checkForUpdates().catch(() => undefined); + } + + public download(): void { + void autoUpdater.downloadUpdate().catch(() => undefined); } public quitAndInstall(): void { autoUpdater.quitAndInstall(false, true); } + public setAutoDownload(enabled: boolean): void { + autoUpdater.autoDownload = enabled; + } + public onCheckStart(handler: () => void): () => void { autoUpdater.on("checking-for-update", handler); return () => autoUpdater.off("checking-for-update", handler); } - public onUpdateAvailable(handler: () => void): () => void { - const l = (_info: UpdateInfo) => handler(); + public onUpdateAvailable( + handler: (info: UpdateAvailableInfo) => void, + ): () => void { + const l = (info: UpdateInfo) => + handler({ + version: info.version, + releaseNotes: normalizeReleaseNotes(info.releaseNotes), + releaseDate: info.releaseDate, + releaseName: info.releaseName, + }); autoUpdater.on("update-available", l); return () => autoUpdater.off("update-available", l); } + public onDownloadProgress( + handler: (progress: UpdateDownloadProgress) => void, + ): () => void { + const l = (info: ProgressInfo) => + handler({ + percent: info.percent, + bytesPerSecond: info.bytesPerSecond, + transferred: info.transferred, + total: info.total, + }); + autoUpdater.on("download-progress", l); + return () => autoUpdater.off("download-progress", l); + } + public onUpdateDownloaded(handler: (version: string) => void): () => void { const l = (info: UpdateInfo) => handler(info.version); autoUpdater.on("update-downloaded", l); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index ec493315df..31dabb003f 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -22,6 +22,7 @@ import { freeformGenRouter } from "@posthog/host-router/routers/freeform-gen.rou import { fsRouter } from "@posthog/host-router/routers/fs.router"; import { gitRouter } from "@posthog/host-router/routers/git.router"; import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router"; +import { githubReleasesRouter } from "@posthog/host-router/routers/github-releases.router"; import { handoffRouter } from "@posthog/host-router/routers/handoff.router"; import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router"; import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router"; @@ -74,6 +75,7 @@ export const trpcRouter = router({ fs: fsRouter, git: gitRouter, githubIntegration: githubIntegrationRouter, + githubReleases: githubReleasesRouter, handoff: handoffRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, diff --git a/apps/code/src/renderer/platform-adapters/updates.ts b/apps/code/src/renderer/platform-adapters/updates.ts index 7682e8ca44..360ea74fad 100644 --- a/apps/code/src/renderer/platform-adapters/updates.ts +++ b/apps/code/src/renderer/platform-adapters/updates.ts @@ -6,12 +6,15 @@ import { updateStore, } from "@posthog/core/updates/updateStore"; import { resolveService } from "@posthog/di/container"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { UPDATES_CLIENT, type UpdatesClient, } from "@posthog/ui/features/updates/updatesClient"; +import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; import { toast } from "@posthog/ui/primitives/toast"; import { logger } from "@posthog/ui/shell/logger"; +import { hostTrpcClient } from "@renderer/trpc/client"; const log = logger.scope("updates-host"); @@ -44,11 +47,8 @@ void client .getStatus() .then((status) => { const update = deriveUpdateUiStatus(status, store().status); - if (update?.status) { - store().setStatus(update.status); - } - if (update && "version" in update) { - store().setVersion(update.version ?? null); + if (update) { + store().applyStatusUpdate(update); } }) .catch((error: unknown) => { @@ -58,11 +58,8 @@ void client client.onStatus({ onData: (status) => { const update = deriveUpdateUiStatus(status, store().status); - if (update?.status) { - store().setStatus(update.status); - } - if (update && "version" in update) { - store().setVersion(update.version ?? null); + if (update) { + store().applyStatusUpdate(update); } const outcome = resolveMenuCheckFromStatus( @@ -119,3 +116,48 @@ client.onCheckFromMenu({ log.error("Update menu check subscription error", { error }); }, }); + +// Bridge the "download updates automatically" preference to the core updater. +let lastSyncedAutoDownload: boolean | null = null; +function syncAutoDownload(enabled: boolean): void { + if (enabled === lastSyncedAutoDownload) return; + lastSyncedAutoDownload = enabled; + void hostTrpcClient.updates.setAutoDownload + .mutate({ enabled }) + .catch((error: unknown) => + log.error("Failed to sync auto-download preference", { error }), + ); +} + +// Auto-show "What's New" once on the first launch after the version changes. +function maybeShowWhatsNew(): void { + void hostTrpcClient.os.getAppVersion + .query() + .then((currentVersion) => { + const settings = useSettingsStore.getState(); + const lastSeen = settings.lastSeenChangelogVersion; + if (lastSeen && lastSeen !== currentVersion) { + useWhatsNewStore.getState().open(); + } + if (lastSeen !== currentVersion) { + settings.setLastSeenChangelogVersion(currentVersion); + } + }) + .catch((error: unknown) => + log.error("Failed to evaluate What's New", { error }), + ); +} + +function onSettingsReady(): void { + syncAutoDownload(useSettingsStore.getState().downloadUpdatesAutomatically); + useSettingsStore.subscribe((state) => + syncAutoDownload(state.downloadUpdatesAutomatically), + ); + maybeShowWhatsNew(); +} + +if (useSettingsStore.persist.hasHydrated()) { + onSettingsReady(); +} else { + useSettingsStore.persist.onFinishHydration(onSettingsReady); +} diff --git a/packages/core/src/updates/schemas.ts b/packages/core/src/updates/schemas.ts index b4dd973948..4e36231afb 100644 --- a/packages/core/src/updates/schemas.ts +++ b/packages/core/src/updates/schemas.ts @@ -16,10 +16,16 @@ export const checkForUpdatesOutput = z.object({ export const updatesStatusOutput = z.object({ checking: z.boolean(), downloading: z.boolean().optional(), + available: z.boolean().optional(), upToDate: z.boolean().optional(), updateReady: z.boolean().optional(), installing: z.boolean().optional(), version: z.string().optional(), + availableVersion: z.string().optional(), + releaseNotes: z.string().nullable().optional(), + releaseDate: z.string().optional(), + downloadPercent: z.number().optional(), + bytesPerSecond: z.number().optional(), error: z.string().optional(), }); diff --git a/packages/core/src/updates/updateStore.test.ts b/packages/core/src/updates/updateStore.test.ts index db1489433d..5ec7267f76 100644 --- a/packages/core/src/updates/updateStore.test.ts +++ b/packages/core/src/updates/updateStore.test.ts @@ -27,7 +27,14 @@ describe("deriveUpdateUiStatus", () => { it("maps checking + downloading to downloading", () => { expect( deriveUpdateUiStatus({ checking: true, downloading: true }, "idle"), - ).toEqual({ status: "downloading" }); + ).toEqual({ + status: "downloading", + availableVersion: null, + releaseNotes: null, + releaseDate: null, + downloadPercent: null, + bytesPerSecond: null, + }); }); it("maps checking to checking", () => { diff --git a/packages/core/src/updates/updateStore.ts b/packages/core/src/updates/updateStore.ts index fa3139141e..46dfa3418e 100644 --- a/packages/core/src/updates/updateStore.ts +++ b/packages/core/src/updates/updateStore.ts @@ -3,6 +3,7 @@ import { createStore } from "zustand/vanilla"; export type UpdateUiStatus = | "idle" + | "available" | "checking" | "downloading" | "ready" @@ -11,6 +12,11 @@ export type UpdateUiStatus = interface UpdateState { status: UpdateUiStatus; version: string | null; + availableVersion: string | null; + releaseNotes: string | null; + releaseDate: string | null; + downloadPercent: number | null; + bytesPerSecond: number | null; isEnabled: boolean; menuCheckPending: boolean; @@ -19,11 +25,17 @@ interface UpdateState { setEnabled: (isEnabled: boolean) => void; setMenuCheckPending: (menuCheckPending: boolean) => void; setReady: (version: string | null) => void; + applyStatusUpdate: (update: UpdateStatusUpdate) => void; } export const updateStore = createStore((set) => ({ status: "idle", version: null, + availableVersion: null, + releaseNotes: null, + releaseDate: null, + downloadPercent: null, + bytesPerSecond: null, isEnabled: false, menuCheckPending: false, @@ -32,6 +44,31 @@ export const updateStore = createStore((set) => ({ setEnabled: (isEnabled) => set({ isEnabled }), setMenuCheckPending: (menuCheckPending) => set({ menuCheckPending }), setReady: (version) => set({ status: "ready", version }), + applyStatusUpdate: (update) => + set((state) => ({ + status: update.status ?? state.status, + version: update.version !== undefined ? update.version : state.version, + availableVersion: + update.availableVersion !== undefined + ? update.availableVersion + : state.availableVersion, + releaseNotes: + update.releaseNotes !== undefined + ? update.releaseNotes + : state.releaseNotes, + releaseDate: + update.releaseDate !== undefined + ? update.releaseDate + : state.releaseDate, + downloadPercent: + update.downloadPercent !== undefined + ? update.downloadPercent + : state.downloadPercent, + bytesPerSecond: + update.bytesPerSecond !== undefined + ? update.bytesPerSecond + : state.bytesPerSecond, + })), })); export const getUpdateUiStatus = () => updateStore.getState().status; @@ -42,6 +79,11 @@ export const getMenuCheckPending = () => export interface UpdateStatusUpdate { status?: UpdateUiStatus; version?: string | null; + availableVersion?: string | null; + releaseNotes?: string | null; + releaseDate?: string | null; + downloadPercent?: number | null; + bytesPerSecond?: number | null; } export function deriveUpdateUiStatus( @@ -57,7 +99,23 @@ export function deriveUpdateUiStatus( } if (payload.checking && payload.downloading) { - return { status: "downloading" }; + return { + status: "downloading", + availableVersion: payload.availableVersion ?? null, + releaseNotes: payload.releaseNotes ?? null, + releaseDate: payload.releaseDate ?? null, + downloadPercent: payload.downloadPercent ?? null, + bytesPerSecond: payload.bytesPerSecond ?? null, + }; + } + + if (payload.available) { + return { + status: "available", + availableVersion: payload.availableVersion ?? null, + releaseNotes: payload.releaseNotes ?? null, + releaseDate: payload.releaseDate ?? null, + }; } if (payload.checking) { diff --git a/packages/core/src/updates/updates.test.ts b/packages/core/src/updates/updates.test.ts index 3ba6e9f8d3..f410c388af 100644 --- a/packages/core/src/updates/updates.test.ts +++ b/packages/core/src/updates/updates.test.ts @@ -1,3 +1,7 @@ +import type { + UpdateAvailableInfo, + UpdateDownloadProgress, +} from "@posthog/platform/updater"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { UpdatesEvent } from "./schemas"; @@ -13,7 +17,8 @@ const { } = vi.hoisted(() => { const updaterHandlers: { checkStart: (() => void) | null; - updateAvailable: (() => void) | null; + updateAvailable: ((info: UpdateAvailableInfo) => void) | null; + downloadProgress: ((progress: UpdateDownloadProgress) => void) | null; noUpdate: (() => void) | null; updateDownloaded: ((version: string) => void) | null; error: ((error: Error) => void) | null; @@ -21,6 +26,7 @@ const { } = { checkStart: null, updateAvailable: null, + downloadProgress: null, noUpdate: null, updateDownloaded: null, error: null, @@ -37,10 +43,18 @@ const { updaterHandlers.checkStart = h; return () => {}; }), - onUpdateAvailable: vi.fn((h: () => void) => { + onUpdateAvailable: vi.fn((h: (info: UpdateAvailableInfo) => void) => { updaterHandlers.updateAvailable = h; return () => {}; }), + onDownloadProgress: vi.fn( + (h: (progress: UpdateDownloadProgress) => void) => { + updaterHandlers.downloadProgress = h; + return () => {}; + }, + ), + download: vi.fn(), + setAutoDownload: vi.fn(), onNoUpdate: vi.fn((h: () => void) => { updaterHandlers.noUpdate = h; return () => {}; @@ -656,14 +670,102 @@ describe("UpdatesService", () => { }); }); - it("returns downloading status while an update is downloading", async () => { + it("returns available status when an update is found", async () => { await initializeService(service); - updaterHandlers.updateAvailable?.(); + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: "Notes", + }); expect(service.getStatus()).toEqual({ + checking: false, + available: true, + availableVersion: "v2.0.0", + releaseNotes: "Notes", + }); + }); + + it("auto-downloads and returns downloading status when enabled", async () => { + await initializeService(service); + service.setAutoDownloadEnabled(true); + + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: null, + }); + + expect(mockUpdater.download).toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ checking: true, downloading: true, + availableVersion: "v2.0.0", + }); + }); + + it("downloads on requestDownload and reaches ready", async () => { + await initializeService(service); + + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: null, + }); + service.requestDownload(); + expect(mockUpdater.download).toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ downloading: true }); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + expect(service.getStatus()).toMatchObject({ + updateReady: true, + version: "v2.0.0", + }); + }); + }); + + describe("available update guards", () => { + it("does not re-check or clear the banner on periodic checks while available", async () => { + await initializeService(service); + + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: "Notes", + }); + expect(service.getStatus()).toMatchObject({ + available: true, + availableVersion: "v2.0.0", + }); + + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + mockUpdater.check.mockClear(); + + const result = service.checkForUpdates("periodic"); + + expect(result).toEqual({ success: true }); + expect(mockUpdater.check).not.toHaveBeenCalled(); + expect(statusHandler).not.toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ + available: true, + availableVersion: "v2.0.0", + }); + }); + + it("starts the download when auto-download is enabled while available", async () => { + await initializeService(service); + + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: null, + }); + expect(service.getStatus()).toMatchObject({ available: true }); + + mockUpdater.download.mockClear(); + service.setAutoDownloadEnabled(true); + + expect(mockUpdater.download).toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ + downloading: true, + availableVersion: "v2.0.0", }); }); }); diff --git a/packages/core/src/updates/updates.ts b/packages/core/src/updates/updates.ts index 04f02128c6..ee4e0fd9c9 100644 --- a/packages/core/src/updates/updates.ts +++ b/packages/core/src/updates/updates.ts @@ -8,7 +8,12 @@ import { type IMainWindow, MAIN_WINDOW_SERVICE, } from "@posthog/platform/main-window"; -import { type IUpdater, UPDATER_SERVICE } from "@posthog/platform/updater"; +import { + type IUpdater, + UPDATER_SERVICE, + type UpdateAvailableInfo, + type UpdateDownloadProgress, +} from "@posthog/platform/updater"; import { type SagaLogger, TypedEventEmitter, @@ -28,6 +33,7 @@ type CheckSource = "user" | "periodic"; type UpdateState = | "idle" | "checking" + | "available" | "downloading" | "ready" | "installing" @@ -82,6 +88,10 @@ export class UpdatesService extends TypedEventEmitter { private lastError: string | null = null; private initialized = false; private unsubscribes: Array<() => void> = []; + private availableInfo: UpdateAvailableInfo | null = null; + private downloadProgress: UpdateDownloadProgress | null = null; + private autoDownloadEnabled = false; + private lastProgressEmit = 0; get hasUpdateReady(): boolean { return this.isUpdateStaged(); @@ -112,13 +122,47 @@ export class UpdatesService extends TypedEventEmitter { this.emit(UpdatesEvent.CheckFromMenu, true); } + setAutoDownloadEnabled(enabled: boolean): void { + this.autoDownloadEnabled = enabled; + if (this.isEnabled) { + this.updater.setAutoDownload(enabled); + } + this.log.info("Auto-download preference updated", { enabled }); + + if (enabled && this.state === "available") { + this.requestDownload(); + } + } + + requestDownload(): void { + if (this.state !== "available") { + this.log.warn("requestDownload called but no update is available", { + state: this.state, + }); + return; + } + this.transitionTo("downloading", { + reason: "user requested download", + incomingVersion: this.availableInfo?.version ?? null, + }); + this.log.info("Downloading update...", { + version: this.availableInfo?.version, + }); + this.updater.download(); + this.emitStatus(this.downloadingStatusPayload()); + } + getStatus(): UpdatesStatusPayload { if (this.state === "checking") { return { checking: true }; } + if (this.state === "available") { + return this.availableStatusPayload(); + } + if (this.state === "downloading") { - return { checking: true, downloading: true }; + return this.downloadingStatusPayload(); } if (this.isUpdateStaged()) { @@ -159,6 +203,14 @@ export class UpdatesService extends TypedEventEmitter { return { success: true }; } + if (source === "periodic" && this.state === "available") { + this.logStateTransition(this.state, { + source, + reason: "periodic check skipped because an update is already available", + }); + return { success: true }; + } + if (this.state === "checking" || this.state === "downloading") { return { success: false, @@ -238,7 +290,12 @@ export class UpdatesService extends TypedEventEmitter { this.unsubscribes.push( this.updater.onError((error) => this.handleError(error)), this.updater.onCheckStart(() => this.log.info("Checking for updates...")), - this.updater.onUpdateAvailable(() => this.handleUpdateAvailable()), + this.updater.onUpdateAvailable((info) => + this.handleUpdateAvailable(info), + ), + this.updater.onDownloadProgress((progress) => + this.handleDownloadProgress(progress), + ), this.updater.onNoUpdate(() => this.handleNoUpdate()), this.updater.onUpdateDownloaded((releaseName) => this.handleUpdateDownloaded(releaseName), @@ -262,6 +319,28 @@ export class UpdatesService extends TypedEventEmitter { }; } + private availableStatusPayload(): UpdatesStatusPayload { + return { + checking: false, + available: true, + availableVersion: this.availableInfo?.version, + releaseNotes: this.availableInfo?.releaseNotes ?? undefined, + releaseDate: this.availableInfo?.releaseDate, + }; + } + + private downloadingStatusPayload(): UpdatesStatusPayload { + return { + checking: true, + downloading: true, + availableVersion: this.availableInfo?.version, + releaseNotes: this.availableInfo?.releaseNotes ?? undefined, + releaseDate: this.availableInfo?.releaseDate, + downloadPercent: this.downloadProgress?.percent, + bytesPerSecond: this.downloadProgress?.bytesPerSecond, + }; + } + private handleError(error: Error): void { this.clearCheckTimeout(); this.log.error("Auto update error", { @@ -289,7 +368,7 @@ export class UpdatesService extends TypedEventEmitter { } } - private handleUpdateAvailable(): void { + private handleUpdateAvailable(info: UpdateAvailableInfo): void { if (this.isUpdateStaged()) { this.log.info( "Ignoring update-available because an update is already staged", @@ -301,9 +380,42 @@ export class UpdatesService extends TypedEventEmitter { } this.clearCheckTimeout(); - this.transitionTo("downloading", { reason: "update available" }); - this.log.info("Update available, downloading..."); - this.emitStatus({ checking: true, downloading: true }); + this.availableInfo = info; + this.downloadProgress = null; + + if (this.autoDownloadEnabled) { + this.transitionTo("downloading", { + reason: "update available (auto-download)", + incomingVersion: info.version, + }); + this.log.info("Update available, auto-downloading...", { + version: info.version, + }); + this.updater.download(); + this.emitStatus(this.downloadingStatusPayload()); + return; + } + + this.transitionTo("available", { + reason: "update available", + incomingVersion: info.version, + }); + this.log.info("Update available, awaiting user download", { + version: info.version, + }); + this.emitStatus(this.availableStatusPayload()); + } + + private handleDownloadProgress(progress: UpdateDownloadProgress): void { + if (this.state !== "downloading") { + return; + } + this.downloadProgress = progress; + const now = Date.now(); + if (now - this.lastProgressEmit >= 400 || progress.percent >= 100) { + this.lastProgressEmit = now; + this.emitStatus(this.downloadingStatusPayload()); + } } private handleNoUpdate(): void { diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index 6370158e1a..e3b96d20a3 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -23,6 +23,7 @@ import { freeformGenRouter } from "./routers/freeform-gen.router"; import { fsRouter } from "./routers/fs.router"; import { gitRouter } from "./routers/git.router"; import { githubIntegrationRouter } from "./routers/github-integration.router"; +import { githubReleasesRouter } from "./routers/github-releases.router"; import { handoffRouter } from "./routers/handoff.router"; import { linearIntegrationRouter } from "./routers/linear-integration.router"; import { llmGatewayRouter } from "./routers/llm-gateway.router"; @@ -72,6 +73,7 @@ export const hostRouter = router({ git: gitRouter, handoff: handoffRouter, githubIntegration: githubIntegrationRouter, + githubReleases: githubReleasesRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, logs: logsRouter, diff --git a/packages/host-router/src/routers/github-releases.router.ts b/packages/host-router/src/routers/github-releases.router.ts new file mode 100644 index 0000000000..0e183f9eba --- /dev/null +++ b/packages/host-router/src/routers/github-releases.router.ts @@ -0,0 +1,14 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { GitHubReleasesService } from "@posthog/workspace-server/services/github-releases/github-releases"; +import { GITHUB_RELEASES_SERVICE } from "@posthog/workspace-server/services/github-releases/identifiers"; +import { listReleasesOutput } from "@posthog/workspace-server/services/github-releases/schemas"; + +export const githubReleasesRouter = router({ + list: publicProcedure + .output(listReleasesOutput) + .query(({ ctx }) => + ctx.container + .get(GITHUB_RELEASES_SERVICE) + .listReleases(), + ), +}); diff --git a/packages/host-router/src/routers/updates.router.ts b/packages/host-router/src/routers/updates.router.ts index b861c100bf..cb17491c25 100644 --- a/packages/host-router/src/routers/updates.router.ts +++ b/packages/host-router/src/routers/updates.router.ts @@ -9,6 +9,7 @@ import { } from "@posthog/core/updates/schemas"; import type { UpdatesService } from "@posthog/core/updates/updates"; import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; function subscribe(event: K) { return publicProcedure.subscription(async function* ({ ctx, signal }) { @@ -41,6 +42,18 @@ export const updatesRouter = router({ return service.installUpdate(); }), + download: publicProcedure.mutation(({ ctx }) => { + ctx.container.get(UPDATES_SERVICE).requestDownload(); + }), + + setAutoDownload: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ ctx, input }) => { + ctx.container + .get(UPDATES_SERVICE) + .setAutoDownloadEnabled(input.enabled); + }), + onReady: subscribe(UpdatesEvent.Ready), onStatus: subscribe(UpdatesEvent.Status), onCheckFromMenu: subscribe(UpdatesEvent.CheckFromMenu), diff --git a/packages/platform/src/updater.ts b/packages/platform/src/updater.ts index 7c314c3680..3e8eabbb61 100644 --- a/packages/platform/src/updater.ts +++ b/packages/platform/src/updater.ts @@ -1,9 +1,28 @@ +export interface UpdateAvailableInfo { + version: string; + releaseNotes: string | null; + releaseDate?: string; + releaseName?: string | null; +} + +export interface UpdateDownloadProgress { + percent: number; + bytesPerSecond: number; + transferred: number; + total: number; +} + export interface IUpdater { isSupported(): boolean; check(): void; + download(): void; quitAndInstall(): void; + setAutoDownload(enabled: boolean): void; onCheckStart(handler: () => void): () => void; - onUpdateAvailable(handler: () => void): () => void; + onUpdateAvailable(handler: (info: UpdateAvailableInfo) => void): () => void; + onDownloadProgress( + handler: (progress: UpdateDownloadProgress) => void, + ): () => void; onUpdateDownloaded(handler: (version: string) => void): () => void; onNoUpdate(handler: () => void): () => void; onError(handler: (error: Error) => void): () => void; diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 9702e59a72..19391dd6a4 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -46,14 +46,21 @@ export function GeneralSettings() { const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); // Power state - const { preventSleepWhileRunning, setPreventSleepWhileRunning } = - useSettingsStore(); + const { + preventSleepWhileRunning, + setPreventSleepWhileRunning, + downloadUpdatesAutomatically, + setDownloadUpdatesAutomatically, + } = useSettingsStore(); const { data: serverPreventSleep } = useQuery( hostTRPC.sleep.getEnabled.queryOptions(), ); const preventSleepMutation = useMutation( hostTRPC.sleep.setEnabled.mutationOptions(), ); + const { data: updatesEnabled } = useQuery( + hostTRPC.updates.isEnabled.queryOptions(), + ); useEffect(() => { if (serverPreventSleep !== undefined) { @@ -74,6 +81,18 @@ export function GeneralSettings() { [setPreventSleepWhileRunning, preventSleepMutation], ); + const handleAutoDownloadChange = useCallback( + (checked: boolean) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "download_updates_automatically", + new_value: checked, + old_value: !checked, + }); + setDownloadUpdatesAutomatically(checked); + }, + [setDownloadUpdatesAutomatically], + ); + // Chat state const { desktopNotifications, @@ -591,6 +610,27 @@ export function GeneralSettings() { /> + {updatesEnabled?.enabled ? ( + <> + {/* Updates */} + + Updates + + + + + + + ) : null} + {/* Fun */} Fun diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index cfdf5c8757..2739da13fa 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -141,8 +141,12 @@ interface SettingsStore { // Experimental / misc hedgehogMode: boolean; mcpAppsDisabledServers: string[]; + downloadUpdatesAutomatically: boolean; + lastSeenChangelogVersion: string | null; setHedgehogMode: (enabled: boolean) => void; setMcpAppsDisabledServers: (servers: string[]) => void; + setDownloadUpdatesAutomatically: (enabled: boolean) => void; + setLastSeenChangelogVersion: (version: string | null) => void; // Onboarding hints hints: Record; @@ -263,7 +267,13 @@ export const useSettingsStore = create()( // Experimental / misc hedgehogMode: false, mcpAppsDisabledServers: [], + downloadUpdatesAutomatically: false, + lastSeenChangelogVersion: null, setHedgehogMode: (enabled) => set({ hedgehogMode: enabled }), + setDownloadUpdatesAutomatically: (enabled) => + set({ downloadUpdatesAutomatically: enabled }), + setLastSeenChangelogVersion: (version) => + set({ lastSeenChangelogVersion: version }), setMcpAppsDisabledServers: (servers) => set({ mcpAppsDisabledServers: servers }), @@ -349,6 +359,8 @@ export const useSettingsStore = create()( // Experimental / misc hedgehogMode: state.hedgehogMode, mcpAppsDisabledServers: state.mcpAppsDisabledServers, + downloadUpdatesAutomatically: state.downloadUpdatesAutomatically, + lastSeenChangelogVersion: state.lastSeenChangelogVersion, // Onboarding hints hints: state.hints, diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 3439ca6423..f74f0413e9 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -6,6 +6,7 @@ import { DiscordLogo, FolderSimple, Gear, + Gift, Info, Keyboard, Plus, @@ -50,6 +51,7 @@ import { import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useProjects } from "@posthog/ui/features/projects/useProjects"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { isMac } from "@posthog/ui/utils/platform"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -163,6 +165,11 @@ export function ProjectSwitcher({ setPopoverOpen(false); }; + const handleViewChangelog = () => { + useWhatsNewStore.getState().open(); + setPopoverOpen(false); + }; + const handleLogout = () => { setPopoverOpen(false); logoutMutation.mutate(); @@ -303,6 +310,11 @@ export function ProjectSwitcher({ + + + View changelog + + diff --git a/packages/ui/src/features/sidebar/components/UpdateBanner.tsx b/packages/ui/src/features/sidebar/components/UpdateBanner.tsx index b02cbe83c6..ab819a70f9 100644 --- a/packages/ui/src/features/sidebar/components/UpdateBanner.tsx +++ b/packages/ui/src/features/sidebar/components/UpdateBanner.tsx @@ -1,4 +1,5 @@ import { ArrowsClockwise, Gift, Spinner } from "@phosphor-icons/react"; +import { useUpdateModalStore } from "@posthog/ui/features/updates/updateModalStore"; import { useInstallUpdate, useUpdateView, @@ -11,12 +12,19 @@ interface UpdateBannerProps { } export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { - const { status, version, isEnabled } = useUpdateView(); + const { status, version, availableVersion, downloadPercent, isEnabled } = + useUpdateView(); const installUpdate = useInstallUpdate(); + const openModal = useUpdateModalStore((state) => state.open); const isVisible = isEnabled && - (status === "downloading" || status === "ready" || status === "installing"); + (status === "available" || + status === "downloading" || + status === "ready" || + status === "installing"); + + const percent = Math.round(downloadPercent ?? 0); if (variant === "compact") { return ( @@ -28,11 +36,26 @@ export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { exit={{ opacity: 0, x: -8 }} transition={{ duration: 0.2, ease: "easeOut" }} > + {status === "available" && ( + + )} + {status === "downloading" && ( -
+
+ Downloading update... {percent}% + )} {status === "ready" && ( @@ -43,8 +66,7 @@ export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { > - {version ? `${version} available` : "Update available"} — - Restart + {version ? `${version} ready` : "Update ready"} — Restart )} @@ -72,62 +94,97 @@ export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { className="shrink-0 overflow-hidden" > + {status === "available" && ( + + + + )} + {status === "downloading" && ( - - - Downloading update... - + + + )} {status === "ready" && ( - - -
- - - -
- - {version ? `${version} ready` : "Update ready"} - - - Restart to apply - -
- -
-
-
+ +
+ + + + + +
+
)} {status === "installing" && ( - - - Restarting... - + +
+ + Restarting... +
+
)}
@@ -136,22 +193,15 @@ export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { ); } -function BannerContent({ - children, - ...props -}: { children: React.ReactNode } & React.ComponentProps) { +function BannerCard({ children }: { children: React.ReactNode }) { return ( -
- {children} -
+ {children}
); } diff --git a/packages/ui/src/features/updates/UpdateAvailableModal.tsx b/packages/ui/src/features/updates/UpdateAvailableModal.tsx new file mode 100644 index 0000000000..7053eb8e4a --- /dev/null +++ b/packages/ui/src/features/updates/UpdateAvailableModal.tsx @@ -0,0 +1,142 @@ +import { ArrowDown, ArrowsClockwise } from "@phosphor-icons/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { useUpdateModalStore } from "@posthog/ui/features/updates/updateModalStore"; +import { + useInstallUpdate, + useUpdateView, +} from "@posthog/ui/features/updates/updateStore"; +import { + Button, + Code, + Dialog, + Flex, + Progress, + ScrollArea, + Text, +} from "@radix-ui/themes"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +function formatSpeed(bytesPerSecond: number | null): string { + if (!bytesPerSecond || bytesPerSecond <= 0) return ""; + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`; +} + +export function UpdateAvailableModal() { + const isOpen = useUpdateModalStore((state) => state.isOpen); + const close = useUpdateModalStore((state) => state.close); + const { + status, + version, + availableVersion, + releaseNotes, + downloadPercent, + bytesPerSecond, + } = useUpdateView(); + const installUpdate = useInstallUpdate(); + const hostTRPC = useHostTRPC(); + const { data: currentVersion } = useQuery( + hostTRPC.os.getAppVersion.queryOptions(), + ); + const downloadMutation = useMutation( + hostTRPC.updates.download.mutationOptions(), + ); + + const targetVersion = version ?? availableVersion; + const percent = Math.round(downloadPercent ?? 0); + const isDownloading = status === "downloading"; + const isReady = status === "ready" || status === "installing"; + + return ( + { + if (!open) close(); + }} + > + + + + Update Available + + + {targetVersion + ? `PostHog Code ${targetVersion} is ready` + : "A new version is ready"} + + + + + {currentVersion ? ( + + + You're currently on version + + {currentVersion} + + ) : null} + + {releaseNotes ? ( + + + Release notes + + +
+ +
+
+
+ ) : null} + + {isDownloading ? ( + + + + Downloading... {percent}% + + + {formatSpeed(bytesPerSecond)} + + + + + ) : null} + + + + {isReady ? ( + + ) : isDownloading ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/packages/ui/src/features/updates/WhatsNewModal.tsx b/packages/ui/src/features/updates/WhatsNewModal.tsx new file mode 100644 index 0000000000..077277c5bd --- /dev/null +++ b/packages/ui/src/features/updates/WhatsNewModal.tsx @@ -0,0 +1,172 @@ +import { X } from "@phosphor-icons/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { + groupReleases, + mergeReleaseNotes, +} from "@posthog/ui/features/updates/releaseNotes"; +import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; +import { + Badge, + Dialog, + Flex, + IconButton, + ScrollArea, + Skeleton, + Text, +} from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; + +function ReleaseSection({ title, items }: { title: string; items: string[] }) { + if (items.length === 0) return null; + return ( + + + {title} + +
    + {items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ); +} + +function ChangelogSkeleton() { + return ( + + {["a", "b", "c"].map((key) => ( + + + + + + + + + + + + + ))} + + ); +} + +export function WhatsNewModal() { + const isOpen = useWhatsNewStore((state) => state.isOpen); + const close = useWhatsNewStore((state) => state.close); + const hostTRPC = useHostTRPC(); + const { data, isLoading, isError } = useQuery({ + ...hostTRPC.githubReleases.list.queryOptions(), + enabled: isOpen, + }); + const { data: currentVersion } = useQuery( + hostTRPC.os.getAppVersion.queryOptions(), + ); + + const groups = groupReleases(data?.releases ?? []); + + return ( + { + if (!open) close(); + }} + > + + + + What's New + + + Release history and recent improvements + + + + + + + + + + + {isLoading ? ( + + ) : isError ? ( + + Could not load releases. Please try again later. + + ) : groups.length === 0 ? ( + + No releases found. + + ) : ( + + + {groups.map((group, index) => { + const { improved, fixed } = mergeReleaseNotes(group.releases); + const containsCurrent = currentVersion + ? group.releases.some( + (release) => release.version === currentVersion, + ) + : false; + return ( + 0 ? "border-gray-6 border-t pt-5" : undefined + } + > + + + {group.label} + + + {group.isLatest ? ( + Latest + ) : null} + {containsCurrent ? ( + + Current + + ) : null} + + {group.releases.length === 1 + ? group.releases[0].name + : `${group.releases.length} releases`} + + + + {improved.length === 0 && fixed.length === 0 ? ( + + No notable changes. + + ) : ( + + + + + )} + + ); + })} + + + )} + + + ); +} diff --git a/packages/ui/src/features/updates/releaseNotes.test.ts b/packages/ui/src/features/updates/releaseNotes.test.ts new file mode 100644 index 0000000000..744280b190 --- /dev/null +++ b/packages/ui/src/features/updates/releaseNotes.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + groupReleases, + mergeReleaseNotes, + parseReleaseNotes, +} from "./releaseNotes"; + +describe("parseReleaseNotes", () => { + it("keeps change bullets, strips prefix + attribution, splits fix vs rest", () => { + const notes = [ + "## What's Changed", + "* feat(canvas): right-click a canvas by @alice in https://github.com/PostHog/code/pull/1", + "* fix(inbox): point link to docs by @bob in https://github.com/PostHog/code/pull/2", + '* Add "PostHog Web" button by @carol in https://github.com/PostHog/code/pull/3', + "* @newbie made their first contribution in https://example.com", + "**Full Changelog**: https://github.com/PostHog/code/compare/v1...v2", + ].join("\n"); + + expect(parseReleaseNotes(notes)).toEqual({ + improved: ["Right-click a canvas", 'Add "PostHog Web" button'], + fixed: ["Point link to docs"], + }); + }); +}); + +describe("mergeReleaseNotes", () => { + it("merges across releases and dedupes", () => { + const releases = [ + { name: "v2", version: "2", date: null, notes: "* fix: a\n* feat: b" }, + { name: "v1", version: "1", date: null, notes: "* fix: a\n* feat: c" }, + ]; + expect(mergeReleaseNotes(releases)).toEqual({ + improved: ["B", "C"], + fixed: ["A"], + }); + }); +}); + +describe("groupReleases", () => { + const now = Date.parse("2026-06-20T12:00:00Z"); + const mk = (name: string, date: string) => ({ + name, + version: name.replace(/^v/, ""), + date, + notes: "", + }); + + it("buckets recent releases by day and older ones by week", () => { + const groups = groupReleases( + [ + mk("v0.55.14", "2026-06-20T12:00:00Z"), + mk("v0.55.13", "2026-06-19T12:00:00Z"), + mk("v0.55.12", "2026-06-19T09:00:00Z"), + mk("v0.55.5", "2026-06-12T12:00:00Z"), + mk("v0.55.4", "2026-06-10T12:00:00Z"), + ], + now, + 3, + ); + + expect(groups).toHaveLength(3); + expect(groups[0].key.startsWith("day-")).toBe(true); + expect(groups[0].releases).toHaveLength(1); + expect(groups[0].isLatest).toBe(true); + expect(groups[1].key.startsWith("day-")).toBe(true); + expect(groups[1].releases).toHaveLength(2); + expect(groups[2].key.startsWith("week-")).toBe(true); + expect(groups[2].releases).toHaveLength(2); + }); +}); diff --git a/packages/ui/src/features/updates/releaseNotes.ts b/packages/ui/src/features/updates/releaseNotes.ts new file mode 100644 index 0000000000..1a8cfd3241 --- /dev/null +++ b/packages/ui/src/features/updates/releaseNotes.ts @@ -0,0 +1,133 @@ +export interface CategorizedNotes { + improved: string[]; + fixed: string[]; +} + +export interface ReleaseLike { + name: string; + version: string; + notes: string; + date: string | null; +} + +export interface ReleaseGroup { + key: string; + label: string; + releases: ReleaseLike[]; + isLatest: boolean; +} + +// Recent releases stay broken out by day; anything older is bucketed by week so +// the timeline stays scannable when we ship a lot. +export const RECENT_DAYS = 3; +const DAY_MS = 86_400_000; + +// Release notes are GitHub auto-generated: a "What's Changed" bullet list of PR +// titles (conventional-commit prefixed), plus contributor and "Full Changelog" +// noise. Keep only the change bullets, strip the prefix and "by @user in " +// attribution, and bucket `fix` into Fixed and everything else into Improved. +export function parseReleaseNotes(notes: string): CategorizedNotes { + const improved: string[] = []; + const fixed: string[] = []; + for (const rawLine of notes.split("\n")) { + const bullet = rawLine.trim().match(/^[-*•]\s+(.*)$/); + if (!bullet) continue; + let text = bullet[1].trim(); + if (text.startsWith("@")) continue; // "New Contributors" lines + text = text.replace(/\s+by\s+@\S+\s+in\s+\S+.*$/i, "").trim(); + const conventional = text.match(/^([a-z]+)(?:\([^)]*\))?!?:\s*(.*)$/i); + let isFix = false; + if (conventional) { + isFix = conventional[1].toLowerCase() === "fix"; + text = conventional[2].trim(); + } else { + isFix = /^fix(ed|es)?\b/i.test(text); + } + if (text.length === 0) continue; + text = text.charAt(0).toUpperCase() + text.slice(1); + (isFix ? fixed : improved).push(text); + } + return { improved, fixed }; +} + +export function mergeReleaseNotes(releases: ReleaseLike[]): CategorizedNotes { + const improved: string[] = []; + const fixed: string[] = []; + for (const release of releases) { + const parsed = parseReleaseNotes(release.notes); + improved.push(...parsed.improved); + fixed.push(...parsed.fixed); + } + return { + improved: Array.from(new Set(improved)), + fixed: Array.from(new Set(fixed)), + }; +} + +function dayLabel(date: Date): string { + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +function mondayOf(date: Date): Date { + const monday = new Date(date); + monday.setDate(date.getDate() - ((date.getDay() + 6) % 7)); + return monday; +} + +function weekLabel(date: Date): string { + return `Week of ${mondayOf(date).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + })}`; +} + +function weekKey(date: Date): string { + return `week-${mondayOf(date).toDateString()}`; +} + +// Groups newest-first releases: each of the last `recentDays` days is its own +// group; everything older is grouped into its calendar week. +export function groupReleases( + releases: ReleaseLike[], + now: number = Date.now(), + recentDays: number = RECENT_DAYS, +): ReleaseGroup[] { + const recentCutoff = now - recentDays * DAY_MS; + const map = new Map(); + + for (const release of releases) { + const time = release.date ? Date.parse(release.date) : Number.NaN; + const dated = !Number.isNaN(time); + let key: string; + let label: string; + if (dated && time >= recentCutoff) { + const date = new Date(time); + key = `day-${date.toDateString()}`; + label = dayLabel(date); + } else if (dated) { + const date = new Date(time); + key = weekKey(date); + label = weekLabel(date); + } else { + key = "earlier"; + label = "Earlier"; + } + + let group = map.get(key); + if (!group) { + group = { key, label, releases: [], isLatest: false }; + map.set(key, group); + } + group.releases.push(release); + } + + const groups = Array.from(map.values()); + if (groups.length > 0) { + groups[0].isLatest = true; + } + return groups; +} diff --git a/packages/ui/src/features/updates/updateModalStore.ts b/packages/ui/src/features/updates/updateModalStore.ts new file mode 100644 index 0000000000..16d4d94cf5 --- /dev/null +++ b/packages/ui/src/features/updates/updateModalStore.ts @@ -0,0 +1,3 @@ +import { createDialogStore } from "@posthog/ui/utils/createDialogStore"; + +export const useUpdateModalStore = createDialogStore(); diff --git a/packages/ui/src/features/updates/updateStore.ts b/packages/ui/src/features/updates/updateStore.ts index fbb67ea242..73cd29489e 100644 --- a/packages/ui/src/features/updates/updateStore.ts +++ b/packages/ui/src/features/updates/updateStore.ts @@ -16,6 +16,10 @@ const log = logger.scope("update-store"); interface UpdateView { status: UpdateUiStatus; version: string | null; + availableVersion: string | null; + releaseNotes: string | null; + downloadPercent: number | null; + bytesPerSecond: number | null; isEnabled: boolean; } @@ -23,6 +27,10 @@ export function useUpdateView(): UpdateView { return useStore(updateStore, (state) => ({ status: state.status, version: state.version, + availableVersion: state.availableVersion, + releaseNotes: state.releaseNotes, + downloadPercent: state.downloadPercent, + bytesPerSecond: state.bytesPerSecond, isEnabled: state.isEnabled, })); } diff --git a/packages/ui/src/features/updates/whatsNewStore.ts b/packages/ui/src/features/updates/whatsNewStore.ts new file mode 100644 index 0000000000..6b14f9e1d2 --- /dev/null +++ b/packages/ui/src/features/updates/whatsNewStore.ts @@ -0,0 +1,3 @@ +import { createDialogStore } from "@posthog/ui/utils/createDialogStore"; + +export const useWhatsNewStore = createDialogStore(); diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index eb3d99f5de..59099f8aad 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -30,6 +30,8 @@ import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOr import { RemoteBranchCheckoutDialog } from "@posthog/ui/features/task-detail/components/RemoteBranchCheckoutDialog"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { TourOverlay } from "@posthog/ui/features/tour/components/TourOverlay"; +import { UpdateAvailableModal } from "@posthog/ui/features/updates/UpdateAvailableModal"; +import { WhatsNewModal } from "@posthog/ui/features/updates/WhatsNewModal"; import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; import LogosLandscape from "@posthog/ui/primitives/Logo"; import { useAppView } from "@posthog/ui/router/useAppView"; @@ -318,6 +320,8 @@ function RootLayout() { onToggleShortcutsSheet={toggleShortcutsSheet} /> {billingEnabled && } + + {billingEnabled && } + + {import.meta.env.DEV && ( @@ -388,6 +394,8 @@ function RootLayout() { /> {billingEnabled && } + + {import.meta.env.DEV && ( diff --git a/packages/ui/src/utils/createDialogStore.ts b/packages/ui/src/utils/createDialogStore.ts new file mode 100644 index 0000000000..298404a768 --- /dev/null +++ b/packages/ui/src/utils/createDialogStore.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +export interface DialogStore { + isOpen: boolean; + open: () => void; + close: () => void; +} + +export function createDialogStore() { + return create((set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + })); +} diff --git a/packages/workspace-server/src/services/github-releases/github-releases.module.ts b/packages/workspace-server/src/services/github-releases/github-releases.module.ts new file mode 100644 index 0000000000..14c9af347a --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/github-releases.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { GitHubReleasesService } from "./github-releases"; +import { GITHUB_RELEASES_SERVICE } from "./identifiers"; + +export const githubReleasesModule = new ContainerModule(({ bind }) => { + bind(GITHUB_RELEASES_SERVICE).to(GitHubReleasesService).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/github-releases/github-releases.test.ts b/packages/workspace-server/src/services/github-releases/github-releases.test.ts new file mode 100644 index 0000000000..b1e101b123 --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/github-releases.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GitHubReleasesService } from "./github-releases"; + +const sampleReleases = [ + { + tag_name: "v1.2.0", + name: "v1.2.0", + body: "## Notes\n- thing", + draft: false, + prerelease: false, + published_at: "2026-06-20T00:00:00Z", + html_url: "https://github.com/PostHog/code/releases/tag/v1.2.0", + }, + { + tag_name: "v1.1.0", + name: "", + body: null, + draft: false, + prerelease: true, + published_at: "2026-06-10T00:00:00Z", + html_url: "https://github.com/PostHog/code/releases/tag/v1.1.0", + }, + { + tag_name: "v1.3.0-draft", + name: "draft", + body: "x", + draft: true, + prerelease: false, + published_at: null, + html_url: "https://github.com/PostHog/code/releases/tag/v1.3.0-draft", + }, +]; + +describe("GitHubReleasesService", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => sampleReleases, + })); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("maps releases, strips the v prefix and drops drafts", async () => { + const service = new GitHubReleasesService(); + const { releases } = await service.listReleases(); + + expect(releases).toHaveLength(2); + expect(releases[0]).toEqual({ + version: "1.2.0", + name: "v1.2.0", + notes: "## Notes\n- thing", + date: "2026-06-20T00:00:00Z", + isPrerelease: false, + htmlUrl: "https://github.com/PostHog/code/releases/tag/v1.2.0", + }); + // empty name falls back to the tag; null body becomes an empty string + expect(releases[1]).toMatchObject({ + version: "1.1.0", + name: "v1.1.0", + notes: "", + isPrerelease: true, + }); + }); + + it("caches results within the TTL", async () => { + const service = new GitHubReleasesService(); + await service.listReleases(); + await service.listReleases(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws on non-ok responses", async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }); + const service = new GitHubReleasesService(); + await expect(service.listReleases()).rejects.toThrow(); + }); + + it("serves stale cache when a later refetch fails", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0); + const service = new GitHubReleasesService(); + const first = await service.listReleases(); + + nowSpy.mockReturnValue(11 * 60_000); + fetchMock.mockResolvedValueOnce({ ok: false, status: 500 }); + const second = await service.listReleases(); + + expect(second).toEqual(first); + expect(fetchMock).toHaveBeenCalledTimes(2); + nowSpy.mockRestore(); + }); +}); diff --git a/packages/workspace-server/src/services/github-releases/github-releases.ts b/packages/workspace-server/src/services/github-releases/github-releases.ts new file mode 100644 index 0000000000..11c92132dc --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/github-releases.ts @@ -0,0 +1,52 @@ +import { injectable } from "inversify"; +import { githubReleasesApiResponse, type ListReleasesOutput } from "./schemas"; + +const RELEASES_URL = + "https://api.github.com/repos/PostHog/code/releases?per_page=30"; +const CACHE_TTL_MS = 10 * 60_000; +const FETCH_TIMEOUT_MS = 10_000; + +@injectable() +export class GitHubReleasesService { + private cache: { fetchedAt: number; data: ListReleasesOutput } | null = null; + + async listReleases(): Promise { + if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { + return this.cache.data; + } + + try { + const response = await fetch(RELEASES_URL, { + headers: { Accept: "application/vnd.github+json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`GitHub releases fetch failed: ${response.status}`); + } + + const parsed = githubReleasesApiResponse.parse(await response.json()); + const releases = parsed + .filter((release) => !release.draft) + .map((release) => ({ + version: release.tag_name.replace(/^v/, ""), + name: + release.name && release.name.length > 0 + ? release.name + : release.tag_name, + notes: release.body ?? "", + date: release.published_at, + isPrerelease: release.prerelease, + htmlUrl: release.html_url, + })); + + const data: ListReleasesOutput = { releases }; + this.cache = { fetchedAt: Date.now(), data }; + return data; + } catch (error) { + if (this.cache) { + return this.cache.data; + } + throw error; + } + } +} diff --git a/packages/workspace-server/src/services/github-releases/identifiers.ts b/packages/workspace-server/src/services/github-releases/identifiers.ts new file mode 100644 index 0000000000..7ee3c1a88c --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/identifiers.ts @@ -0,0 +1,3 @@ +export const GITHUB_RELEASES_SERVICE = Symbol.for( + "posthog.workspace.githubReleasesService", +); diff --git a/packages/workspace-server/src/services/github-releases/schemas.ts b/packages/workspace-server/src/services/github-releases/schemas.ts new file mode 100644 index 0000000000..94181ca921 --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/schemas.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const githubReleaseApiItem = z.object({ + tag_name: z.string(), + name: z.string().nullable(), + body: z.string().nullable(), + draft: z.boolean(), + prerelease: z.boolean(), + published_at: z.string().nullable(), + html_url: z.string(), +}); + +export const githubReleasesApiResponse = z.array(githubReleaseApiItem); + +export const releaseItem = z.object({ + version: z.string(), + name: z.string(), + notes: z.string(), + date: z.string().nullable(), + isPrerelease: z.boolean(), + htmlUrl: z.string(), +}); + +export const listReleasesOutput = z.object({ + releases: z.array(releaseItem), +}); + +export type ReleaseItem = z.infer; +export type ListReleasesOutput = z.infer;