Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion css/modals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2537,6 +2537,15 @@ button.sm-stat:disabled {
transform var(--transition-fast);
}

/* The display above outranks the user agent's [hidden] rule, so without this a
button hidden from script stays on screen. #update-install was the one that
showed: "Restart & Install" sat under "Up to Date", and clicking it asked the
updater to install a download that did not exist. */
.btn-p[hidden],
.btn-g[hidden] {
display: none;
}

.btn-icon {
width: 13px;
height: 13px;
Expand Down Expand Up @@ -3187,11 +3196,48 @@ button.sm-stat:disabled {
transition: width var(--transition-fast);
}

/* "What's new" in #update-modal: the release body as plain text, one line per
paragraph or bullet. It scrolls on its own so a long changelog cannot push the
buttons off the dialog. No display rule here on purpose, so [hidden] still hides. */
.update-notes {
margin-top: 16px;
}

.update-notes-head {
font-size: .7rem;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 6px;
}

.update-notes-body {
max-height: 180px;
overflow-y: auto;
padding: 10px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 7px;
font-family: var(--sans);
font-size: .78rem;
line-height: 1.5;
color: var(--text2);
white-space: pre-line;
overflow-wrap: anywhere;
}

.ctx-i.active {
color: var(--accent);
}

.ctx-i.active svg {
/* The header's update item after a download failed with nobody watching. */
.ctx-i.is-failed {
color: var(--red);
}

.ctx-i.active svg,
.ctx-i.is-failed svg {
opacity: 1;
}

Expand Down
5 changes: 0 additions & 5 deletions css/panels.css
Original file line number Diff line number Diff line change
Expand Up @@ -2841,11 +2841,6 @@ button.dt-cell:hover {
padding: 5px 12px;
}

/* `.btn-g` sets a display of its own, which beats the UA's [hidden]. */
.panel-float-return[hidden] {
display: none;
}

/* ─── desktop and Electron only ───
Not a breakpoint but a device test, and the same one js/panel-float.js asks:
a window wants a pointer that can hover a 6px resize band and hold a title
Expand Down
17 changes: 12 additions & 5 deletions docs/update-error-codes.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Update error codes

The desktop app checks for updates on startup, and again whenever you pick
**⋯ → Check for Updates**. If a check fails it shows a short explanation and a code
like `UPD-02`. This page says what each code means.
The desktop app checks for updates on startup, every few hours while it stays open,
and whenever you pick **⋯ → Check for Updates**. If a check, a download or an
install fails it shows a short explanation and a code like `UPD-02`. This page says
what each code means.

A download that fails in the background, with no dialog open, turns the menu item
red and changes it to **Update Failed — Retry**; picking it checks again and shows
the code if it fails a second time.

Codes only appear in the desktop app (Windows and the Linux AppImage). The website
is always up to date, and the macOS and `.deb` builds are updated by hand.
Expand All @@ -17,6 +22,7 @@ is always up to date, and the macOS and `.deb` builds are updated by hand.
| `UPD-04` | The update server reported a problem on its side. | Nothing to fix locally; try again later. |
| `UPD-05` | An update downloaded, but its contents did not match what the server said they should be, so it was discarded rather than installed. | Try again. If it keeps happening, report it — see below. |
| `UPD-06` | This copy of the app has no update channel, so it cannot update itself. Expected for the macOS and `.deb` builds, and when running from source. | [Download the latest version](https://github.com/thethinkmachine/AutomataStudio/releases/latest) manually. |
| `UPD-07` | An update was downloaded, but its installer could not be started — usually because the downloaded file has since been removed (by a disk cleaner or antivirus, say) or was blocked from running. | Pick **Check for Updates** to download it again, or [download the latest version](https://github.com/thethinkmachine/AutomataStudio/releases/latest) and run its installer. |
| `UPD-99` | Something failed that does not match any case above. | Report it — see below. |

`UPD-05` is a safety feature, not a bug in itself: the app refuses to install
Expand All @@ -36,6 +42,7 @@ updater are prefixed `[updater]`.
## For maintainers

The codes are defined in the `UpdateErrors` table in
[`electron/main.cjs`](../electron/main.cjs), and `classifyUpdateError()` beside it
[`electron/updates.cjs`](../electron/updates.cjs), and `classifyUpdateError()` beside it
decides which one an error maps to. Adding a code means adding a row there **and** a
row here — a code with no entry on this page is worse than no code at all.
row here — a code with no entry on this page is worse than no code at all, and
`tests/updates.test.js` fails until both exist.
221 changes: 21 additions & 200 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const path = require('node:path');
const fs = require('node:fs/promises');
const fsSync = require('node:fs');
const { CLAUDE_CODE_URL, runClaudeCode } = require('./claude-code.cjs');
const { createUpdateController } = require('./updates.cjs');

// `AutomataStudio --cli <command> …` runs the command line instead of the app:
// this executable re-run as Node (ELECTRON_RUN_AS_NODE) on the bundled CLI,
Expand Down Expand Up @@ -313,35 +314,13 @@ if (process.defaultApp && process.argv.length >= 2) {
// canAutoUpdate() the startup check uses -- two copies of that rule would drift,
// and the copy in the renderer cannot see app.isPackaged or APPIMAGE anyway.
ipcMain.handle('updates-supported', () => canAutoUpdate());
ipcMain.on('check-for-updates', () => checkForUpdatesManually());
// The other half of sendUpdateStatus: the page asks for the state it may have
// missed. update-status is a broadcast into a window that is still loading, so
// without this the renderer's only knowledge of the updater is whatever happened
// to arrive after its listener existed. See lastUpdateStatus.
ipcMain.handle('update-state', () => lastUpdateStatus);

// quitAndInstall closes the window on the way out, which runs the page's
// beforeunload backup save exactly as an ordinary quit does.
//
// It can also decline, and silently: BaseUpdater.install() returns false without
// throwing when quitAndInstallCalled is already set or the downloaded file is no
// longer known, and dispatchError's only listener writes to a console a packaged
// GUI app does not have. The page was left showing "Restart & Install" over a
// button that had become a no-op for the rest of the session -- which reads as an
// update that refuses to install. A refusal is now an error like any other.
ipcMain.on('install-update', () => {
if (!updater) {
sendUpdateStatus({ state: 'error', ...UpdateErrors.UNSUPPORTED });
return;
}
try {
updater.quitAndInstall();
} catch (err) {
console.error('[updater] install failed:', err);
const { code, message } = classifyUpdateError(err);
sendUpdateStatus({ state: 'error', code, message });
}
});
ipcMain.on('check-for-updates', () => updates.checkManually());
// The page asks for the status it may have missed: update-status is a broadcast
// into a window that may still be loading. See lastStatus in electron/updates.cjs.
ipcMain.handle('update-state', () => updates.lastStatus());
// Refused unless something is staged, and a refused install is reported as UPD-07
// rather than left as a button that does nothing. See install() in updates.cjs.
ipcMain.on('install-update', () => updates.install());

// ── StateMate transport ───────────────────────────────────────────
// The renderer hands over a fully-formed request and gets the raw response
Expand Down Expand Up @@ -798,18 +777,13 @@ function canAutoUpdate() {
return false;
}

// The startup check and the Help menu's manual check share one updater, so the
// listeners below are registered exactly once no matter which runs first.
// electron-updater's autoUpdater, or null when it cannot be loaded. Everything the
// updater *does* -- checks, downloads, installs, what the page is told -- lives in
// electron/updates.cjs; this is only the Electron half it cannot reach itself.
let updater = null;
let updaterUnavailable = false;
// Set only by the manual check, and read once, when the download lands: it is what
// tells 'update-downloaded' whether a human is waiting on this. A startup download
// reports itself `silent` and only marks the menu item; a requested one opens the
// dialog the user asked for.
let promptOnDownloaded = false;
let manualCheckRunning = false;

function getAutoUpdater() {

function loadUpdater() {
if (updater || updaterUnavailable) return updater;

// Required here rather than at the top of the file, and inside the try, because
Expand All @@ -826,36 +800,6 @@ function getAutoUpdater() {
console.error('[updater] unavailable:', err?.message ?? err);
return null;
}

// Load-bearing: a failed update check must be a no-op, and by default it is not.
// autoUpdater is an EventEmitter, so an 'error' with no listener registered
// becomes an uncaught exception. Being offline, or hitting a release whose
// latest.yml has not finished uploading, would otherwise take down an app that
// was working fine without ever having updated. The manual check reports failures
// through its own rejected promise; this keeps the process alive either way.
updater.on('error', (err) => {
console.error('[updater]', err?.message ?? err);
// Forwarded as well as logged, because this is the only channel a *failed
// install* has: quitAndInstall() reports through dispatchError rather than by
// throwing. It stays safe for a background check because the renderer drops an
// error arriving while #update-modal is closed -- so a failed startup check is
// still the no-op it has to be, and a failed click is not.
const { code, message } = classifyUpdateError(err);
sendUpdateStatus({ state: 'error', code, message });
});

updater.on('download-progress', ({ percent }) => {
sendUpdateStatus({ state: 'downloading', percent: Math.round(percent) });
});

updater.on('update-downloaded', ({ version }) => {
// `silent` separates the startup check from a click. The startup download
// must not take over the screen, so the page only marks its menu item; a
// click opts into the dialog. Either way the update is already on disk.
sendUpdateStatus({ state: 'downloaded', version, silent: !promptOnDownloaded });
promptOnDownloaded = false;
});

return updater;
}

Expand All @@ -864,140 +808,17 @@ function getAutoUpdater() {
// is the one piece of window chrome this app does not draw itself, and it would be
// the only framed surface in a frameless window. See js/electron-bridge.js for the
// receiving end and index.html #update-modal for the markup.
// The last thing sent, replayed over the 'update-state' channel above. Broadcasts
// are fire-and-forget into a window that may not have finished loading: the startup
// check begins at whenReady, while js/electron-bridge.js does not register its
// listener until the whole module graph has evaluated. On the second and later
// launches the installer is already in the pending cache, so 'update-downloaded'
// fires a second or two in -- squarely inside that gap -- and the page never heard
// that an update was staged, never offered the install, and re-checked from scratch
// on the next launch. Forever.
let lastUpdateStatus = null;

function sendUpdateStatus(payload) {
lastUpdateStatus = payload;
mainWindow?.webContents.send('update-status', payload);
}

// What the user is shown when a check fails. electron-updater's own errors are
// unusable here: an HttpError stringifies to the entire response -- status, request
// URL, then every response header, Set-Cookie included -- which fills the dialog
// with session cookies and tells a non-developer nothing they can act on.
//
// So each failure becomes one of these: a sentence saying what to do, plus a stable
// code to quote in a bug report. The code is the half that survives translation,
// screenshots and paraphrasing, which is why it is shown even though the sentence
// is the useful part. Keep this table and docs/update-error-codes.md in step --
// a code with no entry there is worse than no code.
const UpdateErrors = {
OFFLINE: { code: 'UPD-01', message: 'Could not reach the update server. Check your internet connection and try again.' },
NO_RELEASE: { code: 'UPD-02', message: 'No update information has been published yet. Please try again later.' },
REFUSED: { code: 'UPD-03', message: 'The update server refused the request. Please try again in a few minutes.' },
SERVER: { code: 'UPD-04', message: 'The update server is having problems. Please try again later.' },
CORRUPT: { code: 'UPD-05', message: 'The downloaded update failed its safety check and was discarded. Please try again.' },
UNSUPPORTED: { code: 'UPD-06', message: 'This copy cannot update itself. Please download the latest version manually.' },
UNKNOWN: { code: 'UPD-99', message: 'Something went wrong while checking for updates.' },
};

const NETWORK_ERRNOS = new Set([
'ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT',
'ENETUNREACH', 'EHOSTUNREACH', 'EAI_AGAIN', 'EPIPE',
]);

function classifyUpdateError(err) {
const text = String(err?.message ?? err);
if (err?.code === 'UPDATER_UNAVAILABLE') return UpdateErrors.UNSUPPORTED;
if (NETWORK_ERRNOS.has(err?.code)) return UpdateErrors.OFFLINE;
if (/checksum|sha512|signature/i.test(text)) return UpdateErrors.CORRUPT;

// The provider usually rethrows its HttpError wrapped in a plain Error, so the
// status survives only inside the message text -- hence the fallback parse.
const status = typeof err?.statusCode === 'number'
? err.statusCode
: Number(/HttpError:\s*(\d{3})/.exec(text)?.[1]) || null;

// "Cannot find latest.yml …" means the release exists but carries no manifest,
// which is the same story for the user as no release at all.
if (status === 404 || /Cannot find .*(?:in the (?:latest )?release|update info)/i.test(text)) {
return UpdateErrors.NO_RELEASE;
}
if (status === 401 || status === 403 || status === 429) return UpdateErrors.REFUSED;
if (status !== null && status >= 500) return UpdateErrors.SERVER;
return UpdateErrors.UNKNOWN;
}

// electron-updater never empties its own pending directory, so the installer for a
// version that has since been installed stays on disk at full size -- two of them
// here, 100 MB each, for 2.0.0 and 2.5.0. It is only cleared on the way to
// *replacing* it (a cached file whose checksum no longer matches the manifest), and
// "there is nothing newer to install" never takes that path. So do it here, which is
// the one moment the answer is known to be that.
async function clearStaleUpdateCache(u) {
try {
await u.downloadedUpdateHelper?.clear();
} catch (err) {
// Best-effort: a locked or missing cache is not a reason to fail a check that
// has already succeeded.
console.error('[updater] could not clear pending cache:', err?.message ?? err);
}
}
const updates = createUpdateController({
loadUpdater,
appVersion: () => app.getVersion(),
send: payload => mainWindow?.webContents.send('update-status', payload),
});

// checkForUpdates, not checkForUpdatesAndNotify: the latter raises an OS
// notification, and every surface this feature has belongs inside the window.
function initAutoUpdater() {
if (!canAutoUpdate()) return;
const u = getAutoUpdater();
if (!u) return;

// checkForUpdates, not checkForUpdatesAndNotify: the latter raises an OS
// notification, and every surface this feature has belongs inside the window.
// autoDownload is on, so a staged update announces itself through the
// 'update-downloaded' handler above, which marks the menu item and nothing more.
u.checkForUpdates()
.then(result => { if (result && !result.isUpdateAvailable) clearStaleUpdateCache(u); })
.catch(() => {});
}

// Wired to Help > Check for Updates…, which is only built when canAutoUpdate() is
// true -- an always-present item that can only ever answer "not supported here"
// is worse than no item at all.
async function checkForUpdatesManually() {
// checkForUpdates() reuses one in-flight promise internally, so a second click
// would silently resolve against the first check's result. Refusing re-entry
// keeps one click to one visible answer.
if (manualCheckRunning) return;
manualCheckRunning = true;
sendUpdateStatus({ state: 'checking' });
try {
const u = getAutoUpdater();
if (!u) {
const unavailable = new Error('The updater module failed to load.');
unavailable.code = 'UPDATER_UNAVAILABLE';
throw unavailable;
}

const result = await u.checkForUpdates();
// isUpdateAvailable is the provider's own verdict. The manifest names the latest
// version whether or not it is newer, so comparing version strings here would
// reimplement the comparison electron-updater has already done.
if (!result?.isUpdateAvailable) {
sendUpdateStatus({ state: 'up-to-date', version: app.getVersion() });
clearStaleUpdateCache(u);
return;
}

// autoDownload is on, so the fetch is already running by the time checkForUpdates
// resolves; 'download-progress' and 'update-downloaded' carry it from here.
promptOnDownloaded = true;
sendUpdateStatus({ state: 'available', version: result.updateInfo.version });
} catch (err) {
promptOnDownloaded = false;
// The whole error goes here, where a developer can read it; only the code and
// the sentence cross to the window.
console.error('[updater] manual check failed:', err);
const { code, message } = classifyUpdateError(err);
sendUpdateStatus({ state: 'error', code, message });
} finally {
manualCheckRunning = false;
}
updates.start();
}

// Double-clicking a second `.automaton` file while the app is running must open
Expand Down
2 changes: 1 addition & 1 deletion electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
installUpdate: () => ipcRenderer.send('install-update'),
// The state the page may have missed. update-status is broadcast into a window
// that is still loading, and the startup check can resolve before this script's
// consumer exists — see lastUpdateStatus in electron/main.cjs. Resolves null when
// consumer exists — see lastStatus in electron/updates.cjs. Resolves null when
// nothing has been reported yet.
updateState: () => ipcRenderer.invoke('update-state'),
// callback({ state, version?, percent?, message?, silent? }); returns an
Expand Down
Loading
Loading