Files
webadderall eaf87be95b Recordly 2.0 local review stack 2/6
Dependency-ordered review preparation. Not pushed or remotely reviewed.
2026-11-06 05:24:21 +11:00

274 lines
8.7 KiB
TypeScript

// Adapted from Recordly PRs #1142/#1143 by sam hoang (@samhvw8).
import { randomUUID } from "node:crypto";
import type { FileHandle } from "node:fs/promises";
import fs from "node:fs/promises";
export const MAX_RECORDING_CHUNK_BYTES = 8 * 1024 * 1024;
const MAX_PENDING_BYTES = 32 * 1024 * 1024;
const MAX_OWNER_STREAMS = 4;
type RecordingFileStream = {
pendingBytes: number;
path: string;
ownerId: number;
handle: FileHandle;
bytesWritten: number;
writeQueue: Promise<void>;
writeError: Error | null;
};
export type ClosedRecordingFileStream = {
path: string;
bytesWritten: number;
};
/** A close that failed; the part written so far is still on disk at `partialPath`. */
export class RecordingFileCloseError extends Error {
constructor(
message: string,
readonly partialPath: string,
) {
super(message);
this.name = "RecordingFileCloseError";
}
}
/**
* Runs the finalize step for a recording that is already on disk. If it throws, the
* result still names the file, so the caller can tell the user where the recording is.
*/
export async function finalizeKeepingPath<T>(
filePath: string,
finalize: (filePath: string) => Promise<T>,
): Promise<T | { success: false; path: string; message: string; error: string }> {
try {
return await finalize(filePath);
} catch (error) {
console.error("Failed to finalize the recording:", error);
return {
success: false,
path: filePath,
message: `The recording was saved to ${filePath}, but it could not be finalized.`,
error: String(error),
};
}
}
/** The parts of Electron's WebContents that closeWhenOwnerGoes uses. */
export type RecordingFileStreamOwner = Pick<Electron.WebContents, "id" | "on" | "removeListener">;
/**
* Appends MediaRecorder chunks to a file as they arrive, so a long recording
* never has to sit in renderer memory. Each stream belongs to the webContents
* that opened it; when that renderer dies, its streams are closed and the
* partial files are kept on disk.
*/
export class RecordingFileStreams {
private readonly streams = new Map<string, RecordingFileStream>();
/** One token per watch of an owner; a new token means the owner went away in between. */
private readonly ownerWatches = new Map<number, object>();
private readonly ownerGenerations = new Map<number, number>();
private readonly openingCounts = new Map<number, number>();
/**
* Closes the owner's streams when its renderer crashes, is destroyed, or loads a new
* page (a reload drops the renderer's writer without telling main). The files are kept.
* Listeners are added once per owner and removed when they fire.
*/
closeWhenOwnerGoes(
owner: RecordingFileStreamOwner,
onClosed: (closed: ClosedRecordingFileStream[]) => void,
) {
if (this.ownerWatches.has(owner.id)) {
return;
}
this.ownerWatches.set(owner.id, {});
const ownerId = owner.id;
const stopWatching = () => {
this.ownerWatches.delete(ownerId);
owner.removeListener("destroyed", onGone);
owner.removeListener("render-process-gone", onGone);
owner.removeListener("did-start-navigation", onNavigation);
};
const onGone = () => {
stopWatching();
void this.closeAllForOwner(ownerId)
.then(onClosed)
.catch((error) => {
console.error("Failed to recover renderer recording files:", error);
});
};
const onNavigation = (
details: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
) => {
if (details?.isMainFrame && !details.isSameDocument) {
onGone();
}
};
owner.on("destroyed", onGone);
owner.on("render-process-gone", onGone);
owner.on("did-start-navigation", onNavigation);
}
async open(filePath: string, ownerId: number): Promise<string> {
const generation = this.ownerGenerations.get(ownerId) ?? 0;
const openingCount = this.openingCounts.get(ownerId) ?? 0;
const activeCount = [...this.streams.values()].filter(
(stream) => stream.ownerId === ownerId,
).length;
if (activeCount + openingCount >= MAX_OWNER_STREAMS)
throw new Error("Too many recording streams");
this.openingCounts.set(ownerId, openingCount + 1);
try {
// Exclusive creation also rejects existing symlinks; recordings must never be overwritten.
const handle = await fs.open(filePath, "wx", 0o600);
if ((this.ownerGenerations.get(ownerId) ?? 0) !== generation) {
await handle.close();
throw new RecordingFileCloseError("Recording renderer went away", filePath);
}
const streamId = randomUUID();
this.streams.set(streamId, {
path: filePath,
ownerId,
handle,
pendingBytes: 0,
bytesWritten: 0,
writeQueue: Promise.resolve(),
writeError: null,
});
return streamId;
} finally {
const count = (this.openingCounts.get(ownerId) ?? 1) - 1;
if (count) this.openingCounts.set(ownerId, count);
else this.openingCounts.delete(ownerId);
}
}
append(streamId: string, ownerId: number, data: Uint8Array): Promise<void> {
const stream = this.getOwnedStream(streamId, ownerId);
if (!(data instanceof Uint8Array) || data.byteLength > MAX_RECORDING_CHUNK_BYTES) {
return Promise.reject(new Error("Invalid or oversized recording chunk"));
}
if (stream.pendingBytes + data.byteLength > MAX_PENDING_BYTES) {
return Promise.reject(new Error("Recording writes are backlogged"));
}
data = data.slice();
if (stream.writeError) return Promise.reject(stream.writeError);
stream.pendingBytes += data.byteLength;
// Queue synchronously so chunks land in the order they were sent.
stream.writeQueue = stream.writeQueue.then(async () => {
if (stream.writeError) {
stream.pendingBytes -= data.byteLength;
return;
}
try {
let offset = 0;
while (offset < data.byteLength) {
const { bytesWritten } = await stream.handle.write(
data,
offset,
data.byteLength - offset,
stream.bytesWritten,
);
if (bytesWritten <= 0) throw new Error("Recording write made no progress");
offset += bytesWritten;
stream.bytesWritten += bytesWritten;
}
} catch (error) {
stream.writeError = error instanceof Error ? error : new Error(String(error));
} finally {
stream.pendingBytes -= data.byteLength;
}
});
return stream.writeQueue.then(() => {
if (stream.writeError) {
throw stream.writeError;
}
});
}
/**
* Flushes pending writes and closes the file. When `targetPath` differs from
* the path the stream was opened with, the file is renamed to it.
*/
async close(
streamId: string,
ownerId: number,
targetPath?: string,
): Promise<ClosedRecordingFileStream> {
const stream = this.getOwnedStream(streamId, ownerId);
this.streams.delete(streamId);
try {
await this.closeHandle(stream);
} catch (error) {
throw new RecordingFileCloseError(String(error), stream.path);
}
if (stream.writeError) {
throw new RecordingFileCloseError(stream.writeError.message, stream.path);
}
let finalPath = stream.path;
if (targetPath && targetPath !== stream.path) {
try {
await fs.link(stream.path, targetPath);
await fs.unlink(stream.path);
} catch (error) {
throw new RecordingFileCloseError(String(error), stream.path);
}
finalPath = targetPath;
}
return { path: finalPath, bytesWritten: stream.bytesWritten };
}
/** Closes the file and deletes it, for a recording the user cancelled. */
async discard(streamId: string, ownerId: number): Promise<void> {
const stream = this.getOwnedStream(streamId, ownerId);
this.streams.delete(streamId);
await this.closeHandle(stream);
await fs.rm(stream.path, { force: true });
}
/** Closes every stream a renderer opened and keeps what was written. */
async closeAllForOwner(ownerId: number): Promise<ClosedRecordingFileStream[]> {
this.ownerGenerations.set(ownerId, (this.ownerGenerations.get(ownerId) ?? 0) + 1);
const owned = [...this.streams.entries()].filter(
([, stream]) => stream.ownerId === ownerId,
);
// Remove all synchronously, before a new page can append or close any old stream.
for (const [streamId] of owned) this.streams.delete(streamId);
const closed: ClosedRecordingFileStream[] = [];
for (const [, stream] of owned) {
try {
await this.closeHandle(stream);
} catch (error) {
// Keep closing the others; this file keeps whatever reached the disk.
console.error(`Failed to close recording file ${stream.path}:`, error);
}
closed.push({ path: stream.path, bytesWritten: stream.bytesWritten });
}
return closed;
}
private getOwnedStream(streamId: string, ownerId: number): RecordingFileStream {
const stream = this.streams.get(streamId);
if (!stream || stream.ownerId !== ownerId) {
throw new Error("Unknown recording file stream");
}
return stream;
}
private async closeHandle(stream: RecordingFileStream) {
await stream.writeQueue;
try {
await stream.handle.sync();
} finally {
await stream.handle.close();
}
}
}