diff --git a/openless -all/app/scripts/build-mac.sh b/openless -all/app/scripts/build-mac.sh index 7d6a6abb..fb25c95b 100755 --- a/openless -all/app/scripts/build-mac.sh +++ b/openless -all/app/scripts/build-mac.sh @@ -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 diff --git a/openless -all/app/src-tauri/Info.plist b/openless -all/app/src-tauri/Info.plist index 98893b55..b1711f9e 100644 --- a/openless -all/app/src-tauri/Info.plist +++ b/openless -all/app/src-tauri/Info.plist @@ -2,8 +2,6 @@ - LSUIElement - NSMicrophoneUsageDescription OpenLess 需要麦克风权限来听写你的语音。 NSAccessibilityUsageDescription diff --git a/openless -all/app/src-tauri/src/commands.rs b/openless -all/app/src-tauri/src/commands.rs index 622d4e30..b9bd202a 100644 --- a/openless -all/app/src-tauri/src/commands.rs +++ b/openless -all/app/src-tauri/src/commands.rs @@ -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(|_| "".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) ─────────────────────────── diff --git a/openless -all/app/src-tauri/src/coordinator.rs b/openless -all/app/src-tauri/src/coordinator.rs index aa7b1643..22fe7e51 100644 --- a/openless -all/app/src-tauri/src/coordinator.rs +++ b/openless -all/app/src-tauri/src/coordinator.rs @@ -223,6 +223,20 @@ async fn begin_session(inner: &Arc) -> 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) { // ─────────────────────────── helpers ─────────────────────────── +fn ensure_microphone_permission(inner: &Arc) -> 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(); diff --git a/openless -all/app/src-tauri/src/lib.rs b/openless -all/app/src-tauri/src/lib.rs index 971ade0b..14481662 100644 --- a/openless -all/app/src-tauri/src/lib.rs +++ b/openless -all/app/src-tauri/src/lib.rs @@ -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(app: &tauri::AppHandle) { +pub(crate) fn show_main_window(app: &AppHandle) { + 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(app: &AppHandle) { + 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(app: &AppHandle) { + 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(_app: &AppHandle) {} + +#[cfg(target_os = "macos")] +fn activate_menu_bar_mode(app: &AppHandle) { + let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory); + let _ = app.set_dock_visibility(false); +} + +#[cfg(not(target_os = "macos"))] +fn activate_menu_bar_mode(_app: &AppHandle) {} + +#[cfg(target_os = "macos")] +fn activate_app(app: &AppHandle) { + 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(_app: &AppHandle) { } /// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。 diff --git a/openless -all/app/src-tauri/src/permissions.rs b/openless -all/app/src-tauri/src/permissions.rs index da5f6780..d6362792 100644 --- a/openless -all/app/src-tauri/src/permissions.rs +++ b/openless -all/app/src-tauri/src/permissions.rs @@ -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; } diff --git a/openless -all/app/src-tauri/src/recorder.rs b/openless -all/app/src-tauri/src/recorder.rs index c5a7fb43..19539bd5 100644 --- a/openless -all/app/src-tauri/src/recorder.rs +++ b/openless -all/app/src-tauri/src/recorder.rs @@ -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, level_handler: Arc, ) -> Result { + 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::>(); let stop_flag = Arc::new(AtomicBool::new(false));