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
This commit is contained in:
Harshvardhan Vatsa 2026-07-12 15:17:22 +05:30
parent 61344effea
commit 1742620cc1
16 changed files with 1027 additions and 16 deletions

View file

@ -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`.

View file

@ -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,
})
},
{

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -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(),

View file

@ -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/<rel-path> 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

View file

@ -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<UpdaterStatus, "version">): 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<void> {
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);
}
}

View file

@ -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<WindowState>;
try {
raw = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as Partial<WindowState>;
} 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();
});
}

View file

@ -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 */}
<UpdateReadyNotice busy={inCall || activeIsProcessing} />
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
<FixedSidebarToggle
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone, WifiOff } from "lucide-react"
import {
Dialog,
@ -27,6 +27,7 @@ import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
import type { ipc as ipcShared } from "@x/shared"
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
@ -122,11 +123,181 @@ interface SettingsDialogProps {
onOpenChange?: (open: boolean) => void
}
// --- Updates section (Help tab) ---
type UpdaterStatus = ipcShared.IPCChannels['updater:status']['req']
function UpdateSettings() {
const [status, setStatus] = useState<UpdaterStatus | null>(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<number | null>(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 = (
<p className="text-xs text-muted-foreground">
Automatic updates are disabled in development builds.
</p>
)
break
case 'unsupported':
body = status.reason === 'not-in-applications' ? (
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
<AlertTriangle className="size-3.5 shrink-0 mt-0.5 text-amber-500" />
Move Rowboat to the Applications folder to enable automatic updates.
</p>
<Button
size="sm"
variant="outline"
className="shrink-0"
onClick={() =>
void window.ipc.invoke('updater:moveToApplications', null).then(({ moved }) => {
if (!moved) {
toast("Couldn't move Rowboat", {
description: 'Quit Rowboat and drag it into the Applications folder instead.',
})
}
})
}
>
Move to Applications
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground">
{"Automatic updates aren't available on this platform. "}
<a
href="https://github.com/rowboatlabs/rowboat/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
Get the latest release
</a>
</p>
)
break
case 'checking':
case 'downloading':
body = (
<Button size="sm" variant="outline" disabled>
<Loader2 className="size-3.5 animate-spin" />
{status.state === 'checking' ? 'Checking for updates…' : 'Downloading update…'}
</Button>
)
break
case 'ready':
body = (
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{status.newVersion
? `Rowboat ${status.newVersion} is ready to install.`
: 'An update is ready to install.'}
</p>
<Button
size="sm"
className="shrink-0"
onClick={() => void window.ipc.invoke('updater:quitAndInstall', null)}
>
Restart to update
</Button>
</div>
)
break
case 'error':
body = (
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
<AlertTriangle className="size-3.5 shrink-0 mt-0.5 text-destructive" />
{`Update check failed: ${status.error ?? 'unknown error'}`}
</p>
<Button size="sm" variant="outline" className="shrink-0" onClick={checkNow}>
Try again
</Button>
</div>
)
break
case 'offline':
body = (
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
<WifiOff className="size-3.5 shrink-0 mt-0.5" />
{"Couldn't reach the update server. Updates will resume when you're back online."}
</p>
<Button size="sm" variant="outline" className="shrink-0" onClick={checkNow}>
Try again
</Button>
</div>
)
break
case 'idle':
body = (
<div className="flex items-center gap-3">
<Button size="sm" variant="outline" onClick={checkNow}>
<RefreshCw className="size-3.5" />
Check for updates
</Button>
{confirmedUpToDate && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<CheckCircle2 className="size-3.5 text-green-500" />
{"You're up to date."}
</span>
)}
</div>
)
break
}
return (
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium">Updates</h4>
<p className="text-xs text-muted-foreground mt-0.5">Rowboat v{status.version}</p>
</div>
{body}
</div>
)
}
// --- Help & Support tab ---
function HelpSettings() {
return (
<div className="space-y-4">
<UpdateSettings />
<Separator />
<div>
<h4 className="text-sm font-medium">Help &amp; Support</h4>
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>

View file

@ -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 (
<Sonner
theme={resolvedTheme}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
@ -18,6 +24,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
// 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)",

View file

@ -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. "}
<a
href={notesUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{"See what's new"}
</a>
</>
),
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
}

View file

@ -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 })
}

View file

@ -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<unknown> {
return JSON.parse(await fs.readFile(stampPath(), "utf-8"));
}
async function writeStamp(content: string): Promise<void> {
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);
});
});

View file

@ -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;
}

View file

@ -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({