fix(desktop): prevent packaged renderer white screen
This commit is contained in:
1 parent
b71daef0e3
commit
17c8181f85
9 files changed
+136
-9
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user