From 102abbbc9caa6ef82f9d0a8933702a13e507403f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 12 Oct 2026 08:19:29 -0500 Subject: [PATCH 1/6] pythonEnv: extras, a GPU-probed extra, and an offline wheelhouse install ShellConfig gains pythonExtras (always) and gpuExtras (when nvidia-smi finds a device; EnsureOptions.gpu and _GPU override the probe). The lock stamp carries the extras, but stays the bare hash when there are none so existing installs do not re-sync. A wheelhouse (wheelhouse.json + every locked wheel + a python-build-standalone mirror) installs with no network: uv python install from the file:// mirror, uv venv, uv export --frozen, then uv pip install --offline --no-index --require-hashes. A frozen uv sync --offline --find-links cannot do it: uv only looks for registry-locked wheels in its cache. And uv venv refuses the mirror under UV_OFFLINE, hence the separate python install step. A probed GPU whose extras the wheelhouse lacks degrades to CPU and stamps +gpu=unavailable, so an air-gapped GPU machine does not retry online on every launch. Managed interpreters now live under /python. Refs directelectron/de_ground_crew#410 --- de_shell/js/main/config.ts | 10 + de_shell/js/main/pythonEnv.test.ts | 148 ++++++++++- de_shell/js/main/pythonEnv.ts | 381 +++++++++++++++++++++++++++-- 3 files changed, 519 insertions(+), 20 deletions(-) diff --git a/de_shell/js/main/config.ts b/de_shell/js/main/config.ts index 4d47af7..d25cf96 100644 --- a/de_shell/js/main/config.ts +++ b/de_shell/js/main/config.ts @@ -24,6 +24,16 @@ export interface ShellConfig { * (e.g. module `spyde` / dist `spyde`). Used for the pre-built wheel prefix * and the SETUPTOOLS_SCM_PRETEND_VERSION_FOR_ env var. */ pythonDist?: string + /** Optional-dependency groups (`[project.optional-dependencies]`) the packaged + * first-run install ALWAYS adds, each as `uv sync --extra `. Changing + * this list changes the lock stamp, so existing installs re-sync once. */ + pythonExtras?: string[] + /** Extras added ONLY when an NVIDIA GPU is detected (`nvidia-smi -L`), e.g. + * Ground Crew's CuPy stack behind `gpu`. The probe is skipped entirely when + * this is empty. Overridable per run with `_GPU=0|1` or + * `EnsureOptions.gpu`. A machine that gains a GPU later re-syncs, because the + * effective extras are part of the lock stamp. */ + gpuExtras?: string[] } let _config: ShellConfig | null = null diff --git a/de_shell/js/main/pythonEnv.test.ts b/de_shell/js/main/pythonEnv.test.ts index bff50f4..3d7f7d8 100644 --- a/de_shell/js/main/pythonEnv.test.ts +++ b/de_shell/js/main/pythonEnv.test.ts @@ -16,7 +16,11 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { configureShell } from './config.ts' -import { findUv, resolvePythonEnv } from './pythonEnv.ts' +import { + findUv, resolvePythonEnv, hasNvidiaGpu, readWheelhouse, findWheelhouse, + wheelhouseCandidates, lockStamp, buildSyncArgs, offlinePlan, effectiveExtras, + gpuEnvOverride, platformMatches, fileUrl, checkWheelhouseExtras, venvPython, +} from './pythonEnv.ts' configureShell({ appId: 'testapp', @@ -135,3 +139,145 @@ test('dev-mode resolvePythonEnv leaves a bare uv when none is found (the spawn t } } }) + +// ── extras, GPU probe, stamp ───────────────────────────────────────────────── + +test('lockStamp is the bare hash with no extras (existing installs do not re-sync)', () => { + assert.equal(lockStamp('abc', []), 'abc') +}) + +test('lockStamp appends sorted extras, order-insensitively', () => { + assert.equal(lockStamp('abc', ['gpu', 'cuda']), 'abc+extras=cuda,gpu') + assert.equal(lockStamp('abc', ['cuda', 'gpu']), lockStamp('abc', ['gpu', 'cuda'])) +}) + +test('effectiveExtras adds gpuExtras only with a GPU, de-duplicated', () => { + assert.deepEqual(effectiveExtras(['a'], ['gpu'], false), ['a']) + assert.deepEqual(effectiveExtras(['a'], ['gpu'], true), ['a', 'gpu']) + assert.deepEqual(effectiveExtras(['gpu'], ['gpu'], true), ['gpu']) + assert.deepEqual(effectiveExtras(undefined, undefined, true), []) +}) + +test('buildSyncArgs passes --extra for each extra, torch skip last', () => { + assert.deepEqual( + buildSyncArgs(['--no-install-project'], ['gpu', 'x'], ['torch']), + ['sync', '--frozen', '--no-dev', '--no-install-project', + '--extra', 'gpu', '--extra', 'x', '--no-install-package', 'torch']) + assert.deepEqual(buildSyncArgs([], []), ['sync', '--frozen', '--no-dev']) +}) + +const runner = (code: number | null, stdout = '') => async () => ({ code, stdout }) + +test('hasNvidiaGpu parses nvidia-smi -L output', async () => { + assert.equal(await hasNvidiaGpu(runner(0, 'GPU 0: NVIDIA RTX A4000 (UUID: GPU-1)\n')), true) + assert.equal(await hasNvidiaGpu(runner(0, '')), false) + assert.equal(await hasNvidiaGpu(runner(0, 'No devices were found')), false) + assert.equal(await hasNvidiaGpu(runner(9, 'GPU 0: x')), false) + assert.equal(await hasNvidiaGpu(runner(null)), false) +}) + +test('hasNvidiaGpu never throws and asks for a ~5 s timeout', async () => { + assert.equal(await hasNvidiaGpu(async () => { throw new Error('ENOENT') }), false) + let seen: unknown[] = [] + await hasNvidiaGpu(async (cmd, args, t) => { seen = [cmd, args, t]; return { code: 1, stdout: '' } }) + assert.deepEqual(seen, ['nvidia-smi', ['-L'], 5000]) +}) + +test('gpuEnvOverride reads _GPU', () => { + assert.equal(gpuEnvOverride({ TESTAPP_GPU: '0' }), false) + assert.equal(gpuEnvOverride({ TESTAPP_GPU: '1' }), true) + assert.equal(gpuEnvOverride({ TESTAPP_GPU: '' }), undefined) + assert.equal(gpuEnvOverride({}), undefined) +}) + +// ── wheelhouse ─────────────────────────────────────────────────────────────── + +function wheelhouseDir(manifest: unknown): string { + const dir = tempDir('wheelhouse-') + writeFileSync(join(dir, 'wheelhouse.json'), + typeof manifest === 'string' ? manifest : JSON.stringify(manifest)) + return dir +} + +const goodManifest = { + app: 'testapp', version: '1.2.3', platform: 'win_amd64', python: '3.13.12', extras: ['gpu'], +} + +test('readWheelhouse parses a valid manifest', () => { + assert.deepEqual(readWheelhouse(wheelhouseDir(goodManifest)), goodManifest) +}) + +test('readWheelhouse is null for missing, unparseable, or foreign manifests', () => { + assert.equal(readWheelhouse(emptyDir()), null) + assert.equal(readWheelhouse(wheelhouseDir('{not json')), null) + assert.equal(readWheelhouse(wheelhouseDir({ ...goodManifest, app: 'other' })), null) +}) + +test('findWheelhouse returns the first valid directory', () => { + const a = wheelhouseDir({ ...goodManifest, app: 'other' }) + const b = wheelhouseDir(goodManifest) + const c = wheelhouseDir({ ...goodManifest, version: '9' }) + const found = findWheelhouse([emptyDir(), a, b, c]) + assert.equal(found?.dir, b) + assert.equal(found?.manifest.version, '1.2.3') + assert.equal(findWheelhouse([emptyDir()]), null) +}) + +test('wheelhouseCandidates are ordered option, env, resources, userData, exec dir', () => { + const got = wheelhouseCandidates( + { wheelhouse: '/opt', resourcesPath: '/res', userData: '/ud' }, + { TESTAPP_WHEELHOUSE: '/env' }, + join('/bin', 'app.exe'), + ) + assert.deepEqual(got, ['/opt', '/env', join('/res', 'wheelhouse'), + join('/ud', 'wheelhouse'), join('/bin', 'wheelhouse')]) + // Unset option / env are skipped, not left as holes. + assert.deepEqual( + wheelhouseCandidates({ wheelhouse: null, resourcesPath: '/res', userData: '/ud' }, {}, '/bin/app'), + [join('/res', 'wheelhouse'), join('/ud', 'wheelhouse'), join('/bin', 'wheelhouse')]) +}) + +test('platformMatches is loose but os/arch aware', () => { + assert.equal(platformMatches('win_amd64', 'win32', 'x64'), true) + assert.equal(platformMatches('win_amd64', 'linux', 'x64'), false) + assert.equal(platformMatches('manylinux_2_28_x86_64', 'linux', 'x64'), true) + assert.equal(platformMatches('macosx_11_0_arm64', 'darwin', 'arm64'), true) + assert.equal(platformMatches('macosx_11_0_arm64', 'darwin', 'x64'), false) +}) + +test('fileUrl uses forward slashes with a three-slash drive form', () => { + assert.equal(fileUrl('C:\\wh\\python'), 'file:///C:/wh/python') + assert.equal(fileUrl('/wh/python'), 'file:///wh/python') +}) + +test('offlinePlan builds the python, venv, export, pip-install sequence', () => { + const plan = offlinePlan({ + wheelhouse: join('/wh'), manifest: { python: '3.13.12' }, + envDir: join('/ud', 'python-env'), userData: '/ud', extras: ['gpu'], + }) + assert.equal(plan.env.UV_OFFLINE, '1') + assert.equal(plan.env.UV_PYTHON_DOWNLOADS, 'automatic') + assert.ok(plan.env.UV_PYTHON_INSTALL_MIRROR.startsWith('file://')) + assert.ok(plan.env.UV_PYTHON_INSTALL_MIRROR.endsWith('/python')) + assert.deepEqual(plan.python, ['python', 'install', '3.13.12']) + assert.deepEqual(plan.venv, ['venv', '--python', '3.13.12', '--managed-python', + '--allow-existing', join('/ud', 'python-env')]) + assert.deepEqual(plan.export.slice(0, 7), + ['export', '--frozen', '--no-dev', '--no-emit-project', '--no-emit-workspace', + '--extra', 'gpu']) + assert.equal(plan.export.at(-1), plan.requirementsFile) + assert.equal(plan.requirementsFile, join('/ud', 'requirements.offline.txt')) + for (const flag of ['--offline', '--no-index', '--require-hashes']) { + assert.ok(plan.install.includes(flag), flag) + } + assert.equal(plan.install[plan.install.indexOf('--find-links') + 1], join('/wh')) + assert.equal(plan.install[plan.install.indexOf('-r') + 1], plan.requirementsFile) + assert.equal(plan.install.at(-1), venvPython(join('/ud', 'python-env'))) +}) + +test('checkWheelhouseExtras names the missing extra', () => { + const m = { ...goodManifest, extras: [] } + assert.throws(() => checkWheelhouseExtras(['gpu'], m), /built without the 'gpu' extra/) + checkWheelhouseExtras([], m) // gpu disabled → not effective → fine + checkWheelhouseExtras(['gpu'], goodManifest) +}) diff --git a/de_shell/js/main/pythonEnv.ts b/de_shell/js/main/pythonEnv.ts index 1e534bb..55d44ad 100644 --- a/de_shell/js/main/pythonEnv.ts +++ b/de_shell/js/main/pythonEnv.ts @@ -21,16 +21,29 @@ * path), so the working install path can never regress. macOS keeps the plain * sync (no CUDA pin there; PyPI torch already carries MPS). * + * Extras: `ShellConfig.pythonExtras` are always installed (`--extra X`) and + * `gpuExtras` only when an NVIDIA GPU is found (`hasNvidiaGpu`). The effective + * extras are folded into the lock stamp, so gaining a GPU or adding an extra + * re-syncs while an app with no extras keeps its bare-hash stamp (no re-sync on + * upgrade). + * + * Offline: when a WHEELHOUSE (see `readWheelhouse`) is found, the network is not + * touched at all — see `offlinePlan` for why this is venv + export + pip install + * rather than `uv sync`. + * + * No residue: every packaged uv call pins `UV_PYTHON_INSTALL_DIR` under + * userData, so uv's managed interpreters do not land in %APPDATA%\uv\python. + * * In development (no bundled payload) we fall back to `uv run` from the repo * root — exactly the previous behaviour. */ import { spawn } from 'child_process' import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs' import { createHash } from 'crypto' -import { join } from 'path' +import { dirname, join } from 'path' // Extension spelled out so node:test can load this module without a bundler // (native type-stripping resolves relative imports literally). -import { shellConfig } from './config.ts' +import { envPrefix, shellConfig } from './config.ts' export interface ResolvedPython { cmd: string[] // argv to spawn, e.g. [pythonExe, '-m', 'spyde'] (module from ShellConfig) @@ -43,6 +56,12 @@ export interface EnsureOptions { projectRoot: string // repo root (dev) — holds pyproject.toml userData: string // app.getPath('userData') — writable venv lives here onProgress?: (line: string) => void + /** Force the GPU decision (true = add `gpuExtras`, false = never), skipping + * the `nvidia-smi` probe. Beats the `_GPU` env var. */ + gpu?: boolean + /** Explicit wheelhouse directory for an offline install; first candidate + * (see `wheelhouseCandidates`). null/undefined = search the defaults. */ + wheelhouse?: string | null } const isWin = process.platform === 'win32' @@ -137,6 +156,246 @@ export function readLockedTorchVersion(projectDir: string): string | null { return pick.version.split('+')[0] // "2.6.0+cu124" → "2.6.0" } +// ── GPU probe ──────────────────────────────────────────────────────────────── + +/** Runs a command and reports its exit code + stdout. Injectable so the probe's + * parsing is testable without nvidia-smi. A spawn failure may reject or return + * code null — `hasNvidiaGpu` treats both as "no GPU". */ +export type CommandRunner = ( + cmd: string, args: string[], timeoutMs: number, +) => Promise<{ code: number | null; stdout: string }> + +const defaultRunner: CommandRunner = (cmd, args, timeoutMs) => + new Promise((resolve) => { + let out = '' + let settled = false + const done = (code: number | null) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ code, stdout: out }) + } + let proc: ReturnType + // A hung driver must not hang app start-up: kill after the timeout. + const timer = setTimeout(() => { try { proc?.kill() } catch { /* gone */ } done(null) }, timeoutMs) + try { + proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) + } catch { done(null); return } + proc.stdout?.on('data', (b: Buffer) => { out += b.toString() }) + proc.on('error', () => done(null)) // nvidia-smi absent (ENOENT) → no GPU + proc.on('close', (code) => done(code)) + }) + +/** + * Is an NVIDIA GPU usable on this machine? `nvidia-smi -L` lists one + * `GPU 0: (UUID: …)` line per device and ships with the driver, so it + * answers "driver + device present" — the condition under which the CuPy-style + * GPU extras can actually work. Never throws: a missing binary, non-zero exit, + * timeout or output without a `GPU ` line is simply false (a CPU install is + * always a safe fallback). + */ +export async function hasNvidiaGpu(run: CommandRunner = defaultRunner): Promise { + try { + const { code, stdout } = await run('nvidia-smi', ['-L'], 5000) + return code === 0 && /^GPU /m.test(stdout ?? '') + } catch { return false } +} + +/** The `_GPU` override: '0'/'false' → false, '1'/'true' → true, else + * undefined (no override). */ +export function gpuEnvOverride(env: NodeJS.ProcessEnv = process.env): boolean | undefined { + const v = (env[`${envPrefix()}_GPU`] ?? '').trim().toLowerCase() + if (v === '1' || v === 'true') return true + if (v === '0' || v === 'false') return false + return undefined +} + +/** Effective extras, in config order, de-duplicated: `pythonExtras` plus + * `gpuExtras` when `gpu`. Pure. */ +export function effectiveExtras( + pythonExtras: string[] | undefined, gpuExtras: string[] | undefined, gpu: boolean, +): string[] { + return [...new Set([...(pythonExtras ?? []), ...(gpu ? (gpuExtras ?? []) : [])])] +} + +/** + * The stamp written to `.-lock-hash`. With NO extras it is the bare + * sha256 of uv.lock — byte-identical to what earlier versions wrote, so + * upgrading de-shell does not force every existing install to re-sync. With + * extras it is `+extras=` so a new extra, or a + * machine gaining a GPU, re-syncs (order-insensitive: config order is not + * semantic). + */ +export function lockStamp(lockHash: string, extras: string[]): string { + return extras.length ? `${lockHash}+extras=${[...extras].sort().join(',')}` : lockHash +} + +/** `['--extra','a','--extra','b']` for `uv sync` / `uv export`. */ +export function extraArgs(extras: string[]): string[] { + return extras.flatMap((e) => ['--extra', e]) +} + +/** Every `uv sync` argv in one place so the torch two-step and the plain + * fallback cannot drift apart on extras. `skipPackages` → `--no-install-package`. */ +export function buildSyncArgs( + projectArgs: string[], extras: string[], skipPackages: string[] = [], +): string[] { + return [ + 'sync', '--frozen', '--no-dev', ...projectArgs, ...extraArgs(extras), + ...skipPackages.flatMap((p) => ['--no-install-package', p]), + ] +} + +// ── Wheelhouse (offline install) ───────────────────────────────────────────── + +/** `wheelhouse.json`, written by the wheelhouse builder. The directory beside + * it holds a flat set of `*.whl` plus `python//`, a + * `uv python install` mirror. */ +export interface WheelhouseManifest { + app: string + version: string + platform: string // e.g. 'win_amd64' + python: string // full version, e.g. '3.13.12' + extras: string[] +} + +/** Parsed manifest, or null when missing/unparseable/for another app. */ +export function readWheelhouse(dir: string): WheelhouseManifest | null { + try { + const m = JSON.parse(readFileSync(join(dir, 'wheelhouse.json'), 'utf8')) + if (!m || typeof m !== 'object' || m.app !== shellConfig().appId) return null + return { + app: m.app, + version: String(m.version ?? ''), + platform: String(m.platform ?? ''), + python: String(m.python ?? ''), + extras: Array.isArray(m.extras) ? m.extras.map(String) : [], + } + } catch { return null } +} + +/** First candidate directory holding a valid manifest for this app. */ +export function findWheelhouse( + candidates: string[], +): { dir: string; manifest: WheelhouseManifest } | null { + for (const dir of candidates) { + const manifest = readWheelhouse(dir) + if (manifest) return { dir, manifest } + } + return null +} + +/** Where to look for a wheelhouse, most explicit first: the option, the + * `_WHEELHOUSE` env var, beside the bundled payload, in userData (copied + * there by hand), and next to the executable (a USB-stick-style install). */ +export function wheelhouseCandidates( + opts: Pick, + env: NodeJS.ProcessEnv = process.env, + execPath: string = process.execPath, +): string[] { + return [ + opts.wheelhouse, + env[`${envPrefix()}_WHEELHOUSE`], + join(opts.resourcesPath, 'wheelhouse'), + join(opts.userData, 'wheelhouse'), + join(dirname(execPath), 'wheelhouse'), + ].filter((d): d is string => !!d) +} + +/** Does a wheel platform tag (`win_amd64`, `manylinux_2_28_x86_64`, + * `macosx_11_0_arm64`) plausibly describe this machine? Deliberately loose — + * it only drives a warning; uv's hash check is the real guard. */ +export function platformMatches( + tag: string, platform: string = process.platform, arch: string = process.arch, +): boolean { + const t = tag.toLowerCase() + const osOk = platform === 'win32' ? t.startsWith('win') + : platform === 'darwin' ? t.startsWith('macosx') + : t.includes('linux') + const archs: Record = { + x64: ['amd64', 'x86_64'], arm64: ['arm64', 'aarch64'], ia32: ['win32', 'i686'], + } + return osOk && (archs[arch] ?? [arch]).some((a) => t.includes(a)) +} + +/** `file:///C:/x/python` (Windows) or `file:///x/python` (posix). */ +export function fileUrl(dir: string): string { + const p = dir.replace(/\\/g, '/') + return p.startsWith('/') ? `file://${p}` : `file:///${p}` +} + +export interface OfflinePlan { + /** Extra env for every offline uv call. */ + env: Record + /** argv (after `uv`) for each step, in order. */ + python: string[] + venv: string[] + export: string[] + install: string[] + /** Where `uv export` writes the hashed requirements. */ + requirementsFile: string +} + +/** + * The offline install, as argv — pure so it is testable without spawning uv. + * + * Why not `uv sync --offline --find-links`: for a frozen sync uv only looks for + * registry-locked wheels in its CACHE, never in find-links, so it cannot + * install from a wheelhouse. Instead: + * 1. `uv python install ` — the interpreter comes from the wheelhouse's + * python-build-standalone mirror (UV_PYTHON_INSTALL_MIRROR=file://…). + * It has to be its own step: under UV_OFFLINE `uv venv` refuses to fetch + * an interpreter even from a file:// mirror ("a managed Python download + * is available … but uv is set to offline mode"), while `uv python + * install` treats the mirror as the source it was told to use. + * Then `uv venv --managed-python` over the interpreter just installed. + * 2. `uv export --frozen` — reads only the lock (no network) and emits every + * third-party pin WITH hashes. + * 3. `uv pip install --offline --no-index --find-links + * --require-hashes` — lock-exact, hash-verified, no network. Torch is + * therefore lock-exact too (no per-machine two-step). + * The staged member/app wheels are then installed by `installAppWheel` as usual. + */ +export function offlinePlan(p: { + wheelhouse: string + manifest: Pick + envDir: string + userData: string + extras: string[] +}): OfflinePlan { + const requirementsFile = join(p.userData, 'requirements.offline.txt') + return { + env: { + UV_OFFLINE: '1', + UV_PYTHON_INSTALL_MIRROR: fileUrl(join(p.wheelhouse, 'python')), + UV_PYTHON_DOWNLOADS: 'automatic', + }, + python: ['python', 'install', p.manifest.python], + venv: ['venv', '--python', p.manifest.python, '--managed-python', + '--allow-existing', p.envDir], + export: ['export', '--frozen', '--no-dev', '--no-emit-project', '--no-emit-workspace', + ...extraArgs(p.extras), '-o', requirementsFile], + install: ['pip', 'install', '--offline', '--no-index', '--find-links', p.wheelhouse, + '--require-hashes', '-r', requirementsFile, '--python', venvPython(p.envDir)], + requirementsFile, + } +} + +/** Fail loudly when the wheelhouse lacks an extra we need. `gpuExtras` are only + * required when `gpu` is true — a user who set gpu=false never asked for them, + * and they are not in `extras` then. */ +export function checkWheelhouseExtras(extras: string[], manifest: WheelhouseManifest): void { + for (const e of extras) { + if (!manifest.extras.includes(e)) { + throw new Error( + `this wheelhouse was built without the '${e}' extra ` + + `(it has: ${manifest.extras.join(', ') || 'none'}). Rebuild it with '${e}', ` + + `or disable the GPU install with ${envPrefix()}_GPU=0 if '${e}' is a GPU extra.`, + ) + } + } +} + /** Resolve the command to launch the app's backend, creating the venv if needed. */ export async function resolvePythonEnv(opts: EnsureOptions): Promise { const cfg = shellConfig() @@ -157,17 +416,63 @@ export async function resolvePythonEnv(opts: EnsureOptions): Promise _GPU > nvidia-smi probe. The probe only runs + // when the app declared gpuExtras — otherwise there is nothing to decide. An + // explicit choice (option or env var) is strict; a probed one may be dropped + // when the install source cannot satisfy it (see the wheelhouse branch below). + const gpuExtras = cfg.gpuExtras ?? [] + const gpuExplicit = opts.gpu ?? gpuEnvOverride() + const gpu = gpuExtras.length === 0 ? false + : (gpuExplicit ?? await hasNvidiaGpu()) + let extras = effectiveExtras(cfg.pythonExtras, gpuExtras, gpu) + let stamp = lockStamp(lockHash, extras) + // The stamp written when a probed GPU was dropped because the wheelhouse was + // built without the GPU extras. Accepting it below is what stops an + // air-gapped GPU machine from re-syncing — online, so failing — on every + // launch; a new lock, or an explicit _GPU=1, re-evaluates. + const cpuOnly = effectiveExtras(cfg.pythonExtras, gpuExtras, false) + const gpuUnavailableStamp = `${lockStamp(lockHash, cpuOnly)}+gpu=unavailable` + + // Skip the sync if the venv already matches the shipped lock AND extras. + const onDisk = existsSync(stampFile) ? readSafe(stampFile) : null const upToDate = existsSync(pythonExe) && - existsSync(stampFile) && - readSafe(stampFile) === lockHash + (onDisk === stamp || + (gpu && gpuExplicit === undefined && onDisk === gpuUnavailableStamp)) if (!upToDate) { - await setupEnv(bundledProject, envDir, readAppVersion(bundledProject), opts.onProgress) + if (gpuExtras.length) { + opts.onProgress?.(gpu + ? `[env-setup] ${gpuExplicit ? 'GPU install requested' : 'NVIDIA GPU detected'} → extras: ${gpuExtras.join(', ')} +` + : `[env-setup] no NVIDIA GPU (or disabled) → skipping extras: ${gpuExtras.join(', ')} +`) + } + const found = findWheelhouse(wheelhouseCandidates(opts)) + if (found) { + // A probed GPU whose extras the wheelhouse lacks degrades to a CPU + // install rather than failing: the site still gets a working app, and + // the stamp remembers why. Anything else missing (an app extra, or a GPU + // the user asked for explicitly) fails in checkWheelhouseExtras. + const missingGpu = gpu && gpuExplicit === undefined && + gpuExtras.some((e) => !found.manifest.extras.includes(e)) + if (missingGpu) { + opts.onProgress?.( + `[env-setup] this wheelhouse has no GPU packages (${gpuExtras.join(', ')}); ` + + `installing without them. Set ${envPrefix()}_GPU=1 to require them. +`) + extras = cpuOnly + stamp = gpuUnavailableStamp + } + checkWheelhouseExtras(extras, found.manifest) + } + await setupEnv({ + projectDir: bundledProject, envDir, appVersion: readAppVersion(bundledProject), + extras, wheelhouse: found, onProgress: opts.onProgress, + }) try { mkdirSync(envDir, { recursive: true }) - writeFileSync(stampFile, lockHash, 'utf8') + writeFileSync(stampFile, stamp, 'utf8') } catch { /* a missing stamp just forces a re-sync next launch */ } } @@ -197,7 +502,10 @@ function readAppVersion(projectDir: string): string { * exits 1 (the first-launch crash). Absent in dev (marker not present) → the * env is unchanged and setuptools_scm resolves from the repo's git history. */ -function uvEnv(envDir: string, appVersion: string, projectDir?: string): NodeJS.ProcessEnv { +function uvEnv( + envDir: string, appVersion: string, projectDir?: string, + extraEnv: Record = {}, +): NodeJS.ProcessEnv { // Pretend-version env for setuptools_scm. The dist-name–scoped var is the // precise one; the plain SETUPTOOLS_SCM_PRETEND_VERSION is a harmless // belt-and-braces fallback in case the dist name ever changes. @@ -239,6 +547,12 @@ function uvEnv(envDir: string, appVersion: string, projectDir?: string): NodeJS. // Keep uv's own cache/tools next to the env so an air-gapped re-run is // self-contained and we never write into the read-only bundle. UV_CACHE_DIR: join(envDir, '..', 'uv-cache'), + // No residue outside userData: uv's managed interpreters default to + // %APPDATA%\uv\python (~/.local/share/uv/python), which an uninstall never + // cleans. Existing venvs keep working after this moves — their pyvenv.cfg + // records the absolute path of the interpreter they were created with. + UV_PYTHON_INSTALL_DIR: join(envDir, '..', 'python'), + ...extraEnv, } } @@ -249,6 +563,7 @@ function runUv( args: string[], appVersion: string, onProgress?: (line: string) => void, + extraEnv: Record = {}, ): Promise { const uv = join(projectDir, uvBinaryName()) // Fall back to a resolved PATH/per-user uv if not bundled (dev builds). @@ -256,7 +571,7 @@ function runUv( return new Promise((resolve, reject) => { const proc = spawn(uvCmd, args, { cwd: projectDir, - env: uvEnv(envDir, appVersion, projectDir), + env: uvEnv(envDir, appVersion, projectDir, extraEnv), stdio: ['ignore', 'pipe', 'pipe'], }) const relay = (b: Buffer) => onProgress?.(b.toString()) @@ -355,6 +670,7 @@ function installAppWheel( envDir: string, wheel: string, onProgress?: (line: string) => void, + offlineEnv?: Record, ): Promise { onProgress?.( `[env-setup] installing ${shellConfig().pythonDist} from wheel ` + @@ -362,8 +678,9 @@ function installAppWheel( ) return runUv( projectDir, envDir, - ['pip', 'install', '--no-deps', '--python', venvPython(envDir), wheel], - '', onProgress, + ['pip', 'install', ...(offlineEnv ? ['--offline'] : []), '--no-deps', + '--python', venvPython(envDir), wheel], + '', onProgress, offlineEnv, ) } @@ -379,12 +696,15 @@ function installAppWheel( * read-only source tree. In dev (no staged wheel) the project installs from * source as before. Logs which path ran to the progress stream. */ -async function setupEnv( - projectDir: string, - envDir: string, - appVersion: string, - onProgress?: (line: string) => void, -): Promise { +async function setupEnv(o: { + projectDir: string + envDir: string + appVersion: string + extras: string[] + wheelhouse: { dir: string; manifest: WheelhouseManifest } | null + onProgress?: (line: string) => void +}): Promise { + const { projectDir, envDir, appVersion, extras, wheelhouse, onProgress } = o const wheel = stagedAppWheel(projectDir) const memberWheels = stagedMemberWheels(projectDir, wheel) // With a staged wheel, tell uv sync NOT to touch the project (the app builds @@ -400,6 +720,29 @@ async function setupEnv( projectArgs.push('--no-install-package', wheelPackageName(memberWheel)) } + if (wheelhouse) { + const { dir, manifest } = wheelhouse + if (!platformMatches(manifest.platform)) { + onProgress?.(`[env-setup] WARNING: wheelhouse is for ${manifest.platform}, this machine is ${process.platform}/${process.arch}\n`) + } + if (appVersion && manifest.version && manifest.version !== appVersion) { + onProgress?.(`[env-setup] WARNING: wheelhouse is for version ${manifest.version}, app is ${appVersion}\n`) + } + onProgress?.(`[env-setup] offline install from wheelhouse ${dir}\n`) + const plan = offlinePlan({ wheelhouse: dir, manifest, envDir, userData: join(envDir, '..'), extras }) + mkdirSync(join(envDir, '..'), { recursive: true }) + await runUv(projectDir, envDir, plan.python, appVersion, onProgress, plan.env) + await runUv(projectDir, envDir, plan.venv, appVersion, onProgress, plan.env) + await runUv(projectDir, envDir, plan.export, appVersion, onProgress, plan.env) + await runUv(projectDir, envDir, plan.install, appVersion, onProgress, plan.env) + for (const memberWheel of memberWheels) { + await installAppWheel(projectDir, envDir, memberWheel, onProgress, plan.env) + } + if (wheel) await installAppWheel(projectDir, envDir, wheel, onProgress, plan.env) + onProgress?.('[env-setup] offline install complete\n') + return + } + const twoStep = (process.platform === 'win32' || process.platform === 'linux') && readLockedTorchVersion(projectDir) !== null @@ -412,7 +755,7 @@ async function setupEnv( // only torch-family package in the lock, so one exclusion covers it. await runUv( projectDir, envDir, - ['sync', '--frozen', '--no-dev', ...projectArgs, '--no-install-package', 'torch'], + buildSyncArgs(projectArgs, extras, ['torch']), appVersion, onProgress, ) // Step 2: torch resolved for this machine. @@ -434,7 +777,7 @@ async function setupEnv( } onProgress?.('[env-setup] running full locked uv sync\n') - await runUv(projectDir, envDir, ['sync', '--frozen', '--no-dev', ...projectArgs], appVersion, onProgress) + await runUv(projectDir, envDir, buildSyncArgs(projectArgs, extras), appVersion, onProgress) for (const memberWheel of memberWheels) { await installAppWheel(projectDir, envDir, memberWheel, onProgress) } From fb4c868104e1f4efa1d44059f45cdd0589a6ebc2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 12 Oct 2026 08:19:30 -0500 Subject: [PATCH 2/6] env setup, updater and problem-report UI: one call each, from SpyDE runEnvSetup streams throttled env_setup messages and, on failure, waits for the user (retry / install from a folder / quit) instead of leaving a dead window. registerUpdaterIpc and registerProblemReportIpc register the channels every app hand-wired. The bridge gains updater/report/envSetup and exports a ShellBridge type. EnvSetupOverlay, UpdateCard, UpdateDialog and ReportProblemDialog come from SpyDE with every string a prop. Refs directelectron/de_ground_crew#410 --- de_shell/js/main/envSetup.ts | 79 ++++++++ de_shell/js/main/envSetupMachine.test.ts | 154 +++++++++++++++ de_shell/js/main/envSetupMachine.ts | 163 ++++++++++++++++ de_shell/js/main/index.ts | 12 +- de_shell/js/main/shellIpc.ts | 45 +++++ de_shell/js/main/shellIpcCore.test.ts | 99 ++++++++++ de_shell/js/main/shellIpcCore.ts | 97 ++++++++++ de_shell/js/preload/index.ts | 60 ++++++ de_shell/js/renderer/EnvSetupOverlay.tsx | 177 ++++++++++++++++++ de_shell/js/renderer/ReportProblemDialog.tsx | 155 +++++++++++++++ de_shell/js/renderer/ShellModal.tsx | 49 +++++ de_shell/js/renderer/UpdateCard.tsx | 131 +++++++++++++ de_shell/js/renderer/UpdateDialog.tsx | 125 +++++++++++++ de_shell/js/renderer/bridgeTypes.ts | 43 +++++ de_shell/js/renderer/envSetupView.ts | 37 ++++ de_shell/js/renderer/index.ts | 23 ++- de_shell/js/renderer/protocol.ts | 28 ++- de_shell/js/renderer/reportModel.ts | 44 +++++ de_shell/js/renderer/shellModels.test.ts | 91 +++++++++ de_shell/js/renderer/shellState.ts | 49 ++++- de_shell/js/renderer/theme.ts | 45 +++++ de_shell/js/renderer/updateModel.ts | 103 ++++++++++ de_shell/js/renderer/useUpdateStatus.react.ts | 37 ++++ 23 files changed, 1830 insertions(+), 16 deletions(-) create mode 100644 de_shell/js/main/envSetup.ts create mode 100644 de_shell/js/main/envSetupMachine.test.ts create mode 100644 de_shell/js/main/envSetupMachine.ts create mode 100644 de_shell/js/main/shellIpc.ts create mode 100644 de_shell/js/main/shellIpcCore.test.ts create mode 100644 de_shell/js/main/shellIpcCore.ts create mode 100644 de_shell/js/renderer/EnvSetupOverlay.tsx create mode 100644 de_shell/js/renderer/ReportProblemDialog.tsx create mode 100644 de_shell/js/renderer/ShellModal.tsx create mode 100644 de_shell/js/renderer/UpdateCard.tsx create mode 100644 de_shell/js/renderer/UpdateDialog.tsx create mode 100644 de_shell/js/renderer/bridgeTypes.ts create mode 100644 de_shell/js/renderer/envSetupView.ts create mode 100644 de_shell/js/renderer/reportModel.ts create mode 100644 de_shell/js/renderer/shellModels.test.ts create mode 100644 de_shell/js/renderer/theme.ts create mode 100644 de_shell/js/renderer/updateModel.ts create mode 100644 de_shell/js/renderer/useUpdateStatus.react.ts diff --git a/de_shell/js/main/envSetup.ts b/de_shell/js/main/envSetup.ts new file mode 100644 index 0000000..958ebdd --- /dev/null +++ b/de_shell/js/main/envSetup.ts @@ -0,0 +1,79 @@ +/** + * envSetup.ts — `runEnvSetup`: what a packaged app calls INSTEAD of + * `resolvePythonEnv` directly. + * + * It does the same resolve, but narrates it to the renderer as `env_setup` + * shell messages (so the first run shows a real progress overlay rather than a + * blank window) and survives failure by asking the user what to do — retry, + * install from a local wheelhouse folder, or quit. The state machine itself is + * envSetupMachine.ts; this file is only the Electron glue: the IPC answer + * channel, the native folder dialog, and app.quit. + * + * Messages ride the window's normal message pipe (`sendToRenderer`), so they + * are buffered until the renderer listens — a progress line sent before React + * mounted is not lost. + */ +import { app, dialog, ipcMain } from 'electron' + +import { channel, shellConfig } from './config' +import { runEnvSetupMachine } from './envSetupMachine' +import type { EnvSetupAnswer } from './envSetupMachine' +import { resolvePythonEnv } from './pythonEnv' +import type { EnsureOptions, ResolvedPython } from './pythonEnv' +import type { ShellWindow } from './window' + +export type { EnvSetupAnswer, EnvSetupWireMessage } from './envSetupMachine' +export { EnvSetupQuitError } from './envSetupMachine' + +const ANSWERS: readonly EnvSetupAnswer[] = ['retry', 'use-wheelhouse', 'quit'] + +/** The answer the machine is currently waiting on, if any. An answer arriving + * while nothing waits (a double click, a stale renderer) is dropped. */ +let waiter: ((a: EnvSetupAnswer) => void) | null = null +let listening = false + +function listenForAnswers(): void { + if (listening) return + listening = true + ipcMain.on(channel('env-setup-answer'), (_e, answer: unknown) => { + if (!ANSWERS.includes(answer as EnvSetupAnswer)) return + const w = waiter + waiter = null + w?.(answer as EnvSetupAnswer) + }) +} + +/** + * Resolve the Python environment with progress and a recovery path. + * + * `opts.wheelhouse`, if given, is used for the first attempt; later attempts use + * whatever folder the user picks. Rejects only with EnvSetupQuitError. + */ +export function runEnvSetup( + win: Pick, + opts: EnsureOptions, +): Promise { + listenForAnswers() + return runEnvSetupMachine({ + initialWheelhouse: opts.wheelhouse ?? null, + run: (wheelhouse, onProgress) => resolvePythonEnv({ + ...opts, + wheelhouse, + // Keep the app's own sink working alongside ours. + onProgress: (line: string) => { opts.onProgress?.(line); onProgress(line) }, + }), + emit: (msg) => win.sendToRenderer({ ...msg }), + waitForAnswer: () => new Promise((resolve) => { waiter = resolve }), + pickFolder: async () => { + const parent = win.get() + const options = { + title: `Choose the folder of packages to install ${shellConfig().appName} from`, + properties: ['openDirectory' as const], + } + const r = parent ? await dialog.showOpenDialog(parent, options) + : await dialog.showOpenDialog(options) + return r.canceled || !r.filePaths.length ? null : r.filePaths[0] + }, + quit: () => app.quit(), + }) +} diff --git a/de_shell/js/main/envSetupMachine.test.ts b/de_shell/js/main/envSetupMachine.test.ts new file mode 100644 index 0000000..c3c4ea1 --- /dev/null +++ b/de_shell/js/main/envSetupMachine.test.ts @@ -0,0 +1,154 @@ +/** + * envSetupMachine.test.ts — the first-run setup conversation, with a scripted + * resolver and renderer standing in for uv and the overlay. + */ +import { test, describe } from 'node:test' +import assert from 'node:assert/strict' + +import { + createProgressBatcher, runEnvSetupMachine, EnvSetupQuitError, +} from './envSetupMachine.ts' +import type { EnvSetupAnswer, EnvSetupWireMessage } from './envSetupMachine.ts' + +/** A manual clock + timer so batching is deterministic. */ +function fakeClock() { + let t = 1_000 + let pending: { fn: () => void; at: number } | null = null + return { + now: () => t, + setTimer: (fn: () => void, ms: number) => { pending = { fn, at: t + ms }; return pending }, + clearTimer: () => { pending = null }, + advance(ms: number) { + t += ms + if (pending && pending.at <= t) { const p = pending; pending = null; p.fn() } + }, + } +} + +describe('progress batching', () => { + test('the first chunk goes out at once, later ones coalesce until the interval', () => { + const clock = fakeClock() + const out: EnvSetupWireMessage[] = [] + const b = createProgressBatcher({ emit: m => out.push(m), intervalMs: 100, ...clock }) + b.push('Resolved 10 packages\n') + assert.equal(out.length, 1) + b.push('Downloading torch (825.0 MiB)\n') + b.push('torch ====> 100.0 MiB/825.0 MiB\r') + assert.equal(out.length, 1, 'throttled') + clock.advance(100) + assert.equal(out.length, 2) + assert.equal(out[1].line, 'Downloading torch (825.0 MiB)\ntorch ====> 100.0 MiB/825.0 MiB') + // The newest classified event wins, carrying the measured percent. + assert.equal(out[1].event?.percent, 12) + assert.equal(out[1].state, 'progress') + }) + + test('flush drains the pending batch and blank chunks emit nothing', () => { + const clock = fakeClock() + const out: EnvSetupWireMessage[] = [] + const b = createProgressBatcher({ emit: m => out.push(m), ...clock }) + b.push('Resolved 1 package\n') + b.push('\r\n \n') + b.push('Installed 1 package\n') + b.flush() + assert.equal(out.length, 2) + b.flush() + assert.equal(out.length, 2, 'nothing left to flush') + }) + + test('a burst is capped so one IPC cannot balloon', () => { + const clock = fakeClock() + const out: EnvSetupWireMessage[] = [] + const b = createProgressBatcher({ emit: m => out.push(m), intervalMs: 100, ...clock }) + b.push('first\n') + b.push(Array.from({ length: 500 }, (_, i) => `l${i}`).join('\n')) + b.flush() + assert.equal(out[1].line!.split('\n').length, 40) + assert.ok(out[1].line!.endsWith('l499'), 'keeps the newest lines') + }) +}) + +/** Build deps whose run() outcomes and renderer answers are scripted. */ +function scripted(outcomes: Array<'ok' | Error>, answers: EnvSetupAnswer[], folders: Array = []) { + const emitted: EnvSetupWireMessage[] = [] + const runs: Array = [] + let quit = 0 + return { + emitted, runs, get quit() { return quit }, + deps: { + intervalMs: 0, + run: async (wh: string | null, onProgress: (c: string) => void) => { + runs.push(wh) + onProgress('Resolved 3 packages\n') + const o = outcomes.shift()! + if (o !== 'ok') throw o + return { cmd: ['python'], cwd: '/x' } + }, + emit: (m: EnvSetupWireMessage) => emitted.push(m), + waitForAnswer: async () => answers.shift()!, + pickFolder: async () => folders.shift() ?? null, + quit: () => { quit++ }, + }, + } +} + +const states = (s: { emitted: EnvSetupWireMessage[] }) => s.emitted.map(m => m.state) + +describe('the setup state machine', () => { + test('success: start, progress, done, and resolves with the result', async () => { + const s = scripted(['ok'], []) + const r = await runEnvSetupMachine(s.deps) + assert.deepEqual(r, { cmd: ['python'], cwd: '/x' }) + assert.deepEqual(states(s), ['start', 'progress', 'done']) + }) + + test('failure does not reject: it reports the error and retry runs again', async () => { + const s = scripted([new Error('network down'), 'ok'], ['retry']) + await runEnvSetupMachine(s.deps) + assert.deepEqual(states(s), ['start', 'progress', 'error', 'start', 'progress', 'done']) + const err = s.emitted.find(m => m.state === 'error')! + assert.equal(err.message, 'network down') + assert.deepEqual(s.runs, [null, null]) + }) + + test('the last uv lines reach the renderer before the error', async () => { + const s = scripted([new Error('boom')], ['quit']) + await assert.rejects(runEnvSetupMachine(s.deps), EnvSetupQuitError) + const i = s.emitted.findIndex(m => m.state === 'error') + assert.equal(s.emitted[i - 1].state, 'progress') + }) + + test('use-wheelhouse re-runs with the picked folder', async () => { + const s = scripted([new Error('offline'), 'ok'], ['use-wheelhouse'], ['D:/wheels']) + await runEnvSetupMachine(s.deps) + assert.deepEqual(s.runs, [null, 'D:/wheels']) + const restart = s.emitted.filter(m => m.state === 'start')[1] + assert.equal(restart.wheelhouse, 'D:/wheels') + }) + + test('a cancelled folder dialog keeps waiting rather than re-running', async () => { + const s = scripted([new Error('offline'), 'ok'], ['use-wheelhouse', 'retry'], [null]) + await runEnvSetupMachine(s.deps) + assert.deepEqual(s.runs, [null, null]) + assert.equal(s.emitted.filter(m => m.state === 'error').length, 1) + }) + + test('a chosen wheelhouse sticks for later retries', async () => { + const s = scripted( + [new Error('a'), new Error('b'), 'ok'], ['use-wheelhouse', 'retry'], ['D:/w']) + await runEnvSetupMachine(s.deps) + assert.deepEqual(s.runs, [null, 'D:/w', 'D:/w']) + }) + + test('quit calls quit() once and rejects with EnvSetupQuitError', async () => { + const s = scripted([new Error('x')], ['quit']) + await assert.rejects(runEnvSetupMachine(s.deps), EnvSetupQuitError) + assert.equal(s.quit, 1) + }) + + test('an app-supplied initial wheelhouse is used on the first run', async () => { + const s = scripted(['ok'], []) + await runEnvSetupMachine({ ...s.deps, initialWheelhouse: 'E:/offline' }) + assert.deepEqual(s.runs, ['E:/offline']) + }) +}) diff --git a/de_shell/js/main/envSetupMachine.ts b/de_shell/js/main/envSetupMachine.ts new file mode 100644 index 0000000..8eda693 --- /dev/null +++ b/de_shell/js/main/envSetupMachine.ts @@ -0,0 +1,163 @@ +/** + * envSetupMachine.ts — the first-run environment setup state machine, with no + * Electron in it. + * + * A packaged first run downloads ~1.8 GB. Two things go wrong with a bare + * `await resolvePythonEnv()`: the window sits blank for minutes, and a failure + * (offline lab PC, proxy, full disk) leaves the app dead with no recourse. + * This turns the run into a conversation with the renderer: + * + * start -> progress* -> done (resolves) + * start -> progress* -> error -> retry (runs again) + * -> use-wheelhouse (folder picked, runs again offline) + * -> quit (rejects with EnvSetupQuitError) + * + * Failure deliberately does NOT reject: the user is the only one who can fix a + * dead network, so the promise stays pending until they answer. Everything + * Electron-shaped (the IPC listener, the directory dialog, app.quit) is injected + * by envSetup.ts, so this file is unit-testable under node --test. + */ +// Extension spelled out so node:test can load this module without a bundler. +import { parseUvLine } from './envProgress.ts' +import type { EnvProgressEvent } from './envProgress.ts' + +export type EnvSetupAnswer = 'retry' | 'use-wheelhouse' | 'quit' + +/** What the renderer receives (`type: 'env_setup'`). Flat and JSON-safe. */ +export interface EnvSetupWireMessage { + type: 'env_setup' + state: 'start' | 'progress' | 'done' | 'error' + /** Latest classified uv event in this batch (progress only). */ + event?: EnvProgressEvent + /** The raw lines of this batch, newline-joined (progress only). */ + line?: string + /** Human-readable failure (error only). */ + message?: string + /** The wheelhouse folder being installed from, when there is one. */ + wheelhouse?: string +} + +/** Thrown when the user answers `quit` after a failure. The app is exiting; + * callers should swallow it rather than report it as a crash. */ +export class EnvSetupQuitError extends Error { + constructor() { + super('environment setup abandoned: the user chose to quit') + this.name = 'EnvSetupQuitError' + } +} + +export interface BatcherDeps { + emit: (msg: EnvSetupWireMessage) => void + /** Minimum gap between progress messages. uv repaints a bar many times a + * second; one IPC per repaint would flood the renderer for no visible gain. */ + intervalMs?: number + now?: () => number + setTimer?: (fn: () => void, ms: number) => unknown + clearTimer?: (t: unknown) => void +} + +/** Cap on raw lines carried by one message, so a burst cannot make a huge IPC. */ +const MAX_LINES_PER_BATCH = 40 + +/** + * Coalesce uv output chunks into at most one `progress` message per interval. + * The newest classified event wins (it is the current state); every raw line is + * kept (up to the cap) so the log tail still moves. + */ +export function createProgressBatcher(deps: BatcherDeps) { + const interval = deps.intervalMs ?? 100 + const now = deps.now ?? Date.now + const setTimer = deps.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms)) + const clearTimer = deps.clearTimer + ?? ((t: unknown) => clearTimeout(t as ReturnType)) + + let lines: string[] = [] + let event: EnvProgressEvent | undefined + let timer: unknown = null + let lastEmit = -Infinity + + const flush = () => { + if (timer !== null) { clearTimer(timer); timer = null } + if (lines.length === 0 && !event) return + const msg: EnvSetupWireMessage = { + type: 'env_setup', state: 'progress', line: lines.join('\n'), + } + if (event) msg.event = event + lines = [] + event = undefined + lastEmit = now() + deps.emit(msg) + } + + const push = (chunk: string) => { + // uv repaints with \r, so split on both; blank pieces carry nothing. + for (const raw of chunk.split(/[\r\n]+/)) { + const text = raw.trimEnd() + if (!text.trim()) continue + lines.push(text) + const ev = parseUvLine(text) + if (ev) event = ev + } + if (lines.length > MAX_LINES_PER_BATCH) lines = lines.slice(-MAX_LINES_PER_BATCH) + if (lines.length === 0 && !event) return + const wait = interval - (now() - lastEmit) + if (wait <= 0) flush() + else if (timer === null) timer = setTimer(flush, wait) + } + + return { push, flush } +} + +export interface MachineDeps { + /** Run the real resolve. `wheelhouse` is null until the user picks a folder. */ + run: (wheelhouse: string | null, onProgress: (chunk: string) => void) => Promise + emit: (msg: EnvSetupWireMessage) => void + /** Resolve with the renderer's next answer. Only called while in `error`. */ + waitForAnswer: () => Promise + /** Native folder picker; null when cancelled. */ + pickFolder: () => Promise + quit: () => void + /** Wheelhouse to start with (an app that already knows the machine is offline). */ + initialWheelhouse?: string | null + intervalMs?: number +} + +export async function runEnvSetupMachine(deps: MachineDeps): Promise { + let wheelhouse = deps.initialWheelhouse ?? null + for (;;) { + const batcher = createProgressBatcher({ emit: deps.emit, intervalMs: deps.intervalMs }) + deps.emit({ type: 'env_setup', state: 'start', ...(wheelhouse ? { wheelhouse } : {}) }) + try { + const result = await deps.run(wheelhouse, batcher.push) + batcher.flush() + deps.emit({ type: 'env_setup', state: 'done' }) + return result + } catch (err) { + // Flush first so the last uv lines (usually the actual reason) reach the + // log tail BEFORE the error replaces the progress view. + batcher.flush() + deps.emit({ + type: 'env_setup', state: 'error', + message: (err as Error)?.message ?? String(err), + ...(wheelhouse ? { wheelhouse } : {}), + }) + } + + // Wait for a usable answer. A cancelled folder dialog is not an answer: the + // error stays on screen and we keep listening. + for (;;) { + const answer = await deps.waitForAnswer() + if (answer === 'quit') { + deps.quit() + throw new EnvSetupQuitError() + } + if (answer === 'retry') break + if (answer === 'use-wheelhouse') { + const dir = await deps.pickFolder() + if (!dir) continue + wheelhouse = dir + break + } + } + } +} diff --git a/de_shell/js/main/index.ts b/de_shell/js/main/index.ts index 8d59c30..9c370ad 100644 --- a/de_shell/js/main/index.ts +++ b/de_shell/js/main/index.ts @@ -42,9 +42,12 @@ export type { BackendHandlers } from './backendProcess' export { resolvePythonEnv, managedEnvPaths, venvPython, readLockedTorchVersion, - installTorchPerMachine, + installTorchPerMachine, hasNvidiaGpu, readWheelhouse, findWheelhouse, + wheelhouseCandidates, lockStamp, buildSyncArgs, offlinePlan, +} from './pythonEnv' +export type { + ResolvedPython, EnsureOptions, WheelhouseManifest, CommandRunner, OfflinePlan, } from './pythonEnv' -export type { ResolvedPython, EnsureOptions } from './pythonEnv' export { parseUvLine } from './envProgress' export type { EnvPhase, EnvProgressEvent } from './envProgress' @@ -59,3 +62,8 @@ export type { UpdateChannel, UpdateStatus } from './updater' export { isPrereleaseVersion, defaultChannelForVersion, truncateMessage, } from './updaterErrors' + +export { registerUpdaterIpc, registerProblemReportIpc } from './shellIpc' + +export { runEnvSetup, EnvSetupQuitError } from './envSetup' +export type { EnvSetupAnswer, EnvSetupWireMessage } from './envSetup' diff --git a/de_shell/js/main/shellIpc.ts b/de_shell/js/main/shellIpc.ts new file mode 100644 index 0000000..485a123 --- /dev/null +++ b/de_shell/js/main/shellIpc.ts @@ -0,0 +1,45 @@ +/** + * shellIpc.ts — the updater and problem-report IPC, registered with one call + * each. Every app used to hand-wire these (and Ground Crew wired neither). + * + * Channels are `:update-*` and `:report-*`. Update STATUS is + * pushed on `:update-status` by updater.ts itself (it holds the window + * given to `initUpdater`), so there is no subscribe channel here — only the + * `update-info` invoke that seeds a late-mounting UI with the last status. + * Safe to call twice: handlers are replaced, not stacked. + */ +import { app, ipcMain } from 'electron' + +import { channel } from './config' +import { collectDiagnostics, reportingConfigured, submitReport } from './errorReport' +import { bindReportIpc, bindUpdaterIpc } from './shellIpcCore' +import { + checkForUpdates, downloadUpdate, getLastUpdateStatus, quitAndInstall, + readUpdateChannel, setUpdateChannel, updatesSupported, +} from './updater' + +export function registerUpdaterIpc( + opts: { onChannelChange?: (c: 'stable' | 'beta') => void } = {}, +): void { + bindUpdaterIpc(ipcMain, { + channel, + readChannel: readUpdateChannel, + setChannel: setUpdateChannel, + supported: updatesSupported, + status: getLastUpdateStatus, + appVersion: () => app.getVersion(), + check: checkForUpdates, + download: downloadUpdate, + quitAndInstall, + onChannelChange: opts.onChannelChange, + }) +} + +export function registerProblemReportIpc(): void { + bindReportIpc(ipcMain, { + channel, + configured: reportingConfigured, + collect: collectDiagnostics, + submit: submitReport, + }) +} diff --git a/de_shell/js/main/shellIpcCore.test.ts b/de_shell/js/main/shellIpcCore.test.ts new file mode 100644 index 0000000..7a4bf2a --- /dev/null +++ b/de_shell/js/main/shellIpcCore.test.ts @@ -0,0 +1,99 @@ +/** + * shellIpcCore.test.ts — channel names, idempotency and input validation for + * the updater / report IPC, against a fake ipcMain. + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + bindReportIpc, bindUpdaterIpc, REPORT_CHANNELS, UPDATER_CHANNELS, +} from './shellIpcCore.ts' +import type { IpcLike, UpdaterDeps } from './shellIpcCore.ts' + +function fakeIpc() { + const handlers = new Map unknown>() + const listeners = new Map void>>() + const ipc: IpcLike = { + handle(ch, fn) { + if (handlers.has(ch)) throw new Error(`second handler for ${ch}`) // Electron does this + handlers.set(ch, fn) + }, + on(ch, fn) { listeners.set(ch, [...(listeners.get(ch) ?? []), fn]) }, + removeHandler(ch) { handlers.delete(ch) }, + removeAllListeners(ch) { listeners.delete(ch) }, + } + const emit = (ch: string, ...a: unknown[]) => listeners.get(ch)?.forEach(f => f({}, ...a)) + return { ipc, handlers, listeners, emit } +} + +function updaterDeps(calls: string[]): UpdaterDeps { + return { + channel: n => `app:${n}`, + readChannel: () => 'stable', + setChannel: c => calls.push(`set:${c}`), + supported: () => true, + status: () => ({ state: 'idle' }), + appVersion: () => '1.2.3', + check: () => calls.push('check'), + download: () => calls.push('download'), + quitAndInstall: () => calls.push('install'), + onChannelChange: c => calls.push(`changed:${c}`), + } +} + +test('channel names are namespaced and stable (the preload spells the same ones)', () => { + assert.deepEqual(UPDATER_CHANNELS, { + info: 'update-info', check: 'update-check', download: 'update-download', + install: 'update-install', setChannel: 'update-set-channel', status: 'update-status', + }) + assert.deepEqual(REPORT_CHANNELS, { diagnostics: 'report-diagnostics', submit: 'report-submit' }) +}) + +test('updater actions reach the updater; info seeds a late UI', async () => { + const f = fakeIpc(); const calls: string[] = [] + bindUpdaterIpc(f.ipc, updaterDeps(calls)) + f.emit('app:update-check'); f.emit('app:update-download'); f.emit('app:update-install') + assert.deepEqual(calls, ['check', 'download', 'install']) + assert.deepEqual(await f.handlers.get('app:update-info')!({}), { + channel: 'stable', supported: true, status: { state: 'idle' }, appVersion: '1.2.3', + }) +}) + +test('registering twice neither throws nor doubles the work', () => { + const f = fakeIpc(); const calls: string[] = [] + bindUpdaterIpc(f.ipc, updaterDeps(calls)) + bindUpdaterIpc(f.ipc, updaterDeps(calls)) + f.emit('app:update-check') + assert.deepEqual(calls, ['check']) + bindReportIpc(f.ipc, { channel: n => `app:${n}`, configured: () => true, collect: async () => ({}), submit: async () => ({}) }) + bindReportIpc(f.ipc, { channel: n => `app:${n}`, configured: () => true, collect: async () => ({}), submit: async () => ({}) }) +}) + +test('set-channel ignores anything but stable and beta', () => { + const f = fakeIpc(); const calls: string[] = [] + bindUpdaterIpc(f.ipc, updaterDeps(calls)) + f.emit('app:update-set-channel', 'nightly') + f.emit('app:update-set-channel', { evil: 1 }) + f.emit('app:update-set-channel', 'beta') + assert.deepEqual(calls, ['set:beta', 'changed:beta']) +}) + +test('report: diagnostics reports canSend, submit sanitises its input', async () => { + const f = fakeIpc(); const seen: unknown[] = [] + bindReportIpc(f.ipc, { + channel: n => `app:${n}`, configured: () => false, + collect: async () => ({ problems: [] }), + submit: async i => { seen.push(i); return { sent: false } }, + }) + assert.deepEqual(await f.handlers.get('app:report-diagnostics')!({}), + { canSend: false, diagnostics: { problems: [] } }) + const submit = f.handlers.get('app:report-submit')! + await submit({}, { message: 'it broke', contact: ' a@b.c ' }) + await submit({}, { message: 5, contact: ' ' }) + await submit({}, null) + assert.deepEqual(seen, [ + { message: 'it broke', contact: 'a@b.c' }, + { message: '', contact: undefined }, + { message: '', contact: undefined }, + ]) +}) diff --git a/de_shell/js/main/shellIpcCore.ts b/de_shell/js/main/shellIpcCore.ts new file mode 100644 index 0000000..efc688e --- /dev/null +++ b/de_shell/js/main/shellIpcCore.ts @@ -0,0 +1,97 @@ +/** + * shellIpcCore.ts — the updater / problem-report IPC wiring with the Electron + * pieces injected, so it runs under node --test. shellIpc.ts binds it to the + * real `ipcMain` and the real updater.ts / errorReport.ts. + * + * Re-registering REPLACES the previous handlers rather than throwing (Electron + * throws on a second `handle` for one channel), which is what makes the public + * `register*` calls idempotent. + */ +export interface IpcLike { + handle(ch: string, fn: (event: unknown, ...args: any[]) => unknown): void + on(ch: string, fn: (event: unknown, ...args: any[]) => void): void + removeHandler(ch: string): void + removeAllListeners(ch: string): void +} + +/** Channel suffixes, joined to `:` by the caller. preload/index.ts + * spells the same names; shellIpc.test.ts pins them. */ +export const UPDATER_CHANNELS = { + info: 'update-info', + check: 'update-check', + download: 'update-download', + install: 'update-install', + setChannel: 'update-set-channel', + /** Main -> renderer push, sent by updater.ts itself. */ + status: 'update-status', +} as const + +export const REPORT_CHANNELS = { + diagnostics: 'report-diagnostics', + submit: 'report-submit', +} as const + +export interface UpdaterDeps { + channel: (name: string) => string + readChannel: () => 'stable' | 'beta' + setChannel: (c: 'stable' | 'beta') => void + supported: () => boolean + status: () => unknown + appVersion: () => string + check: () => void + download: () => void + quitAndInstall: () => void + /** Called after a valid channel change, e.g. to mirror it into the backend. */ + onChannelChange?: (c: 'stable' | 'beta') => void +} + +function replaceHandle(ipc: IpcLike, ch: string, fn: (e: unknown, ...a: any[]) => unknown) { + ipc.removeHandler(ch) + ipc.handle(ch, fn) +} +function replaceOn(ipc: IpcLike, ch: string, fn: (e: unknown, ...a: any[]) => void) { + ipc.removeAllListeners(ch) + ipc.on(ch, fn) +} + +export function bindUpdaterIpc(ipc: IpcLike, d: UpdaterDeps): void { + const ch = (k: keyof typeof UPDATER_CHANNELS) => d.channel(UPDATER_CHANNELS[k]) + // Seeds a dialog/card that mounts AFTER the startup check already fired. + replaceHandle(ipc, ch('info'), () => ({ + channel: d.readChannel(), + supported: d.supported(), + status: d.status(), + appVersion: d.appVersion(), + })) + replaceOn(ipc, ch('check'), () => d.check()) + replaceOn(ipc, ch('download'), () => d.download()) + replaceOn(ipc, ch('install'), () => d.quitAndInstall()) + replaceOn(ipc, ch('setChannel'), (_e, c: unknown) => { + // The renderer is untrusted input; anything but the two channels is ignored. + if (c !== 'stable' && c !== 'beta') return + d.setChannel(c) + d.onChannelChange?.(c) + }) +} + +export interface ReportDeps { + channel: (name: string) => string + configured: () => boolean + collect: () => Promise + submit: (input: { message: string; contact?: string }) => Promise +} + +export function bindReportIpc(ipc: IpcLike, d: ReportDeps): void { + // Reading diagnostics has no side effects: opening the dialog sends nothing. + replaceHandle(ipc, d.channel(REPORT_CHANNELS.diagnostics), async () => ({ + canSend: d.configured(), + diagnostics: await d.collect(), + })) + replaceHandle(ipc, d.channel(REPORT_CHANNELS.submit), (_e, input: unknown) => { + const i = (input ?? {}) as { message?: unknown; contact?: unknown } + return d.submit({ + message: typeof i.message === 'string' ? i.message : '', + contact: typeof i.contact === 'string' && i.contact.trim() ? i.contact.trim() : undefined, + }) + }) +} diff --git a/de_shell/js/preload/index.ts b/de_shell/js/preload/index.ts index c52cd22..11d2622 100644 --- a/de_shell/js/preload/index.ts +++ b/de_shell/js/preload/index.ts @@ -21,6 +21,35 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' * electron's types. */ export interface FileFilter { name: string; extensions: string[] } +/** Last-known updater state; mirrors @de/shell-main's UpdateStatus (the + * renderer cannot import main, so the shape is restated here). */ +export type BridgeUpdateStatus = + | { state: 'idle' } + | { state: 'checking' } + | { state: 'available'; version: string; releaseNotes?: string } + | { state: 'not-available' } + | { state: 'downloading'; percent: number } + | { state: 'downloaded'; version: string } + | { state: 'error'; message: string } + +export interface BridgeUpdateInfo { + channel: 'stable' | 'beta' + /** False for dev / unpackaged runs, which have no app-update.yml. */ + supported: boolean + status: BridgeUpdateStatus + appVersion: string +} + +export interface BridgeReportResult { + sent: boolean + eventId?: string + bundlePath?: string + error?: string +} + +/** What the renderer answers when first-run setup fails. */ +export type EnvSetupChoice = 'retry' | 'use-wheelhouse' | 'quit' + export interface ShellBridgeOptions { /** Matches @de/shell-main's `appId` — channels are `:`. */ appId: string @@ -112,6 +141,33 @@ export function createShellBridge(opts: ShellBridgeOptions) { saveFile: (filters?: FileFilter[], defaultPath?: string): Promise => ipcRenderer.invoke(channel('save-file'), filters, defaultPath), + /** Auto-update. Needs `registerUpdaterIpc()` in main. Status is pushed by + * main's updater; `info()` seeds a UI that mounts after the first push. */ + updater: { + info: (): Promise => ipcRenderer.invoke(channel('update-info')), + check: () => ipcRenderer.send(channel('update-check')), + download: () => ipcRenderer.send(channel('update-download')), + quitAndInstall: () => ipcRenderer.send(channel('update-install')), + setChannel: (c: 'stable' | 'beta') => ipcRenderer.send(channel('update-set-channel'), c), + onStatus: (cb: (status: BridgeUpdateStatus) => void) => + on<[BridgeUpdateStatus]>(channel('update-status'), cb), + }, + + /** Problem reporting. Needs `registerProblemReportIpc()` in main. */ + report: { + diagnostics: (): Promise<{ canSend: boolean; diagnostics: Record }> => + ipcRenderer.invoke(channel('report-diagnostics')), + submit: (input: { message: string; contact?: string }): Promise => + ipcRenderer.invoke(channel('report-submit'), input), + }, + + /** First-run environment setup. The progress/error messages themselves + * arrive as `{type: 'env_setup'}` on `onMessage` (route them with + * `toShellAction`); this is only the answer path back to `runEnvSetup`. */ + envSetup: { + answer: (choice: EnvSetupChoice) => ipcRenderer.send(channel('env-setup-answer'), choice), + }, + /** Escape hatch for an app's own channels, so it does not have to * re-implement the disposer discipline above. */ onChannel: on, @@ -128,3 +184,7 @@ export function exposeShellBridge( ...createShellBridge(opts), ...extra, }) } + +/** The bridge's shape, so an app types `window.` from this instead of + * hand-copying it (and drifting): `ShellBridge & { myOwnThing(): … }`. */ +export type ShellBridge = ReturnType diff --git a/de_shell/js/renderer/EnvSetupOverlay.tsx b/de_shell/js/renderer/EnvSetupOverlay.tsx new file mode 100644 index 0000000..d4cdf56 --- /dev/null +++ b/de_shell/js/renderer/EnvSetupOverlay.tsx @@ -0,0 +1,177 @@ +/** + * EnvSetupOverlay.tsx — the first-run "Setting up " card shown while main's + * `runEnvSetup` builds the Python environment (a download of well over a GB on + * a packaged first run). Without it the window sits blank for minutes, + * indistinguishable from a hang. + * + * Driven by `ShellState.envSetup` (shellReducer maps the `env_setup` messages): + * a spinner, phase pills, a step line with a real percentage when uv reports + * one (indeterminate otherwise), and a live log tail so it is always visibly + * moving. When setup FAILS the same card turns into a recovery prompt — the + * message plus Retry / "Install from a folder…" / Quit — because on an offline + * lab PC the user is the only one who can fix it. + */ +import React, { useEffect, useRef } from 'react' + +import type { EnvSetupApi } from './bridgeTypes' +import { logTail, phasePills, stepText } from './envSetupView' +import type { EnvSetupState } from './shellState' +import { buttons, colors, font, mono } from './theme' + +export interface EnvSetupOverlayProps { + setup: EnvSetupState + appName: string + /** The answer path back to main (`bridge.envSetup`). */ + envSetup: EnvSetupApi + /** Replaces the default first-launch blurb. */ + description?: React.ReactNode +} + +export function EnvSetupOverlay({ setup, appName, envSetup, description }: EnvSetupOverlayProps) { + const tailRef = useRef(null) + // Keep the log tail pinned to the newest line. + useEffect(() => { + const el = tailRef.current + if (el) el.scrollTop = el.scrollHeight + }, [setup.lines]) + + const failed = !!setup.error + const tail = logTail(setup.lines) + + return ( +
+
+
+ {!failed && } +
+ {failed ? `${appName} could not finish setting up` : `Setting up ${appName}`} +
+
+ + {failed ? ( +
{setup.error}
+ ) : ( +
+ {description ?? 'First launch only — downloading the analysis environment. ' + + 'This can take several minutes; every later launch is instant.'} + {setup.wheelhouse &&
Installing from {setup.wheelhouse}
} +
+ )} + + {!failed && ( + <> +
+ {phasePills(setup.phase).map((p) => ( + + {p.state === 'done' ? '✓ ' : ''}{p.label} + + ))} +
+
+ {stepText(setup)} +
+ + + )} + +
Setup log
+
+ {tail.length === 0 + ? Starting… + : tail.map((l, i) =>
{l}
)} +
+ + {failed && ( +
+ + + +
+ )} +
+
+ ) +} + +/** Indeterminate when percent is null; a filled bar when we have a %. */ +function ProgressBar({ percent }: { percent: number | null }) { + return ( +
+
+ +
+ ) +} + +function Spinner() { + return ( +
+ +
+ ) +} + +/** Keyframes ship inline (no stylesheet to load); duplicates are harmless. */ +function Keyframes() { + return ( + + ) +} + +const S: Record = { + scrim: { + position: 'fixed', inset: 0, zIndex: 99998, background: colors.scrim, + backdropFilter: 'blur(2px)', display: 'flex', alignItems: 'center', + justifyContent: 'center', padding: 24, userSelect: 'text', + }, + card: { + maxWidth: 560, width: '100%', display: 'flex', flexDirection: 'column', + padding: '26px 30px', borderRadius: 12, background: colors.bg, + border: '1px solid', boxShadow: '0 12px 40px rgba(0,0,0,0.5)', + color: colors.fg, fontFamily: font, + }, + header: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }, + blurb: { fontSize: 13, lineHeight: 1.5, color: colors.muted, marginBottom: 16 }, + // Bounded: an arbitrarily long failure scrolls instead of growing the card. + error: { + fontSize: 13, lineHeight: 1.5, color: colors.danger, marginBottom: 12, + maxHeight: 120, overflowY: 'auto', overflowWrap: 'anywhere', whiteSpace: 'pre-wrap', + }, + pills: { display: 'flex', gap: 6, marginBottom: 14, flexWrap: 'wrap' }, + logLabel: { + marginTop: 16, fontSize: 10.5, color: colors.faint, textTransform: 'uppercase', + letterSpacing: 0.5, marginBottom: 5, + }, + log: { + height: 128, overflow: 'auto', background: colors.inset, + border: `1px solid ${colors.border}`, borderRadius: 6, padding: '7px 10px', + fontFamily: mono, fontSize: 11, lineHeight: 1.55, whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + actions: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }, +} diff --git a/de_shell/js/renderer/ReportProblemDialog.tsx b/de_shell/js/renderer/ReportProblemDialog.tsx new file mode 100644 index 0000000..cc31884 --- /dev/null +++ b/de_shell/js/renderer/ReportProblemDialog.tsx @@ -0,0 +1,155 @@ +/** + * ReportProblemDialog.tsx — "Report a Problem…". + * + * What the user writes is the smaller half of a report. The rest — OS, app and + * runtime versions, the last thing the updater said, the tail of the backend's + * output, plus whatever the host app adds — is collected by main + * (errorReport.ts) and shown here BEFORE anything is sent, because a report + * that quietly ships a machine description is a report people learn not to + * send. Nothing leaves the machine until Send is pressed, and a copy is always + * written to disk so an offline PC still produces something to email. + */ +import React, { useEffect, useState } from 'react' + +import type { ReportApi, ReportResult } from './bridgeTypes' +import { canSubmit, countProblems, outcomeModel, submitLabel } from './reportModel' +import type { ReportPhase } from './reportModel' +import { ShellModal } from './ShellModal' +import { buttons, colors, font, mono } from './theme' + +export interface ReportProblemDialogProps { + report: ReportApi + appName: string + onClose: () => void +} + +export function ReportProblemDialog({ report, appName, onClose }: ReportProblemDialogProps) { + const [message, setMessage] = useState('') + const [contact, setContact] = useState('') + const [canSend, setCanSend] = useState(false) + const [diagnostics, setDiagnostics] = useState | null>(null) + const [showDetails, setShowDetails] = useState(false) + const [phase, setPhase] = useState('writing') + const [result, setResult] = useState(null) + + useEffect(() => { + let cancelled = false + report.diagnostics().then((info) => { + if (cancelled) return + setCanSend(info.canSend) + setDiagnostics(info.diagnostics) + }).catch(() => { /* the dialog still works; details just stay empty */ }) + return () => { cancelled = true } + }, [report]) + + const submit = async () => { + setPhase('sending') + try { + setResult(await report.submit({ message, contact: contact.trim() || undefined })) + } catch (err) { + setResult({ sent: false, error: String(err) }) + } + setPhase('done') + } + + const finished = phase === 'done' && result + const problems = countProblems(diagnostics) + const ok = canSubmit(message, phase) + + return ( + Close + ) : <> + + + }> + {finished ? : ( + <> +

+ {canSend + ? `This goes straight to the ${appName} maintainers, with the details below attached.` + : 'This build has no reporting service configured, so the report will be saved ' + + 'to your computer for you to send on.'} +

+ +