fix(macos): Fn 键可被录制为听写触发键 (#979) (#981)

* fix(macos): Fn 键可被录制为听写触发键 (#979)

浏览器/WebView 不向网页层下发 Fn 的 keydown(系统修饰键被隐藏),
ShortcutRecorder 无法通过键盘事件捕获 Fn。运行时本可检测 Fn(CGEventTap 看
keycode 63 + kCGEventFlagMaskSecondaryFn),但仅能通过「设置」写入,录制流程录不进。

- ShortcutRecorder:加 allowFn 开关,菜单提供「Fn」一键设置(绕过录制限制)
- macOS CGEventTap:录制态检测 Fn 边沿 → 上报 FnRecordingPressed → coordinator
  emit fn-shortcut-pressed → 前端提交 {primary:'Fn'}
- 关键修复:MacHotkeyAdapter 缺 set_recording_active override,录制态从未传到监听器,
  Fn 检测永不触发
- i18n: 补 comboFnLabel / comboFnHint 五语言文案

refs #979

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(android): gate desktop Fn recording bridge

* refactor(macos): record Fn through backend event

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-08-22 05:19:45 +08:00
1 parent aa0acf8827
commit 2b796d067e
4 files changed
+118 -9

No files matched your search

+13 -1
View File
@@ -2718,10 +2718,22 @@ impl Coordinator {
self.inner
.shortcut_recording_active
.store(active, Ordering::SeqCst);
// 同步给热键监听器:录制态激活时 CGEventTap 上报 Fn 按下边沿,
// 供前端 ShortcutRecorder 提交 Fn 绑定(浏览器不向网页层下发 Fn keydown)。
#[cfg(not(mobile))]
let sync_ok = self.inner.hotkey.lock().as_ref().map(|m| {
m.set_recording_active(active);
true
});
#[cfg(mobile)]
let sync_ok = None;
if active {
reset_shortcut_held_state(&self.inner);
}
log::info!("[coord] shortcut recording active={active}");
log::info!(
"[coord] shortcut recording active={active} (synced_to_hotkey={})",
sync_ok.unwrap_or(false)
);
}
pub async fn handle_window_hotkey_event(
@@ -588,6 +588,8 @@ pub(super) fn less_computer_modifier_bridge_loop(
HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {}
#[cfg(not(mobile))]
HotkeyEvent::SelectionPolishShortcutPressed => {}
#[cfg(not(mobile))]
HotkeyEvent::FnRecordingPressed => {}
}
}
}
@@ -1562,6 +1564,13 @@ pub(super) fn arm_translation_if_effective(inner: &Arc<Inner>) -> bool {
pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
// 录制态:仅上报「录制 Fn」事件给前端(recorder 在录入态检测到 Fn 按下,
// 浏览器不向网页层下发 Fn keydown,由 CGEventTap 上报),其余热键事件
// 一律跳过,避免录制期间误触发听写。
#[cfg(not(mobile))]
if matches!(evt, HotkeyEvent::FnRecordingPressed) {
emit_fn_recording_pressed(&inner);
}
continue;
}
let inner_cloned = Arc::clone(&inner);
@@ -1617,10 +1626,23 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
}
});
}
// 非录制态不会出现(CGEventTap 仅在 recording_active 时上报);防御性忽略。
#[cfg(not(mobile))]
HotkeyEvent::FnRecordingPressed => {}
}
}
}
/// 录制态检测到 Fn 按下 → 发事件给前端,让 ShortcutRecorder 提交 Fn 绑定。
#[cfg(not(mobile))]
fn emit_fn_recording_pressed(inner: &Arc<Inner>) {
let app = inner.app.lock().clone();
log::info!("[hotkey] 录制 Fn 按下 → emit fn-shortcut-pressed (app_ready={})", app.is_some());
if let Some(app) = app {
let _ = app.emit("fn-shortcut-pressed", ());
}
}
pub(super) fn reset_shortcut_held_state(inner: &Arc<Inner>) {
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
if let Some(monitor) = inner.hotkey.lock().as_ref() {
+46
View File
@@ -38,6 +38,9 @@ pub enum HotkeyEvent {
TranslationModifierPressed,
QaShortcutPressed,
SelectionPolishShortcutPressed,
/// 录制态按下 Fn(浏览器不向网页层下发 Fn 的 keydown,无法通过 recorder 捕获;
/// 由 CGEventTap 在录制态检测后上报,供前端 ShortcutRecorder 提交 Fn 绑定)。
FnRecordingPressed,
}
#[cfg(test)]
@@ -58,6 +61,8 @@ mod tests {
translation_trigger: RwLock::new(None),
translation_trigger_held: AtomicBool::new(true),
translation_modifier_held: AtomicBool::new(true),
recording_active: AtomicBool::new(false),
recording_fn_held: AtomicBool::new(false),
}
}
@@ -134,6 +139,9 @@ pub trait HotkeyAdapter: Send + Sync {
translation_trigger: Option<HotkeyTrigger>,
);
fn reset_held_state(&self);
/// 快捷键录制态开关。激活期间监听器上报 Fn 按下边沿(`FnRecordingPressed`),
/// 供前端 recorder 提交 Fn 绑定(浏览器不向网页层下发 Fn keydown)。
fn set_recording_active(&self, _active: bool) {}
/// 本次按住期间,监听器是否已经看到触发键被叠加了普通键。上层的「仲裁窗口」
/// 按下后先等一小会儿再读它,命中就整条按下作废(麦克风都不用开)。
/// 没有键盘监听器的平台(Linux/fcitx5)恒为 false。
@@ -161,6 +169,11 @@ struct Shared {
/// Shift(翻译修饰键)当前是否按住。用于在 FLAGS_CHANGED 上识别 down 边沿
/// (只在 false → true 时往上层发 TranslationModifierPressed)。详见 issue #4。
translation_modifier_held: AtomicBool,
/// 快捷键录制是否激活(ShortcutRecorder 处于录入态)。激活期间 CGEventTap 上报
/// Fn 按下边沿(`FnRecordingPressed`),供前端 recorder 提交 Fn 绑定。
recording_active: AtomicBool,
/// 录制态 Fn 是否已按住,用于在 FLAGS_CHANGED 上识别 down 边沿(去重)。
recording_fn_held: AtomicBool,
}
pub struct HotkeyMonitor {
@@ -213,6 +226,10 @@ impl HotkeyMonitor {
self.adapter.reset_held_state();
}
pub fn set_recording_active(&self, active: bool) {
self.adapter.set_recording_active(active);
}
pub fn trigger_combined_since_press(&self, press_id: u64) -> bool {
self.adapter.trigger_combined_since_press(press_id)
}
@@ -312,6 +329,8 @@ where
translation_trigger: RwLock::new(None),
translation_trigger_held: AtomicBool::new(false),
translation_modifier_held: AtomicBool::new(false),
recording_active: AtomicBool::new(false),
recording_fn_held: AtomicBool::new(false),
});
let thread_shared = Arc::clone(&shared);
@@ -481,6 +500,12 @@ mod platform {
fn reset_held_state(&self) {
reset_shared_held_state(&self.shared);
}
fn set_recording_active(&self, active: bool) {
self.shared.recording_active.store(active, Ordering::SeqCst);
if !active {
self.shared.recording_fn_held.store(false, Ordering::SeqCst);
}
}
fn trigger_combined_since_press(&self, press_id: u64) -> bool {
self.shared
@@ -703,6 +728,23 @@ mod platform {
fn handle_flags_changed(ctx: &CallbackContext, event: CgEventRef) {
let flags = unsafe { CGEventGetFlags(event) };
// 录制态:浏览器不向网页层下发 Fn keydown(系统修饰键被隐藏),无法通过
// ShortcutRecorder 捕获。这里用 CGEventTap 检测 Fn 按下边沿(keycode 63 +
// kCGEventFlagMaskSecondaryFn),上报 `FnRecordingPressed`,供上层转给前端
// recorder 提交 Fn 绑定。
if ctx.shared.recording_active.load(Ordering::SeqCst) {
let fn_active = (flags & FLAG_MASK_SECONDARY_FN) != 0;
let fn_was_held = ctx.shared.recording_fn_held.load(Ordering::SeqCst);
if fn_active && !fn_was_held {
ctx.shared.recording_fn_held.store(true, Ordering::SeqCst);
log::info!("[hotkey] 录制态检测到 Fn↓ → 上报 FnRecordingPressed");
send_or_log(&ctx.tx, HotkeyEvent::FnRecordingPressed);
} else if !fn_active && fn_was_held {
ctx.shared.recording_fn_held.store(false, Ordering::SeqCst);
log::info!("[hotkey] 录制态 Fn↑(松开)");
}
}
// Shift 是翻译模式修饰键 — 与触发键的 keycode 检查独立,任何时刻按 Shift 都生效。
let shift_active = (flags & FLAG_MASK_SHIFT) != 0;
let shift_was_held = ctx.shared.translation_modifier_held.load(Ordering::SeqCst);
@@ -893,6 +935,8 @@ mod platform {
translation_trigger: RwLock::new(None),
translation_trigger_held: AtomicBool::new(false),
translation_modifier_held: AtomicBool::new(false),
recording_active: AtomicBool::new(false),
recording_fn_held: AtomicBool::new(false),
})
}
@@ -1465,6 +1509,8 @@ mod platform {
translation_trigger: RwLock::new(None),
translation_trigger_held: AtomicBool::new(false),
translation_modifier_held: AtomicBool::new(false),
recording_active: AtomicBool::new(false),
recording_fn_held: AtomicBool::new(false),
})
}
@@ -91,16 +91,8 @@ export function ShortcutRecorder({
useEffect(() => () => {
resetRecordingState();
void setShortcutRecordingActive(false);
}, []);
useEffect(() => {
void setShortcutRecordingActive(recording);
return () => {
if (recording) void setShortcutRecordingActive(false);
};
}, [recording]);
useEffect(() => {
if (!disabled || !recording) return;
setRecording(false);
@@ -119,6 +111,43 @@ export function ShortcutRecorder({
}
};
// 浏览器不下发 Fn keydown:先监听 Rust CGEventTap 转发的事件,再激活后端录制态,
// 避免用户刚进入录制就按 Fn 时事件早于监听器注册。用 ref 拿最新 finish,避免 effect
// 因 finish 引用变化反复注册。
const finishRef = useRef(finish);
finishRef.current = finish;
useEffect(() => {
if (!recording) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
void (async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen('fn-shortcut-pressed', () => {
if (cancelled) return;
setMenuOpen(false);
void finishRef.current({ primary: 'Fn', modifiers: [] });
});
if (cancelled) handle();
else unlisten = handle;
} catch (error) {
console.warn('[shortcut] fn-shortcut-pressed listener failed', error);
}
if (cancelled) return;
try {
await setShortcutRecordingActive(true);
if (cancelled) await setShortcutRecordingActive(false);
} catch (error) {
console.warn('[shortcut] recording state sync failed', error);
}
})();
return () => {
cancelled = true;
unlisten?.();
void setShortcutRecordingActive(false);
};
}, [recording]);
/** 开始录入:同时收起菜单——「录制快捷键」按下后,重置/停用两个按钮随之消失。 */
const startRecording = () => {
if (disabled || recording) return;