mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +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
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': {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue