CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# CLAUDE.md — Voom
## What is Voom?
A privacy-first macOS screen recording app. Records screen + camera + mic, transcribes on-device with FluidAudio, and optionally shares via a self-hosted Cloudflare Worker. No Electron — pure Swift/SwiftUI.
## Quick Reference
```bash
# Build
cd Voom && xcodebuild -scheme Voom -configuration Debug build
# Run (debug — product is named "Voom Debug.app", not "Voom.app")
# Resolve the path instead of globbing Voom-*: a second DerivedData tree (from
# building a worktree copy of the project) makes the glob match two different
# apps and `open` launches both.
VOOM_APP=$(xcodebuild -project Voom/Voom.xcodeproj -scheme Voom -configuration Debug \
-showBuildSettings 2>/dev/null | awk -F' = ' '/ BUILT_PRODUCTS_DIR/{print $2}')/"Voom Debug.app"
open "$VOOM_APP"
# Kill and relaunch
pkill -f "Voom Debug"; sleep 1; open "$VOOM_APP"
# Swift tests (pure logic in VoomCore)
swift test --package-path Packages/VoomCore
# Worker tests (vitest + workers pool)
cd voom-share && npm test
# Deploy worker — ALWAYS via the npm script.
# A bare `npx wrangler deploy` resolves wrangler.jsonc (the ID-less self-host
# config) and would deploy a fresh worker instead of production.
cd voom-share && npm run deploy
# Regenerate the self-host WorkerBundle after ANY sharing-side change
# (src/index.js, web/, schema.sql, migrations/). CI fails on drift.
cd voom-share && node scripts/build-selfhost-worker.mjs
# Release (tag triggers GitHub Actions)
git tag -a v3.X.0 -m "v3.X.0" && git push origin v3.X.0
```
Always rebuild and relaunch after code changes. Never test stale builds.
## Project Layout
Most code lives in local Swift packages under `Packages/`; the Xcode app target is a thin shell (entry point + views).
```
voom/
├── Voom/ # macOS app target (Xcode, manual PBX refs)
│ ├── Voom.xcodeproj/
│ └── Voom/
│ ├── App/ # VoomApp (@main), AppDelegate, AppState, WhatsNewProvider
│ ├── Views/
│ │ ├── Components/ # ToastOverlay
│ │ ├── Overlay/ # RecordingOverlay, CountdownOverlay, DisplayPicker, RegionSelector, Annotation*
│ │ ├── Panel/ # ControlPanelView, ControlPanelManager, RecordingSessionController, MeetingPanelManager
│ │ ├── Player/ # PlayerView, NativePlayerView, TrimView, CutSpliceView, ChapterView, ShareSettingsSheet, …
│ │ ├── Library/ # LibraryWindow, FolderRow, CreateFolderSheet, StitchSheet, TagManager, TagFilterView
│ │ ├── Settings/ # SettingsView, InlineSettingsView, SelfHostSetupView
│ │ └── Onboarding/ # OnboardingView
│ └── Resources/
│ ├── Assets.xcassets / Info.plist / Voom.entitlements
│ └── WorkerBundle/ # GENERATED — worker.js, schema.sql, migration_0002…0006.sql
├── Packages/
│ ├── VoomCore/ # Foundation layer (most services live here)
│ │ ├── Sources/VoomCore/
│ │ │ ├── Models/ # Recording, Folder, Annotation, BlurRegion, RecordingPreset
│ │ │ ├── Theme/Theme.swift # VoomTheme — ALL design tokens
│ │ │ ├── AppDefaults.swift # shared UserDefaults keys (e.g. AutoTranscribe)
│ │ │ └── Services/
│ │ │ ├── Capture/ # CameraCapture, InputTracker, MicTimeAdjuster
│ │ │ ├── Writing/ # VideoWriter (HEVC via AVAssetWriter; finalize() throws)
│ │ │ ├── Storage/ # RecordingStorage, LibraryDatabase (SQLite), PresetStore
│ │ │ ├── Transcription/ # TranscriptionService (FluidAudio), TranscriptSegmenter, SpeakerAttribution, EchoBleedDetector
│ │ │ ├── Sharing/ # ShareService, ShareCoordinator, CloudflareDeployService, ViewNotificationService
│ │ │ ├── Editing/ # VideoEditor, FillerWordDetector, PrivacyBlurRenderer, TranscriptEditor, AutoZoomAnalyzer
│ │ │ ├── Export/ # GIFExporter, TranscriptExporter
│ │ │ ├── TextAnalysis/ # TextAnalysisService (FoundationModels + VoomAI)
│ │ │ ├── KeychainStore.swift
│ │ │ └── GlobalHotkey.swift
│ │ └── Tests/VoomCoreTests/ # transcript math, filler detection, Codable fixtures, SQLite store
│ ├── VoomApp/ # ScreenCaptureKit capture: ScreenRecorder, CameraOnlyRecorder
│ ├── VoomAI/ # BYOK providers: AIService, AIConfig, AIProvider
│ ├── VoomMeetings/ # MeetingDetectionService, MeetingRecorder, MeetingTranscription, SpeakerDiarizationService
│ └── VoomCLI/ # `voom` executable + Claude skill (skill/voom/SKILL.md)
├── voom-share/ # Cloudflare Worker
│ ├── src/index.js # All routes (API + OG pages); share page served from web/dist via ASSETS
│ ├── web/ # Astro 6 share/embed pages (dist/ committed by design)
│ ├── test/ # vitest suites (helpers + API integration)
│ ├── schema.sql # Consolidated D1 schema (kept in sync with SCHEMA_STATEMENTS)
│ ├── migrations/ # 0002…0006 incremental migrations
│ ├── scripts/build-selfhost-worker.mjs # regenerates Voom/Voom/Resources/WorkerBundle
│ ├── wrangler.toml # MAINTAINER config (real resource IDs) — deploy via `npm run deploy`
│ ├── wrangler.jsonc # Self-host / Deploy-button config (ID-less, distinct worker name)
│ └── wrangler.test.jsonc # vitest pool config
└── .github/workflows/
├── build.yml # CI: app build + swift tests + VoomCLI + worker tests + WorkerBundle drift gate
└── release.yml # Release: sign, notarize, DMG, Sparkle appcast, Homebrew cask bump
```
## Architecture
### Core Patterns
**Actor singletons** — every service:
```swift
actor SomeService {
static let shared = SomeService()
private init() {}
}
```
**Observable state** — UI-reactive containers:
```swift
@Observable @MainActor
final class SomeStore {
// ...
}
```
**RecordingStore** uses `update(_ recording:)` for all mutations. Never mutate a `Recording` directly outside the store. `delete()` also removes the public share, thumbnail, and sidecar files.
**RecordingSessionController** (app target, `@Observable @MainActor`) owns the recorder lifecycle (ScreenRecorder / CameraOnlyRecorder / MeetingRecorder), the camera preview, the duration timer, and the user-visible `errorMessage`. Views forward intents to it; AppDelegate's quit guard calls `stopForQuit()` so quitting mid-recording always finalizes and saves first.
**ShareCoordinator** (VoomCore) is the single implementation of share/copy/renew/unshare flows — views only translate outcomes into toasts.
### Concurrency Model
| Context | Isolation | Examples |
|---------|-----------|---------|
| UI state | `@MainActor` | AppState, RecordingStore, RecordingSessionController, ShareUploadTracker, DeployProgress |
| I/O services | `actor` | ScreenRecorder, RecordingStorage, ShareService, TranscriptionService |
| FluidAudio | `nonisolated(unsafe)` | Only accessed from TranscriptionService / SpeakerDiarizationService |
| SCStream callbacks | Global queue | Must dispatch to MainActor for state updates |
Use `await MainActor.run { ... }` for cross-actor UI updates. Never use DispatchQueue.main.async in new code.
### Recording Pipeline
1. **SCContentFilter** excludes Voom's own windows (except camera PiP + annotation overlay)
2. **SCStream** captures video frames (BGRA32, native Retina) + audio samples
3. **VideoWriter** encodes via AVAssetWriter → HEVC hardware encoder → MP4. `finalize()` **throws** if the writer failed (disk full) — recorders attempt salvage and surface errors; never present an unfinalized file as a recording.
4. Camera PiP window is captured directly by SCStream (no compositor overlay)
### Data Persistence
```
~/Movies/Voom/
├── Voom-YYYY-MM-DD-HHmmss.mp4 # Video files (the source of truth)
├── .library.sqlite # SQLite library index (recordings/folders/tags as JSON rows, WAL mode)
├── *.cursor.json # Cursor-event sidecars
└── .thumbnails/{UUID}.jpg # Poster frames
```
- `LibraryDatabase` (VoomCore) wraps system SQLite: per-row upserts, one corrupt row can't take down the library, WAL makes app + CLI concurrent access safe.
- Legacy `.recordings.json` / `.folders.json` / `.tags.json` are migrated to SQLite on first launch (renamed `.migrated`, never deleted).
- If the index is empty but MP4s exist, the store rebuilds entries by disk scan.
- Secrets (`ShareAPISecret`, `AIAPIKey`) live in the **Keychain** via `KeychainStore`, never UserDefaults.
### Cloud Sharing
- **Worker**: Cloudflare Workers (ES module). Top-level error middleware returns JSON 500s.
- **Storage**: R2 bucket `voom-videos`; **Database**: D1 `voom-share-db`.
- **Auth**: Bearer token (`API_SECRET` env var on worker; Keychain on client), timing-safe compare.
- **Share passwords**: client sends SHA256(password); worker stores salted `SHA256(salt + clientHash)` with lazy upgrade of legacy rows; verify-password is rate limited (10/IP/5min); OG pages/images are gated for protected videos.
- **Schema**: `SCHEMA_VERSION` + `SCHEMA_MIGRATIONS` in `src/index.js` is the runtime source of truth. Any change that ALTERs an existing table MUST add a `SCHEMA_MIGRATIONS` entry under a bumped version (CREATE-only changes can't reach existing DBs), plus a numbered file in `migrations/` for the token-deploy path, plus the consolidated `schema.sql`, plus the `migrationResources` list in `CloudflareDeployService.swift`.
- **Expiry**: 30 days per share, daily cron cleanup (03:00 UTC everywhere — keep wrangler.toml, wrangler.jsonc, and CloudflareDeployService in sync).
- **Self-host**: `CloudflareDeployService` auto-provisions from a token; the Deploy-button path uses `wrangler.jsonc` (worker name intentionally differs from production so a bare deploy can never overwrite it).
Worker routes: `/api/*` (authenticated), `/s/:code` (share page; OG HTML for bots), `/v/:code` (video stream with bounded/open/suffix ranges + 416), `/vtt/:code`, `/og/:code`, `/thumb/:code`.
**The WorkerBundle is generated.** After any change to `src/index.js`, `web/`, `schema.sql`, or `migrations/`: rebuild `web/` if its source changed (`cd web && npm run build`), then `node scripts/build-selfhost-worker.mjs`, and commit `Voom/Voom/Resources/WorkerBundle/`. CI has a drift gate that fails otherwise.
## Design System (VoomTheme)
All tokens live in `Packages/VoomCore/Sources/VoomCore/Theme/Theme.swift`. Never hardcode colors, spacing, or fonts.
```swift
// Colors
VoomTheme.backgroundPrimary / Secondary / Tertiary / Card / Hover / Selected
VoomTheme.textPrimary / Secondary / Tertiary / Quaternary
VoomTheme.borderSubtle / Medium / Strong
VoomTheme.accentRed / accentGreen / accentOrange
// Typography (functions)
VoomTheme.fontTitle() // 15pt semibold
VoomTheme.fontHeadline() // 12pt semibold
VoomTheme.fontBody() // 13pt
VoomTheme.fontCaption() // 11pt
VoomTheme.fontMono() // 10pt monospaced
VoomTheme.fontBadge() // 10pt medium
// Spacing (CGFloat constants, NOT functions)
VoomTheme.spacingXS // 4
VoomTheme.spacingSM // 8
VoomTheme.spacingMD // 12
VoomTheme.spacingLG // 16
VoomTheme.spacingXL // 24
VoomTheme.spacingXXL // 32
// Radii (CGFloat constants)
VoomTheme.radiusSmall // 4
VoomTheme.radiusMedium // 8
VoomTheme.radiusLarge // 12
// View modifiers / components
.voomCard() // Standard card background
VoomBadge, ActionBarButton, ToolPillButton, VoomEmptyState, FlowLayout
```
Dark theme only. No light mode.
## Testing
- **Swift**: `swift test --package-path Packages/VoomCore` — covers `VideoEditor.adjustTranscript` math, `FillerWordDetector`, `Recording` decode against a v1 fixture (guards schema evolution — new `Recording` fields MUST be Optional or this test fails), and the SQLite store (round-trip, JSON migration, corrupt recovery).
- **Worker**: `cd voom-share && npm test` — vitest with the Cloudflare workers pool (real D1/R2 semantics): auth, upload validation, ranges, password flow (salting, lazy upgrade, rate limit), delete cleanup, error middleware.
- CI runs both plus a VoomCLI build and the WorkerBundle drift gate on every push/PR.
## Adding New Files
**Packages/`*`**: just create the file — SPM globs sources, no registration needed. Prefer putting new services/models in VoomCore.
**App target (Voom/Voom/)**: Xcode uses manual PBX references. Every new file needs 3 entries in `project.pbxproj`:
1. **PBXFileReference** — declares the file exists
2. **PBXBuildFile** — links it to Sources or Resources build phase
3. **PBXGroup** — places it in the correct folder group
```
// PBXFileReference section:
HEXID1 /* NewFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFile.swift; sourceTree = "<group>"; };
// PBXBuildFile section:
HEXID2 /* NewFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = HEXID1 /* NewFile.swift */; };
// Add HEXID1 to the appropriate PBXGroup children list
// Add HEXID2 to PBXSourcesBuildPhase (A10006001) files list
```
Use random 24-char hex IDs for new entries. Key group UUIDs (verified):
- Sources build phase: `A10006001`
- Resources build phase: `A10006002`
- Views group: `A10005003` · Panel: `A10005009` · Player: `A10005006` · Library: `A10005005` · Overlay: `A10005008` · Settings: `A10005017` · Onboarding: `A10005018` · Components: `A10005019` · Resources group: `A10005020`
For folder references (like WorkerBundle): use `lastKnownFileType = folder` and add to Resources build phase.
## Code Signing
- **Identity**: `Apple Development` (automatic, team `2J3WW2KWBU`)
- **Entitlements**: No sandbox, camera + mic access
- **Debug bundle ID**: `com.voom.app.debug` / **Release**: `com.voom.app`
- **Hardened runtime**: Off in Debug, **on in Release** (pbxproj and CI agree; required for notarization)
- Never leave stale builds in `Voom/build/` — Launch Services may pick them over DerivedData
- **Never `xcodebuild` a worktree/second copy of the project.** It creates a second
`~/Library/Developer/Xcode/DerivedData/Voom-*` tree, so any `Voom-*` glob becomes
ambiguous and can launch the wrong app.
- **Never build with `CODE_SIGNING_ALLOWED=NO` for anything you intend to run.** That
produces an ad-hoc, linker-signed binary whose designated requirement is cdhash-based,
so macOS treats every rebuild as a brand-new app and re-prompts for Screen Recording,
Camera, Mic and the Keychain on every launch. A normal signed Debug build has a stable
requirement (bundle id + development cert) and keeps its grants across rebuilds.
If grants have already been poisoned by such a build:
`tccutil reset ScreenCapture com.voom.app.debug` (also `Camera`, `Microphone`), then
grant once.
## Release Process
1. Commit changes to `main`
2. Tag: `git tag -a v3.X.0 -m "v3.X.0 — description"` and push tag
3. `release.yml` workflow: builds → signs with Developer ID → notarizes (hard-fails on any non-Accepted status) → creates DMG → generates Sparkle appcast → publishes GitHub Release → bumps Homebrew cask
4. Sparkle auto-updater picks up the appcast
Both `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` are set from the git tag automatically by the workflow.
## Coding Conventions
- **Swift 6** with targeted strict concurrency
- **`@Observable`** over `ObservableObject`; **`@State`** over `@StateObject`
- **MARK comments**: `// MARK: - Section` to organize code
- **Minimal changes**: don't refactor code you didn't need to touch
- **No over-engineering**: no abstractions for one-time operations
- **Errors are surfaced, not swallowed**: recording/share/transcription failures reach the user (toast or alert) — never `try?` away a failure on a user-data path
- **Commit style**: short imperative (`Fix audio mixing when both sources active`)
- **Versioning**: [Pride Versioning](https://pridever.org/) — PROUD.DEFAULT.SHAME
## Secrets — Never Commit
- API secrets, tokens, credentials
- Team IDs, cert hashes, signing identities (except in pbxproj where Xcode requires them)
- Worker URLs, email addresses, local paths
- Runtime secrets belong in the Keychain (`KeychainStore`), never UserDefaults
- Audit with `grep -r` before committing docs or config
## Git
- GitHub account: `aritropaul` (switch with `gh auth switch --user aritropaul`)
- Remote: `https://github.com/aritropaul/voom.git`
- Always commit to `main` (no feature branches for solo dev)
## Dependencies
| Package | Purpose | Where | Manager |
|---------|---------|-------|---------|
| FluidAudio (0.17.1+) | On-device ASR and speaker diarization (Nemotron 3, pyannote fallback) | VoomCore, VoomMeetings | Swift Package |
| Sparkle | Auto-updates with EdDSA signing | Xcode project | Swift Package |
| WhatsNewKit | What's-new sheets | Xcode project | Swift Package |
| swift-argument-parser | CLI parsing | VoomCLI | Swift Package |
| wrangler | Worker deploy CLI | voom-share | npm |
| vitest + @cloudflare/vitest-pool-workers | Worker tests | voom-share | npm |
| astro (v6) | Share/embed pages | voom-share/web | npm |