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.
Files changed (57) hide show
  1. de_shell/__init__.py +25 -0
  2. de_shell/actions/__init__.py +0 -0
  3. de_shell/actions/context.py +62 -0
  4. de_shell/actions/figure_registry.py +53 -0
  5. de_shell/actions/lifecycle.py +295 -0
  6. de_shell/actions/registry.py +141 -0
  7. de_shell/actions/wizard.py +115 -0
  8. de_shell/app.py +170 -0
  9. de_shell/compute.py +103 -0
  10. de_shell/debug_flags.py +69 -0
  11. de_shell/ipc.py +236 -0
  12. de_shell/js/__init__.py +38 -0
  13. de_shell/js/__main__.py +4 -0
  14. de_shell/js/main/backendProcess.test.ts +70 -0
  15. de_shell/js/main/backendProcess.ts +330 -0
  16. de_shell/js/main/config.ts +53 -0
  17. de_shell/js/main/dialogs.ts +62 -0
  18. de_shell/js/main/envProgress.ts +126 -0
  19. de_shell/js/main/errorReport.ts +261 -0
  20. de_shell/js/main/index.ts +57 -0
  21. de_shell/js/main/problemLog.ts +53 -0
  22. de_shell/js/main/pythonEnv.test.ts +125 -0
  23. de_shell/js/main/pythonEnv.ts +442 -0
  24. de_shell/js/main/sentryEnvelope.test.ts +94 -0
  25. de_shell/js/main/sentryEnvelope.ts +100 -0
  26. de_shell/js/main/updater.ts +322 -0
  27. de_shell/js/main/updaterErrors.test.ts +111 -0
  28. de_shell/js/main/updaterErrors.ts +65 -0
  29. de_shell/js/main/window.ts +141 -0
  30. de_shell/js/package.json +5 -0
  31. de_shell/js/preload/index.ts +130 -0
  32. de_shell/js/renderer/FigureFrame.tsx +88 -0
  33. de_shell/js/renderer/figureBridge.react.ts +58 -0
  34. de_shell/js/renderer/figureBridge.test.ts +184 -0
  35. de_shell/js/renderer/figureBridge.ts +169 -0
  36. de_shell/js/renderer/index.ts +34 -0
  37. de_shell/js/renderer/protocol.ts +164 -0
  38. de_shell/js/renderer/shellState.test.ts +193 -0
  39. de_shell/js/renderer/shellState.ts +310 -0
  40. de_shell/js/testing/harness.cjs +244 -0
  41. de_shell/js/testing/harness.test.cjs +73 -0
  42. de_shell/log_stream.py +185 -0
  43. de_shell/plotting/__init__.py +0 -0
  44. de_shell/plotting/colormaps.py +27 -0
  45. de_shell/plotting/figure.py +601 -0
  46. de_shell/plotting/selectors/__init__.py +0 -0
  47. de_shell/plotting/selectors/utils.py +29 -0
  48. de_shell/plotting/stream.py +172 -0
  49. de_shell/process_guard.py +190 -0
  50. de_shell/session.py +211 -0
  51. de_shell/testing/__init__.py +0 -0
  52. de_shell/timing.py +28 -0
  53. de_shell-0.2.0.dist-info/METADATA +196 -0
  54. de_shell-0.2.0.dist-info/RECORD +57 -0
  55. de_shell-0.2.0.dist-info/WHEEL +5 -0
  56. de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
  57. de_shell-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * config.ts — the per-app identity the shell needs but must not assume.
3
+ *
4
+ * Everything in this package used to say "spyde" out loud: the IPC channel
5
+ * prefix, the settings directory, the Python module to spawn, the wheel name,
6
+ * the setuptools_scm env-var suffix. None of that is shell knowledge — it is the
7
+ * one thing that differs between SpyDE, de-groundcrew and de-autopilot.
8
+ *
9
+ * An app calls `configureShell()` once, at the top of its main process, before
10
+ * anything else in this package runs.
11
+ */
12
+
13
+ export interface ShellConfig {
14
+ /** Lowercase, filesystem- and URL-safe app id, e.g. 'spyde', 'groundcrew'.
15
+ * Drives the IPC channel prefix (`<appId>:action`), the settings directory
16
+ * (`~/.<appId>/settings.json`), the packaged-app env var
17
+ * (`<APPID>_PACKAGED`), and the custom figure scheme (`<appId>-fig://`). */
18
+ appId: string
19
+ /** Human-readable name for user-facing strings ("… exited with code 1"). */
20
+ appName: string
21
+ /** The Python module the backend runs as: `python -m <pythonModule>`. */
22
+ pythonModule: string
23
+ /** Distribution name of the Python package, when it differs from the module
24
+ * (e.g. module `spyde` / dist `spyde`). Used for the pre-built wheel prefix
25
+ * and the SETUPTOOLS_SCM_PRETEND_VERSION_FOR_<NAME> env var. */
26
+ pythonDist?: string
27
+ }
28
+
29
+ let _config: ShellConfig | null = null
30
+
31
+ export function configureShell(config: ShellConfig): void {
32
+ _config = { pythonDist: config.pythonModule, ...config }
33
+ }
34
+
35
+ export function shellConfig(): ShellConfig {
36
+ if (_config === null) {
37
+ throw new Error(
38
+ 'configureShell() must be called before any @de/shell-main API. ' +
39
+ 'Call it at the top of your main process entry point.',
40
+ )
41
+ }
42
+ return _config
43
+ }
44
+
45
+ /** `<appId>:<name>` — the IPC channel namespace. */
46
+ export function channel(name: string): string {
47
+ return `${shellConfig().appId}:${name}`
48
+ }
49
+
50
+ /** `<APPID>` — the env-var namespace (uppercased, non-alphanumerics to `_`). */
51
+ export function envPrefix(): string {
52
+ return shellConfig().appId.toUpperCase().replace(/[^A-Z0-9]/g, '_')
53
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dialogs.ts — native open/save dialogs, on the shell's channel.
3
+ *
4
+ * A sandboxed renderer cannot open a file dialog and has no filesystem paths,
5
+ * so this is the only way an app can ask the user for a file. Registered once
6
+ * per app by `registerShellDialogs`, on `<appId>:open-file` /
7
+ * `<appId>:open-directory` / `<appId>:save-file`.
8
+ *
9
+ * All resolve to a PATH or null. Null means cancelled, which is a normal
10
+ * outcome and not an error — the caller should do nothing rather than report a
11
+ * failure.
12
+ */
13
+ import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
14
+
15
+ import { channel } from './config'
16
+
17
+ export interface FileFilter { name: string; extensions: string[] }
18
+
19
+ /** Protocols a renderer may hand to the OS browser. Deliberately no `file:` —
20
+ * the web allowlist must never be talked into opening a local path. */
21
+ const EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:'])
22
+
23
+ /** Wire the open/save dialog handlers and the external-link opener. Call
24
+ * once, after `configureShell`. */
25
+ export function registerShellDialogs(): void {
26
+ // Unprefixed on purpose: the preload sends on 'open-external' for every app.
27
+ ipcMain.on('open-external', (_e, url: string) => {
28
+ try {
29
+ if (EXTERNAL_PROTOCOLS.has(new URL(String(url)).protocol)) void shell.openExternal(url)
30
+ } catch { /* not a URL — ignore */ }
31
+ })
32
+
33
+ ipcMain.handle(channel('open-file'), async (event, filters?: FileFilter[]) => {
34
+ // Parent the dialog to the window that asked, so it is modal to that
35
+ // window rather than floating free of the app.
36
+ const win = BrowserWindow.fromWebContents(event.sender)
37
+ const opts = { properties: ['openFile' as const], filters }
38
+ const result = win
39
+ ? await dialog.showOpenDialog(win, opts)
40
+ : await dialog.showOpenDialog(opts)
41
+ return result.canceled || !result.filePaths.length ? null : result.filePaths[0]
42
+ })
43
+
44
+ ipcMain.handle(channel('open-directory'), async (event) => {
45
+ const win = BrowserWindow.fromWebContents(event.sender)
46
+ const opts = { properties: ['openDirectory' as const] }
47
+ const result = win
48
+ ? await dialog.showOpenDialog(win, opts)
49
+ : await dialog.showOpenDialog(opts)
50
+ return result.canceled || !result.filePaths.length ? null : result.filePaths[0]
51
+ })
52
+
53
+ ipcMain.handle(channel('save-file'),
54
+ async (event, filters?: FileFilter[], defaultPath?: string) => {
55
+ const win = BrowserWindow.fromWebContents(event.sender)
56
+ const opts = { filters, defaultPath }
57
+ const result = win
58
+ ? await dialog.showSaveDialog(win, opts)
59
+ : await dialog.showSaveDialog(opts)
60
+ return result.canceled || !result.filePath ? null : result.filePath
61
+ })
62
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * envProgress.ts — parse `uv` first-run output into structured setup progress.
3
+ *
4
+ * `uv sync` / `uv pip install` stream human-readable status to stderr. On first
5
+ * packaged launch that stream is the ONLY signal that anything is happening
6
+ * (hundreds of MB of wheels, incl. PyTorch, are being fetched). The renderer
7
+ * turns these events into a floating "Setting up…" overlay with a live phase,
8
+ * a friendly current-step line, an optional download %, and a raw log tail —
9
+ * so first launch never looks frozen.
10
+ *
11
+ * This module is pure string→event, no I/O, so it is unit-testable
12
+ * (test_env_progress.ts) without spawning uv.
13
+ */
14
+
15
+ export type EnvPhase =
16
+ | 'resolving' // building the dependency graph
17
+ | 'downloading' // fetching wheels/sdists
18
+ | 'installing' // unpacking into the venv
19
+ | 'building' // building the editable spyde package (setuptools_scm)
20
+ | 'torch' // the per-machine torch step (the big one)
21
+ | 'working' // fallback: uv said something we don't specifically classify
22
+
23
+ export interface EnvProgressEvent {
24
+ phase: EnvPhase
25
+ /** Short human sentence for the overlay's headline step, e.g. "Downloading PyTorch". */
26
+ step: string
27
+ /** 0–100 when uv reports a download percentage for a large artifact; else null. */
28
+ percent: number | null
29
+ }
30
+
31
+ const KNOWN_BIG = /\btorch\b/i
32
+
33
+ /**
34
+ * Classify ONE raw line of uv output. Returns null for noise (blank lines,
35
+ * lines we can't meaningfully turn into a step) — the caller keeps the last
36
+ * non-null event as the current state and always appends the raw line to the
37
+ * log tail regardless.
38
+ */
39
+ export function parseUvLine(raw: string): EnvProgressEvent | null {
40
+ const line = raw.replace(/\r/g, '').trim()
41
+ if (!line) return null
42
+
43
+ // Our own [env-setup] breadcrumbs (pythonEnv.ts) — use them verbatim, they're
44
+ // already user-facing phase announcements.
45
+ if (line.startsWith('[env-setup]')) {
46
+ const msg = line.slice('[env-setup]'.length).trim()
47
+ // Order matters: the "two-step install … torch deferred" breadcrumb mentions
48
+ // torch but announces the RESOLVE phase, so match the sync/plan keywords
49
+ // first and only fall to the torch phase for an actual torch-install line.
50
+ if (/two-step|full locked|uv sync|deferred/i.test(msg)) {
51
+ return { phase: 'resolving', step: 'Preparing the analysis environment', percent: null }
52
+ }
53
+ if (/torch/i.test(msg)) {
54
+ return { phase: 'torch', step: 'Installing PyTorch for your GPU', percent: null }
55
+ }
56
+ return { phase: 'working', step: capitalize(msg), percent: null }
57
+ }
58
+
59
+ // A download progress line. uv renders these like:
60
+ // "torch ======> 123.4 MiB/825.0 MiB" (bar form), or
61
+ // "Downloading torch (825.0 MiB)" (start), or a trailing
62
+ // percentage. Pull a percent if the two sizes are present.
63
+ const dl = line.match(/^([\w.\-]+)\s+.*?([\d.]+)\s*([KMG]i?B)\s*\/\s*([\d.]+)\s*([KMG]i?B)/i)
64
+ if (dl) {
65
+ const pkg = dl[1]
66
+ const cur = toBytes(dl[2], dl[3])
67
+ const tot = toBytes(dl[4], dl[5])
68
+ const percent = tot > 0 ? Math.min(100, Math.round((cur / tot) * 100)) : null
69
+ const big = KNOWN_BIG.test(pkg)
70
+ return {
71
+ phase: big ? 'torch' : 'downloading',
72
+ step: big ? 'Downloading PyTorch' : `Downloading ${pkg}`,
73
+ percent,
74
+ }
75
+ }
76
+
77
+ // uv's phase verbs (it prints "Resolved N packages", "Downloaded …",
78
+ // "Prepared …", "Installed N packages", "Building …").
79
+ if (/^Resolv(ing|ed)\b/i.test(line)) {
80
+ return { phase: 'resolving', step: 'Resolving dependencies', percent: null }
81
+ }
82
+ if (/^Download(ing|ed)\b/i.test(line)) {
83
+ const m = line.match(/^Download(?:ing|ed)\s+([\w.\-]+)/i)
84
+ const pkg = m?.[1]
85
+ const big = pkg ? KNOWN_BIG.test(pkg) : false
86
+ return {
87
+ phase: big ? 'torch' : 'downloading',
88
+ step: big ? 'Downloading PyTorch' : (pkg ? `Downloading ${pkg}` : 'Downloading packages'),
89
+ percent: null,
90
+ }
91
+ }
92
+ if (/^Prepar(ing|ed)\b/i.test(line)) {
93
+ return { phase: 'installing', step: 'Preparing packages', percent: null }
94
+ }
95
+ if (/^Install(ing|ed)\b/i.test(line)) {
96
+ const m = line.match(/(\d+)\s+packages?/i)
97
+ return {
98
+ phase: 'installing',
99
+ step: m ? `Installing ${m[1]} packages` : 'Installing packages',
100
+ percent: null,
101
+ }
102
+ }
103
+ if (/^Building\b/i.test(line) || /setuptools[_-]scm|Building wheel|Building editable/i.test(line)) {
104
+ return { phase: 'building', step: 'Building the SpyDE package', percent: null }
105
+ }
106
+ if (/^Audit(ing|ed)\b/i.test(line)) {
107
+ return { phase: 'installing', step: 'Finalizing the environment', percent: null }
108
+ }
109
+
110
+ // Recognized-but-unclassified: keep the phase we can't infer as generic
111
+ // "working" so the overlay still updates its step text and never looks stuck.
112
+ return { phase: 'working', step: 'Working', percent: null }
113
+ }
114
+
115
+ function toBytes(n: string, unit: string): number {
116
+ const v = parseFloat(n)
117
+ const u = unit.toUpperCase()
118
+ if (u.startsWith('G')) return v * 1024 ** 3
119
+ if (u.startsWith('M')) return v * 1024 ** 2
120
+ if (u.startsWith('K')) return v * 1024
121
+ return v
122
+ }
123
+
124
+ function capitalize(s: string): string {
125
+ return s ? s[0].toUpperCase() + s.slice(1) : s
126
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * errorReport.ts — "Report a Problem": what went wrong, on what machine, sent
3
+ * to the maintainers.
4
+ *
5
+ * The install base is small and the failures that matter are environmental —
6
+ * a GPU driver, a Windows install directory, a Python wheel that resolved
7
+ * differently on one machine. So a report is worth much more than a stack
8
+ * trace: it carries the OS, the app and runtime versions, the state of the
9
+ * managed Python environment, the last thing the updater said, and the tail of
10
+ * the backend's own output.
11
+ *
12
+ * NOTHING IS SENT WITHOUT A CLICK. Problems are recorded into a bounded
13
+ * in-memory ring as they happen so a report written minutes later still knows
14
+ * what failed, but the ring never leaves the machine on its own. There is no
15
+ * background transport, no crash handler phoning home, and no first-run consent
16
+ * screen to write, because there is nothing to consent to until the user opens
17
+ * the dialog and presses Send.
18
+ *
19
+ * Where it goes: a Sentry project, when a DSN is configured for the build (see
20
+ * `initErrorReporting`). With no DSN — or when the machine is offline, which
21
+ * for a microscope-room PC is the normal case — the same report is written to
22
+ * `<userData>/reports/` and the path handed back, so the user can attach it to
23
+ * an email. That fallback is not a degraded mode; it is the offline path.
24
+ */
25
+ import { app, net } from 'electron'
26
+ import { randomBytes } from 'crypto'
27
+ import { mkdirSync, writeFileSync } from 'fs'
28
+ import { join } from 'path'
29
+ import { arch, cpus, freemem, platform, release, totalmem, type as osType } from 'os'
30
+ import { shellConfig } from './config'
31
+ import { recentBackendOutput } from './backendProcess'
32
+ import { recordedProblems, type Problem } from './problemLog'
33
+ import { getLastUpdateStatus, readUpdateChannel, updatesSupported } from './updater'
34
+ import {
35
+ buildEnvelope, formatEventId, parseSentryDsn, sentryAuthHeader, type SentryTarget,
36
+ } from './sentryEnvelope'
37
+
38
+ /** Everything a report carries about the machine and the run. */
39
+ export interface Diagnostics {
40
+ app: { name: string; version: string; packaged: boolean; channel: string; updatesSupported: boolean }
41
+ os: { platform: string; type: string; release: string; arch: string; totalMemoryGb: number; freeMemoryGb: number; cpu: string; locale: string }
42
+ runtime: { electron: string; chrome: string; node: string; v8: string }
43
+ update: { lastStatus: unknown }
44
+ problems: Problem[]
45
+ backendOutput: string[]
46
+ /** Whatever the host app added — SpyDE contributes its GPU and Python-env triage. */
47
+ host: Record<string, unknown>
48
+ }
49
+
50
+ /** What `submitReport` managed to do. */
51
+ export interface ReportResult {
52
+ /** True when the report reached the reporting service. */
53
+ sent: boolean
54
+ /** Sentry's id for the event, when it was sent — worth quoting in an email. */
55
+ eventId?: string
56
+ /** Where the report was written locally. Always set, sent or not. */
57
+ bundlePath?: string
58
+ /** Why sending failed, when it did. */
59
+ error?: string
60
+ }
61
+
62
+ /** A report is small; a slow network should not hang the dialog. */
63
+ const SEND_TIMEOUT_MS = 15_000
64
+
65
+ let target: SentryTarget | null = null
66
+ let hostDiagnostics: () => Promise<Record<string, unknown>> = async () => ({})
67
+
68
+ /**
69
+ * Configure reporting. Call once at startup.
70
+ *
71
+ * `dsn` is the Sentry DSN for the build. It is a public key — it grants writing
72
+ * events and nothing else — so shipping it in the app is how Sentry is designed
73
+ * to be used; still, keep it in CI's environment rather than in the repo so it
74
+ * can be rotated without a code change. Absent or malformed, reports still work
75
+ * and go to disk only.
76
+ *
77
+ * `collectHostDiagnostics` lets the app add what only it can answer (SpyDE
78
+ * passes its GPU probe and managed-Python-environment triage).
79
+ */
80
+ export function initErrorReporting(options: {
81
+ dsn?: string | null
82
+ collectHostDiagnostics?: () => Promise<Record<string, unknown>>
83
+ }): void {
84
+ target = parseSentryDsn(options.dsn)
85
+ if (options.collectHostDiagnostics) hostDiagnostics = options.collectHostDiagnostics
86
+ }
87
+
88
+ /** Whether reports can reach the maintainers, or will only be written to disk. */
89
+ export function reportingConfigured(): boolean {
90
+ return target !== null
91
+ }
92
+
93
+ const GIGABYTE = 1024 ** 3
94
+
95
+ /** Gather everything a report carries. Safe to call at any time. */
96
+ export async function collectDiagnostics(): Promise<Diagnostics> {
97
+ const cfg = shellConfig()
98
+ let host: Record<string, unknown> = {}
99
+ try {
100
+ host = await hostDiagnostics()
101
+ } catch (err) {
102
+ host = { error: String(err) }
103
+ }
104
+ return {
105
+ app: {
106
+ name: cfg.appName,
107
+ version: app.getVersion(),
108
+ packaged: app.isPackaged,
109
+ channel: safely(() => readUpdateChannel(), 'unknown'),
110
+ updatesSupported: safely(() => updatesSupported(), false),
111
+ },
112
+ os: {
113
+ platform: platform(),
114
+ type: osType(),
115
+ release: release(),
116
+ arch: arch(),
117
+ totalMemoryGb: round1(totalmem() / GIGABYTE),
118
+ freeMemoryGb: round1(freemem() / GIGABYTE),
119
+ cpu: cpus()[0]?.model ?? 'unknown',
120
+ locale: safely(() => app.getLocale(), 'unknown'),
121
+ },
122
+ runtime: {
123
+ electron: process.versions.electron ?? '',
124
+ chrome: process.versions.chrome ?? '',
125
+ node: process.versions.node ?? '',
126
+ v8: process.versions.v8 ?? '',
127
+ },
128
+ update: { lastStatus: safely(() => getLastUpdateStatus(), null) },
129
+ problems: recordedProblems(),
130
+ backendOutput: recentBackendOutput(),
131
+ host,
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Write the report to disk and, when a DSN is configured, send it.
137
+ *
138
+ * The local copy is written FIRST and unconditionally: if the send fails the
139
+ * user still has something to attach to an email, and if it succeeds they have
140
+ * a record of what they sent. `contact` is whatever the reporter chose to type
141
+ * — it is optional, and an empty one is simply omitted rather than sent blank.
142
+ */
143
+ export async function submitReport(input: {
144
+ message: string
145
+ contact?: string
146
+ }): Promise<ReportResult> {
147
+ const diagnostics = await collectDiagnostics()
148
+ const eventId = formatEventId(randomBytes(16))
149
+ const bundlePath = writeBundle(eventId, input, diagnostics)
150
+
151
+ if (!target) {
152
+ return {
153
+ sent: false,
154
+ bundlePath,
155
+ error: 'No reporting service is configured for this build, so the report was saved locally.',
156
+ }
157
+ }
158
+ try {
159
+ await sendToSentry(target, eventId, input, diagnostics)
160
+ return { sent: true, eventId, bundlePath }
161
+ } catch (err) {
162
+ return { sent: false, eventId, bundlePath, error: (err as Error)?.message ?? String(err) }
163
+ }
164
+ }
165
+
166
+ /** The report as a file under `<userData>/reports/`. Returns '' if unwritable. */
167
+ function writeBundle(
168
+ eventId: string,
169
+ input: { message: string; contact?: string },
170
+ diagnostics: Diagnostics,
171
+ ): string {
172
+ try {
173
+ const dir = join(app.getPath('userData'), 'reports')
174
+ mkdirSync(dir, { recursive: true })
175
+ // Sortable, filename-safe, and second-resolution is plenty for one report.
176
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
177
+ const file = join(dir, `${shellConfig().appId}-report-${stamp}.json`)
178
+ writeFileSync(file, JSON.stringify({ eventId, ...input, diagnostics }, null, 2), 'utf8')
179
+ return file
180
+ } catch {
181
+ return ''
182
+ }
183
+ }
184
+
185
+ /** POST one envelope. Rejects on a network failure, a timeout, or a 4xx/5xx. */
186
+ async function sendToSentry(
187
+ to: SentryTarget,
188
+ eventId: string,
189
+ input: { message: string; contact?: string },
190
+ diagnostics: Diagnostics,
191
+ ): Promise<void> {
192
+ const cfg = shellConfig()
193
+ const event: Record<string, unknown> = {
194
+ event_id: eventId,
195
+ timestamp: new Date().toISOString(),
196
+ platform: 'javascript',
197
+ level: 'error',
198
+ logger: 'user-report',
199
+ release: `${cfg.appId}@${diagnostics.app.version}`,
200
+ environment: diagnostics.app.packaged ? 'production' : 'development',
201
+ message: { formatted: firstLine(input.message) },
202
+ tags: {
203
+ os: diagnostics.os.platform,
204
+ os_release: diagnostics.os.release,
205
+ arch: diagnostics.os.arch,
206
+ channel: diagnostics.app.channel,
207
+ packaged: String(diagnostics.app.packaged),
208
+ },
209
+ contexts: {
210
+ os: { name: diagnostics.os.type, version: diagnostics.os.release },
211
+ device: { arch: diagnostics.os.arch, memory_size: diagnostics.os.totalMemoryGb, model: diagnostics.os.cpu },
212
+ runtime: { name: 'electron', version: diagnostics.runtime.electron },
213
+ },
214
+ extra: {
215
+ report: input.message,
216
+ diagnostics,
217
+ },
218
+ }
219
+ if (input.contact?.trim()) event.user = { email: input.contact.trim() }
220
+
221
+ const body = buildEnvelope(to, event, new Date().toISOString())
222
+ const controller = new AbortController()
223
+ const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS)
224
+ try {
225
+ // Electron's `net` rather than global fetch: it uses Chromium's stack, so a
226
+ // machine configured with a system proxy or a corporate root certificate —
227
+ // which describes a lot of instrument PCs — works without extra setup.
228
+ const response = await net.fetch(to.endpoint, {
229
+ method: 'POST',
230
+ headers: {
231
+ 'Content-Type': 'application/x-sentry-envelope',
232
+ 'X-Sentry-Auth': sentryAuthHeader(to, `${cfg.appId}-shell/1.0`),
233
+ },
234
+ body,
235
+ signal: controller.signal,
236
+ })
237
+ if (!response.ok) {
238
+ throw new Error(`The reporting service refused the report (HTTP ${response.status}).`)
239
+ }
240
+ } catch (err) {
241
+ if ((err as Error)?.name === 'AbortError') {
242
+ throw new Error('Sending timed out. The report was saved on this machine instead.')
243
+ }
244
+ throw err
245
+ } finally {
246
+ clearTimeout(timer)
247
+ }
248
+ }
249
+
250
+ function firstLine(text: string): string {
251
+ const line = text.trim().split('\n')[0]
252
+ return line.length > 200 ? `${line.slice(0, 200)}...` : (line || 'Problem report')
253
+ }
254
+
255
+ function round1(value: number): number {
256
+ return Math.round(value * 10) / 10
257
+ }
258
+
259
+ function safely<T>(read: () => T, fallback: T): T {
260
+ try { return read() } catch { return fallback }
261
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @de/shell-main — the Electron main-process kernel shared by SpyDE,
3
+ * de-groundcrew and de-autopilot.
4
+ *
5
+ * What lives here is everything that answers "how do I run a desktop app with a
6
+ * Python brain?" — spawning and supervising the sidecar, bootstrapping its uv
7
+ * environment on first launch, auto-update, and the identity plumbing that ties
8
+ * those to a particular app.
9
+ *
10
+ * Call `configureShell()` FIRST, before any other export in this package: the
11
+ * IPC channel prefix, settings directory, Python module name and packaged-app
12
+ * env var all read from it, and they throw rather than guess.
13
+ */
14
+ export { configureShell, shellConfig, channel, envPrefix } from './config'
15
+ export type { ShellConfig } from './config'
16
+
17
+ export {
18
+ startBackend, stopBackend, sendAction, sendFigureEvent, sendResize,
19
+ recentBackendOutput,
20
+ } from './backendProcess'
21
+
22
+ export { recordProblem, recordedProblems } from './problemLog'
23
+ export type { Problem } from './problemLog'
24
+
25
+ export {
26
+ initErrorReporting, reportingConfigured, collectDiagnostics, submitReport,
27
+ } from './errorReport'
28
+ export type { Diagnostics, ReportResult } from './errorReport'
29
+
30
+ export { parseSentryDsn } from './sentryEnvelope'
31
+
32
+ export { registerShellDialogs } from './dialogs'
33
+ export type { FileFilter } from './dialogs'
34
+
35
+ export { createShellWindow } from './window'
36
+ export type { ShellWindow, ShellWindowOptions } from './window'
37
+ export type { BackendHandlers } from './backendProcess'
38
+
39
+ export {
40
+ resolvePythonEnv, managedEnvPaths, venvPython, readLockedTorchVersion,
41
+ installTorchPerMachine,
42
+ } from './pythonEnv'
43
+ export type { ResolvedPython, EnsureOptions } from './pythonEnv'
44
+
45
+ export { parseUvLine } from './envProgress'
46
+ export type { EnvPhase, EnvProgressEvent } from './envProgress'
47
+
48
+ export {
49
+ initUpdater, checkForUpdates, downloadUpdate, quitAndInstall, resetToIdle,
50
+ readUpdateChannel, setUpdateChannel, getLastUpdateStatus, updatesSupported,
51
+ friendlyError,
52
+ } from './updater'
53
+ export type { UpdateChannel, UpdateStatus } from './updater'
54
+
55
+ export {
56
+ isPrereleaseVersion, defaultChannelForVersion, truncateMessage,
57
+ } from './updaterErrors'
@@ -0,0 +1,53 @@
1
+ /**
2
+ * problemLog.ts — a bounded in-memory record of things that went wrong.
3
+ *
4
+ * Its own module, importing nothing, so that anything can write to it: the
5
+ * updater records its own failures here, and errorReport.ts reads the ring
6
+ * while also reading the updater's state. Merging the two would make that a
7
+ * cycle.
8
+ *
9
+ * The ring is memory only and never leaves the machine on its own — it exists
10
+ * so that a report the user writes minutes after a failure still knows what
11
+ * failed. See errorReport.ts for what happens to it.
12
+ */
13
+
14
+ /** One thing that went wrong, as recorded when it happened. */
15
+ export interface Problem {
16
+ /** When, as an ISO timestamp. */
17
+ at: string
18
+ /** Coarse source, e.g. 'main', 'backend', 'updater'. */
19
+ kind: string
20
+ /** The message/stack, truncated. */
21
+ detail: string
22
+ }
23
+
24
+ /** Keep it small: it is read in full into every report. */
25
+ const MAX_PROBLEMS = 25
26
+ const MAX_DETAIL_CHARS = 4000
27
+
28
+ const problems: Problem[] = []
29
+
30
+ /**
31
+ * Note that something went wrong.
32
+ *
33
+ * Deliberately cheap and total: it never throws and never sends, so it is safe
34
+ * to call from a crash handler, a stderr pump, or an updater error path.
35
+ */
36
+ export function recordProblem(kind: string, detail: unknown): void {
37
+ try {
38
+ const text = detail instanceof Error
39
+ ? (detail.stack ?? detail.message)
40
+ : String(detail)
41
+ problems.push({
42
+ at: new Date().toISOString(),
43
+ kind,
44
+ detail: text.slice(0, MAX_DETAIL_CHARS),
45
+ })
46
+ if (problems.length > MAX_PROBLEMS) problems.splice(0, problems.length - MAX_PROBLEMS)
47
+ } catch { /* a reporting failure must never become the failure */ }
48
+ }
49
+
50
+ /** The problems recorded so far, oldest first. */
51
+ export function recordedProblems(): Problem[] {
52
+ return [...problems]
53
+ }