Merge branch 'main' into fix/project-atomic-save

This commit is contained in:
PTMH-NMH
2026-07-11 10:40:06 +07:00
committed by GitHub
21 changed files with 1093 additions and 83 deletions
-15
View File
@@ -1,15 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
};
+25
View File
@@ -0,0 +1,25 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.dll binary
*.exe binary
*.gif binary
*.icns binary
*.ico binary
*.jpeg binary
*.jpg binary
*.mov binary
*.mp3 binary
*.mp4 binary
*.node binary
*.pdf binary
*.png binary
*.ttf binary
*.wasm binary
*.wav binary
*.webm binary
*.woff binary
*.woff2 binary
*.zip binary
+65
View File
@@ -0,0 +1,65 @@
name: Code Quality
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Repository quality
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: true
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
id: install
run: npm ci --ignore-scripts
- name: Typecheck
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npx tsc --noEmit
- name: Lint
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npm run lint
- name: Check formatting (advisory)
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
continue-on-error: true
run: npm run format:check
- name: Test
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npm test
# Main currently has known locale-parity debt covered by PR #710. Keep the
# check visible without blocking unrelated PRs; make it required once that
# existing translation PR (or an equivalent fix) lands.
- name: Check translations (advisory)
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
continue-on-error: true
run: npm run i18n:check
+14 -34
View File
@@ -1,7 +1,18 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": false },
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!!**/dist",
"!!**/dist-electron",
"!!**/dist-ssr",
"!!**/release",
"!!**/.tmp",
"!!**/electron/native/**/build"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
@@ -92,42 +103,11 @@
"noWith": "error",
"useGetterReturn": "error"
}
},
"includes": ["**", "**/dist", "**/.eslintrc.cjs", "**", "**/dist", "**/.eslintrc.cjs"]
}
},
"javascript": { "formatter": { "quoteStyle": "double" } },
"css": { "parser": { "tailwindDirectives": true } },
"css": { "parser": { "cssModules": true, "tailwindDirectives": true } },
"overrides": [
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
},
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
+8
View File
@@ -27,6 +27,14 @@ describe("Windows native helper path resolution", () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
it("resolves a requested platform tag independently of the host platform", async () => {
const { getNativeArchTag } = await import("./binaries");
expect(getNativeArchTag("win32")).toBe(
process.arch === "arm64" ? "win32-arm64" : "win32-x64",
);
});
it("prefers the branch-staged helper over a stale local CMake build in dev", async () => {
const buildOutputPath = path.join(
appPath,
+11 -8
View File
@@ -29,24 +29,27 @@ export function getNativeCaptureHelperSourcePath(): string {
return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitRecorder.swift");
}
export function getNativeArchTag(): string {
if (process.platform === "darwin") {
export function getNativeArchTag(platform: NodeJS.Platform = process.platform): string {
if (platform === "darwin") {
return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
}
if (process.platform === "win32") {
if (platform === "win32") {
return process.arch === "arm64" ? "win32-arm64" : "win32-x64";
}
if (process.platform === "linux") {
if (platform === "linux") {
return process.arch === "arm64" ? "linux-arm64" : "linux-x64";
}
return `${process.platform}-${process.arch}`;
return `${platform}-${process.arch}`;
}
export function getPrebundledNativeHelperPath(binaryName: string): string {
return resolveUnpackedAppPath("electron", "native", "bin", getNativeArchTag(), binaryName);
export function getPrebundledNativeHelperPath(
binaryName: string,
archTag = getNativeArchTag(),
): string {
return resolveUnpackedAppPath("electron", "native", "bin", archTag, binaryName);
}
export function resolvePreferredWindowsNativeHelperPath(
@@ -61,7 +64,7 @@ export function resolvePreferredWindowsNativeHelperPath(
"Release",
binaryName,
);
const prebundledPath = getPrebundledNativeHelperPath(binaryName);
const prebundledPath = getPrebundledNativeHelperPath(binaryName, getNativeArchTag("win32"));
if (app.isPackaged && existsSync(prebundledPath)) {
return prebundledPath;
@@ -0,0 +1,53 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveRecordedVideoStoragePath } from "./storagePath";
describe("resolveRecordedVideoStoragePath", () => {
const recordingsDir = path.resolve("recordings-root");
it.each([
"recording-0.webm",
"recording-1720588800000.mp4",
"recording-1720588800000-webcam.webm",
"recording-1720588800000-webcam.mp4",
])("accepts an app-generated recording name: %s", (fileName) => {
expect(resolveRecordedVideoStoragePath(recordingsDir, fileName)).toBe(
path.resolve(recordingsDir, fileName),
);
});
it.each([
"",
"../recording-1.webm",
"..\\recording-1.webm",
"nested/recording-1.webm",
"nested\\recording-1.webm",
"/tmp/recording-1.webm",
"C:\\temp\\recording-1.webm",
"\\\\server\\share\\recording-1.webm",
"recording-1.webm:payload",
"recording-1.webm\n",
"recording-1.webm\0",
"recording--1.webm",
"recording-1.5.webm",
"recording-1.mov",
"other-1.webm",
"recording-1-WEBCAM.webm",
])("rejects an untrusted recording name: %s", (fileName) => {
expect(() => resolveRecordedVideoStoragePath(recordingsDir, fileName)).toThrow(
"Invalid recording file name",
);
});
it.each([
{ label: "undefined", value: undefined },
{ label: "null", value: null },
{ label: "number", value: 1 },
{ label: "object", value: {} },
{ label: "array", value: [] },
])("rejects a non-string recording name: $label", ({ value }) => {
expect(() => resolveRecordedVideoStoragePath(recordingsDir, value)).toThrow(
"Invalid recording file name",
);
});
});
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
const RECORDED_VIDEO_FILE_NAME = /^recording-[0-9]+(?:-webcam)?\.(?:webm|mp4)$/;
export function resolveRecordedVideoStoragePath(recordingsDir: string, fileName: unknown): string {
if (typeof fileName !== "string" || RECORDED_VIDEO_FILE_NAME.exec(fileName)?.[0] !== fileName) {
throw new Error("Invalid recording file name");
}
const resolvedRecordingsDir = path.resolve(recordingsDir);
const candidatePath = path.resolve(resolvedRecordingsDir, fileName);
const relativePath = path.relative(resolvedRecordingsDir, candidatePath);
if (
relativePath.length === 0 ||
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
throw new Error("Invalid recording file name");
}
return candidatePath;
}
+1 -1
View File
@@ -62,7 +62,7 @@ export function registerAssetHandlers() {
await fs.writeFile(thumbPath, jpegData)
})
// Keep the queue moving even if one fails
thumbGenerationQueue = generation.catch(() => {})
thumbGenerationQueue = generation.catch(() => undefined)
await generation
return { success: true, data: jpegData! }
+3 -2
View File
@@ -68,6 +68,7 @@ import {
waitForNativeCaptureStart,
waitForNativeCaptureStop,
} from "../recording/mac";
import { resolveRecordedVideoStoragePath } from "../recording/storagePath";
import {
attachWindowsCaptureLifecycle,
isNativeWindowsCaptureAvailable,
@@ -1747,10 +1748,10 @@ export function registerRecordingHandlers(
},
);
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: unknown) => {
try {
const recordingsDir = await getRecordingsDir();
const videoPath = path.join(recordingsDir, fileName);
const videoPath = resolveRecordedVideoStoragePath(recordingsDir, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
return await finalizeStoredVideo(videoPath);
} catch (error) {
+108 -12
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
app,
BrowserWindow,
@@ -11,8 +11,10 @@ import {
Notification,
nativeImage,
session,
shell,
systemPreferences,
Tray,
webContents as electronWebContents,
} from "electron";
import { RECORDINGS_DIR } from "./appPaths";
import { showCursor } from "./cursorHider";
@@ -26,7 +28,12 @@ import {
registerIpcHandlers,
} from "./ipc/handlers";
import { ensureMediaServer } from "./mediaServer";
import { ensurePackagedRendererServer } from "./rendererServer";
import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer";
import {
hardenWebContentsNavigation,
shouldHardenWebContentsType,
} from "./navigationPolicy";
import type { UpdateToastPayload } from "./updater";
import {
checkForAppUpdates,
@@ -73,6 +80,14 @@ app.commandLine.appendSwitch("ignore-gpu-blocklist");
app.commandLine.appendSwitch("enable-unsafe-webgpu");
app.commandLine.appendSwitch("enable-gpu-rasterization");
app.on("web-contents-created", (_event, contents) => {
if (!shouldHardenWebContentsType(contents.getType())) {
return;
}
hardenWebContentsNavigation(contents, (url) => shell.openExternal(url));
});
function configureGpuAccelerationSwitches() {
const { useAngle, useGl, disableFeatures } = getGpuSwitches(process.platform, process.env);
if (useAngle) {
@@ -132,6 +147,30 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
function getTrustedCaptureDocumentBaseUrls(): string[] {
const trustedUrls = [pathToFileURL(path.join(RENDERER_DIST, "index.html")).href];
if (VITE_DEV_SERVER_URL) {
trustedUrls.push(VITE_DEV_SERVER_URL);
}
const packagedRendererBaseUrl = getPackagedRendererBaseUrl();
if (packagedRendererBaseUrl) {
trustedUrls.push(new URL("/", packagedRendererBaseUrl).href);
}
return trustedUrls;
}
function isHudWebContents(webContents: Electron.WebContents | null): boolean {
if (!webContents || webContents.isDestroyed()) {
return false;
}
const hudWindow = getHudOverlayWindow();
return Boolean(hudWindow && hudWindow.webContents === webContents);
}
// Window references
let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
@@ -879,17 +918,47 @@ app.whenReady().then(async () => {
app.setAppUserModelId("dev.recordly.app");
}
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
return allowed.includes(permission);
});
session.defaultSession.setPermissionCheckHandler(
(webContents, permission, requestingOrigin, details) => {
return shouldGrantMediaPermission(
{
permission,
isTrustedCaptureWindow: isHudWebContents(webContents),
isMainFrame: details.isMainFrame,
currentDocumentUrl: webContents?.getURL() ?? "",
// Electron 39 may supply the last committed document URL, including its
// query, in the requestingOrigin argument for media checks.
requestingUrl: details.requestingUrl ?? requestingOrigin,
securityOrigins:
details.securityOrigin === undefined ? [] : [details.securityOrigin],
},
getTrustedCaptureDocumentBaseUrls(),
);
},
);
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
callback(allowed.includes(permission));
});
session.defaultSession.setPermissionRequestHandler(
(webContents, permission, callback, details) => {
const securityOrigin = "securityOrigin" in details ? details.securityOrigin : undefined;
session.defaultSession.setDevicePermissionHandler((_details) => true);
callback(
shouldGrantMediaPermission(
{
permission,
isTrustedCaptureWindow: isHudWebContents(webContents),
isMainFrame: details.isMainFrame,
currentDocumentUrl: webContents.getURL(),
requestingUrl: details.requestingUrl,
securityOrigins: securityOrigin === undefined ? [] : [securityOrigin],
},
getTrustedCaptureDocumentBaseUrls(),
),
);
},
);
// Recordly does not use WebHID, Web Serial, or WebUSB. Do not grant devices by default.
session.defaultSession.setDevicePermissionHandler(() => false);
if (process.platform === "darwin") {
const cameraStatus = systemPreferences.getMediaAccessStatus("camera");
@@ -1003,8 +1072,35 @@ app.whenReady().then(async () => {
// via an unsafe cast breaks Electron's internal cursor-constraint
// propagation and causes cursor: 'never' from the renderer to be silently
// ignored by the native capture pipeline.
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
try {
const frame = request.frame;
const isLiveFrame = Boolean(frame && !frame.isDestroyed());
const requestingWebContents =
isLiveFrame && frame ? electronWebContents.fromFrame(frame) : undefined;
const isHudMainFrame = Boolean(
isLiveFrame &&
requestingWebContents &&
isHudWebContents(requestingWebContents) &&
frame === requestingWebContents.mainFrame,
);
if (
!shouldGrantDisplayCapture(
{
isTrustedCaptureWindow: isHudMainFrame,
isMainFrame: Boolean(isLiveFrame && frame?.parent === null),
currentDocumentUrl: isLiveFrame ? (frame?.url ?? "") : "",
securityOrigin: request.securityOrigin,
videoRequested: request.videoRequested,
},
getTrustedCaptureDocumentBaseUrls(),
)
) {
callback({});
return;
}
const sourceId = getSelectedSourceId();
// On Linux/Wayland, calling desktopCapturer.getSources() itself
// invokes the xdg-desktop-portal picker. If we then return one of
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, it, vi } from "vitest";
import {
createWillNavigateHandler,
createWillRedirectHandler,
createWindowOpenHandler,
hardenWebContentsNavigation,
isInternalRendererTarget,
normalizeExternalHttpUrl,
shouldHardenWebContentsType,
} from "./navigationPolicy";
describe("normalizeExternalHttpUrl", () => {
it.each([
["https://example.com/docs", "https://example.com/docs"],
["http://127.0.0.1:3000/path?q=1", "http://127.0.0.1:3000/path?q=1"],
["HTTPS://Example.COM:443/docs", "https://example.com/docs"],
])("normalizes an external HTTP(S) URL: %s", (value, expected) => {
expect(normalizeExternalHttpUrl(value)).toBe(expected);
});
it.each([
"",
"not a URL",
"file:///tmp/recordly.html",
"data:text/html,hello",
"javascript:alert(1)",
"mailto:security@example.com",
"https://user:password@example.com/",
])("rejects an unsafe external URL: %s", (value) => {
expect(normalizeExternalHttpUrl(value)).toBeNull();
});
});
describe("shouldHardenWebContentsType", () => {
it("selects BrowserWindow contents only", () => {
expect(shouldHardenWebContentsType("window")).toBe(true);
expect(shouldHardenWebContentsType("webview")).toBe(false);
expect(shouldHardenWebContentsType("offscreen")).toBe(false);
});
});
describe("isInternalRendererTarget", () => {
it.each([
["http://localhost:5173/?windowType=editor", "http://localhost:5173/editor?reload=1"],
["http://127.0.0.1:43123/?windowType=editor", "http://127.0.0.1:43123/assets/index.js"],
[
"file:///opt/Recordly/dist/index.html?windowType=editor",
"file:///opt/Recordly/dist/index.html?windowType=hud-overlay#status",
],
])("identifies the current renderer origin/file", (currentUrl, targetUrl) => {
expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(true);
});
it.each([
["http://localhost:5173/?windowType=editor", "http://localhost.example.com:5173/"],
["http://localhost:5173/", "http://localhost:5174/"],
["https://recordly.example/", "http://recordly.example/"],
["https://recordly.example/", "https://user:pass@recordly.example/"],
["file:///opt/Recordly/dist/index.html", "file:///etc/passwd"],
["file:///opt/Recordly/dist/index.html", "data:text/html,hello"],
["not a URL", "https://example.com/"],
])("distinguishes a target outside the current renderer", (currentUrl, targetUrl) => {
expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(false);
});
});
describe("navigation event handlers", () => {
it("preserves an exact renderer reload", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "http://localhost:5173/editor?windowType=editor",
openExternal,
);
handler({
url: "http://localhost:5173/editor?windowType=editor",
preventDefault,
});
expect(preventDefault).not.toHaveBeenCalled();
expect(openExternal).not.toHaveBeenCalled();
});
it("stops same-origin renderer navigation without externalizing it", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "http://localhost:5173/editor",
openExternal,
);
handler({ url: "http://localhost:5173/settings", preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).not.toHaveBeenCalled();
});
it("stops same-file query mutation without externalizing it", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "file:///opt/Recordly/dist/index.html?windowType=editor",
openExternal,
);
handler({
url: "file:///opt/Recordly/dist/index.html?smokeExport=1",
preventDefault,
});
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).not.toHaveBeenCalled();
});
it("stops cross-origin navigation and opens HTTP(S) in the system browser", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "http://localhost:5173/editor",
openExternal,
);
handler({ url: "https://example.com/docs", preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).toHaveBeenCalledWith("https://example.com/docs");
});
it("stops unsafe schemes without opening them externally", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "file:///opt/Recordly/dist/index.html",
openExternal,
);
handler({ url: "file:///etc/passwd", preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).not.toHaveBeenCalled();
});
it("stops server redirects without externalizing the target", () => {
const preventDefault = vi.fn();
createWillRedirectHandler()({ url: "https://example.com/redirect", preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
});
it("always denies Electron child windows while preserving safe external links", () => {
const openExternal = vi.fn(async () => undefined);
const handler = createWindowOpenHandler(() => "http://localhost:5173/editor", openExternal);
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
expect(handler({ url: "http://localhost:5173/settings" })).toEqual({ action: "deny" });
expect(handler({ url: "javascript:alert(1)" })).toEqual({ action: "deny" });
expect(openExternal).toHaveBeenCalledOnce();
expect(openExternal).toHaveBeenCalledWith("https://example.com/docs");
});
it("reports a system-browser failure without allowing the child window", async () => {
const error = new Error("browser unavailable");
const reportOpenError = vi.fn();
const handler = createWindowOpenHandler(
() => "http://localhost:5173/editor",
vi.fn(async () => {
throw error;
}),
reportOpenError,
);
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
await vi.waitFor(() => {
expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error);
});
});
it("reports a synchronous browser-launch failure while still denying the child", () => {
const error = new Error("browser launch threw");
const reportOpenError = vi.fn();
const handler = createWindowOpenHandler(
() => "http://localhost:5173/editor",
vi.fn(() => {
throw error;
}),
reportOpenError,
);
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error);
});
it("attaches navigation, redirect, and window-open policies", () => {
const on = vi.fn();
const setWindowOpenHandler = vi.fn();
const webContents = {
getURL: () => "http://localhost:5173/",
on,
setWindowOpenHandler,
};
hardenWebContentsNavigation(
webContents,
vi.fn(async () => undefined),
);
expect(on).toHaveBeenCalledWith("will-navigate", expect.any(Function));
expect(on).toHaveBeenCalledWith("will-redirect", expect.any(Function));
expect(on).toHaveBeenCalledWith("did-navigate", expect.any(Function));
expect(setWindowOpenHandler).toHaveBeenCalledWith(expect.any(Function));
});
it("does not trust a renderer-mutated URL as an exact reload", () => {
let currentUrl = "file:///opt/Recordly/dist/index.html?windowType=editor";
const on = vi.fn();
const webContents = {
getURL: () => currentUrl,
on,
setWindowOpenHandler: vi.fn(),
};
const openExternal = vi.fn(async () => undefined);
hardenWebContentsNavigation(webContents, openExternal);
// history.replaceState() changes getURL() without crossing a document-navigation boundary.
currentUrl = "file:///opt/Recordly/dist/index.html?windowType=source-selector";
const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1];
if (typeof willNavigate !== "function") {
throw new Error("will-navigate handler was not registered");
}
const preventDefault = vi.fn();
willNavigate({ url: currentUrl, preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).not.toHaveBeenCalled();
});
it("trusts an exact reload after a completed document navigation", () => {
const on = vi.fn();
const webContents = {
getURL: () => "",
on,
setWindowOpenHandler: vi.fn(),
};
hardenWebContentsNavigation(
webContents,
vi.fn(async () => undefined),
);
const didNavigate = on.mock.calls.find(([eventName]) => eventName === "did-navigate")?.[1];
const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1];
if (typeof didNavigate !== "function" || typeof willNavigate !== "function") {
throw new Error("navigation handlers were not registered");
}
const loadedUrl = "file:///opt/Recordly/dist/index.html?windowType=editor";
didNavigate({}, loadedUrl);
const preventDefault = vi.fn();
willNavigate({ url: loadedUrl, preventDefault });
expect(preventDefault).not.toHaveBeenCalled();
});
});
+151
View File
@@ -0,0 +1,151 @@
import type { WebContents } from "electron";
type OpenExternal = (url: string) => Promise<unknown>;
type ReportOpenError = (url: string, error: unknown) => void;
export type NavigationEvent = {
url: string;
preventDefault: () => void;
};
export function shouldHardenWebContentsType(type: ReturnType<WebContents["getType"]>): boolean {
return type === "window";
}
export function normalizeExternalHttpUrl(value: string): string | null {
try {
const url = new URL(value);
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
!url.hostname ||
url.username ||
url.password
) {
return null;
}
return url.href;
} catch {
return null;
}
}
export function isInternalRendererTarget(currentValue: string, targetValue: string): boolean {
try {
const currentUrl = new URL(currentValue);
const targetUrl = new URL(targetValue);
if (targetUrl.username || targetUrl.password) {
return false;
}
if (
(currentUrl.protocol === "http:" || currentUrl.protocol === "https:") &&
(targetUrl.protocol === "http:" || targetUrl.protocol === "https:")
) {
return currentUrl.origin === targetUrl.origin;
}
if (currentUrl.protocol === "file:" && targetUrl.protocol === "file:") {
return currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname;
}
return currentUrl.href === targetUrl.href;
} catch {
return false;
}
}
function isExactRendererLocation(currentValue: string, targetValue: string): boolean {
try {
const currentUrl = new URL(currentValue);
const targetUrl = new URL(targetValue);
return !targetUrl.username && !targetUrl.password && currentUrl.href === targetUrl.href;
} catch {
return false;
}
}
function openExternalIfSafe(
value: string,
openExternal: OpenExternal,
reportOpenError: ReportOpenError,
): void {
const safeUrl = normalizeExternalHttpUrl(value);
if (!safeUrl) {
return;
}
try {
void openExternal(safeUrl).catch((error) => reportOpenError(safeUrl, error));
} catch (error) {
reportOpenError(safeUrl, error);
}
}
const defaultReportOpenError: ReportOpenError = (url, error) => {
console.error("[navigation-policy] Failed to open external URL", { url, error });
};
export function createWillNavigateHandler(
getTrustedRendererUrl: () => string,
openExternal: OpenExternal,
reportOpenError: ReportOpenError = defaultReportOpenError,
) {
return (event: NavigationEvent): void => {
const trustedRendererUrl = getTrustedRendererUrl();
// Preserve an exact reload, but freeze all renderer-selected destination changes,
// including same-origin query mutations that can carry privileged local paths.
if (isExactRendererLocation(trustedRendererUrl, event.url)) {
return;
}
// The internal-target check only prevents app URLs from leaking into the system browser.
event.preventDefault();
if (isInternalRendererTarget(trustedRendererUrl, event.url)) {
return;
}
openExternalIfSafe(event.url, openExternal, reportOpenError);
};
}
export function createWillRedirectHandler() {
return (event: NavigationEvent): void => {
event.preventDefault();
};
}
export function createWindowOpenHandler(
getCurrentUrl: () => string,
openExternal: OpenExternal,
reportOpenError: ReportOpenError = defaultReportOpenError,
) {
return (details: { url: string }) => {
if (!isInternalRendererTarget(getCurrentUrl(), details.url)) {
openExternalIfSafe(details.url, openExternal, reportOpenError);
}
return { action: "deny" as const };
};
}
export function hardenWebContentsNavigation(
webContents: Pick<WebContents, "getURL" | "on" | "setWindowOpenHandler">,
openExternal: OpenExternal,
reportOpenError: ReportOpenError = defaultReportOpenError,
): void {
// Renderer history APIs mutate getURL() without a document navigation. Keep the last
// main-frame document URL as the reload trust boundary instead of trusting that live value.
let trustedRendererUrl = webContents.getURL();
webContents.on("did-navigate", (_event, url) => {
trustedRendererUrl = url;
});
webContents.on(
"will-navigate",
createWillNavigateHandler(() => trustedRendererUrl, openExternal, reportOpenError),
);
webContents.on("will-redirect", createWillRedirectHandler());
webContents.setWindowOpenHandler(
createWindowOpenHandler(() => webContents.getURL(), openExternal, reportOpenError),
);
}
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import {
isTrustedCaptureDocumentUrl,
shouldGrantDisplayCapture,
shouldGrantMediaPermission,
} from "./permissionPolicy";
const TRUSTED_DOCUMENT_BASE_URLS = [
"http://localhost:5173/",
"http://127.0.0.1:43127/",
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html",
];
const DEV_HUD_URL = "http://localhost:5173/?windowType=hud-overlay";
const PACKAGED_HUD_URL = "http://127.0.0.1:43127/?windowType=hud-overlay";
const FILE_HUD_URL =
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html?windowType=hud-overlay";
describe("isTrustedCaptureDocumentUrl", () => {
it.each([
DEV_HUD_URL,
PACKAGED_HUD_URL,
FILE_HUD_URL,
`${DEV_HUD_URL}#microphone`,
])("accepts a Recordly HUD document: %s", (candidateUrl) => {
expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
});
it.each([
"http://localhost:5173/",
"http://localhost:5173/?windowType=editor",
"http://localhost:5173/?windowType=HUD-OVERLAY",
"http://localhost:5173/?windowType=hud-overlay&debug=1",
"http://localhost:5173/?windowType=hud-overlay&windowType=hud-overlay",
"http://localhost:5174/?windowType=hud-overlay",
"http://localhost.evil.test:5173/?windowType=hud-overlay",
"http://localhost:5173.evil.test/?windowType=hud-overlay",
"http://user@localhost:5173/?windowType=hud-overlay",
"https://localhost:5173/?windowType=hud-overlay",
"http://127.0.0.1:43127/nested/?windowType=hud-overlay",
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/other.html?windowType=hud-overlay",
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html/child?windowType=hud-overlay",
"data:text/html,recordly?windowType=hud-overlay",
"not a url",
])("rejects a non-Recordly capture document: %s", (candidateUrl) => {
expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(false);
});
it("ignores malformed trusted base URLs instead of throwing", () => {
expect(
isTrustedCaptureDocumentUrl(DEV_HUD_URL, ["not a url", ...TRUSTED_DOCUMENT_BASE_URLS]),
).toBe(true);
});
});
describe("shouldGrantMediaPermission", () => {
const makeRequest = (
overrides: Partial<Parameters<typeof shouldGrantMediaPermission>[0]> = {},
): Parameters<typeof shouldGrantMediaPermission>[0] => ({
permission: "media",
isTrustedCaptureWindow: true,
isMainFrame: true,
currentDocumentUrl: DEV_HUD_URL,
requestingUrl: DEV_HUD_URL,
securityOrigins: ["http://localhost:5173"],
...overrides,
});
it("grants camera or microphone media to the trusted HUD main frame", () => {
expect(shouldGrantMediaPermission(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
});
it("accepts Chromium's trailing-slash HTTP origin serialization", () => {
expect(
shouldGrantMediaPermission(
makeRequest({ securityOrigins: ["http://localhost:5173/"] }),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it("accepts the packaged loopback renderer with its exact origin", () => {
expect(
shouldGrantMediaPermission(
makeRequest({
currentDocumentUrl: PACKAGED_HUD_URL,
requestingUrl: PACKAGED_HUD_URL,
securityOrigins: ["http://127.0.0.1:43127"],
}),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it.each([
"null",
"file://",
"file:///",
])("accepts Chromium's packaged file origin form: %s", (securityOrigin) => {
expect(
shouldGrantMediaPermission(
makeRequest({
currentDocumentUrl: FILE_HUD_URL,
requestingUrl: FILE_HUD_URL,
securityOrigins: [securityOrigin],
}),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it.each([
["another permission", { permission: "display-capture" }],
["another BrowserWindow", { isTrustedCaptureWindow: false }],
["a subframe", { isMainFrame: false }],
["an untrusted current document", { currentDocumentUrl: "https://example.com/" }],
["a missing requesting document", { requestingUrl: "" }],
["an untrusted requesting document", { requestingUrl: "https://example.com/" }],
["a different trusted document", { requestingUrl: PACKAGED_HUD_URL }],
["a mismatched origin", { securityOrigins: ["http://localhost:5174"] }],
["an origin lookalike", { securityOrigins: ["http://localhost:5173.evil.test"] }],
["an origin with credentials", { securityOrigins: ["http://user@localhost:5173/"] }],
["an origin with a path", { securityOrigins: ["http://localhost:5173/other"] }],
["an origin with a query", { securityOrigins: ["http://localhost:5173/?debug=1"] }],
["a missing security origin", { securityOrigins: [] }],
["an empty origin", { securityOrigins: [""] }],
] as const)("denies %s", (_label, overrides) => {
expect(shouldGrantMediaPermission(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe(
false,
);
});
});
describe("shouldGrantDisplayCapture", () => {
const makeRequest = (
overrides: Partial<Parameters<typeof shouldGrantDisplayCapture>[0]> = {},
): Parameters<typeof shouldGrantDisplayCapture>[0] => ({
isTrustedCaptureWindow: true,
isMainFrame: true,
currentDocumentUrl: DEV_HUD_URL,
securityOrigin: "http://localhost:5173",
videoRequested: true,
...overrides,
});
it("grants selected-display video to the trusted HUD main frame", () => {
expect(shouldGrantDisplayCapture(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
});
it("accepts a trailing slash on the display security origin", () => {
expect(
shouldGrantDisplayCapture(
makeRequest({ securityOrigin: "http://localhost:5173/" }),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it("accepts the packaged loopback renderer with its exact origin", () => {
expect(
shouldGrantDisplayCapture(
makeRequest({
currentDocumentUrl: PACKAGED_HUD_URL,
securityOrigin: "http://127.0.0.1:43127",
}),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it.each(["null", "file://", "file:///"])(
"accepts Chromium's packaged file origin form: %s",
(securityOrigin) => {
expect(
shouldGrantDisplayCapture(
makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
},
);
it.each([
["another BrowserWindow", { isTrustedCaptureWindow: false }],
["a subframe", { isMainFrame: false }],
["a non-Recordly document", { currentDocumentUrl: "https://example.com/" }],
["a mismatched origin", { securityOrigin: "http://127.0.0.1:43127" }],
["a malformed origin", { securityOrigin: "not an origin" }],
["an origin with a path", { securityOrigin: "http://localhost:5173/other" }],
["an audio-only request", { videoRequested: false }],
] as const)("denies %s", (_label, overrides) => {
expect(shouldGrantDisplayCapture(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe(
false,
);
});
});
+157
View File
@@ -0,0 +1,157 @@
const CAPTURE_WINDOW_TYPE = "hud-overlay";
const TRUSTED_RENDERER_PROTOCOLS = new Set(["http:", "https:", "file:"]);
const FILE_SECURITY_ORIGINS = new Set(["null", "file://", "file:///"]);
export interface MediaPermissionPolicyRequest {
permission: string;
isTrustedCaptureWindow: boolean;
isMainFrame: boolean;
currentDocumentUrl: string;
requestingUrl: string;
securityOrigins: readonly string[];
}
export interface DisplayCapturePolicyRequest {
isTrustedCaptureWindow: boolean;
isMainFrame: boolean;
currentDocumentUrl: string;
securityOrigin: string;
videoRequested: boolean;
}
function parseUrl(value: string): URL | null {
try {
return new URL(value);
} catch {
return null;
}
}
function hasExactCaptureWindowQuery(url: URL): boolean {
const entries = [...url.searchParams.entries()];
return (
entries.length === 1 &&
entries[0]?.[0] === "windowType" &&
entries[0][1] === CAPTURE_WINDOW_TYPE
);
}
function isValidTrustedBaseUrl(url: URL): boolean {
return (
TRUSTED_RENDERER_PROTOCOLS.has(url.protocol) &&
url.username === "" &&
url.password === "" &&
url.search === "" &&
url.hash === ""
);
}
function hasSameBaseLocation(candidate: URL, trustedBase: URL): boolean {
return (
candidate.protocol === trustedBase.protocol &&
candidate.hostname === trustedBase.hostname &&
candidate.port === trustedBase.port &&
candidate.pathname === trustedBase.pathname
);
}
export function isTrustedCaptureDocumentUrl(
candidateUrl: string,
trustedDocumentBaseUrls: readonly string[],
): boolean {
const candidate = parseUrl(candidateUrl);
if (
!candidate ||
!TRUSTED_RENDERER_PROTOCOLS.has(candidate.protocol) ||
candidate.username !== "" ||
candidate.password !== "" ||
!hasExactCaptureWindowQuery(candidate)
) {
return false;
}
return trustedDocumentBaseUrls.some((trustedBaseUrl) => {
const trustedBase = parseUrl(trustedBaseUrl);
return Boolean(
trustedBase &&
isValidTrustedBaseUrl(trustedBase) &&
hasSameBaseLocation(candidate, trustedBase),
);
});
}
function isSameDocument(firstUrl: string, secondUrl: string): boolean {
const first = parseUrl(firstUrl);
const second = parseUrl(secondUrl);
if (!first || !second) {
return false;
}
first.hash = "";
second.hash = "";
return first.href === second.href;
}
function isSecurityOriginForDocument(securityOrigin: string, documentUrl: string): boolean {
const document = parseUrl(documentUrl);
if (!document) {
return false;
}
if (document.protocol === "file:") {
return FILE_SECURITY_ORIGINS.has(securityOrigin);
}
const origin = parseUrl(securityOrigin);
return Boolean(
origin &&
(origin.protocol === "http:" || origin.protocol === "https:") &&
origin.username === "" &&
origin.password === "" &&
origin.origin === document.origin &&
origin.pathname === "/" &&
origin.search === "" &&
origin.hash === "",
);
}
export function shouldGrantMediaPermission(
request: MediaPermissionPolicyRequest,
trustedDocumentBaseUrls: readonly string[],
): boolean {
if (
request.permission !== "media" ||
!request.isTrustedCaptureWindow ||
!request.isMainFrame ||
!isTrustedCaptureDocumentUrl(request.currentDocumentUrl, trustedDocumentBaseUrls)
) {
return false;
}
if (
!isTrustedCaptureDocumentUrl(request.requestingUrl, trustedDocumentBaseUrls) ||
!isSameDocument(request.currentDocumentUrl, request.requestingUrl)
) {
return false;
}
return (
request.securityOrigins.length > 0 &&
request.securityOrigins.every((securityOrigin) =>
isSecurityOriginForDocument(securityOrigin, request.currentDocumentUrl),
)
);
}
export function shouldGrantDisplayCapture(
request: DisplayCapturePolicyRequest,
trustedDocumentBaseUrls: readonly string[],
): boolean {
return (
request.isTrustedCaptureWindow &&
request.isMainFrame &&
request.videoRequested &&
isTrustedCaptureDocumentUrl(request.currentDocumentUrl, trustedDocumentBaseUrls) &&
isSecurityOriginForDocument(request.securityOrigin, request.currentDocumentUrl)
);
}
+3 -2
View File
@@ -18,9 +18,10 @@
"dev": "vite --config vite.config.ts",
"postinstall": "node scripts/postinstall.mjs",
"build": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"format": "biome format --write .",
"format:check": "biome format .",
"preview": "vite preview --config vite.config.ts",
"rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi",
"build:native-helpers": "node scripts/build-native-helpers.mjs",
+1 -1
View File
@@ -80,7 +80,7 @@ export const SourceSelectorContent = ({
windowSources = [],
selectedSource = "Screen",
loading = false,
onSourceSelect = () => {},
onSourceSelect = () => undefined,
}: Pick<SourceSelectorProps, "screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect">) => {
const t = useScopedT("launch");
const renderSourceItem = (source: DesktopSource, index: number) => {
@@ -1,8 +1,8 @@
import { createContext, useContext } from "react";
import { createContext, type MouseEvent, useContext } from "react";
interface HudInteractionContextType {
onMouseEnter: () => void;
onMouseLeave: (event: any) => void;
onMouseLeave: (event: MouseEvent<HTMLDivElement>) => void;
}
export const HudInteractionContext = createContext<HudInteractionContextType | null>(null);
@@ -169,7 +169,6 @@ import {
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import {
layoutVideoContent as layoutVideoContentUtil,
scalePreviewBorderRadius,
} from "./videoPlayback/layoutUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
@@ -6,7 +6,7 @@ type WaveformWorkerRequest = {
interface WorkerContext {
onmessage: (e: MessageEvent<WaveformWorkerRequest>) => void;
postMessage: (message: any, transfer?: Transferable[]) => void;
postMessage: (message: unknown, transfer?: Transferable[]) => void;
}
const workerScope = self as unknown as WorkerContext;
+4 -4
View File
@@ -67,7 +67,7 @@ describe("StreamingVideoDecoder local media loading", () => {
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4");
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled();
expect(mockDemuxerLoad).toHaveBeenCalledWith(
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4",
);
@@ -77,7 +77,7 @@ describe("StreamingVideoDecoder local media loading", () => {
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("/tmp/capture.mp4");
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
expect(window.electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
"/tmp/capture.mp4",
);
expect(mockDemuxerLoad).toHaveBeenCalledWith(
@@ -90,7 +90,7 @@ describe("StreamingVideoDecoder local media loading", () => {
mockDemuxerLoad
.mockRejectedValueOnce(new Error("get_media_info failed: Failed after 3 attempts"))
.mockResolvedValueOnce(undefined);
(window as any).electronAPI.readLocalFile = vi.fn(async () => ({
window.electronAPI.readLocalFile = vi.fn(async () => ({
success: true,
data: new Uint8Array([1, 2, 3]),
}));
@@ -103,7 +103,7 @@ describe("StreamingVideoDecoder local media loading", () => {
"http://127.0.0.1:4321/video?path=%2Ftmp%2Ffallback.mp4",
);
expect(mockDemuxerLoad.mock.calls[1]?.[0]).toBeInstanceOf(File);
expect((window as any).electronAPI.readLocalFile).toHaveBeenCalledWith(
expect(window.electronAPI.readLocalFile).toHaveBeenCalledWith(
"/tmp/fallback.mp4",
);
});