fix(mac): align microphone permission and window activation
This commit is contained in:
1 parent
7acb3b89d4
commit
c66e041ecb
7 files changed
+133
-81
No files matched your search
@@ -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,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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user