mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat(x): client auto-update with restart card and inline release notes (#744)
* feat(x): client auto-update with non-interrupting UX
Auto-update via update.electronjs.org (Squirrel), replacing the native
update dialog with a state machine pushed to the renderer:
- Non-modal "restart to update" toast, deferred while a call/turn is
active and retracted if one starts; 24h "Later" snooze persisted in
main so it survives window reloads
- Offline detection: network errors get a soft `offline` state instead
of a red failure, and don't emit update_failed analytics
- Update-waiting badges: macOS dock badge, Windows taskbar overlay icon
- macOS move-to-Applications prompt parented to the main window, with a
failure fallback dialog and focus re-check after a manual drag
- Settings > Help: version, manual check with accurate transient
"You're up to date" feedback, per-state messaging
- "Updated to vX" card on first launch after an upgrade (downgrades
restamp silently); "What's new" links to release notes
- Toaster: follow app dark mode (fixes unreadable description text) and
restyle to theme tokens
- Window state persistence so restart-to-update feels lossless
- Tests for version stamping and upgrade comparison
* feat(x): replace update toast with Zed-style titlebar chip
The restart-to-update prompt moves from a sonner toast to a persistent,
non-interrupting titlebar indicator: a spinner while an update downloads,
then a 'Restart to update' chip once staged. Clicking restarts into the
new version; the x snoozes the chip for 24h via the existing persisted
snooze. Busy-deferral is dropped - the chip never interrupts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): bottom-left update card with inline release notes
Reframe the restart prompt per PR feedback: by the time the user sees it,
Squirrel has already installed the update — the prompt only asks for a
restart (Chrome-style), and being loud about what shipped matters more
than being unobtrusive during early adoption.
- Replace the titlebar chip with a bottom-left "Update available" card
showing the new version, an inline "What's new" section rendered from
the GitHub release notes, and Later / Release notes / Restart now
- Release notes come from Squirrel.Mac's update feed (update.electronjs.org
passes the release body through); Squirrel.Windows only reports the
release name, so missing notes are backfilled from the GitHub API
- Drop the 24h snooze machinery — Later/× just dismiss for the session
- Drop offline detection (soft `offline` state) — separate PR later
- Drop the macOS move-to-Applications prompt/move button — separate PR
later; Settings still explains why updates are unavailable outside
/Applications
- Drop window state persistence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): aggregate release notes across skipped versions, persistent up-to-date status
- Updater aggregates release bodies for every version between the running
app and the update target, with a commit-log fallback when no release in
range has notes
- CI fills empty release bodies with GitHub's auto-generated notes
- Settings shows a persistent 'You're up to date' line with last-checked
time instead of a transient confirmation
* fix(x): restore updater:quitAndInstall lines dropped in merge
The conflict resolution in 67d6a542 truncated the updater:quitAndInstall
entry in both the shared IPC schema and the main-process handler, leaving
an unclosed brace that broke the shared package build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): simplify release notes handling per review feedback
Release notes are a process concern, not the updater's: drop the
CI job that auto-filled empty release bodies and the in-app GitHub
API backfill (backfillReleaseNotes + commitLogFallback). The update
card now adapts instead — it renders the notes Squirrel supplies,
or a static "Bug fixes and improvements." line when empty.
Also document that updateElectronApp() configures the same
autoUpdater singleton our listeners observe (no race), and that
gen-install-loading.sh is a manual one-off tool whose committed
GIF feeds Squirrel's loadingGif.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): drop update-electron-app, drive autoUpdater directly
With notifyUser: false the package reduced to setFeedURL + an
immediate checkForUpdates() + a 10-minute unconditional timer, plus
guards we already have (isPackaged, platform, app-ready). Inline
those three lines instead and remove the dependency.
The interval now runs through the existing guarded checkForUpdates()
(no-op unless idle/error), so ticks no longer emit Squirrel.Mac
"check already in progress" errors or make Squirrel.Windows
re-download an already-staged update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7b032537bc
commit
70ddf19489
17 changed files with 752 additions and 44 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
{
|
||||
|
|
|
|||
44
apps/x/apps/main/icons/gen-install-loading.sh
Normal file
44
apps/x/apps/main/icons/gen-install-loading.sh
Normal file
|
|
@ -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"
|
||||
BIN
apps/x/apps/main/icons/install-loading.gif
Normal file
BIN
apps/x/apps/main/icons/install-loading.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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/<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
|
||||
|
|
|
|||
134
apps/x/apps/main/src/updater.ts
Normal file
134
apps/x/apps/main/src/updater.ts
Normal file
|
|
@ -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<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, 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();
|
||||
}
|
||||
|
|
@ -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() {
|
|||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<UpdateCard />
|
||||
<BillingErrorDialog
|
||||
open={billingErrorOpen}
|
||||
match={billingErrorMatch}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,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"
|
||||
import { useProviderModels } from "@/hooks/use-provider-models"
|
||||
|
||||
|
|
@ -132,11 +133,139 @@ 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)
|
||||
|
||||
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 = (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatic updates are disabled in development builds.
|
||||
</p>
|
||||
)
|
||||
break
|
||||
case 'unsupported':
|
||||
body = status.reason === 'not-in-applications' ? (
|
||||
<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" />
|
||||
Quit Rowboat and move it to the Applications folder to enable automatic updates.
|
||||
</p>
|
||||
) : (
|
||||
<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 'idle':
|
||||
body = (
|
||||
<div className="space-y-2">
|
||||
{/* 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 && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<span>
|
||||
{`You're up to date! Rowboat v${status.version} is the latest version.`}
|
||||
<span className="text-muted-foreground/60">
|
||||
{` Checked at ${new Date(status.lastCheckedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={checkNow}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for updates
|
||||
</Button>
|
||||
</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 & Support</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
114
apps/x/apps/renderer/src/components/update-card.tsx
Normal file
114
apps/x/apps/renderer/src/components/update-card.tsx
Normal file
|
|
@ -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<UpdaterStatus | null>(null)
|
||||
// The version the user dismissed — if a newer update stages afterwards,
|
||||
// the card re-offers itself for that one.
|
||||
const [dismissedFor, setDismissedFor] = useState<string | null>(null)
|
||||
const promptedForRef = useRef<string | null>(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 (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-4 left-4 z-50 w-[340px] rounded-xl border border-border/60 bg-popover/95 backdrop-blur-xl p-4 shadow-xl shadow-black/10 animate-in fade-in slide-in-from-bottom-4 duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-blue-500 shrink-0" aria-hidden />
|
||||
<h4 className="text-sm font-semibold">Update available</h4>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{version && <Badge variant="secondary">v{version}</Badge>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissedFor(versionKey)}
|
||||
aria-label="Dismiss"
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
A new version is ready to install. Restart to start using it.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<h5 className="text-xs font-semibold">What's new</h5>
|
||||
{status.releaseNotes ? (
|
||||
<div className="mt-1.5 max-h-56 overflow-y-auto">
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-xs [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{status.releaseNotes}
|
||||
</Streamdown>
|
||||
</div>
|
||||
) : (
|
||||
// Releases are expected to carry notes; if this one doesn't, show
|
||||
// a static line instead of an empty pane.
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">Bug fixes and improvements.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDismissedFor(versionKey)}>
|
||||
Later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto"
|
||||
onClick={() => window.open(releaseUrl, "_blank")}
|
||||
>
|
||||
Release notes
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void window.ipc.invoke("updater:quitAndInstall", null)}>
|
||||
Restart now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
125
apps/x/packages/core/src/config/app_version.test.ts
Normal file
125
apps/x/packages/core/src/config/app_version.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
53
apps/x/packages/core/src/config/app_version.ts
Normal file
53
apps/x/packages/core/src/config/app_version.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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': {
|
||||
|
|
|
|||
43
apps/x/pnpm-lock.yaml
generated
43
apps/x/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue