OpenLoom is a self-hosted, open-source screen recording tool. It has two halves — a desktop app that records and uploads, and a web viewer that plays back and collects reactions. They share no backend server; everything runs on the user's own Firebase project.
Desktop App Firebase (user-owned) Web Viewer
+-----------+ upload video +-------------------+ fetch meta +-----------+
| Electron | ─────────────────>| Cloud Storage | | Next.js |
| + React | insert meta | Firestore |<────────────── | + Plyr |
| | ─────────────────>| Cloud Functions |────────────────>| |
+-----------+ deploy func +-------------------+ serve API +-----------+
| Layer | Technology |
|---|---|
| Shell | Electron 33 |
| UI | React 19 + Tailwind CSS 4 |
| Build | electron-vite 3 (bundles main, preload, renderer separately) |
| Backend SDK | firebase-admin 12 (runs in main process only) |
| Storage SDK | @google-cloud/storage (bucket detection and creation) |
| Packaging | electron-builder 25 (DMG for macOS) |
Electron runs three isolated contexts:
┌─────────────────────────────────────────────────┐
│ Main Process (Node.js) │
│ - Firebase Admin SDK (Firestore, Storage) │
│ - Cloud Function deployment (REST API calls) │
│ - Encrypted settings (electron.safeStorage) │
│ - desktopCapturer for source listing │
│ - macOS permission handling │
├─────────────────────────────────────────────────┤
│ Preload (contextBridge) │
│ - Exposes `window.api` to renderer │
│ - Maps each method to an ipcRenderer.invoke() │
├─────────────────────────────────────────────────┤
│ Renderer (Chromium, sandboxed) │
│ - React UI (Vite dev server on port 5180) │
│ - MediaRecorder, Canvas 2D, Web Audio API │
│ - No direct access to Node.js or Firebase SDK │
└─────────────────────────────────────────────────┘
All Firebase operations happen in the main process. The renderer calls them through IPC:
renderer → window.api.firestoreInsert(...)
→ ipcRenderer.invoke('firestore-insert', ...)
→ main process handler
→ firebase-admin SDK
The root App.tsx renders one of four screens based on setup state:
loading → setup-wizard → provisioning → main-layout
│ │ │
│ paste SA JSON │ verify │ Library / Record / Settings
│ validate │ Firestore │
│ │ Storage │
│ │ deploy CF │
- SetupWizard — user pastes a Firebase service account JSON. The app validates connectivity and detects/creates the Firestore database and Storage bucket.
- Provisioning — verifies Firestore access, Storage access, and deploys the Cloud Function. Shows actionable error buttons if APIs or roles are missing.
- Layout — three-tab interface: Library (list/delete videos), Record, Settings (disconnect).
Recording involves four cooperating modules in the renderer:
useRecordingMachine (state orchestrator)
│
├── useMediaCapture → acquires raw MediaStreams
│ screen stream (desktopCapturer + getUserMedia)
│ camera stream (getUserMedia video)
│ mic stream (getUserMedia audio)
│
├── canvasCompositor → composites into a single video stream
│ draws screen to canvas every 33ms (setInterval, not rAF)
│ overlays camera as circular PiP (bottom-right, 160px diameter)
│ captures canvas at 30fps via captureStream()
│
├── audioMixer → mixes system audio + mic
│ AudioContext + MediaStreamDestination
│ GainNode on mic for mute/unmute (gain = 0 or 1)
│
└── recorder → encodes the final stream
MediaRecorder (WebM container, VP9 preferred, VP8 fallback)
HD: 5 Mbps, SD: 2.5 Mbps
collects chunks, returns Blob on stop
Why setInterval instead of requestAnimationFrame? Drawing must continue when the Electron window is unfocused or minimized. rAF pauses when the window is hidden; setInterval does not.
macOS audio caveat: System audio capture is unreliable — tracks often arrive with readyState === 'ended'. The pipeline detects dead system audio and falls back to microphone-only. The UI shows an indicator when system audio is unavailable.
1. User clicks "Start Recording"
2. Source picker shows screens and windows (desktopCapturer)
3. User picks source, toggles camera/mic/HD
4. 3-second countdown
5. Recording starts:
- Canvas compositor draws screen + camera PiP
- Audio mixer combines system audio + mic
- MediaRecorder encodes combined stream
6. User clicks Stop
7. Review screen: play back, enter title
8. Upload:
a. Generate 8-char alphanumeric short code (crypto.getRandomValues)
b. Check Firestore for collisions (retry up to 10x)
c. Upload WebM to Cloud Storage → get public URL
d. Insert metadata doc into Firestore `videos/{shortCode}`
e. Build share link: https://openloom.live/v/{projectId}/{shortCode}
9. Show share link with copy button
The Cloud Function source is embedded as string constants in the desktop app — no file system paths or CLI tools needed. During provisioning:
- Build an in-memory ZIP (manual ZIP format using Node.js
zlib.deflateRawSync) - Upload ZIP to Cloud Storage (
cloud-function-source/openloom-{timestamp}.zip) - Call Cloud Functions v2 REST API to create/update the function
- Poll the long-running operation until complete
- Store the deployed version in settings for idempotent re-deploys
If a v1 (1st gen) function exists from a prior deployment, it is detected and deleted before creating the v2 function.
Settings (including the service account JSON) are encrypted at rest using Electron's safeStorage API, which delegates to the OS keychain on macOS. The encrypted file lives at:
~/Library/Application Support/openloom/config/settings.json
| File | Purpose |
|---|---|
src/main/index.ts |
Main process: all IPC handlers, Firebase init, bucket detection |
src/main/deploy-cloud-function.ts |
Cloud Function deployment via v2 REST API |
src/preload/index.ts |
IPC bridge (window.api) |
src/renderer/App.tsx |
Root phase router |
src/renderer/components/Provisioning.tsx |
Infrastructure setup UI |
src/renderer/components/Recording.tsx |
Recording orchestrator |
src/renderer/components/recording/SourcePicker.tsx |
Screen/window picker with camera/mic/HD toggles |
src/renderer/components/recording/RecordingControls.tsx |
Live recording UI (canvas, timer, stop) |
src/renderer/components/recording/ReviewPlayer.tsx |
Post-recording preview and title entry |
src/renderer/components/recording/UploadProgress.tsx |
Upload progress bar and share link |
src/renderer/lib/recording/useRecordingMachine.ts |
State machine for the full recording lifecycle |
src/renderer/lib/recording/useMediaCapture.ts |
MediaStream acquisition (screen, camera, mic) |
src/renderer/lib/recording/canvasCompositor.ts |
Screen + camera PiP canvas composition |
src/renderer/lib/recording/audioMixer.ts |
System audio + mic mixing via Web Audio API |
src/renderer/lib/recording/recorder.ts |
MediaRecorder wrapper (WebM VP9/VP8) |
src/renderer/lib/firebase.ts |
High-level helpers: insertVideo, listVideos, uploadVideo, etc. |
src/renderer/lib/provisioning.ts |
Three-step provisioning runner |
src/renderer/lib/types.ts |
Shared TypeScript interfaces |
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Video player | Plyr 3.8 |
| Styling | Tailwind CSS 4 |
| Fonts | Space Grotesk (headings), JetBrains Mono (code) |
| Hosting | Netlify (static export) |
/ → Landing page (marketing site)
/technology → Technical deep-dive page
/v/{projectId}/{shortCode} → Video viewer (catch-all route)
/llm.txt → Machine-readable project description
The video route uses a catch-all [...slug] segment. The client component parses slug[0] as the project ID and slug[1] as the short code.
1. Parse URL → projectId + shortCode
2. Fetch metadata:
GET https://us-central1-{projectId}.cloudfunctions.net/openloom/v/{shortCode}
3. Increment view count:
POST .../openloom/v/{shortCode}/view
4. Fetch video blob from storage_url (public GCS URL)
5. Initialize Plyr with controls: play, progress, volume, speed, PiP, fullscreen
6. Fetch reactions:
GET .../openloom/v/{shortCode}/reactions
7. Render reaction markers on the progress bar (density histogram)
8. User can add reactions (emoji bar below video):
POST .../openloom/v/{shortCode}/reactions { emoji, timestamp }
→ Optimistic UI update + floating emoji animation
Duration workaround: Some WebM files don't report duration correctly. The viewer seeks to a very large position (1e101), waits for the timeupdate event to get the real duration, then seeks back to 0.
lib/api.ts builds the Cloud Function URL from the project ID:
function apiBaseUrl(projectId: string): string {
return `https://us-central1-${projectId}.cloudfunctions.net/openloom`
}Every API call goes directly to the user's Firebase project. The web viewer has no backend of its own — it's a static site.
Six emoji reactions: 👍 ❤️ 🔥 😂 👏 🎉
Each reaction is stored as a Firestore document in videos/{code}/reactions with:
emoji— the emoji stringtimestamp— playhead position in seconds when the reaction was addedcreated_at— server timestamp
The viewer renders a timeline histogram on the progress bar, showing reaction density at each point in the video. Individual markers scale in size based on how many reactions fall in that segment.
Reactions are added optimistically — the UI updates immediately and the server call fires asynchronously. A 500ms throttle prevents spam.
| File | Purpose |
|---|---|
app/layout.tsx |
Root layout, metadata, header |
app/page.tsx |
Landing page |
app/v/[...slug]/page.tsx |
Video route wrapper |
app/v/[...slug]/ViewerClient.tsx |
Full video player with reactions |
lib/api.ts |
Cloud Function API client |
lib/reactions.ts |
Emoji definitions and Reaction type |
A single HTTP function named openloom deployed to us-central1 as a 2nd gen Cloud Function (runs on Cloud Run). It serves four endpoints:
| Method | Path | Description |
|---|---|---|
| GET | /v/:code |
Return video metadata from Firestore |
| POST | /v/:code/view |
Atomically increment view_count |
| GET | /v/:code/reactions |
List all reactions ordered by created_at |
| POST | /v/:code/reactions |
Add a reaction ({ emoji, timestamp }) |
All responses include Access-Control-Allow-Origin: * for cross-origin access.
The function uses the v1 Firebase Functions SDK (functions.https.onRequest) which is compatible with both 1st and 2nd gen runtimes. It supports named Firestore databases via the FIRESTORE_DB_ID environment variable.
Runtime: Node.js 20, 256Mi memory, 60s timeout.
videos/
{short_code}/ # document
short_code: string
title: string
description: string | null
storage_url: string # public GCS URL
view_count: number # incremented atomically
duration_ms: number | null
capture_mode: 'screen' | 'window' | 'tab'
created_at: string # ISO timestamp
reactions/ # subcollection
{auto-id}/
emoji: string
timestamp: number # seconds into video
created_at: Timestamp # Firestore server timestamp
gs://{bucket}/
videos/{short_code}.webm # recorded video file (publicly readable)
cloud-function-source/ # deployment artifacts (internal)
The desktop app and web viewer never communicate directly. Firebase is the only shared state:
- Desktop uploads a video file to Cloud Storage and writes metadata to Firestore.
- Desktop generates a share URL:
https://openloom.live/v/{projectId}/{shortCode}. - Web viewer receives this URL, extracts the project ID, and calls the user's Cloud Function to fetch metadata.
- Cloud Function reads from Firestore and returns the metadata including the
storage_url. - Web viewer loads the video directly from the public Cloud Storage URL.
- Reactions flow back through the Cloud Function into the Firestore subcollection.
- Desktop app can see updated
view_countwhen it lists videos in the Library tab.
The project ID in the URL is what makes this work across different users' Firebase projects — each user's videos are served by their own Cloud Function and storage bucket.
Framework: Playwright with Electron support.
tests/e2e/
setup-flow.spec.ts # Setup wizard, provisioning, navigation
record-flow.spec.ts # Full recording pipeline
Tests mock the main process IPC handlers (Firebase operations return fake success) and inject synthetic MediaStreams (canvas + silent audio) so recordings work without real screen capture.
Framework: Playwright.
webviewer/e2e/
hero-canvas.spec.ts # Landing page canvas animations
screenshot.spec.ts # Visual regression
framer-screenshot.spec.ts # Component screenshots
framer-inspect.spec.ts # Layout inspection
Dev server runs on port 3001. Tests are headless by default.
cd desktop
npm install
npm run dev # electron-vite dev (hot reload on renderer)The dev server starts Vite on port 5180 for the renderer. Main process changes require a restart.
cd webviewer
npm install
npm run dev # Next.js dev server on port 3001# Desktop E2E
cd desktop
npx playwright test
# Web viewer E2E
cd webviewer
npx playwright testAll Firebase SDK calls happen in the Electron main process, not in the renderer. The renderer is sandboxed and communicates exclusively through window.api (defined in the preload script). If you need to add a new Firebase operation, you need to:
- Add an IPC handler in
src/main/index.ts - Expose it in
src/preload/index.ts - Add the type signature to
IElectronAPIinsrc/renderer/lib/types.ts
The screen + camera PiP composition uses setInterval(draw, 33) instead of requestAnimationFrame. This is intentional — rAF stops firing when the window is unfocused, which would freeze the recording. Don't replace it with rAF.
Electron's desktopCapturer can request system audio, but macOS often delivers dead tracks. The code in useMediaCapture.ts detects this (readyState === 'ended' or silent analysis) and falls back gracefully. If you're testing on macOS and recordings have no audio, this is expected behavior without a virtual audio driver.
The function code lives as a JavaScript string in deploy-cloud-function.ts, not as a separate deployable project. The cloud-functions/ directory is a reference copy for local development. If you change the function logic, you must update the embedded string — they are not synced automatically.
CLOUD_FUNCTION_VERSION in deploy-cloud-function.ts controls whether the function is redeployed. The desktop app stores the last deployed version in settings and skips deployment if it matches. When you change the embedded function source, bump this version string.
Videos are stored in Firestore with short_code as the document ID (not a random Firestore auto-ID). This means the short code must be unique. The generator retries up to 10 times on collision. The share URL relies on this: /v/{projectId}/{shortCode}.
The Next.js web viewer has no server-side data fetching. All API calls happen client-side via the Cloud Function. This means the viewer can be deployed as a static site (Netlify, Vercel, GitHub Pages). The tradeoff is no SSR for video metadata — the initial load shows a loading spinner.
Firebase projects can have storage buckets named in several patterns (.firebasestorage.app, .appspot.com, or custom). The main process tries known patterns, then lists all buckets, then creates one as a last resort. If you're debugging storage issues, check the resolvedBucket value in settings.
When deployment fails due to missing GCP permissions, the error includes clickable buttons that open the relevant GCP Console page. These are rendered by the Provisioning.tsx component when a step has actions in its error state. See firebase.md for the full list of permission scenarios.