From 41119593643dc47df916d94f243ffcf9cdf189da Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 05:07:19 +0700 Subject: [PATCH 1/5] 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/5] 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/5] 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 7082547b343af3fa984b20b1b5a57be05b0cf1d6 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 13:25:12 +0700 Subject: [PATCH 4/5] 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 5/5] 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;