de-shell 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- de_shell/__init__.py +25 -0
- de_shell/actions/__init__.py +0 -0
- de_shell/actions/context.py +62 -0
- de_shell/actions/figure_registry.py +53 -0
- de_shell/actions/lifecycle.py +295 -0
- de_shell/actions/registry.py +141 -0
- de_shell/actions/wizard.py +115 -0
- de_shell/app.py +170 -0
- de_shell/compute.py +103 -0
- de_shell/debug_flags.py +69 -0
- de_shell/ipc.py +236 -0
- de_shell/js/__init__.py +38 -0
- de_shell/js/__main__.py +4 -0
- de_shell/js/main/backendProcess.test.ts +70 -0
- de_shell/js/main/backendProcess.ts +330 -0
- de_shell/js/main/config.ts +53 -0
- de_shell/js/main/dialogs.ts +62 -0
- de_shell/js/main/envProgress.ts +126 -0
- de_shell/js/main/errorReport.ts +261 -0
- de_shell/js/main/index.ts +57 -0
- de_shell/js/main/problemLog.ts +53 -0
- de_shell/js/main/pythonEnv.test.ts +125 -0
- de_shell/js/main/pythonEnv.ts +442 -0
- de_shell/js/main/sentryEnvelope.test.ts +94 -0
- de_shell/js/main/sentryEnvelope.ts +100 -0
- de_shell/js/main/updater.ts +322 -0
- de_shell/js/main/updaterErrors.test.ts +111 -0
- de_shell/js/main/updaterErrors.ts +65 -0
- de_shell/js/main/window.ts +141 -0
- de_shell/js/package.json +5 -0
- de_shell/js/preload/index.ts +130 -0
- de_shell/js/renderer/FigureFrame.tsx +88 -0
- de_shell/js/renderer/figureBridge.react.ts +58 -0
- de_shell/js/renderer/figureBridge.test.ts +184 -0
- de_shell/js/renderer/figureBridge.ts +169 -0
- de_shell/js/renderer/index.ts +34 -0
- de_shell/js/renderer/protocol.ts +164 -0
- de_shell/js/renderer/shellState.test.ts +193 -0
- de_shell/js/renderer/shellState.ts +310 -0
- de_shell/js/testing/harness.cjs +244 -0
- de_shell/js/testing/harness.test.cjs +73 -0
- de_shell/log_stream.py +185 -0
- de_shell/plotting/__init__.py +0 -0
- de_shell/plotting/colormaps.py +27 -0
- de_shell/plotting/figure.py +601 -0
- de_shell/plotting/selectors/__init__.py +0 -0
- de_shell/plotting/selectors/utils.py +29 -0
- de_shell/plotting/stream.py +172 -0
- de_shell/process_guard.py +190 -0
- de_shell/session.py +211 -0
- de_shell/testing/__init__.py +0 -0
- de_shell/timing.py +28 -0
- de_shell-0.2.0.dist-info/METADATA +196 -0
- de_shell-0.2.0.dist-info/RECORD +57 -0
- de_shell-0.2.0.dist-info/WHEEL +5 -0
- de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
- de_shell-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sentryEnvelope.ts — the wire format for a problem report, with no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Sentry's ingest API is a documented HTTP endpoint, and a report is a small
|
|
5
|
+
* JSON document posted to it. That is the whole integration, so this is written
|
|
6
|
+
* against the protocol rather than against `@sentry/electron`: the SDK's value
|
|
7
|
+
* is automatic crash capture, and reports here are only ever sent because
|
|
8
|
+
* somebody clicked a button. Skipping it keeps a native crash handler out of a
|
|
9
|
+
* hardened-runtime, notarized macOS build and keeps the payload something a
|
|
10
|
+
* maintainer can read.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is pure, so the parts that are easy to get wrong — the DSN
|
|
13
|
+
* split, the auth header, the envelope's length-prefixed framing — are unit
|
|
14
|
+
* tested on every OS without a network.
|
|
15
|
+
*
|
|
16
|
+
* Protocol reference: https://develop.sentry.dev/sdk/envelopes/
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The pieces of a DSN that the ingest request actually needs. */
|
|
20
|
+
export interface SentryTarget {
|
|
21
|
+
/** Full URL to POST an envelope to. */
|
|
22
|
+
endpoint: string
|
|
23
|
+
/** The DSN's public key, for the X-Sentry-Auth header. */
|
|
24
|
+
publicKey: string
|
|
25
|
+
/** The DSN with any secret stripped — envelope headers carry it verbatim. */
|
|
26
|
+
dsn: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Split a Sentry DSN into what an ingest POST needs, or null if it isn't one.
|
|
31
|
+
*
|
|
32
|
+
* A DSN looks like `https://<publicKey>@<host>/<projectId>`; older ones carry
|
|
33
|
+
* `<publicKey>:<secret>@`, and the secret is not used by the envelope endpoint.
|
|
34
|
+
* Returning null (rather than throwing) is what lets a build ship with no DSN
|
|
35
|
+
* configured and fall back to writing the report to disk.
|
|
36
|
+
*/
|
|
37
|
+
export function parseSentryDsn(dsn: string | undefined | null): SentryTarget | null {
|
|
38
|
+
if (!dsn) return null
|
|
39
|
+
let url: URL
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(dsn.trim())
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
|
46
|
+
const publicKey = url.username
|
|
47
|
+
const projectId = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
48
|
+
if (!publicKey || !projectId || !/^\d+$/.test(projectId)) return null
|
|
49
|
+
const base = `${url.protocol}//${url.host}`
|
|
50
|
+
return {
|
|
51
|
+
endpoint: `${base}/api/${projectId}/envelope/`,
|
|
52
|
+
publicKey,
|
|
53
|
+
dsn: `${url.protocol}//${publicKey}@${url.host}/${projectId}`,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The `X-Sentry-Auth` header value for a target. */
|
|
58
|
+
export function sentryAuthHeader(target: SentryTarget, client: string): string {
|
|
59
|
+
return [
|
|
60
|
+
'Sentry sentry_version=7',
|
|
61
|
+
`sentry_client=${client}`,
|
|
62
|
+
`sentry_key=${target.publicKey}`,
|
|
63
|
+
].join(', ')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A 32-character lowercase hex id, the shape Sentry requires for `event_id`.
|
|
68
|
+
* Takes its randomness from the caller so this file stays pure and testable.
|
|
69
|
+
*/
|
|
70
|
+
export function formatEventId(bytes: Uint8Array): string {
|
|
71
|
+
let hex = ''
|
|
72
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, '0')
|
|
73
|
+
return hex.slice(0, 32).padEnd(32, '0')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Frame an event as a Sentry envelope: newline-delimited JSON, where each item
|
|
78
|
+
* is preceded by a header naming its type and byte length. The length is in
|
|
79
|
+
* BYTES, not characters — a report containing a non-ASCII path or a stack trace
|
|
80
|
+
* with a smart quote would be rejected if this counted characters.
|
|
81
|
+
*/
|
|
82
|
+
export function buildEnvelope(
|
|
83
|
+
target: SentryTarget,
|
|
84
|
+
event: Record<string, unknown>,
|
|
85
|
+
sentAt: string,
|
|
86
|
+
): string {
|
|
87
|
+
const body = JSON.stringify(event)
|
|
88
|
+
const length = Buffer.byteLength(body, 'utf8')
|
|
89
|
+
const envelopeHeader = JSON.stringify({
|
|
90
|
+
event_id: event.event_id,
|
|
91
|
+
sent_at: sentAt,
|
|
92
|
+
dsn: target.dsn,
|
|
93
|
+
})
|
|
94
|
+
const itemHeader = JSON.stringify({
|
|
95
|
+
type: 'event',
|
|
96
|
+
content_type: 'application/json',
|
|
97
|
+
length,
|
|
98
|
+
})
|
|
99
|
+
return `${envelopeHeader}\n${itemHeader}\n${body}\n`
|
|
100
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* updater.ts — electron-updater wiring (check / download / install, stable vs
|
|
3
|
+
* beta channel).
|
|
4
|
+
*
|
|
5
|
+
* The GitHub `publish` provider (electron-builder.yml) is what release.yml
|
|
6
|
+
* populates: each tagged release gets latest.yml / latest-mac.yml /
|
|
7
|
+
* latest-linux.yml alongside the installers, which is what autoUpdater reads
|
|
8
|
+
* to detect a new version per platform. Plain vX.Y.Z tags are regular GitHub
|
|
9
|
+
* releases; vX.Y.Z-rc.N/-beta.N tags are marked `prerelease` (release.yml's
|
|
10
|
+
* `channel` job) — `allowPrerelease` is what gates whether autoUpdater will
|
|
11
|
+
* offer those to a given install.
|
|
12
|
+
*
|
|
13
|
+
* CHANNEL DEFAULT FOLLOWS THE RUNNING BUILD. The default channel is derived from
|
|
14
|
+
* THIS build's own version: a prerelease build (…-rc.N) tracks beta, a plain
|
|
15
|
+
* X.Y.Z tracks stable (defaultChannelForVersion in updaterErrors.ts). So an rc
|
|
16
|
+
* install checks the beta feed (where its updates actually are) instead of the
|
|
17
|
+
* stable feed — which, with no stable release published yet, would 404 and error.
|
|
18
|
+
* An explicit user choice (the dialog radio, persisted) always overrides the
|
|
19
|
+
* default. And a "no release for this channel" result is reported as up-to-date,
|
|
20
|
+
* not an error (isNoReleaseForChannel).
|
|
21
|
+
*
|
|
22
|
+
* autoDownload is OFF: check -> tell the renderer -> user clicks "Download" ->
|
|
23
|
+
* we call downloadUpdate() -> "Restart to install" -> quitAndInstall(). This
|
|
24
|
+
* matches the "click here to update" ask (not a silent background install).
|
|
25
|
+
*
|
|
26
|
+
* HARDENING (flaky GitHub can HANG, not just crash): every network step is
|
|
27
|
+
* bounded by a timeout so a half-open connection can't wedge the UI in
|
|
28
|
+
* 'checking'/'downloading' forever (with the "Check Now" button disabled in
|
|
29
|
+
* exactly those states → unrecoverable). A stall detector watches the download
|
|
30
|
+
* for silence. Any timeout/error leaves the updater RECHECKABLE (the in-flight
|
|
31
|
+
* guard clears), raw electron-updater strings are mapped to friendly text, and
|
|
32
|
+
* quitAndInstall() can no longer throw the process down.
|
|
33
|
+
*/
|
|
34
|
+
import { app, BrowserWindow } from 'electron'
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
36
|
+
import { join } from 'path'
|
|
37
|
+
import { autoUpdater } from 'electron-updater'
|
|
38
|
+
import { friendlyError, defaultChannelForVersion } from './updaterErrors'
|
|
39
|
+
import { channel } from './config'
|
|
40
|
+
import { stopBackend } from './backendProcess'
|
|
41
|
+
import { recordProblem } from './problemLog'
|
|
42
|
+
|
|
43
|
+
export type UpdateChannel = 'stable' | 'beta'
|
|
44
|
+
|
|
45
|
+
export type UpdateStatus =
|
|
46
|
+
| { state: 'idle' }
|
|
47
|
+
| { state: 'checking' }
|
|
48
|
+
| { state: 'available'; version: string; releaseNotes?: string }
|
|
49
|
+
| { state: 'not-available' }
|
|
50
|
+
| { state: 'downloading'; percent: number }
|
|
51
|
+
| { state: 'downloaded'; version: string }
|
|
52
|
+
| { state: 'error'; message: string }
|
|
53
|
+
|
|
54
|
+
// Bound the two network steps. A flaky/half-open GitHub connection otherwise
|
|
55
|
+
// leaves autoUpdater's promise pending forever (the 'error' event never fires),
|
|
56
|
+
// wedging the UI in 'checking'/'downloading'. Mirrors the PDF_EXPORT_TIMEOUT_MS
|
|
57
|
+
// race idiom in index.ts.
|
|
58
|
+
const CHECK_TIMEOUT_MS = 30_000
|
|
59
|
+
// The download reports periodic 'download-progress' events; if none arrives for
|
|
60
|
+
// this long while 'downloading', the transfer has stalled (a half-open socket
|
|
61
|
+
// mid-stream doesn't reject, it just goes quiet).
|
|
62
|
+
const DOWNLOAD_STALL_MS = 60_000
|
|
63
|
+
|
|
64
|
+
let win: BrowserWindow | null = null
|
|
65
|
+
let channelFilePath = ''
|
|
66
|
+
let userDataDir = ''
|
|
67
|
+
let lastStatus: UpdateStatus = { state: 'idle' }
|
|
68
|
+
|
|
69
|
+
// In-flight guards. `checkInFlight` prevents overlapping checks AND is what a
|
|
70
|
+
// timeout/error must CLEAR so a subsequent checkForUpdates() isn't blocked by a
|
|
71
|
+
// stale belief that a check is still running (the "guaranteed recovery" rule).
|
|
72
|
+
let checkInFlight = false
|
|
73
|
+
let checkTimer: ReturnType<typeof setTimeout> | null = null
|
|
74
|
+
let downloadStallTimer: ReturnType<typeof setTimeout> | null = null
|
|
75
|
+
|
|
76
|
+
function clearCheckTimer(): void {
|
|
77
|
+
if (checkTimer) { clearTimeout(checkTimer); checkTimer = null }
|
|
78
|
+
}
|
|
79
|
+
function clearDownloadStallTimer(): void {
|
|
80
|
+
if (downloadStallTimer) { clearTimeout(downloadStallTimer); downloadStallTimer = null }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// friendlyError lives in updaterErrors.ts (pure, dependency-free) so it's unit-
|
|
84
|
+
// testable with node:test on every OS. Imported above; re-exported to keep the
|
|
85
|
+
// existing public API (some callers/tests import it from updater.ts).
|
|
86
|
+
export { friendlyError } from './updaterErrors'
|
|
87
|
+
|
|
88
|
+
/** Every error/timeout path funnels through here so state stays consistent:
|
|
89
|
+
* friendly text out, in-flight guard + timers cleared so a retry works. */
|
|
90
|
+
function reportError(rawMessage: string): void {
|
|
91
|
+
recordProblem('updater', rawMessage)
|
|
92
|
+
checkInFlight = false
|
|
93
|
+
clearCheckTimer()
|
|
94
|
+
clearDownloadStallTimer()
|
|
95
|
+
sendStatus({ state: 'error', message: friendlyError(rawMessage) })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendStatus(status: UpdateStatus): void {
|
|
99
|
+
lastStatus = status
|
|
100
|
+
if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) {
|
|
101
|
+
win.webContents.send(channel('update-status'), status)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getLastUpdateStatus(): UpdateStatus {
|
|
106
|
+
return lastStatus
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read the effective update channel. An EXPLICIT persisted choice (the user
|
|
110
|
+
* flipped the radio) always wins; otherwise the default follows the RUNNING
|
|
111
|
+
* build's own version — a prerelease build (…-rc.N) tracks beta, a plain X.Y.Z
|
|
112
|
+
* tracks stable. This is what stops an rc install from fruitlessly checking the
|
|
113
|
+
* (non-existent) stable channel and erroring. Electron-side storage, separate
|
|
114
|
+
* from the app's own settings file — readable before the Python sidecar is up. */
|
|
115
|
+
export function readUpdateChannel(): UpdateChannel {
|
|
116
|
+
try {
|
|
117
|
+
const raw = readFileSync(channelFilePath, 'utf8').trim()
|
|
118
|
+
if (raw === 'beta' || raw === 'stable') return raw
|
|
119
|
+
// Any other/empty content → fall through to the version-derived default.
|
|
120
|
+
} catch {
|
|
121
|
+
// No persisted choice yet → derive from this build's version.
|
|
122
|
+
}
|
|
123
|
+
return defaultChannelForVersion(appVersion())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The running app's version. Wrapped so it's overridable/testable and safe if
|
|
127
|
+
* called before `app` is ready (falls back to a stable-looking version). */
|
|
128
|
+
function appVersion(): string {
|
|
129
|
+
try {
|
|
130
|
+
return app.getVersion()
|
|
131
|
+
} catch {
|
|
132
|
+
return '0.0.0'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function setUpdateChannel(channel: UpdateChannel): void {
|
|
137
|
+
autoUpdater.allowPrerelease = channel === 'beta'
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(userDataDir, { recursive: true })
|
|
140
|
+
writeFileSync(channelFilePath, channel, 'utf8')
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error('[updater] failed to persist update channel:', err)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Wire autoUpdater events + do the initial channel read. Call once from
|
|
147
|
+
* app.whenReady() after the window exists (there's somewhere to show a
|
|
148
|
+
* result). Does NOT check for updates itself — see checkForUpdates(). */
|
|
149
|
+
export function initUpdater(mainWindow: BrowserWindow, userData: string): void {
|
|
150
|
+
win = mainWindow
|
|
151
|
+
userDataDir = userData
|
|
152
|
+
channelFilePath = join(userData, 'update-channel.json')
|
|
153
|
+
|
|
154
|
+
autoUpdater.autoDownload = false
|
|
155
|
+
autoUpdater.autoInstallOnAppQuit = false
|
|
156
|
+
autoUpdater.allowPrerelease = readUpdateChannel() === 'beta'
|
|
157
|
+
|
|
158
|
+
autoUpdater.on('checking-for-update', () => sendStatus({ state: 'checking' }))
|
|
159
|
+
|
|
160
|
+
autoUpdater.on('update-available', (info) => {
|
|
161
|
+
// A definite result arrived → the check is no longer in flight and its
|
|
162
|
+
// timeout must not later fire a spurious "timed out" over this state.
|
|
163
|
+
checkInFlight = false
|
|
164
|
+
clearCheckTimer()
|
|
165
|
+
sendStatus({ state: 'available', version: info.version, releaseNotes: releaseNotesText(info) })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
autoUpdater.on('update-not-available', () => {
|
|
169
|
+
checkInFlight = false
|
|
170
|
+
clearCheckTimer()
|
|
171
|
+
sendStatus({ state: 'not-available' })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
autoUpdater.on('download-progress', (progress) => {
|
|
175
|
+
// Each progress tick proves the transfer is alive — re-arm the stall watch.
|
|
176
|
+
armDownloadStall()
|
|
177
|
+
sendStatus({ state: 'downloading', percent: Math.round(progress.percent) })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
autoUpdater.on('update-downloaded', (info) => {
|
|
181
|
+
clearDownloadStallTimer()
|
|
182
|
+
sendStatus({ state: 'downloaded', version: info.version })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// electron-updater surfaces network + verification failures here. But a
|
|
186
|
+
// "no release found for this channel" error (e.g. a STABLE build/channel when
|
|
187
|
+
// only prereleases exist yet — no stable release published) is NOT a failure —
|
|
188
|
+
// it means "you're up to date on your channel". Report THAT as not-available so
|
|
189
|
+
// the user sees a calm "up to date", not a scary error. Everything else routes
|
|
190
|
+
// through reportError (friendly + recheckable).
|
|
191
|
+
autoUpdater.on('error', (err) => {
|
|
192
|
+
const msg = err?.message ?? String(err)
|
|
193
|
+
if (isNoReleaseForChannel(msg)) {
|
|
194
|
+
checkInFlight = false
|
|
195
|
+
clearCheckTimer()
|
|
196
|
+
sendStatus({ state: 'not-available' })
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
reportError(msg)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Recognise electron-updater's "there is no release matching this channel" error
|
|
204
|
+
* — the feed / release simply doesn't exist for the channel we asked for (a
|
|
205
|
+
* stable build when only prereleases have shipped). That's "up to date", not a
|
|
206
|
+
* fault. Kept conservative: only the shapes electron-updater/GitHubProvider emit
|
|
207
|
+
* for a genuinely-absent release, so a real 404-from-network still surfaces. */
|
|
208
|
+
function isNoReleaseForChannel(msg: string): boolean {
|
|
209
|
+
const s = String(msg || '')
|
|
210
|
+
return /Unable to find latest version on GitHub|No published versions on GitHub|latest-mac\.yml.*not found|Cannot find channel|No version found/i.test(s)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** (Re)arm the download stall watchdog: if no progress event lands within
|
|
214
|
+
* DOWNLOAD_STALL_MS, treat the transfer as failed (recheckable). */
|
|
215
|
+
function armDownloadStall(): void {
|
|
216
|
+
clearDownloadStallTimer()
|
|
217
|
+
downloadStallTimer = setTimeout(() => {
|
|
218
|
+
downloadStallTimer = null
|
|
219
|
+
// Only fires while we still believe we're downloading — a completed/errored
|
|
220
|
+
// download clears the timer, so reaching here means genuine silence.
|
|
221
|
+
if (lastStatus.state === 'downloading') {
|
|
222
|
+
reportError('The download stalled — check your connection and try again.')
|
|
223
|
+
}
|
|
224
|
+
}, DOWNLOAD_STALL_MS)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function releaseNotesText(info: { releaseNotes?: string | Array<{ note?: string | null }> | null }): string | undefined {
|
|
228
|
+
if (typeof info.releaseNotes === 'string') return info.releaseNotes
|
|
229
|
+
if (Array.isArray(info.releaseNotes)) {
|
|
230
|
+
return info.releaseNotes.map((n) => n.note ?? '').filter(Boolean).join('\n\n')
|
|
231
|
+
}
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Force the updater back to a neutral, checkable state. Exposed so any external
|
|
236
|
+
* recovery (e.g. a renderer "Retry" that wants a clean slate) can reset it; the
|
|
237
|
+
* error path already leaves it recheckable, so this is belt-and-braces. */
|
|
238
|
+
export function resetToIdle(): void {
|
|
239
|
+
checkInFlight = false
|
|
240
|
+
clearCheckTimer()
|
|
241
|
+
clearDownloadStallTimer()
|
|
242
|
+
sendStatus({ state: 'idle' })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Manual or startup check. Safe to call repeatedly — an in-flight check is a
|
|
246
|
+
* no-op (guarded), and a prior timed-out/errored check has already cleared the
|
|
247
|
+
* guard so a retry proceeds. Not packaged (dev/e2e) -> no-op, since there's no
|
|
248
|
+
* installed app for electron-updater to reason about updating. */
|
|
249
|
+
export function checkForUpdates(): void {
|
|
250
|
+
if (!app.isPackaged) {
|
|
251
|
+
sendStatus({ state: 'not-available' })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
if (checkInFlight) return
|
|
255
|
+
checkInFlight = true
|
|
256
|
+
|
|
257
|
+
// Bound the check: a half-open GitHub connection never rejects the promise nor
|
|
258
|
+
// fires 'error', so without this the UI wedges in 'checking' forever with the
|
|
259
|
+
// "Check Now" button disabled. On timeout report + clear the guard so a retry
|
|
260
|
+
// works (mirrors index.ts's PDF_EXPORT_TIMEOUT_MS race).
|
|
261
|
+
clearCheckTimer()
|
|
262
|
+
checkTimer = setTimeout(() => {
|
|
263
|
+
checkTimer = null
|
|
264
|
+
if (checkInFlight) {
|
|
265
|
+
reportError('Update check timed out — check your connection and try again.')
|
|
266
|
+
}
|
|
267
|
+
}, CHECK_TIMEOUT_MS)
|
|
268
|
+
|
|
269
|
+
autoUpdater.checkForUpdates().catch((err) => reportError(err?.message ?? String(err)))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function downloadUpdate(): void {
|
|
273
|
+
// A download that never starts producing progress (immediate half-open) would
|
|
274
|
+
// otherwise sit forever — arm the stall watch up front; each progress event
|
|
275
|
+
// re-arms it, downloaded/error clears it.
|
|
276
|
+
armDownloadStall()
|
|
277
|
+
autoUpdater.downloadUpdate().catch((err) => reportError(err?.message ?? String(err)))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Hand off to the downloaded installer.
|
|
282
|
+
*
|
|
283
|
+
* The handoff is a RACE, and losing it is what produced the Windows
|
|
284
|
+
* "SpyDE cannot be closed. Please close it manually and click Retry" dead end:
|
|
285
|
+
* electron-updater spawns the installer FIRST and only then asks the app to
|
|
286
|
+
* quit, so for a second or two both are alive. The installer opens by refusing
|
|
287
|
+
* to touch a directory anything is still running out of — and it counts the
|
|
288
|
+
* Python sidecar and its Dask workers, not just the app window. Electron's own
|
|
289
|
+
* quit is too slow and too conditional to win that race on its own, and the
|
|
290
|
+
* sidecar's tree-kill backstop is on a timer that an exiting process never
|
|
291
|
+
* lives long enough to fire.
|
|
292
|
+
*
|
|
293
|
+
* So: kill the sidecar tree synchronously, hand off, then guarantee this
|
|
294
|
+
* process is gone whether or not the graceful quit completes. There is nothing
|
|
295
|
+
* left to save past this point — the user asked to restart into an installer.
|
|
296
|
+
*/
|
|
297
|
+
const HANDOFF_EXIT_MS = 1500
|
|
298
|
+
|
|
299
|
+
export function quitAndInstall(): void {
|
|
300
|
+
// The one call that used to be able to throw the process down. If the installer
|
|
301
|
+
// handoff fails (missing/locked installer, permissions), surface a friendly
|
|
302
|
+
// recoverable error instead of an uncaught exception crashing the app.
|
|
303
|
+
try {
|
|
304
|
+
clearDownloadStallTimer()
|
|
305
|
+
// Order matters: quitAndInstall() spawns the installer synchronously, so a
|
|
306
|
+
// throw here means the handoff never started and the app has to keep
|
|
307
|
+
// running — killing the sidecar first would leave it alive but useless.
|
|
308
|
+
// Everything after this line happens within microseconds of the spawn,
|
|
309
|
+
// whole seconds before the installer gets as far as looking for us.
|
|
310
|
+
autoUpdater.quitAndInstall()
|
|
311
|
+
stopBackend({ immediate: true })
|
|
312
|
+
setTimeout(() => { try { app.exit(0) } catch { /* already gone */ } }, HANDOFF_EXIT_MS)
|
|
313
|
+
} catch (err) {
|
|
314
|
+
reportError(`Couldn't start the installer — please download manually. (${(err as Error)?.message ?? String(err)})`)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Whether this is a build electron-builder/electron-updater can actually
|
|
319
|
+
* act on (a real installed app, not `electron .` dev / the e2e harness). */
|
|
320
|
+
export function updatesSupported(): boolean {
|
|
321
|
+
return app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml'))
|
|
322
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* updater_errors.test.ts — node:test unit tests for the pure updater error mapper.
|
|
3
|
+
*
|
|
4
|
+
* Runs on every CI push, every OS (`node --test`, native TS type-stripping on
|
|
5
|
+
* Node 24+). Guards the "mac auto-update failed spectacularly" regression: a raw
|
|
6
|
+
* releases.atom XML body must be collapsed to a short line, never passed through.
|
|
7
|
+
*
|
|
8
|
+
* Run: `node --test src/main/updater_errors.test.ts` (from electron/), or via the
|
|
9
|
+
* `test:unit` npm script.
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import {
|
|
14
|
+
friendlyError, truncateMessage, isPrereleaseVersion, defaultChannelForVersion,
|
|
15
|
+
} from './updaterErrors.ts'
|
|
16
|
+
|
|
17
|
+
test('offline / DNS errors → offline message', () => {
|
|
18
|
+
for (const raw of ['net::ERR_INTERNET_DISCONNECTED', 'getaddrinfo ENOTFOUND github.com', 'EAI_AGAIN']) {
|
|
19
|
+
assert.match(friendlyError(raw), /offline/i)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('timeout errors → try again', () => {
|
|
24
|
+
for (const raw of ['ETIMEDOUT', 'net::ERR_TIMED_OUT', 'request timed out']) {
|
|
25
|
+
assert.match(friendlyError(raw), /too long|try again/i)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('connection reset/refused → could not reach', () => {
|
|
30
|
+
for (const raw of ['ECONNRESET', 'net::ERR_CONNECTION_REFUSED', 'socket hang up']) {
|
|
31
|
+
assert.match(friendlyError(raw), /reach the update server/i)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('missing feed / 404 → no update info', () => {
|
|
36
|
+
for (const raw of ['Cannot find latest-mac.yml', 'HttpError: 404', 'status code 404']) {
|
|
37
|
+
assert.match(friendlyError(raw), /no update information/i)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('RAW ATOM/HTML FEED is collapsed, never passed through (the regression)', () => {
|
|
42
|
+
const atom = '<?xml version="1.0" encoding="UTF-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom">'
|
|
43
|
+
+ '<entry><id>tag:github.com,2008:Repository/1/v0.2.0-rc.6</id>'.repeat(300) + '</feed>'
|
|
44
|
+
const out = friendlyError(atom)
|
|
45
|
+
assert.doesNotMatch(out, /<entry|<feed|<\?xml/i, 'markup leaked into the user message')
|
|
46
|
+
assert.ok(out.length < 200, `message not collapsed (len ${out.length})`)
|
|
47
|
+
assert.match(out, /unexpected response|manually/i)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a raw <html> error page is collapsed too', () => {
|
|
51
|
+
const html = '<!DOCTYPE html><html><body>' + '<div>error</div>'.repeat(500) + '</body></html>'
|
|
52
|
+
const out = friendlyError(html)
|
|
53
|
+
assert.doesNotMatch(out, /<html|<div|<!DOCTYPE/i)
|
|
54
|
+
assert.ok(out.length < 200)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('an unrecognised long single-line blob is truncated, not verbatim', () => {
|
|
58
|
+
const blob = 'SomeUpstreamError: ' + 'x'.repeat(5000)
|
|
59
|
+
const out = friendlyError(blob)
|
|
60
|
+
assert.ok(out.length <= 300, `not truncated (len ${out.length})`)
|
|
61
|
+
assert.ok(out.endsWith('…'))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('a short recognisable-enough message passes through (collapsed whitespace)', () => {
|
|
65
|
+
assert.equal(friendlyError('Update failed\n for reason X'), 'Update failed for reason X')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('truncateMessage collapses whitespace and caps length', () => {
|
|
69
|
+
assert.equal(truncateMessage('a\n\n b c'), 'a b c')
|
|
70
|
+
const long = 'y'.repeat(400)
|
|
71
|
+
const t = truncateMessage(long, 100)
|
|
72
|
+
assert.equal(t.length, 100)
|
|
73
|
+
assert.ok(t.endsWith('…'))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('empty / nullish input → empty string, never throws', () => {
|
|
77
|
+
assert.equal(friendlyError(''), '')
|
|
78
|
+
// @ts-expect-error deliberately exercising a nullish raw
|
|
79
|
+
assert.equal(friendlyError(null), '')
|
|
80
|
+
// @ts-expect-error deliberately exercising an undefined raw
|
|
81
|
+
assert.equal(friendlyError(undefined), '')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ── channel detection (rc build → beta, stable build → stable) ────────────────
|
|
85
|
+
|
|
86
|
+
test('prerelease versions are detected', () => {
|
|
87
|
+
for (const v of ['0.2.0-rc.8', '0.2.0-rc.1', '1.0.0-beta.2', '2.3.4-alpha.1',
|
|
88
|
+
'v0.2.0-rc.8', '0.2.0-rc.8+build.5']) {
|
|
89
|
+
assert.equal(isPrereleaseVersion(v), true, `${v} should be prerelease`)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('plain releases are NOT prerelease', () => {
|
|
94
|
+
for (const v of ['0.2.0', '1.0.0', 'v2.3.4', '0.2.0+build.5', '10.20.30']) {
|
|
95
|
+
assert.equal(isPrereleaseVersion(v), false, `${v} should be stable`)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('defaultChannelForVersion: rc build → beta, stable build → stable', () => {
|
|
100
|
+
assert.equal(defaultChannelForVersion('0.2.0-rc.8'), 'beta')
|
|
101
|
+
assert.equal(defaultChannelForVersion('v1.0.0-beta.1'), 'beta')
|
|
102
|
+
assert.equal(defaultChannelForVersion('0.2.0'), 'stable')
|
|
103
|
+
assert.equal(defaultChannelForVersion('1.2.3'), 'stable')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('channel detection tolerates empty/garbage version', () => {
|
|
107
|
+
assert.equal(isPrereleaseVersion(''), false)
|
|
108
|
+
// @ts-expect-error nullish
|
|
109
|
+
assert.equal(isPrereleaseVersion(null), false)
|
|
110
|
+
assert.equal(defaultChannelForVersion(''), 'stable')
|
|
111
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* updater_errors.ts — PURE, dependency-free updater helpers (error mapping +
|
|
3
|
+
* channel detection).
|
|
4
|
+
*
|
|
5
|
+
* Split out of updater.ts (which imports `electron`, so it can't be loaded by a
|
|
6
|
+
* plain node unit test) so these can be unit-tested with `node:test` on every CI
|
|
7
|
+
* push, on every OS. The whole point of friendlyError: an updater error must NEVER
|
|
8
|
+
* reach the UI as a raw blob — the "mac auto-update failed spectacularly"
|
|
9
|
+
* screenshot was ~10 KB of raw releases.atom XML rendered full-screen because the
|
|
10
|
+
* fallback returned the provider's message verbatim. See updater_errors.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** True when a semver string has a prerelease component (-rc.N / -beta.N /
|
|
14
|
+
* -alpha.N / any `-suffix`). Used to pick the DEFAULT update channel from the
|
|
15
|
+
* running build's OWN version: a prerelease build follows the beta channel, a
|
|
16
|
+
* plain X.Y.Z follows stable — so an rc build never fruitlessly looks for a
|
|
17
|
+
* (non-existent) stable release. Tolerant of a leading `v` and build metadata
|
|
18
|
+
* (`+…`). */
|
|
19
|
+
export function isPrereleaseVersion(version: string): boolean {
|
|
20
|
+
const v = String(version ?? '').trim().replace(/^v/i, '')
|
|
21
|
+
// Strip build metadata, then a prerelease is anything after the first '-'.
|
|
22
|
+
const core = v.split('+')[0]
|
|
23
|
+
return core.includes('-')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The channel a build should follow BY DEFAULT (before any explicit user
|
|
27
|
+
* choice): beta if the running build is itself a prerelease, else stable. */
|
|
28
|
+
export function defaultChannelForVersion(version: string): 'stable' | 'beta' {
|
|
29
|
+
return isPrereleaseVersion(version) ? 'beta' : 'stable'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Bound an unrecognised error message so a huge single-line payload can't blow
|
|
33
|
+
* out the error box. Collapse whitespace, cap length, keep it one readable line. */
|
|
34
|
+
export function truncateMessage(s: string, max = 300): string {
|
|
35
|
+
const flat = String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
36
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Map a raw electron-updater / Chromium-net error to something a user can act
|
|
40
|
+
* on. Falls back to a BOUNDED, sanitised version of the raw message when we
|
|
41
|
+
* don't recognise it — never the raw blob verbatim. */
|
|
42
|
+
export function friendlyError(raw: string): string {
|
|
43
|
+
const s = String(raw || '')
|
|
44
|
+
if (/ERR_INTERNET_DISCONNECTED|ENOTFOUND|EAI_AGAIN|ERR_NAME_NOT_RESOLVED|getaddrinfo/i.test(s)) {
|
|
45
|
+
return 'You appear to be offline — check your connection and try again.'
|
|
46
|
+
}
|
|
47
|
+
if (/ETIMEDOUT|ERR_TIMED_OUT|ERR_CONNECTION_TIMED_OUT|timed out/i.test(s)) {
|
|
48
|
+
return 'The update server took too long to respond — please try again.'
|
|
49
|
+
}
|
|
50
|
+
if (/ERR_CONNECTION_(REFUSED|RESET|CLOSED)|ECONNRESET|ECONNREFUSED|socket hang up/i.test(s)) {
|
|
51
|
+
return 'Could not reach the update server — please try again.'
|
|
52
|
+
}
|
|
53
|
+
if (/latest.*\.yml|Cannot find .*\.yml|status code 404|HttpError: 404|ERR_HTTP_RESPONSE_CODE_FAILURE/i.test(s)) {
|
|
54
|
+
return 'No update information available right now — please try again later.'
|
|
55
|
+
}
|
|
56
|
+
// A provider that can't resolve the platform feed can surface the GitHub
|
|
57
|
+
// releases.atom body (or an HTML error page) as the error message. That raw
|
|
58
|
+
// XML/HTML must NEVER reach the UI verbatim — it rendered as a full-screen wall
|
|
59
|
+
// of markup ("mac auto-update failed spectacularly"). Detect markup / an
|
|
60
|
+
// oversized blob and collapse it to a short, actionable line.
|
|
61
|
+
if (/<\?xml|<!DOCTYPE|<feed\b|<entry\b|<html\b|<rss\b/i.test(s)) {
|
|
62
|
+
return 'The update server returned an unexpected response — please try again later or update manually from GitHub.'
|
|
63
|
+
}
|
|
64
|
+
return truncateMessage(s)
|
|
65
|
+
}
|