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 00000000..f673439e Binary files /dev/null and b/apps/x/apps/main/icons/install-loading.gif differ diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ed7a4e4c..bbc47fd5 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -69,6 +69,8 @@ import * as appsIndexer from '@x/core/dist/apps/indexer.js'; 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 * 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'; @@ -805,6 +807,10 @@ export function stopServicesWatcher(): void { // Handler Implementations // ============================================================================ +// app:consumeUpdateInfo returns `updatedFrom` at most once per app run, so a +// renderer reload doesn't re-show the "updated to vX" card. +let updateNoticeConsumed = false; + /** * Register all IPC handlers * Add new handlers here as you add channels to IPCChannels @@ -828,6 +834,34 @@ export function setupIpcHandlers() { 'app:consumePendingDeepLink': async () => { 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({