fix(desktop): harden Windows startup validation
This commit is contained in:
1 parent
10b50829ee
commit
263fafd433
19 files changed
+437
-17
No files matched your search
@@ -0,0 +1,2 @@
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+100
-12
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<DesktopRendererErrorBoundary>
|
||||
<DesktopBootstrapReady />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
@@ -126,7 +133,6 @@ const mountApp = () => {
|
||||
</DesktopRendererErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
queueMicrotask(() => window.edgeeverDesktop?.rendererBootstrapReady());
|
||||
};
|
||||
|
||||
const bootstrap = async () => {
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
@@ -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("\\\\", "/")',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -49,7 +49,7 @@ try {
|
||||
|
||||
await verifyEntry({
|
||||
filePath: entryPath,
|
||||
readySelector: "#root > *",
|
||||
readySelector: '[data-edgeever-renderer-ready="true"] #root > *',
|
||||
label: "Desktop renderer",
|
||||
});
|
||||
await verifyEntry({
|
||||
|
||||
@@ -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 || "<not provided>"}`);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in new issue
Block a user