fix(desktop): prevent packaged renderer white screen

This commit is contained in:
tianma-if committed 2026-08-30 18:28:05 +08:00
1 parent b71daef0e3
commit 17c8181f85
9 files changed
+136 -9

No files matched your search

+45
View File
@@ -25,6 +25,7 @@ import { trayIconPath } from "./tray-icon.mjs";
import { writeRichClipboard } from "./clipboard-write.mjs";
import { LocalDataResetError, scheduleMacLocalDataReset } from "./local-data-reset.mjs";
import { buildDesktopDiagnosticIssueUrl, normalizeDesktopDiagnostic } from "./desktop-diagnostics.mjs";
import { createRendererStartupGuard } from "./renderer-startup-guard.mjs";
import {
fetchTrustedWindowsUpdate,
verifyDownloadedWindowsUpdate,
@@ -96,6 +97,8 @@ let sidecarRestartAttempts = 0;
let sidecarRestartInFlight = false;
let localDataResetScheduled = false;
let rendererCrashDialogOpen = false;
let rendererStartupFailureDialogOpen = false;
let rendererStartupGuard = null;
let recoveredAfterAbnormalExit = false;
const updateCheckIntervalMs = 60 * 60 * 1_000;
const updateCheckFocusThrottleMs = 15 * 60 * 1_000;
@@ -222,6 +225,39 @@ const handleRendererProcessGone = async (details) => {
}
};
const showRendererStartupFailure = async (details) => {
if (isQuitting || rendererStartupFailureDialogOpen || !mainWindow || mainWindow.isDestroyed()) return;
rendererStartupFailureDialogOpen = true;
const isChinese = app.getLocale().toLowerCase().startsWith("zh");
const reason = String(details?.message || details?.errorDescription || details?.kind || "unknown").slice(0, 1000);
await writeDiagnostic("renderer.startup-failed", { ...details, reason });
try {
const result = await dialog.showMessageBox(mainWindow, {
type: "error",
title: isChinese ? "EdgeEver 启动失败" : "EdgeEver failed to start",
message: isChinese ? "页面没有成功启动。错误已经写入诊断日志。" : "The page did not start successfully. The error was written to the diagnostic log.",
detail: `${reason}\n\n${logPath()}`,
buttons: isChinese ? ["打开日志位置", "重新加载", "关闭"] : ["Show log", "Reload", "Close"],
defaultId: 1,
cancelId: 2,
});
if (result.response === 0) shell.showItemInFolder(logPath());
if (result.response === 1 && mainWindow && !mainWindow.isDestroyed()) {
armRendererStartupGuard();
mainWindow.webContents.reload();
}
} finally {
rendererStartupFailureDialogOpen = false;
}
};
const armRendererStartupGuard = () => {
rendererStartupGuard = createRendererStartupGuard({
onFailure: (details) => { void showRendererStartupFailure(details); },
});
rendererStartupGuard.arm();
};
const execFileAsync = (command, argumentsList) => new Promise((resolve, reject) => {
execFile(command, argumentsList, { encoding: "utf8" }, (error, stdout, stderr) => {
if (error) {
@@ -818,6 +854,7 @@ const createWindow = async () => {
},
});
rendererReady = false;
armRendererStartupGuard();
if (state.isMaximized) mainWindow.maximize();
mainWindow.on("resize", () => void saveWindowState());
mainWindow.on("move", () => void saveWindowState());
@@ -840,12 +877,16 @@ const createWindow = async () => {
validatedURL,
isMainFrame,
});
if (isMainFrame && errorCode !== -3) {
rendererStartupGuard?.fail({ kind: "load-failed", errorCode, errorDescription, validatedURL });
}
});
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
void writeDiagnostic("renderer.preload-error", {
preloadPath,
message: String(error?.message || error).slice(0, 2000),
});
rendererStartupGuard?.fail({ kind: "preload-error", message: String(error?.message || error).slice(0, 2000) });
});
mainWindow.webContents.on("console-message", (details) => {
if (details.level !== "error") return;
@@ -1011,6 +1052,10 @@ app.whenReady().then(async () => {
flushPendingDesktopCommands();
flushPendingMarkdownImport();
});
ipcMain.on("desktop:renderer-bootstrap-ready", (event) => {
if (event.sender !== mainWindow?.webContents) return;
if (rendererStartupGuard?.complete()) void writeDiagnostic("renderer.bootstrap-ready");
});
ipcMain.on("desktop:api-base-url-sync", (event) => { event.returnValue = configuredApiBaseUrl; });
ipcMain.on("desktop:session-token-sync", (event) => { event.returnValue = desktopSessionToken; });
ipcMain.on("desktop:recovered-after-abnormal-exit-sync", (event) => { event.returnValue = recoveredAfterAbnormalExit; });
@@ -0,0 +1,39 @@
export const RENDERER_STARTUP_TIMEOUT_MS = 15_000;
export const createRendererStartupGuard = ({
onFailure,
setTimer = setTimeout,
clearTimer = clearTimeout,
timeoutMs = RENDERER_STARTUP_TIMEOUT_MS,
}) => {
let timer = null;
let settled = false;
const cancelTimer = () => {
if (timer === null) return;
clearTimer(timer);
timer = null;
};
const fail = (details) => {
if (settled) return false;
settled = true;
cancelTimer();
onFailure(details);
return true;
};
return {
arm() {
if (settled || timer !== null) return;
timer = setTimer(() => fail({ kind: "startup-timeout" }), timeoutMs);
},
complete() {
if (settled) return false;
settled = true;
cancelTimer();
return true;
},
fail,
};
};
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { createRendererStartupGuard } from "./renderer-startup-guard.mjs";
describe("renderer startup guard", () => {
test("reports a startup that never reaches the renderer", () => {
const failures = [];
let callback;
const guard = createRendererStartupGuard({
onFailure: (details) => failures.push(details),
setTimer: (next) => { callback = next; return 1; },
clearTimer: () => {},
});
guard.arm();
callback();
expect(failures).toEqual([{ kind: "startup-timeout" }]);
expect(guard.complete()).toBe(false);
});
test("cancels the timeout after the renderer reports readiness", () => {
const failures = [];
const cleared = [];
const guard = createRendererStartupGuard({
onFailure: (details) => failures.push(details),
setTimer: () => 7,
clearTimer: (timer) => cleared.push(timer),
});
guard.arm();
expect(guard.complete()).toBe(true);
expect(cleared).toEqual([7]);
expect(guard.fail({ kind: "late-failure" })).toBe(false);
expect(failures).toEqual([]);
});
});
+1
View File
@@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
clearLocalData: () => ipcRenderer.invoke("desktop:clear-local-data"),
recordRendererError: (details) => ipcRenderer.invoke("desktop:record-renderer-error", details),
openRendererIssue: (details) => ipcRenderer.invoke("desktop:open-renderer-issue", details),
rendererBootstrapReady: () => ipcRenderer.send("desktop:renderer-bootstrap-ready"),
updateStatus: () => ipcRenderer.invoke("desktop:update-status"),
checkUpdate: () => ipcRenderer.invoke("desktop:check-update"),
downloadUpdate: () => ipcRenderer.invoke("desktop:download-update"),
+7 -4
View File
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { BrowserRouter, HashRouter } from "react-router";
import { registerSW } from "virtual:pwa-register";
import { App } from "./app/App";
import "./i18n";
@@ -13,6 +13,7 @@ import "./styles/globals.css";
const PWA_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1_000;
const DEVELOPMENT_PWA_RELOAD_KEY = "edgeever.dev-pwa-reset";
const isDesktopRenderer = __EDGEEVER_DESKTOP_BUILD__ || window.edgeeverDesktop?.isAvailable === true;
if (import.meta.env.DEV) {
if (__EDGEEVER_DEVELOPMENT_PROFILE__) {
@@ -106,6 +107,7 @@ const mountApp = () => {
}
initializeTheme();
const Router = isDesktopRenderer ? HashRouter : BrowserRouter;
createRoot(root, {
onUncaughtError(error, errorInfo) {
@@ -116,14 +118,15 @@ const mountApp = () => {
<DesktopRendererErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<BrowserRouter>
<Router>
<App />
</BrowserRouter>
</Router>
</ThemeProvider>
</QueryClientProvider>
</DesktopRendererErrorBoundary>
</React.StrictMode>
);
queueMicrotask(() => window.edgeeverDesktop?.rendererBootstrapReady());
};
const bootstrap = async () => {
@@ -132,7 +135,7 @@ const bootstrap = async () => {
if (reloading) {
return;
}
} else {
} else if (!isDesktopRenderer) {
registerProductionServiceWorker();
}
+2
View File
@@ -12,6 +12,7 @@ declare const __EDGEEVER_RELEASE_SUMMARY__: {
declare const __EDGEEVER_DEPLOYMENT_TRIGGER__: string;
declare const __EDGEEVER_DEPLOYMENT_METHOD__: string;
declare const __EDGEEVER_DEVELOPMENT_PROFILE__: "" | "local" | "demo";
declare const __EDGEEVER_DESKTOP_BUILD__: boolean;
interface EdgeEverDesktopBridge {
isAvailable: boolean;
@@ -30,6 +31,7 @@ interface EdgeEverDesktopBridge {
>;
recordRendererError(details: DesktopRendererErrorDetails): Promise<{ recorded: true }>;
openRendererIssue(details: DesktopRendererErrorDetails): Promise<{ opened: true }>;
rendererBootstrapReady(): void;
sidecarStatus(): Promise<{ available: boolean; path: string; scope: string }>;
systemInfo(): Promise<{
appVersion: string;
+1
View File
@@ -144,6 +144,7 @@ export default defineConfig({
__EDGEEVER_DEPLOYMENT_TRIGGER__: JSON.stringify(deploymentTrigger),
__EDGEEVER_DEPLOYMENT_METHOD__: JSON.stringify(deploymentMethod),
__EDGEEVER_DEVELOPMENT_PROFILE__: JSON.stringify(process.env.EDGE_EVER_DEVELOPMENT_PROFILE ?? ""),
__EDGEEVER_DESKTOP_BUILD__: JSON.stringify(isDesktopBuild),
},
plugins: [
developmentServiceWorkerReset,
+1 -1
View File
@@ -58,7 +58,7 @@
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
"build:worker": "bun scripts/build-cloudflare-worker.mjs",
+3 -4
View File
@@ -17,10 +17,6 @@ const browser = await chromium.launch({
headless: true,
args: [
"--allow-file-access-from-files",
// Electron permits the packaged renderer to normalize index.html to the
// file:// root. Match that behavior so this check exercises module
// evaluation rather than failing inside Chromium's stricter file history.
"--disable-web-security",
],
});
@@ -37,6 +33,9 @@ try {
// Keep observing long enough to catch post-mount update loops that leave
// packaged desktop windows blank after briefly rendering.
await page.waitForTimeout(2_000);
if (await page.locator(readySelector).first().count() === 0) {
throw new Error(`${label} disappeared after startup.`);
}
} catch (error) {
if (pageErrors.length === 0) throw error;
} finally {