From 6a7cd5f4cd1170025b73e210d3582a446d72780f Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 8 Sep 2026 01:39:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(macos):=20=E9=81=BF=E5=85=8D=20Fn=20?= =?UTF-8?q?=E9=95=BF=E6=8C=89=E4=B8=8E=E5=8A=9F=E8=83=BD=E9=94=AE=E8=AF=AF?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=90=AC=E5=86=99=20(#1008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto 和 Toggle 模式只在 Fn 短按松开且未叠加功能层操作时触发听写;长按及亮度、音量等 systemDefined 事件留给 macOS。 保留显式 Hold 模式的按住说话语义,并跳过 Fn 短按释放后的冗余仲裁延迟。 Co-authored-by: Chris233 --- .../app/crates/openless-core/src/api.rs | 6 +- .../openless-core/src/hotkey_interpreter.rs | 34 +- openless-all/app/src-tauri/src/hotkey.rs | 301 ++++++++++++++++-- 3 files changed, 313 insertions(+), 28 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a0baeb3d..75f5f7f6 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -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 diff --git a/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs b/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs index 54f2be3f..f5445000 100644 --- a/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs +++ b/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs @@ -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, + 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(); diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 5e67172d..8b57097b 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -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, + /// Auto / Toggle 下 Fn 延迟到松开才决定是否派发:短按派发,长按或功能层组合丢弃。 + fn_pressed_at: parking_lot::Mutex>, /// 与 MacHotkeyAdapter 共享的 (tap, runloop) refs。tap re-enable on /// TAP_DISABLED_BY_TIMEOUT 走 handles.tap;adapter shutdown 也走这两个 lock。 handles: Arc, @@ -636,7 +652,10 @@ mod platform { combo_tx: Sender, status_tx: StartupTx>, ) { - 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::()] }; + 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"]); + } } } From af9eaa3e16e664173186b2481eb824a20e402e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=A9=E5=BD=AC=28=5E-=5E=29?= <146098469+HKLHaoBin@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:47:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(asr):=20=E7=99=BE=E7=82=BC=20WebSocket?= =?UTF-8?q?=20=E4=BC=98=E5=85=88=E8=B5=B0=20IPv4=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=BC=80=E6=9C=AC=E6=9C=BA=20IPv6=20=E9=BB=91=E6=B4=9E=20(#103?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(asr): 百炼 WebSocket 优先走 IPv4,避开本机 IPv6 黑洞 dashscope 的 AAAA 在部分 Windows 双栈网上 TCP 443 会挂死,connect_async 先试 IPv6 会卡满 5 秒超时,听写进不了 Listening。 Co-authored-by: Cursor * fix(asr): preserve websocket ports and retry handshake addresses --------- Co-authored-by: Cursor Co-authored-by: Chris233 --- .../crates/openless-core/src/asr/bailian.rs | 188 +++++++++++++++++- 1 file changed, 185 insertions(+), 3 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/asr/bailian.rs b/openless-all/app/crates/openless-core/src/asr/bailian.rs index d7f399bc..9e2850e3 100644 --- a/openless-all/app/crates/openless-core/src/asr/bailian.rs +++ b/openless-all/app/crates/openless-core/src/asr/bailian.rs @@ -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 { + 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) -> Vec { + 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, +) -> 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::>(); + connect_ws_to_addrs(request, order_connect_addrs(addrs)).await +} + type WsStream = WebSocketStream>; type WsSink = futures_util::stream::SplitSink; type SharedWriter = Arc>>; @@ -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]