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 00000000..f673439e Binary files /dev/null and b/apps/x/apps/main/icons/install-loading.gif differ diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 1ab8e7e4..2a6c5018 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -26,7 +26,6 @@ "node-pty": "^1.1.0", "papaparse": "^5.5.3", "pdf-parse": "^2.4.5", - "update-electron-app": "^3.1.2", "xlsx": "^0.18.5", "zod": "^4.2.1" }, diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 99bf361c..d02e4f6e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -85,6 +85,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 } 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'; @@ -833,6 +835,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 @@ -856,6 +862,28 @@ 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 {}; + }, '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