fix(desktop): replace lookbehind regex that crashed legacy WebKit at startup

Closes #8202
This commit is contained in:
t8y2 committed 2026-09-05 00:13:55 +08:00
1 parent 8b8c0b8075
commit b8becde86a
3 files changed
+95 -6

No files matched your search

@@ -10,11 +10,44 @@ export const SINGLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9_$-]+$/;
const LEADING_SEPARATOR_PATTERN = /^[_$-]+/;
const TRAILING_SEPARATOR_PATTERN = /[_$-]+$/;
const SEPARATOR_RUN_PATTERN = /[_$-]+/;
// Word boundaries inside an identifier segment: lower→Upper (userName),
// UPPER→Upper+lower (HTTPServer), and digit→letter (user2Name / sha256Hash).
// Letter→digit is deliberately NOT a boundary so digit runs stay attached to
// the preceding word (ipv4, sha256, field1).
const WORD_BOUNDARY_PATTERN = /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[0-9])(?=[A-Za-z])/;
function isLower(letter: string): boolean {
return letter >= "a" && letter <= "z";
}
function isUpper(letter: string): boolean {
return letter >= "A" && letter <= "Z";
}
function isDigit(letter: string): boolean {
return letter >= "0" && letter <= "9";
}
/**
* Split a separator-free segment on its internal word boundaries: lower→Upper
* (userName), UPPER→Upper+lower (HTTPServer), and digit→letter (user2Name /
* sha256Hash). Letter→digit is deliberately NOT a boundary so digit runs stay
* attached to the preceding word (ipv4, sha256, field1).
*
* Implemented as a character scan instead of a zero-width lookbehind regex
* because lookbehind throws at module-parse time on WebViews older than
* Safari 16.4 and crashed the whole app there (issue #8202 / #6521 class).
*/
function splitSegmentOnWordBoundaries(segment: string): string[] {
const words: string[] = [];
let start = 0;
for (let index = 1; index < segment.length; index++) {
const previous = segment[index - 1];
const current = segment[index];
const next = segment[index + 1];
const boundary = (isLower(previous) && isUpper(current)) || (isUpper(previous) && isUpper(current) && isLower(next)) || (isDigit(previous) && (isLower(current) || isUpper(current)));
if (!boundary) continue;
words.push(segment.slice(start, index));
start = index;
}
words.push(segment.slice(start));
return words;
}
interface IdentifierParts {
leading: string;
@@ -30,7 +63,7 @@ function splitIntoWords(core: string): IdentifierParts {
const inner = core.slice(leading.length, core.length - trailing.length);
const words = inner
.split(SEPARATOR_RUN_PATTERN)
.flatMap((segment) => segment.split(WORD_BOUNDARY_PATTERN))
.flatMap(splitSegmentOnWordBoundaries)
.filter((word) => word.length > 0);
return { leading, words, trailing };
}
@@ -0,0 +1,50 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { describe, expect, it } from "vitest";
const SOURCE_EXTENSIONS = new Set([".ts", ".vue"]);
// Workspace sources that ship in the WebView bundle. Named capture groups
// (`(?<name>…)`) are fine — Safari 10.1+ supports them; lookbehind
// (`(?<=…)` / `(?<!…)`) is not — old WebKit (< Safari 16.4, e.g. macOS 12)
// throws "invalid group specifier name" while *parsing the module*, which
// kills the whole app at startup (issues #6521 and #8202), so guard every
// bundled source, not just the file that regressed last time.
const BUNDLED_SOURCE_ROOTS = ["apps/desktop/src", "packages/mongo-shell/src"];
function collectSourceFiles(directory: string, files: string[] = []): string[] {
for (const entry of readdirSync(directory)) {
if (entry === "__tests__" || entry.startsWith(".")) continue;
const fullPath = path.join(directory, entry);
if (statSync(fullPath).isDirectory()) {
collectSourceFiles(fullPath, files);
} else if (SOURCE_EXTENSIONS.has(path.extname(entry))) {
files.push(fullPath);
}
}
return files;
}
const workspaceRoot = path.resolve(fileURLToPath(import.meta.url), "../../../../../../..");
const bundledSources = BUNDLED_SOURCE_ROOTS.flatMap((relativeRoot) => collectSourceFiles(path.join(workspaceRoot, relativeRoot)));
// The runtime feature probe in legacyWebView.ts constructs its lookbehind
// from a string inside try/catch (module still parses on old WebKit; the
// engine that throws is the one being detected), so it is the sole allowed
// exception.
const LOOKBEHIND_ALLOWLIST = new Set(["apps/desktop/src/lib/ui/legacyWebView.ts"]);
describe("WebView startup sources stay compatible with old WebKit regex syntax", () => {
it("ships no lookbehind assertions in bundled sources", () => {
expect(bundledSources.length).toBeGreaterThan(1000);
const offenders = bundledSources
.filter((file) => {
const source = readFileSync(file, "utf8");
return source.includes("(?<=") || source.includes("(?<!");
})
.map((file) => path.relative(workspaceRoot, file))
.filter((relative) => !LOOKBEHIND_ALLOWLIST.has(relative));
expect(offenders).toEqual([]);
});
});
+6
View File
@@ -48,9 +48,15 @@ export function missingLegacyWebViewCapabilities(): string[] {
* expressions with lookbehind or the named-group shapes Shiki's JavaScript
* engine generates from TextMate grammars. Callers use this to pick a regex
* engine (e.g. Shiki's Oniguruma WASM engine) that old WebKit can run.
*
* The pattern below is the only permitted lookbehind in bundled sources: it
* is constructed inside a try/catch at call time (not at module parse time),
* so the engine that cannot compile it is exactly the one being detected.
* legacyWebViewRegexCompat.spec.ts allows this file by name.
*/
export function supportsRegExpLookbehind(): boolean {
try {
// Probe compiled from a string so this module still parses on old WebKit.
new RegExp("(?<=a)b");
return true;
} catch {