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 new file mode 100644 index 00000000..f54a72c5 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 00000000..45e5f651 --- /dev/null +++ b/.github/workflows/quality.yml @@ -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 diff --git a/biome.json b/biome.json index 30eb475d..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", @@ -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": { 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 739f7df8..3e15f332 100644 --- a/electron/ipc/paths/binaries.ts +++ b/electron/ipc/paths/binaries.ts @@ -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; 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/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/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) { 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", ); });