From 1742620cc14e9dfaeb53157650750af1d84bc5be Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa Date: Sun, 12 Jul 2026 15:17:22 +0530 Subject: [PATCH] feat(x): client auto-update with non-interrupting UX Auto-update via update.electronjs.org (Squirrel), replacing the native update dialog with a state machine pushed to the renderer: - Non-modal "restart to update" toast, deferred while a call/turn is active and retracted if one starts; 24h "Later" snooze persisted in main so it survives window reloads - Offline detection: network errors get a soft `offline` state instead of a red failure, and don't emit update_failed analytics - Update-waiting badges: macOS dock badge, Windows taskbar overlay icon - macOS move-to-Applications prompt parented to the main window, with a failure fallback dialog and focus re-check after a manual drag - Settings > Help: version, manual check with accurate transient "You're up to date" feedback, per-state messaging - "Updated to vX" card on first launch after an upgrade (downgrades restamp silently); "What's new" links to release notes - Toaster: follow app dark mode (fixes unreadable description text) and restyle to theme tokens - Window state persistence so restart-to-update feels lossless - Tests for version stamping and upgrade comparison --- apps/x/ANALYTICS.md | 9 + apps/x/apps/main/forge.config.cjs | 10 + apps/x/apps/main/icons/gen-install-loading.sh | 39 +++ apps/x/apps/main/icons/install-loading.gif | Bin 0 -> 9582 bytes apps/x/apps/main/src/ipc.ts | 34 +++ apps/x/apps/main/src/main.ts | 30 +- apps/x/apps/main/src/updater.ts | 275 ++++++++++++++++++ apps/x/apps/main/src/window-state.ts | 87 ++++++ apps/x/apps/renderer/src/App.tsx | 20 ++ .../src/components/settings-dialog.tsx | 173 ++++++++++- .../renderer/src/components/ui/sonner.tsx | 19 ++ .../renderer/src/components/update-notice.tsx | 102 +++++++ apps/x/apps/renderer/src/lib/analytics.ts | 7 + .../core/src/config/app_version.test.ts | 125 ++++++++ .../x/packages/core/src/config/app_version.ts | 53 ++++ apps/x/packages/shared/src/ipc.ts | 60 ++++ 16 files changed, 1027 insertions(+), 16 deletions(-) create mode 100644 apps/x/apps/main/icons/gen-install-loading.sh create mode 100644 apps/x/apps/main/icons/install-loading.gif create mode 100644 apps/x/apps/main/src/updater.ts create mode 100644 apps/x/apps/main/src/window-state.ts create mode 100644 apps/x/apps/renderer/src/components/update-notice.tsx create mode 100644 apps/x/packages/core/src/config/app_version.test.ts create mode 100644 apps/x/packages/core/src/config/app_version.ts diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index b58d5f25..c819a5a4 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -97,6 +97,15 @@ All in `apps/renderer/src/lib/analytics.ts`: - `search_executed` — `{ types: string[] }` - `note_exported` — `{ format }` +### Client auto-update funnel + +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 card 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) +- `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 Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`. diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 0cbcd4ae..581206a8 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -250,6 +250,16 @@ module.exports = { name: `Rowboat-win32-${arch}`, setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, setupIcon: path.join(__dirname, 'icons/icon.ico'), + // The animation is Squirrel's ONLY install UI — without this + // users stare at Squirrel's unbranded default mid-install. + loadingGif: path.join(__dirname, 'icons/install-loading.gif'), + // Add/Remove Programs icon. Must be a remote URL (Squirrel + // limitation); defaults to the Atom feather otherwise. + iconUrl: 'https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/x/apps/main/icons/icon.ico', + // Skip the machine-wide MSI deployment stub — it lands on the + // GitHub release page next to setup.exe and users grab the + // wrong one (it neither launches the app nor auto-updates). + noMsi: true, }) }, { diff --git a/apps/x/apps/main/icons/gen-install-loading.sh b/apps/x/apps/main/icons/gen-install-loading.sh new file mode 100644 index 00000000..263bbe32 --- /dev/null +++ b/apps/x/apps/main/icons/gen-install-loading.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Generates the Squirrel install animation: icon + title + operation label. +# Timeline approximates a typical 20-25s install, then holds at "Almost done" +# for the slow-machine tail. Frame every 0.5s. No progress bar — Squirrel gives +# no real progress signal, so a bar would just be a fake timeline. +set -euo pipefail + +ICON=$(dirname "$0")/icon.png +# DejaVu Sans by name on systems that have it installed; set FONT to a +# DejaVuSans.ttf path on systems that don't (e.g. Windows/macOS). +FONT="${FONT:-DejaVu-Sans}" +OUT_DIR=$(mktemp -d) +mkdir -p "$OUT_DIR" +rm -f "$OUT_DIR"/frame-*.png + +FRAMES=110 # 55s total at 0.5s/frame + +for i in $(seq 0 $((FRAMES - 1))); do + label=$(awk -v i="$i" 'BEGIN { + t = i * 0.5 + if (t < 14) print "Installing"; else if (t < 21) print "Creating shortcuts"; else print "Almost done" + }') + + # Animated trailing dots (ellipsis), cycling every 2s + ndots=$((i % 4)) + dots="" + for _ in $(seq 1 $ndots); do dots=". $dots"; done + + magick -size 480x320 xc:'#252525' \ + \( "$ICON" -resize 84x84 \) -gravity center -geometry +0-72 -composite \ + -gravity center -font "$FONT" -pointsize 21 -fill '#e8e8e8' -annotate +0+8 'Installing Rowboat' \ + -gravity center -font "$FONT" -pointsize 14 -fill '#9a9a9a' -annotate +0+78 "$label" \ + -gravity center -font "$FONT" -pointsize 14 -fill '#6f6f6f' -annotate +0+100 "$dots" \ + "$OUT_DIR/frame-$(printf '%03d' "$i").png" +done + +magick -delay 50 -loop 0 "$OUT_DIR"/frame-*.png -layers Optimize "$(dirname "$0")/install-loading.gif" +echo "frames: $FRAMES" +ls -la "$(dirname "$0")/install-loading.gif" diff --git a/apps/x/apps/main/icons/install-loading.gif b/apps/x/apps/main/icons/install-loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..f673439eae26dd36ec6bee3d2a26759d25f925ec GIT binary patch literal 9582 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 { return { url: consumePendingDeepLink() }; }, + 'app:consumeUpdateInfo': async () => { + const version = app.getVersion(); + if (updateNoticeConsumed) return { version, updatedFrom: null }; + updateNoticeConsumed = true; + const changedFrom = recordAppVersion(version); + // Downgrades still restamp (so the next upgrade reports correctly) but + // don't toast "Updated to vX" or count as a client update. + const updatedFrom = changedFrom && isVersionUpgrade(changedFrom, version) ? changedFrom : null; + // 'app_updated' is taken by the in-app apps feature; this is the client itself. + if (updatedFrom) capture('client_updated', { from: updatedFrom, to: version }); + return { version, updatedFrom }; + }, + 'updater:getStatus': async () => { + return getUpdaterStatus(); + }, + 'updater:check': async () => { + return checkForUpdates(); + }, + 'updater:quitAndInstall': async () => { + 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 08299ce8..09b94fdc 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -17,7 +17,8 @@ import { import { disposeAllTerminals } from "./terminal.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; -import { updateElectronApp, UpdateSourceType } from "update-electron-app"; +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"; @@ -258,9 +259,12 @@ function setupZoomShortcuts(win: BrowserWindow) { } function createWindow() { + const savedState = loadWindowState(); const win = new BrowserWindow({ - width: 1280, - height: 800, + width: savedState?.width ?? 1280, + height: savedState?.height ?? 800, + x: savedState?.x, + y: savedState?.y, minWidth: 600, minHeight: 480, show: false, // Don't show until ready @@ -286,11 +290,14 @@ function createWindow() { setMainWindowForDeepLinks(win); win.on("closed", () => setMainWindowForDeepLinks(null)); - // Show window when content is ready to prevent blank screen + // 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). win.once("ready-to-show", () => { - win.maximize(); + if (!savedState || savedState.maximized) win.maximize(); win.show(); }); + trackWindowState(win); // Open external links in system browser (not sandboxed Electron window) // This handles window.open() and target="_blank" links @@ -359,16 +366,9 @@ app.whenReady().then(async () => { // serves workspace files via app://workspace/ for media previews. registerAppProtocol(); - // Initialize auto-updater (only in production) - if (app.isPackaged) { - updateElectronApp({ - updateSource: { - type: UpdateSourceType.ElectronPublicUpdateService, - repo: "rowboatlabs/rowboat", - }, - notifyUser: true, // Shows native dialog when update is available - }); - } + // Initialize auto-updater (no-ops in dev). Update state is pushed to the + // renderer (updater:status), which owns the restart prompt — see updater.ts. + initUpdater(); // The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs) // and is resolved per call by the shared executor in @x/core. Availability is diff --git a/apps/x/apps/main/src/updater.ts b/apps/x/apps/main/src/updater.ts new file mode 100644 index 00000000..736448dd --- /dev/null +++ b/apps/x/apps/main/src/updater.ts @@ -0,0 +1,275 @@ +import { app, autoUpdater, dialog, 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; + +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()) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send("updater:status", status); + } + } +} + +export function getUpdaterStatus(): UpdaterStatus { + return status; +} + +// 32x32 green dot with a white ring (scratchpad-generated PNG). Windows' +// counterpart of the macOS dock badge: overlays the taskbar icon while an +// update is staged. Cleared implicitly — installing quits the process. +const WIN_BADGE_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABPUlEQVR42s1XOwoCMRC12CvkAhb2HmMvYS/kCgveQU9gYS17AKu1EKwXsdDCwtQWtk9GkiUbkv2RkAw8WLLJzEvmk8kMwCwmpixiAHIAHEAhweUYC0Ugk0Yq9Esl52a+CJAygfEi5NrJBOg4S5vm6+eOgzhh+zr+Qd805pCyyzUu4wsAta7l+X1j89hjeVljfl5ZQf9oDs01pJY6BxFgpnHapcuoC7TGQoINIdA6dn7bjTauQGst7ugkwH0Z7yDBXQQyPdqnHPtAdwg9Ra27pyDyZVzBCExuI9AUGYpk3wRIp1GsWgSY/rcr1aaCdBrCdAK5XmR8G1cwilWuE2j8T1UtFAHSbcaBIlCEiP6ebCiSIhDdBdGDMHoaRi9ESZTi6JdR9Os4iYYkiZYselOaRFuexMMkmadZEo/TYPgB7Se8LkyPD5UAAAAASUVORK5CYII="; + +function showReadyBadge(): void { + if (process.platform === "darwin") { + // The window may be closed for days on macOS (app keeps running) — the + // dock badge is the only surface that says "an update is waiting". + app.dock?.setBadge("1"); + } else if (process.platform === "win32") { + const badge = nativeImage.createFromDataURL(WIN_BADGE_DATA_URL); + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.setOverlayIcon(badge, "Update ready — restart to install"); + } + } +} + +/** + * 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 the non-modal "restart to update" card at a + * moment the user isn't busy. + */ +export function initUpdater(): void { + const version = app.getVersion(); + + if (!app.isPackaged) { + status = { state: "disabled", version, reason: "dev" }; + return; + } + if (process.platform === "linux") { + // Electron's autoUpdater doesn't support Linux (deb/zip installs). + status = { state: "unsupported", version, reason: "platform" }; + return; + } + 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. + 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 }); + }); + autoUpdater.on("update-available", () => { + setStatus({ state: "downloading" }); + }); + 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). + setStatus({ + state: "ready", + newVersion: releaseName || undefined, + snoozedUntil: snoozeUntil && snoozeUntil > Date.now() ? snoozeUntil : undefined, + }); + showReadyBadge(); + }); + autoUpdater.on("error", (err) => { + reportUpdateError(err); + }); + + updateElectronApp({ + updateSource: { + type: UpdateSourceType.ElectronPublicUpdateService, + repo: "rowboatlabs/rowboat", + }, + 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. + */ +export function checkForUpdates(): UpdaterStatus { + if (status.state === "idle" || status.state === "error" || status.state === "offline") { + try { + autoUpdater.checkForUpdates(); + } catch (err) { + reportUpdateError(err instanceof Error ? err : new Error(String(err))); + } + } + 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 new file mode 100644 index 00000000..7f6cde36 --- /dev/null +++ b/apps/x/apps/main/src/window-state.ts @@ -0,0 +1,87 @@ +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 2952b7d0..899873ed 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -78,6 +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 { UpdateReadyNotice } from "@/components/update-notice" import { BillingErrorDialog } from "@/components/billing-error-dialog" import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status" @@ -4646,6 +4647,23 @@ function App() { return window.ipc.on('app:openUrl', ({ url }) => handle(url)) }, []) + // "Updated to vX.Y.Z" card on the first launch after an update. Main + // compares its persisted version stamp against the running version and + // hands out `updatedFrom` exactly once, so reloads don't re-show this. + useEffect(() => { + void window.ipc.invoke('app:consumeUpdateInfo', null).then(({ version, updatedFrom }) => { + if (!updatedFrom) return + toast(`Updated to v${version}`, { + description: `Rowboat was updated from v${updatedFrom}.`, + action: { + label: "What's new", + onClick: () => window.open(`https://github.com/rowboatlabs/rowboat/releases/tag/v${version}`, '_blank'), + }, + duration: 10000, + }) + }) + }, []) + // Report the UI theme to the apps server (spec §7.1): apps read it from // GET /_rowboat/app and get live changes via the SSE theme event. useEffect(() => { @@ -7026,6 +7044,8 @@ function App() { getLevel={tts.getLevel} /> )} + {/* Restart-to-update card; deferred while a call or turn is active */} + {/* Rendered last so its no-drag region paints over the sidebar drag region */} void } +// --- Updates section (Help tab) --- + +type UpdaterStatus = ipcShared.IPCChannels['updater:status']['req'] + +function UpdateSettings() { + const [status, setStatus] = useState(null) + // When the user clicked "Check for updates" — the "You're up to date" + // confirmation only shows for a check completed after this, so a stale + // lastCheckedAt from a background check never reads as click feedback. + const [checkStartedAt, setCheckStartedAt] = useState(null) + + useEffect(() => { + void window.ipc.invoke('updater:getStatus', null).then(setStatus) + return window.ipc.on('updater:status', setStatus) + }, []) + + const confirmedUpToDate = + checkStartedAt !== null && + status?.state === 'idle' && + status.lastCheckedAt !== undefined && + status.lastCheckedAt >= checkStartedAt + + // The confirmation is click feedback, not a status — fade it after a bit + // rather than leaving a stale "You're up to date" up indefinitely. + useEffect(() => { + if (!confirmedUpToDate) return + const timer = setTimeout(() => setCheckStartedAt(null), 5000) + return () => clearTimeout(timer) + }, [confirmedUpToDate]) + + if (!status) return null + + const checkNow = () => { + setCheckStartedAt(Date.now()) + // Progress arrives via updater:status pushes; using the invoke's snapshot + // here could stomp a newer pushed state. + void window.ipc.invoke('updater:check', null) + } + + let body: React.ReactNode + switch (status.state) { + case 'disabled': + body = ( +

+ Automatic updates are disabled in development builds. +

+ ) + break + case 'unsupported': + body = status.reason === 'not-in-applications' ? ( +
+

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

+ +
+ ) : ( +

+ {"Automatic updates aren't available on this platform. "} + + Get the latest release + +

+ ) + break + case 'checking': + case 'downloading': + body = ( + + ) + break + case 'ready': + body = ( +
+

+ {status.newVersion + ? `Rowboat ${status.newVersion} is ready to install.` + : 'An update is ready to install.'} +

+ +
+ ) + break + case 'error': + body = ( +
+

+ + {`Update check failed: ${status.error ?? 'unknown error'}`} +

+ +
+ ) + break + case 'offline': + body = ( +
+

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

+ +
+ ) + break + case 'idle': + body = ( +
+ + {confirmedUpToDate && ( + + + {"You're up to date."} + + )} +
+ ) + break + } + + return ( +
+
+

Updates

+

Rowboat v{status.version}

+
+ {body} +
+ ) +} + // --- Help & Support tab --- function HelpSettings() { return (
+ +

Help & Support

Get help from our community

diff --git a/apps/x/apps/renderer/src/components/ui/sonner.tsx b/apps/x/apps/renderer/src/components/ui/sonner.tsx index 490ba36c..1d938602 100644 --- a/apps/x/apps/renderer/src/components/ui/sonner.tsx +++ b/apps/x/apps/renderer/src/components/ui/sonner.tsx @@ -6,10 +6,16 @@ import { TriangleAlertIcon, } from "lucide-react" import { Toaster as Sonner, type ToasterProps } from "sonner" +import { useTheme } from "@/contexts/theme-context" const Toaster = ({ ...props }: ToasterProps) => { + // Without this, sonner defaults to its light theme: our inline vars keep + // the background/title correct in dark mode, but the description falls + // back to sonner's hardcoded light-theme gray — dark text on dark bg. + const { resolvedTheme } = useTheme() return ( , @@ -18,6 +24,19 @@ const Toaster = ({ ...props }: ToasterProps) => { error: , loading: , }} + // Sonner styles toast parts with attribute selectors that outrank plain + // utility classes, hence the trailing-! (important) utilities. + toastOptions={{ + classNames: { + toast: + "bg-popover/90! backdrop-blur-xl! text-popover-foreground! border-border/60! rounded-xl! shadow-xl! shadow-black/10! gap-3! p-4!", + description: "text-muted-foreground! leading-relaxed! mt-0.5!", + actionButton: + "bg-primary! text-primary-foreground! rounded-md! font-medium! px-3! transition-colors! hover:bg-primary/85!", + cancelButton: + "bg-transparent! text-muted-foreground! border! border-solid! border-border! rounded-md! transition-colors! hover:bg-muted! hover:text-foreground!", + }, + }} style={ { "--normal-bg": "var(--popover)", diff --git a/apps/x/apps/renderer/src/components/update-notice.tsx b/apps/x/apps/renderer/src/components/update-notice.tsx new file mode 100644 index 00000000..03a24ed7 --- /dev/null +++ b/apps/x/apps/renderer/src/components/update-notice.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import { updatePrompted } from "@/lib/analytics" + +// How often to re-evaluate the snooze while an update stays pending. The +// snooze itself (24h) is owned by main — see updater.ts snoozeUpdateNotice — +// so "Later" survives window reloads and reopens. +const RECHECK_MS = 60 * 60 * 1000 + +const TOAST_ID = "update-ready" + +/** + * Non-modal "restart to update" card (gap: the native update dialog used to + * interrupt mid-call). Renders nothing; drives a persistent sonner toast. + * + * `busy` defers the card while the user is in a call or a turn is running — + * it appears at the first idle moment after the update is staged, and is + * retracted if a call/turn starts while it's on screen. + */ +export function UpdateReadyNotice({ busy }: { busy: boolean }) { + const [ready, setReady] = useState<{ newVersion?: string; snoozedUntil?: number } | null>(null) + const promptedRef = useRef(false) + // Distinguishes our own toast.dismiss (retraction) from the user dismissing + // the card — sonner fires onDismiss for both, and only the latter snoozes. + const retractedRef = useRef(false) + + useEffect(() => { + void window.ipc.invoke("updater:getStatus", null).then((s) => { + if (s.state === "ready") setReady({ newVersion: s.newVersion, snoozedUntil: s.snoozedUntil }) + }) + return window.ipc.on("updater:status", (s) => { + setReady(s.state === "ready" ? { newVersion: s.newVersion, snoozedUntil: s.snoozedUntil } : null) + }) + }, []) + + useEffect(() => { + if (busy) { + // Retract the card if a call/turn starts while it's up. Only on busy — + // `ready` briefly clears during the periodic re-check cycle + // (ready → checking → ready), and dismissing there would blink the card. + retractedRef.current = true + toast.dismiss(TOAST_ID) + return + } + if (!ready) return + + // Main persists the snooze and pushes the refreshed status, which updates + // `ready.snoozedUntil` here (and in any other open window). + const snooze = () => { + if (retractedRef.current) return + void window.ipc.invoke("updater:snooze", null) + } + const show = () => { + if (ready.snoozedUntil && Date.now() < ready.snoozedUntil) return + if (!promptedRef.current) { + updatePrompted() + promptedRef.current = true + } + retractedRef.current = false + const notesUrl = ready.newVersion + ? `https://github.com/rowboatlabs/rowboat/releases/tag/v${ready.newVersion}` + : "https://github.com/rowboatlabs/rowboat/releases/latest" + toast("Update ready", { + id: TOAST_ID, // stable id: re-shows update in place, never stacks + description: ( + <> + {ready.newVersion + ? `Rowboat ${ready.newVersion} has been downloaded. ` + : "A new version of Rowboat has been downloaded. "} + {"Restart to finish updating — you'll come right back to where you are. "} + + {"See what's new"} + + + ), + duration: Infinity, + action: { + label: "Restart now", + onClick: () => void window.ipc.invoke("updater:quitAndInstall", null), + }, + cancel: { + label: "Later", + onClick: snooze, + }, + onDismiss: snooze, + }) + } + + show() + // The app can stay open for weeks (macOS especially) — quietly re-offer + // once per day while the update is still pending. + const interval = setInterval(show, RECHECK_MS) + return () => clearInterval(interval) + }, [ready, busy]) + + return null +} diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts index 131d1477..9b013155 100644 --- a/apps/x/apps/renderer/src/lib/analytics.ts +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -89,6 +89,13 @@ export function callTurnLatency(props: { }) } +// Client auto-update funnel: staged (main: update_failed on error) → prompted +// (here, when the restart card is shown) → restarted (main, on quitAndInstall) +// → client_updated (main, first launch on the new version). +export function updatePrompted() { + posthog.capture('update_prompted') +} + export function searchExecuted(types: string[]) { posthog.capture('search_executed', { types }) } diff --git a/apps/x/packages/core/src/config/app_version.test.ts b/apps/x/packages/core/src/config/app_version.test.ts new file mode 100644 index 00000000..e7b8dd84 --- /dev/null +++ b/apps/x/packages/core/src/config/app_version.test.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// WorkDir is resolved at module load, so each test gets a fresh temp workdir +// via ROWBOAT_WORKDIR + resetModules + dynamic import (same pattern as +// filesystem/files.test.ts). +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rowboat-app-version-test-")); + process.env.ROWBOAT_WORKDIR = tmpDir; + vi.resetModules(); + // config.js fire-and-forgets a git init + Today.md migration on import; + // mock them out so no repo appears (and races teardown) in the temp workdir. + vi.doMock("../knowledge/version_history.js", () => ({ + commitAll: vi.fn(async () => undefined), + initRepo: vi.fn(async () => undefined), + })); + vi.doMock("../knowledge/deprecate_today_note.js", () => ({ + deprecateTodayNote: vi.fn(async () => undefined), + })); +}); + +afterEach(async () => { + delete process.env.ROWBOAT_WORKDIR; + vi.doUnmock("../knowledge/version_history.js"); + vi.doUnmock("../knowledge/deprecate_today_note.js"); + vi.resetModules(); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +async function loadAppVersion() { + return import("./app_version.js"); +} + +const stampPath = () => path.join(tmpDir, "config", "app-version.json"); + +async function readStamp(): Promise { + return JSON.parse(await fs.readFile(stampPath(), "utf-8")); +} + +async function writeStamp(content: string): Promise { + await fs.mkdir(path.dirname(stampPath()), { recursive: true }); + await fs.writeFile(stampPath(), content); +} + +describe("recordAppVersion", () => { + it("treats a missing stamp as a fresh install and writes one", async () => { + const { recordAppVersion } = await loadAppVersion(); + + expect(recordAppVersion("1.0.0")).toBeNull(); + expect(await readStamp()).toEqual({ version: "1.0.0" }); + }); + + it("returns null when the version is unchanged", async () => { + await writeStamp(JSON.stringify({ version: "1.0.0" })); + const { recordAppVersion } = await loadAppVersion(); + + expect(recordAppVersion("1.0.0")).toBeNull(); + expect(await readStamp()).toEqual({ version: "1.0.0" }); + }); + + it("returns the previous version once after a change and restamps", async () => { + await writeStamp(JSON.stringify({ version: "1.0.0" })); + const { recordAppVersion } = await loadAppVersion(); + + expect(recordAppVersion("1.1.0")).toBe("1.0.0"); + expect(await readStamp()).toEqual({ version: "1.1.0" }); + // second call on the same version — already stamped, nothing to report + expect(recordAppVersion("1.1.0")).toBeNull(); + }); + + it("treats a corrupt stamp as a fresh install (no spurious updated notice)", async () => { + await writeStamp("{not json"); + const { recordAppVersion } = await loadAppVersion(); + + expect(recordAppVersion("1.1.0")).toBeNull(); + expect(await readStamp()).toEqual({ version: "1.1.0" }); + }); + + it("ignores a stamp whose version is not a string", async () => { + await writeStamp(JSON.stringify({ version: 5 })); + const { recordAppVersion } = await loadAppVersion(); + + expect(recordAppVersion("1.1.0")).toBeNull(); + }); + + it("reports downgrades too — filtering is the caller's job", async () => { + await writeStamp(JSON.stringify({ version: "2.0.0" })); + const { recordAppVersion } = await loadAppVersion(); + + expect(recordAppVersion("1.0.0")).toBe("2.0.0"); + expect(await readStamp()).toEqual({ version: "1.0.0" }); + }); +}); + +describe("isVersionUpgrade", () => { + it.each([ + ["1.0.0", "1.0.1"], + ["1.0.0", "1.1.0"], + ["1.9.0", "1.10.0"], // numeric compare, not lexicographic + ["0.9", "1.0.0"], // shorter version pads with zeros + ["1.2", "1.2.1"], + ["v1.0.0", "v1.0.1"], // leading v tolerated + ["1.2.3-beta.1", "1.2.4"], // prerelease suffix ignored + ])("upgrade: %s -> %s", async (from, to) => { + const { isVersionUpgrade } = await loadAppVersion(); + expect(isVersionUpgrade(from, to)).toBe(true); + }); + + it.each([ + ["1.0.0", "1.0.0"], // unchanged + ["1.1.0", "1.0.0"], // downgrade + ["1.10.0", "1.9.9"], + ["1.2.3", "1.2.3-beta.1"], // prerelease ignored -> equal + ["1.2.3", "1.2"], // padded equal-then-lower + ["abc", "1.0.0"], // unparseable input fails quiet + ["1.0.0", "1.0.x"], + ])("not an upgrade: %s -> %s", async (from, to) => { + const { isVersionUpgrade } = await loadAppVersion(); + expect(isVersionUpgrade(from, to)).toBe(false); + }); +}); diff --git a/apps/x/packages/core/src/config/app_version.ts b/apps/x/packages/core/src/config/app_version.ts new file mode 100644 index 00000000..7c1937c6 --- /dev/null +++ b/apps/x/packages/core/src/config/app_version.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { WorkDir } from './config.js'; + +const VERSION_PATH = path.join(WorkDir, 'config', 'app-version.json'); + +/** + * Record the running app version in WorkDir/config/app-version.json and + * report what changed. Returns the previously recorded version when this + * launch is the first on a new version, or null on a fresh install or when + * the version is unchanged. A missing/corrupt stamp file is treated as a + * fresh install so users never see a spurious "updated" notice. + */ +export function recordAppVersion(currentVersion: string): string | null { + let previous: string | null = null; + try { + const raw = fs.readFileSync(VERSION_PATH, 'utf-8'); + const parsed = JSON.parse(raw) as { version?: string }; + if (typeof parsed.version === 'string') previous = parsed.version; + } catch { + // fresh install or unreadable stamp — fall through with previous = null + } + + if (previous === currentVersion) return null; + + try { + fs.mkdirSync(path.dirname(VERSION_PATH), { recursive: true }); + fs.writeFileSync(VERSION_PATH, JSON.stringify({ version: currentVersion }, null, 2)); + } catch (err) { + console.error('[Updates] Failed to write app-version.json:', err); + } + + return previous; +} + +/** + * True when `to` is a strictly newer dotted version than `from`. Numeric + * segment compare; a leading `v` and any prerelease suffix are ignored. + * Unparseable input counts as not-an-upgrade, so callers stay quiet on + * malformed stamps rather than announcing a bogus update. + */ +export function isVersionUpgrade(from: string, to: string): boolean { + const parse = (v: string) => v.trim().replace(/^v/i, '').split('-')[0].split('.').map(Number); + const a = parse(from); + const b = parse(to); + if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x !== y) return y > x; + } + return false; +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index f09d0a32..df8062bf 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -58,6 +58,24 @@ const KnowledgeSourceConfigSchema = z.object({ filters: z.record(z.string(), z.unknown()).optional(), }); +// 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) +const UpdaterStatusSchema = z.object({ + state: z.enum(['disabled', 'unsupported', 'idle', 'checking', 'downloading', 'ready', 'error', 'offline']), + version: z.string(), + reason: z.enum(['dev', 'platform', 'not-in-applications']).optional(), + newVersion: 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 = { 'app:getVersions': { req: z.null(), @@ -719,6 +737,48 @@ const ipcSchemas = { url: z.string().nullable(), }), }, + // Consume-once "the app was just updated" notice. `updatedFrom` is the + // previously recorded version on the first invoke of the first launch + // after an update, and null on every other invoke (fresh install, + // unchanged version, or already consumed this run). + 'app:consumeUpdateInfo': { + req: z.null(), + res: z.object({ + version: z.string(), + updatedFrom: z.string().nullable(), + }), + }, + // --- Client auto-update (apps/main/src/updater.ts) --- + // Pushed to all windows whenever the updater state changes. + 'updater:status': { + req: UpdaterStatusSchema, + res: z.null(), + }, + 'updater:getStatus': { + req: z.null(), + res: UpdaterStatusSchema, + }, + // Kick off a manual check (no-op unless idle/error); progress arrives via + // updater:status pushes. Returns the snapshot after initiating. + 'updater:check': { + req: z.null(), + res: UpdaterStatusSchema, + }, + 'updater:quitAndInstall': { + 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({