From 41119593643dc47df916d94f243ffcf9cdf189da Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 05:07:19 +0700 Subject: [PATCH 1/9] ci: add pull-request quality checks --- .gitattributes | 1 + .github/workflows/quality.yml | 60 +++++++++++++++++++ biome.json | 35 +---------- electron/ipc/register/assets.ts | 2 +- package.json | 5 +- src/components/launch/SourceSelector.tsx | 2 +- .../launch/contexts/HudInteractionContext.tsx | 4 +- src/components/video-editor/VideoPlayback.tsx | 1 - .../audio/waveform/waveform.worker.ts | 2 +- src/lib/exporter/streamingDecoder.test.ts | 8 +-- 10 files changed, 75 insertions(+), 45 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/quality.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 00000000..3b8169b8 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,60 @@ +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: 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 diff --git a/biome.json b/biome.json index 30eb475d..c1d2304e 100644 --- a/biome.json +++ b/biome.json @@ -92,42 +92,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": { diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts index a0b132ea..f18bf7b6 100644 --- a/electron/ipc/register/assets.ts +++ b/electron/ipc/register/assets.ts @@ -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! } diff --git a/package.json b/package.json index 6357c666..c9266412 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 158b53e1..baa4da59 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -80,7 +80,7 @@ export const SourceSelectorContent = ({ windowSources = [], selectedSource = "Screen", loading = false, - onSourceSelect = () => {}, + onSourceSelect = () => undefined, }: Pick) => { const t = useScopedT("launch"); const renderSourceItem = (source: DesktopSource, index: number) => { diff --git a/src/components/launch/contexts/HudInteractionContext.tsx b/src/components/launch/contexts/HudInteractionContext.tsx index 65a1accb..66b5159e 100644 --- a/src/components/launch/contexts/HudInteractionContext.tsx +++ b/src/components/launch/contexts/HudInteractionContext.tsx @@ -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) => void; } export const HudInteractionContext = createContext(null); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 499179f3..8de8b685 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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"; diff --git a/src/components/video-editor/audio/waveform/waveform.worker.ts b/src/components/video-editor/audio/waveform/waveform.worker.ts index 80403a09..28e2ebd9 100644 --- a/src/components/video-editor/audio/waveform/waveform.worker.ts +++ b/src/components/video-editor/audio/waveform/waveform.worker.ts @@ -6,7 +6,7 @@ type WaveformWorkerRequest = { interface WorkerContext { onmessage: (e: MessageEvent) => void; - postMessage: (message: any, transfer?: Transferable[]) => void; + postMessage: (message: unknown, transfer?: Transferable[]) => void; } const workerScope = self as unknown as WorkerContext; diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index de831671..f49e835c 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -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", ); }); From 4da19fde250ad7c772dba31c307faff4ca487cb6 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 05:12:39 +0700 Subject: [PATCH 2/9] fix: resolve Windows helper paths consistently --- electron/ipc/paths/binaries.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/electron/ipc/paths/binaries.ts b/electron/ipc/paths/binaries.ts index 739f7df8..a95a79cf 100644 --- a/electron/ipc/paths/binaries.ts +++ b/electron/ipc/paths/binaries.ts @@ -53,6 +53,7 @@ export function resolvePreferredWindowsNativeHelperPath( helperDirectory: string, binaryName: string, ): string { + const windowsArchTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64"; const buildOutputPath = resolveUnpackedAppPath( "electron", "native", @@ -61,7 +62,13 @@ export function resolvePreferredWindowsNativeHelperPath( "Release", binaryName, ); - const prebundledPath = getPrebundledNativeHelperPath(binaryName); + const prebundledPath = resolveUnpackedAppPath( + "electron", + "native", + "bin", + windowsArchTag, + binaryName, + ); if (app.isPackaged && existsSync(prebundledPath)) { return prebundledPath; From 682106972ab1ad9c78f35fbad2536a3442cf43f3 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 05:24:53 +0700 Subject: [PATCH 3/9] fix(ipc): confine recorded video writes --- electron/ipc/recording/storagePath.test.ts | 53 ++++++++++++++++++++++ electron/ipc/recording/storagePath.ts | 24 ++++++++++ electron/ipc/register/recording.ts | 5 +- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 electron/ipc/recording/storagePath.test.ts create mode 100644 electron/ipc/recording/storagePath.ts diff --git a/electron/ipc/recording/storagePath.test.ts b/electron/ipc/recording/storagePath.test.ts new file mode 100644 index 00000000..dced4f52 --- /dev/null +++ b/electron/ipc/recording/storagePath.test.ts @@ -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", + ); + }); +}); diff --git a/electron/ipc/recording/storagePath.ts b/electron/ipc/recording/storagePath.ts new file mode 100644 index 00000000..12c3484f --- /dev/null +++ b/electron/ipc/recording/storagePath.ts @@ -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; +} diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index b13453c7..06a33f84 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -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) { From eab81522e0fcea570fc9af3e9e1ef53ead00b70c Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 05:46:22 +0700 Subject: [PATCH 4/9] fix(electron): constrain renderer navigation --- electron/main.ts | 13 ++ electron/navigationPolicy.test.ts | 211 ++++++++++++++++++++++++++++++ electron/navigationPolicy.ts | 145 ++++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 electron/navigationPolicy.test.ts create mode 100644 electron/navigationPolicy.ts diff --git a/electron/main.ts b/electron/main.ts index ed05ffeb..2966cbd1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,6 +11,7 @@ import { Notification, nativeImage, session, + shell, systemPreferences, Tray, } from "electron"; @@ -26,6 +27,10 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; +import { + hardenWebContentsNavigation, + shouldHardenWebContentsType, +} from "./navigationPolicy"; import { ensurePackagedRendererServer } from "./rendererServer"; import type { UpdateToastPayload } from "./updater"; import { @@ -73,6 +78,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) { diff --git a/electron/navigationPolicy.test.ts b/electron/navigationPolicy.test.ts new file mode 100644 index 00000000..f661315d --- /dev/null +++ b/electron/navigationPolicy.test.ts @@ -0,0 +1,211 @@ +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(setWindowOpenHandler).toHaveBeenCalledWith(expect.any(Function)); + }); +}); diff --git a/electron/navigationPolicy.ts b/electron/navigationPolicy.ts new file mode 100644 index 00000000..82d9f352 --- /dev/null +++ b/electron/navigationPolicy.ts @@ -0,0 +1,145 @@ +import type { WebContents } from "electron"; + +type OpenExternal = (url: string) => Promise; +type ReportOpenError = (url: string, error: unknown) => void; + +export type NavigationEvent = { + url: string; + preventDefault: () => void; +}; + +export function shouldHardenWebContentsType(type: ReturnType): 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( + getCurrentUrl: () => string, + openExternal: OpenExternal, + reportOpenError: ReportOpenError = defaultReportOpenError, +) { + return (event: NavigationEvent): void => { + const currentUrl = getCurrentUrl(); + // Preserve an exact reload, but freeze all renderer-selected destination changes, + // including same-origin query mutations that can carry privileged local paths. + if (isExactRendererLocation(currentUrl, event.url)) { + return; + } + + // The internal-target check only prevents app URLs from leaking into the system browser. + event.preventDefault(); + if (isInternalRendererTarget(currentUrl, 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, + openExternal: OpenExternal, + reportOpenError: ReportOpenError = defaultReportOpenError, +): void { + webContents.on( + "will-navigate", + createWillNavigateHandler(() => webContents.getURL(), openExternal, reportOpenError), + ); + webContents.on("will-redirect", createWillRedirectHandler()); + webContents.setWindowOpenHandler( + createWindowOpenHandler(() => webContents.getURL(), openExternal, reportOpenError), + ); +} From 146dc6a1805fb7106e641d59fbb10cace993686a Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 12:43:12 +0700 Subject: [PATCH 5/9] fix(electron): scope capture permissions to the HUD --- electron/main.ts | 107 ++++++++++++++++-- electron/permissionPolicy.test.ts | 182 ++++++++++++++++++++++++++++++ electron/permissionPolicy.ts | 157 ++++++++++++++++++++++++++ 3 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 electron/permissionPolicy.test.ts create mode 100644 electron/permissionPolicy.ts diff --git a/electron/main.ts b/electron/main.ts index ed05ffeb..8131681e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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, @@ -13,6 +13,7 @@ import { session, systemPreferences, Tray, + webContents as electronWebContents, } from "electron"; import { RECORDINGS_DIR } from "./appPaths"; import { showCursor } from "./cursorHider"; @@ -26,7 +27,8 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; -import { ensurePackagedRendererServer } from "./rendererServer"; +import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; +import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, @@ -132,6 +134,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 +905,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 +1059,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 diff --git a/electron/permissionPolicy.test.ts b/electron/permissionPolicy.test.ts new file mode 100644 index 00000000..4cd645e2 --- /dev/null +++ b/electron/permissionPolicy.test.ts @@ -0,0 +1,182 @@ +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[0]> = {}, + ): Parameters[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[0]> = {}, + ): Parameters[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 exact packaged file HUD with an opaque origin", () => { + expect( + shouldGrantDisplayCapture( + makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin: "file://" }), + 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, + ); + }); +}); diff --git a/electron/permissionPolicy.ts b/electron/permissionPolicy.ts new file mode 100644 index 00000000..0c9ea07c --- /dev/null +++ b/electron/permissionPolicy.ts @@ -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) + ); +} From 7082547b343af3fa984b20b1b5a57be05b0cf1d6 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 13:25:12 +0700 Subject: [PATCH 6/9] chore(tooling): harden repository file handling --- .eslintrc.cjs | 15 --------------- .gitattributes | 24 ++++++++++++++++++++++++ biome.json | 13 ++++++++++++- 3 files changed, 36 insertions(+), 16 deletions(-) delete mode 100644 .eslintrc.cjs diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index f63fe7dc..00000000 --- a/.eslintrc.cjs +++ /dev/null @@ -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 }], - }, -}; diff --git a/.gitattributes b/.gitattributes index 6313b56c..f54a72c5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +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 diff --git a/biome.json b/biome.json index c1d2304e..6c8695eb 100644 --- a/biome.json +++ b/biome.json @@ -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", From c97f67ec4d897d6e11007dc4bed1af76e67fefd2 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 11 Jul 2026 02:49:40 +0700 Subject: [PATCH 7/9] chore(ci): address quality review feedback --- .github/workflows/quality.yml | 5 +++++ electron/ipc/paths/binaries.test.ts | 8 ++++++++ electron/ipc/paths/binaries.ts | 26 +++++++++++--------------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 3b8169b8..45e5f651 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -47,6 +47,11 @@ jobs: 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 diff --git a/electron/ipc/paths/binaries.test.ts b/electron/ipc/paths/binaries.test.ts index 5b89d957..36186173 100644 --- a/electron/ipc/paths/binaries.test.ts +++ b/electron/ipc/paths/binaries.test.ts @@ -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, diff --git a/electron/ipc/paths/binaries.ts b/electron/ipc/paths/binaries.ts index a95a79cf..3e15f332 100644 --- a/electron/ipc/paths/binaries.ts +++ b/electron/ipc/paths/binaries.ts @@ -29,31 +29,33 @@ 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( helperDirectory: string, binaryName: string, ): string { - const windowsArchTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64"; const buildOutputPath = resolveUnpackedAppPath( "electron", "native", @@ -62,13 +64,7 @@ export function resolvePreferredWindowsNativeHelperPath( "Release", binaryName, ); - const prebundledPath = resolveUnpackedAppPath( - "electron", - "native", - "bin", - windowsArchTag, - binaryName, - ); + const prebundledPath = getPrebundledNativeHelperPath(binaryName, getNativeArchTag("win32")); if (app.isPackaged && existsSync(prebundledPath)) { return prebundledPath; From eb6a7faeb4a63298c3e62a94889b34f0959270d8 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 11 Jul 2026 10:25:38 +0700 Subject: [PATCH 8/9] fix(electron): preserve trusted navigation location --- electron/navigationPolicy.test.ts | 54 +++++++++++++++++++++++++++++++ electron/navigationPolicy.ts | 16 ++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/electron/navigationPolicy.test.ts b/electron/navigationPolicy.test.ts index f661315d..e021c96a 100644 --- a/electron/navigationPolicy.test.ts +++ b/electron/navigationPolicy.test.ts @@ -206,6 +206,60 @@ describe("navigation event handlers", () => { 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(); + }); }); diff --git a/electron/navigationPolicy.ts b/electron/navigationPolicy.ts index 82d9f352..1bc9df4e 100644 --- a/electron/navigationPolicy.ts +++ b/electron/navigationPolicy.ts @@ -88,21 +88,21 @@ const defaultReportOpenError: ReportOpenError = (url, error) => { }; export function createWillNavigateHandler( - getCurrentUrl: () => string, + getTrustedRendererUrl: () => string, openExternal: OpenExternal, reportOpenError: ReportOpenError = defaultReportOpenError, ) { return (event: NavigationEvent): void => { - const currentUrl = getCurrentUrl(); + 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(currentUrl, event.url)) { + if (isExactRendererLocation(trustedRendererUrl, event.url)) { return; } // The internal-target check only prevents app URLs from leaking into the system browser. event.preventDefault(); - if (isInternalRendererTarget(currentUrl, event.url)) { + if (isInternalRendererTarget(trustedRendererUrl, event.url)) { return; } @@ -134,9 +134,15 @@ export function hardenWebContentsNavigation( 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(() => webContents.getURL(), openExternal, reportOpenError), + createWillNavigateHandler(() => trustedRendererUrl, openExternal, reportOpenError), ); webContents.on("will-redirect", createWillRedirectHandler()); webContents.setWindowOpenHandler( From b9f66e1ae8b2bce82345e8db418698607d01da6e Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 11 Jul 2026 10:36:35 +0700 Subject: [PATCH 9/9] test(electron): cover packaged capture origins --- electron/permissionPolicy.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/electron/permissionPolicy.test.ts b/electron/permissionPolicy.test.ts index 4cd645e2..45ffc82f 100644 --- a/electron/permissionPolicy.test.ts +++ b/electron/permissionPolicy.test.ts @@ -157,15 +157,30 @@ describe("shouldGrantDisplayCapture", () => { ).toBe(true); }); - it("accepts the exact packaged file HUD with an opaque origin", () => { + it("accepts the packaged loopback renderer with its exact origin", () => { expect( shouldGrantDisplayCapture( - makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin: "file://" }), + 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 }],