diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 200a7bf7..919511ca 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -101,9 +101,9 @@ All in `apps/renderer/src/lib/analytics.ts`: The desktop client's own updates — distinct from the in-app apps feature, which owns `app_updated`: -- `update_prompted` — renderer (`apps/renderer/src/lib/analytics.ts`): the restart-to-update titlebar chip was shown for a staged update -- `update_restarted` — main (`apps/main/src/updater.ts`), `{ from, to? }`: the user clicked restart-to-update (`to` is Windows-only; Squirrel.Mac doesn't report the release name) -- `update_failed` — main (`apps/main/src/updater.ts`), `{ message }`: the auto-updater errored. Network/offline errors are excluded — they go to the soft `offline` state and are not captured (a user offline for hours would otherwise emit one per periodic check) +- `update_prompted` — renderer (`apps/renderer/src/lib/analytics.ts`): the "Update available" card was shown for a staged update +- `update_restarted` — main (`apps/main/src/updater.ts`), `{ from, to? }`: the user clicked restart-to-update (`to` may be missing when the update feed doesn't report the release name) +- `update_failed` — main (`apps/main/src/updater.ts`), `{ message }`: the auto-updater errored (includes network errors for now) - `client_updated` — main (`apps/main/src/ipc.ts`), `{ from, to }`: first launch on a newer version (fires once per update, whatever the restart path; downgrades restamp silently and don't fire) ## Person properties diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index bbc47fd5..7dff8d1b 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -70,7 +70,7 @@ import * as appsServer from '@x/core/dist/apps/server.js'; import * as appsAgents from '@x/core/dist/apps/agents.js'; import { capture } from '@x/core/dist/analytics/posthog.js'; import { recordAppVersion, isVersionUpgrade } from '@x/core/dist/config/app_version.js'; -import { getUpdaterStatus, checkForUpdates, quitAndInstallUpdate, snoozeUpdateNotice, moveToApplications } from './updater.js'; +import { getUpdaterStatus, checkForUpdates, quitAndInstallUpdate } from './updater.js'; import * as githubAuth from '@x/core/dist/apps/github-auth.js'; import * as appsStars from '@x/core/dist/apps/stars.js'; import * as appsInstaller from '@x/core/dist/apps/installer.js'; @@ -856,12 +856,6 @@ export function setupIpcHandlers() { quitAndInstallUpdate(); return {}; }, - 'updater:snooze': async () => { - return snoozeUpdateNotice(); - }, - 'updater:moveToApplications': async () => { - return { moved: moveToApplications() }; - }, 'analytics:bootstrap': async () => { return { installationId: getInstallationId(), diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 09b94fdc..b862782b 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -18,7 +18,6 @@ import { disposeAllTerminals } from "./terminal.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { initUpdater } from "./updater.js"; -import { loadWindowState, trackWindowState } from "./window-state.js"; import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js"; import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js"; import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; @@ -259,12 +258,9 @@ function setupZoomShortcuts(win: BrowserWindow) { } function createWindow() { - const savedState = loadWindowState(); const win = new BrowserWindow({ - width: savedState?.width ?? 1280, - height: savedState?.height ?? 800, - x: savedState?.x, - y: savedState?.y, + width: 1280, + height: 800, minWidth: 600, minHeight: 480, show: false, // Don't show until ready @@ -290,14 +286,11 @@ function createWindow() { setMainWindowForDeepLinks(win); win.on("closed", () => setMainWindowForDeepLinks(null)); - // Show window when content is ready to prevent blank screen. - // First run keeps the maximize-by-default behavior; afterwards restore - // whatever the user last had (a restart-to-update should feel lossless). + // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { - if (!savedState || savedState.maximized) win.maximize(); + win.maximize(); win.show(); }); - trackWindowState(win); // Open external links in system browser (not sandboxed Electron window) // This handles window.open() and target="_blank" links diff --git a/apps/x/apps/main/src/updater.ts b/apps/x/apps/main/src/updater.ts index 063845ac..bf274623 100644 --- a/apps/x/apps/main/src/updater.ts +++ b/apps/x/apps/main/src/updater.ts @@ -1,51 +1,14 @@ -import { app, autoUpdater, dialog, net, nativeImage, BrowserWindow } from "electron"; +import { app, autoUpdater, net, nativeImage, BrowserWindow } from "electron"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; -import fs from "node:fs"; -import path from "node:path"; -import { WorkDir } from "@x/core/dist/config/config.js"; import { capture } from "@x/core/dist/analytics/posthog.js"; import type { ipc } from "@x/shared"; export type UpdaterStatus = ipc.IPCChannels["updater:status"]["req"]; -// Cross-launch prefs: the /Applications move prompt opt-out, and the -// restart-prompt snooze (so "Later" survives window reloads and reopens). -const PREFS_PATH = path.join(WorkDir, "config", "updater.json"); - -interface UpdaterPrefs { - suppressMovePrompt?: boolean; - snoozeUntil?: number; -} - -// How long "Later" defers the proactive restart prompt. The update still -// applies on the next natural restart; Settings always offers it too. -const SNOOZE_MS = 24 * 60 * 60 * 1000; +const REPO = "rowboatlabs/rowboat"; let status: UpdaterStatus = { state: "disabled", version: "", reason: "dev" }; -// Squirrel surfaces connectivity loss as generic Errors; match the usual -// Node/Chromium/NSURLError shapes so a flaky connection doesn't read as a -// broken updater. net.isOnline() covers whatever the regex misses. -const NETWORK_ERROR_RE = - /ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|net::ERR_|internet connection appears to be offline|could not connect to the server|hostname could not be found|network connection was lost/i; - -function isNetworkError(err: Error): boolean { - return NETWORK_ERROR_RE.test(err.message) || !net.isOnline(); -} - -/** - * Network blips go to `offline` (soft UI, no analytics — the periodic check - * retries on its own); everything else is a real `error`. - */ -function reportUpdateError(err: Error): void { - if (isNetworkError(err)) { - setStatus({ state: "offline", lastCheckedAt: status.lastCheckedAt }); - return; - } - setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt }); - capture("update_failed", { message: err.message }); -} - function setStatus(next: Omit): void { status = { version: status.version, ...next }; for (const win of BrowserWindow.getAllWindows()) { @@ -81,8 +44,9 @@ function showReadyBadge(): void { /** * Initialize auto-update. Replaces update-electron-app's `notifyUser` native * dialog with our own state machine: events are forwarded to the renderer - * (updater:status), which shows a non-modal "Restart to update" chip in the - * titlebar once the update is staged. + * (updater:status), which shows a "Restart to update" card once the update + * is staged. By then Squirrel has already installed it — the card only asks + * for the restart, Chrome-style. */ export function initUpdater(): void { const version = app.getVersion(); @@ -98,25 +62,14 @@ export function initUpdater(): void { } if (process.platform === "darwin" && !app.isInApplicationsFolder()) { // Squirrel.Mac swaps the .app bundle in place, which fails outside - // /Applications (DMG mount, ~/Downloads). Don't wire the updater yet — - // offer the move instead. A successful move relaunches the app; a manual - // drag while running is picked up by the focus re-check below. + // /Applications (DMG mount, ~/Downloads). Don't wire the updater — + // Settings > Help tells the user to move the app. status = { state: "unsupported", version, reason: "not-in-applications" }; - promptMoveWhenWindowVisible(); - watchForManualMove(); return; } status = { state: "idle", version }; - wireUpdater(); -} -/** - * Attach autoUpdater listeners and start the periodic check. Called once — - * either at init, or later from the focus re-check once the app lands in - * /Applications. - */ -function wireUpdater(): void { autoUpdater.on("checking-for-update", () => { setStatus({ state: "checking", lastCheckedAt: status.lastCheckedAt }); }); @@ -126,150 +79,79 @@ function wireUpdater(): void { autoUpdater.on("update-not-available", () => { setStatus({ state: "idle", lastCheckedAt: Date.now() }); }); - autoUpdater.on("update-downloaded", (_event, _notes, releaseName) => { - // A snooze from before an app restart carries over if still current — - // "Later" means "not today", even if a fresh download re-staged since. - const snoozeUntil = readPrefs().snoozeUntil; - // releaseName is only populated on Windows (Squirrel.Windows). + autoUpdater.on("update-downloaded", (_event, releaseNotes, releaseName) => { + // macOS (Squirrel.Mac fed by update.electronjs.org) supplies both the + // release name and the GitHub release body; Squirrel.Windows only the + // name. Whatever is missing is backfilled from the GitHub API below. setStatus({ state: "ready", newVersion: releaseName || undefined, - snoozedUntil: snoozeUntil && snoozeUntil > Date.now() ? snoozeUntil : undefined, + releaseNotes: releaseNotes || undefined, }); showReadyBadge(); + if (!releaseNotes) void backfillReleaseNotes(releaseName || undefined); }); autoUpdater.on("error", (err) => { - reportUpdateError(err); + setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt }); + capture("update_failed", { message: err.message }); }); updateElectronApp({ updateSource: { type: UpdateSourceType.ElectronPublicUpdateService, - repo: "rowboatlabs/rowboat", + repo: REPO, }, notifyUser: false, }); } /** - * Manual "Check for updates". Only meaningful once the updater is wired - * (idle/error/offline); checking/downloading are already in flight and ready - * is already staged. Returns the snapshot after initiating. + * Fetch the GitHub release body for the staged update so the restart card + * can show "What's new" inline. Best-effort: on any failure the card simply + * omits the notes and keeps the link to the releases page. + */ +async function backfillReleaseNotes(releaseName: string | undefined): Promise { + const url = releaseName + ? `https://api.github.com/repos/${REPO}/releases/tags/${releaseName.startsWith("v") ? releaseName : `v${releaseName}`}` + : `https://api.github.com/repos/${REPO}/releases/latest`; + try { + const res = await net.fetch(url, { + headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" }, + }); + if (!res.ok) return; + const release = (await res.json()) as { tag_name?: string; body?: string }; + // A newer download may have re-staged meanwhile — only fill in gaps. + if (status.state !== "ready" || status.releaseNotes) return; + if (!release.body) return; + setStatus({ + state: "ready", + newVersion: status.newVersion ?? release.tag_name, + releaseNotes: release.body, + }); + } catch (err) { + console.error("[Updater] release notes fetch failed:", err); + } +} + +/** + * Manual "Check for updates". Only meaningful when idle or errored; + * checking/downloading are already in flight and ready is already staged. + * Returns the snapshot after initiating. */ export function checkForUpdates(): UpdaterStatus { - if (status.state === "idle" || status.state === "error" || status.state === "offline") { + if (status.state === "idle" || status.state === "error") { try { autoUpdater.checkForUpdates(); } catch (err) { - reportUpdateError(err instanceof Error ? err : new Error(String(err))); + const error = err instanceof Error ? err : new Error(String(err)); + setStatus({ state: "error", error: error.message, lastCheckedAt: status.lastCheckedAt }); + capture("update_failed", { message: error.message }); } } return status; } -/** - * "Later" on the restart prompt: defer re-offering for SNOOZE_MS. Persisted - * so it holds across window reloads/reopens (and app restarts, in the rare - * case an update re-stages within the window). Returns the snapshot. - */ -export function snoozeUpdateNotice(): UpdaterStatus { - if (status.state === "ready") { - const snoozeUntil = Date.now() + SNOOZE_MS; - writePrefs({ snoozeUntil }); - setStatus({ state: "ready", newVersion: status.newVersion, snoozedUntil: snoozeUntil }); - } - return status; -} - export function quitAndInstallUpdate(): void { - // The user engaged with the prompt — a leftover "Later" shouldn't suppress - // the next update's prompt after this install. (undefined drops the key.) - writePrefs({ snoozeUntil: undefined }); capture("update_restarted", { from: status.version, to: status.newVersion }); autoUpdater.quitAndInstall(); } - -/** Returns false when the move failed or the user declined the OS prompt. */ -export function moveToApplications(): boolean { - if (process.platform !== "darwin") return false; - try { - // Relaunches from the new location on success. The default conflict - // handler prompts if a copy already exists in /Applications. - return app.moveToApplicationsFolder(); - } catch (err) { - console.error("[Updater] moveToApplicationsFolder failed:", err); - return false; - } -} - -/** - * initUpdater runs before any window exists — an unparented dialog there - * would float alone on screen before the app has even appeared. Wait for the - * main window to become visible and attach the prompt to it as a sheet. - */ -function promptMoveWhenWindowVisible(): void { - const attach = (win: BrowserWindow) => { - if (win.isVisible()) void promptMoveToApplications(win); - else win.once("show", () => void promptMoveToApplications(win)); - }; - const existing = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()); - if (existing) attach(existing); - else app.once("browser-window-created", (_event, win) => attach(win)); -} - -/** - * If the user drags the app into /Applications themselves while it's - * running, pick that up on the next window focus and wire the updater — - * no relaunch needed. (The in-app move button relaunches, bypassing this.) - */ -function watchForManualMove(): void { - const recheck = () => { - if (!app.isInApplicationsFolder()) return; - app.removeListener("browser-window-focus", recheck); - setStatus({ state: "idle" }); - wireUpdater(); - }; - app.on("browser-window-focus", recheck); -} - -async function promptMoveToApplications(parent: BrowserWindow): Promise { - if (readPrefs().suppressMovePrompt) return; - const { response, checkboxChecked } = await dialog.showMessageBox(parent, { - type: "info", - message: "Move Rowboat to the Applications folder?", - detail: - "Rowboat can only install updates automatically when it runs from the Applications folder.", - buttons: ["Move to Applications", "Not Now"], - defaultId: 0, - cancelId: 1, - checkboxLabel: "Don't ask again", - }); - if (checkboxChecked) writePrefs({ suppressMovePrompt: true }); - if (response !== 0) return; - if (!moveToApplications() && !parent.isDestroyed()) { - // Gatekeeper app translocation (and declined OS conflict prompts) make - // the move fail without any OS feedback — give the manual path. - await dialog.showMessageBox(parent, { - type: "warning", - message: "Couldn't move Rowboat", - detail: "Quit Rowboat and drag it into the Applications folder instead.", - }); - } -} - -function readPrefs(): UpdaterPrefs { - try { - return JSON.parse(fs.readFileSync(PREFS_PATH, "utf-8")) as UpdaterPrefs; - } catch { - return {}; - } -} - -function writePrefs(patch: UpdaterPrefs): void { - try { - fs.mkdirSync(path.dirname(PREFS_PATH), { recursive: true }); - fs.writeFileSync(PREFS_PATH, JSON.stringify({ ...readPrefs(), ...patch }, null, 2)); - } catch (err) { - console.error("[Updater] Failed to write updater.json:", err); - } -} diff --git a/apps/x/apps/main/src/window-state.ts b/apps/x/apps/main/src/window-state.ts deleted file mode 100644 index 7f6cde36..00000000 --- a/apps/x/apps/main/src/window-state.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { BrowserWindow, screen } from "electron"; -import fs from "node:fs"; -import path from "node:path"; -import { WorkDir } from "@x/core/dist/config/config.js"; - -// Persisted so a restart (especially restart-to-update) puts the window back -// exactly where the user had it instead of resetting to a maximized default. -const STATE_PATH = path.join(WorkDir, "config", "window-state.json"); - -export interface WindowState { - width: number; - height: number; - x?: number; - y?: number; - maximized: boolean; -} - -export function loadWindowState(): WindowState | null { - let raw: Partial; - try { - raw = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as Partial; - } catch { - return null; // first run or unreadable — caller falls back to defaults - } - if (typeof raw.width !== "number" || typeof raw.height !== "number") return null; - - const state: WindowState = { - width: Math.max(600, Math.round(raw.width)), - height: Math.max(480, Math.round(raw.height)), - x: typeof raw.x === "number" ? Math.round(raw.x) : undefined, - y: typeof raw.y === "number" ? Math.round(raw.y) : undefined, - maximized: raw.maximized === true, - }; - - // Only restore a position that still lands on a connected display — a - // position saved on a since-unplugged monitor would open off-screen. - if (state.x !== undefined && state.y !== undefined) { - const MARGIN = 40; // require at least this much of the window on-screen - const visible = screen.getAllDisplays().some(({ workArea: a }) => { - return ( - state.x! < a.x + a.width - MARGIN && - state.x! + state.width > a.x + MARGIN && - state.y! >= a.y - MARGIN && - state.y! < a.y + a.height - MARGIN - ); - }); - if (!visible) { - state.x = undefined; - state.y = undefined; - } - } - return state; -} - -export function trackWindowState(win: BrowserWindow): void { - let timer: NodeJS.Timeout | null = null; - - const save = () => { - if (win.isDestroyed()) return; - const state: WindowState = { - // getNormalBounds() reports the pre-maximize bounds, so un-maximizing - // after a restart returns to the size the user actually chose. - ...win.getNormalBounds(), - maximized: win.isMaximized(), - }; - try { - fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true }); - fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); - } catch (err) { - console.error("[WindowState] save failed:", err); - } - }; - - const debounced = () => { - if (timer) clearTimeout(timer); - timer = setTimeout(save, 500); - }; - - win.on("resize", debounced); - win.on("move", debounced); - win.on("maximize", debounced); - win.on("unmaximize", debounced); - win.on("close", () => { - if (timer) clearTimeout(timer); - save(); - }); -} diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f94b3830..4bfec2cd 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -78,7 +78,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" -import { UpdateIndicator } from "@/components/update-indicator" +import { UpdateCard } from "@/components/update-card" import { BillingErrorDialog } from "@/components/billing-error-dialog" import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status" @@ -6293,7 +6293,6 @@ function App() { New chat )} - {/* Trailing layout control. Always mounted (just toggled invisible when inactive) so its -webkit-app-region:no-drag rect is stable — @@ -7060,6 +7059,7 @@ function App() { /> + -

- - Move Rowboat to the Applications folder to enable automatic updates. -

- - +

+ + Quit Rowboat and move it to the Applications folder to enable automatic updates. +

) : (

{"Automatic updates aren't available on this platform. "} @@ -249,19 +231,6 @@ function UpdateSettings() { ) break - case 'offline': - body = ( -

-

- - {"Couldn't reach the update server. Updates will resume when you're back online."} -

- -
- ) - break case 'idle': body = (
diff --git a/apps/x/apps/renderer/src/components/update-card.tsx b/apps/x/apps/renderer/src/components/update-card.tsx new file mode 100644 index 00000000..a1a31ac1 --- /dev/null +++ b/apps/x/apps/renderer/src/components/update-card.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react" +import { X } from "lucide-react" +import { Streamdown } from "streamdown" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { updatePrompted } from "@/lib/analytics" +import type { ipc as ipcShared } from "@x/shared" + +type UpdaterStatus = ipcShared.IPCChannels["updater:status"]["req"] + +const RELEASES_URL = "https://github.com/rowboatlabs/rowboat/releases" + +/** + * Bottom-left "Update available" card, shown once an update is staged. By + * that point Squirrel has already installed it — the card only asks for the + * restart (Chrome-style) and shows the release notes so new features aren't + * shipped silently. "Later"/× dismiss it for this session; the update still + * applies on the next natural restart. + */ +export function UpdateCard() { + const [status, setStatus] = useState(null) + // The version the user dismissed — if a newer update stages afterwards, + // the card re-offers itself for that one. + const [dismissedFor, setDismissedFor] = useState(null) + const promptedForRef = useRef(null) + + useEffect(() => { + let cancelled = false + void window.ipc + .invoke("updater:getStatus", null) + .then((s) => { + if (!cancelled) setStatus(s) + }) + .catch(() => {}) + const unsubscribe = window.ipc.on("updater:status", setStatus) + return () => { + cancelled = true + unsubscribe() + } + }, []) + + const ready = status?.state === "ready" + // newVersion may be "1.4.0" (Squirrel.Windows) or "v1.4.0" (GitHub tag). + const version = ready ? status.newVersion?.replace(/^v/, "") : undefined + const versionKey = version ?? "unknown" + const visible = ready && dismissedFor !== versionKey + + useEffect(() => { + if (visible && promptedForRef.current !== versionKey) { + updatePrompted() + promptedForRef.current = versionKey + } + }, [visible, versionKey]) + + if (!visible || status?.state !== "ready") return null + + const releaseUrl = version ? `${RELEASES_URL}/tag/v${version}` : `${RELEASES_URL}/latest` + + return ( +
+
+ +

Update available

+
+ {version && v{version}} + +
+
+

+ A new version is ready to install. Restart to start using it. +

+ {status.releaseNotes && ( +
+
What's new
+
+ + {status.releaseNotes} + +
+
+ )} +
+ + + +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/update-indicator.tsx b/apps/x/apps/renderer/src/components/update-indicator.tsx deleted file mode 100644 index 9b9f22e3..00000000 --- a/apps/x/apps/renderer/src/components/update-indicator.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect, useRef, useState } from "react" -import { LoaderIcon, RefreshCw, X } from "lucide-react" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { cn } from "@/lib/utils" -import { updatePrompted } from "@/lib/analytics" - -type UpdaterStatus = { - state: string - newVersion?: string - snoozedUntil?: number -} - -/** - * Titlebar update indicator (Zed-style): invisible while idle, a spinner - * while an update downloads, and a "Restart to update" chip once it's staged. - * Clicking the chip restarts into the new version; the small × snoozes the - * chip for 24h (owned by main — see updater.ts snoozeUpdateNotice — so it - * survives window reloads and reopens). - * - * Both buttons stay mounted and are toggled invisible when inactive — a - * freshly-mounted no-drag button inside the drag-region header has its first - * click swallowed by the window drag (see CaffeinateIndicator). - */ -export function UpdateIndicator() { - const [status, setStatus] = useState(null) - // Bumped when a snooze expires so the chip re-appears without new IPC. - const [now, setNow] = useState(() => Date.now()) - const promptedRef = useRef(false) - - useEffect(() => { - let cancelled = false - void window.ipc - .invoke("updater:getStatus", null) - .then((s) => { - if (!cancelled) setStatus(s) - }) - .catch(() => {}) - const unsubscribe = window.ipc.on("updater:status", (s) => { - setStatus(s) - }) - return () => { - cancelled = true - unsubscribe() - } - }, []) - - const snoozed = !!status?.snoozedUntil && status.snoozedUntil > now - const downloading = status?.state === "downloading" - const ready = status?.state === "ready" && !snoozed - - // Wake up when the snooze lapses so the chip re-offers itself. - useEffect(() => { - if (status?.state !== "ready" || !status.snoozedUntil) return - const delay = status.snoozedUntil - Date.now() - if (delay <= 0) return - const timer = setTimeout(() => setNow(Date.now()), delay + 1000) - return () => clearTimeout(timer) - }, [status]) - - useEffect(() => { - if (ready && !promptedRef.current) { - updatePrompted() - promptedRef.current = true - } - }, [ready]) - - const description = status?.newVersion - ? `Rowboat ${status.newVersion} has been downloaded.` - : "A new version of Rowboat has been downloaded." - - return ( -
- - - - - {downloading && Downloading update…} - {ready && ( - - {description} Restart to finish updating — you'll come right back to where you are. - - )} - - - - - - {ready && Remind me later} - -
- ) -} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index df8062bf..3ed207df 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -61,19 +61,17 @@ const KnowledgeSourceConfigSchema = z.object({ // Lifecycle of the client auto-updater (apps/main/src/updater.ts). // - disabled: dev build — the updater never initializes // - unsupported: platform can't auto-update (`reason` says why) -// - ready: an update is downloaded and staged; restart applies it -// - offline: a check failed for network reasons — retried automatically, -// surfaced softly (unlike `error`, which is a real updater failure) +// - ready: an update is downloaded and installed; restart switches to it const UpdaterStatusSchema = z.object({ - state: z.enum(['disabled', 'unsupported', 'idle', 'checking', 'downloading', 'ready', 'error', 'offline']), + state: z.enum(['disabled', 'unsupported', 'idle', 'checking', 'downloading', 'ready', 'error']), version: z.string(), reason: z.enum(['dev', 'platform', 'not-in-applications']).optional(), newVersion: z.string().optional(), + // Markdown body of the staged update's GitHub release, when known — the + // restart card renders it as "What's new". + releaseNotes: z.string().optional(), error: z.string().optional(), lastCheckedAt: z.number().optional(), - // While `ready`: don't proactively re-offer the restart prompt before this - // epoch ms. Owned by main (persisted) so it survives window reloads. - snoozedUntil: z.number().optional(), }); const ipcSchemas = { @@ -768,17 +766,6 @@ const ipcSchemas = { req: z.null(), res: z.object({}), }, - // "Later" on the restart-to-update prompt. Main persists the snooze and - // pushes the refreshed status (with `snoozedUntil`) to all windows. - 'updater:snooze': { - req: z.null(), - res: UpdaterStatusSchema, - }, - // macOS only: app.moveToApplicationsFolder(). Relaunches the app on success. - 'updater:moveToApplications': { - req: z.null(), - res: z.object({ moved: z.boolean() }), - }, 'granola:getConfig': { req: z.null(), res: z.object({