fix(mic): 无麦克风时明确提示“未检测到麦克风”,不再误报权限未授权(#779) (#896)

This commit is contained in:
Chris233 authored and GitHub committed 2026-08-03 22:10:41 +08:00
1 parent 5be28b1dbd
commit 3c21d28efe
14 files changed
+163 -15

No files matched your search

@@ -31,7 +31,12 @@ pub(super) fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), St
if permissions::windows_microphone_access_explicitly_denied() {
return Err("需要麦克风权限,当前状态: Denied".to_string());
}
return Ok(());
// 注册表只反映隐私开关;没插麦克风时不能当成“已就绪”,
// 否则用户会被误导去系统设置找不存在的麦克风权限。见 issue #779。
if permissions::has_microphone_input_device() {
return Ok(());
}
return Err("未检测到麦克风,请连接麦克风后重试".to_string());
}
let status = permissions::check_microphone();
@@ -41,6 +46,9 @@ pub(super) fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), St
) {
return Ok(());
}
if status == PermissionStatus::NoDevice {
return Err("未检测到麦克风,请连接麦克风后重试".to_string());
}
// 听写路径不抢前台焦点:缺 mic 权限时直接请求系统授权,不再先 show_main_window。
// 用户在设置页手动点“请求权限”仍走 request_microphone_from_foreground,那是显式操作。
@@ -2440,20 +2440,21 @@ pub(super) async fn start_recorder_for_starting(
}
Err(e) => {
log::error!("[coord] recorder start failed: {e}");
let message = e.user_message();
cancel_asr_for_session(inner, session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("录音启动失败: {e}")),
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, session_id);
release_recording_mute(inner, "dictation");
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
return Err(message);
}
}
@@ -953,10 +953,11 @@ pub(super) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
}
Err(e) => {
log::error!("[coord] QA recorder start failed: {e}");
let message = e.user_message();
cancel_qa_asr_for_session(inner, session_id);
release_recording_mute(inner, "qa");
finish_qa_with_error_if_current(inner, session_id, format!("录音启动失败: {e}"));
return Err(e.to_string());
finish_qa_with_error_if_current(inner, session_id, message.clone());
return Err(message);
}
}
+77 -2
View File
@@ -8,6 +8,8 @@
//! `AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: true})` 弹系统授权框。
//! - macOS Microphone:`AVAudioApplication.shared.recordPermission` + requestRecordPermission。
//! - Windows:cpal 不需要 Accessibility 等价权限;麦克风首次使用时 Win10+ 弹一次系统提示。
//! 没有可用输入设备时返回 `NoDevice`(区别于权限被拒的 `Denied`),避免把
//! “没插麦克风”误报成“未授权麦克风”。详见 issue #779。
use serde::Serialize;
@@ -20,6 +22,26 @@ pub enum PermissionStatus {
Restricted,
/// 当前平台不需要这个权限(如 Windows 上的 Accessibility)。
NotApplicable,
/// 当前没有可用的麦克风输入设备(不是权限问题)。
NoDevice,
}
/// 错误字符串是否暗示“当前没有可用输入设备”(区别于权限被拒)。
/// cpal 在不同平台返回的文案不统一,靠关键字粗判。
pub(crate) fn is_no_device_error(lower: &str) -> bool {
[
"no default input device",
"no default input",
"no input device",
"no device",
"device not found",
"device not available",
"not connected",
"unplugged",
"disconnected",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
// ─────────────────────────── macOS ───────────────────────────
@@ -322,7 +344,7 @@ mod platform {
let host = cpal::default_host();
let Some(device) = host.default_input_device() else {
log::warn!("[mic] no default input device");
return PermissionStatus::Denied;
return PermissionStatus::NoDevice;
};
let supported = match device.default_input_config() {
Ok(config) => config,
@@ -340,6 +362,12 @@ mod platform {
check_microphone()
}
/// 轻量检测是否存在输入设备:只枚举设备、不开输入流,
/// 供听写启动前快速区分「没插麦克风」与「有设备但权限被拒」。
pub fn has_microphone_input_device() -> bool {
cpal::default_host().default_input_device().is_some()
}
pub fn windows_microphone_access_explicitly_denied() -> bool {
#[cfg(target_os = "windows")]
{
@@ -355,7 +383,9 @@ mod platform {
fn classify_audio_probe_error(message: String) -> PermissionStatus {
let lower = message.to_lowercase();
log::warn!("[mic] input probe failed: {message}");
if lower.contains("denied")
if super::is_no_device_error(&lower) {
PermissionStatus::NoDevice
} else if lower.contains("denied")
|| lower.contains("permission")
|| lower.contains("authoriz")
|| lower.contains("access")
@@ -460,6 +490,16 @@ pub use platform::{
check_accessibility, check_microphone, request_accessibility, request_microphone,
};
#[cfg(all(not(target_os = "macos"), not(target_os = "android")))]
pub use platform::has_microphone_input_device;
#[cfg(any(target_os = "macos", target_os = "android"))]
pub fn has_microphone_input_device() -> bool {
// macOS / Android 的权限检查走系统授权回调,不依赖这里;
// 录音启动失败路径另有 NoInputDevice 兜底文案。
false
}
#[cfg(target_os = "windows")]
pub use platform::windows_microphone_access_explicitly_denied;
@@ -467,3 +507,38 @@ pub use platform::windows_microphone_access_explicitly_denied;
pub fn windows_microphone_access_explicitly_denied() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::is_no_device_error;
#[test]
fn no_device_errors_are_recognized() {
for message in [
"no default input device",
"No input device is available",
"device not found",
"default device is not connected",
"unplugged microphone",
] {
assert!(
is_no_device_error(&message.to_lowercase()),
"should classify as no-device: {message}"
);
}
}
#[test]
fn permission_errors_are_not_no_device() {
for message in [
"access denied",
"permission denied",
"not authorized",
] {
assert!(
!is_no_device_error(&message.to_lowercase()),
"should NOT classify as no-device: {message}"
);
}
}
}
+42 -3
View File
@@ -48,10 +48,26 @@ pub struct MicrophoneDevice {
pub enum RecorderError {
#[error("microphone permission denied")]
PermissionDenied,
#[error("no microphone input device detected")]
NoInputDevice,
#[error("audio engine failed: {0}")]
EngineFailed(String),
}
impl RecorderError {
/// 面向用户的启动错误文案:无设备 / 无权限给出明确指引,
/// 其余保留原始引擎错误便于排查。
pub fn user_message(&self) -> String {
match self {
RecorderError::NoInputDevice => "未检测到麦克风,请连接麦克风后重试".to_string(),
RecorderError::PermissionDenied => {
"需要麦克风权限,请在系统设置中允许 OpenLess 使用麦克风".to_string()
}
other => format!("录音启动失败: {other}"),
}
}
}
/// 采集器句柄。Drop 时不会自动停止——必须显式调用 `stop`。
pub struct Recorder {
stop_flag: Arc<AtomicBool>,
@@ -421,14 +437,16 @@ fn select_input_device(
}
host.default_input_device()
.ok_or_else(|| RecorderError::EngineFailed("no default input device".into()))
.ok_or(RecorderError::NoInputDevice)
}
/// 启动期 default_input_config 失败:依靠错误字符串关键字粗判权限问题。
/// cpal 在 macOS 没拿到 mic 授权时通常返回 `BackendSpecific`,我们尽力识别。
fn classify_default_config_err(msg: String) -> RecorderError {
let lower = msg.to_lowercase();
if lower.contains("permission") || lower.contains("denied") || lower.contains("authoriz") {
if is_no_device_error(&lower) {
RecorderError::NoInputDevice
} else if lower.contains("permission") || lower.contains("denied") || lower.contains("authoriz") {
RecorderError::PermissionDenied
} else {
RecorderError::EngineFailed(format!("default_input_config: {msg}"))
@@ -439,13 +457,34 @@ fn classify_default_config_err(msg: String) -> RecorderError {
fn classify_build_stream_err(err: cpal::BuildStreamError) -> RecorderError {
let msg = err.to_string();
let lower = msg.to_lowercase();
if lower.contains("permission") || lower.contains("denied") || lower.contains("authoriz") {
if is_no_device_error(&lower) {
RecorderError::NoInputDevice
} else if lower.contains("permission") || lower.contains("denied") || lower.contains("authoriz") {
RecorderError::PermissionDenied
} else {
RecorderError::EngineFailed(format!("build_input_stream: {msg}"))
}
}
/// 错误字符串是否暗示“当前没有可用输入设备”(区别于权限被拒)。
/// 与 permissions.rs::is_no_device_error 保持同一套关键字;backend-tests
/// harness 单独编译本模块,所以不跨模块引用。
fn is_no_device_error(lower: &str) -> bool {
[
"no default input device",
"no default input",
"no input device",
"no device",
"device not found",
"device not available",
"not connected",
"unplugged",
"disconnected",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
/// `SupportedStreamConfig` → 对应 SampleFormat 的具体 build 调用。
/// 只支持 cpal 常见的浮点和整型格式;其它格式 fallback 报错。
#[allow(clippy::too_many_arguments)]
+3 -1
View File
@@ -246,7 +246,9 @@ export function App({ isCapsule, isQa, isSelectionPolishPreview, isLessComputer,
]);
if (cancelled) return;
const aOk = a === 'granted' || a === 'notApplicable';
const mOk = m === 'granted' || m === 'notApplicable';
// noDevice(当前没有麦克风)不是权限问题:不卡 onboarding,
// 让用户进应用后在权限页看到“未检测到麦克风”的明确提示。见 issue #779。
const mOk = m === 'granted' || m === 'notApplicable' || m === 'noDevice';
setGate(aOk && mOk ? 'ready' : 'onboarding');
})().catch(error => {
console.warn('[startup] permission gate failed', error);
@@ -302,7 +302,8 @@ function DesktopOnboarding({
// onboarding finish before we knew macOS needs Accessibility, dropping users
// into the app with a dead hotkey. Mirrors the gate in App.tsx.
const aOk = a === 'granted' || a === 'notApplicable';
const mOk = m === 'granted' || m === 'notApplicable';
// noDevice = 当前没有麦克风设备,不是权限问题,不阻塞进入应用。
const mOk = m === 'granted' || m === 'notApplicable' || m === 'noDevice';
if (aOk && mOk) {
onComplete();
}
@@ -429,12 +430,15 @@ function DesktopOnboarding({
actionLabel={
microphone === 'granted'
? t('onboarding.actionGranted')
: microphone === 'noDevice'
? t('common.retry')
: microphone === 'denied'
? t('onboarding.actionOpenSystem')
: t('onboarding.actionRequestMic')
}
onAction={onRequestMicrophone}
onAction={microphone === 'noDevice' ? refresh : onRequestMicrophone}
disabled={busy || microphone === 'granted'}
hint={microphone === 'noDevice' ? t('onboarding.micNoDeviceHint') : undefined}
/>
<div style={footerHintStyle}>
+2
View File
@@ -271,6 +271,7 @@ export const en: typeof zhCN = {
actionRestart: 'Reset Accessibility and Restart OpenLess',
actionGrant: 'Grant',
actionRequestMic: 'Request access',
micNoDeviceHint: 'No microphone detected. Connect and enable a microphone, then retry.',
accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).',
footerHint: 'This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.',
continueToSettings: 'Open settings only (voice and global shortcuts unavailable)',
@@ -980,6 +981,7 @@ export const en: typeof zhCN = {
notApplicable: 'Not required',
denied: 'Not granted',
indeterminate: 'Undetermined',
micNoDevice: 'No microphone detected',
openSystem: 'Open System Settings',
restart: 'Reset and Restart',
grant: 'Grant',
+2
View File
@@ -273,6 +273,7 @@ export const ja: typeof zhCN = {
actionRestart: 'アクセシビリティをリセットして OpenLess を再起動',
actionGrant: '許可する',
actionRequestMic: '許可ダイアログを表示',
micNoDeviceHint: 'マイクが検出されません。マイクを接続して有効にしてから、もう一度お試しください。',
accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。',
footerHint: 'すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。',
continueToSettings: '設定のみ開く(音声とグローバルショートカットは利用不可)',
@@ -982,6 +983,7 @@ export const ja: typeof zhCN = {
notApplicable: '権限不要',
denied: '未許可',
indeterminate: '未確定',
micNoDevice: 'マイクが検出されません',
openSystem: 'システム設定を開く',
restart: 'リセットして再起動',
grant: '許可する',
+2
View File
@@ -273,6 +273,7 @@ export const ko: typeof zhCN = {
actionRestart: '접근성 권한 재설정 후 OpenLess 재시작',
actionGrant: '허용',
actionRequestMic: '권한 대화상자 표시',
micNoDeviceHint: '마이크가 감지되지 않습니다. 마이크를 연결하고 활성화한 후 다시 시도하세요.',
accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).',
footerHint: '모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.',
continueToSettings: '설정만 열기(음성 및 전역 단축키는 사용 불가)',
@@ -982,6 +983,7 @@ export const ko: typeof zhCN = {
notApplicable: '권한 불필요',
denied: '허용되지 않음',
indeterminate: '미결정',
micNoDevice: '마이크가 감지되지 않음',
openSystem: '시스템 설정 열기',
restart: '재설정 후 재시작',
grant: '허용',
+2
View File
@@ -269,6 +269,7 @@ export const zhCN = {
actionRestart: '重置授权并重启 OpenLess',
actionGrant: '授权',
actionRequestMic: '弹出授权',
micNoDeviceHint: '未检测到麦克风,请连接并启用麦克风后重试。',
accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。',
footerHint: '授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。',
continueToSettings: '仅进入设置(语音与全局快捷键暂不可用)',
@@ -978,6 +979,7 @@ export const zhCN = {
notApplicable: '无需授权',
denied: '未授权',
indeterminate: '未确定',
micNoDevice: '未检测到麦克风',
openSystem: '打开系统设置',
restart: '重置授权并重启',
grant: '授权',
+2
View File
@@ -271,6 +271,7 @@ export const zhTW: typeof zhCN = {
actionRestart: '重置授權並重新啟動 OpenLess',
actionGrant: '授權',
actionRequestMic: '彈出授權',
micNoDeviceHint: '未偵測到麥克風,請連接並啟用麥克風後重試。',
accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。',
footerHint: '授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。',
continueToSettings: '僅進入設定(語音與全域快速鍵暫不可用)',
@@ -980,6 +981,7 @@ export const zhTW: typeof zhCN = {
notApplicable: '無需授權',
denied: '未授權',
indeterminate: '未確定',
micNoDevice: '未偵測到麥克風',
openSystem: '打開系統設置',
restart: '重置授權並重新啟動',
grant: '授權',
+2 -1
View File
@@ -616,7 +616,8 @@ export type PermissionStatus =
| 'denied'
| 'notDetermined'
| 'restricted'
| 'notApplicable';
| 'notApplicable'
| 'noDevice';
/** Runtime platform kind returned by `get_platform_capabilities`. */
export type PlatformKind = 'desktop' | 'android' | 'mobile';
@@ -154,7 +154,11 @@ export function PermissionsSection() {
<SettingRow label={t('settings.permissions.micLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
<PermissionPill status={microphone} />
{microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && (
{microphone === 'noDevice' ? (
<Btn variant="ghost" size="sm" onClick={refreshPermissions}>
{t('common.retry')}
</Btn>
) : microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && (
<Btn variant="ghost" size="sm" onClick={reRequestMicrophone}>
{microphone === 'denied' || microphone === 'restricted' ? t('settings.permissions.openSystem') : t('settings.permissions.grant')}
</Btn>
@@ -248,6 +252,9 @@ function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) {
if (status === 'notApplicable') {
return <Pill tone="default">{t('settings.permissions.notApplicable')}</Pill>;
}
if (status === 'noDevice') {
return <Pill tone="outline">{t('settings.permissions.micNoDevice')}</Pill>;
}
if (status === 'denied' || status === 'restricted') {
return <Pill tone="outline">{t('settings.permissions.denied')}</Pill>;
}