diff --git a/packages/app/README.md b/packages/app/README.md index f96da3dd..f135baf6 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -4,6 +4,49 @@ From the workspace root, run it with `pnpm tauri dev`. Create a desktop build with `pnpm build:desktop`. +## Meeting microphone (macOS) + +`src-tauri/Info.plist` is merged into the bundle by Tauri and supplies +`NSMicrophoneUsageDescription`. The existing hardened-runtime entitlements file +now includes `com.apple.security.device.audio-input`. Recording is requested only +from the meeting block's Record action, via `getUserMedia({audio: true})`. + +The locked Tauri 2.11.2 / Wry 0.55.1 already installs the Rust +[`WKUIDelegate` media-capture callback](https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/wkwebview/class/wry_web_view_ui_delegate.rs#L126-L137). +It grants the webview permission layer; macOS TCC still controls device access, +prompts for initial consent, and persists Allow/Deny. Do not replace Wry's UI +delegate or add a second permission store. This version does not expose the newer +`with_permission_handler` API. An OS denial reaches the recorder's existing +graceful microphone error and returns it to idle. + +The recorder restarts every 45 seconds for independently playable files, targeting +64 kbit/s: approximately 360,000 bytes, or 480,000 base64 characters per chunk +(container overhead and actual encoder bitrate vary). Safari can fall back to +`audio/mp4`; the server accepts both MP4 and WebM. SDK uploads and playback use +base64 JSON across `tauriFetch`, below the default 10 MiB decoded asset cap and +the server's `ceil(cap * 4 / 3) + 64 KiB` request cap at this target size. The +desktop transport test checks two such chunks byte-for-byte with mocked native +IPC; it does not prove native capture or audible playback. + +Manual release-app check (requires an interactive macOS microphone): + +1. Run `pnpm build:desktop`, quit any other OpenBook instance, then launch + `packages/app/src-tauri/target/release/bundle/macos/OpenBook.app/Contents/MacOS/OpenBook`. + Use a release bundle: `tauri dev` does not launch the managed server sidecar. +2. Insert a meeting block on a page, press Record, and allow the macOS prompt. + Record for more than 45 seconds, then Stop. Confirm two uploaded audio chunks + and play each; reload the page and play again to verify sidecar persistence. +3. Restart the same app and record again; the OS should retain consent. +4. Disable OpenBook under System Settings → Privacy & Security → Microphone, + relaunch, and press Record. Confirm the microphone error appears, the block + returns to idle, and no capture/upload starts. Re-enable permission to recover. + +Distribution must rebuild, sign, and notarize the app with the new entitlement +and purpose string through the existing release process. No signing identity, +hardened-runtime setting, or notarization configuration is changed here. Permission +continuity across locally signed and distributed builds needs a check using the +owner's normal signing identity; signing changes remain owner-gated. + ## Sidecar supervision verification The bundled sidecar is supervised only in a release build (`tauri dev` uses the diff --git a/packages/app/src-tauri/Info.plist b/packages/app/src-tauri/Info.plist new file mode 100644 index 00000000..4122baba --- /dev/null +++ b/packages/app/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + OpenBook records meeting audio only when you press Record. + + diff --git a/packages/app/src-tauri/entitlements.plist b/packages/app/src-tauri/entitlements.plist index 48f7bf5c..8edce312 100644 --- a/packages/app/src-tauri/entitlements.plist +++ b/packages/app/src-tauri/entitlements.plist @@ -2,6 +2,8 @@ + com.apple.security.device.audio-input + com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory diff --git a/packages/app/src-tauri/src/main.rs b/packages/app/src-tauri/src/main.rs index c3eb1acc..ad0e3568 100644 --- a/packages/app/src-tauri/src/main.rs +++ b/packages/app/src-tauri/src/main.rs @@ -15,6 +15,13 @@ //! dev` the host is unmanaged and the webview talks to the external `pnpm dev` //! server over loopback instead. Preferences (publish, token, book folder) //! persist in `host-config.json` under the app-data dir. +//! +//! Microphone permissions: the locked Wry 0.55.1 already implements +//! `WKUIDelegate::requestMediaCapturePermissionForOrigin` and grants the webview +//! layer's request. macOS still asks for and persists the user's OS permission; +//! Info.plist supplies its purpose string and entitlements.plist enables audio +//! input under the hardened runtime. Keep Wry's delegate (including its other +//! callbacks); replacing it would duplicate upstream behavior. See README.md. mod ipc; mod sidecar_supervision; diff --git a/packages/app/src/data/microphoneTransport.test.ts b/packages/app/src/data/microphoneTransport.test.ts new file mode 100644 index 00000000..0faa6aaf --- /dev/null +++ b/packages/app/src/data/microphoneTransport.test.ts @@ -0,0 +1,43 @@ +import {afterEach, expect, it, vi} from 'vitest'; +import {HttpDataClient, DEFAULT_MAX_ASSET_BYTES} from '@book.dev/sdk'; +import {invoke} from '@tauri-apps/api/core'; +import {tauriFetch} from './ipc'; + +vi.mock('@tauri-apps/api/core', () => ({invoke: vi.fn(), Channel: vi.fn()})); +vi.mock('@tauri-apps/api/event', () => ({listen: vi.fn()})); + +afterEach(() => vi.resetAllMocks()); + +it.each(['audio/webm', 'audio/mp4'])('round-trips two meeting-sized %s chunks through the desktop JSON transport', async (mime) => { + const stored = new Map(); + vi.mocked(invoke).mockImplementation(async (command, args) => { + expect(command).toBe('api_request'); + const request = args as {method: string; path: string; body: string}; + if (request.method === 'POST') { + expect(request.path).toContain('/api/assets?pageId=meeting'); + const body = JSON.parse(request.body) as {data: string; mime: string}; + expect(body.mime).toBe(mime); + expect(body.data.length).toBe(480000); + expect(new TextEncoder().encode(request.body).length).toBeLessThan(Math.ceil(DEFAULT_MAX_ASSET_BYTES * 4 / 3) + 64 * 1024); + const id = `chunk-${stored.size}`; + stored.set(id, body); + return {status: 201, headers: [], body: JSON.stringify({id})}; + } + const url = new URL(request.path, 'http://localhost'); + expect(url.searchParams.get('encoding')).toBe('base64'); + return {status: 200, headers: [], body: JSON.stringify(stored.get(url.pathname.split('/').pop()!))}; + }); + const client = new HttpDataClient('', undefined, {fetchImpl: tauriFetch}); + for (let chunk = 0; chunk < 2; chunk++) { + // 45 seconds at the recorder's 64 kbit/s target. Include every byte value + // so UTF-8 coercion or binary-body corruption cannot pass this check. + const bytes = Uint8Array.from({length: 360000}, (_, i) => (i + chunk) % 256); + const {id} = await client.putAsset(bytes, mime, 'meeting'); + const playback = await client.getAsset(id); + if (!playback) throw new Error('Uploaded audio was not returned for playback'); + expect(playback.mime).toBe(mime); + expect(playback.bytes.length).toBe(bytes.length); + expect(playback.bytes.every((byte, i) => byte === bytes[i])).toBe(true); + } + expect(stored.size).toBe(2); +});