mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
Merge remote-tracking branch 'origin/main' into fix/hud-visible-after-recording-start
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": {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatMarketplaceHttpError } from "./errorUtils";
|
||||
|
||||
describe("formatMarketplaceHttpError", () => {
|
||||
it("hides upstream HTML when the marketplace is unavailable", () => {
|
||||
const html = "<!DOCTYPE html><html><body>SSL handshake failed</body></html>";
|
||||
|
||||
const message = formatMarketplaceHttpError({
|
||||
status: 525,
|
||||
contentType: "text/html; charset=UTF-8",
|
||||
body: html,
|
||||
});
|
||||
|
||||
expect(message).toBe(
|
||||
"Marketplace is temporarily unavailable (HTTP 525). Please try again later.",
|
||||
);
|
||||
expect(message).not.toContain(html);
|
||||
});
|
||||
|
||||
it("keeps a short JSON error for client-side request failures", () => {
|
||||
expect(
|
||||
formatMarketplaceHttpError({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Invalid search query" }),
|
||||
}),
|
||||
).toBe("Marketplace request failed (HTTP 400): Invalid search query");
|
||||
});
|
||||
|
||||
it("uses a JSON message when an error field is absent", () => {
|
||||
expect(
|
||||
formatMarketplaceHttpError({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Extension version already exists" }),
|
||||
}),
|
||||
).toBe("Marketplace request failed (HTTP 409): Extension version already exists");
|
||||
});
|
||||
|
||||
it("prefers a string error when both JSON detail fields are present", () => {
|
||||
expect(
|
||||
formatMarketplaceHttpError({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Primary detail", message: "Secondary detail" }),
|
||||
}),
|
||||
).toBe("Marketplace request failed (HTTP 400): Primary detail");
|
||||
});
|
||||
|
||||
it("hides malformed JSON bodies", () => {
|
||||
const body = '{"error":"internal route details"';
|
||||
const message = formatMarketplaceHttpError({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body,
|
||||
});
|
||||
|
||||
expect(message).toBe("Marketplace request failed (HTTP 400).");
|
||||
expect(message).not.toContain(body);
|
||||
});
|
||||
|
||||
it("bounds long JSON details and marks truncation without splitting Unicode", () => {
|
||||
const detail = `🚀${"x".repeat(200)}`;
|
||||
const message = formatMarketplaceHttpError({
|
||||
status: 400,
|
||||
contentType: "application/problem+json",
|
||||
body: JSON.stringify({ error: detail }),
|
||||
});
|
||||
|
||||
expect(message).toBe(`Marketplace request failed (HTTP 400): 🚀${"x".repeat(198)}…`);
|
||||
expect(Array.from(message.split(": ")[1])).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("does not expose non-JSON response bodies", () => {
|
||||
expect(
|
||||
formatMarketplaceHttpError({
|
||||
status: 404,
|
||||
contentType: "text/plain",
|
||||
body: "internal route details",
|
||||
}),
|
||||
).toBe("Marketplace request failed (HTTP 404).");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,43 @@
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_MARKETPLACE_ERROR_DETAIL_LENGTH = 200;
|
||||
|
||||
export function formatMarketplaceHttpError({
|
||||
status,
|
||||
contentType,
|
||||
body,
|
||||
}: {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
body: string;
|
||||
}): string {
|
||||
if (status >= 500) {
|
||||
return `Marketplace is temporarily unavailable (HTTP ${status}). Please try again later.`;
|
||||
}
|
||||
|
||||
let detail: string | null = null;
|
||||
if (contentType?.toLowerCase().includes("json")) {
|
||||
try {
|
||||
const payload: unknown = JSON.parse(body);
|
||||
if (payload && typeof payload === "object") {
|
||||
const { error, message } = payload as { error?: unknown; message?: unknown };
|
||||
const value = typeof error === "string" ? error : message;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const normalized = value.trim().replace(/\s+/g, " ");
|
||||
const codePoints = Array.from(normalized);
|
||||
detail =
|
||||
codePoints.length > MAX_MARKETPLACE_ERROR_DETAIL_LENGTH
|
||||
? `${codePoints.slice(0, MAX_MARKETPLACE_ERROR_DETAIL_LENGTH - 1).join("")}…`
|
||||
: normalized;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed or non-API responses are intentionally not exposed to the renderer.
|
||||
}
|
||||
}
|
||||
|
||||
const summary = `Marketplace request failed (HTTP ${status})`;
|
||||
return detail ? `${summary}: ${detail}` : `${summary}.`;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
import { app } from "electron";
|
||||
import { getErrorMessage } from "./errorUtils";
|
||||
import { formatMarketplaceHttpError, getErrorMessage } from "./errorUtils";
|
||||
import { getRegisteredExtensions, installExtensionFromPath } from "./extensionLoader";
|
||||
import type {
|
||||
ExtensionReview,
|
||||
@@ -97,7 +97,13 @@ async function marketplaceFetch<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Marketplace API error ${response.status}: ${text}`);
|
||||
throw new Error(
|
||||
formatMarketplaceHttpError({
|
||||
status: response.status,
|
||||
contentType: response.headers.get("content-type"),
|
||||
body: text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -87,7 +87,12 @@ import {
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
import { planClipSpeedChange } from "./clipSpeedChange";
|
||||
import { ExtensionIcon } from "./ExtensionIcon";
|
||||
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
|
||||
import {
|
||||
calculateMp4ExportDimensions,
|
||||
calculateMp4SourceDimensions,
|
||||
type Mp4SupportProbeSnapshot,
|
||||
shouldDebounceMp4SupportProbe,
|
||||
} from "./exportDimensions";
|
||||
import { resolveSavingExportProgress } from "./exportProgressState";
|
||||
import { resolveExportStartSettings } from "./exportStartSettings";
|
||||
import { resolveExportStatusModel } from "./exportStatusModel";
|
||||
@@ -256,6 +261,7 @@ type CancelableExporter = {
|
||||
};
|
||||
|
||||
const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024;
|
||||
const MP4_CROP_PROBE_DEBOUNCE_MS = 200;
|
||||
|
||||
async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise<string | null> {
|
||||
if (
|
||||
@@ -701,6 +707,7 @@ export default function VideoEditor() {
|
||||
const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0);
|
||||
const cropSnapshotRef = useRef<CropRegion | null>(null);
|
||||
const mp4SupportRequestRef = useRef(0);
|
||||
const previousMp4SupportProbeRef = useRef<Mp4SupportProbeSnapshot | null>(null);
|
||||
const smokeExportStartedRef = useRef(false);
|
||||
const projectAutosaveTimeoutRef = useRef<number | null>(null);
|
||||
const pendingProjectSaveDialogRef = useRef<PendingProjectSaveDialog | null>(null);
|
||||
@@ -1485,15 +1492,22 @@ export default function VideoEditor() {
|
||||
[gifSizePreset],
|
||||
);
|
||||
|
||||
const desiredMp4SourceDimensions = useMemo(
|
||||
() =>
|
||||
calculateMp4SourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
aspectRatio,
|
||||
),
|
||||
[aspectRatio],
|
||||
);
|
||||
const mp4SourceDimensions = useMemo(() => {
|
||||
const sourceVideo = isPreviewReady ? videoPlaybackRef.current?.video : null;
|
||||
return {
|
||||
width: sourceVideo?.videoWidth || 1920,
|
||||
height: sourceVideo?.videoHeight || 1080,
|
||||
};
|
||||
}, [isPreviewReady]);
|
||||
|
||||
const desiredMp4SourceDimensions = useMemo(() => {
|
||||
return calculateMp4SourceDimensions(
|
||||
mp4SourceDimensions.width,
|
||||
mp4SourceDimensions.height,
|
||||
aspectRatio,
|
||||
cropRegion,
|
||||
);
|
||||
}, [aspectRatio, cropRegion, mp4SourceDimensions.height, mp4SourceDimensions.width]);
|
||||
|
||||
const mp4OutputDimensions = useMemo(() => {
|
||||
const baseWidth = supportedMp4SourceDimensions.encoderPath
|
||||
@@ -1533,21 +1547,6 @@ export default function VideoEditor() {
|
||||
);
|
||||
}
|
||||
|
||||
setSupportedMp4SourceDimensions((current) => {
|
||||
if (
|
||||
current.width === result.width &&
|
||||
current.height === result.height &&
|
||||
current.capped === result.capped &&
|
||||
current.encoderPath?.codec === result.encoderPath?.codec &&
|
||||
current.encoderPath?.hardwareAcceleration ===
|
||||
result.encoderPath?.hardwareAcceleration
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
[desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width],
|
||||
@@ -1557,6 +1556,19 @@ export default function VideoEditor() {
|
||||
let cancelled = false;
|
||||
const requestId = mp4SupportRequestRef.current + 1;
|
||||
mp4SupportRequestRef.current = requestId;
|
||||
const probeSnapshot: Mp4SupportProbeSnapshot = {
|
||||
sourceWidth: mp4SourceDimensions.width,
|
||||
sourceHeight: mp4SourceDimensions.height,
|
||||
targetWidth: desiredMp4SourceDimensions.width,
|
||||
targetHeight: desiredMp4SourceDimensions.height,
|
||||
aspectRatio,
|
||||
frameRate: mp4FrameRate,
|
||||
};
|
||||
const shouldDebounce = shouldDebounceMp4SupportProbe(
|
||||
previousMp4SupportProbeRef.current,
|
||||
probeSnapshot,
|
||||
);
|
||||
previousMp4SupportProbeRef.current = probeSnapshot;
|
||||
setSupportedMp4SourceDimensions({
|
||||
width: desiredMp4SourceDimensions.width,
|
||||
height: desiredMp4SourceDimensions.height,
|
||||
@@ -1564,33 +1576,48 @@ export default function VideoEditor() {
|
||||
encoderPath: null,
|
||||
});
|
||||
|
||||
void ensureSupportedMp4SourceDimensions(mp4FrameRate)
|
||||
.then((result) => {
|
||||
if (cancelled || requestId !== mp4SupportRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
setSupportedMp4SourceDimensions(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || requestId !== mp4SupportRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
setSupportedMp4SourceDimensions({
|
||||
width: desiredMp4SourceDimensions.width,
|
||||
height: desiredMp4SourceDimensions.height,
|
||||
capped: false,
|
||||
encoderPath: null,
|
||||
const runProbe = () => {
|
||||
void ensureSupportedMp4SourceDimensions(mp4FrameRate)
|
||||
.then((result) => {
|
||||
if (cancelled || requestId !== mp4SupportRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
setSupportedMp4SourceDimensions(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || requestId !== mp4SupportRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
setSupportedMp4SourceDimensions({
|
||||
width: desiredMp4SourceDimensions.width,
|
||||
height: desiredMp4SourceDimensions.height,
|
||||
capped: false,
|
||||
encoderPath: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const timeoutId = shouldDebounce
|
||||
? window.setTimeout(runProbe, MP4_CROP_PROBE_DEBOUNCE_MS)
|
||||
: null;
|
||||
if (timeoutId === null) {
|
||||
runProbe();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
aspectRatio,
|
||||
desiredMp4SourceDimensions.height,
|
||||
desiredMp4SourceDimensions.width,
|
||||
ensureSupportedMp4SourceDimensions,
|
||||
mp4FrameRate,
|
||||
mp4SourceDimensions.height,
|
||||
mp4SourceDimensions.width,
|
||||
]);
|
||||
|
||||
// Extension-contributed standalone section pages (no parentSection)
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
enablePitchPreservingPlayback,
|
||||
getMediaSyncPlaybackRate,
|
||||
} from "@/lib/mediaTiming";
|
||||
import {
|
||||
destroyPixiApplication,
|
||||
initializePixiApplicationWithTimeout,
|
||||
} from "@/lib/pixiApplicationLifecycle";
|
||||
import {
|
||||
DEFAULT_WALLPAPER_PATH,
|
||||
DEFAULT_WALLPAPER_RELATIVE_PATH,
|
||||
@@ -169,7 +173,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";
|
||||
@@ -260,30 +263,6 @@ function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): st
|
||||
return `No supported Pixi preview renderer was available. Attempted: ${details}`;
|
||||
}
|
||||
|
||||
type PixiInitOptions = Parameters<Application["init"]>[0];
|
||||
|
||||
async function initApplicationWithTimeout(
|
||||
app: Application,
|
||||
options: PixiInitOptions,
|
||||
backend: PixiPreviewBackend,
|
||||
): Promise<void> {
|
||||
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(timeoutErrorMessage));
|
||||
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([app.init(options), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCursorPositionAtTime(
|
||||
telemetry: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
@@ -681,7 +660,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const initStarted =
|
||||
typeof performance === "undefined" ? Date.now() : performance.now();
|
||||
try {
|
||||
await initApplicationWithTimeout(
|
||||
await initializePixiApplicationWithTimeout(
|
||||
rendererApp,
|
||||
{
|
||||
width: container.clientWidth,
|
||||
@@ -695,6 +674,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
autoStart: true,
|
||||
sharedTicker: false,
|
||||
},
|
||||
PIXI_RENDERER_INIT_TIMEOUT_MS,
|
||||
backend,
|
||||
);
|
||||
const elapsed = Math.round(
|
||||
@@ -723,7 +703,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
`[VideoPlayback] Failed to init ${backend} renderer (${statusMessage}) after ${elapsed}ms; trying fallback.`,
|
||||
error,
|
||||
);
|
||||
rendererApp.destroy(true);
|
||||
destroyPixiApplication(
|
||||
rendererApp,
|
||||
`${backend} preview renderer initialization`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2127,11 +2110,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
app.ticker.maxFPS = 60;
|
||||
|
||||
if (!mounted) {
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
texture: false,
|
||||
textureSource: false,
|
||||
});
|
||||
destroyPixiApplication(app, "unmounted preview renderer");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2227,13 +2206,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
motionBlurFilterRef.current?.destroy();
|
||||
zoomBlurFilterRef.current = null;
|
||||
motionBlurFilterRef.current = null;
|
||||
if (app && app.renderer) {
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
texture: false,
|
||||
textureSource: false,
|
||||
});
|
||||
}
|
||||
destroyPixiApplication(app, "preview renderer");
|
||||
appRef.current = null;
|
||||
cameraContainerRef.current = null;
|
||||
videoEffectsContainerRef.current = null;
|
||||
|
||||
@@ -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,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
|
||||
import {
|
||||
calculateMp4ExportDimensions,
|
||||
calculateMp4SourceDimensions,
|
||||
shouldDebounceMp4SupportProbe,
|
||||
} from "./exportDimensions";
|
||||
|
||||
describe("calculateMp4SourceDimensions", () => {
|
||||
it("keeps native exports at the source dimensions", () => {
|
||||
@@ -9,6 +13,18 @@ describe("calculateMp4SourceDimensions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the cropped source bounds for native exports", () => {
|
||||
expect(
|
||||
calculateMp4SourceDimensions(320, 180, "native", {
|
||||
width: 1,
|
||||
height: 0.8,
|
||||
}),
|
||||
).toEqual({
|
||||
width: 320,
|
||||
height: 144,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the rotated source bounds for 9:16 original exports", () => {
|
||||
expect(calculateMp4SourceDimensions(1920, 1080, "9:16")).toEqual({
|
||||
width: 1080,
|
||||
@@ -16,6 +32,18 @@ describe("calculateMp4SourceDimensions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores crop bounds for fixed-aspect exports", () => {
|
||||
expect(
|
||||
calculateMp4SourceDimensions(1920, 1080, "9:16", {
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
}),
|
||||
).toEqual({
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the rotated source bounds for portrait social ratios", () => {
|
||||
expect(calculateMp4SourceDimensions(1920, 1080, "4:5")).toEqual({
|
||||
width: 1080,
|
||||
@@ -70,3 +98,48 @@ describe("calculateMp4ExportDimensions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDebounceMp4SupportProbe", () => {
|
||||
const baseSnapshot = {
|
||||
sourceWidth: 1920,
|
||||
sourceHeight: 1080,
|
||||
targetWidth: 1920,
|
||||
targetHeight: 1080,
|
||||
aspectRatio: "native" as const,
|
||||
frameRate: 30 as const,
|
||||
};
|
||||
|
||||
it("debounces only native crop-driven target changes", () => {
|
||||
expect(
|
||||
shouldDebounceMp4SupportProbe(baseSnapshot, {
|
||||
...baseSnapshot,
|
||||
targetHeight: 864,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps non-crop probe changes immediate", () => {
|
||||
expect(shouldDebounceMp4SupportProbe(null, baseSnapshot)).toBe(false);
|
||||
expect(
|
||||
shouldDebounceMp4SupportProbe(baseSnapshot, {
|
||||
...baseSnapshot,
|
||||
frameRate: 60,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDebounceMp4SupportProbe(baseSnapshot, {
|
||||
...baseSnapshot,
|
||||
sourceWidth: 1280,
|
||||
sourceHeight: 720,
|
||||
targetWidth: 1280,
|
||||
targetHeight: 720,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDebounceMp4SupportProbe(baseSnapshot, {
|
||||
...baseSnapshot,
|
||||
aspectRatio: "16:9",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
import type { ExportQuality } from "@/lib/exporter";
|
||||
import type { ExportMp4FrameRate, ExportQuality } from "@/lib/exporter";
|
||||
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
|
||||
export type Mp4SupportProbeSnapshot = {
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
aspectRatio: AspectRatio;
|
||||
frameRate: ExportMp4FrameRate;
|
||||
};
|
||||
|
||||
export function shouldDebounceMp4SupportProbe(
|
||||
previous: Mp4SupportProbeSnapshot | null,
|
||||
current: Mp4SupportProbeSnapshot,
|
||||
): boolean {
|
||||
if (
|
||||
!previous ||
|
||||
current.aspectRatio !== "native" ||
|
||||
previous.aspectRatio !== current.aspectRatio ||
|
||||
previous.frameRate !== current.frameRate ||
|
||||
previous.sourceWidth !== current.sourceWidth ||
|
||||
previous.sourceHeight !== current.sourceHeight
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
previous.targetWidth !== current.targetWidth ||
|
||||
previous.targetHeight !== current.targetHeight
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeEvenDimension(value: number): number {
|
||||
return Math.max(2, Math.floor(value / 2) * 2);
|
||||
}
|
||||
@@ -30,9 +60,15 @@ export function calculateMp4SourceDimensions(
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
aspectRatio: AspectRatio,
|
||||
cropRegion?: { width: number; height: number },
|
||||
): { width: number; height: number } {
|
||||
const safeSourceWidth = normalizeEvenDimension(sourceWidth);
|
||||
const safeSourceHeight = normalizeEvenDimension(sourceHeight);
|
||||
const useCroppedBounds = aspectRatio === "native";
|
||||
const safeSourceWidth = normalizeEvenDimension(
|
||||
sourceWidth * (useCroppedBounds ? (cropRegion?.width ?? 1) : 1),
|
||||
);
|
||||
const safeSourceHeight = normalizeEvenDimension(
|
||||
sourceHeight * (useCroppedBounds ? (cropRegion?.height ?? 1) : 1),
|
||||
);
|
||||
const sourceAspectRatio = safeSourceHeight > 0 ? safeSourceWidth / safeSourceHeight : 16 / 9;
|
||||
const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { WebDemuxer } from "web-demuxer";
|
||||
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
|
||||
import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource";
|
||||
import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder";
|
||||
import {
|
||||
createReadableMediaResourceFile,
|
||||
resolveMediaResourceUrl,
|
||||
} from "./localMediaSource";
|
||||
|
||||
const DEFAULT_MAX_DECODE_QUEUE = 12;
|
||||
const DEFAULT_MAX_PENDING_FRAMES = 32;
|
||||
@@ -59,7 +56,7 @@ export class ForwardFrameSource {
|
||||
mediaInfo = await loadMediaInfo(resourceUrl);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[ForwardFrameSource] Direct source load failed, retrying with file fallback:",
|
||||
"[ForwardFrameSource] Direct source load failed, retrying with a fresh media source:",
|
||||
error,
|
||||
);
|
||||
const currentDemuxer = this.demuxer;
|
||||
@@ -70,7 +67,7 @@ export class ForwardFrameSource {
|
||||
// Ignore cleanup errors before fallback re-init.
|
||||
}
|
||||
}
|
||||
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
|
||||
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
|
||||
}
|
||||
|
||||
const videoStream = mediaInfo.streams.find(
|
||||
|
||||
@@ -72,6 +72,10 @@ import {
|
||||
clampMediaTimeToDuration,
|
||||
getEffectiveVideoStreamDurationSeconds,
|
||||
} from "@/lib/mediaTiming";
|
||||
import {
|
||||
destroyPixiApplication,
|
||||
initializePixiApplicationWithTimeout,
|
||||
} from "@/lib/pixiApplicationLifecycle";
|
||||
import { isVideoWallpaperSource } from "@/lib/wallpapers";
|
||||
import { renderAnnotations } from "./annotationRenderer";
|
||||
import { renderCaptions } from "./captionRenderer";
|
||||
@@ -173,30 +177,6 @@ function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error ?? "Unknown renderer init error");
|
||||
}
|
||||
|
||||
type PixiInitOptions = Parameters<Application["init"]>[0];
|
||||
|
||||
async function initApplicationWithTimeout(
|
||||
app: Application,
|
||||
options: PixiInitOptions,
|
||||
backend: ExportRenderBackend,
|
||||
): Promise<void> {
|
||||
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(timeoutErrorMessage));
|
||||
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([app.init(options), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string {
|
||||
const details = attempts.map((attempt) => `${attempt.backend}: ${attempt.message}`).join(" | ");
|
||||
return `No supported Pixi export backend was available. Attempted: ${details}`;
|
||||
@@ -358,12 +338,13 @@ export class FrameRenderer {
|
||||
const app = new Application();
|
||||
const initStarted = typeof performance === "undefined" ? Date.now() : performance.now();
|
||||
try {
|
||||
await initApplicationWithTimeout(
|
||||
await initializePixiApplicationWithTimeout(
|
||||
app,
|
||||
{
|
||||
...baseOptions,
|
||||
preference: backend,
|
||||
},
|
||||
PIXI_RENDERER_INIT_TIMEOUT_MS,
|
||||
backend,
|
||||
);
|
||||
const elapsed = Math.round(
|
||||
@@ -389,7 +370,7 @@ export class FrameRenderer {
|
||||
`[FrameRenderer] ${backend} renderer unavailable after ${elapsed}ms; trying next backend.`,
|
||||
error,
|
||||
);
|
||||
app.destroy(true);
|
||||
destroyPixiApplication(app, `${backend} export renderer initialization`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2587,11 +2568,7 @@ export class FrameRenderer {
|
||||
}
|
||||
this.backgroundSprite = null;
|
||||
if (this.app) {
|
||||
this.app.destroy(true, {
|
||||
children: true,
|
||||
texture: false,
|
||||
textureSource: false,
|
||||
});
|
||||
destroyPixiApplication(this.app, "legacy export renderer");
|
||||
this.app = null;
|
||||
}
|
||||
this.zoomBlurFilter?.destroy();
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveMediaElementSource } from "./localMediaSource";
|
||||
import { createFallbackDemuxerSource, resolveMediaElementSource } from "./localMediaSource";
|
||||
|
||||
const readLocalFile = vi.fn();
|
||||
const getLocalMediaUrl = vi.fn(async (filePath: string) => ({
|
||||
success: true,
|
||||
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
|
||||
}));
|
||||
|
||||
describe("resolveMediaElementSource", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
readLocalFile.mockReset();
|
||||
getLocalMediaUrl.mockReset();
|
||||
getLocalMediaUrl.mockImplementation(async (filePath: string) => ({
|
||||
success: true,
|
||||
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
|
||||
}));
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
electronAPI: {
|
||||
readLocalFile: vi.fn(),
|
||||
getLocalMediaUrl: vi.fn(async (filePath: string) => ({
|
||||
success: true,
|
||||
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
|
||||
})),
|
||||
readLocalFile,
|
||||
getLocalMediaUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -21,20 +30,16 @@ describe("resolveMediaElementSource", () => {
|
||||
it("resolves file URLs through the local media server for media elements", async () => {
|
||||
const result = await resolveMediaElementSource("file:///tmp/example.mp4");
|
||||
|
||||
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
|
||||
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
|
||||
"/tmp/example.mp4",
|
||||
);
|
||||
expect(readLocalFile).not.toHaveBeenCalled();
|
||||
expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.mp4");
|
||||
expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.mp4");
|
||||
});
|
||||
|
||||
it("resolves absolute local paths through the local media server without copying them into blobs", async () => {
|
||||
const result = await resolveMediaElementSource("/tmp/example.wav");
|
||||
|
||||
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
|
||||
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
|
||||
"/tmp/example.wav",
|
||||
);
|
||||
expect(readLocalFile).not.toHaveBeenCalled();
|
||||
expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.wav");
|
||||
expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.wav");
|
||||
});
|
||||
|
||||
@@ -43,20 +48,39 @@ describe("resolveMediaElementSource", () => {
|
||||
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4",
|
||||
);
|
||||
|
||||
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
|
||||
expect((window as any).electronAPI.getLocalMediaUrl).not.toHaveBeenCalled();
|
||||
expect(result.src).toBe(
|
||||
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4",
|
||||
);
|
||||
expect(readLocalFile).not.toHaveBeenCalled();
|
||||
expect(getLocalMediaUrl).not.toHaveBeenCalled();
|
||||
expect(result.src).toBe("http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4");
|
||||
});
|
||||
|
||||
it("leaves remote URLs untouched", async () => {
|
||||
const readLocalFile = vi.fn();
|
||||
(window as any).electronAPI.readLocalFile = readLocalFile;
|
||||
|
||||
const result = await resolveMediaElementSource("https://example.com/video.mp4");
|
||||
|
||||
expect(result.src).toBe("https://example.com/video.mp4");
|
||||
expect(readLocalFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps local demuxer fallback on the range-streamed media URL", async () => {
|
||||
const source = await createFallbackDemuxerSource("/tmp/large-recording.mp4");
|
||||
|
||||
expect(source).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Flarge-recording.mp4");
|
||||
expect(readLocalFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains the readable File fallback for remote media", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: "video/mp4" }),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
const source = await createFallbackDemuxerSource("https://example.com/video.mp4");
|
||||
|
||||
expect(source).toBeInstanceOf(File);
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://example.com/video.mp4");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,36 +116,29 @@ export async function resolveMediaResourceUrl(resource: string): Promise<string>
|
||||
return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath);
|
||||
}
|
||||
|
||||
export async function createReadableMediaResourceFile(resource: string): Promise<File> {
|
||||
const localFilePath = getLocalFilePath(resource);
|
||||
const filename = (localFilePath ?? resource).split(/[\\/]/).pop()?.split("?")[0] || "media";
|
||||
|
||||
if (localFilePath && typeof window !== "undefined" && window.electronAPI?.readLocalFile) {
|
||||
const result = await window.electronAPI.readLocalFile(localFilePath);
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || "Failed to read local media file");
|
||||
}
|
||||
|
||||
const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data);
|
||||
const arrayBuffer = bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
return new File([arrayBuffer], filename, { type: inferMimeType(filename) });
|
||||
}
|
||||
|
||||
async function createReadableMediaResourceFile(resource: string): Promise<File> {
|
||||
const filename = resource.split(/[\\/]/).pop()?.split("?")[0] || "media";
|
||||
const resourceUrl = await resolveMediaResourceUrl(resource);
|
||||
const response = await fetch(resourceUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load media resource: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
throw new Error(`Failed to load media resource: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return new File([blob], filename, { type: blob.type || inferMimeType(filename) });
|
||||
}
|
||||
|
||||
export async function createFallbackDemuxerSource(resource: string): Promise<string | File> {
|
||||
// Local media already has a random-access transport: WebDemuxer issues bounded
|
||||
// byte-range requests against this URL. Converting it to a File would copy the
|
||||
// complete recording through Electron IPC and make memory use scale with file size.
|
||||
if (getLocalFilePath(resource)) {
|
||||
return resolveMediaResourceUrl(resource);
|
||||
}
|
||||
|
||||
return createReadableMediaResourceFile(resource);
|
||||
}
|
||||
|
||||
export async function resolveMediaElementSource(resource: string): Promise<{
|
||||
src: string;
|
||||
revoke: () => void;
|
||||
|
||||
@@ -6,12 +6,21 @@ const {
|
||||
destroyForwardFrameSourceMock,
|
||||
getForwardFrameAtTimeMock,
|
||||
initializeForwardFrameSourceMock,
|
||||
pixiApplicationInstancesMock,
|
||||
pixiInitializationErrorsMock,
|
||||
resolveMediaElementSourceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
cancelForwardFrameSourceMock: vi.fn(),
|
||||
destroyForwardFrameSourceMock: vi.fn(async () => undefined),
|
||||
getForwardFrameAtTimeMock: vi.fn(async () => null),
|
||||
initializeForwardFrameSourceMock: vi.fn(async () => undefined),
|
||||
pixiApplicationInstancesMock: [] as Array<{
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
renderer: { destroy: ReturnType<typeof vi.fn> };
|
||||
stage: { destroy: ReturnType<typeof vi.fn> };
|
||||
}>,
|
||||
pixiInitializationErrorsMock: [] as Array<Error | undefined>,
|
||||
resolveMediaElementSourceMock: vi.fn(async () => ({
|
||||
src: "blob:background",
|
||||
revoke: vi.fn(),
|
||||
@@ -19,7 +28,21 @@ const {
|
||||
}));
|
||||
|
||||
vi.mock("pixi.js", () => ({
|
||||
Application: class {},
|
||||
Application: class {
|
||||
destroy = vi.fn(() => {
|
||||
throw new TypeError("this._cancelResize is not a function");
|
||||
});
|
||||
init = vi.fn(async () => {
|
||||
const error = pixiInitializationErrorsMock.shift();
|
||||
if (error) throw error;
|
||||
});
|
||||
renderer = { destroy: vi.fn() };
|
||||
stage = { destroy: vi.fn() };
|
||||
|
||||
constructor() {
|
||||
pixiApplicationInstancesMock.push(this);
|
||||
}
|
||||
},
|
||||
BlurFilter: class {},
|
||||
Container: class {
|
||||
visible = true;
|
||||
@@ -179,6 +202,36 @@ function createRenderer() {
|
||||
});
|
||||
}
|
||||
|
||||
describe("ModernFrameRenderer Pixi lifecycle", () => {
|
||||
it("continues to the next backend when failed-init cleanup would throw", async () => {
|
||||
pixiApplicationInstancesMock.length = 0;
|
||||
pixiInitializationErrorsMock.length = 0;
|
||||
pixiInitializationErrorsMock.push(new Error("WebGPU initialization failed"), undefined);
|
||||
vi.stubGlobal("navigator", { gpu: {} });
|
||||
|
||||
try {
|
||||
const renderer = createRenderer() as unknown as {
|
||||
config: { preferredRenderBackend?: "webgl" | "webgpu" };
|
||||
createPixiApplication: (
|
||||
canvas: HTMLCanvasElement,
|
||||
) => Promise<{ backend: "webgl" | "webgpu" }>;
|
||||
};
|
||||
renderer.config.preferredRenderBackend = "webgpu";
|
||||
|
||||
await expect(renderer.createPixiApplication({} as HTMLCanvasElement)).resolves.toMatchObject({
|
||||
backend: "webgl",
|
||||
});
|
||||
|
||||
expect(pixiApplicationInstancesMock).toHaveLength(2);
|
||||
expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled();
|
||||
expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(pixiApplicationInstancesMock[0].renderer.destroy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModernFrameRenderer blur export path", () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, {
|
||||
|
||||
@@ -82,6 +82,10 @@ import {
|
||||
clampMediaTimeToDuration,
|
||||
getEffectiveVideoStreamDurationSeconds,
|
||||
} from "@/lib/mediaTiming";
|
||||
import {
|
||||
destroyPixiApplication,
|
||||
initializePixiApplicationWithTimeout,
|
||||
} from "@/lib/pixiApplicationLifecycle";
|
||||
import { isVideoWallpaperSource } from "@/lib/wallpapers";
|
||||
import {
|
||||
type AnnotationRenderAssets,
|
||||
@@ -284,30 +288,6 @@ function isKnownRendererUnavailableError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
type PixiInitOptions = Parameters<Application["init"]>[0];
|
||||
|
||||
async function initApplicationWithTimeout(
|
||||
app: Application,
|
||||
options: PixiInitOptions,
|
||||
backend: ExportRenderBackend,
|
||||
): Promise<void> {
|
||||
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(timeoutErrorMessage));
|
||||
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([app.init(options), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface RenderSnapshot {
|
||||
timeMs: number;
|
||||
cursorTimeMs: number;
|
||||
@@ -720,12 +700,13 @@ export class FrameRenderer {
|
||||
const app = new Application();
|
||||
const initStarted = typeof performance === "undefined" ? Date.now() : performance.now();
|
||||
try {
|
||||
await initApplicationWithTimeout(
|
||||
await initializePixiApplicationWithTimeout(
|
||||
app,
|
||||
{
|
||||
...baseOptions,
|
||||
preference: backend,
|
||||
},
|
||||
PIXI_RENDERER_INIT_TIMEOUT_MS,
|
||||
backend,
|
||||
);
|
||||
const elapsed = Math.round(
|
||||
@@ -754,7 +735,7 @@ export class FrameRenderer {
|
||||
`[FrameRenderer] ${backend} export renderer unavailable (${rendererMessage}) after ${elapsed}ms; trying next backend:`,
|
||||
error,
|
||||
);
|
||||
app.destroy(true);
|
||||
destroyPixiApplication(app, `${backend} export renderer initialization`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3921,11 +3902,7 @@ export class FrameRenderer {
|
||||
this.motionBlurFilter?.destroy();
|
||||
this.backgroundBlurFilter?.destroy();
|
||||
|
||||
this.app?.destroy(true, {
|
||||
children: true,
|
||||
texture: false,
|
||||
textureSource: false,
|
||||
});
|
||||
destroyPixiApplication(this.app, "Lightning export renderer");
|
||||
|
||||
for (const texture of texturesToDestroy) {
|
||||
try {
|
||||
|
||||
@@ -238,7 +238,7 @@ describe("ModernVideoExporter native fallback routing", () => {
|
||||
expect(mocks.streamingDecoderLoadMetadata).not.toHaveBeenCalled();
|
||||
}, 15_000);
|
||||
|
||||
it("retries the main decode path once with a readable file-backed source", async () => {
|
||||
it("retries the main decode path once with a fresh media source", async () => {
|
||||
mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1);
|
||||
mocks.streamingDecoderDecodeAll
|
||||
.mockRejectedValueOnce(
|
||||
@@ -277,13 +277,13 @@ describe("ModernVideoExporter native fallback routing", () => {
|
||||
expect(mocks.streamingDecoderLoadMetadata.mock.calls[0]).toEqual([
|
||||
"file:///recording.mp4",
|
||||
{
|
||||
forceReadableFileSource: false,
|
||||
useFallbackMediaSource: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.streamingDecoderLoadMetadata.mock.calls[1]).toEqual([
|
||||
"file:///recording.mp4",
|
||||
{
|
||||
forceReadableFileSource: true,
|
||||
useFallbackMediaSource: true,
|
||||
},
|
||||
]);
|
||||
expect(mocks.streamingDecoderDecodeAll).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -288,7 +288,7 @@ type NativeStaticLayoutZoomSample = {
|
||||
};
|
||||
|
||||
const NATIVE_EXPORT_ENGINE_NAME = "Breeze";
|
||||
const READABLE_SOURCE_RETRY_ERROR_TOKENS = [
|
||||
const MEDIA_SOURCE_RETRY_ERROR_TOKENS = [
|
||||
"readavpacket",
|
||||
"get_media_info",
|
||||
"avfoundation",
|
||||
@@ -371,11 +371,11 @@ export class ModernVideoExporter {
|
||||
}
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
let preferReadableFileSource = false;
|
||||
let retriedWithReadableFileSource = false;
|
||||
let useFallbackMediaSource = false;
|
||||
let retriedWithFallbackMediaSource = false;
|
||||
|
||||
while (true) {
|
||||
let shouldRetryWithReadableFileSource = false;
|
||||
let shouldRetryWithFallbackMediaSource = false;
|
||||
try {
|
||||
this.cleanup();
|
||||
this.cancelled = false;
|
||||
@@ -522,7 +522,7 @@ export class ModernVideoExporter {
|
||||
});
|
||||
stageStartedAt = this.getNowMs();
|
||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl, {
|
||||
forceReadableFileSource: preferReadableFileSource,
|
||||
useFallbackMediaSource,
|
||||
});
|
||||
this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt;
|
||||
const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo);
|
||||
@@ -892,15 +892,15 @@ export class ModernVideoExporter {
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
!preferReadableFileSource &&
|
||||
!retriedWithReadableFileSource &&
|
||||
this.shouldRetryWithReadableFileSource(error)
|
||||
!useFallbackMediaSource &&
|
||||
!retriedWithFallbackMediaSource &&
|
||||
this.shouldRetryWithFallbackMediaSource(error)
|
||||
) {
|
||||
retriedWithReadableFileSource = true;
|
||||
preferReadableFileSource = true;
|
||||
shouldRetryWithReadableFileSource = true;
|
||||
retriedWithFallbackMediaSource = true;
|
||||
useFallbackMediaSource = true;
|
||||
shouldRetryWithFallbackMediaSource = true;
|
||||
console.warn(
|
||||
"[VideoExporter] Primary decode path failed; retrying export once with a readable file-backed media source.",
|
||||
"[VideoExporter] Primary decode path failed; retrying export once with a fresh media source.",
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
@@ -921,7 +921,7 @@ export class ModernVideoExporter {
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
if (!shouldRetryWithReadableFileSource && this.totalExportStartTimeMs > 0) {
|
||||
if (!shouldRetryWithFallbackMediaSource && this.totalExportStartTimeMs > 0) {
|
||||
console.log(
|
||||
`[VideoExporter] Final metrics ${JSON.stringify(this.buildExportMetrics())}`,
|
||||
);
|
||||
@@ -929,20 +929,18 @@ export class ModernVideoExporter {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
if (shouldRetryWithReadableFileSource) {
|
||||
if (shouldRetryWithFallbackMediaSource) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetryWithReadableFileSource(error: unknown): boolean {
|
||||
private shouldRetryWithFallbackMediaSource(error: unknown): boolean {
|
||||
const resolvedError = this.encoderError ?? error;
|
||||
const message =
|
||||
resolvedError instanceof Error ? resolvedError.message : String(resolvedError);
|
||||
const normalizedMessage = message.toLowerCase();
|
||||
return READABLE_SOURCE_RETRY_ERROR_TOKENS.some((token) =>
|
||||
normalizedMessage.includes(token),
|
||||
);
|
||||
return MEDIA_SOURCE_RETRY_ERROR_TOKENS.some((token) => normalizedMessage.includes(token));
|
||||
}
|
||||
|
||||
private getPlatformLabel(): string {
|
||||
|
||||
@@ -40,6 +40,12 @@ vi.mock("web-demuxer", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockReadLocalFile = vi.fn();
|
||||
const mockGetLocalMediaUrl = vi.fn(async (filePath: string) => ({
|
||||
success: true,
|
||||
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
|
||||
}));
|
||||
|
||||
describe("StreamingVideoDecoder local media loading", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -47,17 +53,20 @@ describe("StreamingVideoDecoder local media loading", () => {
|
||||
mockDemuxerGetMediaInfo.mockClear();
|
||||
mockDemuxerDestroy.mockClear();
|
||||
mockDemuxerGetDecoderConfig.mockClear();
|
||||
mockReadLocalFile.mockReset();
|
||||
mockGetLocalMediaUrl.mockReset();
|
||||
mockGetLocalMediaUrl.mockImplementation(async (filePath: string) => ({
|
||||
success: true,
|
||||
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
|
||||
}));
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
location: {
|
||||
href: "http://localhost:5173/",
|
||||
},
|
||||
electronAPI: {
|
||||
readLocalFile: vi.fn(),
|
||||
getLocalMediaUrl: vi.fn(async (filePath: string) => ({
|
||||
success: true,
|
||||
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
|
||||
})),
|
||||
readLocalFile: mockReadLocalFile,
|
||||
getLocalMediaUrl: mockGetLocalMediaUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -67,17 +76,17 @@ 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",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes absolute local paths to file URLs before loading them", async () => {
|
||||
it("resolves absolute local paths to range-streamed media URLs", async () => {
|
||||
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(
|
||||
@@ -85,27 +94,31 @@ describe("StreamingVideoDecoder local media loading", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a readable File when direct loading fails", async () => {
|
||||
it("retries the range-streamed URL when direct local loading fails", async () => {
|
||||
mockDemuxerLoad.mockReset();
|
||||
mockDemuxerLoad
|
||||
.mockRejectedValueOnce(new Error("get_media_info failed: Failed after 3 attempts"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
(window as any).electronAPI.readLocalFile = vi.fn(async () => ({
|
||||
success: true,
|
||||
data: new Uint8Array([1, 2, 3]),
|
||||
}));
|
||||
|
||||
const decoder = new StreamingVideoDecoder();
|
||||
await decoder.loadMetadata("/tmp/fallback.mp4");
|
||||
|
||||
expect(mockDemuxerLoad).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
2,
|
||||
"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(
|
||||
"/tmp/fallback.mp4",
|
||||
expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an explicit local retry on the range-streamed URL", async () => {
|
||||
const decoder = new StreamingVideoDecoder();
|
||||
await decoder.loadMetadata("/tmp/retry.mp4", {
|
||||
useFallbackMediaSource: true,
|
||||
});
|
||||
|
||||
expect(mockDemuxerLoad).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:4321/video?path=%2Ftmp%2Fretry.mp4",
|
||||
);
|
||||
expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WebDemuxer } from "web-demuxer";
|
||||
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
||||
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
|
||||
import { createReadableMediaResourceFile, resolveMediaResourceUrl } from "./localMediaSource";
|
||||
import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource";
|
||||
|
||||
const DEFAULT_MAX_DECODE_QUEUE = 12;
|
||||
const DEFAULT_MAX_PENDING_FRAMES = 32;
|
||||
@@ -24,7 +24,7 @@ export interface DecodedVideoInfo {
|
||||
}
|
||||
|
||||
interface StreamingVideoDecoderLoadOptions {
|
||||
forceReadableFileSource?: boolean;
|
||||
useFallbackMediaSource?: boolean;
|
||||
}
|
||||
|
||||
/** Decoder retains ownership of the VideoFrame and closes it after use. */
|
||||
@@ -126,14 +126,14 @@ export class StreamingVideoDecoder {
|
||||
};
|
||||
|
||||
let mediaInfo;
|
||||
if (options.forceReadableFileSource) {
|
||||
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
|
||||
if (options.useFallbackMediaSource) {
|
||||
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
|
||||
} else {
|
||||
try {
|
||||
mediaInfo = await loadMediaInfo(resourceUrl);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[StreamingVideoDecoder] Direct source load failed, retrying with file fallback:",
|
||||
"[StreamingVideoDecoder] Direct source load failed, retrying with a fresh media source:",
|
||||
error,
|
||||
);
|
||||
const currentDemuxer = this.demuxer;
|
||||
@@ -144,7 +144,7 @@ export class StreamingVideoDecoder {
|
||||
// Ignore cleanup errors before fallback re-init.
|
||||
}
|
||||
}
|
||||
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
|
||||
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Application } from "pixi.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
destroyPixiApplication,
|
||||
initializePixiApplication,
|
||||
initializePixiApplicationWithTimeout,
|
||||
} from "./pixiApplicationLifecycle";
|
||||
|
||||
function createApplication(init: () => Promise<void> = async () => undefined) {
|
||||
return {
|
||||
init: vi.fn(init),
|
||||
destroy: vi.fn(),
|
||||
stage: { destroy: vi.fn() },
|
||||
renderer: { destroy: vi.fn() },
|
||||
} as unknown as Application;
|
||||
}
|
||||
|
||||
describe("Pixi application lifecycle", () => {
|
||||
it("cleans a failed initialization without running uninitialized plugins", async () => {
|
||||
const initializationError = new Error("No available renderer");
|
||||
const app = createApplication(async () => {
|
||||
throw initializationError;
|
||||
});
|
||||
const applicationDestroy = vi.mocked(app.destroy);
|
||||
applicationDestroy.mockImplementation(() => {
|
||||
throw new TypeError("this._cancelResize is not a function");
|
||||
});
|
||||
|
||||
await expect(initializePixiApplication(app, {})).rejects.toBe(initializationError);
|
||||
expect(() => destroyPixiApplication(app, "test renderer init")).not.toThrow();
|
||||
|
||||
expect(applicationDestroy).not.toHaveBeenCalled();
|
||||
expect(app.stage.destroy).toHaveBeenCalledWith({
|
||||
children: true,
|
||||
texture: false,
|
||||
textureSource: false,
|
||||
});
|
||||
expect(app.renderer.destroy).toHaveBeenCalledWith({
|
||||
removeView: true,
|
||||
releaseGlobalResources: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("destroys a successfully initialized application at most once", async () => {
|
||||
const app = createApplication();
|
||||
|
||||
await initializePixiApplication(app, {});
|
||||
destroyPixiApplication(app, "test renderer");
|
||||
destroyPixiApplication(app, "test renderer");
|
||||
|
||||
expect(app.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(app.destroy).toHaveBeenCalledWith(
|
||||
{ removeView: true, releaseGlobalResources: false },
|
||||
{ children: true, texture: false, textureSource: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("defers teardown until an in-flight initialization settles", async () => {
|
||||
let finishInitialization: (() => void) | undefined;
|
||||
const app = createApplication(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishInitialization = resolve;
|
||||
}),
|
||||
);
|
||||
const initialization = initializePixiApplication(app, {});
|
||||
|
||||
destroyPixiApplication(app, "timed-out renderer init");
|
||||
expect(app.destroy).not.toHaveBeenCalled();
|
||||
|
||||
finishInitialization?.();
|
||||
await initialization;
|
||||
|
||||
expect(app.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports cleanup errors without throwing or retrying unsafe teardown", async () => {
|
||||
const app = createApplication();
|
||||
const cleanupError = new Error("renderer cleanup failed");
|
||||
vi.mocked(app.destroy).mockImplementation(() => {
|
||||
throw cleanupError;
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
await initializePixiApplication(app, {});
|
||||
expect(() => destroyPixiApplication(app, "test renderer")).not.toThrow();
|
||||
expect(() => destroyPixiApplication(app, "test renderer")).not.toThrow();
|
||||
|
||||
expect(app.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[PixiApplication] Failed to clean up test renderer:",
|
||||
cleanupError,
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("reports the backend when initialization times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const app = createApplication(() => new Promise<void>(() => undefined));
|
||||
const initialization = initializePixiApplicationWithTimeout(app, {}, 250, "webgpu");
|
||||
const rejection = initialization.catch((error: unknown) => error);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
await expect(rejection).resolves.toEqual(
|
||||
new Error("Initialization timed out after 250ms for webgpu renderer"),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Application } from "pixi.js";
|
||||
|
||||
type PixiInitializationState = "initializing" | "initialized" | "failed";
|
||||
type PixiInitOptions = Parameters<Application["init"]>[0];
|
||||
|
||||
const initializationStates = new WeakMap<Application, PixiInitializationState>();
|
||||
const destroyRequests = new WeakSet<Application>();
|
||||
const destroyContexts = new WeakMap<Application, string>();
|
||||
const completedCleanups = new WeakSet<Application>();
|
||||
|
||||
const RENDERER_DESTROY_OPTIONS = {
|
||||
removeView: true,
|
||||
releaseGlobalResources: false,
|
||||
} as const;
|
||||
|
||||
const STAGE_DESTROY_OPTIONS = {
|
||||
children: true,
|
||||
texture: false,
|
||||
textureSource: false,
|
||||
} as const;
|
||||
|
||||
function reportCleanupError(app: Application, error: unknown): void {
|
||||
const context = destroyContexts.get(app) ?? "Pixi application";
|
||||
console.warn(`[PixiApplication] Failed to clean up ${context}:`, error);
|
||||
}
|
||||
|
||||
function destroyFailedApplication(app: Application): void {
|
||||
const partialApp = app as Partial<Application>;
|
||||
|
||||
try {
|
||||
partialApp.stage?.destroy(STAGE_DESTROY_OPTIONS);
|
||||
} catch (error) {
|
||||
reportCleanupError(app, error);
|
||||
}
|
||||
|
||||
try {
|
||||
partialApp.renderer?.destroy(RENDERER_DESTROY_OPTIONS);
|
||||
} catch (error) {
|
||||
reportCleanupError(app, error);
|
||||
}
|
||||
}
|
||||
|
||||
function completeDestroy(app: Application): void {
|
||||
if (completedCleanups.has(app)) return;
|
||||
completedCleanups.add(app);
|
||||
|
||||
if (initializationStates.get(app) !== "initialized") {
|
||||
destroyFailedApplication(app);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app.destroy(RENDERER_DESTROY_OPTIONS, STAGE_DESTROY_OPTIONS);
|
||||
} catch (error) {
|
||||
reportCleanupError(app, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializePixiApplication(
|
||||
app: Application,
|
||||
options: PixiInitOptions,
|
||||
): Promise<void> {
|
||||
if (initializationStates.has(app) || destroyRequests.has(app)) {
|
||||
throw new Error("Pixi application lifecycle has already started");
|
||||
}
|
||||
|
||||
initializationStates.set(app, "initializing");
|
||||
try {
|
||||
await app.init(options);
|
||||
initializationStates.set(app, "initialized");
|
||||
} catch (error) {
|
||||
initializationStates.set(app, "failed");
|
||||
if (destroyRequests.has(app)) completeDestroy(app);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (destroyRequests.has(app)) completeDestroy(app);
|
||||
}
|
||||
|
||||
export async function initializePixiApplicationWithTimeout(
|
||||
app: Application,
|
||||
options: PixiInitOptions,
|
||||
timeoutMs: number,
|
||||
backendLabel: string,
|
||||
): Promise<void> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(`Initialization timed out after ${timeoutMs}ms for ${backendLabel} renderer`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([initializePixiApplication(app, options), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyPixiApplication(app: Application | null, context: string): void {
|
||||
if (!app || destroyRequests.has(app) || completedCleanups.has(app)) return;
|
||||
|
||||
destroyRequests.add(app);
|
||||
destroyContexts.set(app, context);
|
||||
if (initializationStates.get(app) !== "initializing") completeDestroy(app);
|
||||
}
|
||||
Reference in New Issue
Block a user