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:
co-authored by
Claude Sonnet 5
parent
c2b95d057f
commit
8d52a23c75
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user