Merge remote-tracking branch 'origin/beta' into codex/ui-2.0-details
This commit is contained in:
commit
7ae366e2aa
4 files changed
+498
-31
No files matched your search
@@ -3255,8 +3255,10 @@ impl OpenLessBackend {
|
||||
};
|
||||
let preferences = self.get_preferences();
|
||||
let mode = preferences.hotkey.mode;
|
||||
let modifier_only =
|
||||
crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey).is_some();
|
||||
let modifier_only = crate::hotkey_interpreter::modifier_arbitration_required(
|
||||
crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey),
|
||||
mode,
|
||||
);
|
||||
let (intent, reservation) = {
|
||||
let mut hotkey = self
|
||||
.hotkey
|
||||
|
||||
@@ -12,12 +12,14 @@ use std::time::{Duration, Instant};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use parking_lot::Mutex as ParkingMutex;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::net::{lookup_host, TcpStream};
|
||||
use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify};
|
||||
use tokio_tungstenite::client_async_tls;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::header::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::{TaskSpawner, TokioTaskSpawner};
|
||||
@@ -39,6 +41,92 @@ const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
/// 热键彻底失灵(开不了也停不了,只能退出重开)。详见 stepfun_realtime.rs 同名常量。
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// 单个候选地址的 TCP 上限。IPv6 黑洞时不能把整段 5s 耗在第一个 AAAA 上。
|
||||
const PER_ADDR_TCP_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
|
||||
fn default_port_for_request(
|
||||
request: &tokio_tungstenite::tungstenite::handshake::client::Request,
|
||||
) -> Result<u16, WsError> {
|
||||
let default_port = match request.uri().scheme_str() {
|
||||
Some("ws") => 80,
|
||||
Some("wss") => 443,
|
||||
_ => {
|
||||
return Err(WsError::Url(
|
||||
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme,
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(request.uri().port_u16().unwrap_or(default_port))
|
||||
}
|
||||
|
||||
fn order_connect_addrs(mut addrs: Vec<std::net::SocketAddr>) -> Vec<std::net::SocketAddr> {
|
||||
addrs.sort_by_key(|addr| u8::from(!addr.ip().is_ipv4()));
|
||||
addrs
|
||||
}
|
||||
|
||||
async fn connect_ws_to_addrs(
|
||||
request: tokio_tungstenite::tungstenite::handshake::client::Request,
|
||||
addrs: Vec<std::net::SocketAddr>,
|
||||
) -> Result<
|
||||
(
|
||||
WsStream,
|
||||
tokio_tungstenite::tungstenite::handshake::client::Response,
|
||||
),
|
||||
WsError,
|
||||
> {
|
||||
if addrs.is_empty() {
|
||||
return Err(WsError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no addresses for websocket endpoint",
|
||||
)));
|
||||
}
|
||||
|
||||
let mut last_err = None;
|
||||
for addr in addrs {
|
||||
match tokio::time::timeout(PER_ADDR_TCP_TIMEOUT, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(stream)) => match client_async_tls(request.clone(), stream).await {
|
||||
Ok(connection) => return Ok(connection),
|
||||
Err(error) => last_err = Some(error),
|
||||
},
|
||||
Ok(Err(error)) => last_err = Some(WsError::Io(error)),
|
||||
Err(_) => {
|
||||
last_err = Some(WsError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"tcp connect timeout ({})",
|
||||
if addr.ip().is_ipv4() { "v4" } else { "v6" }
|
||||
),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
WsError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"no tcp candidate",
|
||||
))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn connect_ws_prefer_ipv4(
|
||||
request: tokio_tungstenite::tungstenite::handshake::client::Request,
|
||||
) -> Result<
|
||||
(
|
||||
WsStream,
|
||||
tokio_tungstenite::tungstenite::handshake::client::Response,
|
||||
),
|
||||
WsError,
|
||||
> {
|
||||
let port = default_port_for_request(&request)?;
|
||||
let host = request.uri().host().unwrap_or("").to_string();
|
||||
let addrs = lookup_host((host.as_str(), port))
|
||||
.await
|
||||
.map_err(WsError::Io)?
|
||||
.collect::<Vec<_>>();
|
||||
connect_ws_to_addrs(request, order_connect_addrs(addrs)).await
|
||||
}
|
||||
|
||||
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
type WsSink = futures_util::stream::SplitSink<WsStream, Message>;
|
||||
type SharedWriter = Arc<AsyncMutex<Option<WsSink>>>;
|
||||
@@ -171,7 +259,7 @@ impl BailianRealtimeASR {
|
||||
.map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
|
||||
let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
|
||||
let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_ws_prefer_ipv4(request))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
BailianASRError::ConnectionFailed(format!(
|
||||
@@ -765,6 +853,100 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_default_ports_match_uri_scheme() {
|
||||
let ws = "ws://localhost/path".into_client_request().unwrap();
|
||||
let wss = "wss://localhost/path".into_client_request().unwrap();
|
||||
let explicit = "ws://localhost:9000/path".into_client_request().unwrap();
|
||||
|
||||
assert_eq!(default_port_for_request(&ws).unwrap(), 80);
|
||||
assert_eq!(default_port_for_request(&wss).unwrap(), 443);
|
||||
assert_eq!(default_port_for_request(&explicit).unwrap(), 9000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_default_port_rejects_non_websocket_scheme() {
|
||||
let request = "https://localhost/path".into_client_request().unwrap();
|
||||
let explicit_port = "https://localhost:443/path".into_client_request().unwrap();
|
||||
assert!(matches!(
|
||||
default_port_for_request(&request),
|
||||
Err(WsError::Url(
|
||||
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme
|
||||
))
|
||||
));
|
||||
assert!(matches!(
|
||||
default_port_for_request(&explicit_port),
|
||||
Err(WsError::Url(
|
||||
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_connect_addrs_puts_ipv4_first() {
|
||||
let v6: std::net::SocketAddr = "[2408:400a::1]:443".parse().unwrap();
|
||||
let v4: std::net::SocketAddr = "8.152.159.24:443".parse().unwrap();
|
||||
let ordered = order_connect_addrs(vec![v6, v4]);
|
||||
assert!(ordered[0].ip().is_ipv4());
|
||||
assert!(ordered[1].ip().is_ipv6());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_handshake_failure_falls_back_to_next_address() {
|
||||
let rejected_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let rejected_addr = rejected_listener.local_addr().unwrap();
|
||||
let rejected_task = tokio::spawn(async move {
|
||||
let (stream, _) = rejected_listener.accept().await.unwrap();
|
||||
drop(stream);
|
||||
});
|
||||
|
||||
let accepted_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let accepted_addr = accepted_listener.local_addr().unwrap();
|
||||
let accepted_task = tokio::spawn(async move {
|
||||
let (stream, _) = accepted_listener.accept().await.unwrap();
|
||||
tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
});
|
||||
|
||||
let request = format!("ws://localhost:{}/path", accepted_addr.port())
|
||||
.into_client_request()
|
||||
.unwrap();
|
||||
let (stream, _) = connect_ws_to_addrs(request, vec![rejected_addr, accepted_addr])
|
||||
.await
|
||||
.unwrap();
|
||||
drop(stream);
|
||||
|
||||
rejected_task.await.unwrap();
|
||||
accepted_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_connects_to_ipv6_candidate_when_available() {
|
||||
let Ok(listener) = tokio::net::TcpListener::bind("[::1]:0").await else {
|
||||
return;
|
||||
};
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
});
|
||||
|
||||
let request = format!("ws://[::1]:{}/path", addr.port())
|
||||
.into_client_request()
|
||||
.unwrap();
|
||||
let (stream, _) = connect_ws_to_addrs(request, vec![addr]).await.unwrap();
|
||||
drop(stream);
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_connect_with_no_candidates_fails_immediately() {
|
||||
let request = "ws://localhost/path".into_client_request().unwrap();
|
||||
let result = connect_ws_to_addrs(request, Vec::new()).await;
|
||||
assert!(
|
||||
matches!(result, Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- merge_segments ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::shared_types::HotkeyMode;
|
||||
use crate::shared_types::{HotkeyMode, HotkeyTrigger};
|
||||
use crate::types::DictationPhase;
|
||||
|
||||
/// Auto mode treats a shorter press as a latched Toggle press and a longer
|
||||
@@ -21,6 +21,17 @@ const MODIFIER_ARBITRATION_GRACE: Duration = Duration::from_millis(150);
|
||||
/// awaits microphone/ASR setup. Keep a bounded set of those early Combined ids.
|
||||
const MAX_PENDING_COMBINED: usize = 64;
|
||||
|
||||
/// macOS emits Fn Auto/Toggle edges only after native release arbitration.
|
||||
pub(crate) fn modifier_arbitration_required(
|
||||
trigger: Option<HotkeyTrigger>,
|
||||
mode: HotkeyMode,
|
||||
) -> bool {
|
||||
trigger.is_some()
|
||||
&& !(cfg!(target_os = "macos")
|
||||
&& trigger == Some(HotkeyTrigger::Fn)
|
||||
&& matches!(mode, HotkeyMode::Auto | HotkeyMode::Toggle))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum HotkeyIntent {
|
||||
Noop,
|
||||
@@ -266,6 +277,27 @@ impl HotkeyInterpreter {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mac_fn_tap_arrives_pre_arbitrated_but_hold_and_other_modifiers_do_not() {
|
||||
let fn_tap_needs_grace = !cfg!(target_os = "macos");
|
||||
assert_eq!(
|
||||
modifier_arbitration_required(Some(HotkeyTrigger::Fn), HotkeyMode::Auto),
|
||||
fn_tap_needs_grace
|
||||
);
|
||||
assert_eq!(
|
||||
modifier_arbitration_required(Some(HotkeyTrigger::Fn), HotkeyMode::Toggle),
|
||||
fn_tap_needs_grace
|
||||
);
|
||||
assert!(modifier_arbitration_required(
|
||||
Some(HotkeyTrigger::Fn),
|
||||
HotkeyMode::Hold
|
||||
));
|
||||
assert!(modifier_arbitration_required(
|
||||
Some(HotkeyTrigger::LeftOption),
|
||||
HotkeyMode::Auto
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_before_pressed_cancels_only_that_generation() {
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -437,7 +437,9 @@ mod platform {
|
||||
update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyCombinedEdge, HotkeyEvent, Shared,
|
||||
StartupTx,
|
||||
};
|
||||
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger};
|
||||
use crate::types::{
|
||||
HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyMode, HotkeyTrigger,
|
||||
};
|
||||
|
||||
pub fn start_adapter(
|
||||
binding: HotkeyBinding,
|
||||
@@ -565,6 +567,9 @@ mod platform {
|
||||
const KEY_DOWN: CgEventType = 10;
|
||||
const KEY_UP: CgEventType = 11;
|
||||
const FLAGS_CHANGED: CgEventType = 12;
|
||||
/// Brightness / volume / keyboard-backlight and similar macOS function-layer
|
||||
/// actions are delivered as system-defined events instead of KEY_DOWN.
|
||||
const SYSTEM_DEFINED: CgEventType = 14;
|
||||
const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE;
|
||||
const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF;
|
||||
|
||||
@@ -577,6 +582,15 @@ mod platform {
|
||||
const FLAG_MASK_SECONDARY_FN: CgEventFlags = 0x0080_0000;
|
||||
|
||||
const ESC_KEYCODE: i64 = 53;
|
||||
// IOKit hidsystem/IOLLEvent.h + ev_keymap.h. systemDefined subtype 8 carries
|
||||
// auxiliary control keys in data1's high 16 bits; 0..=23 are the scanned
|
||||
// brightness / volume / media / illumination family. Globe/Menu is 25 and
|
||||
// must stay excluded, otherwise a plain Fn tap could cancel itself.
|
||||
const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8;
|
||||
const NX_NUM_SCANNED_SPECIAL_KEYS: u16 = 24;
|
||||
/// Auto / Toggle 下 Fn 是双用途键:短按用于听写,明显长按留给 macOS 功能层。
|
||||
/// 与 Auto 模式既有的 350ms 短按 / 长按分界保持一致。
|
||||
const FN_TAP_MAX_DURATION: std::time::Duration = std::time::Duration::from_millis(350);
|
||||
|
||||
type CgEventTapCallBack = extern "C" fn(
|
||||
proxy: *mut c_void,
|
||||
@@ -621,6 +635,8 @@ mod platform {
|
||||
cancel_tx: Sender<()>,
|
||||
/// 组合键撤销专用通道,见模块注释——不与 tx 挤同一条串行 bridge。
|
||||
combo_tx: Sender<HotkeyCombinedEdge>,
|
||||
/// Auto / Toggle 下 Fn 延迟到松开才决定是否派发:短按派发,长按或功能层组合丢弃。
|
||||
fn_pressed_at: parking_lot::Mutex<Option<std::time::Instant>>,
|
||||
/// 与 MacHotkeyAdapter 共享的 (tap, runloop) refs。tap re-enable on
|
||||
/// TAP_DISABLED_BY_TIMEOUT 走 handles.tap;adapter shutdown 也走这两个 lock。
|
||||
handles: Arc<MacShutdownHandles>,
|
||||
@@ -636,7 +652,10 @@ mod platform {
|
||||
combo_tx: Sender<HotkeyCombinedEdge>,
|
||||
status_tx: StartupTx<Arc<MacShutdownHandles>>,
|
||||
) {
|
||||
let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN) | (1u64 << KEY_UP);
|
||||
let mask: CgEventMask = (1u64 << FLAGS_CHANGED)
|
||||
| (1u64 << KEY_DOWN)
|
||||
| (1u64 << KEY_UP)
|
||||
| (1u64 << SYSTEM_DEFINED);
|
||||
let handles = Arc::new(MacShutdownHandles {
|
||||
tap: std::sync::Mutex::new(None),
|
||||
runloop: std::sync::Mutex::new(None),
|
||||
@@ -646,6 +665,7 @@ mod platform {
|
||||
tx,
|
||||
cancel_tx,
|
||||
combo_tx,
|
||||
fn_pressed_at: parking_lot::Mutex::new(None),
|
||||
handles: Arc::clone(&handles),
|
||||
}));
|
||||
|
||||
@@ -719,6 +739,13 @@ mod platform {
|
||||
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
|
||||
crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, false);
|
||||
}
|
||||
// Fn+亮度 / 音量 / 键盘背光等不会产生 KEY_DOWN,而是 systemDefined。
|
||||
// 只要 Fn 触发键正按住,就把它视为功能层组合,不能同时唤起听写。
|
||||
SYSTEM_DEFINED => {
|
||||
if let Some((subtype, data1)) = system_defined_event_payload(event) {
|
||||
note_fn_function_layer_event(ctx, subtype, data1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
event
|
||||
@@ -742,6 +769,9 @@ mod platform {
|
||||
ctx.shared.recording_fn_held.store(false, Ordering::SeqCst);
|
||||
log::info!("[hotkey] 录制态 Fn↑(松开)");
|
||||
}
|
||||
// 录制态只负责把 Fn 交给 ShortcutRecorder。不要同时更新听写触发 latch,
|
||||
// 否则前端保存 Fn、退出录制态后,同一次物理松手会被误当成一次听写短按。
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift 是翻译模式修饰键 — 与触发键的 keycode 检查独立,任何时刻按 Shift 都生效。
|
||||
@@ -788,17 +818,35 @@ mod platform {
|
||||
None,
|
||||
);
|
||||
|
||||
let trigger = ctx.shared.binding.read().trigger;
|
||||
if trigger == HotkeyTrigger::Custom {
|
||||
handle_dictation_trigger_flags_changed(ctx, keycode, flags, std::time::Instant::now());
|
||||
}
|
||||
|
||||
fn fn_uses_tap_only_semantics(trigger: HotkeyTrigger, mode: HotkeyMode) -> bool {
|
||||
trigger == HotkeyTrigger::Fn && matches!(mode, HotkeyMode::Auto | HotkeyMode::Toggle)
|
||||
}
|
||||
|
||||
/// 处理 modifier-only 听写触发键的 flagsChanged 边沿。
|
||||
///
|
||||
/// Fn 在 Auto / Toggle 下不能沿用其他修饰键的“按下即触发”:它同时是 macOS
|
||||
/// 功能层修饰键。这里先记住按下,等松开后确认是短按且未叠加功能键,才补发一对
|
||||
/// Pressed / Released。这样长按、Fn+F1/音量/亮度等系统操作从未启动听写,不会闪胶囊。
|
||||
fn handle_dictation_trigger_flags_changed(
|
||||
ctx: &CallbackContext,
|
||||
keycode: i64,
|
||||
flags: CgEventFlags,
|
||||
now: std::time::Instant,
|
||||
) {
|
||||
let (trigger, mode) = {
|
||||
let binding = ctx.shared.binding.read();
|
||||
(binding.trigger, binding.mode)
|
||||
};
|
||||
if trigger == HotkeyTrigger::Custom || keycode != trigger_to_keycode(trigger) {
|
||||
return;
|
||||
}
|
||||
let expected_keycode = trigger_to_keycode(trigger);
|
||||
if keycode != expected_keycode {
|
||||
return;
|
||||
}
|
||||
let mask = trigger_to_flag_mask(trigger);
|
||||
let is_active = (flags & mask) != 0;
|
||||
|
||||
let is_active = (flags & trigger_to_flag_mask(trigger)) != 0;
|
||||
let was_held = ctx.shared.trigger_held.load(Ordering::SeqCst);
|
||||
let tap_only_fn = fn_uses_tap_only_semantics(trigger, mode);
|
||||
|
||||
if is_active && !was_held {
|
||||
ctx.shared.trigger_held.store(true, Ordering::SeqCst);
|
||||
@@ -807,23 +855,46 @@ mod platform {
|
||||
.trigger_press_id
|
||||
.store(press_id, Ordering::SeqCst);
|
||||
ctx.shared.trigger_companion_seen.store(0, Ordering::SeqCst);
|
||||
send_or_log(
|
||||
&ctx.tx,
|
||||
HotkeyEvent::Pressed {
|
||||
at: std::time::Instant::now(),
|
||||
press_id,
|
||||
},
|
||||
);
|
||||
} else if !is_active && was_held {
|
||||
if tap_only_fn {
|
||||
*ctx.fn_pressed_at.lock() = Some(now);
|
||||
log::debug!("[hotkey] Fn↓ 等待松开后判定短按 / 功能层操作");
|
||||
} else {
|
||||
send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: now, press_id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !is_active && was_held {
|
||||
ctx.shared.trigger_held.store(false, Ordering::SeqCst);
|
||||
let press_id = ctx.shared.trigger_press_id.swap(0, Ordering::SeqCst);
|
||||
send_or_log(
|
||||
&ctx.tx,
|
||||
HotkeyEvent::Released {
|
||||
at: std::time::Instant::now(),
|
||||
press_id,
|
||||
},
|
||||
);
|
||||
if !tap_only_fn {
|
||||
send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id });
|
||||
return;
|
||||
}
|
||||
|
||||
let pressed_at = ctx.fn_pressed_at.lock().take();
|
||||
let companion_seen = press_id != 0
|
||||
&& ctx.shared.trigger_companion_seen.load(Ordering::SeqCst) == press_id;
|
||||
let held_for = pressed_at.map(|at| now.saturating_duration_since(at));
|
||||
if !companion_seen && held_for.is_some_and(|duration| duration < FN_TAP_MAX_DURATION) {
|
||||
let pressed_at = pressed_at.expect("checked above");
|
||||
send_or_log(
|
||||
&ctx.tx,
|
||||
HotkeyEvent::Pressed {
|
||||
at: pressed_at,
|
||||
press_id,
|
||||
},
|
||||
);
|
||||
send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id });
|
||||
} else {
|
||||
log::info!(
|
||||
"[hotkey] Fn 操作未触发听写(held_ms={}, companion_seen={companion_seen})",
|
||||
held_for.map(|duration| duration.as_millis()).unwrap_or(0)
|
||||
);
|
||||
}
|
||||
} else if !is_active && trigger == HotkeyTrigger::Fn {
|
||||
// 录制态 / binding 更新会主动重置 held latch;物理松手仍需清掉旧时间戳。
|
||||
ctx.fn_pressed_at.lock().take();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -868,6 +939,40 @@ mod platform {
|
||||
note_companion_key_down(ctx);
|
||||
}
|
||||
|
||||
fn system_defined_event_payload(event: CgEventRef) -> Option<(i16, isize)> {
|
||||
use objc2::msg_send;
|
||||
use objc2::runtime::{AnyClass, AnyObject};
|
||||
|
||||
if event.is_null() {
|
||||
return None;
|
||||
}
|
||||
let cls = AnyClass::get("NSEvent")?;
|
||||
let ns_event: *mut AnyObject =
|
||||
unsafe { msg_send![cls, eventWithCGEvent: event.cast::<c_void>()] };
|
||||
if ns_event.is_null() {
|
||||
return None;
|
||||
}
|
||||
let subtype: i16 = unsafe { msg_send![ns_event, subtype] };
|
||||
let data1: isize = unsafe { msg_send![ns_event, data1] };
|
||||
Some((subtype, data1))
|
||||
}
|
||||
|
||||
fn is_auxiliary_function_key_event(subtype: i16, data1: isize) -> bool {
|
||||
if subtype != NX_SUBTYPE_AUX_CONTROL_BUTTONS {
|
||||
return false;
|
||||
}
|
||||
let key_type = ((data1 as u64 >> 16) & 0xffff) as u16;
|
||||
key_type < NX_NUM_SCANNED_SPECIAL_KEYS
|
||||
}
|
||||
|
||||
fn note_fn_function_layer_event(ctx: &CallbackContext, subtype: i16, data1: isize) {
|
||||
if is_auxiliary_function_key_event(subtype, data1)
|
||||
&& ctx.shared.binding.read().trigger == HotkeyTrigger::Fn
|
||||
{
|
||||
note_companion_key_down(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// 触发键按住期间按下任意普通键 = 用户在打组合键(Option+任意字母/数字键、Option+Tab…),
|
||||
/// 不是想说话 —— 往组合键撤销通道发一次让上层撤销这次按下。
|
||||
///
|
||||
@@ -891,6 +996,16 @@ mod platform {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let deferred_fn = {
|
||||
let binding = ctx.shared.binding.read();
|
||||
fn_uses_tap_only_semantics(binding.trigger, binding.mode)
|
||||
};
|
||||
if deferred_fn {
|
||||
// Auto / Toggle 下 Fn 尚未向 coordinator 派发 Pressed;记住 companion 即可,
|
||||
// 松手时会整次丢弃。此时发送 abort 只会留下一个永远等不到 Pressed 的 pending id。
|
||||
log::info!("[hotkey] Fn 功能层组合按下 —— 本次短按候选作废");
|
||||
return;
|
||||
}
|
||||
log::info!("[hotkey] 触发键与其他键组合按下 —— 撤销本次触发");
|
||||
send_combo_abort_or_log(&ctx.combo_tx, press_id);
|
||||
}
|
||||
@@ -970,6 +1085,7 @@ mod platform {
|
||||
tx,
|
||||
cancel_tx,
|
||||
combo_tx,
|
||||
fn_pressed_at: parking_lot::Mutex::new(None),
|
||||
handles: Arc::new(MacShutdownHandles {
|
||||
tap: std::sync::Mutex::new(None),
|
||||
runloop: std::sync::Mutex::new(None),
|
||||
@@ -1083,6 +1199,141 @@ mod platform {
|
||||
// 撤销全程不碰 Pressed/Released 那条串行通道 —— 它此刻正卡在 begin_session 里。
|
||||
assert!(drain(&rx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_fn_auto_short_tap_dispatches_only_after_release() {
|
||||
let shared = shared(HotkeyTrigger::Fn);
|
||||
shared.binding.write().mode = HotkeyMode::Auto;
|
||||
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
|
||||
let pressed_at = std::time::Instant::now();
|
||||
|
||||
handle_dictation_trigger_flags_changed(
|
||||
&ctx,
|
||||
trigger_to_keycode(HotkeyTrigger::Fn),
|
||||
FLAG_MASK_SECONDARY_FN,
|
||||
pressed_at,
|
||||
);
|
||||
assert!(drain(&rx).is_empty(), "Fn down must stay a tap candidate");
|
||||
|
||||
let released_at = pressed_at + std::time::Duration::from_millis(120);
|
||||
handle_dictation_trigger_flags_changed(
|
||||
&ctx,
|
||||
trigger_to_keycode(HotkeyTrigger::Fn),
|
||||
0,
|
||||
released_at,
|
||||
);
|
||||
|
||||
let events = drain(&rx);
|
||||
assert!(matches!(events.as_slice(), (
|
||||
[HotkeyEvent::Pressed { press_id: left, .. }, HotkeyEvent::Released { press_id: right, .. }]
|
||||
) if *left != 0 && left == right));
|
||||
assert_eq!(drain_combo(&combo_rx), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_fn_auto_long_hold_is_reserved_for_system_functions() {
|
||||
let shared = shared(HotkeyTrigger::Fn);
|
||||
shared.binding.write().mode = HotkeyMode::Auto;
|
||||
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
|
||||
let pressed_at = std::time::Instant::now();
|
||||
|
||||
handle_dictation_trigger_flags_changed(
|
||||
&ctx,
|
||||
trigger_to_keycode(HotkeyTrigger::Fn),
|
||||
FLAG_MASK_SECONDARY_FN,
|
||||
pressed_at,
|
||||
);
|
||||
handle_dictation_trigger_flags_changed(
|
||||
&ctx,
|
||||
trigger_to_keycode(HotkeyTrigger::Fn),
|
||||
0,
|
||||
pressed_at + FN_TAP_MAX_DURATION,
|
||||
);
|
||||
|
||||
assert!(drain(&rx).is_empty());
|
||||
assert_eq!(drain_combo(&combo_rx), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_fn_function_layer_event_suppresses_tap_without_pending_abort() {
|
||||
let shared = shared(HotkeyTrigger::Fn);
|
||||
shared.binding.write().mode = HotkeyMode::Toggle;
|
||||
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
|
||||
let pressed_at = std::time::Instant::now();
|
||||
|
||||
handle_dictation_trigger_flags_changed(
|
||||
&ctx,
|
||||
trigger_to_keycode(HotkeyTrigger::Fn),
|
||||
FLAG_MASK_SECONDARY_FN,
|
||||
pressed_at,
|
||||
);
|
||||
// brightness-up (NX_KEYTYPE_BRIGHTNESS_UP=2) 的 subtype/data1 编码。
|
||||
note_fn_function_layer_event(
|
||||
&ctx,
|
||||
NX_SUBTYPE_AUX_CONTROL_BUTTONS,
|
||||
(2_i64 << 16) as isize,
|
||||
);
|
||||
handle_dictation_trigger_flags_changed(
|
||||
&ctx,
|
||||
trigger_to_keycode(HotkeyTrigger::Fn),
|
||||
0,
|
||||
pressed_at + std::time::Duration::from_millis(80),
|
||||
);
|
||||
|
||||
assert!(drain(&rx).is_empty());
|
||||
assert_eq!(drain_combo(&combo_rx), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_system_defined_event_does_not_abort_non_fn_dictation() {
|
||||
let shared = shared(HotkeyTrigger::LeftOption);
|
||||
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
|
||||
shared.trigger_press_id.store(7, Ordering::SeqCst);
|
||||
shared.trigger_held.store(true, Ordering::SeqCst);
|
||||
|
||||
note_fn_function_layer_event(
|
||||
&ctx,
|
||||
NX_SUBTYPE_AUX_CONTROL_BUTTONS,
|
||||
(2_i64 << 16) as isize,
|
||||
);
|
||||
|
||||
assert_eq!(shared.trigger_companion_seen.load(Ordering::SeqCst), 0);
|
||||
assert!(drain(&rx).is_empty());
|
||||
assert_eq!(drain_combo(&combo_rx), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_globe_system_event_is_not_mistaken_for_a_function_layer_key() {
|
||||
assert!(!is_auxiliary_function_key_event(
|
||||
NX_SUBTYPE_AUX_CONTROL_BUTTONS,
|
||||
(25_i64 << 16) as isize,
|
||||
));
|
||||
assert!(!is_auxiliary_function_key_event(0, (2_i64 << 16) as isize));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_fn_hold_mode_keeps_press_to_talk_semantics() {
|
||||
let shared = shared(HotkeyTrigger::Fn);
|
||||
shared.binding.write().mode = HotkeyMode::Hold;
|
||||
let (ctx, rx) = callback_context(Arc::clone(&shared));
|
||||
let pressed_at = std::time::Instant::now();
|
||||
|
||||
handle_dictation_trigger_flags_changed(
|
||||
&ctx,
|
||||
trigger_to_keycode(HotkeyTrigger::Fn),
|
||||
FLAG_MASK_SECONDARY_FN,
|
||||
pressed_at,
|
||||
);
|
||||
assert_eq!(edge_names(drain(&rx)), vec!["pressed"]);
|
||||
|
||||
handle_dictation_trigger_flags_changed(
|
||||
&ctx,
|
||||
trigger_to_keycode(HotkeyTrigger::Fn),
|
||||
0,
|
||||
pressed_at + std::time::Duration::from_secs(1),
|
||||
);
|
||||
assert_eq!(edge_names(drain(&rx)), vec!["released"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user