mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
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>
This commit is contained in:
parent
06e52af81c
commit
e2aac0a711
10 changed files with 181 additions and 460 deletions
|
|
@ -101,9 +101,9 @@ All in `apps/renderer/src/lib/analytics.ts`:
|
||||||
|
|
||||||
The desktop client's own updates — distinct from the in-app apps feature, which owns `app_updated`:
|
The desktop client's own updates — distinct from the in-app apps feature, which owns `app_updated`:
|
||||||
|
|
||||||
- `update_prompted` — renderer (`apps/renderer/src/lib/analytics.ts`): the restart-to-update titlebar chip was shown for a staged update
|
- `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` is Windows-only; Squirrel.Mac doesn't report the release name)
|
- `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. Network/offline errors are excluded — they go to the soft `offline` state and are not captured (a user offline for hours would otherwise emit one per periodic check)
|
- `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)
|
- `client_updated` — main (`apps/main/src/ipc.ts`), `{ from, to }`: first launch on a newer version (fires once per update, whatever the restart path; downgrades restamp silently and don't fire)
|
||||||
|
|
||||||
## Person properties
|
## Person properties
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ import * as appsServer from '@x/core/dist/apps/server.js';
|
||||||
import * as appsAgents from '@x/core/dist/apps/agents.js';
|
import * as appsAgents from '@x/core/dist/apps/agents.js';
|
||||||
import { capture } from '@x/core/dist/analytics/posthog.js';
|
import { capture } from '@x/core/dist/analytics/posthog.js';
|
||||||
import { recordAppVersion, isVersionUpgrade } from '@x/core/dist/config/app_version.js';
|
import { recordAppVersion, isVersionUpgrade } from '@x/core/dist/config/app_version.js';
|
||||||
import { getUpdaterStatus, checkForUpdates, quitAndInstallUpdate, snoozeUpdateNotice, moveToApplications } from './updater.js';
|
import { getUpdaterStatus, checkForUpdates, quitAndInstallUpdate } from './updater.js';
|
||||||
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
|
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
|
||||||
import * as appsStars from '@x/core/dist/apps/stars.js';
|
import * as appsStars from '@x/core/dist/apps/stars.js';
|
||||||
import * as appsInstaller from '@x/core/dist/apps/installer.js';
|
import * as appsInstaller from '@x/core/dist/apps/installer.js';
|
||||||
|
|
@ -856,12 +856,6 @@ export function setupIpcHandlers() {
|
||||||
quitAndInstallUpdate();
|
quitAndInstallUpdate();
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
'updater:snooze': async () => {
|
|
||||||
return snoozeUpdateNotice();
|
|
||||||
},
|
|
||||||
'updater:moveToApplications': async () => {
|
|
||||||
return { moved: moveToApplications() };
|
|
||||||
},
|
|
||||||
'analytics:bootstrap': async () => {
|
'analytics:bootstrap': async () => {
|
||||||
return {
|
return {
|
||||||
installationId: getInstallationId(),
|
installationId: getInstallationId(),
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import { disposeAllTerminals } from "./terminal.js";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { initUpdater } from "./updater.js";
|
import { initUpdater } from "./updater.js";
|
||||||
import { loadWindowState, trackWindowState } from "./window-state.js";
|
|
||||||
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
|
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
|
||||||
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.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";
|
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||||
|
|
@ -259,12 +258,9 @@ function setupZoomShortcuts(win: BrowserWindow) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const savedState = loadWindowState();
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: savedState?.width ?? 1280,
|
width: 1280,
|
||||||
height: savedState?.height ?? 800,
|
height: 800,
|
||||||
x: savedState?.x,
|
|
||||||
y: savedState?.y,
|
|
||||||
minWidth: 600,
|
minWidth: 600,
|
||||||
minHeight: 480,
|
minHeight: 480,
|
||||||
show: false, // Don't show until ready
|
show: false, // Don't show until ready
|
||||||
|
|
@ -290,14 +286,11 @@ function createWindow() {
|
||||||
setMainWindowForDeepLinks(win);
|
setMainWindowForDeepLinks(win);
|
||||||
win.on("closed", () => setMainWindowForDeepLinks(null));
|
win.on("closed", () => setMainWindowForDeepLinks(null));
|
||||||
|
|
||||||
// Show window when content is ready to prevent blank screen.
|
// Show window when content is ready to prevent blank screen
|
||||||
// First run keeps the maximize-by-default behavior; afterwards restore
|
|
||||||
// whatever the user last had (a restart-to-update should feel lossless).
|
|
||||||
win.once("ready-to-show", () => {
|
win.once("ready-to-show", () => {
|
||||||
if (!savedState || savedState.maximized) win.maximize();
|
win.maximize();
|
||||||
win.show();
|
win.show();
|
||||||
});
|
});
|
||||||
trackWindowState(win);
|
|
||||||
|
|
||||||
// Open external links in system browser (not sandboxed Electron window)
|
// Open external links in system browser (not sandboxed Electron window)
|
||||||
// This handles window.open() and target="_blank" links
|
// This handles window.open() and target="_blank" links
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,14 @@
|
||||||
import { app, autoUpdater, dialog, net, nativeImage, BrowserWindow } from "electron";
|
import { app, autoUpdater, net, nativeImage, BrowserWindow } from "electron";
|
||||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { WorkDir } from "@x/core/dist/config/config.js";
|
|
||||||
import { capture } from "@x/core/dist/analytics/posthog.js";
|
import { capture } from "@x/core/dist/analytics/posthog.js";
|
||||||
import type { ipc } from "@x/shared";
|
import type { ipc } from "@x/shared";
|
||||||
|
|
||||||
export type UpdaterStatus = ipc.IPCChannels["updater:status"]["req"];
|
export type UpdaterStatus = ipc.IPCChannels["updater:status"]["req"];
|
||||||
|
|
||||||
// Cross-launch prefs: the /Applications move prompt opt-out, and the
|
const REPO = "rowboatlabs/rowboat";
|
||||||
// restart-prompt snooze (so "Later" survives window reloads and reopens).
|
|
||||||
const PREFS_PATH = path.join(WorkDir, "config", "updater.json");
|
|
||||||
|
|
||||||
interface UpdaterPrefs {
|
|
||||||
suppressMovePrompt?: boolean;
|
|
||||||
snoozeUntil?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// How long "Later" defers the proactive restart prompt. The update still
|
|
||||||
// applies on the next natural restart; Settings always offers it too.
|
|
||||||
const SNOOZE_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
let status: UpdaterStatus = { state: "disabled", version: "", reason: "dev" };
|
let status: UpdaterStatus = { state: "disabled", version: "", reason: "dev" };
|
||||||
|
|
||||||
// Squirrel surfaces connectivity loss as generic Errors; match the usual
|
|
||||||
// Node/Chromium/NSURLError shapes so a flaky connection doesn't read as a
|
|
||||||
// broken updater. net.isOnline() covers whatever the regex misses.
|
|
||||||
const NETWORK_ERROR_RE =
|
|
||||||
/ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|net::ERR_|internet connection appears to be offline|could not connect to the server|hostname could not be found|network connection was lost/i;
|
|
||||||
|
|
||||||
function isNetworkError(err: Error): boolean {
|
|
||||||
return NETWORK_ERROR_RE.test(err.message) || !net.isOnline();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Network blips go to `offline` (soft UI, no analytics — the periodic check
|
|
||||||
* retries on its own); everything else is a real `error`.
|
|
||||||
*/
|
|
||||||
function reportUpdateError(err: Error): void {
|
|
||||||
if (isNetworkError(err)) {
|
|
||||||
setStatus({ state: "offline", lastCheckedAt: status.lastCheckedAt });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt });
|
|
||||||
capture("update_failed", { message: err.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(next: Omit<UpdaterStatus, "version">): void {
|
function setStatus(next: Omit<UpdaterStatus, "version">): void {
|
||||||
status = { version: status.version, ...next };
|
status = { version: status.version, ...next };
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
|
@ -81,8 +44,9 @@ function showReadyBadge(): void {
|
||||||
/**
|
/**
|
||||||
* Initialize auto-update. Replaces update-electron-app's `notifyUser` native
|
* Initialize auto-update. Replaces update-electron-app's `notifyUser` native
|
||||||
* dialog with our own state machine: events are forwarded to the renderer
|
* dialog with our own state machine: events are forwarded to the renderer
|
||||||
* (updater:status), which shows a non-modal "Restart to update" chip in the
|
* (updater:status), which shows a "Restart to update" card once the update
|
||||||
* titlebar once the update is staged.
|
* is staged. By then Squirrel has already installed it — the card only asks
|
||||||
|
* for the restart, Chrome-style.
|
||||||
*/
|
*/
|
||||||
export function initUpdater(): void {
|
export function initUpdater(): void {
|
||||||
const version = app.getVersion();
|
const version = app.getVersion();
|
||||||
|
|
@ -98,25 +62,14 @@ export function initUpdater(): void {
|
||||||
}
|
}
|
||||||
if (process.platform === "darwin" && !app.isInApplicationsFolder()) {
|
if (process.platform === "darwin" && !app.isInApplicationsFolder()) {
|
||||||
// Squirrel.Mac swaps the .app bundle in place, which fails outside
|
// Squirrel.Mac swaps the .app bundle in place, which fails outside
|
||||||
// /Applications (DMG mount, ~/Downloads). Don't wire the updater yet —
|
// /Applications (DMG mount, ~/Downloads). Don't wire the updater —
|
||||||
// offer the move instead. A successful move relaunches the app; a manual
|
// Settings > Help tells the user to move the app.
|
||||||
// drag while running is picked up by the focus re-check below.
|
|
||||||
status = { state: "unsupported", version, reason: "not-in-applications" };
|
status = { state: "unsupported", version, reason: "not-in-applications" };
|
||||||
promptMoveWhenWindowVisible();
|
|
||||||
watchForManualMove();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
status = { state: "idle", version };
|
status = { state: "idle", version };
|
||||||
wireUpdater();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach autoUpdater listeners and start the periodic check. Called once —
|
|
||||||
* either at init, or later from the focus re-check once the app lands in
|
|
||||||
* /Applications.
|
|
||||||
*/
|
|
||||||
function wireUpdater(): void {
|
|
||||||
autoUpdater.on("checking-for-update", () => {
|
autoUpdater.on("checking-for-update", () => {
|
||||||
setStatus({ state: "checking", lastCheckedAt: status.lastCheckedAt });
|
setStatus({ state: "checking", lastCheckedAt: status.lastCheckedAt });
|
||||||
});
|
});
|
||||||
|
|
@ -126,150 +79,79 @@ function wireUpdater(): void {
|
||||||
autoUpdater.on("update-not-available", () => {
|
autoUpdater.on("update-not-available", () => {
|
||||||
setStatus({ state: "idle", lastCheckedAt: Date.now() });
|
setStatus({ state: "idle", lastCheckedAt: Date.now() });
|
||||||
});
|
});
|
||||||
autoUpdater.on("update-downloaded", (_event, _notes, releaseName) => {
|
autoUpdater.on("update-downloaded", (_event, releaseNotes, releaseName) => {
|
||||||
// A snooze from before an app restart carries over if still current —
|
// macOS (Squirrel.Mac fed by update.electronjs.org) supplies both the
|
||||||
// "Later" means "not today", even if a fresh download re-staged since.
|
// release name and the GitHub release body; Squirrel.Windows only the
|
||||||
const snoozeUntil = readPrefs().snoozeUntil;
|
// name. Whatever is missing is backfilled from the GitHub API below.
|
||||||
// releaseName is only populated on Windows (Squirrel.Windows).
|
|
||||||
setStatus({
|
setStatus({
|
||||||
state: "ready",
|
state: "ready",
|
||||||
newVersion: releaseName || undefined,
|
newVersion: releaseName || undefined,
|
||||||
snoozedUntil: snoozeUntil && snoozeUntil > Date.now() ? snoozeUntil : undefined,
|
releaseNotes: releaseNotes || undefined,
|
||||||
});
|
});
|
||||||
showReadyBadge();
|
showReadyBadge();
|
||||||
|
if (!releaseNotes) void backfillReleaseNotes(releaseName || undefined);
|
||||||
});
|
});
|
||||||
autoUpdater.on("error", (err) => {
|
autoUpdater.on("error", (err) => {
|
||||||
reportUpdateError(err);
|
setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt });
|
||||||
|
capture("update_failed", { message: err.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
updateElectronApp({
|
updateElectronApp({
|
||||||
updateSource: {
|
updateSource: {
|
||||||
type: UpdateSourceType.ElectronPublicUpdateService,
|
type: UpdateSourceType.ElectronPublicUpdateService,
|
||||||
repo: "rowboatlabs/rowboat",
|
repo: REPO,
|
||||||
},
|
},
|
||||||
notifyUser: false,
|
notifyUser: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual "Check for updates". Only meaningful once the updater is wired
|
* Fetch the GitHub release body for the staged update so the restart card
|
||||||
* (idle/error/offline); checking/downloading are already in flight and ready
|
* can show "What's new" inline. Best-effort: on any failure the card simply
|
||||||
* is already staged. Returns the snapshot after initiating.
|
* omits the notes and keeps the link to the releases page.
|
||||||
*/
|
*/
|
||||||
export function checkForUpdates(): UpdaterStatus {
|
async function backfillReleaseNotes(releaseName: string | undefined): Promise<void> {
|
||||||
if (status.state === "idle" || status.state === "error" || status.state === "offline") {
|
const url = releaseName
|
||||||
|
? `https://api.github.com/repos/${REPO}/releases/tags/${releaseName.startsWith("v") ? releaseName : `v${releaseName}`}`
|
||||||
|
: `https://api.github.com/repos/${REPO}/releases/latest`;
|
||||||
try {
|
try {
|
||||||
autoUpdater.checkForUpdates();
|
const res = await net.fetch(url, {
|
||||||
|
headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const release = (await res.json()) as { tag_name?: string; body?: string };
|
||||||
|
// A newer download may have re-staged meanwhile — only fill in gaps.
|
||||||
|
if (status.state !== "ready" || status.releaseNotes) return;
|
||||||
|
if (!release.body) return;
|
||||||
|
setStatus({
|
||||||
|
state: "ready",
|
||||||
|
newVersion: status.newVersion ?? release.tag_name,
|
||||||
|
releaseNotes: release.body,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reportUpdateError(err instanceof Error ? err : new Error(String(err)));
|
console.error("[Updater] release notes fetch failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Later" on the restart prompt: defer re-offering for SNOOZE_MS. Persisted
|
* Manual "Check for updates". Only meaningful when idle or errored;
|
||||||
* so it holds across window reloads/reopens (and app restarts, in the rare
|
* checking/downloading are already in flight and ready is already staged.
|
||||||
* case an update re-stages within the window). Returns the snapshot.
|
* Returns the snapshot after initiating.
|
||||||
*/
|
*/
|
||||||
export function snoozeUpdateNotice(): UpdaterStatus {
|
export function checkForUpdates(): UpdaterStatus {
|
||||||
if (status.state === "ready") {
|
if (status.state === "idle" || status.state === "error") {
|
||||||
const snoozeUntil = Date.now() + SNOOZE_MS;
|
try {
|
||||||
writePrefs({ snoozeUntil });
|
autoUpdater.checkForUpdates();
|
||||||
setStatus({ state: "ready", newVersion: status.newVersion, snoozedUntil: snoozeUntil });
|
} 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;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function quitAndInstallUpdate(): void {
|
export function quitAndInstallUpdate(): void {
|
||||||
// The user engaged with the prompt — a leftover "Later" shouldn't suppress
|
|
||||||
// the next update's prompt after this install. (undefined drops the key.)
|
|
||||||
writePrefs({ snoozeUntil: undefined });
|
|
||||||
capture("update_restarted", { from: status.version, to: status.newVersion });
|
capture("update_restarted", { from: status.version, to: status.newVersion });
|
||||||
autoUpdater.quitAndInstall();
|
autoUpdater.quitAndInstall();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns false when the move failed or the user declined the OS prompt. */
|
|
||||||
export function moveToApplications(): boolean {
|
|
||||||
if (process.platform !== "darwin") return false;
|
|
||||||
try {
|
|
||||||
// Relaunches from the new location on success. The default conflict
|
|
||||||
// handler prompts if a copy already exists in /Applications.
|
|
||||||
return app.moveToApplicationsFolder();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[Updater] moveToApplicationsFolder failed:", err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* initUpdater runs before any window exists — an unparented dialog there
|
|
||||||
* would float alone on screen before the app has even appeared. Wait for the
|
|
||||||
* main window to become visible and attach the prompt to it as a sheet.
|
|
||||||
*/
|
|
||||||
function promptMoveWhenWindowVisible(): void {
|
|
||||||
const attach = (win: BrowserWindow) => {
|
|
||||||
if (win.isVisible()) void promptMoveToApplications(win);
|
|
||||||
else win.once("show", () => void promptMoveToApplications(win));
|
|
||||||
};
|
|
||||||
const existing = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed());
|
|
||||||
if (existing) attach(existing);
|
|
||||||
else app.once("browser-window-created", (_event, win) => attach(win));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If the user drags the app into /Applications themselves while it's
|
|
||||||
* running, pick that up on the next window focus and wire the updater —
|
|
||||||
* no relaunch needed. (The in-app move button relaunches, bypassing this.)
|
|
||||||
*/
|
|
||||||
function watchForManualMove(): void {
|
|
||||||
const recheck = () => {
|
|
||||||
if (!app.isInApplicationsFolder()) return;
|
|
||||||
app.removeListener("browser-window-focus", recheck);
|
|
||||||
setStatus({ state: "idle" });
|
|
||||||
wireUpdater();
|
|
||||||
};
|
|
||||||
app.on("browser-window-focus", recheck);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promptMoveToApplications(parent: BrowserWindow): Promise<void> {
|
|
||||||
if (readPrefs().suppressMovePrompt) return;
|
|
||||||
const { response, checkboxChecked } = await dialog.showMessageBox(parent, {
|
|
||||||
type: "info",
|
|
||||||
message: "Move Rowboat to the Applications folder?",
|
|
||||||
detail:
|
|
||||||
"Rowboat can only install updates automatically when it runs from the Applications folder.",
|
|
||||||
buttons: ["Move to Applications", "Not Now"],
|
|
||||||
defaultId: 0,
|
|
||||||
cancelId: 1,
|
|
||||||
checkboxLabel: "Don't ask again",
|
|
||||||
});
|
|
||||||
if (checkboxChecked) writePrefs({ suppressMovePrompt: true });
|
|
||||||
if (response !== 0) return;
|
|
||||||
if (!moveToApplications() && !parent.isDestroyed()) {
|
|
||||||
// Gatekeeper app translocation (and declined OS conflict prompts) make
|
|
||||||
// the move fail without any OS feedback — give the manual path.
|
|
||||||
await dialog.showMessageBox(parent, {
|
|
||||||
type: "warning",
|
|
||||||
message: "Couldn't move Rowboat",
|
|
||||||
detail: "Quit Rowboat and drag it into the Applications folder instead.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPrefs(): UpdaterPrefs {
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(PREFS_PATH, "utf-8")) as UpdaterPrefs;
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writePrefs(patch: UpdaterPrefs): void {
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(PREFS_PATH), { recursive: true });
|
|
||||||
fs.writeFileSync(PREFS_PATH, JSON.stringify({ ...readPrefs(), ...patch }, null, 2));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[Updater] Failed to write updater.json:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { BrowserWindow, screen } from "electron";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { WorkDir } from "@x/core/dist/config/config.js";
|
|
||||||
|
|
||||||
// Persisted so a restart (especially restart-to-update) puts the window back
|
|
||||||
// exactly where the user had it instead of resetting to a maximized default.
|
|
||||||
const STATE_PATH = path.join(WorkDir, "config", "window-state.json");
|
|
||||||
|
|
||||||
export interface WindowState {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
maximized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadWindowState(): WindowState | null {
|
|
||||||
let raw: Partial<WindowState>;
|
|
||||||
try {
|
|
||||||
raw = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as Partial<WindowState>;
|
|
||||||
} catch {
|
|
||||||
return null; // first run or unreadable — caller falls back to defaults
|
|
||||||
}
|
|
||||||
if (typeof raw.width !== "number" || typeof raw.height !== "number") return null;
|
|
||||||
|
|
||||||
const state: WindowState = {
|
|
||||||
width: Math.max(600, Math.round(raw.width)),
|
|
||||||
height: Math.max(480, Math.round(raw.height)),
|
|
||||||
x: typeof raw.x === "number" ? Math.round(raw.x) : undefined,
|
|
||||||
y: typeof raw.y === "number" ? Math.round(raw.y) : undefined,
|
|
||||||
maximized: raw.maximized === true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only restore a position that still lands on a connected display — a
|
|
||||||
// position saved on a since-unplugged monitor would open off-screen.
|
|
||||||
if (state.x !== undefined && state.y !== undefined) {
|
|
||||||
const MARGIN = 40; // require at least this much of the window on-screen
|
|
||||||
const visible = screen.getAllDisplays().some(({ workArea: a }) => {
|
|
||||||
return (
|
|
||||||
state.x! < a.x + a.width - MARGIN &&
|
|
||||||
state.x! + state.width > a.x + MARGIN &&
|
|
||||||
state.y! >= a.y - MARGIN &&
|
|
||||||
state.y! < a.y + a.height - MARGIN
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (!visible) {
|
|
||||||
state.x = undefined;
|
|
||||||
state.y = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trackWindowState(win: BrowserWindow): void {
|
|
||||||
let timer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
const save = () => {
|
|
||||||
if (win.isDestroyed()) return;
|
|
||||||
const state: WindowState = {
|
|
||||||
// getNormalBounds() reports the pre-maximize bounds, so un-maximizing
|
|
||||||
// after a restart returns to the size the user actually chose.
|
|
||||||
...win.getNormalBounds(),
|
|
||||||
maximized: win.isMaximized(),
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true });
|
|
||||||
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[WindowState] save failed:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debounced = () => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
timer = setTimeout(save, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
win.on("resize", debounced);
|
|
||||||
win.on("move", debounced);
|
|
||||||
win.on("maximize", debounced);
|
|
||||||
win.on("unmaximize", debounced);
|
|
||||||
win.on("close", () => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
save();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -78,7 +78,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { UpdateIndicator } from "@/components/update-indicator"
|
import { UpdateCard } from "@/components/update-card"
|
||||||
import { BillingErrorDialog } from "@/components/billing-error-dialog"
|
import { BillingErrorDialog } from "@/components/billing-error-dialog"
|
||||||
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
|
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
|
||||||
import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
|
import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
|
||||||
|
|
@ -6293,7 +6293,6 @@ function App() {
|
||||||
<TooltipContent side="bottom">New chat</TooltipContent>
|
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<UpdateIndicator />
|
|
||||||
<CaffeinateIndicator />
|
<CaffeinateIndicator />
|
||||||
{/* Trailing layout control. Always mounted (just toggled invisible
|
{/* Trailing layout control. Always mounted (just toggled invisible
|
||||||
when inactive) so its -webkit-app-region:no-drag rect is stable —
|
when inactive) so its -webkit-app-region:no-drag rect is stable —
|
||||||
|
|
@ -7060,6 +7059,7 @@ function App() {
|
||||||
/>
|
/>
|
||||||
</SidebarSectionProvider>
|
</SidebarSectionProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<UpdateCard />
|
||||||
<BillingErrorDialog
|
<BillingErrorDialog
|
||||||
open={billingErrorOpen}
|
open={billingErrorOpen}
|
||||||
match={billingErrorMatch}
|
match={billingErrorMatch}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone, WifiOff } from "lucide-react"
|
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -173,28 +173,10 @@ function UpdateSettings() {
|
||||||
break
|
break
|
||||||
case 'unsupported':
|
case 'unsupported':
|
||||||
body = status.reason === 'not-in-applications' ? (
|
body = status.reason === 'not-in-applications' ? (
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
|
<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" />
|
<AlertTriangle className="size-3.5 shrink-0 mt-0.5 text-amber-500" />
|
||||||
Move Rowboat to the Applications folder to enable automatic updates.
|
Quit Rowboat and move it to the Applications folder to enable automatic updates.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() =>
|
|
||||||
void window.ipc.invoke('updater:moveToApplications', null).then(({ moved }) => {
|
|
||||||
if (!moved) {
|
|
||||||
toast("Couldn't move Rowboat", {
|
|
||||||
description: 'Quit Rowboat and drag it into the Applications folder instead.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Move to Applications
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{"Automatic updates aren't available on this platform. "}
|
{"Automatic updates aren't available on this platform. "}
|
||||||
|
|
@ -249,19 +231,6 @@ function UpdateSettings() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'offline':
|
|
||||||
body = (
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
|
|
||||||
<WifiOff className="size-3.5 shrink-0 mt-0.5" />
|
|
||||||
{"Couldn't reach the update server. Updates will resume when you're back online."}
|
|
||||||
</p>
|
|
||||||
<Button size="sm" variant="outline" className="shrink-0" onClick={checkNow}>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'idle':
|
case 'idle':
|
||||||
body = (
|
body = (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
110
apps/x/apps/renderer/src/components/update-card.tsx
Normal file
110
apps/x/apps/renderer/src/components/update-card.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
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>
|
||||||
|
{status.releaseNotes && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<h5 className="text-xs font-semibold">What's new</h5>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { LoaderIcon, RefreshCw, X } from "lucide-react"
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { updatePrompted } from "@/lib/analytics"
|
|
||||||
|
|
||||||
type UpdaterStatus = {
|
|
||||||
state: string
|
|
||||||
newVersion?: string
|
|
||||||
snoozedUntil?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Titlebar update indicator (Zed-style): invisible while idle, a spinner
|
|
||||||
* while an update downloads, and a "Restart to update" chip once it's staged.
|
|
||||||
* Clicking the chip restarts into the new version; the small × snoozes the
|
|
||||||
* chip for 24h (owned by main — see updater.ts snoozeUpdateNotice — so it
|
|
||||||
* survives window reloads and reopens).
|
|
||||||
*
|
|
||||||
* Both buttons stay mounted and are toggled invisible when inactive — a
|
|
||||||
* freshly-mounted no-drag button inside the drag-region header has its first
|
|
||||||
* click swallowed by the window drag (see CaffeinateIndicator).
|
|
||||||
*/
|
|
||||||
export function UpdateIndicator() {
|
|
||||||
const [status, setStatus] = useState<UpdaterStatus | null>(null)
|
|
||||||
// Bumped when a snooze expires so the chip re-appears without new IPC.
|
|
||||||
const [now, setNow] = useState(() => Date.now())
|
|
||||||
const promptedRef = useRef(false)
|
|
||||||
|
|
||||||
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", (s) => {
|
|
||||||
setStatus(s)
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const snoozed = !!status?.snoozedUntil && status.snoozedUntil > now
|
|
||||||
const downloading = status?.state === "downloading"
|
|
||||||
const ready = status?.state === "ready" && !snoozed
|
|
||||||
|
|
||||||
// Wake up when the snooze lapses so the chip re-offers itself.
|
|
||||||
useEffect(() => {
|
|
||||||
if (status?.state !== "ready" || !status.snoozedUntil) return
|
|
||||||
const delay = status.snoozedUntil - Date.now()
|
|
||||||
if (delay <= 0) return
|
|
||||||
const timer = setTimeout(() => setNow(Date.now()), delay + 1000)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [status])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ready && !promptedRef.current) {
|
|
||||||
updatePrompted()
|
|
||||||
promptedRef.current = true
|
|
||||||
}
|
|
||||||
}, [ready])
|
|
||||||
|
|
||||||
const description = status?.newVersion
|
|
||||||
? `Rowboat ${status.newVersion} has been downloaded.`
|
|
||||||
: "A new version of Rowboat has been downloaded."
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center self-center shrink-0">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={ready ? () => void window.ipc.invoke("updater:quitAndInstall", null) : undefined}
|
|
||||||
disabled={!ready && !downloading}
|
|
||||||
aria-hidden={!ready && !downloading}
|
|
||||||
aria-label={ready ? "Restart to update" : downloading ? "Downloading update" : undefined}
|
|
||||||
className={cn(
|
|
||||||
"titlebar-no-drag flex h-8 items-center justify-center rounded-md transition-colors self-center shrink-0",
|
|
||||||
ready
|
|
||||||
? "gap-1.5 px-2.5 text-xs font-medium bg-accent/60 text-foreground hover:bg-accent"
|
|
||||||
: downloading
|
|
||||||
? "w-8 text-muted-foreground cursor-default"
|
|
||||||
: "w-0 invisible pointer-events-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{downloading ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : ready ? (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
<span>Restart to update</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{downloading && <TooltipContent side="bottom">Downloading update…</TooltipContent>}
|
|
||||||
{ready && (
|
|
||||||
<TooltipContent side="bottom" className="max-w-64">
|
|
||||||
{description} Restart to finish updating — you'll come right back to where you are.
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void window.ipc.invoke("updater:snooze", null)}
|
|
||||||
disabled={!ready}
|
|
||||||
aria-hidden={!ready}
|
|
||||||
aria-label="Remind me later"
|
|
||||||
className={cn(
|
|
||||||
"titlebar-no-drag flex h-8 items-center justify-center rounded-md text-muted-foreground transition-colors self-center shrink-0",
|
|
||||||
ready ? "w-5 hover:text-foreground" : "w-0 invisible pointer-events-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<X className={cn("size-3.5", !ready && "hidden")} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{ready && <TooltipContent side="bottom">Remind me later</TooltipContent>}
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -61,19 +61,17 @@ const KnowledgeSourceConfigSchema = z.object({
|
||||||
// Lifecycle of the client auto-updater (apps/main/src/updater.ts).
|
// Lifecycle of the client auto-updater (apps/main/src/updater.ts).
|
||||||
// - disabled: dev build — the updater never initializes
|
// - disabled: dev build — the updater never initializes
|
||||||
// - unsupported: platform can't auto-update (`reason` says why)
|
// - unsupported: platform can't auto-update (`reason` says why)
|
||||||
// - ready: an update is downloaded and staged; restart applies it
|
// - ready: an update is downloaded and installed; restart switches to it
|
||||||
// - offline: a check failed for network reasons — retried automatically,
|
|
||||||
// surfaced softly (unlike `error`, which is a real updater failure)
|
|
||||||
const UpdaterStatusSchema = z.object({
|
const UpdaterStatusSchema = z.object({
|
||||||
state: z.enum(['disabled', 'unsupported', 'idle', 'checking', 'downloading', 'ready', 'error', 'offline']),
|
state: z.enum(['disabled', 'unsupported', 'idle', 'checking', 'downloading', 'ready', 'error']),
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
reason: z.enum(['dev', 'platform', 'not-in-applications']).optional(),
|
reason: z.enum(['dev', 'platform', 'not-in-applications']).optional(),
|
||||||
newVersion: z.string().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(),
|
error: z.string().optional(),
|
||||||
lastCheckedAt: z.number().optional(),
|
lastCheckedAt: z.number().optional(),
|
||||||
// While `ready`: don't proactively re-offer the restart prompt before this
|
|
||||||
// epoch ms. Owned by main (persisted) so it survives window reloads.
|
|
||||||
snoozedUntil: z.number().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ipcSchemas = {
|
const ipcSchemas = {
|
||||||
|
|
@ -768,17 +766,6 @@ const ipcSchemas = {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({}),
|
res: z.object({}),
|
||||||
},
|
},
|
||||||
// "Later" on the restart-to-update prompt. Main persists the snooze and
|
|
||||||
// pushes the refreshed status (with `snoozedUntil`) to all windows.
|
|
||||||
'updater:snooze': {
|
|
||||||
req: z.null(),
|
|
||||||
res: UpdaterStatusSchema,
|
|
||||||
},
|
|
||||||
// macOS only: app.moveToApplicationsFolder(). Relaunches the app on success.
|
|
||||||
'updater:moveToApplications': {
|
|
||||||
req: z.null(),
|
|
||||||
res: z.object({ moved: z.boolean() }),
|
|
||||||
},
|
|
||||||
'granola:getConfig': {
|
'granola:getConfig': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue