summaryrefslogtreecommitdiff
path: root/apps/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'apps/desktop')
-rw-r--r--apps/desktop/.gitignore4
-rw-r--r--apps/desktop/Cargo.toml11
-rw-r--r--apps/desktop/README.md47
-rw-r--r--apps/desktop/build/icon.pngbin0 -> 741 bytes
-rw-r--r--apps/desktop/electron-builder.yml28
-rw-r--r--apps/desktop/package.json25
-rw-r--r--apps/desktop/script/setup110
-rw-r--r--apps/desktop/script/setup.ps127
-rw-r--r--apps/desktop/scripts/copy-web.mjs18
-rw-r--r--apps/desktop/src/export.ts293
-rw-r--r--apps/desktop/src/main.rs41
-rw-r--r--apps/desktop/src/main.ts131
-rw-r--r--apps/desktop/src/preload.ts22
-rw-r--r--apps/desktop/tsconfig.json17
14 files changed, 538 insertions, 236 deletions
diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore
new file mode 100644
index 00000000..090e2f63
--- /dev/null
+++ b/apps/desktop/.gitignore
@@ -0,0 +1,4 @@
+dist/
+web/
+release/
+node_modules/
diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml
deleted file mode 100644
index e09443ab..00000000
--- a/apps/desktop/Cargo.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-[package]
-name = "opencut-desktop"
-version = "0.1.0"
-edition = "2021"
-
-[[bin]]
-name = "opencut"
-path = "src/main.rs"
-
-[dependencies]
-gpui = "0.2.2"
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
deleted file mode 100644
index 9cff04e1..00000000
--- a/apps/desktop/README.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Desktop
-
-The native desktop app, built with [GPUI](https://gpui.rs).
-
-## Getting started
-
-**1. Install Rust:**
-
-```bash
-# Linux / macOS / WSL
-./script/setup-rust
-```
-
-```powershell
-# Windows
-powershell -ExecutionPolicy Bypass -File .\script\setup-rust.ps1
-```
-
-Both scripts skip installation if Rust is already present. On Linux/macOS/WSL only: after a fresh install, reload your shell with `source "$HOME/.cargo/env"`
-
-**2. Install native dependencies:**
-
-```bash
-# Linux / macOS / WSL
-./apps/desktop/script/setup
-```
-
-```powershell
-# Windows
-powershell -ExecutionPolicy Bypass -File .\apps\desktop\script\setup.ps1
-```
-
-**3. Run:**
-
-```bash
-cargo run -p opencut-desktop
-```
-
-## Platform notes
-
-**Linux:** supports apt (Debian/Ubuntu/Mint), dnf (Fedora/RHEL), and pacman (Arch).
-
-**macOS:** installs Xcode Command Line Tools if missing.
-
-**Windows:** the setup script checks for Visual Studio Build Tools. If missing, it prints the install link.
-
-**WSL:** runs the same scripts as Linux. Window rendering works via WSLg on Windows 11 and Windows 10 22H2+. If you're on an older build, test on the host instead.
diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png
new file mode 100644
index 00000000..f534f429
--- /dev/null
+++ b/apps/desktop/build/icon.png
Binary files differ
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
new file mode 100644
index 00000000..b47cf1d7
--- /dev/null
+++ b/apps/desktop/electron-builder.yml
@@ -0,0 +1,28 @@
+appId: rs.cashmere.opencut
+productName: OpenCut
+directories:
+ output: release
+files:
+ - dist/**
+ - package.json
+asarUnpack:
+ - "**/ffmpeg-static/**"
+extraResources:
+ - from: web
+ to: web
+linux:
+ executableName: opencut
+ icon: build/icon.png
+ syncDesktopName: true
+ category: AudioVideo;Video
+ target:
+ - AppImage
+ - deb
+ - rpm
+ artifactName: OpenCut-${version}-linux-${arch}.${ext}
+deb:
+ maintainer: cashmere
+ description: Offline-first video editor
+rpm:
+ maintainer: cashmere
+ description: Offline-first video editor
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 00000000..99934d7b
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@opencut/desktop",
+ "version": "0.1.0",
+ "private": true,
+ "main": "dist/main.js",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "dev": "bun run build && electron .",
+ "pack": "bun run build && electron-builder --dir",
+ "dist": "bun run build && electron-builder --linux AppImage deb rpm",
+ "copy:web": "node scripts/copy-web.mjs"
+ },
+ "dependencies": {
+ "ffmpeg-static": "^5.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.2",
+ "electron": "^43.2.0",
+ "electron-builder": "^26.15.3",
+ "typescript": "^7.0.2"
+ },
+ "author": "cashmere",
+ "homepage": "https://github.com/cashmeredev/opencut",
+ "desktopName": "OpenCut"
+}
diff --git a/apps/desktop/script/setup b/apps/desktop/script/setup
deleted file mode 100644
index 3152a080..00000000
--- a/apps/desktop/script/setup
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env bash
-
-# Installs the native system libraries GPUI needs to compile on Linux/macOS.
-# Run script/setup-rust first if you don't have Rust installed yet.
-
-set -euo pipefail
-
-if [ "$(id -u)" -eq 0 ]; then
- maysudo=''
-else
- maysudo="$(command -v sudo || command -v doas || true)"
-fi
-
-# Ubuntu, Debian, Mint, Pop!_OS, Kali, Raspbian, etc.
-apt=$(command -v apt-get || true)
-if [[ -n $apt ]]; then
- deps=(
- curl
- build-essential
- pkg-config
- gcc
- g++
- make
- libasound2-dev
- libfontconfig-dev
- libglib2.0-dev
- libssl-dev
- libva-dev
- libvulkan1
- libwayland-dev
- libx11-xcb-dev
- libxkbcommon-x11-dev
- libzstd-dev
- libsqlite3-dev
- )
-
- $maysudo "$apt" update
- $maysudo "$apt" install -y "${deps[@]}"
- echo "Desktop native dependencies installed."
- exit 0
-fi
-
-# Fedora, RHEL, CentOS, Alma, etc.
-dnf=$(command -v dnf || true)
-if [[ -n $dnf ]]; then
- deps=(
- gcc
- gcc-c++
- make
- pkg-config
- alsa-lib-devel
- fontconfig-devel
- glib2-devel
- openssl-devel
- libva-devel
- vulkan-loader
- wayland-devel
- libxcb-devel
- libxkbcommon-x11-devel
- libzstd-devel
- sqlite-devel
- curl
- )
-
- $maysudo "$dnf" install -y "${deps[@]}"
- echo "Desktop native dependencies installed."
- exit 0
-fi
-
-# Arch, Manjaro, etc.
-pacman=$(command -v pacman || true)
-if [[ -n $pacman ]]; then
- deps=(
- gcc
- make
- pkgconf
- alsa-lib
- fontconfig
- glib2
- openssl
- libva
- vulkan-loader
- wayland
- libxcb
- libxkbcommon-x11
- zstd
- sqlite
- curl
- )
-
- $maysudo "$pacman" -Syu --needed --noconfirm "${deps[@]}"
- echo "Desktop native dependencies installed."
- exit 0
-fi
-
-# macOS
-if [[ "$(uname)" == "Darwin" ]]; then
- if ! xcode-select -p &> /dev/null; then
- echo "Installing Xcode Command Line Tools..."
- xcode-select --install
- echo "Re-run this script after the Xcode install completes."
- exit 0
- fi
-
- echo "Desktop native dependencies installed."
- exit 0
-fi
-
-echo "Unsupported platform. See apps/desktop/README.md for manual setup instructions."
-exit 1
diff --git a/apps/desktop/script/setup.ps1 b/apps/desktop/script/setup.ps1
deleted file mode 100644
index 63f7fd1a..00000000
--- a/apps/desktop/script/setup.ps1
+++ /dev/null
@@ -1,27 +0,0 @@
-# Installs the native build tools GPUI needs to compile on Windows.
-# Run script/setup-rust.ps1 first if you don't have Rust installed yet.
-
-$ErrorActionPreference = "Stop"
-
-function Check-VSBuildTools {
- $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"
-
- if (-not (Test-Path $vswhere)) {
- return $false
- }
-
- $result = & $vswhere -latest -requires Microsoft.VisualStudio.Workload.NativeDesktop -property installationPath
- return $result -and $result.Trim().Length -gt 0
-}
-
-if (-not (Check-VSBuildTools)) {
- Write-Output ""
- Write-Output "Visual Studio Build Tools with 'Desktop development with C++' not found."
- Write-Output ""
- Write-Output "Install it from: https://visualstudio.microsoft.com/visual-cpp-build-tools/"
- Write-Output "Check the 'Desktop development with C++' workload during setup, then re-run this script."
- Write-Output ""
- exit 1
-}
-
-Write-Output "Desktop native dependencies ready."
diff --git a/apps/desktop/scripts/copy-web.mjs b/apps/desktop/scripts/copy-web.mjs
new file mode 100644
index 00000000..f5f6f4f6
--- /dev/null
+++ b/apps/desktop/scripts/copy-web.mjs
@@ -0,0 +1,18 @@
+import { cpSync, existsSync, rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const desktopDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const source = path.join(desktopDir, "..", "web", "out");
+const target = path.join(desktopDir, "web");
+
+if (!existsSync(source)) {
+ process.stderr.write(
+ `Web bundle not found at ${source}. Run \`bun build:web\` first.\n`,
+ );
+ process.exit(1);
+}
+
+rmSync(target, { recursive: true, force: true });
+cpSync(source, target, { recursive: true });
+process.stdout.write(`Copied ${source} -> ${target}\n`);
diff --git a/apps/desktop/src/export.ts b/apps/desktop/src/export.ts
new file mode 100644
index 00000000..4cbfe613
--- /dev/null
+++ b/apps/desktop/src/export.ts
@@ -0,0 +1,293 @@
+import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
+import { type ChildProcessByStdio, spawn } from "node:child_process";
+import type { Readable, Writable } from "node:stream";
+import { randomUUID } from "node:crypto";
+import { unlink, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import ffmpegStaticPath from "ffmpeg-static";
+
+export type DesktopExportBegin = {
+ width: number;
+ height: number;
+ fpsNumerator: number;
+ fpsDenominator: number;
+ format: "mp4" | "webm";
+ quality: "low" | "medium" | "high" | "very_high";
+ hasAudio: boolean;
+ sampleRate: number;
+ channels: number;
+ defaultFileName: string;
+};
+
+type ExportQuality = DesktopExportBegin["quality"];
+
+type FfmpegProcess = ChildProcessByStdio<Writable, null, Readable>;
+
+type ExportSession = {
+ id: string;
+ filePath: string;
+ opts: DesktopExportBegin;
+ audioPath: string | null;
+ process: FfmpegProcess | null;
+ exitCode: Promise<number> | null;
+ stderrTail: string;
+};
+
+const MP4_CRF: Record<ExportQuality, number> = {
+ low: 28,
+ medium: 23,
+ high: 19,
+ very_high: 15,
+};
+
+const WEBM_CRF: Record<ExportQuality, number> = {
+ low: 40,
+ medium: 32,
+ high: 24,
+ very_high: 16,
+};
+
+const STDERR_TAIL_LIMIT = 8192;
+
+const sessions = new Map<string, ExportSession>();
+
+function resolveFfmpegPath(): string {
+ if (!ffmpegStaticPath) {
+ throw new Error("ffmpeg binary is not available for this platform");
+ }
+ if (app.isPackaged) {
+ return ffmpegStaticPath.replace("app.asar", "app.asar.unpacked");
+ }
+ return ffmpegStaticPath;
+}
+
+function buildFfmpegArgs(session: ExportSession): string[] {
+ const { opts, filePath, audioPath } = session;
+
+ const args = [
+ "-y",
+ "-f",
+ "rawvideo",
+ "-pix_fmt",
+ "rgba",
+ "-s",
+ `${opts.width}x${opts.height}`,
+ "-r",
+ `${opts.fpsNumerator}/${opts.fpsDenominator}`,
+ "-i",
+ "pipe:0",
+ ];
+
+ if (audioPath) {
+ args.push(
+ "-f",
+ "f32le",
+ "-ar",
+ String(opts.sampleRate),
+ "-ac",
+ String(opts.channels),
+ "-i",
+ audioPath,
+ );
+ }
+
+ if (opts.format === "mp4") {
+ args.push(
+ "-c:v",
+ "libx264",
+ "-preset",
+ "medium",
+ "-crf",
+ String(MP4_CRF[opts.quality]),
+ "-pix_fmt",
+ "yuv420p",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-colorspace",
+ "bt709",
+ );
+ if (audioPath) {
+ args.push("-c:a", "aac", "-b:a", "192k");
+ }
+ args.push("-movflags", "+faststart");
+ } else {
+ args.push(
+ "-c:v",
+ "libvpx-vp9",
+ "-deadline",
+ "good",
+ "-cpu-used",
+ "2",
+ "-row-mt",
+ "1",
+ "-crf",
+ String(WEBM_CRF[opts.quality]),
+ "-b:v",
+ "0",
+ "-pix_fmt",
+ "yuv420p",
+ );
+ if (audioPath) {
+ args.push("-c:a", "libopus", "-b:a", "192k");
+ }
+ }
+
+ if (audioPath) {
+ args.push("-shortest");
+ }
+
+ args.push(filePath);
+ return args;
+}
+
+function getSession(id: string): ExportSession {
+ const session = sessions.get(id);
+ if (!session) {
+ throw new Error(`Unknown export session: ${id}`);
+ }
+ return session;
+}
+
+function toBuffer(data: ArrayBuffer | Uint8Array): Buffer {
+ if (data instanceof Uint8Array) {
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
+ }
+ return Buffer.from(data);
+}
+
+function spawnFfmpeg(session: ExportSession): FfmpegProcess {
+ const proc = spawn(resolveFfmpegPath(), buildFfmpegArgs(session), {
+ stdio: ["pipe", "ignore", "pipe"],
+ });
+
+ proc.stderr.on("data", (chunk: Buffer) => {
+ session.stderrTail = (session.stderrTail + chunk.toString()).slice(
+ -STDERR_TAIL_LIMIT,
+ );
+ });
+
+ session.exitCode = new Promise<number>((resolve, reject) => {
+ proc.on("error", reject);
+ proc.on("close", (code) => resolve(code ?? 1));
+ });
+ session.exitCode.catch(() => {});
+ session.process = proc;
+ return proc;
+}
+
+async function teardownSession(session: ExportSession): Promise<void> {
+ if (session.process) {
+ session.process.kill("SIGKILL");
+ await session.exitCode?.catch(() => {});
+ }
+ if (session.audioPath) {
+ await unlink(session.audioPath).catch(() => {});
+ }
+ await unlink(session.filePath).catch(() => {});
+}
+
+export function registerExportHandlers(): void {
+ ipcMain.handle("export:begin", async (event, opts: DesktopExportBegin) => {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ const filters =
+ opts.format === "mp4"
+ ? [{ name: "MP4 Video", extensions: ["mp4"] }]
+ : [{ name: "WebM Video", extensions: ["webm"] }];
+ const saveOptions = { defaultPath: opts.defaultFileName, filters };
+ const result = win
+ ? await dialog.showSaveDialog(win, saveOptions)
+ : await dialog.showSaveDialog(saveOptions);
+
+ if (result.canceled || !result.filePath) {
+ return null;
+ }
+
+ const id = randomUUID();
+ sessions.set(id, {
+ id,
+ filePath: result.filePath,
+ opts,
+ audioPath: null,
+ process: null,
+ exitCode: null,
+ stderrTail: "",
+ });
+ return { id, filePath: result.filePath };
+ });
+
+ ipcMain.handle(
+ "export:writeAudio",
+ async (_event, id: string, pcm: ArrayBuffer | Uint8Array) => {
+ const session = getSession(id);
+ const audioPath = path.join(os.tmpdir(), `opencut-audio-${id}.f32`);
+ await writeFile(audioPath, toBuffer(pcm));
+ session.audioPath = audioPath;
+ },
+ );
+
+ ipcMain.handle(
+ "export:writeFrame",
+ async (_event, id: string, rgba: ArrayBuffer | Uint8Array) => {
+ const session = getSession(id);
+ const proc = session.process ?? spawnFfmpeg(session);
+ const buffer = toBuffer(rgba);
+
+ await new Promise<void>((resolve, reject) => {
+ const onError = (error: Error) => reject(error);
+ proc.stdin.once("error", onError);
+ if (proc.stdin.write(buffer)) {
+ proc.stdin.removeListener("error", onError);
+ resolve();
+ return;
+ }
+ proc.stdin.once("drain", () => {
+ proc.stdin.removeListener("error", onError);
+ resolve();
+ });
+ });
+ },
+ );
+
+ ipcMain.handle("export:finish", async (_event, id: string) => {
+ const session = getSession(id);
+ sessions.delete(id);
+
+ try {
+ if (session.process && session.exitCode) {
+ session.process.stdin.end();
+ const code = await session.exitCode;
+ if (code !== 0) {
+ const tail = session.stderrTail.trim();
+ throw new Error(
+ `ffmpeg exited with code ${code}${tail ? `: ${tail}` : ""}`,
+ );
+ }
+ }
+ } finally {
+ if (session.audioPath) {
+ await unlink(session.audioPath).catch(() => {});
+ }
+ }
+ });
+
+ ipcMain.handle("export:cancel", async (_event, id: string) => {
+ const session = sessions.get(id);
+ if (!session) return;
+ sessions.delete(id);
+ await teardownSession(session);
+ });
+
+ ipcMain.handle("reveal-file", (_event, filePath: string) => {
+ shell.showItemInFolder(filePath);
+ });
+}
+
+export function cancelAllExports(): void {
+ for (const session of sessions.values()) {
+ session.process?.kill("SIGKILL");
+ }
+ sessions.clear();
+}
diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs
deleted file mode 100644
index 0ba70959..00000000
--- a/apps/desktop/src/main.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-use gpui::{
- div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
- WindowBounds, WindowOptions,
-};
-
-struct AppWindow {
- title: SharedString,
-}
-
-impl Render for AppWindow {
- fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
- div()
- .size_full()
- .bg(rgb(0x0f0f0f))
- .flex()
- .justify_center()
- .items_center()
- .text_xl()
- .text_color(rgb(0xffffff))
- .child(self.title.clone())
- }
-}
-
-fn main() {
- Application::new().run(|cx: &mut App| {
- let bounds = Bounds::centered(None, size(px(1280.), px(720.)), cx);
- cx.open_window(
- WindowOptions {
- window_bounds: Some(WindowBounds::Windowed(bounds)),
- ..Default::default()
- },
- |_, cx| {
- cx.new(|_| AppWindow {
- title: "OpenCut".into(),
- })
- },
- )
- .unwrap();
- cx.activate(true);
- });
-}
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();
+});
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
new file mode 100644
index 00000000..ad3f6cf9
--- /dev/null
+++ b/apps/desktop/src/preload.ts
@@ -0,0 +1,22 @@
+import { contextBridge, ipcRenderer } from "electron";
+import type { DesktopExportBegin } from "./export.js";
+
+const api = {
+ isDesktop: true as const,
+ exportBegin: (
+ opts: DesktopExportBegin,
+ ): Promise<{ id: string; filePath: string } | null> =>
+ ipcRenderer.invoke("export:begin", opts),
+ exportWriteAudio: (id: string, pcm: ArrayBuffer): Promise<void> =>
+ ipcRenderer.invoke("export:writeAudio", id, pcm),
+ exportWriteFrame: (id: string, rgba: ArrayBuffer): Promise<void> =>
+ ipcRenderer.invoke("export:writeFrame", id, rgba),
+ exportFinish: (id: string): Promise<void> =>
+ ipcRenderer.invoke("export:finish", id),
+ exportCancel: (id: string): Promise<void> =>
+ ipcRenderer.invoke("export:cancel", id),
+ revealFile: (path: string): Promise<void> =>
+ ipcRenderer.invoke("reveal-file", path),
+};
+
+contextBridge.exposeInMainWorld("opencutDesktop", api);
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
new file mode 100644
index 00000000..1948e8df
--- /dev/null
+++ b/apps/desktop/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "sourceMap": false,
+ "types": ["node"]
+ },
+ "include": ["src"]
+}