summaryrefslogtreecommitdiff
path: root/apps/desktop/src/main.ts
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-30 18:17:45 +0200
committercashmere <cashmere@autistici.org>2026-07-30 18:18:15 +0200
commited02ba872c02eafed45f406c68d389d0a1112a4e (patch)
tree40530d77cfe306621915f067c66531cb9e783a4d /apps/desktop/src/main.ts
parente837e7504379e100ff3b56282577ec67bb88bec9 (diff)
downloadopencut-ed02ba872c02eafed45f406c68d389d0a1112a4e.tar.gz
feat: Electron desktop app, native ffmpeg export, fully static web
- apps/desktop: Electron shell serving the static web build over a privileged opencut:// protocol; exports stream frames over IPC to a bundled ffmpeg (libx264/libvpx-vp9, BT.709 tagged) with backpressure. electron-builder packaging: AppImage, deb, rpm. - apps/web: dead backend deleted (better-auth, drizzle/pg, upstash, botid, freesound proxy, blog, rss, env validation); next build emits a zero-env static export; /editor/[project_id] -> /editor?project=. - Native exporter mirrors SceneExporter; activates when the preload bridge is present; web keeps the mediabunny path. - Docker self-hosting now builds and serves the static export via nginx. - Release workflow: v* tags publish static web tarball + Linux packages. - Changelog 0.4.0; README/AGENTS/CONTRIBUTING/roadmap updated. - GPUI/Rust rewrite parked on branch rust-rewrite.
Diffstat (limited to 'apps/desktop/src/main.ts')
-rw-r--r--apps/desktop/src/main.ts131
1 files changed, 131 insertions, 0 deletions
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
new file mode 100644
index 00000000..fccd7c1d
--- /dev/null
+++ b/apps/desktop/src/main.ts
@@ -0,0 +1,131 @@
+import { app, BrowserWindow, protocol } from "electron";
+import { stat } from "node:fs/promises";
+import { createReadStream } from "node:fs";
+import path from "node:path";
+import { Readable } from "node:stream";
+import { cancelAllExports, registerExportHandlers } from "./export.js";
+
+const contentTypes: Record<string, string> = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".wasm": "application/wasm",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".svg": "image/svg+xml",
+ ".ico": "image/x-icon",
+ ".woff2": "font/woff2",
+ ".mp4": "video/mp4",
+ ".webm": "video/webm",
+ ".txt": "text/plain; charset=utf-8",
+ ".xml": "application/xml; charset=utf-8",
+ ".map": "application/json; charset=utf-8",
+};
+
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: "opencut",
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ stream: true,
+ bypassCSP: false,
+ },
+ },
+]);
+
+function registerWebProtocol(): void {
+ const webDir = app.isPackaged
+ ? path.join(process.resourcesPath, "web")
+ : path.join(__dirname, "..", "web");
+
+ protocol.handle("opencut", async (request) => {
+ const url = new URL(request.url);
+ const pathname = decodeURIComponent(url.pathname);
+ const resolved = path.normalize(path.join(webDir, pathname));
+
+ if (resolved !== webDir && !resolved.startsWith(`${webDir}${path.sep}`)) {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ let filePath = resolved;
+ let info = await stat(filePath).catch(() => null);
+
+ if (info?.isDirectory() || (!info && path.extname(filePath) === "")) {
+ filePath = path.join(resolved, "index.html");
+ info = await stat(filePath).catch(() => null);
+ }
+
+ if (!info?.isFile()) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const contentType = contentTypes[path.extname(filePath).toLowerCase()];
+ if (!contentType) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const stream = Readable.toWeb(createReadStream(filePath));
+ return new Response(stream as ReadableStream, {
+ headers: { "Content-Type": contentType },
+ });
+ });
+}
+
+function createWindow(): void {
+ const win = new BrowserWindow({
+ width: 1440,
+ height: 900,
+ minWidth: 960,
+ minHeight: 600,
+ backgroundColor: "#0a0a0a",
+ autoHideMenuBar: true,
+ webPreferences: {
+ preload: path.join(__dirname, "preload.js"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: false,
+ },
+ });
+
+ const devServerUrl = process.env.OPENCUT_DEV_SERVER_URL;
+ if (devServerUrl) {
+ win.loadURL(devServerUrl);
+ return;
+ }
+
+ win.loadURL("opencut://app/");
+}
+
+const gotSingleInstanceLock = app.requestSingleInstanceLock();
+
+if (!gotSingleInstanceLock) {
+ app.quit();
+} else {
+ app.on("second-instance", () => {
+ const [win] = BrowserWindow.getAllWindows();
+ if (!win) return;
+ if (win.isMinimized()) win.restore();
+ win.focus();
+ });
+
+ app.whenReady().then(() => {
+ registerWebProtocol();
+ registerExportHandlers();
+ createWindow();
+
+ app.on("activate", () => {
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
+ });
+ });
+}
+
+app.on("window-all-closed", () => {
+ app.quit();
+});
+
+app.on("will-quit", () => {
+ cancelAllExports();
+});