macOS Menu Bar App & Chrome Companion Extension Guide
OpenScribe splits local capture on macOS across two components that communicate strictly over loopback (127.0.0.1:48721):
- OpenScribe Chrome Companion Extension (
extension/): Runs inside Google Chrome onhttps://meet.google.com/*(extension/manifest.json). It hooks WebRTCRTCRtpReceiverinstances (extension/src/main-runtime.jsandextension/src/opus-pipeline.js), decodes per-stream Opus packets to 48,000 Hz mono PCM using WebCodecsAudioDecoder, tracks RTP contributing sources (CSRCs) and participant device IDs, and forwards metadata plus 48 kHz PCM chunks to127.0.0.1:48721throughextension/background.js. OpenScribe.app(native/): A native macOS 14+ menu bar and companion window application written in Swift 6 (AppKit+SwiftUI). It listens on127.0.0.1:48721(LocalHostBridgeServer.swift), records your local voice (local_mic) at 48,000 Hz via the outgoing Meet audio track orLocalMicrophoneCapturer.swift, writes crash-safe local recordings to~/Library/Application Support/OpenScribe/recordings/, streams live audio throughTranscriptionStreamManager.swift(elevenlabs,openai_realtime,gemini_realtime, andopenai_compatible), and syncs transcripts, active-meeting notes, links, files, and screenshots to your account.
1. Platform Capture Capabilities & Honest Verification Status
OpenScribe records the verified captureCapability and speaker attribution mode on every meeting and transcript segment (native/Sources/OpenScribeCore/Models.swift and server/API.md). It never fabricates speaker names or emails when a platform only provides mixed audio or window metadata:
| Platform | Enum Value | Capture Capability | Speaker Attribution | Verification Status & Real Evidence |
|---|---|---|---|---|
| Google Meet (Chrome + Extension) | google_meet | per_participant_pcm | one_mapped_source, mixed_sources, unmapped | Bounded installed path verified. The selected local microphone and two simultaneous remote sources produced durable audio and five committed word-timestamped segments visible in the web transcript. Mute stopped local packets while remote capture continued. Controlled DTX and rejoin research is recorded in .agents/skills/meet-analysis/. General recovery and installed final-response draining remain pending. |
| Local Microphone (macOS App) | local_mic | single_stream_verified | local_mic | Verified local capture, active meeting-mic following unit-tested. When the Chrome extension supplies the outgoing Meet audio track (outgoingMeetingTrack in RecorderCoordinator.swift), OpenScribe.app uses that stream and stops duplicate AVAudioEngine capture. Otherwise LocalMicrophoneCapturer.swift resolves the meeting input device by matching the browser-reported or app-selected device UID/name (coreAudioMatchedDevice) or active audio-input process (preferredPIDs) before falling back to the system default input device (systemDefaultFallback). |
| Microsoft Teams (Browser Research Path) | teams_browser | single_stream_verified | one_mapped_source, mixed_sources, unmapped | Tested research receive path (single active speaker). Browser WebRTC receiver capture was tested for single-speaker attribution (csrc_single) in .agents/skills/teams-analysis/references/findings.md. It is not enabled in extension/manifest.json host permissions by default, and overlapping multi-speaker separation is not claimed. |
| Microsoft Teams (macOS Desktop App) | teams_native | mixed_audio (integrated, live speech unverified) | mixed_sources + local_mic | Integrated and fixture-tested. TeamsNativeAdapter.swift feeds the coordinator and durable recording store. Tests cover presence, input selection, mute suppression, session binding and cross-adapter isolation. Live speech and independent native participant audio remain unverified. |
| Zoom (macOS Desktop App) | zoom_native | mixed_audio (live speech unresolved) | mixed_sources + local_mic | Wired in RecorderCoordinator and unit-tested, controlled live speech capture failed. ZoomNativeAdapter.swift is wired into RecorderCoordinator.swift for standard CoreAudio process-output mixed capture (zoom_remote_mixed with attribution = "mixed_sources" and speakerName = nil) plus separate local microphone capture following Zoom's selected input device, fail-closed mute gating, and duplicate-name preservation (ZoomNativeAdapterTests.swift). The latest two controlled live speech attempts failed peakAbs > 500 and nonZeroSamples > 4800, leaving real Zoom speech capture and native per-person audio unresolved. |
| Slack Huddle (macOS Desktop App) | slack_huddle | metadata_only | local_mic + metadata | Window and huddle state detection plus offline store replay only. MeetingPresenceDetector.swift detects active Slack huddles, and .agents/skills/slack-analysis/SKILL.md provides offline huddle store replay. Live huddle audio capture and per-speaker isolation remain unresolved. |
2. Building and Loading the Chrome Companion Extension (extension/)
Step 1: Bundle the Extension Scripts
From the repository root, run the extension build script (extension/build.mjs):
node extension/build.mjsextension/build.mjs concatenates the plain JavaScript source modules in extension/src/ (meet-collections.js, opus-pipeline.js, schema.js, main-runtime.js, and isolated-runtime.js) into extension/meet-main.js (MAIN world content script) and extension/meet-isolated.js (ISOLATED world content script) directly inside extension/ alongside extension/manifest.json, extension/background.js, extension/popup.html, and extension/popup.js.
Step 2: Load Unpacked in Google Chrome
- Open Google Chrome and navigate to
chrome://extensions. - Enable Developer mode using the toggle in the top-right corner.
- Click Load unpacked in the top-left corner.
- Select the
/path/to/openscribe/extensiondirectory (do not look for adist/subdirectory, becausemanifest.jsonand the bundled.jsfiles live directly inextension/). - Confirm that OpenScribe Meet Companion (
version 0.1.0) appears and is enabled.
How the Extension Communicates with OpenScribe.app (LocalHostBridgeServer.swift)
The extension never talks to your remote OpenScribe server directly and holds no cloud credentials. Instead, extension/background.js connects only to http://127.0.0.1:48721 (native/Sources/OpenScribeCore/LocalHostBridgeServer.swift):
- Bridge Readiness Check (
GET /api/status):- You can check whether
OpenScribe.appis listening on port48721at any time by running:curl -sS http://127.0.0.1:48721/api/status - Expected
200 OKJSON response (RecorderCoordinator.swift):{ "ok": true, "app": "OpenScribe", "port": 48721 }
- You can check whether
- Loopback Pairing (
GET /api/extension/pairorPOST /api/extension/pair):extension/background.jsrequests a random bridge session token fromhttp://127.0.0.1:48721/api/extension/pair.LocalHostBridgeServer.handlePairrequires a non-emptyOriginheader matching the pinned extension originchrome-extension://bnfgpacpapkllohejfcahnblgkodahnb(expectedExtensionId). Requests with an emptyOriginreturn403 {"error": "extension_origin_required"}, and requests from any other origin return403 {"error": "unauthorized_origin"}.- When the origin matches,
LocalHostBridgeServerreturns200 OK:{ "ok": true, "token": "<bridgeAuthToken>", "pairedOrigin": "chrome-extension://bnfgpacpapkllohejfcahnblgkodahnb", "port": 48721 }
- WebSocket & HTTP Event Streaming (
/ws/extensionandPOST /api/extension/event):- After pairing,
extension/background.jsopens a WebSocket tows://127.0.0.1:48721/ws/extension?token=<bridgeAuthToken>and sends an authentication frame on open:{ "type": "authenticate", "bridgeToken": "<bridgeAuthToken>" } LocalHostBridgeServerreplies with{"type": "authenticated", "ok": true}and accepts incomingExtensionIncomingMessageframes (Models.swiftandRecorderCoordinator.swift) withtypevalues"presence","roster","audio_batch","capture_error", and"status".- If the WebSocket is not currently open,
extension/background.jsfalls back toPOST http://127.0.0.1:48721/api/extension/eventwith headerX-OpenScribe-Bridge-Token: <bridgeAuthToken>(orAuthorization: Bearer <bridgeAuthToken>), which returns{"ok": true}.
- After pairing,
3. Building and Launching OpenScribe.app (native/)
Prerequisites
- macOS 14.2 (Sonoma) or newer
- Xcode Command Line Tools (
xcode-select --install) with Swift 6 (swift --version)
Build the Application Bundle (native/scripts/build-app.sh)
Run the packaging script from the repository root:
./native/scripts/build-app.shWhat native/scripts/build-app.sh does:
- Compiles the release binary (
swift build -c release --product OpenScribe) insidenative/. - Creates the macOS application bundle at
./native/dist/OpenScribe.app(or at<custom-dir>/OpenScribe.appif you pass a target directory argument such as./native/scripts/build-app.sh /Applications). - Writes
Contents/Info.plist(CFBundleIdentifier = com.akyonlabs.openscribe,NSMicrophoneUsageDescription,NSScreenCaptureUsageDescription) and ad-hoc signs the bundle (codesign --force --deep --sign -).
Launch the built app in Finder or from the terminal:
open ./native/dist/OpenScribe.appOr run the binary directly in the terminal to view structured stdout/stderr logs:
./native/dist/OpenScribe.app/Contents/MacOS/OpenScribeSupported Command-Line Arguments (OpenScribeMain.swift)
OpenScribe supports three non-interactive CLI modes for automated verification and testing (native/Sources/OpenScribeApp/OpenScribeMain.swift):
| Command | Behavior |
|---|---|
./native/dist/OpenScribe.app/Contents/MacOS/OpenScribe | Launches the menu bar status item (NSStatusItem) and companion window GUI. |
./native/dist/OpenScribe.app/Contents/MacOS/OpenScribe --self-test | Runs SelfTestRunner.run() (SelfTest.swift) against local recording storage, localhost bridge, active-meeting composer binding, and model serialization, then exits. |
./native/dist/OpenScribe.app/Contents/MacOS/OpenScribe --detect-once | Runs MeetingPresenceDetector().detectActiveMeetings() once, prints detected meeting windows as pretty-printed JSON to stdout, and exits. |
./native/dist/OpenScribe.app/Contents/MacOS/OpenScribe --snapshot <outputDir> [storeDir] | Renders deterministic light/dark UI snapshots (companion-idle-{light,dark}.png, companion-idle-min-{light,dark}.png, settings-general-{light,dark}.png, plus recording/transcript/stopped views when storeDir is passed) into <outputDir> and exits. |
OpenScribe does not take --backend-url or --bridge-port CLI flags. Change the workspace in Settings. Development runs can set OPENSCRIBE_SERVER_URL before launch.
4. Pointing OpenScribe.app to Your Server (BackendClient.swift)
A fresh app connects to https://scribe.akyonlabs.com. BackendClient (native/Sources/OpenScribeCore/BackendClient.swift) preserves an explicitly configured workspace. It resolves the server base URL in the following precedence order:
OPENSCRIBE_SERVER_URLenvironment variable (if set and non-empty when the process starts).UserDefaultskeyserverBaseURLin domaincom.akyonlabs.openscribe.- Default fallback:
https://scribe.akyonlabs.com.
Change the workspace
- Open Settings with the gear button or
Cmd+,. - Under Workspace, click Change Workspace….
- Enter the workspace address and click Connect.
The app validates the workspace before switching. Credentials and local recordings stay bound to their originating workspace. Sign in to the selected workspace through browser pairing.
For development, OPENSCRIBE_SERVER_URL sets the starting address. Automated tests can supply OPENSCRIBE_SESSION_TOKEN through the process environment. Never put a session token in a URL or command log.
Choose calendars and open meetings
Connect calendars in the web workspace. In Mac Settings, check the calendars you want to see under each connected account. Selections are saved for that workspace and signed-in user.
Upcoming groups events by local date and shows the time, owning account and meeting-platform icon. Join opens the meeting link. Open a row for its calendar and attendees. The calendar account identifies the event owner, not a verified account used to join the call.
Past opens each meeting in the web workspace. During an active call, Live contains the transcript and saved notes. Browsing other tabs preserves recording and the composer draft.
5. Browser Device Pairing & Opt-In macOS Keychain Storage
OpenScribe.app never embeds Google OAuth client secrets. Instead, it pairs with your OpenScribe server through your web browser (native/Sources/OpenScribeCore/BackendClient.swift, RecorderCoordinator.swift, and server/auth.go).
Device Pairing Flow (/api/openscribe/auth/device/*)
- Start Pairing (
POST /api/openscribe/auth/device/start):- Click Sign In in the OpenScribe companion window (
CompanionView.swift) or inside Settings -> Account (SettingsView.swift). BackendClient.startDeviceLoginsendsPOST <serverBaseURL>/api/openscribe/auth/device/startwith{"device_label": "<Mac Host Name>"}.- The server creates a pending record in the
device_loginscollection (valid for 10 minutes) and returns:{ "id": "<request_id>", "code": "ABCD-EFGH", "user_code": "ABCD-EFGH", "secret": "<high_entropy_secret>", "device_code": "<high_entropy_secret>", "verify_url": "https://scribe.example.com/pair?id=<request_id>&code=ABCD-EFGH", "verification_uri": "https://scribe.example.com/pair", "verification_uri_complete": "https://scribe.example.com/pair?id=<request_id>&code=ABCD-EFGH", "expires": "2026-11-09T14:30:00Z", "expires_in": 600, "interval": 2 }
- Click Sign In in the OpenScribe companion window (
- Approve in Browser (
/pair->POST /api/openscribe/auth/device/approve):OpenScribe.appdisplays the short verification code (such asABCD-EFGH) and opensverify_urlin your default browser.- Sign in with Google if prompted, confirm the verification code matches what is shown in
OpenScribe.app, and click Approve Device. - The web app sends
POST /api/openscribe/auth/device/approvewith{"id": "<request_id>", "code": "ABCD-EFGH"}andAuthorization: Bearer <web_jwt>, returning{"status": "approved"}.
- Poll for Token (
POST /api/openscribe/auth/device/poll):RecorderCoordinator.pollDeviceApprovalLooppollsPOST <serverBaseURL>/api/openscribe/auth/device/pollwith{"id": "<request_id>", "secret": "<high_entropy_secret>"}every 2 seconds (up to 90 attempts, or 3 minutes).- Once approved, the server returns
{"status": "approved", "token": "<pb_auth_jwt>", "user": {"id": "...", "email": "...", "name": "...", "admin": ...}}and immediately wipesissued_tokenfromdevice_loginsso the poll secret cannot be reused.
Opt-In macOS Keychain Storage (KeychainStore.swift)
To avoid unexpected macOS Keychain password prompts during development or automated runs, persistent Keychain storage is opt-in (rememberSignIn defaults to false in RecorderCoordinator.swift):
- Default (
rememberSignIn = false): The session JWT is held only in memory for the currentOpenScribe.appprocess. No Keychain read or write calls are made on startup, andKeychainStore.clearSessionToken()removes any previously saved item when toggled off or when signing out. - Opt-In (
rememberSignIn = true): Open Settings -> Account and enable Remember sign-in on this Mac (SettingsView.swift). When enabled,KeychainStore.saveSessionToken(_:)stores the token in the macOS login Keychain with these attributes:kSecClass:kSecClassGenericPasswordkSecAttrService:"com.akyonlabs.openscribe"kSecAttrAccount:"app_session_token"kSecAttrLabel:"OpenScribe sign-in"
KeychainStore sets SecKeychainSetUserInteractionAllowed(true) only during interactive saveSessionToken(_:) calls and sets it to false during loadSessionToken() and clearSessionToken() so background checks never block on a modal prompt.
To delete a stored Keychain token from the terminal:
security delete-generic-password -s "com.akyonlabs.openscribe" -a "app_session_token"6. macOS permissions
OpenScribe requests access when you use the feature that needs it. Settings shows actions for missing access.
| Action | macOS access | Purpose |
|---|---|---|
| Connect beside Microphone | Microphone | Capture your voice when the meeting extension does not supply its outgoing track |
| Connect Meeting Detection… in the Recording menu, or Connect in Settings | Accessibility, shown as Device Control and Data Access on some macOS versions | Read meeting app and window state |
| Connect beside Meeting screenshots | Screen Recording | Capture the selected meeting window through ScreenCaptureKit |
Install the app in Applications before granting access. Development builds use ad-hoc signing, so rebuilding changes their code identity. An enabled entry for an older build can remain visible while macOS rejects the current build. Accessibility and Screen Recording are separate grants. The app checks actual access rather than treating the visible toggle as proof.
7. Active-Meeting Notes, Links & Files vs. Post-Meeting Web Editing
OpenScribe.app enforces a strict boundary between in-meeting capture and post-meeting curation (RecorderCoordinator.swift and CompanionView.swift):
- During a confirmed joined or recording meeting: Add notes, links, files or meeting-window screenshots. Each accepted item is saved locally with immutable session, backend meeting and capture-time binding before its saved indicator appears. Queued uploads continue to that meeting after the call ends. Notes open as selectable text with Copy and Done, and attachments open in Quick Look.
- After the meeting ends: New Mac additions stop. Open dashboard returns to the web workspace, and each Past meeting opens its exact web detail page at
/?meeting=<id>. Add or edit post-meeting content there.
The actual native picker saved the supplied test file locally. Backend attachment receipt and drag-and-drop acceptance remain pending. Isolated SwiftUI actions verified note previews, file Quick Look and joined-call preservation after Stop.
8. Crash-Safe Local Recording & Recovery (LocalRecordingStore.swift)
OpenScribe writes all audio, transcript segments, notes, and attachments to local disk before attempting any cloud upload (native/Sources/OpenScribeCore/LocalRecordingStore.swift). If your network drops, the server restarts, or OpenScribe.app terminates mid-meeting, your recording remains intact on your Mac.
Local Storage Layout
By default, sessions are stored under:
~/Library/Application Support/OpenScribe/recordings/<sessionId>/
├── manifest.json # LocalSessionManifest (state, participants, streams, unsyncedSegmentsCount)
├── transcript.jsonl # Append-only JSON Lines log of partial and committed TranscriptSegments
├── notes.jsonl # Append-only JSON Lines log of active-meeting notes and pasted links
├── attachments.jsonl # Append-only JSON Lines log of active-meeting dropped file metadata
├── attachments/ # Copied user-dropped files captured during an active meeting
├── screenshots/ # Captured meeting window PNG files (shot-<timestamp>.png)
└── streams/ # Per-stream 48,000 Hz mono audio tracks
├── <streamKey>.pcm # Incremental 16-bit little-endian PCM (flushed + fsynced per chunk)
├── <streamKey>.f32 # Incremental 32-bit IEEE float PCM
├── <streamKey>.packets.jsonl # Per-chunk RTP/observer timestamps and sequence metadata
└── <streamKey>.wav # Standard 48 kHz mono WAV file built on finalize or crash recoveryRecovery and sync
At startup, OpenScribe rebuilds missing WAV files from retained PCM and reloads local sessions. It retries pending transcript, note and attachment uploads automatically. Every retry uses the originating meeting and workspace identity.
Past meetings appear in the main window. Clicking one opens its web meeting page after required sync and sign-in. Settings contains preferences and connected calendars.