Files
claude-code-haha/desktop/electron/services/microphonePermissions.ts
T
程序员阿江(Relakkes) 6d75fac69e feat(desktop): add local voice dictation to the chat composer
Adds a microphone button beside the composer. Click to record, click to stop; the audio is resampled to 16 kHz mono PCM16 WAV, posted to the local server and transcribed by a SenseVoice worker process, and the text lands in the draft without being sent. If the draft changed or an IME is composing, the result is held behind an insert button instead of overwriting the user's text.

Server: a small provider registry behind /api/voice/* (catalog, preferences, prepare/cancel/status/remove, transcribe). The engine and model are downloaded at runtime to <config>/cc-haha/voice with pinned sha256/sha512, HuggingFace plus hf-mirror and npm plus npmmirror, HTTP Range resume, automatic retry after interruptions, and a partial file kept across cancels. Recognition runs in a separate worker process (sidecar --voice-worker), started on demand and reclaimed when idle.

Desktop: an independent Voice input settings tab with enable switch, model download progress and resume, language, microphone selection and a transcription test with a live waveform. Uses the shared Dropdown/Card/Button components; adds a danger-ghost Button variant. Preferences live in desktop-ui.json (schemaVersion 6); the microphone device id stays in localStorage.

Electron: main-window media permission handler limited to app pages, main frame and audio only, plus the audio-input entitlement and NSMicrophoneUsageDescription.

Scope: Electron desktop only. Not verified on Windows or Linux, with a real microphone, or in a signed and notarized package.
2026-10-01 15:23:21 +08:00

122 lines
4.1 KiB
TypeScript

import { pathToFileURL } from 'node:url'
import type { Session, WebContents } from 'electron'
type PermissionSession = Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>
export type MediaAccessStatus = 'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'
export type MicrophoneSystemPreferences = {
getMediaAccessStatus(mediaType: 'microphone'): MediaAccessStatus
askForMediaAccess(mediaType: 'microphone'): Promise<boolean>
}
export type MicrophonePermissionOptions = {
/**
* True for contents that live in a window this application created and that
* is still open (main window and the detached trace window, both of which
* render the chat input).
*/
isAppContents: (contents: WebContents) => boolean
/** True only for a frame URL that is the application's own renderer entry. */
isAppUrl: (url: string) => boolean
systemPreferences: MicrophoneSystemPreferences
platform?: NodeJS.Platform
}
/**
* Match the application's own renderer document: the dev server origin, or the
* packaged `dist/index.html`. Query and hash are ignored because the main and
* trace windows load the same entry with different query strings.
*/
export function createRendererUrlMatcher(
entry: string,
platform: NodeJS.Platform = process.platform,
): (url: string) => boolean {
if (/^https?:\/\//.test(entry)) {
const origin = new URL(entry).origin
return url => {
try {
return new URL(url).origin === origin
} catch {
return false
}
}
}
// Compare decoded paths (Chromium and pathToFileURL may encode differently)
// and, on Windows, case-insensitively, so drive-letter case cannot lock
// dictation out of an otherwise identical entry.
const normalize = (pathname: string) => {
let decoded = pathname
try {
decoded = decodeURIComponent(pathname)
} catch {
// Keep the raw path; it just will not match.
}
return platform === 'win32' ? decoded.toLowerCase() : decoded
}
const entryPath = normalize(pathToFileURL(entry).pathname)
return url => {
try {
const parsed = new URL(url)
return parsed.protocol === 'file:' && normalize(parsed.pathname) === entryPath
} catch {
return false
}
}
}
/**
* Grant audio-only capture to the main frame of application windows and leave every
* other permission at Electron's default. Installing any handler replaces that
* default, so the non-media branches explicitly keep the previous "granted"
* behaviour instead of silently tightening notifications, clipboard and so on.
*/
export function installMicrophonePermissions(
session: PermissionSession,
options: MicrophonePermissionOptions,
): void {
const platform = options.platform ?? process.platform
const prefs = options.systemPreferences
const fromApplication = (
contents: WebContents | null,
isMainFrame: boolean,
url: string | undefined,
) => {
if (!contents || !isMainFrame || !url) return false
return options.isAppContents(contents) && options.isAppUrl(url)
}
session.setPermissionCheckHandler((contents, permission, _origin, details) => {
if (permission !== 'media') return true
return fromApplication(contents, details.isMainFrame, details.requestingUrl)
&& details.mediaType === 'audio'
&& (platform !== 'darwin' || prefs.getMediaAccessStatus('microphone') === 'granted')
})
session.setPermissionRequestHandler((contents, permission, callback, details) => {
if (permission !== 'media') {
callback(true)
return
}
const mediaTypes = 'mediaTypes' in details ? details.mediaTypes : undefined
const audioOnly = mediaTypes?.length === 1 && mediaTypes[0] === 'audio'
if (!audioOnly || !fromApplication(contents, details.isMainFrame, details.requestingUrl)) {
callback(false)
return
}
if (platform !== 'darwin') {
callback(true)
return
}
const status = prefs.getMediaAccessStatus('microphone')
if (status === 'granted') {
callback(true)
} else if (status === 'not-determined') {
prefs.askForMediaAccess('microphone').then(callback, () => callback(false))
} else {
callback(false)
}
})
}