diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..ac2b23f8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 56ce1a80..555b5b4f 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -422,6 +422,16 @@ jobs: EDGE_EVER_VERIFY_TARGET: win32 run: bun scripts/verify-desktop-package.mjs + - name: Run packaged Windows sidecar integration tests + shell: pwsh + run: | + $env:EDGE_EVER_SIDECAR_PATH = (Resolve-Path "release/desktop/win-unpacked/resources/sidecar/edgeever-sidecar.exe").Path + $env:EDGE_EVER_MIGRATIONS_PATH = (Resolve-Path "release/desktop/win-unpacked/resources/migrations").Path + bun scripts/test-desktop-sidecar.mjs + + - name: Verify packaged Windows first launch + run: bun run verify:packaged-desktop-startup -- release/desktop/win-unpacked/EdgeEver.exe + - name: Verify that Preview executables are unsigned shell: pwsh run: | diff --git a/.github/workflows/windows-test-signing.yml b/.github/workflows/windows-test-signing.yml index ed3a9ae4..93cf2522 100644 --- a/.github/workflows/windows-test-signing.yml +++ b/.github/workflows/windows-test-signing.yml @@ -57,6 +57,22 @@ jobs: - name: Package unsigned Windows installer run: bun scripts/run-desktop-builder.mjs --publish never + - name: Verify Windows package contents and architecture + env: + EDGE_EVER_DESKTOP_ARCH: x64 + EDGE_EVER_VERIFY_TARGET: win32 + run: bun scripts/verify-desktop-package.mjs + + - name: Run packaged Windows sidecar integration tests + shell: pwsh + run: | + $env:EDGE_EVER_SIDECAR_PATH = (Resolve-Path "release/desktop/win-unpacked/resources/sidecar/edgeever-sidecar.exe").Path + $env:EDGE_EVER_MIGRATIONS_PATH = (Resolve-Path "release/desktop/win-unpacked/resources/migrations").Path + bun scripts/test-desktop-sidecar.mjs + + - name: Verify packaged Windows first launch + run: bun run verify:packaged-desktop-startup -- release/desktop/win-unpacked/EdgeEver.exe + - name: Verify unsigned Windows installer id: installer shell: pwsh diff --git a/apps/desktop/src/main/child-process-start.mjs b/apps/desktop/src/main/child-process-start.mjs new file mode 100644 index 00000000..bb7ab8e8 --- /dev/null +++ b/apps/desktop/src/main/child-process-start.mjs @@ -0,0 +1,13 @@ +export const waitForChildProcessSpawn = (child) => new Promise((resolve, reject) => { + const onSpawn = () => { + child.off("error", onError); + resolve(child); + }; + const onError = (error) => { + child.off("spawn", onSpawn); + reject(error); + }; + + child.once("spawn", onSpawn); + child.once("error", onError); +}); diff --git a/apps/desktop/src/main/child-process-start.test.mjs b/apps/desktop/src/main/child-process-start.test.mjs new file mode 100644 index 00000000..1e27c463 --- /dev/null +++ b/apps/desktop/src/main/child-process-start.test.mjs @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { waitForChildProcessSpawn } from "./child-process-start.mjs"; + +describe("child process startup", () => { + test("resolves only after the process is spawned", async () => { + const child = new EventEmitter(); + const result = waitForChildProcessSpawn(child); + child.emit("spawn"); + await expect(result).resolves.toBe(child); + }); + + test("rejects a missing runtime or executable", async () => { + const child = new EventEmitter(); + const result = waitForChildProcessSpawn(child); + child.emit("error", new Error("VCRUNTIME140.dll was not found")); + await expect(result).rejects.toThrow("VCRUNTIME140.dll"); + }); +}); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 13890472..8bce2c41 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -26,6 +26,7 @@ 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 { waitForChildProcessSpawn } from "./child-process-start.mjs"; import { fetchTrustedWindowsUpdate, verifyDownloadedWindowsUpdate, @@ -99,6 +100,8 @@ let localDataResetScheduled = false; let rendererCrashDialogOpen = false; let rendererStartupFailureDialogOpen = false; let rendererStartupGuard = null; +let rendererUnresponsiveTimer = null; +let rendererUnresponsiveDialogOpen = false; let recoveredAfterAbnormalExit = false; const updateCheckIntervalMs = 60 * 60 * 1_000; const updateCheckFocusThrottleMs = 15 * 60 * 1_000; @@ -252,12 +255,63 @@ const showRendererStartupFailure = async (details) => { }; const armRendererStartupGuard = () => { + rendererStartupGuard?.complete(); rendererStartupGuard = createRendererStartupGuard({ onFailure: (details) => { void showRendererStartupFailure(details); }, }); rendererStartupGuard.arm(); }; +const clearRendererUnresponsiveTimer = () => { + if (!rendererUnresponsiveTimer) return; + clearTimeout(rendererUnresponsiveTimer); + rendererUnresponsiveTimer = null; +}; + +const showRendererUnresponsive = async () => { + rendererUnresponsiveTimer = null; + if (isQuitting || rendererUnresponsiveDialogOpen || !mainWindow || mainWindow.isDestroyed()) return; + rendererUnresponsiveDialogOpen = true; + const isChinese = app.getLocale().toLowerCase().startsWith("zh"); + try { + const result = await dialog.showMessageBox(mainWindow, { + type: "warning", + title: isChinese ? "EdgeEver 页面无响应" : "EdgeEver page is not responding", + message: isChinese ? "页面持续无响应。你可以重新加载或先查看诊断信息。" : "The page remains unresponsive. You can reload it or review diagnostics first.", + buttons: isChinese ? ["报告到 GitHub", "重新加载", "继续等待"] : ["Report to GitHub", "Reload", "Keep waiting"], + defaultId: 1, + cancelId: 2, + }); + if (result.response === 0) await openDesktopDiagnosticIssue({ kind: "renderer-unresponsive" }); + if ((result.response === 0 || result.response === 1) && mainWindow && !mainWindow.isDestroyed()) { + armRendererStartupGuard(); + mainWindow.webContents.reload(); + } + } finally { + rendererUnresponsiveDialogOpen = false; + } +}; + +const showMainStartupFailure = async (error) => { + const message = String(error?.message || error).slice(0, 2000); + await writeDiagnostic("main.startup-failed", { message, stack: error?.stack }); + const isChinese = app.getLocale().toLowerCase().startsWith("zh"); + const options = { + type: "error", + title: isChinese ? "EdgeEver 无法启动" : "EdgeEver could not start", + message: isChinese ? "桌面应用启动失败。错误已经写入诊断日志。" : "The desktop application failed to start. The error was written to the diagnostic log.", + detail: `${message}\n\n${logPath()}`, + buttons: isChinese ? ["打开日志位置", "关闭"] : ["Show log", "Close"], + defaultId: 0, + cancelId: 1, + }; + const result = mainWindow && !mainWindow.isDestroyed() + ? await dialog.showMessageBox(mainWindow, options) + : await dialog.showMessageBox(options); + if (result.response === 0) shell.showItemInFolder(logPath()); + app.quit(); +}; + const execFileAsync = (command, argumentsList) => new Promise((resolve, reject) => { execFile(command, argumentsList, { encoding: "utf8" }, (error, stdout, stderr) => { if (error) { @@ -781,24 +835,37 @@ const startSidecar = async (accountId = null) => { const migrationsPath = app.isPackaged ? join(process.resourcesPath, "migrations") : join(projectRoot, "migrations"); sidecarScopeKey = accountScopeKey(configuredApiBaseUrl, accountId); activeAccountId = accountId; - sidecarProcess = spawn(sidecarPath, ["--data-dir", sidecarDataDirectory(accountId), "--migrations-dir", migrationsPath], { + const spawnedProcess = spawn(sidecarPath, ["--data-dir", sidecarDataDirectory(accountId), "--migrations-dir", migrationsPath], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); - sidecarProcess.stderr.on("data", (chunk) => { + sidecarProcess = spawnedProcess; + let spawnedSuccessfully = false; + spawnedProcess.stderr.on("data", (chunk) => { const message = chunk.toString().trimEnd(); console.error(`[sidecar] ${message}`); void writeDiagnostic("sidecar.stderr", { message }); }); - const processForExitHandler = sidecarProcess; - sidecarProcess.on("exit", (code, signal) => { - void writeDiagnostic("sidecar.exit", { code, signal }); - if (sidecarProcess !== processForExitHandler || isQuitting) return; + spawnedProcess.on("error", (error) => { + void writeDiagnostic("sidecar.spawn-error", { message: error.message, code: error.code }); + if (sidecarProcess !== spawnedProcess) return; sidecarProcess = null; sidecar = null; - scheduleSidecarRestart(); + if (spawnedSuccessfully && !isQuitting) scheduleSidecarRestart(); }); - sidecar = new SidecarRpcClient(sidecarProcess); + spawnedProcess.on("exit", (code, signal) => { + void writeDiagnostic("sidecar.exit", { code, signal }); + if (sidecarProcess !== spawnedProcess || isQuitting) return; + sidecarProcess = null; + sidecar = null; + if (spawnedSuccessfully) scheduleSidecarRestart(); + }); + await waitForChildProcessSpawn(spawnedProcess); + spawnedSuccessfully = true; + if (spawnedProcess.exitCode !== null || sidecarProcess !== spawnedProcess) { + throw new Error(`EdgeEver sidecar exited during startup (${spawnedProcess.exitCode ?? "unknown"})`); + } + sidecar = new SidecarRpcClient(spawnedProcess); return sidecar; }; @@ -900,11 +967,20 @@ const createWindow = async () => { void writeDiagnostic("renderer.loaded", { url: mainWindow?.webContents.getURL() || "" }); }); mainWindow.webContents.on("render-process-gone", (_event, details) => { + clearRendererUnresponsiveTimer(); void writeDiagnostic("renderer.gone", details); void handleRendererProcessGone(details); }); - mainWindow.webContents.on("unresponsive", () => { void writeDiagnostic("renderer.unresponsive"); }); - mainWindow.webContents.on("responsive", () => { void writeDiagnostic("renderer.responsive"); }); + mainWindow.webContents.on("unresponsive", () => { + void writeDiagnostic("renderer.unresponsive"); + if (!rendererUnresponsiveTimer && !rendererUnresponsiveDialogOpen) { + rendererUnresponsiveTimer = setTimeout(() => { void showRendererUnresponsive(); }, 5_000); + } + }); + mainWindow.webContents.on("responsive", () => { + clearRendererUnresponsiveTimer(); + void writeDiagnostic("renderer.responsive"); + }); try { if (app.isPackaged && !process.env.EDGE_EVER_DESKTOP_WEB_URL) { @@ -961,7 +1037,7 @@ const confirmMacInstallation = async () => { void writeDiagnostic("installation.confirmed"); }; -app.whenReady().then(async () => { +const startApplication = async () => { applyMacDockIcon(); if (app.isPackaged && isMountedInstallerPath(app.getAppPath())) { const isChinese = app.getLocale().toLowerCase().startsWith("zh"); @@ -1013,7 +1089,10 @@ app.whenReady().then(async () => { await writeFile(crashMarkerPath(), new Date().toISOString()); session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); registerResourceProtocol(); - await startSidecar(); + const initialSidecar = await startSidecar(); + if (!initialSidecar) throw new Error("EdgeEver sidecar is unavailable"); + await initialSidecar.waitUntilReady(); + void writeDiagnostic("sidecar.ready", { scope: sidecarScopeKey }); createTray(); ipcMain.on("desktop:local-data-reset-available-sync", (event) => { @@ -1255,6 +1334,13 @@ app.whenReady().then(async () => { if (!showWindow(mainWindow)) void createWindow(); void checkForDesktopUpdate("activate"); }); +}; + +void app.whenReady().then(startApplication).catch((error) => { + void showMainStartupFailure(error).catch((dialogError) => { + console.error("Failed to show the desktop startup error", dialogError); + app.quit(); + }); }); app.on("open-file", (event, filePath) => { @@ -1276,6 +1362,8 @@ app.on("before-quit", (event) => { event.preventDefault(); shutdownCleanupStarted = true; isQuitting = true; + rendererStartupGuard?.complete(); + clearRendererUnresponsiveTimer(); if (sidecarRestartTimer) { clearTimeout(sidecarRestartTimer); sidecarRestartTimer = null; diff --git a/apps/web/src/components/DesktopRendererErrorBoundary.tsx b/apps/web/src/components/DesktopRendererErrorBoundary.tsx index 87c77339..ba268e21 100644 --- a/apps/web/src/components/DesktopRendererErrorBoundary.tsx +++ b/apps/web/src/components/DesktopRendererErrorBoundary.tsx @@ -1,5 +1,6 @@ import React, { type ErrorInfo, type ReactNode } from "react"; import { markRendererRecoveryRequired } from "@/lib/renderer-recovery"; +import { reportDesktopRendererReadyAfterPaint } from "@/lib/desktop-renderer-ready"; type RendererErrorDetails = { kind: "react-error"; @@ -41,6 +42,7 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re this.setState({ error: details }); console.error("Caught React renderer error", error, info.componentStack); void window.edgeeverDesktop?.recordRendererError(details).catch(() => undefined); + reportDesktopRendererReadyAfterPaint(); } private report = async () => { diff --git a/apps/web/src/lib/desktop-renderer-ready.test.mjs b/apps/web/src/lib/desktop-renderer-ready.test.mjs new file mode 100644 index 00000000..09d25305 --- /dev/null +++ b/apps/web/src/lib/desktop-renderer-ready.test.mjs @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { reportDesktopRendererReadyAfterPaint } from "./desktop-renderer-ready.ts"; + +const originalWindow = globalThis.window; +const originalDocument = globalThis.document; + +afterEach(() => { + globalThis.window = originalWindow; + globalThis.document = originalDocument; +}); + +describe("desktop renderer readiness", () => { + test("reports readiness only after UI exists across two animation frames", () => { + const frames = []; + let reports = 0; + globalThis.window = { + edgeeverDesktop: { rendererBootstrapReady: () => { reports += 1; } }, + requestAnimationFrame: (callback) => { frames.push(callback); return frames.length; }, + cancelAnimationFrame: () => {}, + }; + globalThis.document = { + documentElement: { dataset: {} }, + querySelector: (selector) => selector === "#root > *" ? {} : null, + }; + + reportDesktopRendererReadyAfterPaint(); + expect(reports).toBe(0); + frames.shift()(16); + expect(reports).toBe(0); + frames.shift()(32); + + expect(reports).toBe(1); + expect(document.documentElement.dataset.edgeeverRendererReady).toBe("true"); + }); +}); diff --git a/apps/web/src/lib/desktop-renderer-ready.ts b/apps/web/src/lib/desktop-renderer-ready.ts new file mode 100644 index 00000000..9761fb53 --- /dev/null +++ b/apps/web/src/lib/desktop-renderer-ready.ts @@ -0,0 +1,22 @@ +let rendererReadyReported = false; + +export const reportDesktopRendererReadyAfterPaint = () => { + if (rendererReadyReported || typeof window === "undefined") return () => {}; + + let cancelled = false; + let secondFrame = 0; + const firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => { + if (cancelled || rendererReadyReported || !document.querySelector("#root > *")) return; + rendererReadyReported = true; + document.documentElement.dataset.edgeeverRendererReady = "true"; + window.edgeeverDesktop?.rendererBootstrapReady(); + }); + }); + + return () => { + cancelled = true; + window.cancelAnimationFrame(firstFrame); + if (secondFrame) window.cancelAnimationFrame(secondFrame); + }; +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ad1902c4..5a3473ae 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import React from "react"; +import React, { useEffect } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter, HashRouter } from "react-router"; import { registerSW } from "virtual:pwa-register"; @@ -9,12 +9,18 @@ import { emitPwaUpdateNotice } from "./lib/pwa-update-notice"; import { withEnvironmentTitlePrefix } from "./lib/environment-title"; import { initializeTheme, ThemeProvider } from "./components/ThemeProvider"; import { DesktopRendererErrorBoundary } from "./components/DesktopRendererErrorBoundary"; +import { reportDesktopRendererReadyAfterPaint } from "./lib/desktop-renderer-ready"; 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; +const DesktopBootstrapReady = () => { + useEffect(() => reportDesktopRendererReadyAfterPaint(), []); + return null; +}; + if (import.meta.env.DEV) { if (__EDGEEVER_DEVELOPMENT_PROFILE__) { document.documentElement.dataset.edgeeverEnvironment = __EDGEEVER_DEVELOPMENT_PROFILE__; @@ -116,6 +122,7 @@ const mountApp = () => { }).render( + @@ -126,7 +133,6 @@ const mountApp = () => { ); - queueMicrotask(() => window.edgeeverDesktop?.rendererBootstrapReady()); }; const bootstrap = async () => { diff --git a/package.json b/package.json index 74398d0b..64645e91 100644 --- a/package.json +++ b/package.json @@ -54,12 +54,13 @@ "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run prepare:desktop:icons && bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", "verify:web-performance": "bun scripts/verify-web-performance.mjs", - "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/windows-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", + "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/windows-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/pe-imports.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", "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/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", + "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/child-process-start.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-renderer-ready.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:packaged-desktop-startup": "bun scripts/verify-packaged-desktop-startup.mjs", "verify:desktop-package": "bun scripts/verify-desktop-package.mjs", "build:worker": "bun scripts/build-cloudflare-worker.mjs", "build:cloudflare": "bun run typecheck && bun run build && bun run build:worker", diff --git a/scripts/desktop-build-workflow.test.mjs b/scripts/desktop-build-workflow.test.mjs index 5352f853..d7bd496d 100644 --- a/scripts/desktop-build-workflow.test.mjs +++ b/scripts/desktop-build-workflow.test.mjs @@ -4,6 +4,8 @@ import { describe, expect, test } from "bun:test"; const workflow = readFileSync(new URL("../.github/workflows/desktop-build.yml", import.meta.url), "utf8"); const mobileWorkflow = readFileSync(new URL("../.github/workflows/mobile-build.yml", import.meta.url), "utf8"); const desktopPackageVerifier = readFileSync(new URL("./verify-desktop-package.mjs", import.meta.url), "utf8"); +const packagedStartupVerifier = readFileSync(new URL("./verify-packaged-desktop-startup.mjs", import.meta.url), "utf8"); +const cargoConfig = readFileSync(new URL("../.cargo/config.toml", import.meta.url), "utf8"); function step(name) { const start = workflow.indexOf(` - name: ${name}\n`); @@ -83,6 +85,13 @@ describe("desktop release workflow", () => { expect(workflow).toContain("allow-missing-windows-signature"); expect(workflow).toContain("name: Audit signed Windows update"); expect(workflow).toContain("verify-windows-update-release.mjs"); + expect(workflow).toContain("name: Run packaged Windows sidecar integration tests"); + expect(workflow).toContain("name: Verify packaged Windows first launch"); + expect(workflow).toContain("verify:packaged-desktop-startup"); + expect(packagedStartupVerifier).toContain('new Set(["sidecar.ready", "renderer.bootstrap-ready"])'); + expect(desktopPackageVerifier).toContain("isVisualCppRuntimeDll"); + expect(cargoConfig).toContain('target.x86_64-pc-windows-msvc'); + expect(cargoConfig).toContain('target-feature=+crt-static'); expect(desktopPackageVerifier).toContain( 'path.replaceAll("\\\\", "/")', ); diff --git a/scripts/pe-imports.mjs b/scripts/pe-imports.mjs new file mode 100644 index 00000000..19feec1a --- /dev/null +++ b/scripts/pe-imports.mjs @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; + +const readCString = (bytes, offset, maxLength = 260) => { + const endLimit = Math.min(bytes.length, offset + maxLength); + let end = offset; + while (end < endLimit && bytes[end] !== 0) end += 1; + if (end === endLimit) throw new Error("PE import name is not null-terminated"); + return bytes.toString("ascii", offset, end); +}; + +export const readPeImportedDlls = (input) => { + const bytes = Buffer.isBuffer(input) ? input : readFileSync(input); + if (bytes.length < 0x40 || bytes.toString("ascii", 0, 2) !== "MZ") throw new Error("Invalid PE DOS header"); + + const peOffset = bytes.readUInt32LE(0x3c); + if (peOffset + 24 > bytes.length || bytes.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") { + throw new Error("Invalid PE signature"); + } + + const coffOffset = peOffset + 4; + const sectionCount = bytes.readUInt16LE(coffOffset + 2); + const optionalHeaderSize = bytes.readUInt16LE(coffOffset + 16); + const optionalHeaderOffset = coffOffset + 20; + const optionalHeaderMagic = bytes.readUInt16LE(optionalHeaderOffset); + const dataDirectoryOffset = optionalHeaderOffset + (optionalHeaderMagic === 0x20b ? 112 : optionalHeaderMagic === 0x10b ? 96 : 0); + if (!dataDirectoryOffset || dataDirectoryOffset + 16 > bytes.length) throw new Error("Unsupported PE optional header"); + + const importTableRva = bytes.readUInt32LE(dataDirectoryOffset + 8); + const importTableSize = bytes.readUInt32LE(dataDirectoryOffset + 12); + if (!importTableRva || !importTableSize) return []; + + const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize; + const sections = []; + for (let index = 0; index < sectionCount; index += 1) { + const offset = sectionTableOffset + index * 40; + if (offset + 40 > bytes.length) throw new Error("PE section table is truncated"); + sections.push({ + virtualSize: bytes.readUInt32LE(offset + 8), + virtualAddress: bytes.readUInt32LE(offset + 12), + rawSize: bytes.readUInt32LE(offset + 16), + rawOffset: bytes.readUInt32LE(offset + 20), + }); + } + + const fileOffsetFromRva = (rva) => { + const section = sections.find(({ virtualAddress, virtualSize, rawSize }) => + rva >= virtualAddress && rva < virtualAddress + Math.max(virtualSize, rawSize)); + if (!section) throw new Error(`PE RVA is outside every section: 0x${rva.toString(16)}`); + const offset = section.rawOffset + (rva - section.virtualAddress); + if (offset >= bytes.length) throw new Error("PE RVA points outside the file"); + return offset; + }; + + const imports = []; + let descriptorOffset = fileOffsetFromRva(importTableRva); + const descriptorLimit = Math.min(bytes.length, descriptorOffset + importTableSize); + while (descriptorOffset + 20 <= descriptorLimit) { + const originalFirstThunk = bytes.readUInt32LE(descriptorOffset); + const nameRva = bytes.readUInt32LE(descriptorOffset + 12); + const firstThunk = bytes.readUInt32LE(descriptorOffset + 16); + if (originalFirstThunk === 0 && nameRva === 0 && firstThunk === 0) break; + if (!nameRva) throw new Error("PE import descriptor has no DLL name"); + imports.push(readCString(bytes, fileOffsetFromRva(nameRva))); + descriptorOffset += 20; + } + return imports; +}; + +export const isVisualCppRuntimeDll = (name) => /^(?:vcruntime|msvcp|concrt)\d[^/\\]*\.dll$/i.test(name); diff --git a/scripts/pe-imports.test.mjs b/scripts/pe-imports.test.mjs new file mode 100644 index 00000000..bb09df16 --- /dev/null +++ b/scripts/pe-imports.test.mjs @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { isVisualCppRuntimeDll, readPeImportedDlls } from "./pe-imports.mjs"; + +const peWithImport = (dllName) => { + const bytes = Buffer.alloc(0x400); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt32LE(0x80, 0x3c); + bytes.write("PE\0\0", 0x80, "ascii"); + const coff = 0x84; + bytes.writeUInt16LE(0x8664, coff); + bytes.writeUInt16LE(1, coff + 2); + bytes.writeUInt16LE(0xf0, coff + 16); + const optional = coff + 20; + bytes.writeUInt16LE(0x20b, optional); + bytes.writeUInt32LE(16, optional + 108); + bytes.writeUInt32LE(0x1000, optional + 120); + bytes.writeUInt32LE(40, optional + 124); + const section = optional + 0xf0; + bytes.write(".rdata", section, "ascii"); + bytes.writeUInt32LE(0x200, section + 8); + bytes.writeUInt32LE(0x1000, section + 12); + bytes.writeUInt32LE(0x200, section + 16); + bytes.writeUInt32LE(0x200, section + 20); + bytes.writeUInt32LE(0x1040, 0x200); + bytes.writeUInt32LE(0x1080, 0x20c); + bytes.writeUInt32LE(0x1040, 0x210); + bytes.write(`${dllName}\0`, 0x280, "ascii"); + return bytes; +}; + +describe("PE imports", () => { + test("reads imported DLL names from a PE32+ image", () => { + expect(readPeImportedDlls(peWithImport("VCRUNTIME140.dll"))).toEqual(["VCRUNTIME140.dll"]); + }); + + test("identifies Visual C++ runtime dependencies", () => { + expect(isVisualCppRuntimeDll("VCRUNTIME140.dll")).toBe(true); + expect(isVisualCppRuntimeDll("MSVCP140_1.dll")).toBe(true); + expect(isVisualCppRuntimeDll("KERNEL32.dll")).toBe(false); + }); +}); diff --git a/scripts/plan-native-release.mjs b/scripts/plan-native-release.mjs index 0f6f14aa..bdbb8d80 100644 --- a/scripts/plan-native-release.mjs +++ b/scripts/plan-native-release.mjs @@ -48,16 +48,19 @@ export const planNativeRelease = (platform, changedFiles) => { ...mobileOnlyDependencyPatches, ]) : new Set([ + ".cargo/config.toml", ".github/workflows/desktop-build.yml", "bun.lock", "scripts/create-mac-update-metadata.mjs", "scripts/create-windows-update-metadata.mjs", "scripts/desktop-icns.mjs", "scripts/prepare-desktop-icons.mjs", + "scripts/pe-imports.mjs", "scripts/run-desktop-builder.mjs", "scripts/sign-windows-update-manifest.mjs", "scripts/verify-windows-update-release.mjs", "scripts/verify-desktop-package.mjs", + "scripts/verify-packaged-desktop-startup.mjs", ]); const relevantChanges = runtimeChangedFiles.filter( diff --git a/scripts/plan-native-release.test.mjs b/scripts/plan-native-release.test.mjs index 09b875f7..1a219435 100644 --- a/scripts/plan-native-release.test.mjs +++ b/scripts/plan-native-release.test.mjs @@ -69,14 +69,17 @@ describe("native release planning", () => { test("rebuilds desktop when its architecture packaging pipeline changes", () => { const changedFiles = [ + ".cargo/config.toml", ".github/workflows/desktop-build.yml", "scripts/create-mac-update-metadata.mjs", "scripts/create-windows-update-metadata.mjs", "scripts/prepare-desktop-icons.mjs", "scripts/desktop-icns.mjs", + "scripts/pe-imports.mjs", "scripts/run-desktop-builder.mjs", "scripts/sign-windows-update-manifest.mjs", "scripts/verify-windows-update-release.mjs", + "scripts/verify-packaged-desktop-startup.mjs", ]; expect(planNativeRelease("desktop", changedFiles)).toEqual({ rebuild: true, diff --git a/scripts/verify-desktop-package.mjs b/scripts/verify-desktop-package.mjs index 45f5d922..1e56888c 100644 --- a/scripts/verify-desktop-package.mjs +++ b/scripts/verify-desktop-package.mjs @@ -4,6 +4,7 @@ import { existsSync, openSync, closeSync, readFileSync, readSync, readdirSync, r import { basename, join } from "node:path"; import { listPackage } from "@electron/asar"; import { assertMacIcnsComplete } from "./desktop-icns.mjs"; +import { isVisualCppRuntimeDll, readPeImportedDlls } from "./pe-imports.mjs"; const outputDirectory = join(process.cwd(), "release", "desktop"); const version = JSON.parse( @@ -101,6 +102,12 @@ if (requestedPlatform === "darwin") { assert.ok(existsSync(join(resources, "migrations")), "Windows app bundle is missing migrations"); verifyPeX64(executable, "Electron executable"); verifyPeX64(sidecar, "Rust sidecar"); + const sidecarRuntimeImports = readPeImportedDlls(sidecar).filter(isVisualCppRuntimeDll); + assert.deepEqual( + sidecarRuntimeImports, + [], + `Windows sidecar must statically link the Visual C++ runtime; found: ${sidecarRuntimeImports.join(", ")}`, + ); const asarPath = join(resources, "app.asar"); assert.ok(existsSync(asarPath), `Windows app bundle is missing app.asar: ${asarPath}`); const asarFiles = listAsarFiles(asarPath); diff --git a/scripts/verify-desktop-renderer.mjs b/scripts/verify-desktop-renderer.mjs index 881d3313..e26a2dc5 100644 --- a/scripts/verify-desktop-renderer.mjs +++ b/scripts/verify-desktop-renderer.mjs @@ -49,7 +49,7 @@ try { await verifyEntry({ filePath: entryPath, - readySelector: "#root > *", + readySelector: '[data-edgeever-renderer-ready="true"] #root > *', label: "Desktop renderer", }); await verifyEntry({ diff --git a/scripts/verify-packaged-desktop-startup.mjs b/scripts/verify-packaged-desktop-startup.mjs new file mode 100644 index 00000000..bed7adce --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.mjs @@ -0,0 +1,74 @@ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const requestedExecutablePath = process.argv[2]?.trim(); +const executablePath = requestedExecutablePath ? resolve(requestedExecutablePath) : ""; +if (!executablePath || !existsSync(executablePath) || !statSync(executablePath).isFile()) { + throw new Error(`Packaged desktop executable is missing: ${executablePath || ""}`); +} + +const userDataDirectory = mkdtempSync(join(tmpdir(), "edgeever-packaged-startup-")); +const diagnosticLogPath = join(userDataDirectory, "logs", "desktop.log"); +const output = []; +let processError = null; +const child = spawn(executablePath, [`--user-data-dir=${userDataDirectory}`], { + env: { + ...process.env, + EDGE_EVER_DISABLE_AUTO_UPDATE: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, +}); +child.on("error", (error) => { processError = error; }); +child.stdout.on("data", (chunk) => output.push(chunk.toString())); +child.stderr.on("data", (chunk) => output.push(chunk.toString())); + +const timeoutMs = Number(process.env.EDGE_EVER_DESKTOP_STARTUP_TIMEOUT_MS) || 45_000; +const deadline = Date.now() + timeoutMs; +let startupReady = false; +let lastLog = ""; +const requiredEvents = new Set(["sidecar.ready", "renderer.bootstrap-ready"]); +const observedEvents = new Set(); + +try { + while (Date.now() < deadline) { + if (existsSync(diagnosticLogPath)) { + lastLog = readFileSync(diagnosticLogPath, "utf8"); + for (const line of lastLog.split(/\r?\n/)) { + try { + const event = JSON.parse(line).event; + if (typeof event === "string") observedEvents.add(event); + } catch { /* Ignore a partial trailing log line. */ } + } + if ([...requiredEvents].every((event) => observedEvents.has(event))) { + startupReady = true; + break; + } + } + if (processError || child.exitCode !== null) break; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + + if (!startupReady) { + throw new Error([ + `Packaged desktop did not report renderer readiness within ${timeoutMs}ms (exit=${child.exitCode ?? "running"}).`, + `Missing events: ${[...requiredEvents].filter((event) => !observedEvents.has(event)).join(", ") || "none"}`, + processError && `Process error: ${processError.message}`, + lastLog && `Diagnostic log:\n${lastLog}`, + output.length > 0 && `Process output:\n${output.join("")}`, + ].filter(Boolean).join("\n\n")); + } + + console.log(JSON.stringify({ ok: true, executablePath, events: [...requiredEvents] })); +} finally { + if (child.exitCode === null && child.pid) { + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); + } else { + child.kill("SIGTERM"); + } + } + rmSync(userDataDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}