From 70ddf19489d94af6ab49fdbdb97aa3a551ab29ee Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa <76729417+hrsvrn@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:59:03 +0530 Subject: [PATCH] feat(x): client auto-update with restart card and inline release notes (#744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat(x): replace update toast with Zed-style titlebar chip The restart-to-update prompt moves from a sonner toast to a persistent, non-interrupting titlebar indicator: a spinner while an update downloads, then a 'Restart to update' chip once staged. Clicking restarts into the new version; the x snoozes the chip for 24h via the existing persisted snooze. Busy-deferral is dropped - the chip never interrupts. Co-Authored-By: Claude Fable 5 * feat(x): bottom-left update card with inline release notes Reframe the restart prompt per PR feedback: by the time the user sees it, Squirrel has already installed the update — the prompt only asks for a restart (Chrome-style), and being loud about what shipped matters more than being unobtrusive during early adoption. - Replace the titlebar chip with a bottom-left "Update available" card showing the new version, an inline "What's new" section rendered from the GitHub release notes, and Later / Release notes / Restart now - Release notes come from Squirrel.Mac's update feed (update.electronjs.org passes the release body through); Squirrel.Windows only reports the release name, so missing notes are backfilled from the GitHub API - Drop the 24h snooze machinery — Later/× just dismiss for the session - Drop offline detection (soft `offline` state) — separate PR later - Drop the macOS move-to-Applications prompt/move button — separate PR later; Settings still explains why updates are unavailable outside /Applications - Drop window state persistence Co-Authored-By: Claude Fable 5 * feat(x): aggregate release notes across skipped versions, persistent up-to-date status - Updater aggregates release bodies for every version between the running app and the update target, with a commit-log fallback when no release in range has notes - CI fills empty release bodies with GitHub's auto-generated notes - Settings shows a persistent 'You're up to date' line with last-checked time instead of a transient confirmation * fix(x): restore updater:quitAndInstall lines dropped in merge The conflict resolution in 67d6a542 truncated the updater:quitAndInstall entry in both the shared IPC schema and the main-process handler, leaving an unclosed brace that broke the shared package build. Co-Authored-By: Claude Fable 5 * fix(x): simplify release notes handling per review feedback Release notes are a process concern, not the updater's: drop the CI job that auto-filled empty release bodies and the in-app GitHub API backfill (backfillReleaseNotes + commitLogFallback). The update card now adapts instead — it renders the notes Squirrel supplies, or a static "Bug fixes and improvements." line when empty. Also document that updateElectronApp() configures the same autoUpdater singleton our listeners observe (no race), and that gen-install-loading.sh is a manual one-off tool whose committed GIF feeds Squirrel's loadingGif. Co-Authored-By: Claude Fable 5 * refactor(x): drop update-electron-app, drive autoUpdater directly With notifyUser: false the package reduced to setFeedURL + an immediate checkForUpdates() + a 10-minute unconditional timer, plus guards we already have (isPackaged, platform, app-ready). Inline those three lines instead and remove the dependency. The interval now runs through the existing guarded checkForUpdates() (no-op unless idle/error), so ticks no longer emit Squirrel.Mac "check already in progress" errors or make Squirrel.Windows re-download an already-staged update. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/x/ANALYTICS.md | 8 ++ apps/x/apps/main/forge.config.cjs | 10 ++ apps/x/apps/main/icons/gen-install-loading.sh | 44 ++++++ apps/x/apps/main/icons/install-loading.gif | Bin 0 -> 9582 bytes apps/x/apps/main/package.json | 1 - apps/x/apps/main/src/ipc.ts | 28 ++++ apps/x/apps/main/src/main.ts | 15 +- apps/x/apps/main/src/updater.ts | 134 ++++++++++++++++++ apps/x/apps/renderer/src/App.tsx | 19 +++ .../src/components/settings-dialog.tsx | 129 +++++++++++++++++ .../renderer/src/components/ui/sonner.tsx | 19 +++ .../renderer/src/components/update-card.tsx | 114 +++++++++++++++ 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 | 47 ++++++ apps/x/pnpm-lock.yaml | 43 ++---- 17 files changed, 752 insertions(+), 44 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/renderer/src/components/update-card.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 e933ab9a..5bc0f456 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -96,6 +96,14 @@ 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 "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) ### `view_opened` — feature-importance funnel One event per view the user lands on, fired centrally from the `currentViewState` effect in `apps/renderer/src/App.tsx`. `view` is one of: `chat`, `file`, `graph`, `task`, `suggested-topics`, `meetings`, `live-notes`, `email`, `workspace`, `knowledge-view`, `chat-history`, `home`, `code`, `bg-tasks`, `apps`. Keyed on the view *type*, so switching files or threads inside a view doesn't re-fire. 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..9b807fad --- /dev/null +++ b/apps/x/apps/main/icons/gen-install-loading.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# One-off dev tool, run manually (requires ImageMagick) — NOT part of any +# build step. Its output, install-loading.gif, is committed and wired into +# forge.config.cjs as Squirrel.Windows' `loadingGif` (the installer's only +# UI). Re-run only if the icon or branding changes. +# +# 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 {}; + }, 'app:consumePendingTrayCommand': async () => { return { toggleMeetingNotes: consumePendingToggleMeetingNotes() }; }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 108dce6f..cde05238 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -18,7 +18,7 @@ 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 { 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"; @@ -449,16 +449,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..66d5aee3 --- /dev/null +++ b/apps/x/apps/main/src/updater.ts @@ -0,0 +1,134 @@ +import { app, autoUpdater, nativeImage, BrowserWindow } from "electron"; +import { capture } from "@x/core/dist/analytics/posthog.js"; +import type { ipc } from "@x/shared"; + +export type UpdaterStatus = ipc.IPCChannels["updater:status"]["req"]; + +const REPO = "rowboatlabs/rowboat"; +const CHECK_INTERVAL_MS = 10 * 60 * 1000; + +let status: UpdaterStatus = { state: "disabled", version: "", reason: "dev" }; + +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, driving Electron's autoUpdater (Squirrel) against + * update.electronjs.org directly. Events are forwarded to the renderer + * (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. Must be called after app ready (it is — + * from whenReady() in main.ts). + */ +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 — + // Settings > Help tells the user to move the app. + status = { state: "unsupported", version, reason: "not-in-applications" }; + return; + } + + status = { state: "idle", version }; + + 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, 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. When notes are missing the card shows a static fallback line. + setStatus({ + state: "ready", + newVersion: releaseName || undefined, + releaseNotes: releaseNotes || undefined, + }); + showReadyBadge(); + }); + autoUpdater.on("error", (err) => { + setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt }); + capture("update_failed", { message: err.message }); + }); + + // update.electronjs.org serves both Squirrel dialects from one URL: + // Squirrel.Mac GETs it as-is (204 = up to date, JSON = update; that legacy + // format is serverType "default"), Squirrel.Windows appends /RELEASES. + autoUpdater.setFeedURL({ + url: `https://update.electronjs.org/${REPO}/${process.platform}-${process.arch}/${version}`, + serverType: "default", + }); + // Check now and every 10 minutes, through the same guard as the manual + // check: a tick is a no-op while a check/download is in flight or an + // update is already staged. + checkForUpdates(); + setInterval(checkForUpdates, CHECK_INTERVAL_MS); +} + +/** + * 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") { + try { + autoUpdater.checkForUpdates(); + } catch (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; +} + +export function quitAndInstallUpdate(): void { + capture("update_restarted", { from: status.version, to: status.newVersion }); + autoUpdater.quitAndInstall(); +} diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 3ee42e8c..9d942607 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -79,6 +79,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 { 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" @@ -4703,6 +4704,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(() => { @@ -7186,6 +7204,7 @@ function App() { /> + void } +// --- Updates section (Help tab) --- + +type UpdaterStatus = ipcShared.IPCChannels['updater:status']['req'] + +function UpdateSettings() { + const [status, setStatus] = useState(null) + + useEffect(() => { + void window.ipc.invoke('updater:getStatus', null).then(setStatus) + return window.ipc.on('updater:status', setStatus) + }, []) + + if (!status) return null + + const checkNow = () => { + // 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' ? ( +

+ + Quit Rowboat and move it 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 'idle': + body = ( +
+ {/* lastCheckedAt only exists after a check that found no update + (an available update moves the state to downloading/ready), so + idle + lastCheckedAt genuinely means "on the latest version". */} + {status.lastCheckedAt !== undefined && ( +

+ + + {`You're up to date! Rowboat v${status.version} is the latest version.`} + + {` Checked at ${new Date(status.lastCheckedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`} + + +

+ )} + +
+ ) + 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-card.tsx b/apps/x/apps/renderer/src/components/update-card.tsx new file mode 100644 index 00000000..3c9d5039 --- /dev/null +++ b/apps/x/apps/renderer/src/components/update-card.tsx @@ -0,0 +1,114 @@ +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. +

+
+
What's new
+ {status.releaseNotes ? ( +
+ + {status.releaseNotes} + +
+ ) : ( + // Releases are expected to carry notes; if this one doesn't, show + // a static line instead of an empty pane. +

Bug fixes and improvements.

+ )} +
+
+ + + +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts index 5acf3d84..0774e574 100644 --- a/apps/x/apps/renderer/src/lib/analytics.ts +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -87,6 +87,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 1bdfde81..98191887 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -58,6 +58,22 @@ 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 installed; restart switches to it +const UpdaterStatusSchema = z.object({ + 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(), +}); + const ipcSchemas = { 'app:getVersions': { req: z.null(), @@ -798,6 +814,37 @@ 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({}), + }, // Tray commands issued before the renderer was ready (mirrors the pending // deep-link pull above): the renderer drains this once on mount. 'app:consumePendingTrayCommand': { diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 9a4d1f32..1b6a26d4 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -4,12 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -catalogs: - default: - vitest: - specifier: 4.1.7 - version: 4.1.7 - overrides: vscode-jsonrpc: 8.2.0 @@ -91,9 +85,6 @@ importers: pdf-parse: specifier: ^2.4.5 version: 2.4.5 - update-electron-app: - specifier: ^3.1.2 - version: 3.1.2 xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -665,21 +656,25 @@ packages: resolution: {integrity: sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==} cpu: [arm64] os: [linux] + libc: [musl] '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.198': resolution: {integrity: sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==} cpu: [arm64] os: [linux] + libc: [glibc] '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.198': resolution: {integrity: sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==} cpu: [x64] os: [linux] + libc: [musl] '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.198': resolution: {integrity: sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==} cpu: [x64] os: [linux] + libc: [glibc] '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.198': resolution: {integrity: sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==} @@ -2137,30 +2132,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} @@ -5664,10 +5664,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -5984,9 +5980,6 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} - github-url-to-object@4.0.6: - resolution: {integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -8766,9 +8759,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - update-electron-app@3.1.2: - resolution: {integrity: sha512-htLyPJv7mEoCpaSzCg0W3Hxz7ID0GC7BIhhpK32/ITG7McrWak4aOkLEOjJheKAI94AxtBVTjCk4EFIvyttw2w==} - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -11416,7 +11406,7 @@ snapshots: cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 7.5.1(express@5.2.1) jose: 6.1.3 @@ -15423,13 +15413,11 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.6: {} - eventsource-parser@3.1.0: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 execa@1.0.0: dependencies: @@ -15822,10 +15810,6 @@ snapshots: dependencies: pump: 3.0.3 - github-url-to-object@4.0.6: - dependencies: - is-url: 1.2.4 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -19209,11 +19193,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-electron-app@3.1.2: - dependencies: - github-url-to-object: 4.0.6 - ms: 2.1.3 - uri-js@4.4.1: dependencies: punycode: 2.3.1