fix(mic): 无麦克风时明确提示“未检测到麦克风”,不再误报权限未授权(#779) (#896)
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '許可する',
|
||||
|
||||
@@ -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: '허용',
|
||||
|
||||
@@ -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: '授权',
|
||||
|
||||
@@ -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: '授權',
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user