fix(mac): align microphone permission and window activation

This commit is contained in:
baiqing committed 2026-04-29 18:13:28 +08:00
1 parent 7acb3b89d4
commit c66e041ecb
7 files changed
+133 -81

No files matched your search

+1 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# 一键构建 macOS 正式版 .app / .dmg。
#
# macOS 的 LSUIElement / NSXxxUsageDescription 放在 src-tauri/Info.plist,
# macOS 的 NSXxxUsageDescription 放在 src-tauri/Info.plist,
# 由 Tauri 在生成 .app 和 .dmg 前合入,避免上传的 DMG 仍是旧 Info.plist。
#
# 用法:在 app/ 目录下执行
@@ -27,7 +27,6 @@ echo "▶ tauri build"
npm run tauri build
echo "▶ 校验 Info.plist / 签名"
/usr/libexec/PlistBuddy -c "Print :LSUIElement" "$INFO" >/dev/null
/usr/libexec/PlistBuddy -c "Print :NSMicrophoneUsageDescription" "$INFO" >/dev/null
codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -2
-2
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>OpenLess 需要麦克风权限来听写你的语音。</string>
<key>NSAccessibilityUsageDescription</key>
+9 -61
View File
@@ -2,7 +2,7 @@
use std::sync::Arc;
use tauri::State;
use tauri::{AppHandle, State};
use crate::coordinator::Coordinator;
use crate::permissions::{self, PermissionStatus};
@@ -198,7 +198,8 @@ pub fn check_microphone_permission() -> PermissionStatus {
}
#[tauri::command]
pub fn request_microphone_permission() -> PermissionStatus {
pub fn request_microphone_permission(app: AppHandle) -> PermissionStatus {
crate::show_main_window(&app);
permissions::request_microphone()
}
@@ -226,69 +227,16 @@ pub fn open_system_settings(pane: String) -> Result<(), String> {
}
/// 触发 macOS 系统弹"是否允许 OpenLess 访问麦克风"对话框。
/// 关键:**必须 `.play()` 才会触发 TCC 检查并弹框** —— `build_input_stream` 仅构造对象,
/// 不会让操作系统去问用户。**这是上次 trigger 看似不响应的真正原因。**
/// 流启动 ~400ms 后关闭 — 足够 macOS 处理弹窗,又不会真采到声音。
///
/// 注意:仅在权限 == NotDetermined 时调用有意义;Denied 状态下系统**不会再弹**任何框,
/// 必须用户手动到系统设置 → 隐私与安全性 → 麦克风 把 OpenLess 勾上。
/// 与 Swift `MicrophonePermission.request()` 同语义:只信系统权限回调,
/// 不用 cpal stream 成功与否伪造授权状态。
#[tauri::command]
pub fn trigger_microphone_prompt() -> Result<(), String> {
let status = permissions::request_microphone();
if matches!(status, PermissionStatus::Granted) {
return Ok(());
if matches!(status, PermissionStatus::Granted | PermissionStatus::NotApplicable) {
Ok(())
} else {
Err(format!("microphone permission is {status:?}"))
}
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| "no input device".to_string())?;
let config = device
.default_input_config()
.map_err(|e| e.to_string())?;
log::info!(
"[mic] trigger_microphone_prompt: opening device {} ({} Hz, {:?})",
device.name().unwrap_or_else(|_| "<unnamed>".into()),
config.sample_rate().0,
config.sample_format(),
);
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => device.build_input_stream(
&config.into(),
|_data: &[f32], _: &_| {},
|err| log::warn!("[mic] trigger stream err: {err}"),
None,
),
cpal::SampleFormat::I16 => device.build_input_stream(
&config.into(),
|_data: &[i16], _: &_| {},
|err| log::warn!("[mic] trigger stream err: {err}"),
None,
),
cpal::SampleFormat::U16 => device.build_input_stream(
&config.into(),
|_data: &[u16], _: &_| {},
|err| log::warn!("[mic] trigger stream err: {err}"),
None,
),
other => return Err(format!("unsupported sample format {other:?}")),
}
.map_err(|e| {
log::warn!("[mic] build_input_stream failed: {e}");
e.to_string()
})?;
// ★ 关键:play() 才让 macOS 实际去 TCC 查权限 → 触发系统弹框。
if let Err(e) = stream.play() {
log::warn!("[mic] stream.play() failed: {e}");
return Err(e.to_string());
}
// 给 OS 一点时间把弹框显示出来;用户在他们的世界里慢慢看 / 点。
std::thread::sleep(std::time::Duration::from_millis(400));
drop(stream);
log::info!("[mic] trigger_microphone_prompt: stream play() + drop 完成");
Ok(())
}
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
@@ -223,6 +223,20 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
state.started_at = Instant::now();
}
if let Err(message) = ensure_microphone_permission(inner) {
log::warn!("[coord] microphone permission gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
None,
);
inner.state.lock().phase = SessionPhase::Idle;
return Err(message);
}
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
let creds = read_volc_credentials();
@@ -402,6 +416,29 @@ fn cancel_session(inner: &Arc<Inner>) {
// ─────────────────────────── helpers ───────────────────────────
fn ensure_microphone_permission(inner: &Arc<Inner>) -> Result<(), String> {
use crate::permissions::{self, PermissionStatus};
let status = permissions::check_microphone();
if matches!(status, PermissionStatus::Granted | PermissionStatus::NotApplicable) {
return Ok(());
}
if let Some(app) = inner.app.lock().clone() {
crate::show_main_window(&app);
}
let requested = permissions::request_microphone();
if matches!(
requested,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
Ok(())
} else {
Err(format!("需要麦克风权限,当前状态: {requested:?}"))
}
}
async fn polish_or_passthrough(raw: &RawTranscript, mode: PolishMode, hotwords: &[String]) -> String {
if mode == PolishMode::Raw {
return raw.text.clone();
+69 -13
View File
@@ -24,7 +24,7 @@ mod types;
use std::sync::Arc;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent};
use tauri::{LogicalPosition, Manager};
use tauri::{AppHandle, LogicalPosition, Manager, RunEvent, Runtime};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -70,6 +70,8 @@ pub fn run() {
}
}
show_main_window(app.handle());
// 启动时主动弹 Accessibility 授权框(与 Swift `AppDelegate` 行为一致)。
// 用户首次必看到系统提示;已授权则静默返回。
#[cfg(target_os = "macos")]
@@ -110,15 +112,6 @@ pub fn run() {
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// 菜单栏 app:关闭主窗口仅隐藏,保留菜单栏入口。退出走 tray 菜单 → 退出。
if window.label() == "main" {
api.prevent_close();
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
commands::get_settings,
commands::set_settings,
@@ -145,8 +138,21 @@ pub fn run() {
commands::trigger_microphone_prompt,
commands::read_credential,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| match event {
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => show_main_window(app),
RunEvent::WindowEvent { label, event, .. } => {
if label == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
hide_main_window(app);
}
}
}
_ => {}
});
}
/// 把日志同时写到 stderr + ~/Library/Logs/OpenLess/openless.log(match Swift `Log.swift`)。
@@ -208,12 +214,62 @@ fn log_dir_path() -> std::path::PathBuf {
std::env::temp_dir().join("OpenLess")
}
fn show_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
pub(crate) fn show_main_window<R: Runtime>(app: &AppHandle<R>) {
activate_window_mode(app);
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
activate_app(app);
}
fn hide_main_window<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.hide();
}
activate_menu_bar_mode(app);
}
#[cfg(target_os = "macos")]
fn activate_window_mode<R: Runtime>(app: &AppHandle<R>) {
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
let _ = app.set_dock_visibility(true);
let _ = app.show();
}
#[cfg(not(target_os = "macos"))]
fn activate_window_mode<R: Runtime>(_app: &AppHandle<R>) {}
#[cfg(target_os = "macos")]
fn activate_menu_bar_mode<R: Runtime>(app: &AppHandle<R>) {
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
let _ = app.set_dock_visibility(false);
}
#[cfg(not(target_os = "macos"))]
fn activate_menu_bar_mode<R: Runtime>(_app: &AppHandle<R>) {}
#[cfg(target_os = "macos")]
fn activate_app<R: Runtime>(app: &AppHandle<R>) {
let _ = app.run_on_main_thread(|| {
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject, Bool};
unsafe {
let Some(cls) = AnyClass::get("NSApplication") else {
return;
};
let ns_app: *mut AnyObject = msg_send![cls, sharedApplication];
if !ns_app.is_null() {
let _: () = msg_send![ns_app, activateIgnoringOtherApps: Bool::YES];
}
}
});
}
#[cfg(not(target_os = "macos"))]
fn activate_app<R: Runtime>(_app: &AppHandle<R>) {
}
/// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。
@@ -60,7 +60,7 @@ mod platform {
}
// AVAudioApplication 在 AVFAudio 框架(macOS 14+)。Swift 原版 MicrophonePermission.swift
// 走的就是这条;它和 cpal/AVAudioEngine 共享同一个权限状态。
// 走的就是这条;它是录音启动前判断权限的唯一真相源。
#[link(name = "AVFAudio", kind = "framework")]
extern "C" {}
@@ -100,7 +100,7 @@ mod platform {
}
pub fn check_microphone() -> PermissionStatus {
// 与 Swift 旧版 `MicrophonePermission.isGranted()` 保持同源。
// 与 Swift `MicrophonePermission.isGranted()` 保持同源。
if let Some(status) = check_microphone_via_avaudio_application() {
return status;
}
@@ -108,7 +108,7 @@ mod platform {
}
pub fn request_microphone() -> PermissionStatus {
// 与 Swift 旧版 `MicrophonePermission.request()` 保持同源,8 秒兜底。
// 与 Swift `MicrophonePermission.request()` 保持同源,8 秒兜底。
if let Some(status) = request_microphone_via_avaudio_application() {
return status;
}
@@ -20,6 +20,8 @@ use cpal::{SampleFormat, StreamConfig};
use parking_lot::Mutex;
use thiserror::Error;
use crate::permissions::{self, PermissionStatus};
/// 目标采样率(与 Swift 端常量一致;不要改)。
const TARGET_SAMPLE_RATE: u32 = 16_000;
/// 每多少个回调打一次诊断日志。
@@ -58,6 +60,18 @@ impl Recorder {
consumer: Arc<dyn AudioConsumer>,
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
) -> Result<Self, RecorderError> {
let status = permissions::check_microphone();
if !matches!(status, PermissionStatus::Granted | PermissionStatus::NotApplicable) {
let requested = permissions::request_microphone();
if !matches!(
requested,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
log::warn!("[recorder] microphone permission not granted: {:?}", requested);
return Err(RecorderError::PermissionDenied);
}
}
// 启动信号:子线程构造 Stream 完成后通过 startup_tx 报告结果。
let (startup_tx, startup_rx) = channel::<Result<(), RecorderError>>();
let stop_flag = Arc::new(AtomicBool::new(false));