Skip to content
Merged
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
171 changes: 152 additions & 19 deletions opennow-stable/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, session } from "electron";
import { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, session, protocol } from "electron";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve, relative } from "node:path";
import { dirname, join, resolve } from "node:path";
import { existsSync, readFileSync, createWriteStream } from "node:fs";
import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile, realpath } from "node:fs/promises";
import * as net from "node:net";
Expand All @@ -14,6 +14,11 @@ import { spawn } from "node:child_process";
// F8 - Toggle mouse/pointer lock (handled in main process via IPC)

import { IPC_CHANNELS } from "@shared/ipc";
import {
getTrustedVideoPlaybackFileUrl,
registerOpenNowMediaProtocol,
resolveTrustedOpenNowMediaPath,
} from "./mediaPaths";
import { initLogCapture, exportLogs } from "@shared/logger";
import { cacheManager } from "./services/cacheManager";
import { refreshScheduler } from "./services/refreshScheduler";
Expand Down Expand Up @@ -223,6 +228,20 @@ app.commandLine.appendSwitch("disable-backgrounding-occluded-windows");
// Remove getUserMedia FPS cap (not strictly needed for receive-only but avoids potential limits)
app.commandLine.appendSwitch("max-gum-fps", "999");

// file:// in <video> is blocked by Chromium for renderer pages; use a privileged custom scheme.
protocol.registerSchemesAsPrivileged([
{
scheme: "opennow-media",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
corsEnabled: true,
},
},
]);

let mainWindow: BrowserWindow | null = null;
let rendererControlledFullscreen = false;
let signalingClient: GfnSignalingClient | null = null;
Expand Down Expand Up @@ -567,10 +586,49 @@ function md5(input: string): string {
return createHash("md5").update(input).digest("hex");
}

async function generateVideoThumbnail(sourcePath: string, outPath: string): Promise<boolean> {
/** Seconds; null if ffprobe missing or unreadable. */
async function probeVideoDurationSeconds(sourcePath: string): Promise<number | null> {
return new Promise((resolve) => {
const args = [
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
sourcePath,
];
const child = spawn("ffprobe", args, { stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout?.on("data", (chunk: Buffer) => {
out += chunk.toString();
});
child.on("error", () => resolve(null));
child.on("close", (code) => {
if (code !== 0) {
resolve(null);
return;
}
const n = Number.parseFloat(out.trim());
resolve(Number.isFinite(n) && n > 0 ? n : null);
});
});
}

function randomThumbnailSeekSeconds(durationSec: number | null): number {
if (durationSec !== null && durationSec > 0.2) {
const margin = Math.min(0.35, durationSec * 0.08);
const hi = Math.max(durationSec - margin, margin + 0.05);
const lo = Math.min(margin, hi * 0.5);
return lo + Math.random() * (hi - lo);
}
return 0.2 + Math.random() * 4.8;
}

function ffmpegExtractOneFrame(sourcePath: string, outPath: string, seekSec: number): Promise<boolean> {
const ss = seekSec.toFixed(3);
return new Promise<boolean>((resolve) => {
// Try to run ffmpeg to extract a frame at 1s.
const args = ["-y", "-ss", "1", "-i", sourcePath, "-frames:v", "1", "-q:v", "2", outPath];
const args = ["-y", "-ss", ss, "-i", sourcePath, "-frames:v", "1", "-q:v", "2", outPath];
const child = spawn("ffmpeg", args, { stdio: "ignore" });
child.on("error", () => resolve(false));
child.on("close", (code) => {
Expand All @@ -579,6 +637,14 @@ async function generateVideoThumbnail(sourcePath: string, outPath: string): Prom
});
}

async function generateVideoThumbnail(sourcePath: string, outPath: string): Promise<boolean> {
const durationSec = await probeVideoDurationSeconds(sourcePath);
const seekSec = randomThumbnailSeekSeconds(durationSec);
if (await ffmpegExtractOneFrame(sourcePath, outPath, seekSec)) return true;
if (seekSec > 0.02) return ffmpegExtractOneFrame(sourcePath, outPath, 0);
return false;
}

async function ensureThumbnailForMedia(filePath: string): Promise<string | null> {
try {
const stats = await stat(filePath);
Expand Down Expand Up @@ -648,7 +714,7 @@ async function listRecordings(): Promise<RecordingEntry[]> {
const filePath = join(dir, fileName);
try {
const fileStats = await stat(filePath);
const stem = fileName.replace(/\.webm$/i, "");
const stem = fileName.replace(/\.(mp4|webm)$/i, "");
const thumbName = `${stem}-thumb.jpg`;
const thumbPath = join(dir, thumbName);

Expand Down Expand Up @@ -1803,14 +1869,9 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.MEDIA_THUMBNAIL, async (_event, payload: { filePath: string }): Promise<string | null> => {
const rawFp = payload?.filePath;
if (typeof rawFp !== "string") return null;
if (rawFp.length > 4096) return null;
try {
const allowedRoot = resolve(join(app.getPath("pictures"), "OpenNOW"));
const fpResolved = resolve(rawFp);
const allowedRootReal = await realpath(allowedRoot).catch(() => allowedRoot);
const fpReal = await realpath(fpResolved).catch(() => fpResolved);
const rel = relative(allowedRootReal, fpReal);
if (rel.startsWith("..")) return null;
const fpReal = await resolveTrustedOpenNowMediaPath(rawFp);
if (!fpReal) return null;

const lower = fpReal.toLowerCase();
if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".webp")) {
Expand Down Expand Up @@ -1855,18 +1916,89 @@ function registerIpcHandlers(): void {
const rawFp = payload?.filePath;
if (typeof rawFp !== "string") return;
try {
const allowedRoot = resolve(join(app.getPath("pictures"), "OpenNOW"));
const fpResolved = resolve(rawFp);
const allowedRootReal = await realpath(allowedRoot).catch(() => allowedRoot);
const fpReal = await realpath(fpResolved).catch(() => fpResolved);
const rel = relative(allowedRootReal, fpReal);
if (rel.startsWith("..")) return;
const fpReal = await resolveTrustedOpenNowMediaPath(rawFp);
if (!fpReal) return;
shell.showItemInFolder(fpReal);
} catch {
return;
}
});

ipcMain.handle(IPC_CHANNELS.MEDIA_PLAYBACK_URL, async (_event, payload: { filePath: string }): Promise<string | null> => {
const rawFp = payload?.filePath;
if (typeof rawFp !== "string") return null;
try {
return await getTrustedVideoPlaybackFileUrl(rawFp);
} catch (err) {
console.warn("MEDIA_PLAYBACK_URL error:", err);
return null;
}
});

ipcMain.handle(IPC_CHANNELS.MEDIA_DELETE_FILE, async (_event, payload: { filePath: string }): Promise<{ ok: boolean }> => {
const rawFp = payload?.filePath;
if (typeof rawFp !== "string") return { ok: false };
try {
const fpReal = await resolveTrustedOpenNowMediaPath(rawFp);
if (!fpReal) return { ok: false };
let st: Awaited<ReturnType<typeof stat>>;
try {
st = await stat(fpReal);
} catch {
return { ok: false };
}
const key = md5(`${fpReal}|${st.mtimeMs}`);
const cacheDir = await ensureThumbnailCacheDirectory();
await unlink(join(cacheDir, `${key}.jpg`)).catch(() => undefined);
const stem = fpReal.replace(/\.(mp4|webm|mkv|mov|png|jpg|jpeg|webp)$/i, "");
await unlink(`${stem}-thumb.jpg`).catch(() => undefined);
await unlink(fpReal);
return { ok: true };
} catch (err) {
console.warn("MEDIA_DELETE_FILE error:", err);
return { ok: false };
}
});

ipcMain.handle(
IPC_CHANNELS.MEDIA_REGEN_THUMBNAIL,
async (_event, payload: { filePath: string }): Promise<{ ok: boolean; thumbnailDataUrl: string | null }> => {
const rawFp = payload?.filePath;
if (typeof rawFp !== "string") return { ok: false, thumbnailDataUrl: null };
try {
const fpReal = await resolveTrustedOpenNowMediaPath(rawFp);
if (!fpReal) return { ok: false, thumbnailDataUrl: null };
const st = await stat(fpReal);
const key = md5(`${fpReal}|${st.mtimeMs}`);
const cacheDir = await ensureThumbnailCacheDirectory();
await unlink(join(cacheDir, `${key}.jpg`)).catch(() => undefined);
if (/\.(mp4|webm|mkv|mov)$/i.test(fpReal)) {
const videoStem = fpReal.replace(/\.(mp4|webm|mkv|mov)$/i, "");
await unlink(`${videoStem}-thumb.jpg`).catch(() => undefined);
}
const genPath = await ensureThumbnailForMedia(fpReal);
if (!genPath) return { ok: false, thumbnailDataUrl: null };

if (/\.(mp4|webm|mkv|mov)$/i.test(fpReal)) {
const videoStem = fpReal.replace(/\.(mp4|webm|mkv|mov)$/i, "");
const sidecar = `${videoStem}-thumb.jpg`;
await copyFile(genPath, sidecar).catch((err) => {
console.warn("MEDIA_REGEN_THUMBNAIL sidecar copy:", err);
});
}

const b = await readFile(genPath);
return {
ok: true,
thumbnailDataUrl: `data:image/jpeg;base64,${b.toString("base64")}`,
};
} catch (err) {
console.warn("MEDIA_REGEN_THUMBNAIL error:", err);
return { ok: false, thumbnailDataUrl: null };
}
},
);

ipcMain.handle(IPC_CHANNELS.CACHE_REFRESH_MANUAL, async (): Promise<void> => {
await refreshScheduler.manualRefresh();
});
Expand Down Expand Up @@ -2173,6 +2305,7 @@ app.whenReady().then(async () => {
return allowedPermissions.has(permission);
});

registerOpenNowMediaProtocol();
registerIpcHandlers();

refreshScheduler.initialize(
Expand Down
151 changes: 151 additions & 0 deletions opennow-stable/src/main/mediaPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { app, protocol } from "electron";
import { createReadStream } from "node:fs";
import { join, resolve, relative } from "node:path";
import { Readable } from "node:stream";
import { realpath, stat } from "node:fs/promises";
import { isPlayableVideoFilePath } from "@shared/mediaPlayback";

const MAX_MEDIA_PATH_LENGTH = 4096;

const OPENNOW_MEDIA_HOST = "opennow";

let openNowMediaProtocolHandleInstalled = false;

function videoMimeTypeForPath(filePath: string): string {
const lower = filePath.toLowerCase();
if (lower.endsWith(".webm")) return "video/webm";
if (lower.endsWith(".mp4")) return "video/mp4";
if (lower.endsWith(".mov")) return "video/quicktime";
if (lower.endsWith(".mkv")) return "video/x-matroska";
return "application/octet-stream";
}

/**
* Parse a single Range: bytes=… header. Returns inclusive start/end, or null if unsatisfiable.
*/
function parseByteRangeHeader(rangeHeader: string, fileSize: number): { start: number; end: number } | null {
const m = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
if (!m) return null;
const g1 = m[1];
const g2 = m[2];
if (g1 !== "" && g2 !== "") {
const start = Number(g1);
const end = Number(g2);
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= fileSize) return null;
return { start, end: Math.min(end, fileSize - 1) };
}
if (g1 !== "" && g2 === "") {
const start = Number(g1);
if (!Number.isFinite(start) || start >= fileSize) return null;
return { start, end: fileSize - 1 };
}
if (g1 === "" && g2 !== "") {
const len = Number(g2);
if (!Number.isFinite(len) || len <= 0) return null;
if (len >= fileSize) return { start: 0, end: fileSize - 1 };
return { start: fileSize - len, end: fileSize - 1 };
}
return { start: 0, end: fileSize - 1 };
}

/**
* Resolve a user-supplied path to a real path under Pictures/OpenNOW, or null if unsafe / missing.
*/
export async function resolveTrustedOpenNowMediaPath(rawFp: string): Promise<string | null> {
if (typeof rawFp !== "string" || rawFp.length > MAX_MEDIA_PATH_LENGTH) return null;
try {
const allowedRoot = resolve(join(app.getPath("pictures"), "OpenNOW"));
const fpResolved = resolve(rawFp);
const allowedRootReal = await realpath(allowedRoot).catch(() => allowedRoot);
const fpReal = await realpath(fpResolved).catch(() => fpResolved);
const rel = relative(allowedRootReal, fpReal);
if (rel.startsWith("..")) return null;
return fpReal;
} catch {
return null;
}
}

/**
* URL for in-renderer &lt;video src&gt;. Uses custom scheme (registered in main) because file:// is often blocked.
*/
export async function getTrustedVideoPlaybackFileUrl(rawFp: string): Promise<string | null> {
const fpReal = await resolveTrustedOpenNowMediaPath(rawFp);
if (!fpReal || !isPlayableVideoFilePath(fpReal)) return null;
return `opennow-media://${OPENNOW_MEDIA_HOST}/playback?p=${encodeURIComponent(fpReal)}`;
}

/**
* Must run during app startup (after ready), before windows load media URLs.
*/
export function registerOpenNowMediaProtocol(): void {
if (openNowMediaProtocolHandleInstalled) return;
openNowMediaProtocolHandleInstalled = true;

protocol.handle("opennow-media", async (request) => {
try {
const url = new URL(request.url);
if (url.hostname.toLowerCase() !== OPENNOW_MEDIA_HOST) {
return new Response(null, { status: 404 });
}
const pathNorm = url.pathname.replace(/\/$/, "") || "/";
if (!pathNorm.endsWith("/playback")) {
return new Response(null, { status: 404 });
}
const p = url.searchParams.get("p");
if (!p) return new Response(null, { status: 400 });
const fpReal = await resolveTrustedOpenNowMediaPath(p);
if (!fpReal || !isPlayableVideoFilePath(fpReal)) return new Response(null, { status: 404 });

const mime = videoMimeTypeForPath(fpReal);
const { size } = await stat(fpReal);
const baseHeaders: Record<string, string> = {
"Content-Type": mime,
"Accept-Ranges": "bytes",
};

if (request.method === "HEAD") {
return new Response(null, {
status: 200,
headers: {
...baseHeaders,
"Content-Length": String(size),
},
});
}

const rangeRaw = request.headers.get("range");
if (rangeRaw) {
const firstRange = rangeRaw.split(",")[0]?.trim() ?? "";
const parsed = firstRange ? parseByteRangeHeader(firstRange, size) : null;
if (parsed) {
const { start, end } = parsed;
const chunkLength = end - start + 1;
const nodeStream = createReadStream(fpReal, { start, end });
const body = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new Response(body, {
status: 206,
headers: {
...baseHeaders,
"Content-Length": String(chunkLength),
"Content-Range": `bytes ${start}-${end}/${size}`,
},
});
}
}

const nodeStream = createReadStream(fpReal);
const body = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new Response(body, {
status: 200,
headers: {
...baseHeaders,
"Content-Length": String(size),
},
});
} catch (err) {
console.warn("[opennow-media] protocol handler:", err);
return new Response(null, { status: 500 });
}
});
}
Loading
Loading