Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,51 @@ interface UpdateStatusSummary {
detail?: string;
}

type RendererPhoneRemoteSessionStatus =
| "waiting"
| "phone-connected"
| "preview-live"
| "mic-active"
| "reconnecting"
| "disconnected"
| "camera-permission-denied"
| "microphone-permission-denied"
| "no-audio-track"
| "phone-backgrounded"
| "phone-sleeping"
| "error";

type RendererPhoneRemoteSignalMessage =
| {
type: "offer" | "answer";
description: RTCSessionDescriptionInit;
}
| {
type: "ice-candidate";
candidate: RTCIceCandidateInit | null;
};

interface RendererPhoneRemoteStatusMessage {
status: RendererPhoneRemoteSessionStatus;
detail?: string;
hasAudio?: boolean;
hasVideo?: boolean;
facingMode?: "user" | "environment";
}

interface RendererPhoneRemoteSession {
id: string;
code: string;
joinUrl: string;
localJoinUrl: string;
lanJoinUrl: string;
tunnelJoinUrl?: string;
urlMode: "secure-tunnel" | "lan";
tunnelError?: string;
expiresAt: number;
status: RendererPhoneRemoteSessionStatus;
}

type RendererExtensionInfo = import("./extensions/extensionTypes").ExtensionInfo;
type RendererExtensionReview = import("./extensions/extensionTypes").ExtensionReview;
type RendererMarketplaceExtension = import("./extensions/extensionTypes").MarketplaceExtension;
Expand Down Expand Up @@ -862,6 +907,28 @@ interface Window {
cancelCountdown: () => Promise<{ success: boolean }>;
getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>;
onCountdownTick: (callback: (seconds: number) => void) => () => void;
createPhoneRemoteSession: () => Promise<{
success: boolean;
session: RendererPhoneRemoteSession;
error?: string;
}>;
endPhoneRemoteSession: (sessionId: string) => Promise<{ success: boolean }>;
sendPhoneRemoteSignal: (
sessionId: string,
message: RendererPhoneRemoteSignalMessage,
) => Promise<{ success: boolean; index?: number; error?: string }>;
onPhoneRemoteSignal: (
callback: (payload: {
sessionId: string;
message: RendererPhoneRemoteSignalMessage;
}) => void,
) => () => void;
onPhoneRemoteStatus: (
callback: (payload: {
sessionId: string;
status: RendererPhoneRemoteStatusMessage;
}) => void,
) => () => void;
extensionsDiscover: () => Promise<RendererExtensionInfo[]>;
extensionsList: () => Promise<RendererExtensionInfo[]>;
extensionsGet: (id: string) => Promise<RendererExtensionInfo | null>;
Expand Down
2 changes: 2 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { registerAssetHandlers } from "./register/assets";
import { registerCaptionHandlers } from "./register/captions";
import { registerExportHandlers } from "./register/export";
import { registerPermissionHandlers } from "./register/permissions";
import { registerPhoneRemoteHandlers } from "./register/phoneRemote";
import { registerProjectHandlers } from "./register/project";
import { registerRecordingHandlers } from "./register/recording";
import { registerSettingsHandlers } from "./register/settings";
Expand Down Expand Up @@ -69,4 +70,5 @@ export function registerIpcHandlers(
registerCaptionHandlers();
registerProjectHandlers();
registerSettingsHandlers();
registerPhoneRemoteHandlers();
}
14 changes: 9 additions & 5 deletions electron/ipc/monitorResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface WinMonitorHandle {

/**
* Retrieves raw HMONITOR handles from the Windows OS using a PowerShell bridge.
* This is necessary because Electron's display IDs are often internal hashes that
* This is necessary because Electron's display IDs are often internal hashes that
* cannot be used directly with native Windows APIs like Graphics Capture (WGC).
*/
export function getMonitorHandles(): WinMonitorHandle[] {
Expand Down Expand Up @@ -53,10 +53,14 @@ public class MonitorHelper {
[MonitorHelper]::GetMonitors()
`.trim();

const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", psScript], {
encoding: "utf-8",
timeout: 5000,
});
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", psScript],
{
encoding: "utf-8",
timeout: 5000,
},
);

if (result.error || result.status !== 0) {
// Silent failure is preferred; the caller will fall back to coordinate-based matching.
Expand Down
20 changes: 20 additions & 0 deletions electron/ipc/recording/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,24 @@ describe("getCompanionAudioFallbackPaths", () => {
"Recorded output is too small to contain playable video",
);
});

it("keeps a non-empty recording usable when FFmpeg is missing in a dev install", async () => {
vi.doMock("../ffmpeg/binary", () => ({
getFfmpegBinaryPath: () => {
throw new Error(
"FFmpeg binary is unavailable. Install ffmpeg-static for this platform or make ffmpeg available on PATH.",
);
},
}));

const videoPath = path.join(tempRoot, "recording-456.mp4");
await fs.writeFile(videoPath, Buffer.alloc(4096));

const { validateRecordedVideo } = await import("./diagnostics");

await expect(validateRecordedVideo(videoPath)).resolves.toEqual({
fileSizeBytes: 4096,
durationSeconds: null,
});
});
});
25 changes: 18 additions & 7 deletions electron/ipc/recording/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,7 @@ export async function probeMediaDurationSeconds(filePath: string): Promise<numbe
return duration;
}
} finally {
console.log(
`[PERF:MAIN] probeMediaDurationSeconds: COMPLETED in ${Date.now() - start}ms`,
);
console.log(`[PERF:MAIN] probeMediaDurationSeconds: COMPLETED in ${Date.now() - start}ms`);
}
return 0;
}
Expand Down Expand Up @@ -292,9 +290,7 @@ export async function probeVideoStreamDuration(
} catch {
return null;
} finally {
console.log(
`[PERF:MAIN] probeVideoStreamDuration: COMPLETED in ${Date.now() - start}ms`,
);
console.log(`[PERF:MAIN] probeVideoStreamDuration: COMPLETED in ${Date.now() - start}ms`);
}
}

Expand Down Expand Up @@ -563,7 +559,22 @@ export async function validateRecordedVideo(videoPath: string) {
);
}

const ffmpegPath = getFfmpegBinaryPath();
let ffmpegPath: string;
try {
ffmpegPath = getFfmpegBinaryPath();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/ffmpeg\s+binary\s+is\s+unavailable/i.test(message)) {
console.warn(
`[recording] FFmpeg is unavailable; skipping deep media validation for ${videoPath}.`,
);
return {
fileSizeBytes: stat.size,
durationSeconds: null,
};
}
throw error;
}
let stderr = "";

try {
Expand Down
11 changes: 7 additions & 4 deletions electron/ipc/recording/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,13 @@ async function loadSavedProjectMediaPaths() {
editor?: { webcam?: { sourcePath?: unknown } };
}>(await fs.readFile(projectPath, "utf-8"));
} catch (error) {
console.warn("[prune] Aborting recording prune because a saved project is unreadable", {
projectPath,
error,
});
console.warn(
"[prune] Aborting recording prune because a saved project is unreadable",
{
projectPath,
error,
},
);
throw error;
}
const candidatePaths = [
Expand Down
150 changes: 150 additions & 0 deletions electron/ipc/register/phoneRemote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { ipcMain, webContents } from "electron";
import { getPhoneRemoteJoinUrls } from "../../phoneRemote/server";
import {
addLaptopSignal,
createPhoneRemoteSession,
endPhoneRemoteSession,
subscribePhoneRemoteStore,
} from "../../phoneRemote/sessionStore";
import type { PhoneRemoteSignalMessage } from "../../phoneRemote/types";

let subscribed = false;

function sendToOwner(ownerWebContentsId: number, channel: string, payload: unknown) {
const target = webContents.fromId(ownerWebContentsId);
if (!target || target.isDestroyed()) {
return;
}
target.send(channel, payload);
}

function parseSignalMessage(value: unknown): PhoneRemoteSignalMessage | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}

const record = value as Record<string, unknown>;
if (record.type === "offer" || record.type === "answer") {
const description = record.description as Record<string, unknown> | null;
if (
!description ||
typeof description.type !== "string" ||
typeof description.sdp !== "string"
) {
return null;
}
if (description.type !== "offer" && description.type !== "answer") {
return null;
}
if (description.type !== record.type) {
return null;
}
return {
type: record.type,
description: {
type: description.type,
sdp: description.sdp,
},
};
}

if (record.type === "ice-candidate") {
if (record.candidate === null) {
return {
type: "ice-candidate",
candidate: null,
};
}
if (
!record.candidate ||
typeof record.candidate !== "object" ||
Array.isArray(record.candidate)
) {
return null;
}
const candidate = record.candidate as Record<string, unknown>;
if (typeof candidate.candidate !== "string") {
return null;
}
return {
type: "ice-candidate",
candidate: {
candidate: candidate.candidate,
sdpMid: typeof candidate.sdpMid === "string" ? candidate.sdpMid : null,
sdpMLineIndex:
typeof candidate.sdpMLineIndex === "number" ? candidate.sdpMLineIndex : null,
usernameFragment:
typeof candidate.usernameFragment === "string"
? candidate.usernameFragment
: undefined,
},
};
}

return null;
}

export function registerPhoneRemoteHandlers() {
if (subscribed) {
return;
}

subscribed = true;
subscribePhoneRemoteStore((event) => {
if (event.type === "signal") {
sendToOwner(event.ownerWebContentsId, "phone-remote-signal", {
sessionId: event.sessionId,
message: event.message,
});
return;
}

sendToOwner(event.ownerWebContentsId, "phone-remote-status", {
sessionId: event.sessionId,
status: event.status,
});
});

ipcMain.handle("phone-remote:create-session", async (event) => {
const urls = await getPhoneRemoteJoinUrls();
const session = createPhoneRemoteSession(event.sender.id, {
...urls,
joinUrl: `${urls.joinUrl}?code=`,
localJoinUrl: `${urls.localJoinUrl}?code=`,
lanJoinUrl: `${urls.lanJoinUrl}?code=`,
tunnelJoinUrl: urls.tunnelJoinUrl ? `${urls.tunnelJoinUrl}?code=` : undefined,
});

return {
success: true,
session: {
...session,
joinUrl: `${session.joinUrl}${encodeURIComponent(session.code)}`,
localJoinUrl: `${session.localJoinUrl}${encodeURIComponent(session.code)}`,
lanJoinUrl: `${session.lanJoinUrl}${encodeURIComponent(session.code)}`,
tunnelJoinUrl: session.tunnelJoinUrl
? `${session.tunnelJoinUrl}${encodeURIComponent(session.code)}`
: undefined,
},
};
});

ipcMain.handle("phone-remote:end-session", (_event, sessionId: string) => {
return { success: endPhoneRemoteSession(sessionId) };
});

ipcMain.handle("phone-remote:send-signal", (event, sessionId: string, value: unknown) => {
const message = parseSignalMessage(value);
if (!message) {
return {
success: false,
error: "Invalid phone remote signal payload.",
};
}

const envelope = addLaptopSignal(sessionId, event.sender.id, message);
return envelope
? { success: true, index: envelope.index }
: { success: false, error: "Phone remote session was not found." };
});
}
Loading