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 { 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user