Pre-pull DDEV's default Docker images in the background on launch

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 <[email protected]>
This commit is contained in:
reaper
2026-08-08 05:53:18 -05:00
co-authored by Claude Sonnet 5
parent c2b95d057f
commit 8d52a23c75
2 changed files with 86 additions and 0 deletions
+81
View File
@@ -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<WarmupMarker | null> {
try {
return JSON.parse(await readFile(markerPath(), 'utf-8')) as WarmupMarker
} catch {
return null
}
}
async function writeMarker(marker: WarmupMarker): Promise<void> {
await mkdir(app.getPath('userData'), { recursive: true })
await writeFile(markerPath(), JSON.stringify(marker), 'utf-8')
}
function run(cmd: string, args: string[]): Promise<string> {
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<string, string> }
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<void> {
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)
}
}
+5
View File
@@ -10,6 +10,7 @@ import { registerLogsIpc } from './ipc/logs'
import { registerCreateIpc } from './ipc/create' import { registerCreateIpc } from './ipc/create'
import { registerWindowIpc } from './ipc/window' import { registerWindowIpc } from './ipc/window'
import { killAllRunningCommands } from './commandRunner' import { killAllRunningCommands } from './commandRunner'
import { warmupDdevImagesIfNeeded } from './imageWarmup'
function createWindow(): void { function createWindow(): void {
// Create the browser window. // Create the browser window.
@@ -68,6 +69,10 @@ app.whenReady().then(() => {
createWindow() 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 () { app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the // 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. // dock icon is clicked and there are no other windows open.