fix(desktop): replace lookbehind regex that crashed legacy WebKit at startup
Closes #8202
This commit is contained in:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user