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);
+});