* 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:
4 files changed
+118
-9
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user