diff --git a/e2e/boot.spec.ts b/e2e/boot.spec.ts new file mode 100644 index 000000000..64e400eb3 --- /dev/null +++ b/e2e/boot.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test"; + +test("a module evaluation failure replaces the splash with copyable details", async ({ page }) => { + await page.route("**/src/main.tsx", (route) => + route.fulfill({ contentType: "text/javascript", body: 'throw new Error("Simulated startup failure");' }), + ); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "OpenIDEA could not start" })).toBeVisible(); + await expect(page.locator("#splash")).toHaveCount(0); + await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(/Simulated startup failure/); + await expect(page.getByRole("button", { name: "Copy details" })).toBeFocused(); + await expect(page.getByRole("link", { name: "Report an issue" })).toHaveAttribute("href", /github.com/); +}); + +test("an unresolved module gets a startup timeout", async ({ page }) => { + await page.clock.install(); + await page.route("**/src/main.tsx", (route) => + route.fulfill({ contentType: "text/javascript", body: "await new Promise(() => {});" }), + ); + await page.goto("/", { waitUntil: "commit" }); + await page.locator("#splash").waitFor({ state: "attached" }); + await page.clock.runFor(30_000); + await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue( + /Startup did not finish within 30 seconds/, + ); +}); diff --git a/package.json b/package.json index 1fb159a4a..722a52f21 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build && node scripts/check-webkit.ts", "preview": "vite preview", "tauri": "node scripts/tauri.ts", "test": "vitest run", diff --git a/public/boot.js b/public/boot.js index 6f81c4e8b..81e9f6506 100644 --- a/public/boot.js +++ b/public/boot.js @@ -1,4 +1,115 @@ -/* global document, localStorage, window */ +/* global document, localStorage, navigator, window */ +// Keep this independent of the module graph: a dependency can fail before main.tsx runs. +(() => { + const zh = navigator.language.toLowerCase().startsWith("zh"); + const text = zh + ? { + title: "OpenIDEA 无法启动", + report: "请复制下方详情,并附在 GitHub 问题中。", + link: "报告问题", + copy: "复制详情", + copied: "已复制", + manual: "无法复制。请选择下方详情并手动复制。", + details: "启动错误详情", + timeout: "启动未在 30 秒内完成。", + } + : { + title: "OpenIDEA could not start", + report: "Copy the details below and include them in a GitHub issue.", + link: "Report an issue", + copy: "Copy details", + copied: "Copied", + manual: "Could not copy. Select the details below and copy them.", + details: "Startup error details", + timeout: "Startup did not finish within 30 seconds.", + }; + let failed = false; + let ready = false; + const describe = (reason) => { + try { + if (typeof reason?.stack === "string") { + const message = String(reason); + return reason.stack.includes(message) ? reason.stack : `${message}\n${reason.stack}`; + } + return (typeof reason === "string" ? reason : JSON.stringify(reason)) || String(reason); + } catch { + return "Unknown startup error"; + } + }; + const show = (reason) => { + if (failed || ready) return; + failed = true; + window.clearTimeout(timer); + document.documentElement.dataset.boot = "failed"; + const render = () => { + const panel = document.createElement("section"); + panel.id = "boot-error"; + panel.className = "splash boot-error"; + panel.setAttribute("role", "alert"); + const title = document.createElement("h1"); + title.textContent = text.title; + const hint = document.createElement("p"); + hint.textContent = text.report; + const details = document.createElement("textarea"); + details.readOnly = true; + details.setAttribute("aria-label", text.details); + details.value = `OpenIDEA startup failure\n${navigator.userAgent}\n\n${describe(reason)}`; + const actions = document.createElement("div"); + actions.className = "boot-error-actions"; + const copy = document.createElement("button"); + copy.type = "button"; + copy.textContent = text.copy; + const status = document.createElement("p"); + status.setAttribute("role", "status"); + copy.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(details.value); + status.textContent = text.copied; + } catch { + details.focus(); + details.select(); + status.textContent = text.manual; + } + }); + const report = document.createElement("a"); + report.href = "https://github.com/l0ng-ai/openidea/issues/new?template=bug_report.yml"; + report.target = "_blank"; + report.rel = "noreferrer"; + report.textContent = text.link; + actions.append(copy, report); + panel.append(title, hint, details, actions, status); + document.getElementById("splash")?.remove(); + document.body.appendChild(panel); + copy.focus(); + }; + if (document.body) render(); + else document.addEventListener("DOMContentLoaded", render, { once: true }); + }; + const onError = (event) => { + const target = event.target; + if (target?.tagName === "SCRIPT" || (target?.tagName === "LINK" && target.rel === "modulepreload")) { + show(`Could not load startup script: ${target.src || target.href}`); + } else if (event.error || event.message) { + show(event.error || `${event.message}\n${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`); + } + }; + const onRejection = (event) => show(event.reason); + const onBootError = (event) => show(event.detail); + const onReady = () => { + ready = true; + window.clearTimeout(timer); + window.removeEventListener("error", onError, true); + window.removeEventListener("unhandledrejection", onRejection); + window.removeEventListener("openidea:boot-error", onBootError); + window.removeEventListener("openidea:boot-ready", onReady); + }; + const timer = window.setTimeout(() => show(text.timeout), 30_000); + window.addEventListener("error", onError, true); + window.addEventListener("unhandledrejection", onRejection); + window.addEventListener("openidea:boot-error", onBootError); + window.addEventListener("openidea:boot-ready", onReady); +})(); + // Runs before the app's CSS loads: restores the last theme (theme.ts stores it) // so a new window starts in it instead of flashing the light default. // An external file because the CSP forbids inline scripts. diff --git a/public/splash.css b/public/splash.css index ecb40b616..349fcd0c9 100644 --- a/public/splash.css +++ b/public/splash.css @@ -67,10 +67,10 @@ body, var(--splash-progress-easing, cubic-bezier(0.1, 0.7, 0.3, 1)) both; } /* Settings window: a dialog, not a launch. Only the bar, and only if loading takes a while. */ -:root[data-window="settings"] .splash { +:root[data-window="settings"] .splash:not(.boot-error) { animation-delay: 400ms; } -:root[data-window="settings"] .splash > :not(.splash-bar) { +:root[data-window="settings"] .splash:not(.boot-error) > :not(.splash-bar) { display: none; } @keyframes splash-in { @@ -92,3 +92,63 @@ body, animation: none; } } + +.splash.boot-error { + align-items: stretch; + justify-content: center; + gap: 12px; + padding: 32px max(24px, calc((100vw - 560px) / 2)); + overflow: auto; + animation: none; + font: var(--fs-ui, 13px) / 1.5 var(--font-ui, system-ui, sans-serif); + -webkit-user-select: text; + user-select: text; +} +.boot-error h1 { + margin: 0; + color: var(--fg, inherit); + font-size: 20px; + font-weight: 600; +} +.boot-error p { + margin: 0; +} +.boot-error textarea { + box-sizing: border-box; + width: 100%; + min-height: 140px; + height: 180px; + padding: 12px; + resize: vertical; + border: 1px solid var(--border-strong, currentColor); + border-radius: var(--radius-control, 4px); + background: var(--bg-input, transparent); + color: inherit; + font: 12px / 1.5 var(--font-mono, monospace); +} +.boot-error-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; +} +.boot-error button { + padding: 6px 12px; + border: 1px solid var(--border-strong, currentColor); + border-radius: var(--radius-control, 4px); + background: var(--bg-elev, transparent); + color: inherit; + font: inherit; + cursor: pointer; +} +.boot-error button:hover { + background: var(--bg-hover, transparent); +} +.boot-error a { + color: var(--link, inherit); + text-underline-offset: 3px; +} +.boot-error :is(button, a, textarea):focus-visible { + outline: 2px solid var(--focus-ring, var(--boot-accent, currentColor)); + outline-offset: 2px; +} diff --git a/scripts/check-webkit.test.ts b/scripts/check-webkit.test.ts new file mode 100644 index 000000000..8c7480f0c --- /dev/null +++ b/scripts/check-webkit.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment node +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { checkBuild, checkSource } from "./check-webkit.ts"; + +it.each([ + "Promise.withResolvers()", + "Object.groupBy(items, key)", + "Array.fromAsync(items)", + "text.toWellFormed()", + 'new RegExp("[a-z]", "v")', + 'RegExp("(?<=a)b")', + "/[a-z]/v", + "/(? { + expect(checkSource(source, true).length).toBeGreaterThan(0); +}); + +it("does not mistake regex detection strings or named groups for newer syntax", () => { + expect(checkSource('const message = "(?<=a)"; const regex = /(?a)/u;', true)).toEqual([]); + expect(checkSource("const regex = /(\\(\\?<=)|(\\(\\? { + const dir = mkdtempSync(join(tmpdir(), "openidea-webkit-")); + try { + mkdirSync(join(dir, "assets")); + writeFileSync(join(dir, "index.html"), ''); + writeFileSync(join(dir, "boot.js"), ""); + writeFileSync(join(dir, "assets/entry.js"), 'import "./dependency.js"; import("./lazy.js");'); + writeFileSync(join(dir, "assets/dependency.js"), "Promise.withResolvers();"); + writeFileSync(join(dir, "assets/lazy.js"), "Object.groupBy(items, key); const re = /[a-z]/v;"); + expect(checkBuild(dir)).toEqual([ + "assets/dependency.js: Promise.withResolvers requires newer WebKit", + "assets/lazy.js: RegExp v flag requires Safari 17", + ]); + } finally { + rmSync(dir, { recursive: true }); + } +}); diff --git a/scripts/check-webkit.ts b/scripts/check-webkit.ts new file mode 100644 index 000000000..a3f43d7cc --- /dev/null +++ b/scripts/check-webkit.ts @@ -0,0 +1,89 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import ts from "typescript"; + +// macOS 11 can update to Safari 16. Keep this floor in sync with vite.config.ts. +// Syntax lowering does not replace newer runtime APIs in dependencies. +const newerStatics = new Set([ + "Promise.withResolvers", + "Object.groupBy", + "Map.groupBy", + "Array.fromAsync", + "Atomics.waitAsync", + "CSS.registerProperty", + "Uint8Array.fromBase64", + "Uint8Array.fromHex", +]); +const newerMethods = new Set(["isWellFormed", "toWellFormed"]); + +export function checkSource(source: string, startup: boolean): string[] { + const tree = ts.createSourceFile("bundle.js", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + const errors = new Set(); + const checkRegex = (pattern: string, flags: string) => { + if (flags.includes("v")) errors.add("RegExp v flag requires Safari 17"); + if (/\(\?<([=!])/.test(pattern)) errors.add("RegExp lookbehind requires Safari 16.4"); + }; + const walk = (node: ts.Node) => { + if (ts.isRegularExpressionLiteral(node)) { + const end = node.text.lastIndexOf("/"); + checkRegex(node.text.slice(1, end), node.text.slice(end + 1)); + } + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.attributes) { + errors.add("Import attributes require newer WebKit"); + } + if (startup && ts.isPropertyAccessExpression(node)) { + const name = node.getText(tree); + if (newerStatics.has(name) || newerMethods.has(node.name.text)) errors.add(`${name} requires newer WebKit`); + } + if ( + startup && + (ts.isNewExpression(node) || ts.isCallExpression(node)) && + node.expression.getText(tree) === "RegExp" && + node.arguments?.[0] && + ts.isStringLiteralLike(node.arguments[0]) + ) { + const flags = node.arguments[1]; + checkRegex(node.arguments[0].text, flags && ts.isStringLiteralLike(flags) ? flags.text : ""); + } + ts.forEachChild(node, walk); + }; + walk(tree); + return [...errors]; +} + +export function checkBuild(dir: string): string[] { + const entry = readFileSync(resolve(dir, "index.html"), "utf8").match(/" })); + document.querySelector("button")?.click(); + await vi.waitFor(() => expect(document.querySelector('[role="status"]')?.textContent).toContain("Select")); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("")); + expect(document.querySelector("#boot-error script")).toBeNull(); + expect(document.activeElement).toBe(document.querySelector("textarea")); +}); + +it("uses Chinese boot text without loading the application catalog", () => { + window.dispatchEvent(new Event("openidea:boot-ready")); + vi.spyOn(navigator, "language", "get").mockReturnValue("zh-CN"); + new Function(boot)(); + window.dispatchEvent(new ErrorEvent("error", { message: "failure" })); + expect(document.querySelector('[role="alert"]')?.textContent).toContain("OpenIDEA 无法启动"); +}); + +it("confirms a successful copy", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + window.dispatchEvent(new ErrorEvent("error", { message: "startup failed" })); + document.querySelector("button")?.click(); + await vi.waitFor(() => expect(document.querySelector('[role="status"]')?.textContent).toBe("Copied")); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("startup failed")); +}); + +it("defers an early script failure until the document body exists", () => { + const body = document.body; + body.remove(); + window.dispatchEvent(new ErrorEvent("error", { message: "early failure" })); + document.documentElement.append(body); + document.dispatchEvent(new Event("DOMContentLoaded")); + expect(document.querySelector("textarea")?.value).toContain("early failure"); +}); + +it("preserves Safari error messages when its stack only lists locations", () => { + const error = new SyntaxError("Unsupported regular expression"); + error.stack = "global code@tauri://localhost/assets/entry.js:1:20"; + window.dispatchEvent(new ErrorEvent("error", { error })); + expect(document.querySelector("textarea")?.value).toContain("SyntaxError: Unsupported regular expression"); + expect(document.querySelector("textarea")?.value).toContain(error.stack); +}); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 5a4d0189e..4c228228e 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,12 @@ { + "OpenIDEA could not start": "OpenIDEA could not start", + "Copy the details below and include them in a GitHub issue.": "Copy the details below and include them in a GitHub issue.", + "Report an issue": "Report an issue", + "Copy details": "Copy details", + "Copied": "Copied", + "Could not copy. Select the details below and copy them.": "Could not copy. Select the details below and copy them.", + "Startup error details": "Startup error details", + "Startup did not finish within 30 seconds.": "Startup did not finish within 30 seconds.", "this data source does not list databases": "this data source does not list databases", "commit or roll back before clearing the selected database": "commit or roll back before clearing the selected database", "connect_timeout must be greater than zero": "connect_timeout must be greater than zero", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d09ef0908..3c5d4f016 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,11 @@ { + "OpenIDEA could not start": "OpenIDEA 无法启动", + "Copy the details below and include them in a GitHub issue.": "请复制下方详情,并附在 GitHub 问题中。", + "Report an issue": "报告问题", + "Copy details": "复制详情", + "Could not copy. Select the details below and copy them.": "无法复制。请选择下方详情并手动复制。", + "Startup error details": "启动错误详情", + "Startup did not finish within 30 seconds.": "启动未在 30 秒内完成。", "this data source does not list databases": "此数据源不支持列出数据库", "commit or roll back before clearing the selected database": "清除所选数据库前,请先提交或回滚事务", "connect_timeout must be greater than zero": "connect_timeout 必须大于零", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 7a6571115..c3397e078 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -70,6 +70,8 @@ const NOTIFY = new Set(["notify"]); /** Code templates shown as written, and the in-memory backends of plain-browser development. */ const UNCHECKED_FILES = new Set([ + // Pre-module failure UI carries its own English/Chinese text when catalogs cannot load. + "public/boot.js", "src/editor/core/backend.ts", "src/features/plugins/backend.ts", "src/features/tools/backend.ts", @@ -485,7 +487,10 @@ function scanTypeScript(): Scan { const spliced: Use[] = []; const keys: Use[] = []; const literals = new Set(); - const files = walk(`${ROOT}src`, (p) => /\.tsx?$/.test(p) && !isTest(p) && !p.endsWith("/bindings.ts")); + const files = [ + ...walk(`${ROOT}src`, (p) => /\.tsx?$/.test(p) && !isTest(p) && !p.endsWith("/bindings.ts")), + `${ROOT}public/boot.js`, + ]; for (const file of files) { const path = file.slice(ROOT.length); const source = readFileSync(file, "utf8"); diff --git a/src/platform/splash.ts b/src/platform/splash.ts index 6a5b50fbe..431b22603 100644 --- a/src/platform/splash.ts +++ b/src/platform/splash.ts @@ -36,8 +36,13 @@ export function hideSplash(): void { requestAnimationFrame(() => { setTimeout( () => { + if (document.documentElement.dataset.boot === "failed") return; el.classList.add("splash-out"); - setTimeout(() => el.remove(), 200); + setTimeout(() => { + if (document.documentElement.dataset.boot === "failed") return; + el.remove(); + window.dispatchEvent(new Event("openidea:boot-ready")); + }, 200); }, completeBar(el) ? FILL_MS : 0, );