diff --git a/electron/bios/sessionState.ts b/electron/bios/sessionState.ts index 41696ce..19a3e93 100644 --- a/electron/bios/sessionState.ts +++ b/electron/bios/sessionState.ts @@ -63,8 +63,24 @@ export function loadBiosSession(userDataPath: string): BiosSessionState | null { try { const sessionFile = getSessionFile(userDataPath); if (!fs.existsSync(sessionFile)) return null; - const parsed = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as BiosSessionState; - return parsed; + const parsed = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as Partial; + if ( + typeof parsed?.sessionId !== 'string' + || typeof parsed?.hardwareFingerprint !== 'string' + || typeof parsed?.stage !== 'string' + || typeof parsed?.vendor !== 'string' + ) { + return null; + } + return { + sessionId: parsed.sessionId, + hardwareFingerprint: parsed.hardwareFingerprint, + selectedChanges: (parsed.selectedChanges ?? {}) as BiosSessionState['selectedChanges'], + stage: parsed.stage as BiosSessionStage, + vendor: parsed.vendor as BiosSessionState['vendor'], + rebootRequested: parsed.rebootRequested === true, + timestamp: typeof parsed.timestamp === 'number' ? parsed.timestamp : Date.now(), + }; } catch { return null; } diff --git a/electron/efiBuildFlow.ts b/electron/efiBuildFlow.ts new file mode 100644 index 0000000..47ebce3 --- /dev/null +++ b/electron/efiBuildFlow.ts @@ -0,0 +1,134 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { HardwareProfile } from './configGenerator.js'; +import type { OpToken } from './taskManager.js'; + +export interface EfiBuildRegistry { + create(kind: 'efi-build'): OpToken; + updateProgress(taskId: string, payload: { kind: 'efi-build'; phase: string; detail: string }): void; + complete(taskId: string): void; + fail(taskId: string, message: string): void; + cancel(taskId: string): void; +} + +export interface ClassifiedBuildError { + message: string; + explanation?: string | null; + category?: string | null; +} + +export interface RunEfiBuildFlowDependencies { + registry: EfiBuildRegistry; + getUserDataPath(): string; + log(level: string, area: string, message: string, detail?: Record): void; + rememberFailureContext(input: { + trigger: string; + message: string; + detail?: string | null; + code?: string | null; + }): void; + checkCompatibility(profile: HardwareProfile): { + isCompatible: boolean; + errors: string[]; + explanation: string; + }; + ensureBiosReady( + profile: HardwareProfile, + options?: { allowAcceptedSession?: boolean }, + ): Promise; + createEfiStructure( + efiPath: string, + profile: HardwareProfile, + token: OpToken, + onPhase?: (phase: string, detail: string) => void, + ): Promise; + withTimeout(promise: Promise, timeoutMs: number, label: string): Promise; + classifyError(error: unknown): ClassifiedBuildError; + createClassifiedIpcError(classified: ClassifiedBuildError, error: unknown): Error; + removeDir(targetPath: string): void; +} + +export interface RunEfiBuildFlowInput { + profile: HardwareProfile; + allowAcceptedSession?: boolean; +} + +export async function runEfiBuildFlow( + input: RunEfiBuildFlowInput, + deps: RunEfiBuildFlowDependencies, +): Promise { + const { profile, allowAcceptedSession } = input; + const token = deps.registry.create('efi-build'); + const efiPath = path.resolve(deps.getUserDataPath(), 'EFI_Build_' + Date.now()); + deps.log('INFO', 'efi', 'Building EFI', { + efiPath, + cpu: profile.cpu, + smbios: profile.smbios, + taskId: token.taskId, + }); + + const compatibility = deps.checkCompatibility(profile); + if (!compatibility.isCompatible || compatibility.errors.length > 0) { + deps.rememberFailureContext({ + trigger: 'efi_build_failure', + message: compatibility.errors[0] ?? compatibility.explanation, + detail: compatibility.explanation, + }); + deps.registry.fail(token.taskId, compatibility.errors[0] ?? compatibility.explanation); + throw new Error(compatibility.errors[0] ?? 'Hardware compatibility is blocked for this EFI build.'); + } + + try { + await deps.ensureBiosReady(profile, { allowAcceptedSession: allowAcceptedSession === true }); + } catch (error) { + const classified = deps.classifyError(error); + deps.rememberFailureContext({ + trigger: 'efi_build_failure', + message: classified.message, + detail: classified.explanation, + code: classified.category, + }); + deps.registry.fail(token.taskId, classified.message); + throw deps.createClassifiedIpcError(classified, error); + } + + if (!fs.existsSync(efiPath)) fs.mkdirSync(efiPath, { recursive: true }); + + try { + deps.registry.updateProgress(token.taskId, { + kind: 'efi-build', + phase: 'initialising', + detail: 'Preparing build environment', + }); + + await deps.withTimeout( + deps.createEfiStructure(efiPath, profile, token, (phase, detail) => { + deps.registry.updateProgress(token.taskId, { kind: 'efi-build', phase, detail }); + }), + 120_000, + 'createEfiStructure', + ); + + deps.registry.updateProgress(token.taskId, { + kind: 'efi-build', + phase: 'EFI structure complete', + detail: 'Base OpenCore files and placeholders are ready for validation.', + }); + deps.registry.complete(token.taskId); + deps.log('INFO', 'efi', 'EFI build complete', { efiPath }); + return efiPath; + } catch (error) { + const classified = deps.classifyError(error); + deps.rememberFailureContext({ + trigger: 'efi_build_failure', + message: classified.message, + detail: classified.explanation, + code: classified.category, + }); + deps.log('ERROR', 'efi', 'EFI build failed', { error: classified.explanation }); + if (!token.aborted) deps.registry.fail(token.taskId, classified.message); + else deps.registry.cancel(token.taskId); + deps.removeDir(efiPath); + throw deps.createClassifiedIpcError(classified, error); + } +} diff --git a/electron/main.ts b/electron/main.ts index 76183f1..40a117d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -71,11 +71,13 @@ import { simulateBuild, dryRunRecovery, verifyBuildState, verifyEfiBuildSuccess, import { runSafeSimulation, type SafeSimulationResult } from './safeSimulation.js'; import { sim } from './simulation.js'; import { getCompatModeConfigPath, getPackagedRendererEntryPath, getPreloadScriptPath } from './runtimePaths.js'; +import { runEfiBuildFlow } from './efiBuildFlow.js'; import { buildStartupFailurePageUrl, + determineDidFailLoadAction, describeStartupFailure, + MAX_MAIN_FRAME_LOAD_RETRIES, RENDERER_READY_TIMEOUT_MS, - shouldIgnoreDidFailLoad, type StartupFailureEventInput, } from './startupRecovery.js'; import { @@ -233,6 +235,9 @@ async function downloadFileWithProgress( checkAborted?: () => void, ): Promise { return new Promise((resolve, reject) => { + const CONNECT_TIMEOUT_MS = 30_000; + const INACTIVITY_TIMEOUT_MS = 20_000; + function fetchUrl(urlStr: string, redirects = 0): void { if (redirects > 10) { reject(new Error('Too many redirects')); return; } const parsedUrl = new URL(urlStr); @@ -246,14 +251,16 @@ async function downloadFileWithProgress( path: parsedUrl.pathname + parsedUrl.search, headers }; - lib.get(options as any, (res) => { + const req = lib.get(options as any, (res) => { if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) { + clearTimeout(connectTimer); fetchUrl(res.headers.location!, redirects + 1); return; } const isResume = res.statusCode === 206; const isFull = res.statusCode === 200; if (!isResume && !isFull) { + clearTimeout(connectTimer); reject(new Error(`HTTP ${res.statusCode} downloading ${urlStr}`)); return; } @@ -263,10 +270,33 @@ async function downloadFileWithProgress( let downloaded = effectiveOffset; let rejected = false; const file = fs.createWriteStream(dest, isResume ? { flags: 'a' } : { flags: 'w' }); + let inactivityTimer: NodeJS.Timeout | null = null; + + const clearInactivityTimer = () => { + if (inactivityTimer) { + clearTimeout(inactivityTimer); + inactivityTimer = null; + } + }; + + const armInactivityTimer = () => { + clearInactivityTimer(); + inactivityTimer = setTimeout(() => { + if (rejected) return; + rejected = true; + res.destroy(new Error(`Download stalled after ${INACTIVITY_TIMEOUT_MS / 1000}s with no progress: ${urlStr}`)); + file.destroy(new Error(`Download stalled after ${INACTIVITY_TIMEOUT_MS / 1000}s with no progress: ${urlStr}`)); + reject(new Error(`Download stalled after ${INACTIVITY_TIMEOUT_MS / 1000}s with no progress: ${urlStr}`)); + }, INACTIVITY_TIMEOUT_MS); + }; + + clearTimeout(connectTimer); + armInactivityTimer(); res.on('data', (chunk: Buffer) => { if (rejected) return; try { checkAborted?.(); } catch (abortErr) { rejected = true; + clearInactivityTimer(); res.destroy(); file.destroy(); try { fs.truncateSync(dest, downloaded); } catch {} @@ -274,13 +304,31 @@ async function downloadFileWithProgress( return; } downloaded += chunk.length; + armInactivityTimer(); onProgress(downloaded, total); }); res.pipe(file); - file.on('finish', () => { if (!rejected) file.close(() => resolve()); }); - res.on('error', (e) => { if (!rejected) { rejected = true; reject(e); } }); - file.on('error', (e) => { if (!rejected) { rejected = true; reject(e); } }); - }).on('error', reject); + file.on('finish', () => { + if (rejected) return; + clearInactivityTimer(); + file.close(() => resolve()); + }); + res.on('error', (e) => { + clearInactivityTimer(); + if (!rejected) { rejected = true; reject(e); } + }); + file.on('error', (e) => { + clearInactivityTimer(); + if (!rejected) { rejected = true; reject(e); } + }); + }); + const connectTimer = setTimeout(() => { + req.destroy(new Error(`Timed out after ${CONNECT_TIMEOUT_MS / 1000}s connecting to ${urlStr}`)); + }, CONNECT_TIMEOUT_MS); + req.on('error', (error) => { + clearTimeout(connectTimer); + reject(error); + }); } fetchUrl(url); }); @@ -688,7 +736,11 @@ async function probeBiosSettings(): Promise { // --- EFI Structure --- -async function ensureOpenCoreBinaries(basePath: string, token?: OpToken) { +async function ensureOpenCoreBinaries( + basePath: string, + token?: OpToken, + onPhase?: (phase: string, detail: string) => void, +) { const cacheDir = path.resolve(app.getPath('userData'), 'OpenCore_Cache'); const ocVersion = '1.0.3'; const ocUrl = `https://github.com/acidanthera/OpenCorePkg/releases/download/${ocVersion}/OpenCore-${ocVersion}-RELEASE.zip`; @@ -700,11 +752,18 @@ async function ensureOpenCoreBinaries(basePath: string, token?: OpToken) { const coreFile = path.resolve(ocExtracted, 'X64/EFI/OC/OpenCore.efi'); if (!fs.existsSync(coreFile)) { log('INFO', 'efi', 'Downloading base OpenCore binaries...', { version: ocVersion }); - await downloadFileWithProgress(ocUrl, ocZip, () => {}, 0, () => token?.check()); + onPhase?.('Downloading OpenCore binaries…', `Caching OpenCore ${ocVersion} for the EFI build.`); + await downloadFileWithProgress(ocUrl, ocZip, (downloaded, total) => { + const detail = total > 0 + ? `${formatBytes(downloaded)} of ${formatBytes(total)}` + : `${formatBytes(downloaded)} downloaded`; + onPhase?.('Downloading OpenCore binaries…', detail); + }, 0, () => token?.check()); try { if (fs.existsSync(ocExtracted)) fs.rmSync(ocExtracted, { recursive: true, force: true }); fs.mkdirSync(ocExtracted, { recursive: true }); + onPhase?.('Extracting OpenCore base files…', 'Preparing bootloader files for config generation.'); if (process.platform === 'win32') { await runCommand(`powershell -Command "Expand-Archive -Path '${ocZip}' -DestinationPath '${ocExtracted}' -Force"`, {}, token); @@ -722,6 +781,7 @@ async function ensureOpenCoreBinaries(basePath: string, token?: OpToken) { const x64Efi = path.resolve(ocExtracted, 'X64/EFI'); if (fs.existsSync(x64Efi)) { + onPhase?.('Copying OpenCore base files…', 'Moving the base EFI structure into the build workspace.'); copyDirSync(x64Efi, path.resolve(basePath, 'EFI')); const versionedFiles = [ path.resolve(basePath, 'EFI/OC/OpenCore.efi'), @@ -739,8 +799,13 @@ async function ensureOpenCoreBinaries(basePath: string, token?: OpToken) { } } -async function createEfiStructure(basePath: string, profile: HardwareProfile, token?: OpToken) { - await ensureOpenCoreBinaries(basePath, token); +async function createEfiStructure( + basePath: string, + profile: HardwareProfile, + token?: OpToken, + onPhase?: (phase: string, detail: string) => void, +) { + await ensureOpenCoreBinaries(basePath, token, onPhase); const { kexts, ssdts } = getRequiredResources(profile); const dirs = [ @@ -758,8 +823,10 @@ async function createEfiStructure(basePath: string, profile: HardwareProfile, to const fullPath = path.resolve(basePath, dir); if (!fs.existsSync(fullPath)) fs.mkdirSync(fullPath, { recursive: true }); } + onPhase?.('Writing OpenCore configuration…', `Generating config.plist for ${profile.smbios}.`); const configContent = generateConfigPlist(profile); fs.writeFileSync(path.resolve(basePath, 'EFI/OC/config.plist'), configContent); + onPhase?.('Preparing ACPI and kext placeholders…', `${ssdts.length} SSDT entries and ${kexts.length} kext folders queued.`); ssdts.forEach(s => { const p = path.resolve(basePath, 'EFI/OC/ACPI', s); if (!fs.existsSync(p)) fs.writeFileSync(p, ''); @@ -1484,14 +1551,20 @@ async function continueWithCurrentBiosState( }); } -async function ensureBiosReady(profile: HardwareProfile): Promise { +async function ensureBiosReady( + profile: HardwareProfile, + options?: { allowAcceptedSession?: boolean }, +): Promise { + if (options?.allowAcceptedSession) { + return; + } const state = await getBiosStateForProfile(profile); if (!state.readyToBuild || state.stage !== 'complete') { throw new Error(`BIOS step incomplete: ${state.blockingIssues[0] ?? 'Required firmware settings are not verified.'}`); } } -async function getBuildFlowGuard(profile: HardwareProfile): Promise { +async function getBuildFlowGuard(profile: HardwareProfile, allowAcceptedSession = false): Promise { const compatibility = checkCompatibility(profile); const compatibilityBlocked = !compatibility.isCompatible || compatibility.errors.length > 0; const biosState = await getBiosStateForProfile(profile); @@ -1503,6 +1576,7 @@ async function getBuildFlowGuard(profile: HardwareProfile): Promise { @@ -2060,11 +2136,47 @@ function createWindow() { }); mainWindow.webContents.on('did-fail-load', (_e, errorCode, errorDescription, validatedURL, isMainFrame) => { - if (shouldIgnoreDidFailLoad({ errorCode, errorDescription, validatedURL, isMainFrame })) { + const action = determineDidFailLoadAction( + { errorCode, errorDescription, validatedURL, isMainFrame }, + startupLifecycle.mainFrameLoadRetries, + ); + + if (action === 'ignore') { log('INFO', 'startup', 'did-fail-load ignored', { errorCode, errorDescription, validatedURL, isMainFrame }); return; } + if (action === 'retry' && startupLifecycle.appEntryUrl && !startupLifecycle.recoveryShown) { + const retryWindow = mainWindow; + if (!retryWindow) { + void showStartupRecovery({ + kind: 'did_fail_load', + detail: `Main window was unavailable during startup retry: ${validatedURL}`, + }); + return; + } + startupLifecycle.mainFrameLoadRetries += 1; + startupLifecycle.rendererReady = false; + startupLifecycle.preloadReady = false; + clearStartupReadyTimer(); + log('WARN', 'startup', 'did-fail-load main-frame failure — retrying once', { + errorCode, + errorDescription, + validatedURL, + isMainFrame, + retry: startupLifecycle.mainFrameLoadRetries, + maxRetries: MAX_MAIN_FRAME_LOAD_RETRIES, + }); + void retryWindow.loadURL(startupLifecycle.appEntryUrl).catch((error) => { + log('FATAL', 'startup', 'automatic startup reload failed', { error: String(error) }); + void showStartupRecovery({ + kind: 'load_rejected', + detail: `Automatic startup retry failed: ${String(error)}`, + }); + }); + return; + } + log('ERROR', 'startup', 'did-fail-load', { errorCode, errorDescription, validatedURL, isMainFrame }); void showStartupRecovery({ kind: 'did_fail_load', @@ -2389,8 +2501,8 @@ app.whenReady().then(async () => { }; }); - ipcHandle('flow:guard-build', async (_event: Electron.IpcMainInvokeEvent, profile: HardwareProfile) => { - return getBuildFlowGuard(profile); + ipcHandle('flow:guard-build', async (_event: Electron.IpcMainInvokeEvent, profile: HardwareProfile, allowAcceptedSession?: boolean) => { + return getBuildFlowGuard(profile, allowAcceptedSession === true); }); ipcHandle('flow:guard-deploy', async (_event: Electron.IpcMainInvokeEvent, profile: HardwareProfile, efiPath: string) => { @@ -2512,50 +2624,26 @@ app.whenReady().then(async () => { }); // EFI build - ipcHandle('build-efi', async (_event: Electron.IpcMainInvokeEvent, profile: HardwareProfile) => { - const token = registry.create('efi-build'); - const efiPath = path.resolve(app.getPath('userData'), 'EFI_Build_' + Date.now()); - log('INFO', 'efi', 'Building EFI', { efiPath, cpu: profile.cpu, smbios: profile.smbios, taskId: token.taskId }); + ipcHandle('build-efi', async (_event: Electron.IpcMainInvokeEvent, profile: HardwareProfile, allowAcceptedSession?: boolean) => { lastBuildProfile = profile; - - const compatibility = checkCompatibility(profile); - if (!compatibility.isCompatible || compatibility.errors.length > 0) { - rememberFailureContext({ - trigger: 'efi_build_failure', - message: compatibility.errors[0] ?? compatibility.explanation, - detail: compatibility.explanation, - }); - registry.fail(token.taskId, compatibility.errors[0] ?? compatibility.explanation); - throw new Error(compatibility.errors[0] ?? 'Hardware compatibility is blocked for this EFI build.'); - } - await ensureBiosReady(profile); - - if (!fs.existsSync(efiPath)) fs.mkdirSync(efiPath, { recursive: true }); - - try { - registry.updateProgress(token.taskId, { kind: 'efi-build', phase: 'initialising', detail: 'Preparing build environment' }); - - await withTimeout(createEfiStructure(efiPath, profile, token), 45_000, 'createEfiStructure'); - - registry.complete(token.taskId); - log('INFO', 'efi', 'EFI build complete', { efiPath }); - } catch (e) { - const classified = classifyError(e); - rememberFailureContext({ - trigger: 'efi_build_failure', - message: classified.message, - detail: classified.explanation, - code: classified.category, - }); - log('ERROR', 'efi', 'EFI build failed', { error: classified.explanation }); - if (!token.aborted) registry.fail(token.taskId, classified.message); - else registry.cancel(token.taskId); - - // Clean up partial build directory - try { fs.rmSync(efiPath, { recursive: true, force: true }); } catch (_) {} - throw createClassifiedIpcError(classified, e); - } - return efiPath; + return runEfiBuildFlow( + { profile, allowAcceptedSession }, + { + registry, + getUserDataPath: () => app.getPath('userData'), + log, + rememberFailureContext, + checkCompatibility, + ensureBiosReady, + createEfiStructure, + withTimeout, + classifyError, + createClassifiedIpcError, + removeDir: (targetPath) => { + try { fs.rmSync(targetPath, { recursive: true, force: true }); } catch (_) {} + }, + }, + ); }); // Kext fetcher — hybrid: GitHub (latest) → embedded fallback → hard fail @@ -3556,6 +3644,7 @@ app.whenReady().then(async () => { ipcHandle('renderer:ready', async () => { startupLifecycle.rendererReady = true; + startupLifecycle.mainFrameLoadRetries = 0; clearStartupReadyTimer(); log('INFO', 'startup', 'renderer-ready handshake received', { preloadReady: startupLifecycle.preloadReady, diff --git a/electron/preload.ts b/electron/preload.ts index 048606e..c37ed66 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -51,7 +51,7 @@ try { inspectEfiBackupPolicy: (device: string) => ipcRenderer.invoke('efi-backup:inspect-policy', device), // EFI build - buildEFI: (profile: object) => ipcRenderer.invoke('build-efi', profile), + buildEFI: (profile: object, allowAcceptedSession = false) => ipcRenderer.invoke('build-efi', profile, allowAcceptedSession), // Kext fetcher fetchLatestKexts: (efiPath: string, kextNames: string[]) => @@ -89,7 +89,7 @@ try { getBiosRestartCapability: () => ipcRenderer.invoke('bios:restart-capability'), // Flow guards - guardBuild: (profile: object) => ipcRenderer.invoke('flow:guard-build', profile), + guardBuild: (profile: object, allowAcceptedSession = false) => ipcRenderer.invoke('flow:guard-build', profile, allowAcceptedSession), guardDeploy: (profile: object, efiPath: string) => ipcRenderer.invoke('flow:guard-deploy', profile, efiPath), // File system / diagnostics diff --git a/electron/startupRecovery.ts b/electron/startupRecovery.ts index c5b0fb4..0efb34b 100644 --- a/electron/startupRecovery.ts +++ b/electron/startupRecovery.ts @@ -3,6 +3,7 @@ import type { IssueReportDraft, PublicDiagnosticsSnapshot } from './releaseDiagn export const DID_FAIL_LOAD_ERR_ABORTED = -3; export const RENDERER_READY_TIMEOUT_MS = 8_000; +export const MAX_MAIN_FRAME_LOAD_RETRIES = 1; export type StartupFailureKind = | 'missing_assets' @@ -18,6 +19,8 @@ export interface DidFailLoadContext { isMainFrame: boolean; } +export type DidFailLoadAction = 'ignore' | 'retry' | 'recover'; + export interface StartupFailurePageInput { kind: StartupFailureKind; diagnostics: PublicDiagnosticsSnapshot; @@ -54,6 +57,15 @@ export function shouldIgnoreDidFailLoad(context: DidFailLoadContext): boolean { return false; } +export function determineDidFailLoadAction( + context: DidFailLoadContext, + retryCount: number, +): DidFailLoadAction { + if (shouldIgnoreDidFailLoad(context)) return 'ignore'; + if (retryCount < MAX_MAIN_FRAME_LOAD_RETRIES) return 'retry'; + return 'recover'; +} + function escapeHtml(value: string): string { return value .replaceAll('&', '&') diff --git a/src/App.tsx b/src/App.tsx index 2b526f5..76a5950 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,7 +95,7 @@ declare global { exportHardwareProfile: (artifact?: HardwareProfileArtifact | null) => Promise<{ filePath: string; artifact: HardwareProfileArtifact } | null>; importHardwareProfile: () => Promise; inspectEfiBackupPolicy: (device: string) => Promise; - buildEFI: (p: HardwareProfile) => Promise; + buildEFI: (p: HardwareProfile, allowAcceptedSession?: boolean) => Promise; fetchLatestKexts: (efi: string, ks: string[]) => Promise; downloadRecovery: (dir: string, osv: string, startOffset?: number) => Promise<{ dmgPath: string; recoveryDir: string }>; listUsbDevices: () => Promise<{ name: string; device: string; size: string }[]>; @@ -111,7 +111,7 @@ declare global { clearBiosSession: () => Promise; getBiosResumeState: () => Promise; getBiosRestartCapability: () => Promise; - guardBuild: (profile: import('../electron/configGenerator').HardwareProfile) => Promise; + guardBuild: (profile: import('../electron/configGenerator').HardwareProfile, allowAcceptedSession?: boolean) => Promise; guardDeploy: (profile: import('../electron/configGenerator').HardwareProfile, efiPath: string) => Promise; openFolder: (p: string) => Promise; getLogPath: () => Promise; @@ -323,6 +323,7 @@ export default function App() { const [recentEvents, setRecentEvents] = useState([]); const [watchdogCount, setWatchdogCount] = useState(0); const [recoveryTryCount, setRecoveryTryCount] = useState(0); + const [biosAccepted, setBiosAccepted] = useState(false); const hasLiveHardwareContext = planningProfileContext === 'live_scan'; const biosReady = biosState?.readyToBuild === true && biosState?.stage === 'complete'; const compatibilityBlocked = isCompatibilityBlocked(compat); @@ -354,9 +355,10 @@ export default function App() { () => evaluateBuildGuard({ compatibilityBlocked, biosFlowState, + biosAccepted, releaseFlowState, }), - [biosFlowState, compatibilityBlocked, releaseFlowState], + [biosAccepted, biosFlowState, compatibilityBlocked, releaseFlowState], ); const localDeployGuard = useMemo( () => evaluateDeployGuard({ @@ -368,7 +370,11 @@ export default function App() { }), [biosFlowState, compatibilityBlocked, efiPath, releaseFlowState, validationBlocked], ); - const postBuildReady = !compatibilityBlocked && biosReady && buildReady && !!efiPath && !validationBlocked; + const postBuildReady = !compatibilityBlocked && (biosReady || biosAccepted) && buildReady && !!efiPath && !validationBlocked; + + useEffect(() => { + setBiosAccepted(false); + }, [biosState?.hardwareFingerprint]); useEffect(() => { if (!profile) return; @@ -432,6 +438,7 @@ export default function App() { compat, hasLiveHardwareContext, biosReady, + biosAccepted, buildReady, efiPath, biosConf, @@ -694,10 +701,29 @@ export default function App() { }; } - const guard = await window.electron.guardBuild(activeProfile); + const guard = await window.electron.guardBuild(activeProfile, biosAccepted); if (!guard.allowed && options?.surfaceError !== false) { const redirect = getBuildGuardRedirect(activeCompat); - setErrorWithSuggestion(guard.reason ?? 'Build is blocked by the current firmware or compatibility state.', redirect); + setGlobalNotice(null); + setGlobalError(JSON.stringify({ + code: 'build_blocked_by_guard', + message: 'EFI build is blocked', + explanation: guard.reason ?? 'Build is blocked by the current firmware or compatibility state.', + decisionSummary: guard.reason ?? 'The EFI build cannot start from the current release state.', + suggestion: redirect === 'bios' + ? 'Return to the BIOS step and use Continue or Recheck BIOS before building again.' + : 'Return to the report step and fix the blocking prerequisite before rebuilding.', + category: 'build_error', + severity: 'warning', + targetStep: redirect, + rawMessage: guard.reason ?? 'Build is blocked by the current firmware or compatibility state.', + })); + logUiEvent('error_surface_opened', { + step: redirect, + code: 'build_blocked_by_guard', + message: 'EFI build is blocked', + rawMessage: guard.reason ?? 'Build is blocked by the current firmware or compatibility state.', + }); _setStepRaw(redirect); } return guard; @@ -781,11 +807,13 @@ export default function App() { const refreshBiosState = async (activeProfile: HardwareProfile, options?: { redirectIfBlocked?: boolean }) => { if (!hasLiveHardwareContext) { setBiosState(null); + setBiosAccepted(false); return null; } try { const nextState = await window.electron.getBiosState(activeProfile); setBiosState(nextState); + setBiosAccepted(false); if (options?.redirectIfBlocked && (!(nextState.readyToBuild && nextState.stage === 'complete')) && STEP_ORDER.indexOf(step) > STEP_ORDER.indexOf('bios')) { _setStepRaw('bios'); } @@ -1312,12 +1340,15 @@ export default function App() { const applySupportedBiosChanges = async (selectedChanges: Record) => { if (!profile) throw new Error('Hardware profile missing for BIOS orchestration.'); + setBiosAccepted(false); const result = await window.electron.applySupportedBiosChanges(profile, selectedChanges); setBiosState(result.state); return { message: result.message }; }; - const recheckBiosState = async (selectedChanges: Record) => performBiosRecheck({ + const recheckBiosState = async (selectedChanges: Record) => { + setBiosAccepted(false); + return performBiosRecheck({ profile, currentState: biosState, applyVerifiedState: setBiosState, @@ -1334,7 +1365,8 @@ export default function App() { rawMessage: payload.rawMessage ?? payload.explanation ?? payload.message, }); }, - }, selectedChanges); + }, selectedChanges); + }; const continueFromCurrentBiosState = async (selectedChanges: Record) => performBiosContinue({ profile, @@ -1343,13 +1375,15 @@ export default function App() { recheckManualChanges: (activeProfile, changes) => window.electron.verifyManualBiosChanges(activeProfile, changes), continueWithCurrentState: (activeProfile, changes) => window.electron.continueBiosWithCurrentState(activeProfile, changes), advanceToBuildStep: () => { + setBiosAccepted(true); const nextBuildGuard = evaluateBuildGuard({ compatibilityBlocked, biosFlowState: 'complete', + biosAccepted: true, releaseFlowState, }); const transition = attemptStepTransition('building', { - biosReady: true, + biosAccepted: true, localBuildGuard: nextBuildGuard, }); if (!transition?.ok) { @@ -1362,7 +1396,7 @@ export default function App() { setGlobalError(JSON.stringify(payload)); logUiEvent('error_surface_opened', { step: 'bios', - code: payload.code ?? 'bios_continue_failed', + code: payload.code ?? 'bios_continue_blocked', message: payload.message, rawMessage: payload.rawMessage ?? payload.explanation ?? payload.message, }); @@ -1371,6 +1405,7 @@ export default function App() { const restartToFirmwareWithSession = async (selectedChanges: Record) => { if (!profile) return { supported: false, error: 'Hardware profile missing for BIOS reboot.' }; + setBiosAccepted(false); const result = await window.electron.restartToFirmwareWithSession(profile, selectedChanges); setBiosState(result.state); window.electron.getBiosResumeState().then(setBiosResumeState).catch(() => {}); @@ -1382,7 +1417,7 @@ export default function App() { const startDeploy = async () => { if (!profile || isDeployingRef.current) return; - const liveBiosState = biosReady ? biosState : await refreshBiosState(profile, { redirectIfBlocked: true }); + const liveBiosState = (biosReady || biosAccepted) ? biosState : await refreshBiosState(profile, { redirectIfBlocked: true }); if (!liveBiosState) { return; } @@ -1504,7 +1539,7 @@ export default function App() { setStatus('Generating OpenCore configuration…'); setProgress(10); await new Promise(r => setTimeout(r, 800)); - const built = await window.electron.buildEFI(profile); + const built = await window.electron.buildEFI(profile, biosAccepted); if (!isCurrentRun()) return; setEfiPath(built); setProgress(55); @@ -1773,7 +1808,25 @@ export default function App() { pendingRendererExpectation: null, stalledReason: e.message || 'Build failed', }); - setErrorWithSuggestion(e.message || 'Build failed. Please check the hardware compatibility.', 'building'); + const message = e.message || 'Build failed. Please check the hardware compatibility.'; + setGlobalNotice(null); + setGlobalError(JSON.stringify({ + code: 'build_ipc_failed', + message: 'EFI build failed', + explanation: message, + decisionSummary: message, + suggestion: 'Return to the previous step, confirm the BIOS/build prerequisites, then retry the EFI build once.', + category: 'build_error', + severity: 'warning', + targetStep: 'report', + rawMessage: message, + })); + logUiEvent('error_surface_opened', { + step: 'building', + code: 'build_ipc_failed', + message: 'EFI build failed', + rawMessage: message, + }); setStep('report'); } finally { if (isCurrentRun()) { diff --git a/src/electron.d.ts b/src/electron.d.ts index 3150278..0fa533b 100644 --- a/src/electron.d.ts +++ b/src/electron.d.ts @@ -21,7 +21,7 @@ declare global { } | null>; importHardwareProfile: () => Promise; inspectEfiBackupPolicy: (device: string) => Promise; - buildEFI: (profile: any) => Promise; + buildEFI: (profile: any, allowAcceptedSession?: boolean) => Promise; fetchLatestKexts: (efiPath: string, kextNames: string[]) => Promise>; downloadRecovery: (targetPath: string, macOSVersion: string, startOffset?: number) => Promise<{ dmgPath: string; recoveryDir: string }>; listUsbDevices: () => Promise>; @@ -37,7 +37,7 @@ declare global { clearBiosSession: () => Promise; getBiosResumeState: () => Promise; getBiosRestartCapability: () => Promise; - guardBuild: (profile: any) => Promise; + guardBuild: (profile: any, allowAcceptedSession?: boolean) => Promise; guardDeploy: (profile: any, efiPath: string) => Promise; openFolder: (folderPath: string) => Promise; getLogPath: () => Promise; diff --git a/src/lib/biosStepFlow.ts b/src/lib/biosStepFlow.ts index 0a96b71..9e7c921 100644 --- a/src/lib/biosStepFlow.ts +++ b/src/lib/biosStepFlow.ts @@ -6,7 +6,7 @@ export type BiosRecoveryCode = | 'bios_recheck_failed' | 'bios_state_unavailable' | 'bios_requirements_not_met' - | 'bios_continue_failed' + | 'bios_continue_blocked' | 'bios_restart_failed'; export interface BiosActionFeedback { @@ -121,10 +121,10 @@ export function buildBiosRecoveryPayload(input: { rawMessage: detail || undefined, targetStep: 'bios', }; - case 'bios_continue_failed': + case 'bios_continue_blocked': default: return { - code: 'bios_continue_failed', + code: 'bios_continue_blocked', message: 'Could not continue from the BIOS step', explanation: 'The app could not carry the current BIOS checklist state into the next step.', decisionSummary: detail || 'The BIOS step did not produce a usable continuation state.', @@ -205,7 +205,7 @@ export async function performBiosContinue( }; } catch (error: any) { const payload = buildBiosRecoveryPayload({ - code: 'bios_continue_failed', + code: 'bios_continue_blocked', detail: error?.message || 'Unknown BIOS continuation failure.', }); deps.openRecoverySurface(payload); diff --git a/src/lib/installStepGuards.ts b/src/lib/installStepGuards.ts index b9dd9d4..6e7dc23 100644 --- a/src/lib/installStepGuards.ts +++ b/src/lib/installStepGuards.ts @@ -26,6 +26,7 @@ export interface StepGuardState { compat: CompatibilityReport | null; hasLiveHardwareContext: boolean; biosReady: boolean; + biosAccepted: boolean; buildReady: boolean; efiPath: string | null; biosConf: BIOSConfig | null; @@ -147,8 +148,26 @@ export function evaluateStepTransition(target: StepId, state: StepGuardState): S }; } - if (target === 'recovery-download' || target === 'method-select' || target === 'usb-select' || target === 'part-prep') { + if (target === 'recovery-download' || target === 'method-select' || target === 'usb-select') { return state.postBuildReady + ? { ok: true } + : { + ok: false, + reason: state.validationBlocked + ? 'EFI validation must pass before continuing.' + : state.compatibilityBlocked + ? 'Compatibility must remain unblocked before continuing.' + : (state.biosReady || state.biosAccepted) + ? 'A validated EFI is required before continuing.' + : 'BIOS preparation must be complete before continuing.', + redirect: state.compatibilityBlocked || state.validationBlocked || !state.buildReady || !state.efiPath || state.biosAccepted + ? 'report' + : 'bios', + }; + } + + if (target === 'part-prep') { + return state.buildReady && state.efiPath && state.compat && state.compat.errors.length === 0 && state.biosReady ? { ok: true } : { ok: false, diff --git a/src/lib/stateMachine.ts b/src/lib/stateMachine.ts index c273fc2..a6b0395 100644 --- a/src/lib/stateMachine.ts +++ b/src/lib/stateMachine.ts @@ -393,6 +393,7 @@ export function deriveReleaseFlowState(input: ReleaseStateDerivationInput): Rele export interface SharedFlowGuardContext { compatibilityBlocked: boolean; biosFlowState: BiosFlowState; + biosAccepted?: boolean; releaseFlowState: ReleaseFlowState; } @@ -411,6 +412,17 @@ export function evaluateBuildGuard(ctx: SharedFlowGuardContext): FlowGuardResult }; } + if (ctx.biosAccepted) { + const releaseFlow = createReleaseFlowMachine(ctx.releaseFlowState); + const allowed = releaseFlow.matches('build', 'bios'); + return { + allowed, + reason: allowed ? null : `Cannot build from state: ${ctx.releaseFlowState}`, + currentState: ctx.releaseFlowState, + biosState: ctx.biosFlowState, + }; + } + const result = canBuild({ releaseFlow: createReleaseFlowMachine(ctx.releaseFlowState), biosFlow: createBiosFlowMachine(ctx.biosFlowState), diff --git a/src/lib/structuredErrors.ts b/src/lib/structuredErrors.ts index 2df25c1..2b2ece4 100644 --- a/src/lib/structuredErrors.ts +++ b/src/lib/structuredErrors.ts @@ -41,6 +41,33 @@ const ERROR_MAP: Array<{ retryable: true, }, }, + { + test: m => m.includes('bios_continue_blocked') || m.includes('could not continue from the bios step'), + structured: { + title: 'BIOS continue is blocked', + what: 'The current BIOS checklist could not advance to EFI build from this state.', + nextStep: 'Stay on the BIOS step, review the blocking reason, and use Continue again only after the prerequisite is resolved.', + retryable: true, + }, + }, + { + test: m => m.includes('build_blocked_by_guard') || m.includes('efi build is blocked'), + structured: { + title: 'EFI build is blocked', + what: 'A build guard stopped EFI generation before it started.', + nextStep: 'Fix the blocking prerequisite shown in the report or BIOS step, then retry the EFI build.', + retryable: true, + }, + }, + { + test: m => m.includes('build_ipc_failed') || m.includes('efi build failed'), + structured: { + title: 'EFI build failed', + what: 'The EFI build IPC path returned a concrete runtime failure.', + nextStep: 'Review the reported build error, correct the blocker, then retry the EFI build once.', + retryable: true, + }, + }, { test: m => m.includes('no supported display path') || m.includes('val_gpu_no_supported_path'), structured: { diff --git a/src/lib/suggestionEngine.ts b/src/lib/suggestionEngine.ts index f7e5e2b..8b0cbf6 100644 --- a/src/lib/suggestionEngine.ts +++ b/src/lib/suggestionEngine.ts @@ -368,6 +368,46 @@ const TEMPLATES: SuggestionTemplate[] = [ }, // ── Pre-build blockers from deterministic/preflight checks ─────────────── + { + test: m => m.includes('build_blocked_by_guard') || m.includes('efi build is blocked'), + code: 'build_blocked_by_guard', + category: 'validation_error', + build: () => ({ + title: 'EFI build is blocked', + explanation: 'The app stopped the EFI build before generation because a prerequisite is still unsatisfied.', + severity: 'critical', + decisionSummary: '', + primaryAction: act( + 'Resolve the blocking prerequisite shown in the BIOS or report step, then retry the EFI build', + 'high', + 'The guard already identified the exact class of blocker, so retrying blindly is low value', + 'fix_now', + 'Build guard failures are deterministic gate checks, not transient renderer noise', + 'The next build starts only after the prerequisite is satisfied', + ), + alternatives: [], + }), + }, + { + test: m => m.includes('build_ipc_failed') || m.includes('efi build failed'), + code: 'build_ipc_failed', + category: 'validation_error', + build: () => ({ + title: 'EFI build failed', + explanation: 'The backend build flow returned a concrete runtime failure while generating the EFI.', + severity: 'critical', + decisionSummary: '', + primaryAction: act( + 'Review the concrete build error, fix it, then retry the EFI build once', + 'high', + 'This is a real backend build failure, not a safe retry candidate without first understanding the blocker', + 'fix_now', + 'Repeating the same failed build without addressing the reported cause is low signal', + 'The next build attempt starts from a corrected state', + ), + alternatives: [], + }), + }, { test: m => m.includes('pre-build check failed') || m.includes('build will fail'), code: 'build_precheck_failed', diff --git a/test/buildFlowMonitor.test.ts b/test/buildFlowMonitor.test.ts index 7fcc645..3856744 100644 --- a/test/buildFlowMonitor.test.ts +++ b/test/buildFlowMonitor.test.ts @@ -18,6 +18,7 @@ function makeGuardState(): StepGuardState { } as any, hasLiveHardwareContext: true, biosReady: true, + biosAccepted: false, buildReady: false, efiPath: null, biosConf: {} as any, diff --git a/test/efiBuildFlow.test.ts b/test/efiBuildFlow.test.ts new file mode 100644 index 0000000..bc26f94 --- /dev/null +++ b/test/efiBuildFlow.test.ts @@ -0,0 +1,118 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { afterEach, describe, test } from 'vitest'; +import type { HardwareProfile } from '../electron/configGenerator.js'; +import { runEfiBuildFlow, type EfiBuildRegistry } from '../electron/efiBuildFlow.js'; + +function makeProfile(overrides: Partial = {}): HardwareProfile { + return { + cpu: 'Intel Core i5-8250U', + architecture: 'Intel', + generation: 'Kaby Lake', + coreCount: 4, + gpu: 'Intel UHD Graphics 620', + gpuDevices: [{ name: 'Intel UHD Graphics 620', vendorName: 'Intel' }], + ram: '16 GB', + motherboard: 'Lenovo 20L5', + targetOS: 'macOS Ventura 13', + smbios: 'MacBookPro14,1', + kexts: [], + ssdts: [], + bootArgs: '-v', + isLaptop: true, + isVM: false, + audioLayoutId: 3, + strategy: 'canonical', + scanConfidence: 'high', + ...overrides, + }; +} + +function makeRegistry(events: string[]): EfiBuildRegistry { + return { + create: () => ({ + taskId: 'task-1', + aborted: false, + abort: () => {}, + check: () => {}, + registerProcess: () => {}, + }), + updateProgress: (_taskId, payload) => { + events.push(`progress:${payload.phase}`); + }, + complete: (taskId) => { + events.push(`complete:${taskId}`); + }, + fail: (taskId, message) => { + events.push(`fail:${taskId}:${message}`); + }, + cancel: (taskId) => { + events.push(`cancel:${taskId}`); + }, + }; +} + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const target = tempDirs.pop()!; + fs.rmSync(target, { recursive: true, force: true }); + } +}); + +describe('runEfiBuildFlow', () => { + test('accepted BIOS session resolves the EFI build path instead of hanging', async () => { + const events: string[] = []; + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'efi-build-flow-')); + tempDirs.push(tmpRoot); + const ensureCalls: Array<{ allowAcceptedSession?: boolean }> = []; + const structureCalls: string[] = []; + + const efiPath = await runEfiBuildFlow( + { + profile: makeProfile(), + allowAcceptedSession: true, + }, + { + registry: makeRegistry(events), + getUserDataPath: () => tmpRoot, + log: () => {}, + rememberFailureContext: () => {}, + checkCompatibility: () => ({ + isCompatible: true, + errors: [], + explanation: 'ok', + }), + ensureBiosReady: async (_profile, options) => { + ensureCalls.push(options ?? {}); + }, + createEfiStructure: async (targetPath) => { + structureCalls.push(targetPath); + fs.mkdirSync(path.join(targetPath, 'EFI', 'OC'), { recursive: true }); + fs.writeFileSync(path.join(targetPath, 'EFI', 'OC', 'OpenCore.efi'), ''); + }, + withTimeout: async (promise) => await promise, + classifyError: () => ({ + message: 'classified failure', + explanation: 'classified failure', + category: 'build_error', + }), + createClassifiedIpcError: (_classified, error) => error as Error, + removeDir: (targetPath) => { + fs.rmSync(targetPath, { recursive: true, force: true }); + }, + }, + ); + + assert.equal(ensureCalls.length, 1); + assert.deepEqual(ensureCalls[0], { allowAcceptedSession: true }); + assert.equal(structureCalls.length, 1); + assert.equal(structureCalls[0], efiPath); + assert.ok(fs.existsSync(path.join(efiPath, 'EFI', 'OC', 'OpenCore.efi'))); + assert.ok(events.includes('progress:initialising')); + assert.ok(events.includes('complete:task-1')); + }); +}); diff --git a/test/installStepGuards.test.ts b/test/installStepGuards.test.ts index 160bdee..b4a54dd 100644 --- a/test/installStepGuards.test.ts +++ b/test/installStepGuards.test.ts @@ -64,6 +64,7 @@ function makeState(overrides: Partial = {}): StepGuardState { compat, hasLiveHardwareContext: true, biosReady: true, + biosAccepted: false, buildReady: true, efiPath: '/tmp/efi', biosConf: { enable: [], disable: [] }, @@ -112,6 +113,30 @@ describe('install step guards', () => { assert.match(result.reason ?? '', /efi validation/i); }); + test('keeps post-build redirects on the report step when BIOS was manually accepted', () => { + const result = evaluateStepTransition('method-select', makeState({ + biosReady: false, + biosAccepted: true, + postBuildReady: false, + })); + + assert.equal(result.ok, false); + assert.equal(result.redirect, 'report'); + assert.match(result.reason ?? '', /validated efi/i); + }); + + test('does not allow accepted BIOS state to enter partition prep', () => { + const result = evaluateStepTransition('part-prep', makeState({ + biosReady: false, + biosAccepted: true, + postBuildReady: true, + })); + + assert.equal(result.ok, false); + assert.equal(result.redirect, 'bios'); + assert.match(result.reason ?? '', /bios preparation/i); + }); + test('blocks flashing when no target drive is selected', () => { const result = evaluateStepTransition('flashing', makeState({ selectedUsb: null, @@ -121,6 +146,22 @@ describe('install step guards', () => { assert.match(result.reason ?? '', /select a target drive/i); }); + test('does not allow accepted BIOS state to reach flashing', () => { + const result = evaluateStepTransition('flashing', makeState({ + biosReady: false, + biosAccepted: true, + localDeployGuard: makeGuard({ + allowed: false, + reason: 'BIOS preparation must be complete before deployment.', + currentState: 'deploy', + biosState: 'blocked', + }), + })); + + assert.equal(result.ok, false); + assert.match(result.reason ?? '', /bios preparation/i); + }); + test('blocks flashing when deploy guard blocks even with a drive selected', () => { const result = evaluateStepTransition('flashing', makeState({ validationBlocked: true, diff --git a/test/stateMachine.test.ts b/test/stateMachine.test.ts index 368665e..0544133 100644 --- a/test/stateMachine.test.ts +++ b/test/stateMachine.test.ts @@ -359,6 +359,18 @@ describe('Invalidation rules', () => { assert.equal(result.allowed, true); }); + test('evaluateBuildGuard allows a manually accepted BIOS session to enter build without a fresh probe', () => { + const result = evaluateBuildGuard({ + compatibilityBlocked: false, + biosFlowState: 'blocked', + biosAccepted: true, + releaseFlowState: 'bios', + }); + + assert.equal(result.allowed, true); + assert.equal(result.reason, null); + }); + test('evaluateDeployGuard blocks deploy when validation fails', () => { const result = evaluateDeployGuard({ compatibilityBlocked: false, @@ -396,4 +408,18 @@ describe('Invalidation rules', () => { assert.equal(result.allowed, true); }); + + test('evaluateDeployGuard still blocks deploy when build used biosAccepted only', () => { + const result = evaluateDeployGuard({ + compatibilityBlocked: false, + biosFlowState: 'blocked', + biosAccepted: true, + releaseFlowState: 'deploy', + validationBlocked: false, + hasEfi: true, + }); + + assert.equal(result.allowed, false); + assert.match(result.reason ?? '', /bios preparation must be complete/i); + }); });