mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
Merge branch 'main' into fix/735-marketplace-error-response
This commit is contained in:
@@ -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 }],
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Code Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Repository quality
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CI: true
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
id: install
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Typecheck
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
|
||||
run: npm run lint
|
||||
|
||||
- name: Check formatting (advisory)
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
run: npm run format:check
|
||||
|
||||
- name: Test
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
|
||||
run: npm test
|
||||
|
||||
# Main currently has known locale-parity debt covered by PR #710. Keep the
|
||||
# check visible without blocking unrelated PRs; make it required once that
|
||||
# existing translation PR (or an equivalent fix) lands.
|
||||
- name: Check translations (advisory)
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
run: npm run i18n:check
|
||||
+14
-34
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,24 +29,27 @@ export function getNativeCaptureHelperSourcePath(): string {
|
||||
return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitRecorder.swift");
|
||||
}
|
||||
|
||||
export function getNativeArchTag(): string {
|
||||
if (process.platform === "darwin") {
|
||||
export function getNativeArchTag(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === "darwin") {
|
||||
return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (platform === "win32") {
|
||||
return process.arch === "arm64" ? "win32-arm64" : "win32-x64";
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
if (platform === "linux") {
|
||||
return process.arch === "arm64" ? "linux-arm64" : "linux-x64";
|
||||
}
|
||||
|
||||
return `${process.platform}-${process.arch}`;
|
||||
return `${platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
export function getPrebundledNativeHelperPath(binaryName: string): string {
|
||||
return resolveUnpackedAppPath("electron", "native", "bin", getNativeArchTag(), binaryName);
|
||||
export function getPrebundledNativeHelperPath(
|
||||
binaryName: string,
|
||||
archTag = getNativeArchTag(),
|
||||
): string {
|
||||
return resolveUnpackedAppPath("electron", "native", "bin", archTag, binaryName);
|
||||
}
|
||||
|
||||
export function resolvePreferredWindowsNativeHelperPath(
|
||||
@@ -61,7 +64,7 @@ export function resolvePreferredWindowsNativeHelperPath(
|
||||
"Release",
|
||||
binaryName,
|
||||
);
|
||||
const prebundledPath = getPrebundledNativeHelperPath(binaryName);
|
||||
const prebundledPath = getPrebundledNativeHelperPath(binaryName, getNativeArchTag("win32"));
|
||||
|
||||
if (app.isPackaged && existsSync(prebundledPath)) {
|
||||
return prebundledPath;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getProjectBackupPath, writeProjectFileAtomically } from "./atomicSave";
|
||||
|
||||
describe("writeProjectFileAtomically", () => {
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-atomic-project-"));
|
||||
projectPath = path.join(tempDir, "demo.recordly");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function expectNoTemporaryArtifacts() {
|
||||
const entries = await fs.readdir(tempDir);
|
||||
expect(entries.filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
|
||||
}
|
||||
|
||||
it("commits a complete new project without creating a backup", async () => {
|
||||
await writeProjectFileAtomically(projectPath, '{"version":1}');
|
||||
|
||||
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1}');
|
||||
await expect(fs.access(getProjectBackupPath(projectPath))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expectNoTemporaryArtifacts();
|
||||
await expectNoTemporaryArtifacts();
|
||||
});
|
||||
|
||||
it("removes a stale backup when the target has no previous generation", async () => {
|
||||
await fs.writeFile(getProjectBackupPath(projectPath), "stale-project");
|
||||
|
||||
await writeProjectFileAtomically(projectPath, '{"version":1}');
|
||||
|
||||
await expect(fs.access(getProjectBackupPath(projectPath))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the previous complete generation before replacement", async () => {
|
||||
await writeProjectFileAtomically(projectPath, '{"version":1,"name":"old"}');
|
||||
await writeProjectFileAtomically(projectPath, '{"version":1,"name":"new"}');
|
||||
|
||||
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1,"name":"new"}');
|
||||
await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
|
||||
'{"version":1,"name":"old"}',
|
||||
);
|
||||
await expectNoTemporaryArtifacts();
|
||||
});
|
||||
|
||||
it("keeps the active generation unchanged when backup commit fails", async () => {
|
||||
await writeProjectFileAtomically(projectPath, '{"version":1,"name":"old"}');
|
||||
await fs.mkdir(getProjectBackupPath(projectPath));
|
||||
|
||||
await expect(
|
||||
writeProjectFileAtomically(projectPath, '{"version":1,"name":"new"}'),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1,"name":"old"}');
|
||||
await expectNoTemporaryArtifacts();
|
||||
|
||||
await fs.rm(getProjectBackupPath(projectPath), { recursive: true });
|
||||
await writeProjectFileAtomically(projectPath, '{"version":1,"name":"retry"}');
|
||||
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe(
|
||||
'{"version":1,"name":"retry"}',
|
||||
);
|
||||
await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
|
||||
'{"version":1,"name":"old"}',
|
||||
);
|
||||
await expectNoTemporaryArtifacts();
|
||||
});
|
||||
|
||||
it("serializes overlapping writes to the same project", async () => {
|
||||
await writeProjectFileAtomically(projectPath, '{"revision":1}');
|
||||
|
||||
// Each call enters the queue synchronously before its first await, preserving invocation order.
|
||||
await Promise.all([
|
||||
writeProjectFileAtomically(projectPath, '{"revision":2}'),
|
||||
writeProjectFileAtomically(projectPath, '{"revision":3}'),
|
||||
]);
|
||||
|
||||
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"revision":3}');
|
||||
await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
|
||||
'{"revision":2}',
|
||||
);
|
||||
await expectNoTemporaryArtifacts();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves exact project permission bits despite the process umask",
|
||||
async () => {
|
||||
await fs.writeFile(projectPath, '{"revision":1}', { mode: 0o666 });
|
||||
await fs.chmod(projectPath, 0o666);
|
||||
const previousUmask = process.umask(0o077);
|
||||
|
||||
try {
|
||||
await writeProjectFileAtomically(projectPath, '{"revision":2}');
|
||||
} finally {
|
||||
process.umask(previousUmask);
|
||||
}
|
||||
|
||||
expect((await fs.stat(projectPath)).mode & 0o777).toBe(0o666);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const pendingWrites = new Map<string, Promise<void>>();
|
||||
|
||||
const unsupportedDirectorySyncErrors = new Set([
|
||||
"EACCES",
|
||||
"EINVAL",
|
||||
"EISDIR",
|
||||
"ENOSYS",
|
||||
"ENOTSUP",
|
||||
"EOPNOTSUPP",
|
||||
"EPERM",
|
||||
]);
|
||||
|
||||
export function getProjectBackupPath(projectPath: string): string {
|
||||
return `${projectPath}.bak`;
|
||||
}
|
||||
|
||||
function getQueueKey(projectPath: string): string {
|
||||
const resolvedPath = path.resolve(projectPath);
|
||||
return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
|
||||
}
|
||||
|
||||
function createTemporaryPath(parentDir: string, label: string): string {
|
||||
return path.join(parentDir, `.recordly-${label}-${process.pid}-${randomUUID()}.tmp`);
|
||||
}
|
||||
|
||||
async function getExistingFileMode(filePath: string): Promise<number | undefined> {
|
||||
try {
|
||||
return (await fs.stat(filePath)).mode & 0o777;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSyncedTemporaryFile(
|
||||
filePath: string,
|
||||
contents: string,
|
||||
mode?: number,
|
||||
): Promise<void> {
|
||||
const handle = await fs.open(filePath, "wx", mode);
|
||||
try {
|
||||
await handle.writeFile(contents, "utf-8");
|
||||
if (mode !== undefined) {
|
||||
await handle.chmod(mode);
|
||||
}
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncExistingFile(filePath: string): Promise<void> {
|
||||
const handle = await fs.open(filePath, "r+");
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncParentDirectory(parentDir: string): Promise<void> {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await fs.open(parentDir, "r");
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (!code || !unsupportedDirectorySyncErrors.has(code)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function preservePreviousGeneration(
|
||||
targetPath: string,
|
||||
backupPath: string,
|
||||
backupTemporaryPath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fs.copyFile(targetPath, backupTemporaryPath, fsConstants.COPYFILE_EXCL);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
await fs.rm(backupPath, { force: true });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await syncExistingFile(backupTemporaryPath);
|
||||
await fs.rename(backupTemporaryPath, backupPath);
|
||||
}
|
||||
|
||||
async function commitProjectFile(projectPath: string, contents: string): Promise<void> {
|
||||
const targetPath = path.resolve(projectPath);
|
||||
const parentDir = path.dirname(targetPath);
|
||||
const backupPath = getProjectBackupPath(targetPath);
|
||||
const temporaryPath = createTemporaryPath(parentDir, "project");
|
||||
const backupTemporaryPath = createTemporaryPath(parentDir, "backup");
|
||||
const existingMode = await getExistingFileMode(targetPath);
|
||||
|
||||
try {
|
||||
await writeSyncedTemporaryFile(temporaryPath, contents, existingMode);
|
||||
await preservePreviousGeneration(targetPath, backupPath, backupTemporaryPath);
|
||||
await fs.rename(temporaryPath, targetPath);
|
||||
await syncParentDirectory(parentDir);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.rm(temporaryPath, { force: true }).catch(() => undefined),
|
||||
fs.rm(backupTemporaryPath, { force: true }).catch(() => undefined),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeProjectFileAtomically(
|
||||
projectPath: string,
|
||||
contents: string,
|
||||
): Promise<void> {
|
||||
const queueKey = getQueueKey(projectPath);
|
||||
const previousWrite = pendingWrites.get(queueKey) ?? Promise.resolve();
|
||||
const currentWrite = previousWrite
|
||||
.catch(() => undefined)
|
||||
.then(() => commitProjectFile(projectPath, contents));
|
||||
pendingWrites.set(queueKey, currentWrite);
|
||||
|
||||
try {
|
||||
await currentWrite;
|
||||
} finally {
|
||||
if (pendingWrites.get(queueKey) === currentWrite) {
|
||||
pendingWrites.delete(queueKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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! }
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LEGACY_PROJECT_FILE_EXTENSIONS,
|
||||
PROJECT_FILE_EXTENSION,
|
||||
} from "../constants";
|
||||
import { getProjectBackupPath, writeProjectFileAtomically } from "../project/atomicSave";
|
||||
import {
|
||||
getProjectsDir,
|
||||
getProjectThumbnailPath,
|
||||
@@ -305,7 +306,10 @@ export function registerProjectHandlers() {
|
||||
: null
|
||||
|
||||
if (trustedExistingProjectPath) {
|
||||
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
|
||||
await writeProjectFileAtomically(
|
||||
trustedExistingProjectPath,
|
||||
JSON.stringify(preparedProject.projectData, null, 2),
|
||||
)
|
||||
setCurrentProjectPath(trustedExistingProjectPath)
|
||||
await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl)
|
||||
await rememberRecentProject(trustedExistingProjectPath)
|
||||
@@ -345,7 +349,10 @@ export function registerProjectHandlers() {
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(result.filePath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
|
||||
await writeProjectFileAtomically(
|
||||
result.filePath,
|
||||
JSON.stringify(preparedProject.projectData, null, 2),
|
||||
)
|
||||
setCurrentProjectPath(result.filePath)
|
||||
await saveProjectThumbnail(result.filePath, thumbnailDataUrl)
|
||||
await rememberRecentProject(result.filePath)
|
||||
@@ -411,7 +418,10 @@ export function registerProjectHandlers() {
|
||||
return overwriteCheck
|
||||
}
|
||||
|
||||
await fs.writeFile(targetProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
|
||||
await writeProjectFileAtomically(
|
||||
targetProjectPath,
|
||||
JSON.stringify(preparedProject.projectData, null, 2),
|
||||
)
|
||||
await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl)
|
||||
await rememberRecentProject(targetProjectPath)
|
||||
|
||||
@@ -422,6 +432,7 @@ export function registerProjectHandlers() {
|
||||
}
|
||||
})
|
||||
await fs.rm(getProjectThumbnailPath(activeProjectPath), { force: true }).catch(() => undefined)
|
||||
await fs.rm(getProjectBackupPath(activeProjectPath), { force: true }).catch(() => undefined)
|
||||
|
||||
const recentProjectPaths = await loadRecentProjectPaths()
|
||||
const filteredRecentProjectPaths: string[] = []
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
waitForNativeCaptureStart,
|
||||
waitForNativeCaptureStop,
|
||||
} from "../recording/mac";
|
||||
import { resolveRecordedVideoStoragePath } from "../recording/storagePath";
|
||||
import {
|
||||
attachWindowsCaptureLifecycle,
|
||||
isNativeWindowsCaptureAvailable,
|
||||
@@ -1747,10 +1748,10 @@ export function registerRecordingHandlers(
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: unknown) => {
|
||||
try {
|
||||
const recordingsDir = await getRecordingsDir();
|
||||
const videoPath = path.join(recordingsDir, fileName);
|
||||
const videoPath = resolveRecordedVideoStoragePath(recordingsDir, fileName);
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData));
|
||||
return await finalizeStoredVideo(videoPath);
|
||||
} catch (error) {
|
||||
|
||||
+108
-12
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
Notification,
|
||||
nativeImage,
|
||||
session,
|
||||
shell,
|
||||
systemPreferences,
|
||||
Tray,
|
||||
webContents as electronWebContents,
|
||||
} from "electron";
|
||||
import { RECORDINGS_DIR } from "./appPaths";
|
||||
import { showCursor } from "./cursorHider";
|
||||
@@ -26,7 +28,12 @@ import {
|
||||
registerIpcHandlers,
|
||||
} from "./ipc/handlers";
|
||||
import { ensureMediaServer } from "./mediaServer";
|
||||
import { ensurePackagedRendererServer } from "./rendererServer";
|
||||
import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
|
||||
import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer";
|
||||
import {
|
||||
hardenWebContentsNavigation,
|
||||
shouldHardenWebContentsType,
|
||||
} from "./navigationPolicy";
|
||||
import type { UpdateToastPayload } from "./updater";
|
||||
import {
|
||||
checkForAppUpdates,
|
||||
@@ -73,6 +80,14 @@ app.commandLine.appendSwitch("ignore-gpu-blocklist");
|
||||
app.commandLine.appendSwitch("enable-unsafe-webgpu");
|
||||
app.commandLine.appendSwitch("enable-gpu-rasterization");
|
||||
|
||||
app.on("web-contents-created", (_event, contents) => {
|
||||
if (!shouldHardenWebContentsType(contents.getType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
hardenWebContentsNavigation(contents, (url) => shell.openExternal(url));
|
||||
});
|
||||
|
||||
function configureGpuAccelerationSwitches() {
|
||||
const { useAngle, useGl, disableFeatures } = getGpuSwitches(process.platform, process.env);
|
||||
if (useAngle) {
|
||||
@@ -132,6 +147,30 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||
? path.join(process.env.APP_ROOT, "public")
|
||||
: RENDERER_DIST;
|
||||
|
||||
function getTrustedCaptureDocumentBaseUrls(): string[] {
|
||||
const trustedUrls = [pathToFileURL(path.join(RENDERER_DIST, "index.html")).href];
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
trustedUrls.push(VITE_DEV_SERVER_URL);
|
||||
}
|
||||
|
||||
const packagedRendererBaseUrl = getPackagedRendererBaseUrl();
|
||||
if (packagedRendererBaseUrl) {
|
||||
trustedUrls.push(new URL("/", packagedRendererBaseUrl).href);
|
||||
}
|
||||
|
||||
return trustedUrls;
|
||||
}
|
||||
|
||||
function isHudWebContents(webContents: Electron.WebContents | null): boolean {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hudWindow = getHudOverlayWindow();
|
||||
return Boolean(hudWindow && hudWindow.webContents === webContents);
|
||||
}
|
||||
|
||||
// Window references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let sourceSelectorWindow: BrowserWindow | null = null;
|
||||
@@ -879,17 +918,47 @@ app.whenReady().then(async () => {
|
||||
app.setAppUserModelId("dev.recordly.app");
|
||||
}
|
||||
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
|
||||
return allowed.includes(permission);
|
||||
});
|
||||
session.defaultSession.setPermissionCheckHandler(
|
||||
(webContents, permission, requestingOrigin, details) => {
|
||||
return shouldGrantMediaPermission(
|
||||
{
|
||||
permission,
|
||||
isTrustedCaptureWindow: isHudWebContents(webContents),
|
||||
isMainFrame: details.isMainFrame,
|
||||
currentDocumentUrl: webContents?.getURL() ?? "",
|
||||
// Electron 39 may supply the last committed document URL, including its
|
||||
// query, in the requestingOrigin argument for media checks.
|
||||
requestingUrl: details.requestingUrl ?? requestingOrigin,
|
||||
securityOrigins:
|
||||
details.securityOrigin === undefined ? [] : [details.securityOrigin],
|
||||
},
|
||||
getTrustedCaptureDocumentBaseUrls(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
|
||||
callback(allowed.includes(permission));
|
||||
});
|
||||
session.defaultSession.setPermissionRequestHandler(
|
||||
(webContents, permission, callback, details) => {
|
||||
const securityOrigin = "securityOrigin" in details ? details.securityOrigin : undefined;
|
||||
|
||||
session.defaultSession.setDevicePermissionHandler((_details) => true);
|
||||
callback(
|
||||
shouldGrantMediaPermission(
|
||||
{
|
||||
permission,
|
||||
isTrustedCaptureWindow: isHudWebContents(webContents),
|
||||
isMainFrame: details.isMainFrame,
|
||||
currentDocumentUrl: webContents.getURL(),
|
||||
requestingUrl: details.requestingUrl,
|
||||
securityOrigins: securityOrigin === undefined ? [] : [securityOrigin],
|
||||
},
|
||||
getTrustedCaptureDocumentBaseUrls(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Recordly does not use WebHID, Web Serial, or WebUSB. Do not grant devices by default.
|
||||
session.defaultSession.setDevicePermissionHandler(() => false);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const cameraStatus = systemPreferences.getMediaAccessStatus("camera");
|
||||
@@ -1003,8 +1072,35 @@ app.whenReady().then(async () => {
|
||||
// via an unsafe cast breaks Electron's internal cursor-constraint
|
||||
// propagation and causes cursor: 'never' from the renderer to be silently
|
||||
// ignored by the native capture pipeline.
|
||||
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||
session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
|
||||
try {
|
||||
const frame = request.frame;
|
||||
const isLiveFrame = Boolean(frame && !frame.isDestroyed());
|
||||
const requestingWebContents =
|
||||
isLiveFrame && frame ? electronWebContents.fromFrame(frame) : undefined;
|
||||
const isHudMainFrame = Boolean(
|
||||
isLiveFrame &&
|
||||
requestingWebContents &&
|
||||
isHudWebContents(requestingWebContents) &&
|
||||
frame === requestingWebContents.mainFrame,
|
||||
);
|
||||
|
||||
if (
|
||||
!shouldGrantDisplayCapture(
|
||||
{
|
||||
isTrustedCaptureWindow: isHudMainFrame,
|
||||
isMainFrame: Boolean(isLiveFrame && frame?.parent === null),
|
||||
currentDocumentUrl: isLiveFrame ? (frame?.url ?? "") : "",
|
||||
securityOrigin: request.securityOrigin,
|
||||
videoRequested: request.videoRequested,
|
||||
},
|
||||
getTrustedCaptureDocumentBaseUrls(),
|
||||
)
|
||||
) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceId = getSelectedSourceId();
|
||||
// On Linux/Wayland, calling desktopCapturer.getSources() itself
|
||||
// invokes the xdg-desktop-portal picker. If we then return one of
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createWillNavigateHandler,
|
||||
createWillRedirectHandler,
|
||||
createWindowOpenHandler,
|
||||
hardenWebContentsNavigation,
|
||||
isInternalRendererTarget,
|
||||
normalizeExternalHttpUrl,
|
||||
shouldHardenWebContentsType,
|
||||
} from "./navigationPolicy";
|
||||
|
||||
describe("normalizeExternalHttpUrl", () => {
|
||||
it.each([
|
||||
["https://example.com/docs", "https://example.com/docs"],
|
||||
["http://127.0.0.1:3000/path?q=1", "http://127.0.0.1:3000/path?q=1"],
|
||||
["HTTPS://Example.COM:443/docs", "https://example.com/docs"],
|
||||
])("normalizes an external HTTP(S) URL: %s", (value, expected) => {
|
||||
expect(normalizeExternalHttpUrl(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"not a URL",
|
||||
"file:///tmp/recordly.html",
|
||||
"data:text/html,hello",
|
||||
"javascript:alert(1)",
|
||||
"mailto:security@example.com",
|
||||
"https://user:password@example.com/",
|
||||
])("rejects an unsafe external URL: %s", (value) => {
|
||||
expect(normalizeExternalHttpUrl(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldHardenWebContentsType", () => {
|
||||
it("selects BrowserWindow contents only", () => {
|
||||
expect(shouldHardenWebContentsType("window")).toBe(true);
|
||||
expect(shouldHardenWebContentsType("webview")).toBe(false);
|
||||
expect(shouldHardenWebContentsType("offscreen")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInternalRendererTarget", () => {
|
||||
it.each([
|
||||
["http://localhost:5173/?windowType=editor", "http://localhost:5173/editor?reload=1"],
|
||||
["http://127.0.0.1:43123/?windowType=editor", "http://127.0.0.1:43123/assets/index.js"],
|
||||
[
|
||||
"file:///opt/Recordly/dist/index.html?windowType=editor",
|
||||
"file:///opt/Recordly/dist/index.html?windowType=hud-overlay#status",
|
||||
],
|
||||
])("identifies the current renderer origin/file", (currentUrl, targetUrl) => {
|
||||
expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://localhost:5173/?windowType=editor", "http://localhost.example.com:5173/"],
|
||||
["http://localhost:5173/", "http://localhost:5174/"],
|
||||
["https://recordly.example/", "http://recordly.example/"],
|
||||
["https://recordly.example/", "https://user:pass@recordly.example/"],
|
||||
["file:///opt/Recordly/dist/index.html", "file:///etc/passwd"],
|
||||
["file:///opt/Recordly/dist/index.html", "data:text/html,hello"],
|
||||
["not a URL", "https://example.com/"],
|
||||
])("distinguishes a target outside the current renderer", (currentUrl, targetUrl) => {
|
||||
expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation event handlers", () => {
|
||||
it("preserves an exact renderer reload", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "http://localhost:5173/editor?windowType=editor",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({
|
||||
url: "http://localhost:5173/editor?windowType=editor",
|
||||
preventDefault,
|
||||
});
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops same-origin renderer navigation without externalizing it", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "http://localhost:5173/editor",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({ url: "http://localhost:5173/settings", preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops same-file query mutation without externalizing it", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "file:///opt/Recordly/dist/index.html?windowType=editor",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({
|
||||
url: "file:///opt/Recordly/dist/index.html?smokeExport=1",
|
||||
preventDefault,
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops cross-origin navigation and opens HTTP(S) in the system browser", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "http://localhost:5173/editor",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({ url: "https://example.com/docs", preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).toHaveBeenCalledWith("https://example.com/docs");
|
||||
});
|
||||
|
||||
it("stops unsafe schemes without opening them externally", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "file:///opt/Recordly/dist/index.html",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({ url: "file:///etc/passwd", preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops server redirects without externalizing the target", () => {
|
||||
const preventDefault = vi.fn();
|
||||
createWillRedirectHandler()({ url: "https://example.com/redirect", preventDefault });
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("always denies Electron child windows while preserving safe external links", () => {
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWindowOpenHandler(() => "http://localhost:5173/editor", openExternal);
|
||||
|
||||
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
|
||||
expect(handler({ url: "http://localhost:5173/settings" })).toEqual({ action: "deny" });
|
||||
expect(handler({ url: "javascript:alert(1)" })).toEqual({ action: "deny" });
|
||||
expect(openExternal).toHaveBeenCalledOnce();
|
||||
expect(openExternal).toHaveBeenCalledWith("https://example.com/docs");
|
||||
});
|
||||
|
||||
it("reports a system-browser failure without allowing the child window", async () => {
|
||||
const error = new Error("browser unavailable");
|
||||
const reportOpenError = vi.fn();
|
||||
const handler = createWindowOpenHandler(
|
||||
() => "http://localhost:5173/editor",
|
||||
vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
reportOpenError,
|
||||
);
|
||||
|
||||
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
|
||||
await vi.waitFor(() => {
|
||||
expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a synchronous browser-launch failure while still denying the child", () => {
|
||||
const error = new Error("browser launch threw");
|
||||
const reportOpenError = vi.fn();
|
||||
const handler = createWindowOpenHandler(
|
||||
() => "http://localhost:5173/editor",
|
||||
vi.fn(() => {
|
||||
throw error;
|
||||
}),
|
||||
reportOpenError,
|
||||
);
|
||||
|
||||
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
|
||||
expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error);
|
||||
});
|
||||
|
||||
it("attaches navigation, redirect, and window-open policies", () => {
|
||||
const on = vi.fn();
|
||||
const setWindowOpenHandler = vi.fn();
|
||||
const webContents = {
|
||||
getURL: () => "http://localhost:5173/",
|
||||
on,
|
||||
setWindowOpenHandler,
|
||||
};
|
||||
|
||||
hardenWebContentsNavigation(
|
||||
webContents,
|
||||
vi.fn(async () => undefined),
|
||||
);
|
||||
|
||||
expect(on).toHaveBeenCalledWith("will-navigate", expect.any(Function));
|
||||
expect(on).toHaveBeenCalledWith("will-redirect", expect.any(Function));
|
||||
expect(on).toHaveBeenCalledWith("did-navigate", expect.any(Function));
|
||||
expect(setWindowOpenHandler).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
it("does not trust a renderer-mutated URL as an exact reload", () => {
|
||||
let currentUrl = "file:///opt/Recordly/dist/index.html?windowType=editor";
|
||||
const on = vi.fn();
|
||||
const webContents = {
|
||||
getURL: () => currentUrl,
|
||||
on,
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
};
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
|
||||
hardenWebContentsNavigation(webContents, openExternal);
|
||||
|
||||
// history.replaceState() changes getURL() without crossing a document-navigation boundary.
|
||||
currentUrl = "file:///opt/Recordly/dist/index.html?windowType=source-selector";
|
||||
const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1];
|
||||
if (typeof willNavigate !== "function") {
|
||||
throw new Error("will-navigate handler was not registered");
|
||||
}
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
willNavigate({ url: currentUrl, preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trusts an exact reload after a completed document navigation", () => {
|
||||
const on = vi.fn();
|
||||
const webContents = {
|
||||
getURL: () => "",
|
||||
on,
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
};
|
||||
|
||||
hardenWebContentsNavigation(
|
||||
webContents,
|
||||
vi.fn(async () => undefined),
|
||||
);
|
||||
|
||||
const didNavigate = on.mock.calls.find(([eventName]) => eventName === "did-navigate")?.[1];
|
||||
const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1];
|
||||
if (typeof didNavigate !== "function" || typeof willNavigate !== "function") {
|
||||
throw new Error("navigation handlers were not registered");
|
||||
}
|
||||
|
||||
const loadedUrl = "file:///opt/Recordly/dist/index.html?windowType=editor";
|
||||
didNavigate({}, loadedUrl);
|
||||
const preventDefault = vi.fn();
|
||||
willNavigate({ url: loadedUrl, preventDefault });
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { WebContents } from "electron";
|
||||
|
||||
type OpenExternal = (url: string) => Promise<unknown>;
|
||||
type ReportOpenError = (url: string, error: unknown) => void;
|
||||
|
||||
export type NavigationEvent = {
|
||||
url: string;
|
||||
preventDefault: () => void;
|
||||
};
|
||||
|
||||
export function shouldHardenWebContentsType(type: ReturnType<WebContents["getType"]>): boolean {
|
||||
return type === "window";
|
||||
}
|
||||
|
||||
export function normalizeExternalHttpUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (
|
||||
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
||||
!url.hostname ||
|
||||
url.username ||
|
||||
url.password
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInternalRendererTarget(currentValue: string, targetValue: string): boolean {
|
||||
try {
|
||||
const currentUrl = new URL(currentValue);
|
||||
const targetUrl = new URL(targetValue);
|
||||
|
||||
if (targetUrl.username || targetUrl.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(currentUrl.protocol === "http:" || currentUrl.protocol === "https:") &&
|
||||
(targetUrl.protocol === "http:" || targetUrl.protocol === "https:")
|
||||
) {
|
||||
return currentUrl.origin === targetUrl.origin;
|
||||
}
|
||||
|
||||
if (currentUrl.protocol === "file:" && targetUrl.protocol === "file:") {
|
||||
return currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname;
|
||||
}
|
||||
|
||||
return currentUrl.href === targetUrl.href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isExactRendererLocation(currentValue: string, targetValue: string): boolean {
|
||||
try {
|
||||
const currentUrl = new URL(currentValue);
|
||||
const targetUrl = new URL(targetValue);
|
||||
return !targetUrl.username && !targetUrl.password && currentUrl.href === targetUrl.href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalIfSafe(
|
||||
value: string,
|
||||
openExternal: OpenExternal,
|
||||
reportOpenError: ReportOpenError,
|
||||
): void {
|
||||
const safeUrl = normalizeExternalHttpUrl(value);
|
||||
if (!safeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void openExternal(safeUrl).catch((error) => reportOpenError(safeUrl, error));
|
||||
} catch (error) {
|
||||
reportOpenError(safeUrl, error);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultReportOpenError: ReportOpenError = (url, error) => {
|
||||
console.error("[navigation-policy] Failed to open external URL", { url, error });
|
||||
};
|
||||
|
||||
export function createWillNavigateHandler(
|
||||
getTrustedRendererUrl: () => string,
|
||||
openExternal: OpenExternal,
|
||||
reportOpenError: ReportOpenError = defaultReportOpenError,
|
||||
) {
|
||||
return (event: NavigationEvent): void => {
|
||||
const trustedRendererUrl = getTrustedRendererUrl();
|
||||
// Preserve an exact reload, but freeze all renderer-selected destination changes,
|
||||
// including same-origin query mutations that can carry privileged local paths.
|
||||
if (isExactRendererLocation(trustedRendererUrl, event.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The internal-target check only prevents app URLs from leaking into the system browser.
|
||||
event.preventDefault();
|
||||
if (isInternalRendererTarget(trustedRendererUrl, event.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
openExternalIfSafe(event.url, openExternal, reportOpenError);
|
||||
};
|
||||
}
|
||||
|
||||
export function createWillRedirectHandler() {
|
||||
return (event: NavigationEvent): void => {
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
|
||||
export function createWindowOpenHandler(
|
||||
getCurrentUrl: () => string,
|
||||
openExternal: OpenExternal,
|
||||
reportOpenError: ReportOpenError = defaultReportOpenError,
|
||||
) {
|
||||
return (details: { url: string }) => {
|
||||
if (!isInternalRendererTarget(getCurrentUrl(), details.url)) {
|
||||
openExternalIfSafe(details.url, openExternal, reportOpenError);
|
||||
}
|
||||
return { action: "deny" as const };
|
||||
};
|
||||
}
|
||||
|
||||
export function hardenWebContentsNavigation(
|
||||
webContents: Pick<WebContents, "getURL" | "on" | "setWindowOpenHandler">,
|
||||
openExternal: OpenExternal,
|
||||
reportOpenError: ReportOpenError = defaultReportOpenError,
|
||||
): void {
|
||||
// Renderer history APIs mutate getURL() without a document navigation. Keep the last
|
||||
// main-frame document URL as the reload trust boundary instead of trusting that live value.
|
||||
let trustedRendererUrl = webContents.getURL();
|
||||
webContents.on("did-navigate", (_event, url) => {
|
||||
trustedRendererUrl = url;
|
||||
});
|
||||
webContents.on(
|
||||
"will-navigate",
|
||||
createWillNavigateHandler(() => trustedRendererUrl, openExternal, reportOpenError),
|
||||
);
|
||||
webContents.on("will-redirect", createWillRedirectHandler());
|
||||
webContents.setWindowOpenHandler(
|
||||
createWindowOpenHandler(() => webContents.getURL(), openExternal, reportOpenError),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isTrustedCaptureDocumentUrl,
|
||||
shouldGrantDisplayCapture,
|
||||
shouldGrantMediaPermission,
|
||||
} from "./permissionPolicy";
|
||||
|
||||
const TRUSTED_DOCUMENT_BASE_URLS = [
|
||||
"http://localhost:5173/",
|
||||
"http://127.0.0.1:43127/",
|
||||
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html",
|
||||
];
|
||||
|
||||
const DEV_HUD_URL = "http://localhost:5173/?windowType=hud-overlay";
|
||||
const PACKAGED_HUD_URL = "http://127.0.0.1:43127/?windowType=hud-overlay";
|
||||
const FILE_HUD_URL =
|
||||
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html?windowType=hud-overlay";
|
||||
|
||||
describe("isTrustedCaptureDocumentUrl", () => {
|
||||
it.each([
|
||||
DEV_HUD_URL,
|
||||
PACKAGED_HUD_URL,
|
||||
FILE_HUD_URL,
|
||||
`${DEV_HUD_URL}#microphone`,
|
||||
])("accepts a Recordly HUD document: %s", (candidateUrl) => {
|
||||
expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://localhost:5173/",
|
||||
"http://localhost:5173/?windowType=editor",
|
||||
"http://localhost:5173/?windowType=HUD-OVERLAY",
|
||||
"http://localhost:5173/?windowType=hud-overlay&debug=1",
|
||||
"http://localhost:5173/?windowType=hud-overlay&windowType=hud-overlay",
|
||||
"http://localhost:5174/?windowType=hud-overlay",
|
||||
"http://localhost.evil.test:5173/?windowType=hud-overlay",
|
||||
"http://localhost:5173.evil.test/?windowType=hud-overlay",
|
||||
"http://user@localhost:5173/?windowType=hud-overlay",
|
||||
"https://localhost:5173/?windowType=hud-overlay",
|
||||
"http://127.0.0.1:43127/nested/?windowType=hud-overlay",
|
||||
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/other.html?windowType=hud-overlay",
|
||||
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html/child?windowType=hud-overlay",
|
||||
"data:text/html,recordly?windowType=hud-overlay",
|
||||
"not a url",
|
||||
])("rejects a non-Recordly capture document: %s", (candidateUrl) => {
|
||||
expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores malformed trusted base URLs instead of throwing", () => {
|
||||
expect(
|
||||
isTrustedCaptureDocumentUrl(DEV_HUD_URL, ["not a url", ...TRUSTED_DOCUMENT_BASE_URLS]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldGrantMediaPermission", () => {
|
||||
const makeRequest = (
|
||||
overrides: Partial<Parameters<typeof shouldGrantMediaPermission>[0]> = {},
|
||||
): Parameters<typeof shouldGrantMediaPermission>[0] => ({
|
||||
permission: "media",
|
||||
isTrustedCaptureWindow: true,
|
||||
isMainFrame: true,
|
||||
currentDocumentUrl: DEV_HUD_URL,
|
||||
requestingUrl: DEV_HUD_URL,
|
||||
securityOrigins: ["http://localhost:5173"],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("grants camera or microphone media to the trusted HUD main frame", () => {
|
||||
expect(shouldGrantMediaPermission(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts Chromium's trailing-slash HTTP origin serialization", () => {
|
||||
expect(
|
||||
shouldGrantMediaPermission(
|
||||
makeRequest({ securityOrigins: ["http://localhost:5173/"] }),
|
||||
TRUSTED_DOCUMENT_BASE_URLS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the packaged loopback renderer with its exact origin", () => {
|
||||
expect(
|
||||
shouldGrantMediaPermission(
|
||||
makeRequest({
|
||||
currentDocumentUrl: PACKAGED_HUD_URL,
|
||||
requestingUrl: PACKAGED_HUD_URL,
|
||||
securityOrigins: ["http://127.0.0.1:43127"],
|
||||
}),
|
||||
TRUSTED_DOCUMENT_BASE_URLS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"null",
|
||||
"file://",
|
||||
"file:///",
|
||||
])("accepts Chromium's packaged file origin form: %s", (securityOrigin) => {
|
||||
expect(
|
||||
shouldGrantMediaPermission(
|
||||
makeRequest({
|
||||
currentDocumentUrl: FILE_HUD_URL,
|
||||
requestingUrl: FILE_HUD_URL,
|
||||
securityOrigins: [securityOrigin],
|
||||
}),
|
||||
TRUSTED_DOCUMENT_BASE_URLS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another permission", { permission: "display-capture" }],
|
||||
["another BrowserWindow", { isTrustedCaptureWindow: false }],
|
||||
["a subframe", { isMainFrame: false }],
|
||||
["an untrusted current document", { currentDocumentUrl: "https://example.com/" }],
|
||||
["a missing requesting document", { requestingUrl: "" }],
|
||||
["an untrusted requesting document", { requestingUrl: "https://example.com/" }],
|
||||
["a different trusted document", { requestingUrl: PACKAGED_HUD_URL }],
|
||||
["a mismatched origin", { securityOrigins: ["http://localhost:5174"] }],
|
||||
["an origin lookalike", { securityOrigins: ["http://localhost:5173.evil.test"] }],
|
||||
["an origin with credentials", { securityOrigins: ["http://user@localhost:5173/"] }],
|
||||
["an origin with a path", { securityOrigins: ["http://localhost:5173/other"] }],
|
||||
["an origin with a query", { securityOrigins: ["http://localhost:5173/?debug=1"] }],
|
||||
["a missing security origin", { securityOrigins: [] }],
|
||||
["an empty origin", { securityOrigins: [""] }],
|
||||
] as const)("denies %s", (_label, overrides) => {
|
||||
expect(shouldGrantMediaPermission(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldGrantDisplayCapture", () => {
|
||||
const makeRequest = (
|
||||
overrides: Partial<Parameters<typeof shouldGrantDisplayCapture>[0]> = {},
|
||||
): Parameters<typeof shouldGrantDisplayCapture>[0] => ({
|
||||
isTrustedCaptureWindow: true,
|
||||
isMainFrame: true,
|
||||
currentDocumentUrl: DEV_HUD_URL,
|
||||
securityOrigin: "http://localhost:5173",
|
||||
videoRequested: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("grants selected-display video to the trusted HUD main frame", () => {
|
||||
expect(shouldGrantDisplayCapture(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a trailing slash on the display security origin", () => {
|
||||
expect(
|
||||
shouldGrantDisplayCapture(
|
||||
makeRequest({ securityOrigin: "http://localhost:5173/" }),
|
||||
TRUSTED_DOCUMENT_BASE_URLS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the packaged loopback renderer with its exact origin", () => {
|
||||
expect(
|
||||
shouldGrantDisplayCapture(
|
||||
makeRequest({
|
||||
currentDocumentUrl: PACKAGED_HUD_URL,
|
||||
securityOrigin: "http://127.0.0.1:43127",
|
||||
}),
|
||||
TRUSTED_DOCUMENT_BASE_URLS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["null", "file://", "file:///"])(
|
||||
"accepts Chromium's packaged file origin form: %s",
|
||||
(securityOrigin) => {
|
||||
expect(
|
||||
shouldGrantDisplayCapture(
|
||||
makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }),
|
||||
TRUSTED_DOCUMENT_BASE_URLS,
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["another BrowserWindow", { isTrustedCaptureWindow: false }],
|
||||
["a subframe", { isMainFrame: false }],
|
||||
["a non-Recordly document", { currentDocumentUrl: "https://example.com/" }],
|
||||
["a mismatched origin", { securityOrigin: "http://127.0.0.1:43127" }],
|
||||
["a malformed origin", { securityOrigin: "not an origin" }],
|
||||
["an origin with a path", { securityOrigin: "http://localhost:5173/other" }],
|
||||
["an audio-only request", { videoRequested: false }],
|
||||
] as const)("denies %s", (_label, overrides) => {
|
||||
expect(shouldGrantDisplayCapture(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Generated
+879
-3089
File diff suppressed because it is too large
Load Diff
+5
-11
@@ -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",
|
||||
@@ -55,8 +56,6 @@
|
||||
"@biomejs/biome": "2.3.13",
|
||||
"@electron/rebuild": "^4.0.3",
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
"@pixi/filter-motion-blur": "^5.1.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -72,7 +71,6 @@
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@uiw/color-convert": "^2.9.2",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
@@ -80,14 +78,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"electron": "^39.2.7",
|
||||
"electron": "^39.8.10",
|
||||
"electron-builder": "^26.7.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"fast-check": "^4.5.2",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gif.js": "^0.2.0",
|
||||
"gsap": "^3.13.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
@@ -105,11 +100,10 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.2.2",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest": "^4.1.10",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
"main": "dist-electron/main.cjs"
|
||||
|
||||
@@ -80,7 +80,7 @@ export const SourceSelectorContent = ({
|
||||
windowSources = [],
|
||||
selectedSource = "Screen",
|
||||
loading = false,
|
||||
onSourceSelect = () => {},
|
||||
onSourceSelect = () => undefined,
|
||||
}: Pick<SourceSelectorProps, "screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect">) => {
|
||||
const t = useScopedT("launch");
|
||||
const renderSourceItem = (source: DesktopSource, index: number) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { createContext, type MouseEvent, useContext } from "react";
|
||||
|
||||
interface HudInteractionContextType {
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: (event: any) => void;
|
||||
onMouseLeave: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export const HudInteractionContext = createContext<HudInteractionContextType | null>(null);
|
||||
|
||||
@@ -169,7 +169,6 @@ import {
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import {
|
||||
layoutVideoContent as layoutVideoContentUtil,
|
||||
scalePreviewBorderRadius,
|
||||
} from "./videoPlayback/layoutUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
|
||||
|
||||
@@ -6,7 +6,7 @@ type WaveformWorkerRequest = {
|
||||
|
||||
interface WorkerContext {
|
||||
onmessage: (e: MessageEvent<WaveformWorkerRequest>) => void;
|
||||
postMessage: (message: any, transfer?: Transferable[]) => void;
|
||||
postMessage: (message: unknown, transfer?: Transferable[]) => void;
|
||||
}
|
||||
|
||||
const workerScope = self as unknown as WorkerContext;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { TimelineRegion } from "../core/timelineTypes";
|
||||
|
||||
interface UseTimelineSelectionParams {
|
||||
@@ -56,7 +55,10 @@ export function useTimelineSelection({
|
||||
if (totalMs === 0) return;
|
||||
const time = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return;
|
||||
setKeyframes((prev) => [...prev, { id: uuidv4(), time }]);
|
||||
setKeyframes((prev) => [
|
||||
...prev,
|
||||
{ id: globalThis.crypto.randomUUID(), time },
|
||||
]);
|
||||
}, [currentTimeMs, totalMs, keyframes]);
|
||||
|
||||
const deleteSelectedKeyframe = useCallback(() => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user