Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src-tauri/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>OpenBook records meeting audio only when you press Record.</string>
</dict>
</plist>
2 changes: 2 additions & 0 deletions packages/app/src-tauri/entitlements.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions packages/app/src/data/microphoneTransport.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, {data: string; mime: string}>();
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);
});
Loading