diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 13ab9a78..13890472 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -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; }); diff --git a/apps/desktop/src/main/renderer-startup-guard.mjs b/apps/desktop/src/main/renderer-startup-guard.mjs new file mode 100644 index 00000000..1246d327 --- /dev/null +++ b/apps/desktop/src/main/renderer-startup-guard.mjs @@ -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, + }; +}; diff --git a/apps/desktop/src/main/renderer-startup-guard.test.mjs b/apps/desktop/src/main/renderer-startup-guard.test.mjs new file mode 100644 index 00000000..eb0e43d4 --- /dev/null +++ b/apps/desktop/src/main/renderer-startup-guard.test.mjs @@ -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([]); + }); +}); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 4f491afb..7716d3e7 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -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"), diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 86c210e1..ad1902c4 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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 = () => { - + - + ); + queueMicrotask(() => window.edgeeverDesktop?.rendererBootstrapReady()); }; const bootstrap = async () => { @@ -132,7 +135,7 @@ const bootstrap = async () => { if (reloading) { return; } - } else { + } else if (!isDesktopRenderer) { registerProductionServiceWorker(); } diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 4a292ea1..6279c66e 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -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; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 0da935e6..30355762 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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, diff --git a/package.json b/package.json index 79a12b6c..74398d0b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-desktop-renderer.mjs b/scripts/verify-desktop-renderer.mjs index 0ec253f3..881d3313 100644 --- a/scripts/verify-desktop-renderer.mjs +++ b/scripts/verify-desktop-renderer.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 {