diff --git a/electron/main.ts b/electron/main.ts index 1cfa1c07..f5f1b89f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -46,6 +46,7 @@ import { isHudOverlayMousePassthroughSupported, showUpdateToastWindow, } from "./windows"; +import { ensurePackagedRendererServer } from "./rendererServer"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const IS_SMOKE_EXPORT = process.env.RECORDLY_SMOKE_EXPORT === "1"; @@ -747,6 +748,14 @@ app.whenReady().then(async () => { // Ensure recordings directory exists await ensureRecordingsDir(); + if (!VITE_DEV_SERVER_URL) { + try { + await ensurePackagedRendererServer(RENDERER_DIST); + } catch (error) { + console.warn("[renderer-server] Failed to start packaged renderer server:", error); + } + } + registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, diff --git a/electron/rendererServer.ts b/electron/rendererServer.ts new file mode 100644 index 00000000..881430c5 --- /dev/null +++ b/electron/rendererServer.ts @@ -0,0 +1,128 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const MIME_TYPES: Record = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +let packagedRendererBaseUrl: string | null = null; +let packagedRendererServerStartPromise: Promise | null = null; + +function getContentType(filePath: string): string { + return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +function resolveRequestedFilePath(rootDir: string, requestPathname: string): string | null { + const trimmedPathname = requestPathname === "/" ? "/index.html" : requestPathname; + const relativePath = path.normalize(decodeURIComponent(trimmedPathname)).replace(/^\/+/, ""); + const resolvedRootDir = path.resolve(rootDir); + const resolvedFilePath = path.resolve(resolvedRootDir, relativePath); + + if ( + resolvedFilePath !== resolvedRootDir && + !resolvedFilePath.startsWith(`${resolvedRootDir}${path.sep}`) + ) { + return null; + } + + return resolvedFilePath; +} + +async function servePackagedRendererRequest( + rootDir: string, + request: IncomingMessage, + response: ServerResponse, +): Promise { + try { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + const resolvedFilePath = resolveRequestedFilePath(rootDir, requestUrl.pathname); + + if (!resolvedFilePath) { + response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Forbidden"); + return; + } + + const filePath = resolvedFilePath; + const fileContents = await fs.readFile(filePath); + + response.writeHead(200, { + "Cache-Control": "no-cache", + "Content-Type": getContentType(filePath), + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + response.end(fileContents); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not Found"); + return; + } + + response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Internal Server Error"); + } +} + +export function getPackagedRendererBaseUrl(): string | null { + return packagedRendererBaseUrl; +} + +export async function ensurePackagedRendererServer(rootDir: string): Promise { + if (packagedRendererBaseUrl) { + return packagedRendererBaseUrl; + } + + if (packagedRendererServerStartPromise) { + return packagedRendererServerStartPromise; + } + + packagedRendererServerStartPromise = new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void servePackagedRendererRequest(rootDir, request, response); + }); + + server.once("error", (error) => { + reject(error); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Renderer server did not expose a TCP address")); + return; + } + + packagedRendererBaseUrl = `http://127.0.0.1:${address.port}`; + resolve(packagedRendererBaseUrl); + }); + }); + + try { + return await packagedRendererServerStartPromise; + } finally { + packagedRendererServerStartPromise = null; + } +} \ No newline at end of file diff --git a/electron/windows.ts b/electron/windows.ts index 240d89f1..3ab5e3de 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { app, BrowserWindow, ipcMain } from "electron"; import { USER_DATA_PATH } from "./appPaths"; +import { getPackagedRendererBaseUrl } from "./rendererServer"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const nodeRequire = createRequire(import.meta.url); @@ -643,10 +644,19 @@ export function createEditorWindow(): BrowserWindow { const query = new URLSearchParams(getEditorWindowQuery()); win.loadURL(`${VITE_DEV_SERVER_URL}?${query.toString()}`); } else { - console.log("[editor-window] load-file", path.join(RENDERER_DIST, "index.html")); - win.loadFile(path.join(RENDERER_DIST, "index.html"), { - query: getEditorWindowQuery(), - }); + const query = new URLSearchParams(getEditorWindowQuery()); + const packagedRendererBaseUrl = getPackagedRendererBaseUrl(); + + if (packagedRendererBaseUrl) { + const targetUrl = `${packagedRendererBaseUrl}/?${query.toString()}`; + console.log("[editor-window] load-url", targetUrl); + win.loadURL(targetUrl); + } else { + console.log("[editor-window] load-file", path.join(RENDERER_DIST, "index.html")); + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: getEditorWindowQuery(), + }); + } } return win;