From 8d52a23c75b87383bde09743bc55698d03a8e5ca Mon Sep 17 00:00:00 2001 From: reaper Date: Sat, 8 Aug 2026 05:53:18 -0500 Subject: [PATCH] Pre-pull DDEV's default Docker images in the background on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first-ever ddev start pulls several GB across the webserver, db, router, and ssh-agent images, which reads as the app hanging rather than as an expected download. Warms the cache automatically once per DDEV version (re-warms after a DDEV upgrade, since image tags are versioned) using `ddev version --json-output` to discover the exact default image tags — stays correct without hardcoding image names. Runs fire-and-forget right after window creation so it never blocks app startup or usage, and no-ops quietly if ddev/docker aren't available yet. Co-Authored-By: Claude Sonnet 5 --- src/main/imageWarmup.ts | 81 +++++++++++++++++++++++++++++++++++++++++ src/main/index.ts | 5 +++ 2 files changed, 86 insertions(+) create mode 100644 src/main/imageWarmup.ts diff --git a/src/main/imageWarmup.ts b/src/main/imageWarmup.ts new file mode 100644 index 0000000..070ecec --- /dev/null +++ b/src/main/imageWarmup.ts @@ -0,0 +1,81 @@ +import { app } from 'electron' +import { spawn } from 'child_process' +import { readFile, writeFile, mkdir } from 'fs/promises' +import { join } from 'path' + +const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin'] +const ENV_WITH_DDEV_PATH = { + ...process.env, + PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':') +} + +interface WarmupMarker { + ddevVersion: string +} + +function markerPath(): string { + return join(app.getPath('userData'), 'image-warmup.json') +} + +async function readMarker(): Promise { + try { + return JSON.parse(await readFile(markerPath(), 'utf-8')) as WarmupMarker + } catch { + return null + } +} + +async function writeMarker(marker: WarmupMarker): Promise { + await mkdir(app.getPath('userData'), { recursive: true }) + await writeFile(markerPath(), JSON.stringify(marker), 'utf-8') +} + +function run(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { env: ENV_WITH_DDEV_PATH, stdio: ['ignore', 'pipe', 'pipe'] }) + let out = '' + child.stdout.on('data', (d: Buffer) => (out += d.toString())) + child.stderr.on('data', (d: Buffer) => (out += d.toString())) + child.on('error', reject) + child.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(out)))) + }) +} + +// `ddev version` reports the exact default image tags (webserver, db, +// router, ssh-agent, xhgui) for whatever DDEV release is installed, so this +// stays correct across DDEV upgrades without hardcoding image names here. +async function getDefaultImages(): Promise<{ version: string; images: string[] }> { + const out = await run('ddev', ['version', '--json-output']) + const lastLine = out.trim().split('\n').pop() ?? '{}' + const envelope = JSON.parse(lastLine) as { raw?: Record } + const raw = envelope.raw ?? {} + const version = raw['DDEV version'] ?? 'unknown' + const images = Object.values(raw).filter((v) => /^[\w-]+\/[\w.-]+:[\w.-]+$/.test(v)) + return { version, images: [...new Set(images)] } +} + +// Pre-pulls DDEV's core Docker images (webserver, db, router, ssh-agent) in +// the background so a first-ever `ddev start` doesn't stall on a multi-GB +// download the moment someone tries to use a freshly installed app. Runs +// once per DDEV version — re-warms automatically after a DDEV upgrade, +// since image tags are versioned. No-ops quietly if ddev/docker aren't +// available yet; a real `ddev start` will surface that error properly. +export async function warmupDdevImagesIfNeeded(): Promise { + try { + const { version, images } = await getDefaultImages() + const marker = await readMarker() + if (marker?.ddevVersion === version) return + + for (const image of images) { + try { + await run('docker', ['pull', image]) + } catch (err) { + console.error(`[image warmup] failed to pull ${image}:`, err) + } + } + + await writeMarker({ ddevVersion: version }) + } catch (err) { + console.error('[image warmup] skipped:', err) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index a17ff7e..e184929 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,7 @@ import { registerLogsIpc } from './ipc/logs' import { registerCreateIpc } from './ipc/create' import { registerWindowIpc } from './ipc/window' import { killAllRunningCommands } from './commandRunner' +import { warmupDdevImagesIfNeeded } from './imageWarmup' function createWindow(): void { // Create the browser window. @@ -68,6 +69,10 @@ app.whenReady().then(() => { createWindow() + // Fire-and-forget: warms the Docker image cache in the background so it + // never blocks window creation or app usage. + void warmupDdevImagesIfNeeded() + app.on('activate', function () { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open.