diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 80e2e92d..fdde3bfe 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -155,7 +155,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -350,6 +350,60 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.21.7" @@ -1276,7 +1330,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1523,7 +1577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1985,6 +2039,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "gio" version = "0.18.4" @@ -2291,6 +2357,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -2305,6 +2377,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2932,6 +3005,17 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "local-ip-address" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2994,6 +3078,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.0" @@ -3188,6 +3278,35 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3702,6 +3821,7 @@ version = "1.3.6-3" dependencies = [ "anyhow", "arboard", + "axum", "base64 0.22.1", "block2 0.5.1", "bytes", @@ -3720,8 +3840,11 @@ dependencies = [ "getrandom 0.3.4", "global-hotkey", "hmac", + "hyper", + "hyper-util", "keyring", "libc", + "local-ip-address", "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -3729,7 +3852,9 @@ dependencies = [ "once_cell", "parking_lot", "raw-window-handle", + "rcgen", "reqwest 0.12.28", + "rustls", "serde", "serde_json", "sha2", @@ -3746,7 +3871,9 @@ dependencies = [ "tauri-plugin-updater", "thiserror 1.0.69", "tokio", + "tokio-rustls", "tokio-tungstenite", + "tower", "url", "uuid", "window-vibrancy 0.7.1", @@ -3821,7 +3948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3908,6 +4035,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4210,6 +4347,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -4312,7 +4471,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -4410,6 +4569,19 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4688,7 +4860,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4746,7 +4918,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5873,7 +6045,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6989,7 +7161,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7916,6 +8088,15 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index d83df290..3dd38f47 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -62,6 +62,14 @@ global-hotkey = "0.6" cpal = "0.15" enigo = "0.2" arboard = { version = "3", features = ["wayland-data-control"] } +rcgen = "^0.13" +local-ip-address = "^0.6" +rustls = { version = "^0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +tokio-rustls = { version = "^0.26", default-features = false, features = ["ring", "tls12", "logging"] } +axum = { version = "^0.7", default-features = false, features = ["ws", "http1", "tokio", "query"] } +hyper = { version = "^1", features = ["server", "http1"] } +hyper-util = { version = "^0.1", features = ["tokio", "server-auto", "server", "http1"] } +tower = { version = "^0.5", features = ["util"] } [target.'cfg(target_os = "macos")'.dependencies.keyring] version = "3.6.3" diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index ad003964..53ebd656 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -66,6 +66,7 @@ mod misc; mod permissions_cmds; mod providers; mod qa; +mod remote_input; mod settings; mod sherpa_asr; mod style_packs; @@ -83,6 +84,7 @@ pub use misc::*; pub use permissions_cmds::*; pub use providers::*; pub use qa::*; +pub use remote_input::*; pub use settings::*; // sherpa_onnx_asr_* 命令整组 `#[cfg(target_os = "windows")]`(见 lib.rs 的 // generate_handler! 清单)。非 Windows 平台这组 glob 重导出无人引用,会触发 diff --git a/openless-all/app/src-tauri/src/commands/remote_input.rs b/openless-all/app/src-tauri/src/commands/remote_input.rs new file mode 100644 index 00000000..dee8eaed --- /dev/null +++ b/openless-all/app/src-tauri/src/commands/remote_input.rs @@ -0,0 +1,34 @@ +//! 远程输入(局域网手机录音)命令面。 +//! +//! 手机在同一局域网用浏览器打开 `https://:` 的 H5 录音页,经 +//! WSS 把 16kHz PCM 推回 PC,由 Coordinator 当作"手机麦克风"喂进现有听写 +//! 管线。本模块只暴露设置页需要的状态查询 / PIN 重置 / 语言同步命令; +//! 服务启停由 set_settings 里的 prefs diff 触发(见 settings.rs)。 + +use super::*; + +#[tauri::command] +pub fn get_remote_input_status( + coord: CoordinatorState<'_>, +) -> crate::remote_server::RemoteInputStatus { + coord.remote_input_status() +} + +#[tauri::command] +pub fn list_local_ips() -> Vec { + crate::remote_server::local_lan_ipv4s() + .iter() + .map(|ip| ip.to_string()) + .collect() +} + +#[tauri::command] +pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> String { + coord.regenerate_remote_pin() +} + +/// 同步 PC 端界面语言到远程输入服务,H5 录音页据此显示对应语言。 +#[tauri::command] +pub fn set_remote_locale(coord: CoordinatorState<'_>, locale: String) { + coord.set_remote_locale(locale); +} diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 9df40bbb..56b01698 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -176,6 +176,8 @@ pub fn set_settings( tray_microphones: State<'_, TrayMicrophoneMenuState>, mut prefs: UserPreferences, ) -> Result<(), String> { + // 捕获旧值用于远程输入服务的 diff(persist 后端口/开关变化时启停/重启)。 + let remote_prev = coord.prefs().get(); let packs = coord.style_packs().list().map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, @@ -202,6 +204,12 @@ pub fn set_settings( // 但函数签名保留 State 入参,以便 Tauri 在调用前注入。 let _ = tray_microphones; let _ = app.emit("prefs:changed", &prefs); + // 远程输入:开关 / 端口变化时启停或重启服务(PIN 变化走 regenerate_remote_pin 命令)。 + if remote_prev.remote_input_enabled != prefs.remote_input_enabled + || remote_prev.remote_input_port != prefs.remote_input_port + { + coord.refresh_remote_server(); + } Ok(()) } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 1579dc3f..6c19b141 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -9,7 +9,7 @@ //! insertion, persists history, emits `capsule:state` events to the capsule //! window. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::time::Instant; @@ -248,6 +248,29 @@ pub(crate) struct Inner { /// supervisor 线程,但 integration test 和未来 RunEvent::Exit 钩子需要这条 /// 显式退出路径。审计 3.1.2。 shutdown: AtomicBool, + // ── 远程输入(局域网手机录音)───────────────────────────── + /// true = 当前 begin_session 应跳过本地 cpal,改用手机经 WS 推来的 PCM。 + /// 由 Coordinator::start_remote_dictation 在 begin_session 前置位。 + remote_source_active: AtomicBool, + /// 远程会话的音频入口:begin_session 把组装好的 AudioConsumer 存这里, + /// WS server 收到手机 PCM 时取出 consume_pcm_chunk。等价于本地 cpal 喂 recorder。 + remote_audio_sink: Mutex>>, + /// 远程输入 HTTPS+WS 服务句柄。None = 未启动。 + remote_server: Mutex>, + /// refresh_remote_server 的代数:每次调用自增,spawn 出的任务持自己的代数, + /// 持锁后发现已有更新代排队则直接让位(连点开关/连改端口只跑最后一轮)。 + remote_refresh_gen: AtomicU64, + /// 串行化「停旧 → 启新」全流程的异步锁。无串行化时两轮 refresh 可交错: + /// 后到者 take 到 None 跳过关停、去 bind 旧服务尚未释放的端口 → 误报 port-in-use。 + remote_refresh_lock: tokio::sync::Mutex<()>, + /// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。 + remote_pin: Mutex>, + /// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步, + /// H5 录音页据此渲染对应语言。进程内镜像,不持久化(前端会在启动/切换时重新下发)。 + remote_locale: Mutex, + /// 远程「仅回传」开关:true = 手机端关掉了「电脑落字」,本次远程听写不插入到电脑光标, + /// 只把最终文字回传给手机(见 dictation 落字处 + remote:result)。默认 false(照常落字)。 + remote_no_insert: AtomicBool, /// Less Computer 连续对话:true=浮窗里已有进行中的会话,下一轮 `claude --continue` 续上下文; /// 关闭浮窗(dismiss)复位为 false,下次说话开新会话。 less_computer_conversation: AtomicBool, @@ -323,6 +346,14 @@ impl Coordinator { qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), shutdown: AtomicBool::new(false), + remote_source_active: AtomicBool::new(false), + remote_audio_sink: Mutex::new(None), + remote_server: Mutex::new(None), + remote_refresh_gen: AtomicU64::new(0), + remote_refresh_lock: tokio::sync::Mutex::new(()), + remote_pin: Mutex::new(None), + remote_locale: Mutex::new(String::from("zh-CN")), + remote_no_insert: AtomicBool::new(false), less_computer_conversation: AtomicBool::new(false), }), } @@ -391,6 +422,14 @@ impl Coordinator { foundry_local_runtime, sherpa_onnx_runtime, shutdown: AtomicBool::new(false), + remote_source_active: AtomicBool::new(false), + remote_audio_sink: Mutex::new(None), + remote_server: Mutex::new(None), + remote_refresh_gen: AtomicU64::new(0), + remote_refresh_lock: tokio::sync::Mutex::new(()), + remote_pin: Mutex::new(None), + remote_locale: Mutex::new(String::from("zh-CN")), + remote_no_insert: AtomicBool::new(false), less_computer_conversation: AtomicBool::new(false), }), } @@ -960,6 +999,201 @@ impl Coordinator { cancel_session(&self.inner); } + // ───────────────────────── 远程输入(局域网手机录音)───────────────────────── + // 把"远程输入"实现为一次普通听写会话,只是音频源换成手机经 WS 推来的 PCM: + // 完整复用 begin_session / end_session / cancel_session(一行不改)。本地与远程 + // 共用 inner.state,天然互斥。详见 dictation::start_recorder_for_starting 的远程分支。 + + /// 手机点"开始录音"。本地听写正在进行(phase != Idle)则拒绝并回 "busy"; + /// 否则置位 remote 标志后走 begin_session(内部跳过 cpal,把 consumer 存进 sink)。 + /// 设置远程「仅回传」开关(手机端「电脑落字」开关的反值)。true = 不落字、只回传。 + pub fn set_remote_no_insert(&self, no_insert: bool) { + self.inner + .remote_no_insert + .store(no_insert, Ordering::SeqCst); + } + + pub async fn start_remote_dictation(&self) -> Result<(), String> { + // busy 判定与 remote_source_active 置位都在 begin_session_with_source 的 + // state 临界区内原子完成(与本地热键的 begin_session_state 同构)。之前是 + // 锁外预检查 + 锁外置位,竞态输家会把残留标志泄给抢先启动的本地会话。 + let r = begin_session_with_source(&self.inner, true).await; + if let Err(e) = &r { + // busy = 标志从未置位,不能清——清了会破坏正在进行的远程会话 + // (手机重复点「开始」就会走到这里)。置位之后的失败(ASR 凭据等)才回滚。 + if e != REMOTE_BUSY { + self.clear_remote_source(); + } + } + r + } + + /// WS 每收到一帧二进制 PCM 调一次。仅 Starting/Listening 阶段转发给已组装的 + /// consumer(流式 ASR 的 DeferredAsrBridge 在 attach 前自缓冲,不丢早期音频)。 + pub fn feed_remote_pcm(&self, pcm: &[u8]) { + { + let phase = self.inner.state.lock().phase; + if phase != SessionPhase::Listening && phase != SessionPhase::Starting { + return; + } + } + let sink = self.inner.remote_audio_sink.lock().clone(); + if let Some(consumer) = sink { + consumer.consume_pcm_chunk(pcm); + } + } + + /// 手机点"停止"。Starting 阶段记 pending_stop(等启动完成自动收尾);否则走 + /// end_session(转写→润色→光标落字,与本地一致)。 + /// 远程标志的清理不在这里做:end_session 内的 RemoteFlagsJanitor 在会话真正 + /// 回到 Idle 时统一清。这里清会在 double-stop(第二次调用对 Processing 中的 + /// 在飞 end_session 早退后)把标志过早清掉——在飞调用读到 false 后, + /// 「仅回传」开关失效(文字落到 PC)且 remote:result 不再回传手机。 + pub async fn stop_remote_dictation(&self) -> Result<(), String> { + // 守卫:当前会话不是远程发起的则忽略。否则手机的 stop 会终止 PC 用户 + // 正在进行的本地听写(stop/cancel 方向没有 busy 那样的天然互斥)。 + if !self.inner.remote_source_active.load(Ordering::SeqCst) { + return Ok(()); + } + if self.inner.state.lock().phase == SessionPhase::Starting { + request_stop_during_starting(&self.inner, "remote stop"); + return Ok(()); + } + end_session(&self.inner).await + } + + /// 手机断连 / 点取消:丢弃本次,不落字。 + /// 手机锁屏/切后台/Wi-Fi 抖动都会触发 WS 断连进而走到这里——守卫确保只 + /// 取消远程发起的会话,不误杀 PC 用户正在进行的本地听写。 + pub fn cancel_remote_dictation(&self) { + if !self.inner.remote_source_active.load(Ordering::SeqCst) { + return; + } + cancel_session(&self.inner); + self.clear_remote_source(); + } + + fn clear_remote_source(&self) { + clear_remote_source_flags(&self.inner); + } + + /// 当前远程输入运行态(供命令/前端查询)。 + pub fn remote_input_status(&self) -> crate::remote_server::RemoteInputStatus { + let prefs = self.inner.prefs.get(); + let handle = self.inner.remote_server.lock(); + let running = handle.is_some(); + let port = handle + .as_ref() + .map(|h| h.bound_port) + .unwrap_or(prefs.remote_input_port); + let pin = self.inner.remote_pin.lock().clone().unwrap_or_default(); + let urls = if running { + crate::remote_server::access_urls(port) + } else { + Vec::new() + }; + crate::remote_server::RemoteInputStatus { + running, + port, + pin, + urls, + } + } + + /// 重新生成 6 位配对码并重启服务。 + pub fn regenerate_remote_pin(self: &Arc) -> String { + let pin = crate::remote_server::generate_pin(); + *self.inner.remote_pin.lock() = Some(pin.clone()); + // 写盘持久化,否则下次启动会读回旧的持久化码、把这次重置覆盖掉。 + if let Some(app) = self.inner.app.lock().clone() { + crate::remote_server::save_pin(&app, &pin); + } + self.refresh_remote_server(); + pin + } + + /// 同步 PC 端界面语言(前端切换语言时调用)。H5 录音页据此选择显示语言。 + /// 仅接受受支持的白名单值,非法输入忽略(值会注入到 H5 的 lang,需防注入)。 + pub fn set_remote_locale(&self, locale: String) { + const SUPPORTED: [&str; 5] = ["zh-CN", "zh-TW", "en", "ja", "ko"]; + if SUPPORTED.contains(&locale.as_str()) { + *self.inner.remote_locale.lock() = locale; + } + } + + /// 当前 PC 端界面语言(供 H5 首页注入 lang)。 + pub fn remote_locale(&self) -> String { + self.inner.remote_locale.lock().clone() + } + + /// 按 prefs 启停 / 重启远程输入服务。在 setup 与 prefs 变更(端口/开关)时调用。 + pub fn refresh_remote_server(self: &Arc) { + let coord = Arc::clone(self); + let gen = self.inner.remote_refresh_gen.fetch_add(1, Ordering::SeqCst) + 1; + tauri::async_runtime::spawn(async move { + // 串行化整个「停旧 → 启新」:并发的两轮 refresh 交错时,后到者会 take 到 + // None 跳过关停、去 bind 旧服务还没释放的端口 → 误报 port-in-use。 + let _serial = coord.inner.remote_refresh_lock.lock().await; + // 已有更新代排队(用户连点开关/连改端口):本代直接让位,只跑最后一轮。 + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { + return; + } + // 先停旧(优雅关停) + let old = coord.inner.remote_server.lock().take(); + if let Some(handle) = old { + handle.shutdown().await; + } + let prefs = coord.inner.prefs.get(); + let app = coord.inner.app.lock().clone(); + if !prefs.remote_input_enabled { + if let Some(app) = &app { + let _ = + app.emit("remote-input:running", serde_json::json!({"running": false})); + } + return; + } + let Some(app) = app else { + return; + }; + // PIN:进程内 remote_pin 缺失时从磁盘读持久化的(没有才新生成并写盘)—— + // 否则每次重启配对码都变,用户得反复找新码(这正是"配对码错误"的根因)。 + let pin = { + let mut guard = coord.inner.remote_pin.lock(); + if guard.is_none() { + *guard = Some(crate::remote_server::load_or_create_pin(&app)); + } + guard.clone().unwrap_or_default() + }; + log::info!("[remote-input] 当前配对码 = {pin}(在手机上输入这个)"); + let port = prefs.remote_input_port; + match crate::remote_server::start(crate::remote_server::RemoteServerConfig { + port, + pin: pin.clone(), + coordinator: Arc::clone(&coord), + app: app.clone(), + }) + .await + { + Ok(handle) => { + let urls = crate::remote_server::access_urls(port); + *coord.inner.remote_server.lock() = Some(handle); + let _ = app.emit( + "remote-input:running", + serde_json::json!({"running": true, "port": port, "urls": urls, "pin": pin}), + ); + log::info!("[remote-input] server started on port {port}"); + } + Err(e) => { + let _ = app.emit( + "remote-input:error", + serde_json::json!({"reason": e, "port": port}), + ); + log::error!("[remote-input] server start failed: {e}"); + } + } + }); + } + /// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。 /// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start, /// Listening → stop。可用于桌面快捷键 → CLI 转发的备用触发路径。 @@ -1149,3 +1383,13 @@ fn set_phase_idle_if_session_matches(inner: &Arc, session_id: SessionId) state.phase = SessionPhase::Idle; } } + +/// 清远程音频源标志(幂等)。必须在远程会话生命周期的**每个**终结点调用: +/// 残留的 `remote_source_active=true` 会让下一次本地听写误走远程分支 +/// (跳过 cpal、挂上 sink 等手机 PCM),本地录音从此失效。 +/// 终结点:stop/cancel_remote_dictation、start 失败回滚、cancel_session、 +/// pending_stop 的延迟 end_session(finish_starting_session)。 +pub(crate) fn clear_remote_source_flags(inner: &Inner) { + inner.remote_source_active.store(false, Ordering::SeqCst); + *inner.remote_audio_sink.lock() = None; +} diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs index 09439d9b..7becc51a 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs @@ -6,7 +6,27 @@ use crate::correction::apply_correction_rules; use super::resources::*; use super::*; +/// 远程标志清道夫。end_session 的终结路径有十余处(正常收尾、ASR 失败/超时、 +/// 空转写、cancel 丢弃……),任何一处漏清 remote_source_active 都会把下一次本地 +/// 听写错引到远程分支(跳过 cpal、永远等不到手机 PCM)。逐点补调用维护不动, +/// 改用 Drop 统一兜底:end_session 以任何方式退出时,若会话已回 Idle 则清远程 +/// 标志(本地会话下是 no-op)。phase 非 Idle 时不清——比如 double-stop 的第二次 +/// 调用对着 Processing 中的在飞 end_session 早退,此刻清会让在飞调用读到 false: +/// 「仅回传」开关失效、remote:result 不回传。 +struct RemoteFlagsJanitor<'a> { + inner: &'a Arc, +} + +impl Drop for RemoteFlagsJanitor<'_> { + fn drop(&mut self) { + if self.inner.state.lock().phase == SessionPhase::Idle { + clear_remote_source_flags(self.inner); + } + } +} + pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { + let _remote_janitor = RemoteFlagsJanitor { inner }; let current_session_id = { let mut state = inner.state.lock(); let Some(session_id) = start_processing_if_listening(&mut state) else { @@ -526,13 +546,18 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { }; // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 - let streaming_eligible = streaming_insert_eligible( - prefs.streaming_insert, - translation_active, - mode, - raw_uses_llm, - chinese_script_preference, - ); + // 远程「仅回传」模式:手机端关掉了「电脑落字」开关 —— 禁用流式插入(否则会边润色边把字 + // 落到电脑),改走一次性路径,最后在插入处统一跳过,只把文字回传给手机。 + let remote_no_insert = inner.remote_source_active.load(Ordering::SeqCst) + && inner.remote_no_insert.load(Ordering::SeqCst); + let streaming_eligible = !remote_no_insert + && streaming_insert_eligible( + prefs.streaming_insert, + translation_active, + mode, + raw_uses_llm, + chinese_script_preference, + ); log::info!( "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" ); @@ -641,7 +666,14 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; let paste_shortcut = prefs.paste_shortcut; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 - let status = if already_streamed { + let status = if remote_no_insert { + // 仅回传模式:不碰光标/剪贴板,电脑端无感;文字稍后经 remote:result 发给手机。 + log::info!( + "[coord] remote no-insert: skip insertion, relay {} chars to phone only", + polished.chars().count() + ); + InsertStatus::Inserted + } else if already_streamed { log::info!( "[coord] insertion skipped: {} chars already streamed via unicode_keystroke (polish_error={:?})", polished.chars().count(), @@ -768,6 +800,14 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some(inserted_chars), ); + // 远程会话:把最终文字回传给手机 H5。PC 胶囊只显示字数,但手机端用户看不到电脑 + // 屏幕,需要直接看到这次落下的文字内容(remote_server 转发为 type=result)。 + if inner.remote_source_active.load(Ordering::SeqCst) { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("remote:result", polished.clone()); + } + } + { let mut state = inner.state.lock(); state.phase = SessionPhase::Idle; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs index 480883af..c99ae3b1 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs @@ -18,13 +18,36 @@ pub(crate) fn request_stop_during_starting(inner: &Arc, reason: &str) { } pub(crate) async fn begin_session(inner: &Arc) -> Result<(), String> { + begin_session_with_source(inner, false).await +} + +/// 远程会话被拒(busy)时的错误值。remote_server 据此给手机回 busy 提示; +/// start_remote_dictation 据此区分「未置位无需回滚」与「置位后失败需回滚」。 +pub(crate) const REMOTE_BUSY: &str = "busy"; + +/// `remote=true`:busy 时返回 `Err(REMOTE_BUSY)`(手机需要回执,不能像本地热键 +/// 那样静默吞掉);并且 `remote_source_active` 的置位发生在 Idle→Starting 转移的 +/// **同一临界区**内。之前是「预检查 → 锁外置位 → begin_session」三段式,本地热键 +/// 会话在窗口内抢先启动会读到残留的远程标志,被劫持进远程分支(不开麦克风、 +/// 听写全文经 remote:result 泄给手机)。 +pub(crate) async fn begin_session_with_source( + inner: &Arc, + remote: bool, +) -> Result<(), String> { let current_session_id = { let mut state = inner.state.lock(); let Some(session_id) = begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app()) else { - return Ok(()); + return if remote { + Err(REMOTE_BUSY.into()) + } else { + Ok(()) + }; }; + if remote { + inner.remote_source_active.store(true, Ordering::SeqCst); + } if let Some(label) = state.front_app.as_deref() { log::info!("[coord] front_app captured: {label}"); } @@ -66,20 +89,27 @@ pub(crate) async fn begin_session(inner: &Arc) -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); - 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, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(message); + // 远程输入的音频来自手机,电脑不开本地麦克风,跳过电脑麦克风权限闸门 + // (否则电脑麦克风为 Denied 时会把远程会话也挡住)。 + if !inner + .remote_source_active + .load(std::sync::atomic::Ordering::Relaxed) + { + 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, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(message); + } } // 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在 @@ -408,6 +438,22 @@ pub(crate) async fn start_recorder_for_starting( active_asr: &str, consumer: Arc, ) -> Result<(), String> { + // 远程输入:不开本地 cpal,把组装好的 consumer 交给 WS server 喂手机 PCM。 + // 其余(Starting→Listening、pending_stop、cancel race、end_session 收尾)与本地 + // 听写完全一致。详见 Coordinator::start_remote_dictation。 + if inner + .remote_source_active + .load(std::sync::atomic::Ordering::Relaxed) + { + *inner.remote_audio_sink.lock() = Some(Arc::clone(&consumer)); + inner + .audio_archive_active + .store(false, std::sync::atomic::Ordering::Relaxed); + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + log::info!("[coord] remote audio source active (asr={active_asr}, session={session_id})"); + return Ok(()); + } + let inner_for_level = Arc::clone(inner); // 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS // transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔), @@ -608,6 +654,7 @@ pub(crate) async fn finish_starting_session(inner: &Arc, session_id: Sess log::info!("[coord] session started"); if matches!(outcome, BeginOutcome::PendingStop) { log::info!("[coord] applying pending_stop edge → end_session immediately"); + // 远程标志的清理由 end_session 内的 RemoteFlagsJanitor 统一兜底。 let _ = end_session(inner).await; } } @@ -650,6 +697,9 @@ pub(crate) fn cancel_session(inner: &Arc) { stop_recorder_for_session(inner, decision.session_id); cancel_asr_for_session(inner, decision.session_id); + // 远程会话被取消(含本地 Esc / 错误路径触发的 cancel)时同步清远程标志, + // 避免 remote_source_active 残留把下一次本地听写错引到远程分支。 + clear_remote_source_flags(inner); restore_prepared_windows_ime_session(inner, decision.session_id); // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; // 其他阶段直接转 Idle。 diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 567d5a8b..3cf4a4a1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ mod persistence; mod polish; mod qa_hotkey; mod recorder; +mod remote_server; mod selection; mod shortcut_binding; mod types; @@ -327,6 +328,8 @@ pub fn run() { let app_handle = app.handle().clone(); coordinator.bind_app(app_handle); coordinator.start_hotkey_listener(); + // 远程输入:按 prefs 启动局域网录音服务(未启用时为 no-op)。 + coordinator.refresh_remote_server(); // QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS). // Start those after RunEvent::Ready, when the AppKit event loop is live. if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { @@ -347,6 +350,10 @@ pub fn run() { commands::get_settings, commands::get_default_style_system_prompts, commands::set_settings, + commands::get_remote_input_status, + commands::list_local_ips, + commands::regenerate_remote_pin, + commands::set_remote_locale, commands::get_update_channel, commands::set_update_channel, commands::fetch_latest_beta_release, diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js new file mode 100644 index 00000000..957ef25a --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -0,0 +1,1361 @@ +/* ============================================================ + * OpenLess 远程输入 — 手机端录音页 + * 纯静态,无外部依赖。通过 WSS 把 16kHz/单声道/16bit LE PCM + * 实时推送给 PC 端 Rust 服务。 + * + * 显示语言跟随 PC 端界面语言:Rust 在返回首页时把 window.__OL_LANG__ + * 注入成 PC 当前 locale(前端切换语言时经 set_remote_locale 命令同步)。 + * ========================================================== */ +(function () { + 'use strict'; + + // ============================================================ + // i18n —— 文案字典(与 PC 端 src/i18n 对齐的 5 种语言) + // ============================================================ + var I18N = { + 'zh-CN': { + title: 'OpenLess 远程输入', + brandTitle: 'OpenLess 远程输入', + brandSub: '在手机上录音,实时输入到电脑', + pinFieldLabel: '配对码(电脑上显示的 6 位数字)', + btnConnect: '连接', + btnConnecting: '连接中…', + modeToggle: '点按', + insertLabel: '电脑落字', + modeHold: '按住', + offlineTitle: '连接已断开', + offlineSub: '与电脑的连接已中断。', + btnReconnect: '重新连接', + certTip: '首次访问浏览器会提示“连接不安全”(本地自签名证书)。Android Chrome:点“高级”→“继续前往”;iOS Safari:点“显示详情”→“访问此网站”。', + tipToggle: '点击大按钮开始录音,再次点击结束并识别。', + tipHold: '按住大按钮说话,松开结束并识别。', + labelToggleIdle: '点击开始', + labelToggleRec: '点击结束', + labelHoldIdle: '按住说话', + labelHoldRec: '松开结束', + ready: '准备就绪', + preparingMic: '正在准备麦克风…', + statusRecording: '🎤 录音中', + statusTranscribing: '🔄 识别中', + statusPolishing: '✨ 润色中', + statusDone: '✅ 已输入 {n} 字', + cancelled: '已取消', + connLost: '连接已断开', + errPinFormat: '请输入 6 位数字配对码。', + errPinWrong: '配对码错误,请重试。', + errPinLocked: '配对已锁定,请在电脑上重新生成配对码。', + errConnFail: '连接失败。多半是手机未信任电脑证书,请先信任证书后重试。', + errConnCreate: '无法建立连接,请检查网络。', + errConnTimeout: '连接超时。多半是手机未信任电脑证书,请按下方说明信任后重试。', + busy: '电脑忙:{reason}', + busyDefault: '请稍候', + micDenied: '❌ 麦克风权限被拒绝,请在浏览器设置中允许。', + micNotFound: '❌ 未找到可用麦克风。', + micBusy: '❌ 麦克风被其他应用占用。', + micTimeout: '❌ 麦克风准备超时,请重试。', + micUnknown: '❌ 无法启动录音{name}。', + errGeneric: '发生错误', + helpTitle: '连不上?多半是手机没信任证书', + helpAndroid: '① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。', + helpIos: '② iOS Safari:用无痕模式打开本页,出现“不安全”提示时点“显示详情 → 访问此网站”,再输入配对码连接(无需安装证书)。', + helpDownloadCert: '⬇ 下载并安装证书', + helpCopyLink: '⧉ 复制链接', + helpCopied: '已复制 ✓', + copy: '复制', + copied: '已复制 ✓', + }, + 'zh-TW': { + title: 'OpenLess 遠端輸入', + brandTitle: 'OpenLess 遠端輸入', + brandSub: '在手機上錄音,即時輸入到電腦', + pinFieldLabel: '配對碼(電腦上顯示的 6 位數字)', + btnConnect: '連線', + btnConnecting: '連線中…', + modeToggle: '點按', + insertLabel: '電腦落字', + modeHold: '按住', + offlineTitle: '連線已中斷', + offlineSub: '與電腦的連線已中斷。', + btnReconnect: '重新連線', + certTip: '首次造訪瀏覽器會提示「連線不安全」(本機自簽憑證)。Android Chrome:點「進階」→「繼續前往」;iOS Safari:點「顯示詳細資訊」→「瀏覽此網站」。', + tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。', + tipHold: '按住大按鈕說話,放開結束並辨識。', + labelToggleIdle: '點擊開始', + labelToggleRec: '點擊結束', + labelHoldIdle: '按住說話', + labelHoldRec: '放開結束', + ready: '準備就緒', + preparingMic: '正在準備麥克風…', + statusRecording: '🎤 錄音中', + statusTranscribing: '🔄 辨識中', + statusPolishing: '✨ 潤飾中', + statusDone: '✅ 已輸入 {n} 字', + cancelled: '已取消', + connLost: '連線已中斷', + errPinFormat: '請輸入 6 位數字配對碼。', + errPinWrong: '配對碼錯誤,請重試。', + errPinLocked: '配對已鎖定,請在電腦上重新產生配對碼。', + errConnFail: '連線失敗。多半是手機未信任電腦憑證,請先信任憑證後重試。', + errConnCreate: '無法建立連線,請檢查網路。', + errConnTimeout: '連線逾時。多半是手機未信任電腦憑證,請依下方說明信任後重試。', + busy: '電腦忙碌:{reason}', + busyDefault: '請稍候', + micDenied: '❌ 麥克風權限遭拒,請在瀏覽器設定中允許。', + micNotFound: '❌ 找不到可用的麥克風。', + micBusy: '❌ 麥克風被其他應用程式佔用。', + micTimeout: '❌ 麥克風準備逾時,請重試。', + micUnknown: '❌ 無法啟動錄音{name}。', + errGeneric: '發生錯誤', + helpTitle: '連不上?多半是手機沒信任憑證', + helpAndroid: '① 安卓 / 一般情況:用瀏覽器無痕模式開啟本頁,出現“不安全”警告時選“繼續前往”,再輸入配對碼連線。', + helpIos: '② iOS Safari:用無痕模式開啟本頁,出現“不安全”提示時點“顯示詳細資訊 → 瀏覽此網站”,再輸入配對碼連線(無需安裝憑證)。', + helpDownloadCert: '⬇ 下載並安裝憑證', + helpCopyLink: '⧉ 複製連結', + helpCopied: '已複製 ✓', + copy: '複製', + copied: '已複製 ✓', + }, + en: { + title: 'OpenLess Remote Input', + brandTitle: 'OpenLess Remote Input', + brandSub: 'Record on your phone, type to your computer in real time', + pinFieldLabel: 'Pairing code (6 digits shown on your computer)', + btnConnect: 'Connect', + btnConnecting: 'Connecting…', + modeToggle: 'Tap', + insertLabel: 'Type on PC', + modeHold: 'Hold', + offlineTitle: 'Disconnected', + offlineSub: 'The connection to your computer was lost.', + btnReconnect: 'Reconnect', + certTip: 'On first visit the browser will warn "Not secure" (local self-signed certificate). Android Chrome: tap "Advanced" → "Proceed"; iOS Safari: tap "Show Details" → "visit this website".', + tipToggle: 'Tap the big button to start recording, tap again to finish and transcribe.', + tipHold: 'Hold the big button to talk, release to finish and transcribe.', + labelToggleIdle: 'Tap to start', + labelToggleRec: 'Tap to stop', + labelHoldIdle: 'Hold to talk', + labelHoldRec: 'Release to stop', + ready: 'Ready', + preparingMic: 'Preparing microphone…', + statusRecording: '🎤 Recording', + statusTranscribing: '🔄 Transcribing', + statusPolishing: '✨ Polishing', + statusDone: '✅ Inserted {n} chars', + cancelled: 'Cancelled', + connLost: 'Connection lost', + errPinFormat: 'Please enter the 6-digit pairing code.', + errPinWrong: 'Wrong pairing code, please try again.', + errPinLocked: 'Pairing locked. Please regenerate the code on your computer.', + errConnFail: 'Connection failed — usually the phone does not trust the computer certificate. Trust it, then retry.', + errConnCreate: 'Could not connect. Please check your network.', + errConnTimeout: 'Connection timed out — the phone likely does not trust the certificate. Follow the steps below to trust it, then retry.', + busy: 'Computer busy: {reason}', + busyDefault: 'please wait', + micDenied: '❌ Microphone permission denied. Please allow it in browser settings.', + micNotFound: '❌ No microphone available.', + micBusy: '❌ Microphone is in use by another app.', + micTimeout: '❌ Microphone setup timed out. Please try again.', + micUnknown: '❌ Could not start recording{name}.', + errGeneric: 'An error occurred', + helpTitle: "Can't connect? The phone probably doesn't trust the certificate", + helpAndroid: '① Android / general: open this page in an incognito tab, choose "Proceed" on the "Not secure" warning, then enter the pairing code.', + helpIos: '② iOS Safari: open this page in an incognito tab; on the "Not Private" warning tap "Show Details → visit this website", then enter the code (no certificate install needed).', + helpDownloadCert: '⬇ Download & install cert', + helpCopyLink: '⧉ Copy link', + helpCopied: 'Copied ✓', + copy: 'Copy', + copied: 'Copied ✓', + }, + ja: { + title: 'OpenLess リモート入力', + brandTitle: 'OpenLess リモート入力', + brandSub: 'スマホで録音し、リアルタイムでパソコンに入力', + pinFieldLabel: 'ペアリングコード(パソコンに表示される6桁の数字)', + btnConnect: '接続', + btnConnecting: '接続中…', + modeToggle: 'タップ', + insertLabel: 'PCに入力', + modeHold: '長押し', + offlineTitle: '接続が切断されました', + offlineSub: 'パソコンとの接続が切断されました。', + btnReconnect: '再接続', + certTip: '初回アクセス時、ブラウザに「保護されていません」と表示されます(ローカル自己署名証明書)。Android Chrome:「詳細設定」→「アクセスする」、iOS Safari:「詳細を表示」→「このWebサイトを閲覧」をタップしてください。', + tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。', + tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。', + labelToggleIdle: 'タップで開始', + labelToggleRec: 'タップで終了', + labelHoldIdle: '長押しで話す', + labelHoldRec: '離して終了', + ready: '準備完了', + preparingMic: 'マイクを準備中…', + statusRecording: '🎤 録音中', + statusTranscribing: '🔄 認識中', + statusPolishing: '✨ 整文中', + statusDone: '✅ {n}文字を入力しました', + cancelled: 'キャンセルしました', + connLost: '接続が切断されました', + errPinFormat: '6桁の数字のペアリングコードを入力してください。', + errPinWrong: 'ペアリングコードが違います。もう一度お試しください。', + errPinLocked: 'ペアリングがロックされました。パソコンでコードを再生成してください。', + errConnFail: '接続に失敗しました。多くはスマホがパソコンの証明書を信頼していないためです。証明書を信頼してから再試行してください。', + errConnCreate: '接続できません。ネットワークを確認してください。', + errConnTimeout: '接続がタイムアウトしました。多くはスマホが証明書を信頼していないためです。下の手順で信頼してから再試行してください。', + busy: 'パソコンがビジー状態です:{reason}', + busyDefault: 'お待ちください', + micDenied: '❌ マイクの許可が拒否されました。ブラウザの設定で許可してください。', + micNotFound: '❌ 利用可能なマイクが見つかりません。', + micBusy: '❌ マイクが他のアプリで使用されています。', + micTimeout: '❌ マイクの準備がタイムアウトしました。もう一度お試しください。', + micUnknown: '❌ 録音を開始できませんでした{name}。', + errGeneric: 'エラーが発生しました', + helpTitle: '接続できない?多くは証明書が信頼されていません', + helpAndroid: '① Android / 一般:ブラウザのシークレットモードで本ページを開き、「保護されていません」で「アクセスする」を選び、ペアリングコードを入力。', + helpIos: '② iOS Safari:シークレットモードで本ページを開き、「安全ではありません」で「詳細を表示 → このWebサイトにアクセス」をタップしてコードを入力(証明書のインストール不要)。', + helpDownloadCert: '⬇ 証明書をインストール', + helpCopyLink: '⧉ リンクをコピー', + helpCopied: 'コピーしました ✓', + copy: 'コピー', + copied: 'コピー済み ✓', + }, + ko: { + title: 'OpenLess 원격 입력', + brandTitle: 'OpenLess 원격 입력', + brandSub: '휴대폰으로 녹음하여 실시간으로 컴퓨터에 입력', + pinFieldLabel: '페어링 코드 (컴퓨터에 표시된 6자리 숫자)', + btnConnect: '연결', + btnConnecting: '연결 중…', + modeToggle: '탭', + insertLabel: 'PC에 입력', + modeHold: '길게 누르기', + offlineTitle: '연결이 끊겼습니다', + offlineSub: '컴퓨터와의 연결이 끊겼습니다.', + btnReconnect: '다시 연결', + certTip: '처음 접속하면 브라우저에 "안전하지 않음" 경고가 표시됩니다(로컬 자체 서명 인증서). Android Chrome: "고급" → "계속 진행"; iOS Safari: "세부정보 표시" → "이 웹사이트 방문"을 탭하세요.', + tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.', + tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.', + labelToggleIdle: '탭하여 시작', + labelToggleRec: '탭하여 종료', + labelHoldIdle: '눌러서 말하기', + labelHoldRec: '떼면 종료', + ready: '준비 완료', + preparingMic: '마이크 준비 중…', + statusRecording: '🎤 녹음 중', + statusTranscribing: '🔄 인식 중', + statusPolishing: '✨ 다듬는 중', + statusDone: '✅ {n}자 입력함', + cancelled: '취소됨', + connLost: '연결이 끊겼습니다', + errPinFormat: '6자리 숫자 페어링 코드를 입력하세요.', + errPinWrong: '페어링 코드가 잘못되었습니다. 다시 시도하세요.', + errPinLocked: '페어링이 잠겼습니다. 컴퓨터에서 코드를 다시 생성하세요.', + errConnFail: '연결에 실패했습니다. 대개 휴대폰이 컴퓨터 인증서를 신뢰하지 않기 때문입니다. 인증서를 신뢰한 후 다시 시도하세요.', + errConnCreate: '연결할 수 없습니다. 네트워크를 확인하세요.', + errConnTimeout: '연결 시간이 초과되었습니다. 대개 인증서를 신뢰하지 않기 때문입니다. 아래 안내대로 신뢰 후 다시 시도하세요.', + busy: '컴퓨터가 사용 중입니다: {reason}', + busyDefault: '잠시 기다려 주세요', + micDenied: '❌ 마이크 권한이 거부되었습니다. 브라우저 설정에서 허용하세요.', + micNotFound: '❌ 사용 가능한 마이크가 없습니다.', + micBusy: '❌ 마이크가 다른 앱에서 사용 중입니다.', + micTimeout: '❌ 마이크 준비 시간이 초과되었습니다. 다시 시도하세요.', + micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.', + errGeneric: '오류가 발생했습니다', + helpTitle: '연결이 안 되나요? 대개 인증서를 신뢰하지 않아서입니다', + helpAndroid: '① Android / 일반: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "계속"을 선택한 뒤 페어링 코드를 입력하세요.', + helpIos: '② iOS Safari: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "세부사항 표시 → 이 웹사이트 방문"을 누른 뒤 코드를 입력하세요(인증서 설치 불필요).', + helpDownloadCert: '⬇ 인증서 설치', + helpCopyLink: '⧉ 링크 복사', + helpCopied: '복사됨 ✓', + copy: '복사', + copied: '복사됨 ✓', + }, + }; + + // 解析显示语言:优先 PC 注入的 window.__OL_LANG__,回退手机系统语言。 + var LANG = (function () { + var supported = { 'zh-CN': 1, 'zh-TW': 1, en: 1, ja: 1, ko: 1 }; + var injected = (window.__OL_LANG__ || '').trim(); + if (supported[injected]) return injected; + var nav = (navigator.language || '').toLowerCase(); + if (nav.indexOf('zh') === 0) { + if (nav.indexOf('hant') >= 0 || nav.indexOf('tw') >= 0 || nav.indexOf('hk') >= 0 || nav.indexOf('mo') >= 0) return 'zh-TW'; + return 'zh-CN'; + } + if (nav.indexOf('ja') === 0) return 'ja'; + if (nav.indexOf('ko') === 0) return 'ko'; + if (nav.indexOf('en') === 0) return 'en'; + return 'zh-CN'; + })(); + var L = I18N[LANG] || I18N['zh-CN']; + + // 极简插值:把 "{n}" / "{reason}" / "{name}" 替换成对应值。 + function fmt(tpl, vars) { + return String(tpl).replace(/\{(\w+)\}/g, function (_, k) { + return (vars && vars[k] != null) ? vars[k] : ''; + }); + } + + // 把 index.html 里带 data-i18n 的静态文案按当前语言渲染。 + function applyStaticI18n() { + try { document.title = L.title; } catch (e) {} + var nodes = document.querySelectorAll('[data-i18n]'); + for (var i = 0; i < nodes.length; i++) { + var key = nodes[i].getAttribute('data-i18n'); + if (L[key] != null) nodes[i].textContent = L[key]; + } + } + + // ---------- 常量 ---------- + var TARGET_SR = 16000; // 目标采样率,必须与 PC 端一致 + var MODE_KEY = 'ol_remote_mode'; // localStorage 键:录音方式 + var PIN_KEY = 'ol_remote_pin'; // localStorage 键:上次成功的配对码 + var INSERT_KEY = 'ol_remote_insert'; // localStorage 键:电脑落字开关(默认开) + var MIC_PREP_TIMEOUT_MS = 10000; // 麦克风准备超时:超过则判失败让用户重试,避免无限卡"准备中" + + // ---------- DOM ---------- + var $ = function (id) { return document.getElementById(id); }; + var screenPin = $('screen-pin'); + var screenRec = $('screen-rec'); + var screenOffline = $('screen-offline'); + + var pinInput = $('pin-input'); + var pinError = $('pin-error'); + var btnConnect = $('btn-connect'); + + var recordBtn = $('btn-record'); + var recordLabel = $('record-label'); + var statusBar = $('status-bar'); + var statusText = $('status-text'); + var statusIcon = $('status-icon'); + var statusDots = $('status-dots'); + var resultWrap = $('result-wrap'); + var resultText = $('result-text'); + var resultCopy = $('result-copy'); + var levelBar = $('level-bar'); + var recTip = $('rec-tip'); + var modeSwitch = $('mode-switch'); + var insertSwitch = $('insert-switch'); + + var btnReconnect = $('btn-reconnect'); + var offlineReason = $('offline-reason'); + var copyCertBtn = $('copy-cert-link'); + + // ---------- 状态 ---------- + var ws = null; + var authed = false; + var recording = false; // 是否正在录音(决定是否 send 音频) + var startSent = false; // 本次录音的 {type:'start'} 是否已真正发出(等 ensureAudio 异步就绪后才发) + var busy = false; // PC 端忙,本次禁用 + var mode = readMode(); // 'toggle' | 'hold' + var lastPin = ''; + + // 音频相关 + var audioCtx = null; + var mediaStream = null; + var sourceNode = null; + var workletNode = null; + var scriptNode = null; + var workletUrl = null; + var usingWorklet = false; + // 音频代际计数:每次重置/释放音频时自增。getUserMedia 可能在 withTimeout 超时后 + // 迟到 resolve,若不校验代际,迟到的 stream 会泄漏活跃麦克风轨道,甚至覆盖丢失 + // 用户重试成功后的新流。 + var audioGen = 0; + // ScriptProcessor 兜底用的重采样状态(跨块保留) + var resampleState = { phase: 0, last: 0, hasLast: false }; + + // ============================================================ + // 配对码持久化(localStorage) + // ============================================================ + function readPin() { + try { + var p = localStorage.getItem(PIN_KEY); + return /^\d{6}$/.test(p || '') ? p : ''; + } catch (e) { return ''; } + } + function writePin(p) { + try { if (/^\d{6}$/.test(p)) localStorage.setItem(PIN_KEY, p); } catch (e) {} + } + function clearPin() { + try { localStorage.removeItem(PIN_KEY); } catch (e) {} + } + + // ============================================================ + // 屏幕切换 + // ============================================================ + function showScreen(which) { + screenPin.classList.toggle('active', which === 'pin'); + screenRec.classList.toggle('active', which === 'rec'); + screenOffline.classList.toggle('active', which === 'offline'); + } + + // ============================================================ + // 模式(toggle / hold) + // ============================================================ + // ============================================================ + // 电脑落字开关(关闭=只把文字回传手机、不落到电脑光标) + // ============================================================ + function readInsert() { + try { return localStorage.getItem(INSERT_KEY) !== '0'; } catch (e) { return true; } + } + function writeInsert(v) { + try { localStorage.setItem(INSERT_KEY, v ? '1' : '0'); } catch (e) {} + } + // 把当前开关值发给电脑(仅已连接时生效):进录音屏时同步一次,之后每次切换即时下发。 + function sendInsertConfig() { + wsSendJSON({ type: 'set_insert', value: insertSwitch ? insertSwitch.checked : true }); + } + function initInsertSwitch() { + if (!insertSwitch) return; + insertSwitch.checked = readInsert(); + insertSwitch.addEventListener('change', function () { + writeInsert(insertSwitch.checked); + sendInsertConfig(); + }); + } + + function readMode() { + var m = null; + try { m = localStorage.getItem(MODE_KEY); } catch (e) {} + return m === 'hold' ? 'hold' : 'toggle'; + } + function writeMode(m) { + mode = m; + try { localStorage.setItem(MODE_KEY, m); } catch (e) {} + syncModeUI(); + } + function syncModeUI() { + var btns = modeSwitch.querySelectorAll('.mode-btn'); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.toggle('active', btns[i].getAttribute('data-mode') === mode); + } + if (mode === 'hold') { + recTip.textContent = L.tipHold; + recordLabel.textContent = recording ? L.labelHoldRec : L.labelHoldIdle; + recordBtn.style.touchAction = 'none'; // hold 防滚动 + } else { + recTip.textContent = L.tipToggle; + recordLabel.textContent = recording ? L.labelToggleRec : L.labelToggleIdle; + recordBtn.style.touchAction = 'manipulation'; + } + } + + // 切换模式时若约定的 prefer 变化,告知 PC(若已连接) + modeSwitch.addEventListener('click', function (e) { + var t = e.target.closest('.mode-btn'); + if (!t) return; + var m = t.getAttribute('data-mode'); + if (m === mode) return; + // 录音中切换模式先安全停止(取消本次,避免状态错乱) + if (recording) cancelRecording(); + writeMode(m); + }); + + // ============================================================ + // 状态文字 / 音量 + // ============================================================ + function setStatus(text, kind) { + statusText.textContent = text; + // 每次切状态先清掉图标/三点动效,由调用方(applyStatusKind)按需重新点亮。 + if (statusIcon) statusIcon.hidden = true; + if (statusDots) statusDots.hidden = true; + statusBar.classList.remove('is-error', 'is-ok', 'is-work'); + if (kind === 'error') statusBar.classList.add('is-error'); + else if (kind === 'ok') statusBar.classList.add('is-ok'); + else if (kind === 'work') statusBar.classList.add('is-work'); + } + function setLevel(v) { + if (typeof v !== 'number' || isNaN(v)) return; + v = Math.max(0, Math.min(1, v)); + levelBar.style.width = (v * 100).toFixed(1) + '%'; + } + + // 去掉状态文案开头的 emoji 图标(如 '🎤 录音中' → '录音中'),改用 DOM 图标/动效呈现。 + function stripLeadingIcon(s) { + return String(s).replace(/^\S+\s+/, ''); + } + + // PC 端落字完成后回传的最终文字,显示在状态区下方;开始新一次录音时清空。 + function showResult(text) { + if (!resultWrap) return; + if (!text) { clearResult(); return; } + resultText.textContent = text; + resultWrap.hidden = false; + } + function clearResult() { + if (!resultWrap) return; + resultWrap.hidden = true; + resultText.textContent = ''; + if (resultCopy) { + resultCopy.classList.remove('copied'); + resultCopy.textContent = L.copy || '复制'; + } + } + + // done 后过几秒自动回到"准备就绪",方便直接开始下一次,而不是一直停在结果上。 + var readyTimer = null; + function scheduleReady() { + if (readyTimer) clearTimeout(readyTimer); + readyTimer = setTimeout(function () { + readyTimer = null; + if (!recording && authed) setStatus(L.ready, null); + }, 2500); + } + // 录音/停止/取消入口都要清掉 readyTimer,否则上一次 done 的回 ready 定时器会迟到 + // 触发,把"识别中…"等新状态错盖成"准备就绪"。 + function clearReadyTimer() { + if (readyTimer) { clearTimeout(readyTimer); readyTimer = null; } + } + + // busy 提示的解除定时器:跟踪起来,新状态到来时清除,避免多个 busy 消息叠加定时器 + // 或迟到的定时器覆盖新状态。 + var busyTimer = null; + + // 识别/润色阶段的客户端兜底超时:服务端任何原因不回 done/error(如孤立会话、进程异常) + // 时,30 秒后显示通用错误并回 ready,防止 UI 永久卡在"识别中…"。 + var workTimer = null; + function armWorkTimeout() { + clearWorkTimeout(); + workTimer = setTimeout(function () { + workTimer = null; + if (!recording && authed) { + setStatus('❌ ' + L.errGeneric, 'error'); + setLevel(0); + scheduleReady(); + } + }, 30000); + } + function clearWorkTimeout() { + if (workTimer) { clearTimeout(workTimer); workTimer = null; } + } + + // ============================================================ + // WebSocket + // ============================================================ + function wsSendJSON(obj) { + if (ws && ws.readyState === 1) { + try { ws.send(JSON.stringify(obj)); } catch (e) {} + } + } + + // 连接看门狗:wss 握手或认证在 12s 内没完成,几乎都是手机没信任电脑证书 + // (iOS Safari 对自签名 wss 不复用页面级证书例外)。与其无限"连接中",不如回到 + // 配对屏给出明确提示,引导用户去信任证书。 + var connectTimer = null; + function armConnectTimeout() { + clearConnectTimeout(); + connectTimer = setTimeout(function () { + connectTimer = null; + if (!authed) { + closeWS(); + showScreen('pin'); + showPinError(L.errConnTimeout); + resetConnectBtn(); + } + }, 12000); + } + function clearConnectTimeout() { + if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } + } + + function connect(pin) { + lastPin = pin; + closeWS(); // 清理旧连接 + authed = false; + busy = false; + + var url = 'wss://' + location.host + '/ws'; + try { + ws = new WebSocket(url); + } catch (e) { + showPinError(L.errConnCreate); + resetConnectBtn(); + return; + } + ws.binaryType = 'arraybuffer'; + armConnectTimeout(); // 看门狗:握手/认证迟迟不完成 → 多半是证书没被信任 + + ws.onopen = function () { + // 连上立即握手 + wsSendJSON({ type: 'hello', pin: pin, prefer: mode }); + }; + + ws.onmessage = function (ev) { + if (typeof ev.data !== 'string') return; // 下行只处理文本 + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + handleMessage(msg); + }; + + ws.onerror = function () { + // onerror 后通常紧跟 onclose,统一在 close 里处理 UI + }; + + ws.onclose = function () { + clearConnectTimeout(); + var wasAuthed = authed; + authed = false; + recording = false; + teardownAudio(); + if (wasAuthed) { + // 已进入录音屏后断开 → 断线屏 + offlineReason.textContent = L.offlineSub; + showScreen('offline'); + } else { + // 未认证就关闭(握手被拒/证书不受信任/网络中断)。无论当前是否在配对屏都给出 + // 明确提示 —— 否则(尤其安卓 Chrome 对不受信任的自签名 wss 会立刻 onclose) + // 用户只看到按钮闪一下变回"连接",完全不知道发生了什么。 + showScreen('pin'); + showPinError(L.errConnFail); + } + resetConnectBtn(); + }; + } + + function closeWS() { + clearConnectTimeout(); + if (ws) { + ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null; + try { ws.close(); } catch (e) {} + ws = null; + } + } + + function handleMessage(msg) { + if (!msg || typeof msg.type !== 'string') return; + + switch (msg.type) { + case 'auth': + if (msg.ok) { + authed = true; + busy = false; + clearConnectTimeout(); + writePin(lastPin); // 配对成功 → 记住配对码,刷新后免重输 + enterRecScreen(); + } else { + authed = false; + clearPin(); // 配对码失效(错误/锁定)→ 清除,避免下次自动重连又失败 + var reason = msg.reason === 'locked' ? L.errPinLocked : L.errPinWrong; + closeWS(); + showScreen('pin'); + showPinError(reason); + resetConnectBtn(); + } + break; + + case 'status': + applyStatusKind(msg); + break; + + case 'level': + setLevel(msg.value); + break; + + case 'busy': + busy = true; + recording = false; + startSent = false; // 本次会话被服务端拒绝,复位 start 标记 + teardownAudioCapture(); // 停止采集但保留 ctx + updateRecordBtnUI(); + setStatus(fmt(L.busy, { reason: msg.reason || L.busyDefault }), 'error'); + // 短暂后解除忙态,允许重试。定时器存入 busyTimer 跟踪,重入时先清,避免叠加。 + if (busyTimer) clearTimeout(busyTimer); + busyTimer = setTimeout(function () { + busyTimer = null; + busy = false; + updateRecordBtnUI(); + if (!recording) setStatus(L.ready, null); + }, 1500); + break; + + case 'result': + // 电脑落字完成后回传的最终文字,显示给手机用户看本次识别结果。 + showResult(msg.text); + break; + } + } + + function applyStatusKind(msg) { + // 真实状态到来即解除 busy 兜底定时,避免它迟到触发把新状态错盖成"准备就绪"。 + if (busyTimer) { + clearTimeout(busyTimer); busyTimer = null; + busy = false; + updateRecordBtnUI(); + } + switch (msg.kind) { + case 'recording': + setStatus(stripLeadingIcon(L.statusRecording), 'work'); + break; + case 'transcribing': + setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); + if (statusDots) statusDots.hidden = false; // 识别中:三点加载动效 + armWorkTimeout(); // 工作状态续上兜底超时,防止服务端中途无响应卡死 + break; + case 'polishing': + setStatus(L.statusPolishing, 'work'); // 润色保留 ✨ + armWorkTimeout(); // 同上 + break; + case 'done': + clearWorkTimeout(); // 正常收尾,解除兜底超时 + var n = (typeof msg.insertedChars === 'number') ? msg.insertedChars : 0; + setStatus(stripLeadingIcon(fmt(L.statusDone, { n: n })), 'ok'); + if (statusIcon) { statusIcon.src = '/done.png'; statusIcon.hidden = false; } // 完成:对勾图 + setLevel(0); + scheduleReady(); + break; + case 'error': + clearWorkTimeout(); // 服务端已明确报错,解除兜底超时 + setStatus('❌ ' + (msg.message || L.errGeneric), 'error'); + setLevel(0); + break; + default: + if (msg.message) setStatus(msg.message, null); + } + } + + // ============================================================ + // 屏幕状态判断辅助 + // ============================================================ + function isPinScreen() { return screenPin.classList.contains('active'); } + + function enterRecScreen() { + showPinError(''); + showScreen('rec'); + syncModeUI(); + updateRecordBtnUI(); + setStatus(L.ready, null); + setLevel(0); + sendInsertConfig(); // 进录音屏时把「电脑落字」开关同步给电脑 + } + + // ============================================================ + // PIN 屏交互 + // ============================================================ + pinInput.addEventListener('input', function () { + // 仅保留数字 + var v = pinInput.value.replace(/\D+/g, '').slice(0, 6); + if (v !== pinInput.value) pinInput.value = v; + showPinError(''); + }); + pinInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') doConnect(); + }); + btnConnect.addEventListener('click', doConnect); + + function doConnect() { + var pin = (pinInput.value || '').replace(/\D+/g, ''); + if (pin.length !== 6) { + showPinError(L.errPinFormat); + return; + } + showPinError(''); + btnConnect.disabled = true; + btnConnect.textContent = L.btnConnecting; + connect(pin); + } + + function showPinError(text) { + if (!text) { + pinError.hidden = true; + pinError.textContent = ''; + } else { + pinError.hidden = false; + pinError.textContent = text; + } + } + function resetConnectBtn() { + btnConnect.disabled = false; + btnConnect.textContent = L.btnConnect; + } + + // 重新连接 + btnReconnect.addEventListener('click', function () { + showScreen('pin'); + showPinError(''); + resetConnectBtn(); + var p = lastPin || readPin(); + if (p) { + pinInput.value = p; + doConnect(); // 有配对码直接重连,省去再点一次 + } + }); + + // 复制证书下载链接 —— 方便换个浏览器打开,或发给自己。 + function fallbackCopyText(text, cb) { + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + if (cb) cb(); + } catch (e) {} + } + if (copyCertBtn) { + copyCertBtn.addEventListener('click', function () { + var url = location.origin + '/cert.cer'; + var ok = function () { + copyCertBtn.textContent = L.helpCopied; + setTimeout(function () { copyCertBtn.textContent = L.helpCopyLink; }, 1500); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(ok, function () { fallbackCopyText(url, ok); }); + } else { + fallbackCopyText(url, ok); + } + }); + } + + // 结果文字「一键复制」:优先 navigator.clipboard(需安全上下文,本页是 HTTPS), + // 失败或旧浏览器回退 execCommand(兼容性高,见 fallbackCopyText)。 + if (resultCopy) { + resultCopy.addEventListener('click', function () { + var text = resultText.textContent || ''; + if (!text) return; + var done = function () { + resultCopy.classList.add('copied'); + resultCopy.textContent = L.copied || '已复制 ✓'; + setTimeout(function () { + resultCopy.classList.remove('copied'); + resultCopy.textContent = L.copy || '复制'; + }, 1500); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done, function () { fallbackCopyText(text, done); }); + } else { + fallbackCopyText(text, done); + } + }); + } + + // ============================================================ + // 录音按钮交互(toggle / hold) + // ============================================================ + function updateRecordBtnUI() { + recordBtn.classList.toggle('recording', recording); + recordBtn.classList.toggle('busy', busy && !recording); + if (recording) { + recordLabel.textContent = (mode === 'hold') ? L.labelHoldRec : L.labelToggleRec; + } else { + recordLabel.textContent = (mode === 'hold') ? L.labelHoldIdle : L.labelToggleIdle; + } + } + + // toggle 模式:click 切换 + recordBtn.addEventListener('click', function () { + if (mode !== 'toggle') return; + if (!authed || busy) return; + if (recording) stopRecording(); + else startRecording(); + }); + + // hold 模式:按下开始;松开/取消结束。 + // 关键:用 document 级监听兜底"松开"事件。移动端 setPointerCapture 在动画/重排/ + // 系统权限弹窗时可能丢失,导致 recordBtn 自身的 pointerup 收不到 —— 表现为"手已 + // 松开却还在录音,得再点一下才停"。改为按下时在 document 上挂一次性的 pointerup/ + // pointercancel,无论指针最终在哪释放都能结束录音。 + var holdEndHandler = null; + function attachHoldEnd() { + if (holdEndHandler) return; + holdEndHandler = function () { + if (recording) stopRecording(); // stopRecording 内部会 detachHoldEnd + else detachHoldEnd(); + }; + document.addEventListener('pointerup', holdEndHandler, true); + document.addEventListener('pointercancel', holdEndHandler, true); + } + function detachHoldEnd() { + if (!holdEndHandler) return; + document.removeEventListener('pointerup', holdEndHandler, true); + document.removeEventListener('pointercancel', holdEndHandler, true); + holdEndHandler = null; + } + + recordBtn.addEventListener('pointerdown', function (e) { + if (mode !== 'hold') return; + if (!authed || busy) return; + e.preventDefault(); + attachHoldEnd(); + if (!recording) startRecording(); + }); + + // ============================================================ + // 录音流程 + // ============================================================ + // 给可能"永久 pending"的 Promise 兜底超时。移动端 audioCtx.resume() / getUserMedia() + // 在息屏/切后台/被占用时可能既不 resolve 也不 reject,整条 ensureAudio 链就永久卡住 —— + // start 指令发不出去、电脑端不弹胶囊,H5 一直停在"正在准备麦克风…"。超时即判失败,复位 + // 状态并提示重试,而不是无限等待。 + function withTimeout(promise, ms, tag) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { + var err = new Error(tag || 'TIMEOUT'); + err.name = tag || 'TIMEOUT'; + reject(err); + }, ms); + promise.then( + function (v) { clearTimeout(timer); resolve(v); }, + function (e) { clearTimeout(timer); reject(e); } + ); + }); + } + + function startRecording() { + if (recording) return; + if (!ws || ws.readyState !== 1) { + setStatus(L.connLost, 'error'); + return; + } + // 先乐观置态,保证 iOS 在手势同步栈内 resume() + recording = true; + startSent = false; // start 尚未真正发出(等 ensureAudio 异步完成后才发) + clearReadyTimer(); // 防止上一次 done 的回 ready 定时器迟到覆盖本次状态 + clearWorkTimeout(); // 新一次录音开始,作废上一轮的识别兜底超时 + updateRecordBtnUI(); + setStatus(L.preparingMic, 'work'); + clearResult(); // 清掉上一次的识别结果,避免新录音时还显示旧文字 + + withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT') + .then(function () { + if (!recording) { + // 期间已被取消/松手 + teardownAudioCapture(); + return; + } + wsSendJSON({ type: 'start' }); + startSent = true; // start 已发出,stopRecording 才需要配对发 stop + setStatus(stripLeadingIcon(L.statusRecording), 'work'); + }) + .catch(function (err) { + recording = false; + // 超时多半是 audioCtx 卡死(resume 永不 settle),彻底重建,否则下次重试会继续卡在 + // 同一个坏 ctx 上;非超时错误只需停采集链。 + if (err && err.name === 'TIMEOUT') resetAudioContext(); + else teardownAudioCapture(); + updateRecordBtnUI(); + setStatus(err && err.name === 'TIMEOUT' ? L.micTimeout : micErrorText(err), 'error'); + }); + } + + function stopRecording() { + detachHoldEnd(); + if (!recording) return; + clearReadyTimer(); // 防止迟到的回 ready 定时器覆盖"识别中…" + recording = false; + updateRecordBtnUI(); + teardownAudioCapture(); + // start 还没发出(hold 按下后立即松手,ensureAudio 尚未完成)→ 按本地取消处理: + // 不发孤立 stop,否则 PC 无对应会话、不回 done/error,UI 会永久卡在"识别中…"。 + if (!startSent) { + setStatus(L.ready, null); + setLevel(0); + return; + } + startSent = false; + wsSendJSON({ type: 'stop' }); + setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); + if (statusDots) statusDots.hidden = false; + setLevel(0); + armWorkTimeout(); // 兜底:30 秒内服务端不回 done/error 则强制回 ready + } + + function cancelRecording() { + detachHoldEnd(); + if (!recording) { + // 即便未在录音也确保采集停掉 + teardownAudioCapture(); + return; + } + clearReadyTimer(); + clearWorkTimeout(); + recording = false; + updateRecordBtnUI(); + teardownAudioCapture(); + // 同 stopRecording:start 未发出就不发孤立 cancel + if (startSent) wsSendJSON({ type: 'cancel' }); + startSent = false; + setStatus(L.cancelled, null); + setLevel(0); + } + + function micErrorText(err) { + var name = err && err.name ? err.name : ''; + if (name === 'NotAllowedError' || name === 'SecurityError') { + return L.micDenied; + } + if (name === 'NotFoundError' || name === 'OverconstrainedError') { + return L.micNotFound; + } + if (name === 'NotReadableError') { + return L.micBusy; + } + return fmt(L.micUnknown, { name: name ? '(' + name + ')' : '' }); + } + + // ============================================================ + // 音频:获取设备 + 建立采集链 + // ============================================================ + // 确保 AudioContext / getUserMedia / 采集节点就绪并开始推流。 + // 必须在用户手势调用栈内(startRecording 由手势触发)。 + function ensureAudio() { + // 不支持 getUserMedia + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器')); + } + + // 1) AudioContext(iOS 需手势内 resume) + if (!audioCtx) { + var AC = window.AudioContext || window.webkitAudioContext; + if (!AC) { + return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器')); + } + audioCtx = new AC(); + } + + // 注意:iOS Safari 来电/Siri 后 ctx 处于私有的 'interrupted' 状态,只判 'suspended' + // 不命中,会导致录音静默无声 —— 凡是非 running 都尝试 resume。 + var resumeP = (audioCtx.state !== 'running') + ? audioCtx.resume().catch(function () {}) + : Promise.resolve(); + + return resumeP + .then(function () { + // 2) 麦克风流(已存在则复用) + if (mediaStream) return mediaStream; + // 捕获当前代际:迟到 resolve 时若代际已变(超时重置/断线释放),停掉轨道并放弃, + // 避免泄漏麦克风或覆盖重试成功的新流。 + var gen = audioGen; + return navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }, + video: false + }).then(function (stream) { + if (gen !== audioGen) { + try { stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} + return null; // 交给下一步判空直接放弃 + } + mediaStream = stream; + return stream; + }); + }) + .then(function (stream) { + // 3) 建立采集图(若已建好则跳过)。audioCtx 可能在准备超时后被 resetAudioContext + // 置空(本次 getUserMedia 迟到 resolve),此时直接放弃,避免对 null ctx 建图报错。 + if (sourceNode || !audioCtx || !stream) return; + sourceNode = audioCtx.createMediaStreamSource(stream); + return buildCaptureGraph(); + }); + } + + // 建立 AudioWorklet(优先)或 ScriptProcessor(兜底) + function buildCaptureGraph() { + var inSr = audioCtx.sampleRate || 48000; + + // 优先 AudioWorklet + if (audioCtx.audioWorklet && typeof AudioWorkletNode !== 'undefined') { + return loadWorklet() + .then(function () { + workletNode = new AudioWorkletNode(audioCtx, 'ol-pcm-worklet', { + numberOfInputs: 1, + numberOfOutputs: 0, + channelCount: 1, + processorOptions: { inSr: inSr, targetSr: TARGET_SR } + }); + workletNode.port.onmessage = function (e) { + // e.data 是已转换好的 Int16 LE ArrayBuffer + sendAudio(e.data); + }; + sourceNode.connect(workletNode); + usingWorklet = true; + }) + .catch(function () { + // worklet 加载失败 → 回退 ScriptProcessor + usingWorklet = false; + buildScriptProcessor(inSr); + }); + } + + // 无 audioWorklet:直接兜底 + usingWorklet = false; + buildScriptProcessor(inSr); + return Promise.resolve(); + } + + // ---- AudioWorklet processor(字符串 → Blob URL 加载) ---- + function loadWorklet() { + if (workletUrl) return audioCtx.audioWorklet.addModule(workletUrl); + + var code = + 'class OlPcmWorklet extends AudioWorkletProcessor {' + + ' constructor(o){' + + ' super();' + + ' var p=(o&&o.processorOptions)||{};' + + ' this.inSr=p.inSr||sampleRate;' + + ' this.targetSr=p.targetSr||16000;' + + ' this.ratio=this.inSr/this.targetSr;' + + ' this.phase=0;' + // 当前小数相位 + ' this.last=0;' + // 上一块最后一个样本(用于跨块拼接) + ' this.hasLast=false;' + + ' }' + + ' process(inputs){' + + ' var ch=inputs[0]&&inputs[0][0];' + + ' if(!ch||ch.length===0){return true;}' + + ' var ratio=this.ratio;' + + ' var phase=this.phase;' + + ' var prev=this.last;' + + ' var hasPrev=this.hasLast;' + + ' var n=ch.length;' + + // 估算输出样本数上界 + ' var outCap=Math.ceil((n+1)/ratio)+2;' + + ' var pcm=new ArrayBuffer(outCap*2);' + + ' var dv=new DataView(pcm);' + + ' var oi=0;' + + // 线性插值:phase 以"输入样本"为单位推进,step=inSr/16000 + // i=floor(phase),frac=phase-i;a=样本[i],b=样本[i+1] + // 跨块时 i 可能为 -1,用 prev 作为 a。 + ' while(true){' + + ' var i=Math.floor(phase);' + + ' var frac=phase-i;' + + ' var a,b;' + + ' if(i+1>=n){break;}' + // 需要 i 和 i+1 都在块内(或 a 用 prev) + ' if(i<0){' + + ' if(!hasPrev){phase+=ratio;continue;}' + + ' a=prev;b=ch[0];' + + ' }else{' + + ' a=ch[i];b=ch[i+1];' + + ' }' + + ' var s=a+(b-a)*frac;' + + ' if(s>1)s=1;else if(s<-1)s=-1;' + + ' dv.setInt16(oi*2, (s*32767)|0, true);' + + ' oi++;' + + ' phase+=ratio;' + + ' }' + + // 保留余数:把 phase 拉回到相对下一块起点 + ' this.phase=phase-n;' + + ' this.last=ch[n-1];' + + ' this.hasLast=true;' + + ' if(oi>0){' + + ' var out=pcm.slice(0,oi*2);' + + ' this.port.postMessage(out,[out]);' + + ' }' + + ' return true;' + + ' }' + + '}' + + 'registerProcessor("ol-pcm-worklet", OlPcmWorklet);'; + + workletUrl = URL.createObjectURL(new Blob([code], { type: 'application/javascript' })); + return audioCtx.audioWorklet.addModule(workletUrl); + } + + // ---- ScriptProcessor 兜底 ---- + function buildScriptProcessor(inSr) { + scriptNode = audioCtx.createScriptProcessor(4096, 1, 1); + resampleState.phase = 0; + resampleState.last = 0; + resampleState.hasLast = false; + + scriptNode.onaudioprocess = function (e) { + if (!recording) return; + var input = e.inputBuffer.getChannelData(0); + var buf = resampleToInt16LE(input, inSr); + if (buf && buf.byteLength) sendAudio(buf); + }; + // ScriptProcessor 需连到 destination 才会触发(用静音增益避免回放) + sourceNode.connect(scriptNode); + var silent = audioCtx.createGain(); + silent.gain.value = 0; + scriptNode.connect(silent); + silent.connect(audioCtx.destination); + scriptNode._silentGain = silent; + } + + // 主线程线性插值重采样(给 ScriptProcessor 用),逻辑与 worklet 一致 + function resampleToInt16LE(ch, inSr) { + var ratio = inSr / TARGET_SR; + var phase = resampleState.phase; + var prev = resampleState.last; + var hasPrev = resampleState.hasLast; + var n = ch.length; + if (n === 0) return null; + + var outCap = Math.ceil((n + 1) / ratio) + 2; + var pcm = new ArrayBuffer(outCap * 2); + var dv = new DataView(pcm); + var oi = 0; + + while (true) { + var i = Math.floor(phase); + var frac = phase - i; + var a, b; + if (i + 1 >= n) break; + if (i < 0) { + if (!hasPrev) { phase += ratio; continue; } + a = prev; b = ch[0]; + } else { + a = ch[i]; b = ch[i + 1]; + } + var s = a + (b - a) * frac; + if (s > 1) s = 1; else if (s < -1) s = -1; + dv.setInt16(oi * 2, (s * 32767) | 0, true); + oi++; + phase += ratio; + } + + resampleState.phase = phase - n; + resampleState.last = ch[n - 1]; + resampleState.hasLast = true; + + return oi > 0 ? pcm.slice(0, oi * 2) : null; + } + + // 发送二进制音频帧(仅录音中且连接可用) + function sendAudio(buf) { + if (!recording) return; + if (ws && ws.readyState === 1 && buf && buf.byteLength) { + try { ws.send(buf); } catch (e) {} + updateLocalLevel(buf); + } + } + + // 本地音量可视化:直接用即将上传的 Int16 PCM 算 RMS。远程模式下 PC 端没有麦克风 + // 电平源(不开本地 cpal),所以电平条由手机端自己的音频驱动 —— 实时,且不依赖后端事件。 + var lastLevelAt = 0; + function updateLocalLevel(buf) { + var now = (window.performance && performance.now) ? performance.now() : 0; + if (now && now - lastLevelAt < 50) return; // 限到 ~20Hz,避免过度刷新 DOM + lastLevelAt = now; + var n = buf.byteLength >> 1; + if (n === 0) return; + var dv = new DataView(buf); + var sum = 0; + for (var i = 0; i < n; i++) { + var s = dv.getInt16(i * 2, true) / 32768; + sum += s * s; + } + var rms = Math.sqrt(sum / n); + setLevel(Math.min(1, rms * 3.5)); // 适度放大,让正常说话有明显跳动 + } + + // ============================================================ + // 音频清理 + // ============================================================ + // 仅停止"采集/推流"(断开节点),保留 audioCtx & mediaStream 以便快速重启。 + function teardownAudioCapture() { + try { if (workletNode) { workletNode.port.onmessage = null; workletNode.disconnect(); } } catch (e) {} + workletNode = null; + + try { + if (scriptNode) { + scriptNode.onaudioprocess = null; + scriptNode.disconnect(); + if (scriptNode._silentGain) { + try { scriptNode._silentGain.disconnect(); } catch (e2) {} + } + } + } catch (e) {} + scriptNode = null; + + try { if (sourceNode) sourceNode.disconnect(); } catch (e) {} + // sourceNode 置空,下次 ensureAudio 重新从 stream 创建 + sourceNode = null; + + // 复位兜底重采样状态 + resampleState.phase = 0; + resampleState.last = 0; + resampleState.hasLast = false; + } + + // 彻底释放(断线时):停止麦克风轨道并关闭 ctx。 + function teardownAudio() { + audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 + teardownAudioCapture(); + if (mediaStream) { + try { + var tracks = mediaStream.getTracks(); + for (var i = 0; i < tracks.length; i++) tracks[i].stop(); + } catch (e) {} + mediaStream = null; + } + // 不强行 close ctx(部分浏览器再次 new 较慢);仅在确实需要时挂起 + if (audioCtx && audioCtx.state === 'running') { + try { audioCtx.suspend(); } catch (e) {} + } + } + + // 准备超时后的硬复位:停麦克风轨道并彻底关闭 audioCtx,使下次 ensureAudio 从零重建。 + // 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉 + // (resume 永不 settle),保留它只会让下次继续卡。 + function resetAudioContext() { + audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 + teardownAudioCapture(); + if (mediaStream) { + try { + var tracks = mediaStream.getTracks(); + for (var i = 0; i < tracks.length; i++) tracks[i].stop(); + } catch (e) {} + mediaStream = null; + } + if (audioCtx) { + try { audioCtx.close(); } catch (e) {} + audioCtx = null; + } + } + + // ============================================================ + // 页面可见性:切后台时若在 hold 录音则取消,避免半截音频 + // ============================================================ + document.addEventListener('visibilitychange', function () { + if (document.hidden && recording) { + cancelRecording(); + } + }); + + // ============================================================ + // 初始化 + // ============================================================ + function init() { + // iOS Safari 怪癖兜底:页面"首次加载"后,页面内 wss 的证书信任不生效 —— 首次连接 + // 会卡在 TLS 握手→超时,手动刷新一次就好(已用日志证实:首次 TCP 到了却不升级,刷新 + // 后立刻 WS 升级成功)。这里把那一下"刷新"自动化:每个浏览器会话首次加载时静默 + // reload 一次,之后再初始化+自动连接,wss 握手就能成功。sessionStorage 标记保证只刷 + // 一次、不会死循环;手动刷新(同标签)不会重复触发,新标签/重开才会再刷。 + var reloadedOnce = false; + try { reloadedOnce = sessionStorage.getItem('ol_reloaded_once') === '1'; } catch (e) {} + if (!reloadedOnce) { + // 写后立即读回校验:sessionStorage 被禁用(写入抛异常/写不进去)时标记永远落不下, + // 若仍 reload 会无限循环刷新 —— 校验失败就放弃刷新,直接继续初始化。 + var marked = false; + try { + sessionStorage.setItem('ol_reloaded_once', '1'); + marked = sessionStorage.getItem('ol_reloaded_once') === '1'; + } catch (e) {} + if (marked) { + location.reload(); + return; + } + } + + applyStaticI18n(); + syncModeUI(); + initInsertSwitch(); + showScreen('pin'); + showPinError(''); + // 上次成功的配对码 → 自动填充并重连,刷新/重开页面免再输一次 + var saved = readPin(); + if (saved) { + pinInput.value = saved; + doConnect(); + } else { + // 自动聚焦 PIN(部分移动端会被策略拦截,忽略失败) + setTimeout(function () { try { pinInput.focus(); } catch (e) {} }, 200); + } + } + + init(); +})(); diff --git a/openless-all/app/src-tauri/src/remote_server/assets/done.png b/openless-all/app/src-tauri/src/remote_server/assets/done.png new file mode 100644 index 00000000..dc113323 Binary files /dev/null and b/openless-all/app/src-tauri/src/remote_server/assets/done.png differ diff --git a/openless-all/app/src-tauri/src/remote_server/assets/icon.png b/openless-all/app/src-tauri/src/remote_server/assets/icon.png new file mode 100644 index 00000000..358c3b3b Binary files /dev/null and b/openless-all/app/src-tauri/src/remote_server/assets/icon.png differ diff --git a/openless-all/app/src-tauri/src/remote_server/assets/index.html b/openless-all/app/src-tauri/src/remote_server/assets/index.html new file mode 100644 index 00000000..96537850 --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/assets/index.html @@ -0,0 +1,107 @@ + + + + + + + + OpenLess + + + + +
+ +
+
+ OpenLess +

OpenLess 远程输入

+

在手机上录音,实时输入到电脑

+
+ +
+ + + + +
+ + + +
+ + +
+
+ OpenLess + OpenLess +
+ + +
+
+ +
+ + + + +
+ + + 准备就绪 +
+ + + +
+ +

点击大按钮开始录音,再次点击结束并识别。

+ + + +
+ + +
+
+
📵
+

连接已断开

+

与电脑的连接已中断。

+ +
+
+
+ + + + diff --git a/openless-all/app/src-tauri/src/remote_server/assets/mic.png b/openless-all/app/src-tauri/src/remote_server/assets/mic.png new file mode 100644 index 00000000..1e0570ee Binary files /dev/null and b/openless-all/app/src-tauri/src/remote_server/assets/mic.png differ diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css new file mode 100644 index 00000000..026c3625 --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css @@ -0,0 +1,586 @@ +/* ===== OpenLess 远程输入 — 移动端样式 ===== + * 配色 / 圆角 / 阴影 / 字体对齐 PC 端 design tokens(src/styles/tokens.css): + * 黑 + 白 + 电光蓝,浅色 glassy 风格(与桌面端保持一致)。 + */ + +:root { + /* 中性色 */ + --bg: #f7f7f8; + --surface: #ffffff; + --surface-2: #fafafa; + --line: rgba(0, 0, 0, 0.08); + --line-strong: rgba(0, 0, 0, 0.14); + + /* 墨色文字 */ + --ink: #0a0a0b; + --ink-2: #2a2a2d; + --ink-3: rgba(10, 10, 11, 0.62); + --ink-4: rgba(10, 10, 11, 0.42); + + /* 蓝色强调 */ + --blue: #2563eb; + --blue-hover: #1d4ed8; + --blue-soft: #eff4ff; + --blue-ring: rgba(37, 99, 235, 0.22); + + /* 状态色 */ + --ok: #16a34a; + --ok-soft: #ecfdf5; + --warn: #d97706; + --danger: #dc2626; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(15, 17, 22, 0.04), 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-md: 0 1px 2px rgba(15, 17, 22, 0.05), 0 6px 24px -12px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 20px 60px -20px rgba(15, 17, 22, 0.18), 0 8px 32px -16px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.06); + + /* 圆角 */ + --r-lg: 14px; + --r-xl: 18px; + --r-2xl: 22px; + --r-pill: 999px; + + /* 字体 */ + --font-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif; + + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +/* 关键:很多元素用 hidden 属性控制显隐,但元素自带 display(flex/inline-flex)会覆盖浏览器 + 默认的 [hidden]{display:none},导致空框照常显示。这条强制 hidden 优先(结果框/三点/图标都靠它)。 */ +[hidden] { display: none !important; } + +html, body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background: + radial-gradient(120% 80% at 50% -10%, #eef2fb 0%, var(--bg) 55%) fixed, + var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'cv11', 'ss01', 'ss03'; + user-select: none; + -webkit-user-select: none; + overscroll-behavior: none; +} + +#app { + min-height: 100%; + display: flex; + flex-direction: column; + padding-bottom: calc(24px + var(--safe-bottom)); +} + +/* ===== 屏幕切换 ===== */ +.screen { + display: none; + flex: 1; + flex-direction: column; + padding: 24px 20px; + animation: fadeIn .25s ease; +} +.screen.active { + display: flex; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ===== 品牌头 ===== */ +.brand { + text-align: center; + margin: 28px 0 22px; +} +.brand-logo-img { + width: 72px; + height: 72px; + border-radius: var(--r-xl); + object-fit: cover; + box-shadow: var(--shadow-lg); +} +.brand-title { + font-size: 22px; + font-weight: 700; + margin: 16px 0 4px; + letter-spacing: .2px; + color: var(--ink); +} +.brand-sub { + margin: 0; + color: var(--ink-3); + font-size: 14px; +} + +/* ===== 卡片 ===== */ +.card { + background: var(--surface); + border: 0.5px solid var(--line); + border-radius: var(--r-2xl); + padding: 22px 20px; + box-shadow: var(--shadow-lg); +} +.card-center { + text-align: center; +} + +.field-label { + display: block; + font-size: 13px; + color: var(--ink-3); + margin-bottom: 10px; +} + +/* ===== PIN 输入 ===== */ +.pin-input { + width: 100%; + font-size: 30px; + letter-spacing: 14px; + text-align: center; + padding: 16px 12px; + color: var(--ink); + background: var(--surface-2); + border: 1.5px solid var(--line-strong); + border-radius: var(--r-lg); + outline: none; + font-variant-numeric: tabular-nums; + transition: border-color .15s ease, box-shadow .15s ease; +} +.pin-input::placeholder { + color: var(--ink-4); + letter-spacing: 14px; +} +.pin-input:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px var(--blue-ring); +} + +/* ===== 按钮 ===== */ +.btn { + -webkit-appearance: none; + appearance: none; + display: block; + width: 100%; + margin-top: 16px; + padding: 15px 18px; + font-size: 17px; + font-weight: 600; + color: #fff; + border: none; + border-radius: var(--r-lg); + cursor: pointer; + transition: transform .08s ease, background .15s ease, opacity .15s ease; +} +.btn:active { transform: scale(.98); } +.btn:disabled { opacity: .5; cursor: default; } + +.btn-primary { + background: var(--blue); + box-shadow: 0 6px 18px -6px var(--blue-ring); +} +.btn-primary:active { background: var(--blue-hover); } + +.hint-error { + color: var(--danger); + font-size: 13px; + margin: 12px 2px 0; + min-height: 1em; +} + +/* ===== 连接帮助(配对屏) ===== */ +.help { + margin-top: 18px; + padding: 16px 16px 18px; + border-radius: var(--r-xl); + background: var(--surface-2); + border: 0.5px solid var(--line); +} +.help-title { + font-size: 13.5px; + font-weight: 600; + color: var(--ink-2); + margin-bottom: 10px; +} +.help-step { + font-size: 12.5px; + color: var(--ink-3); + line-height: 1.65; + margin: 0 0 9px; +} +.help-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} +.help-link { + display: inline-block; + padding: 9px 16px; + border-radius: var(--r-md); + border: none; + background: var(--blue); + color: #fff; + font-size: 13px; + font-weight: 600; + font-family: inherit; + text-decoration: none; + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} +.help-link:active { background: var(--blue-hover); } +.help-link-ghost { + background: var(--surface); + color: var(--blue); + border: 1px solid var(--blue); +} +.help-link-ghost:active { background: var(--blue-soft); } + +/* ===== 录音屏头部 ===== */ +.rec-header { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 8px; +} +.app-icon { + width: 26px; + height: 26px; + border-radius: 7px; + flex: none; + box-shadow: var(--shadow-sm); +} +.rec-header-title { + font-weight: 700; + font-size: 16px; + letter-spacing: .2px; + color: var(--ink); +} +.mode-switch { + margin-left: auto; + display: inline-flex; + background: var(--surface-2); + border: 0.5px solid var(--line); + border-radius: 12px; + padding: 3px; + gap: 2px; +} +.mode-btn { + -webkit-appearance: none; + appearance: none; + border: none; + background: transparent; + color: var(--ink-3); + font-size: 13px; + font-weight: 600; + padding: 7px 14px; + border-radius: 9px; + cursor: pointer; + transition: background .15s ease, color .15s ease; +} +.mode-btn.active { + background: var(--blue); + color: #fff; +} + +/* ===== 录音主区 ===== */ +.rec-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; +} + +/* 录音大按钮 —— 默认蓝色实心(对齐 PC 主操作蓝),录音中转红 */ +.record-btn { + position: relative; + width: 168px; + height: 168px; + border-radius: 50%; + border: none; + cursor: pointer; + color: #fff; + background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); + box-shadow: + 0 16px 36px -10px rgba(37, 99, 235, .5), + inset 0 1px 0 rgba(255, 255, 255, .25); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + transition: transform .1s ease, box-shadow .2s ease, background .2s ease; + user-select: none; + -webkit-user-select: none; +} +.record-btn:active { transform: scale(.97); } + +.record-btn-ring { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid rgba(37, 99, 235, .35); + opacity: 0; + pointer-events: none; +} +.record-btn-icon { + width: 60px; + height: 60px; + object-fit: contain; + line-height: 1; + transition: transform .15s ease; +} +.record-btn-label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, .92); + letter-spacing: .3px; +} + +/* 录音中:红色 + 呼吸脉冲动画 */ +.record-btn.recording { + background: linear-gradient(180deg, #f87171 0%, #dc2626 100%); + box-shadow: 0 16px 36px -10px rgba(220, 38, 38, .5); + animation: breathe 1.6s ease-in-out infinite; +} +.record-btn.recording .record-btn-label { color: #fff; } +.record-btn.recording .record-btn-ring { + opacity: 1; + border-color: rgba(220, 38, 38, .4); + animation: pulseRing 1.6s ease-out infinite; +} + +@keyframes breathe { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.04); } +} +@keyframes pulseRing { + 0% { transform: scale(1); opacity: .7; } + 70% { transform: scale(1.28); opacity: 0; } + 100% { transform: scale(1.28); opacity: 0; } +} + +/* 忙/禁用态 */ +.record-btn.busy { + opacity: .5; + cursor: default; + animation: none; +} + +/* ===== 音量条 ===== */ +.level-wrap { + width: 78%; + max-width: 320px; + height: 8px; + border-radius: var(--r-pill); + background: #e9ebf0; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, .07); + overflow: hidden; + /* 平时淡化成一个浅凹槽;录音时才高亮(下方规则),避免像一根无意义的白条 */ + opacity: .45; + transition: opacity .2s ease; +} +.record-btn.recording ~ .level-wrap { + opacity: 1; +} +.level-bar { + height: 100%; + width: 0%; + border-radius: var(--r-pill); + background: linear-gradient(90deg, var(--ok), var(--blue)); + transition: width .08s linear; +} + +/* ===== 状态条 ===== */ +.status-bar { + min-height: 28px; + padding: 8px 18px; + border-radius: var(--r-pill); + background: var(--surface); + border: 0.5px solid var(--line); + box-shadow: var(--shadow-sm); + font-size: 15px; + font-weight: 600; + color: var(--ink); + text-align: center; + max-width: 90%; +} +.status-bar.is-error { color: var(--danger); border-color: rgba(220, 38, 38, .35); } +.status-bar.is-ok { color: var(--ok); border-color: rgba(22, 163, 74, .35); } +.status-bar.is-work { color: var(--blue); border-color: var(--blue-ring); } + +/* 状态图标(如完成对勾) */ +.status-icon { + width: 18px; + height: 18px; + object-fit: contain; + vertical-align: -3px; + margin-right: 3px; +} + +/* 识别中三点加载动效(替代旋转 emoji) */ +.dots { + display: inline-flex; + align-items: center; + gap: 3px; + margin-right: 5px; + vertical-align: middle; + color: var(--blue); +} +.dots i { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + display: inline-block; + animation: dotPulse 1.2s infinite ease-in-out both; +} +.dots i:nth-child(1) { animation-delay: -.32s; } +.dots i:nth-child(2) { animation-delay: -.16s; } +@keyframes dotPulse { + 0%, 80%, 100% { transform: scale(.5); opacity: .35; } + 40% { transform: scale(1); opacity: 1; } +} + +/* ===== 识别结果文字(电脑回传) ===== */ +.result-wrap { + max-width: 90%; + margin-top: 2px; + display: flex; + flex-direction: column; + gap: 8px; + animation: fadeIn .25s ease; +} +.result-text { + padding: 12px 16px; + border-radius: var(--r-lg); + background: var(--surface); + border: 0.5px solid var(--line); + box-shadow: var(--shadow-sm); + font-size: 15px; + line-height: 1.55; + color: var(--ink); + text-align: left; + white-space: pre-wrap; + word-break: break-word; + /* 结果文字允许选中复制(其余 UI 默认禁选) */ + user-select: text; + -webkit-user-select: text; +} +.result-copy { + align-self: flex-end; + -webkit-appearance: none; + appearance: none; + border: 1px solid var(--blue); + background: var(--blue-soft); + color: var(--blue); + font-size: 13px; + font-weight: 600; + font-family: inherit; + padding: 8px 18px; + border-radius: var(--r-lg); + cursor: pointer; + transition: background .15s ease, color .15s ease; +} +.result-copy:active { background: var(--blue); color: #fff; } +.result-copy.copied { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); } + +/* ===== 提示文字 ===== */ +.rec-tip { + text-align: center; + color: var(--ink-4); + font-size: 13px; + margin: 18px 0 0; +} + +/* ===== 电脑落字开关 ===== */ +.insert-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 14px 0 0; + font-size: 13px; + color: var(--ink-3); + cursor: pointer; +} +.insert-switch { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} +.insert-track { + position: relative; + width: 42px; + height: 24px; + border-radius: 999px; + background: var(--line-strong); + transition: background .2s ease; + flex: none; +} +.insert-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, .25); + transition: transform .2s ease; +} +.insert-switch:checked ~ .insert-track { + background: var(--blue); +} +.insert-switch:checked ~ .insert-track::after { + transform: translateX(18px); +} + +/* ===== 断线屏 ===== */ +.offline-icon { font-size: 48px; } +.offline-title { font-size: 20px; margin: 12px 0 6px; color: var(--ink); } +.offline-sub { color: var(--ink-3); font-size: 14px; margin: 0 0 8px; } + +/* ===== 底部证书提示(固定) ===== */ +.cert-tip { + position: fixed; + left: 0; + right: 0; + bottom: 0; + padding: 12px 16px calc(12px + var(--safe-bottom)); + font-size: 12px; + line-height: 1.5; + color: var(--ink-4); + background: rgba(255, 255, 255, .85); + backdrop-filter: blur(12px) saturate(160%); + -webkit-backdrop-filter: blur(12px) saturate(160%); + border-top: 0.5px solid var(--line); + text-align: center; +} + +/* 小屏微调 */ +@media (max-height: 640px) { + .brand { margin: 14px 0; } + .brand-logo-img { width: 56px; height: 56px; } + .record-btn { width: 148px; height: 148px; } + .record-btn-icon { width: 52px; height: 52px; } +} diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs new file mode 100644 index 00000000..d6eb39c4 --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -0,0 +1,748 @@ +//! 远程输入(局域网手机录音)的 HTTPS + WebSocket 服务。 +//! +//! 手机在同一局域网用浏览器打开 `https://:`,得到一个录音页 +//! (assets/ 下的 index.html / app.js / style.css,编译期 include_str! 内嵌)。 +//! 手机录音以 16k/单声道/16-bit LE PCM 经 WebSocket 实时推回 PC,由 Coordinator +//! 当作"手机麦克风"喂进现有「录音→ASR→润色→光标落字」管线(见 +//! `Coordinator::start_remote_dictation`)。 +//! +//! 关键约束:浏览器 `getUserMedia` 仅在安全上下文可用,所以必须 HTTPS。证书用 +//! rcgen 自签名(SAN 含本机局域网 IP),手机首次访问需手动信任。TLS 走 ring +//! 后端(与项目 reqwest/tungstenite 一致,避免 aws-lc-sys 的 C 编译依赖)。 + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + extract::State, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use parking_lot::Mutex; +use serde::Serialize; +use tauri::{AppHandle, Listener, Manager}; +use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; + +use crate::coordinator::Coordinator; + +mod assets { + pub const INDEX_HTML: &str = include_str!("assets/index.html"); + pub const APP_JS: &str = include_str!("assets/app.js"); + pub const STYLE_CSS: &str = include_str!("assets/style.css"); + pub const ICON_PNG: &[u8] = include_bytes!("assets/icon.png"); + pub const MIC_PNG: &[u8] = include_bytes!("assets/mic.png"); + pub const DONE_PNG: &[u8] = include_bytes!("assets/done.png"); +} + +const HEADER_HTML: &str = "text/html; charset=utf-8"; +const HEADER_JS: &str = "application/javascript; charset=utf-8"; +const HEADER_CSS: &str = "text/css; charset=utf-8"; + +/// 同一来源 IP 连续输错 PIN 的锁定阈值与时长。按 IP 而非全局计数:全局锁会被 +/// 局域网内多台机器分摊(每台只贡献几次失败就触发全局锁,反而 DoS 正常用户); +/// 按 IP 则每个攻击源各自被限到 ~5 次/分钟,10^6 个 PIN 组合在锁定节奏下不可行。 +const PIN_MAX_FAILS: u32 = 5; +const PIN_LOCK_SECS: u64 = 60; +/// pin_fails 表的容量上限:超过即清理已过期/已解锁的条目,防止伪造海量源 IP 撑爆内存。 +const PIN_FAILS_MAX_ENTRIES: usize = 256; +/// 单个 PCM 二进制帧的上限。16kHz/16bit 实时流正常每帧只有几 KB,64KB ≈ 2 秒音频; +/// 超限帧直接丢弃,防已配对客户端(或驱动它的恶意网页)推超大帧造成内存压力。 +const MAX_PCM_FRAME_BYTES: usize = 64 * 1024; +/// 服务端 keepalive:每 KEEPALIVE_PING_SECS 发一次 WS Ping(浏览器自动回 Pong); +/// 连续 IDLE_TIMEOUT_SECS 收不到任何上行帧(含 Pong)则视为半开死链断开。 +/// 手机息屏/Wi-Fi 漂移常常不发 TCP FIN,没有探活时 recv() 永久挂起:连接任务、 +/// 事件订阅、进行中的远程会话全部悬挂。 +const KEEPALIVE_PING_SECS: u64 = 30; +const IDLE_TIMEOUT_SECS: u64 = 90; + +// ───────────────────────── 对外类型 ───────────────────────── + +pub struct RemoteServerConfig { + pub port: u16, + pub pin: String, + pub coordinator: Arc, + pub app: AppHandle, +} + +/// 运行中的服务句柄。drop / shutdown 触发优雅关停。 +pub struct RemoteServerHandle { + shutdown_tx: Option>, + /// 广播给所有已建立 WS 连接的关停信号。只停 accept loop 是不够的:连接任务 + /// 是独立 spawn 的,不通知它们的话,用户关掉远程输入(或重置 PIN 触发重启) + /// 后已配对的手机会话原样存活,仍能录音、向 PC 光标落字——撤销语义失效。 + conn_shutdown_tx: tokio::sync::watch::Sender, + join: tauri::async_runtime::JoinHandle<()>, + pub bound_port: u16, + #[allow(dead_code)] + pub pin: String, +} + +impl RemoteServerHandle { + /// 通知 accept loop 与所有存量 WS 连接退出,并等待 accept loop 结束。 + pub async fn shutdown(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + let _ = self.conn_shutdown_tx.send(true); + let _ = self.join.await; + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteInputStatus { + pub running: bool, + pub port: u16, + pub pin: String, + pub urls: Vec, +} + +// ───────────────────────── 工具函数 ───────────────────────── + +/// 生成 6 位数字配对码。用 uuid v4 的随机字节取模,无需引入 rand。 +pub fn generate_pin() -> String { + let b = uuid::Uuid::new_v4().into_bytes(); + let n = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) % 1_000_000; + format!("{n:06}") +} + +fn pin_path(app: &AppHandle) -> Option { + app.path() + .app_config_dir() + .ok() + .map(|d| d.join("remote-input-pin.txt")) +} + +/// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定 —— 否则每次启动 +/// 都重新随机一个,用户得反复回来找新码("配对码错误"的根因)。 +pub fn load_or_create_pin(app: &AppHandle) -> String { + if let Some(p) = pin_path(app) { + if let Ok(s) = std::fs::read_to_string(&p) { + let s = s.trim(); + if s.len() == 6 && s.bytes().all(|b| b.is_ascii_digit()) { + return s.to_string(); + } + } + } + let pin = generate_pin(); + save_pin(app, &pin); + pin +} + +/// 写配对码到磁盘(用户点"重置配对码"时覆盖)。 +pub fn save_pin(app: &AppHandle, pin: &str) { + if let Some(p) = pin_path(app) { + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&p, pin); + } +} + +fn is_private_lan(ip: &Ipv4Addr) -> bool { + let o = ip.octets(); + !ip.is_loopback() + && !ip.is_link_local() + && ((o[0] == 192 && o[1] == 168) + || o[0] == 10 + || (o[0] == 172 && (16..=31).contains(&o[1]))) +} + +/// 本机所有局域网 IPv4(过滤回环 / link-local / 虚拟网卡的非私网段)。 +pub fn local_lan_ipv4s() -> Vec { + let mut out: Vec = Vec::new(); + if let Ok(ifaces) = local_ip_address::list_afinet_netifas() { + for (_name, ip) in ifaces { + if let IpAddr::V4(v4) = ip { + if is_private_lan(&v4) { + out.push(v4); + } + } + } + } + out.sort(); + out.dedup(); + out +} + +/// 给前端展示的访问网址列表。 +pub fn access_urls(port: u16) -> Vec { + local_lan_ipv4s() + .iter() + .map(|ip| format!("https://{ip}:{port}")) + .collect() +} + +// ───────────────────────── TLS ───────────────────────── + +/// 自签名证书:持久化到磁盘并跨重启复用。否则每次启动证书都变 —— 手机(尤其 iOS +/// Safari)上一次信任过的证书立刻失效,wss 握手静默挂起,表现为"连接中"卡死。仅当 +/// 磁盘无证书 / 解析失败 / 当前局域网 IP 不在已存 SAN 列表里(换了网络)时才重新生成。 +/// 返回 (证书 DER 原始字节, 私钥)。 +fn load_or_generate_cert( + dir: Option<&std::path::Path>, + sans: &[String], +) -> Result<(Vec, rustls::pki_types::PrivateKeyDer<'static>), String> { + use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; + // 文件名带 schema 版本:证书结构变更(v4 改回非 CA 服务器证书)时旧文件自动失效、重新生成。 + const CERT_FILE: &str = "remote-cert-v4.der"; + const KEY_FILE: &str = "remote-key-v4.der"; + const SANS_FILE: &str = "remote-cert-sans-v4.txt"; + if let Some(dir) = dir { + if let (Ok(cert), Ok(key), Ok(saved)) = ( + std::fs::read(dir.join(CERT_FILE)), + std::fs::read(dir.join(KEY_FILE)), + std::fs::read_to_string(dir.join(SANS_FILE)), + ) { + let saved_set: std::collections::HashSet<&str> = saved.lines().collect(); + // 当前需要的 SAN 都在已存证书里 → 复用,证书保持稳定(手机信任一次长期有效)。 + if sans.iter().all(|s| saved_set.contains(s.as_str())) { + log::info!("[remote-input] reusing persisted self-signed server cert"); + return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))); + } + } + } + // 生成自签名服务器证书(SAN 含本机各局域网 IP)。主路径是浏览器页面级 + // “继续访问/访问此网站”例外;/cert.cer 与 /cert.mobileconfig 是手机系统级 + // 安装信任的兜底(部分浏览器的 wss 不复用页面级例外时使用)。 + let (cert_der, key_der) = { + use rcgen::{ + CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair, + KeyUsagePurpose, + }; + let mut params = + CertificateParams::new(sans.to_vec()).map_err(|e| format!("rcgen params: {e}"))?; + // 关键:做成普通服务器证书(非 CA,rcgen 默认即 NoCa)。iOS Safari 用页面级 + // “访问此网站”即可信任、无需安装证书 —— 这正是之前一直能用的方式。把证书做成 CA + // 反而会让 iOS 拒绝页面级例外(CA 不能直接当服务器证书),导致一直超时。 + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "OpenLess Remote Input"); + dn.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = dn; + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + let key_pair = KeyPair::generate().map_err(|e| format!("rcgen keypair: {e}"))?; + let cert = params + .self_signed(&key_pair) + .map_err(|e| format!("rcgen self_signed: {e}"))?; + (cert.der().as_ref().to_vec(), key_pair.serialize_der()) + }; + if let Some(dir) = dir { + let _ = std::fs::create_dir_all(dir); + let _ = std::fs::write(dir.join(CERT_FILE), &cert_der); + let _ = std::fs::write(dir.join(KEY_FILE), &key_der); + // 私钥收紧为 0600:app 配置目录通常已是用户私有,但多用户/共享主机上 + // 默认 umask 可能给到组/其他用户可读。Windows 下 %APPDATA% 的 ACL + // 本身仅限本用户,无对应权限位可设。 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + dir.join(KEY_FILE), + std::fs::Permissions::from_mode(0o600), + ); + } + let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n")); + log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})"); + } + Ok(( + cert_der, + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), + )) +} + +fn build_server_config( + cert_der: Vec, + key_der: rustls::pki_types::PrivateKeyDer<'static>, +) -> Result, String> { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let config = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| format!("tls protocol: {e}"))? + .with_no_client_auth() + .with_single_cert( + vec![rustls::pki_types::CertificateDer::from(cert_der)], + key_der, + ) + .map_err(|e| format!("tls cert: {e}"))?; + Ok(Arc::new(config)) +} + +// ───────────────────────── 启动 ───────────────────────── + +struct WsState { + pin: String, + coordinator: Arc, + app: AppHandle, + /// 按源 IP 的 PIN 失败计数 + 锁定截止时刻(防爆破;TLS+6 位 PIN 已是主防线)。 + pin_fails: Mutex)>>, + /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 + cert_der: Vec, + /// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。 + conn_shutdown_rx: tokio::sync::watch::Receiver, +} + +/// 经 accept loop 注入的对端 IP(axum Extension)。hyper 直连 TLS 流时拿不到 +/// ConnectInfo,这里在每条连接的 service 上挂一层 Extension 把 peer 传进 handler。 +#[derive(Clone, Copy)] +struct PeerIp(IpAddr); + +fn build_router(state: Arc) -> Router { + Router::new() + .route("/", get(index_handler)) + .route( + "/app.js", + get(|| async { ([(axum::http::header::CONTENT_TYPE, HEADER_JS)], assets::APP_JS) }), + ) + .route( + "/style.css", + get(|| async { + ([(axum::http::header::CONTENT_TYPE, HEADER_CSS)], assets::STYLE_CSS) + }), + ) + .route( + "/icon.png", + get(|| async { + ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::ICON_PNG) + }), + ) + .route( + "/mic.png", + get(|| async { + ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::MIC_PNG) + }), + ) + .route( + "/done.png", + get(|| async { + ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::DONE_PNG) + }), + ) + // 证书下载:手机在浏览器打开它即可下载并安装信任(iOS Safari 的 wss 不复用 + // 页面级证书例外,需在系统里完全信任后 wss 才稳定)。 + .route( + "/cert.cer", + get(|State(state): State>| async move { + ( + [(axum::http::header::CONTENT_TYPE, "application/x-x509-ca-cert")], + state.cert_der.clone(), + ) + }), + ) + .route("/cert.mobileconfig", get(mobileconfig_handler)) + .route("/ws", get(ws_upgrade)) + .with_state(state) +} + +/// 首页:按 PC 端当前界面语言把 `__OL_LANG__` 占位替换成实际 locale, +/// H5 据此(window.__OL_LANG__ / )选择显示语言。 +async fn index_handler(State(state): State>) -> impl IntoResponse { + let lang = state.coordinator.remote_locale(); + Html(assets::INDEX_HTML.replace("%%OL_LANG%%", &lang)) +} + +/// 极简标准 base64:构造 .mobileconfig 时把证书 DER 编码进 XML,避免引入额外依赖。 +fn base64_encode(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((data.len() + 2) / 3 * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0]; + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + out.push(T[(b0 >> 2) as usize] as char); + out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + out.push(if chunk.len() > 1 { + T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + T[(b2 & 0x3f) as usize] as char + } else { + '=' + }); + } + out +} + +/// iOS 配置描述文件:把证书包成 .mobileconfig。Safari 点击后凭 content-type +/// (application/x-apple-aspen-config) 直接进入“安装描述文件”流程,比裸 .cer 顺滑、 +/// 也不会把当前页面导航走。安装后仍需到「设置→通用→关于本机→证书信任设置」打开完全信任。 +/// +/// 安全边界:PayloadType `com.apple.security.root` 只是 iOS 安装证书的固定入口, +/// 证书本身是非 CA 的纯服务器证书(rcgen NoCa + EKU=ServerAuth,见 +/// load_or_generate_cert)——不含签发能力,无法用来给其他域名签证书做 MITM。 +/// 信任它的影响范围仅限「持有本机私钥者可冒充 SAN 里列出的本机局域网 IP」, +/// 私钥只存在用户 PC 的应用配置目录。设置页 certTrustWarning 同步向用户说明。 +async fn mobileconfig_handler(State(state): State>) -> impl IntoResponse { + let b64 = base64_encode(&state.cert_der); + let xml = format!( + r#" + +PayloadContentPayloadCertificateFileNameopenless.cerPayloadContent{b64}PayloadTypecom.apple.security.rootPayloadIdentifiercom.openless.remote-input.certPayloadUUIDA1B2C3D4-0001-4000-8000-000000000001PayloadVersion1PayloadDisplayNameOpenLess Remote Input CertificatePayloadDisplayNameOpenLess Remote InputPayloadIdentifiercom.openless.remote-inputPayloadTypeConfigurationPayloadUUIDA1B2C3D4-0002-4000-8000-000000000002PayloadVersion1"#, + b64 = b64 + ); + ( + [( + axum::http::header::CONTENT_TYPE, + "application/x-apple-aspen-config", + )], + xml, + ) +} + +pub async fn start(cfg: RemoteServerConfig) -> Result { + let _ = HEADER_HTML; // index 用 axum Html() 自带 content-type + let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + for ip in local_lan_ipv4s() { + sans.push(ip.to_string()); + } + // 证书目录用 app 配置目录(跨重启稳定);拿不到则退回内存生成(不持久化)。 + let cert_dir = cfg.app.path().app_config_dir().ok(); + let (cert_der, key_der) = load_or_generate_cert(cert_dir.as_deref(), &sans)?; + let rustls_config = build_server_config(cert_der.clone(), key_der)?; + let acceptor = TlsAcceptor::from(rustls_config); + + let addr = SocketAddr::from(([0, 0, 0, 0], cfg.port)); + let listener = TcpListener::bind(addr).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::AddrInUse { + "port-in-use".to_string() + } else { + format!("bind: {e}") + } + })?; + let bound_port = listener.local_addr().map(|a| a.port()).unwrap_or(cfg.port); + + let (conn_shutdown_tx, conn_shutdown_rx) = tokio::sync::watch::channel(false); + let state = Arc::new(WsState { + pin: cfg.pin.clone(), + coordinator: cfg.coordinator, + app: cfg.app, + pin_fails: Mutex::new(std::collections::HashMap::new()), + cert_der, + conn_shutdown_rx, + }); + let router = build_router(state); + + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let join = tauri::async_runtime::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => { + log::info!("[remote-input] accept loop shutting down"); + break; + } + accepted = listener.accept() => { + let (tcp, peer) = match accepted { + Ok(x) => x, + Err(e) => { + log::warn!("[remote-input] accept error: {e}"); + continue; + } + }; + // 最底层诊断:每个到达本机 8443 的 TCP 连接都记下来源 IP。手机一连就能 + // 看到它到底有没有真的到这台电脑、来自哪个网段(排查"是不是连到别的设备")。 + log::info!("[remote-input] 收到 TCP 连接,来自 {peer}"); + let acceptor = acceptor.clone(); + // 每条连接把对端 IP 以 Extension 挂进 router,供 PIN 按 IP 锁定。 + let router = router.clone().layer(axum::Extension(PeerIp(peer.ip()))); + tokio::spawn(async move { + let tls = match acceptor.accept(tcp).await { + Ok(t) => t, + Err(e) => { + log::warn!("[remote-input] 来自 {peer} 的 TLS 握手失败(证书没被接受):{e}"); + return; + } + }; + let io = TokioIo::new(tls); + let svc = hyper_util::service::TowerToHyperService::new(router); + let _ = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(io, svc) + .await; + }); + } + } + } + }); + + Ok(RemoteServerHandle { + shutdown_tx: Some(shutdown_tx), + conn_shutdown_tx, + join, + bound_port, + pin: cfg.pin, + }) +} + +// ───────────────────────── WebSocket ───────────────────────── + +async fn ws_upgrade( + State(state): State>, + axum::Extension(PeerIp(peer_ip)): axum::Extension, + ws: WebSocketUpgrade, +) -> impl IntoResponse { + // 能走到这里说明 wss 的 TLS 握手已成功(证书被手机接受)。排查"连不上"时看有没有 + // 这行:没有 = 卡在 TLS/证书(握手就失败);有 = 握手 OK,问题在认证/后续逻辑。 + log::info!("[remote-input] WS 已升级:手机已通过 wss 接入(TLS/证书 OK)"); + ws.on_upgrade(move |socket| handle_ws(socket, state, peer_ip)) +} + +fn send_json(value: &T) -> Message { + Message::Text(serde_json::to_string(value).unwrap_or_else(|_| "{}".into())) +} + +/// 把后端 capsule 事件 payload 映射成手机端 status / level JSON 文本。 +fn capsule_payload_to_phone(payload: &str) -> Vec { + let v: serde_json::Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let state = v.get("state").and_then(|s| s.as_str()).unwrap_or(""); + let kind = match state { + s if s.eq_ignore_ascii_case("recording") => "recording", + s if s.eq_ignore_ascii_case("transcribing") => "transcribing", + s if s.eq_ignore_ascii_case("polishing") => "polishing", + s if s.eq_ignore_ascii_case("done") => "done", + s if s.eq_ignore_ascii_case("error") => "error", + s if s.eq_ignore_ascii_case("cancelled") => "done", + _ => "", + }; + let mut out = Vec::new(); + if !kind.is_empty() { + let inserted = v + .get("insertedChars") + .or_else(|| v.get("inserted_chars")) + .and_then(|n| n.as_u64()); + let message = v.get("message").and_then(|m| m.as_str()); + out.push( + serde_json::json!({ + "type": "status", + "kind": kind, + "insertedChars": inserted, + "message": message, + }) + .to_string(), + ); + } + if let Some(level) = v.get("level").and_then(|l| l.as_f64()) { + if state.eq_ignore_ascii_case("recording") { + out.push(serde_json::json!({"type": "level", "value": level}).to_string()); + } + } + out +} + +async fn handle_ws(mut socket: WebSocket, state: Arc, peer_ip: IpAddr) { + // 1) 握手:等第一帧 hello + PIN。 + let authed = match tokio::time::timeout(Duration::from_secs(15), socket.recv()).await { + Ok(Some(Ok(Message::Text(txt)))) => verify_hello(&txt, &state, peer_ip), + _ => return, // 超时 / 非文本首帧 / 断开 + }; + match authed { + AuthResult::Ok => { + log::info!("[remote-input] 配对成功,进入录音会话"); + let _ = socket.send(send_json(&serde_json::json!({"type":"auth","ok":true}))).await; + } + AuthResult::BadPin => { + log::warn!("[remote-input] 配对码错误,已拒绝"); + let _ = socket + .send(send_json(&serde_json::json!({"type":"auth","ok":false,"reason":"bad-pin"}))) + .await; + return; + } + AuthResult::Locked => { + log::warn!("[remote-input] 配对已锁定(连续错误过多),已拒绝"); + let _ = socket + .send(send_json(&serde_json::json!({"type":"auth","ok":false,"reason":"locked"}))) + .await; + return; + } + } + + // 2) 订阅 capsule 事件,转发给手机做状态显示。 + let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::(); + let listener_id = { + let tx = evt_tx.clone(); + // 必须用 listen_any:capsule 状态是通过 emit_to("capsule", …) 定向发给胶囊 + // 窗口的,普通 app.listen(target=App) 收不到定向事件 —— 那样手机永远收不到 + // done/polishing 等状态,会一直卡在前端本地设的"识别中"。listen_any 接收 + // 所有 target 的事件,把胶囊状态如实转发给手机。 + state.app.listen_any("capsule:state", move |event| { + for msg in capsule_payload_to_phone(event.payload()) { + let _ = tx.send(msg); + } + }) + }; + + // 听写完成后 PC 端把最终文字 emit 到 "remote:result"。手机用户看不到电脑屏幕, + // 所以把这次落下的完整文字转发过去,H5 在状态区下方显示(type=result)。 + let result_listener_id = { + let tx = evt_tx.clone(); + state.app.listen_any("remote:result", move |event| { + // emit 的是 String,payload 是带引号的 JSON 字符串,反序列化回纯文本。 + if let Ok(text) = serde_json::from_str::(event.payload()) { + let _ = tx.send(serde_json::json!({ "type": "result", "text": text }).to_string()); + } + }) + }; + + // 3) 主循环:手机上行(控制 / PCM) + 后端状态下行 + keepalive 探活 + 关停广播。 + let mut conn_shutdown_rx = state.conn_shutdown_rx.clone(); + let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_PING_SECS)); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last_rx = Instant::now(); + loop { + tokio::select! { + incoming = socket.recv() => { + last_rx = Instant::now(); + match incoming { + Some(Ok(Message::Binary(pcm))) => { + if pcm.len() >= 2 && pcm.len() % 2 == 0 && pcm.len() <= MAX_PCM_FRAME_BYTES { + state.coordinator.feed_remote_pcm(&pcm); + } + } + Some(Ok(Message::Text(txt))) => { + if !handle_control(&txt, &state, &mut socket).await { + break; + } + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + _ => {} + } + } + Some(msg) = evt_rx.recv() => { + if socket.send(Message::Text(msg)).await.is_err() { + break; + } + } + _ = keepalive.tick() => { + // 半开探活:浏览器收到 Ping 自动回 Pong(上面 recv 收到即刷新 last_rx)。 + // 超时无任何上行 → 死链,break 走下方统一收尾(cancel + unlisten), + // 避免录音中掉线时远程会话与标志悬挂。 + if last_rx.elapsed() > Duration::from_secs(IDLE_TIMEOUT_SECS) { + log::info!("[remote-input] 连接 {}s 无上行(含 Pong),按半开死链断开", IDLE_TIMEOUT_SECS); + break; + } + if socket.send(Message::Ping(Vec::new())).await.is_err() { + break; + } + } + changed = conn_shutdown_rx.changed() => { + // 服务关停(用户关闭远程输入 / 重置 PIN / 改端口触发重启): + // 主动断开存量连接,撤销已配对手机的会话与落字能力。 + if changed.is_err() || *conn_shutdown_rx.borrow() { + log::info!("[remote-input] 服务关停,断开存量手机连接"); + break; + } + } + } + } + + // 4) 收尾:断连即取消未完成的远程会话,避免 ASR 句柄悬挂。 + log::info!("[remote-input] WS 连接已关闭"); + state.app.unlisten(listener_id); + state.app.unlisten(result_listener_id); + state.coordinator.cancel_remote_dictation(); +} + +/// 返回 false 表示应断开连接。 +async fn handle_control(txt: &str, state: &Arc, socket: &mut WebSocket) -> bool { + let v: serde_json::Value = match serde_json::from_str(txt) { + Ok(v) => v, + Err(_) => return true, + }; + match v.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "start" => { + log::info!("[remote-input] 收到「开始录音」"); + match state.coordinator.start_remote_dictation().await { + Ok(()) => {} + Err(reason) => { + log::warn!("[remote-input] 开始录音被拒:{reason}"); + let _ = socket + .send(send_json(&serde_json::json!({"type":"busy","reason":reason}))) + .await; + } + } + } + "stop" => { + log::info!("[remote-input] 收到「结束录音」"); + let _ = state.coordinator.stop_remote_dictation().await; + } + "cancel" => { + state.coordinator.cancel_remote_dictation(); + } + "set_insert" => { + // 手机端「电脑落字」开关:value=true 表示要落字。no_insert = !value。 + let insert = v.get("value").and_then(|b| b.as_bool()).unwrap_or(true); + state.coordinator.set_remote_no_insert(!insert); + log::info!("[remote-input] 电脑落字开关 = {insert}"); + } + _ => {} + } + true +} + +enum AuthResult { + Ok, + BadPin, + Locked, +} + +fn verify_hello(txt: &str, state: &Arc, peer_ip: IpAddr) -> AuthResult { + // PIN 比较在锁外完成(无共享状态;constant_time_eq 防计时侧信道)。 + let v: serde_json::Value = match serde_json::from_str(txt) { + Ok(v) => v, + Err(_) => serde_json::Value::Null, // 非法 JSON 按 BadPin 计数 + }; + let pin_ok = v.get("type").and_then(|t| t.as_str()) == Some("hello") + && v.get("pin") + .and_then(|p| p.as_str()) + .map(|p| constant_time_eq(p.as_bytes(), state.pin.as_bytes())) + .unwrap_or(false); + + // 锁定检查与失败累计放同一临界区:之前分两次拿锁,同一 IP 的并发握手可以 + // 都先通过锁定检查再各自累计失败,让计数越过阈值却不触发锁定。 + let now = Instant::now(); + let mut guard = state.pin_fails.lock(); + if let Some((_, Some(until))) = guard.get(&peer_ip) { + if now < *until { + return AuthResult::Locked; + } + // 锁定到期,重置该 IP + guard.remove(&peer_ip); + } + if pin_ok { + guard.remove(&peer_ip); + AuthResult::Ok + } else { + // 容量兜底:先丢已解锁/过期的条目,防伪造海量源 IP 撑爆表。 + if guard.len() >= PIN_FAILS_MAX_ENTRIES { + guard.retain(|_, (_, until)| matches!(until, Some(t) if *t > now)); + } + let entry = guard.entry(peer_ip).or_insert((0, None)); + entry.0 += 1; + if entry.0 >= PIN_MAX_FAILS { + entry.1 = Some(now + Duration::from_secs(PIN_LOCK_SECS)); + } + AuthResult::BadPin + } +} + +/// 等长常量时间比较,避免 PIN 计时侧信道。 +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 97124109..7df37f60 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -749,6 +749,27 @@ pub struct UserPreferences { /// 上传 / 点赞需要带这个 header;空时上传被后端 401。 #[serde(default)] pub marketplace_dev_login: String, + /// ── 远程输入(局域网手机录音)──────────────────────────────── + /// 是否启用远程输入 HTTPS+WS 服务。默认 false(关闭,按需手动开启)。 + #[serde(default)] + pub remote_input_enabled: bool, + /// 远程输入服务监听端口(HTTPS)。默认 8443。 + #[serde(default = "default_remote_input_port")] + pub remote_input_port: u16, + /// 远程输入配对码(6 位数字)。空 = server 首次启动时随机生成并回写。 + #[serde(default)] + pub remote_input_pin: String, + /// 手机录音页默认交互方式:"toggle"(点击切换)/ "hold"(按住说话)。 + #[serde(default = "default_remote_input_mode")] + pub remote_input_default_mode: String, +} + +fn default_remote_input_port() -> u16 { + 8443 +} + +fn default_remote_input_mode() -> String { + "toggle".into() } fn default_local_asr_model() -> String { @@ -898,6 +919,14 @@ struct UserPreferencesWire { marketplace_base_url: String, #[serde(default)] marketplace_dev_login: String, + #[serde(default)] + remote_input_enabled: bool, + #[serde(default = "default_remote_input_port")] + remote_input_port: u16, + #[serde(default)] + remote_input_pin: String, + #[serde(default = "default_remote_input_mode")] + remote_input_default_mode: String, } impl Default for UserPreferencesWire { @@ -965,6 +994,10 @@ impl Default for UserPreferencesWire { audio_recording_max_entries: prefs.audio_recording_max_entries, marketplace_base_url: prefs.marketplace_base_url, marketplace_dev_login: prefs.marketplace_dev_login, + remote_input_enabled: prefs.remote_input_enabled, + remote_input_port: prefs.remote_input_port, + remote_input_pin: prefs.remote_input_pin, + remote_input_default_mode: prefs.remote_input_default_mode, } } } @@ -1061,6 +1094,10 @@ impl<'de> Deserialize<'de> for UserPreferences { audio_recording_max_entries: wire.audio_recording_max_entries, marketplace_base_url: wire.marketplace_base_url, marketplace_dev_login: wire.marketplace_dev_login, + remote_input_enabled: wire.remote_input_enabled, + remote_input_port: wire.remote_input_port, + remote_input_pin: wire.remote_input_pin, + remote_input_default_mode: wire.remote_input_default_mode, }) } } @@ -1789,6 +1826,10 @@ impl Default for UserPreferences { audio_recording_max_entries: None, marketplace_base_url: String::new(), marketplace_dev_login: String::new(), + remote_input_enabled: false, + remote_input_port: default_remote_input_port(), + remote_input_pin: String::new(), + remote_input_default_mode: default_remote_input_mode(), } } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 326e2c07..cc262ff2 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -852,6 +852,24 @@ export const en: typeof zhCN = { ko: '한국어 (Beta)', restartHint: 'Some native menus (system tray, etc.) may require an app restart to fully switch.', }, + remoteInput: { + title: 'Remote Input', + enableLabel: 'Enable remote input', + enableDesc: 'Record from a phone/tablet browser on your LAN; speech is typed at your computer\'s cursor (HTTPS required; trust the certificate on first visit)', + portLabel: 'Port', + defaultModeLabel: 'Default recording mode', + modeToggle: 'Tap to toggle', + modeHold: 'Hold to talk', + urlLabel: 'Access URL', + pinLabel: 'Pairing code', + regeneratePin: 'Regenerate', + portInUse: 'Port {{port}} is in use, please change it', + startError: 'Failed to start the remote input service: {{reason}}', + securityHint: 'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.', + certHint: 'On first visit the browser warns the certificate is untrusted — choose "Proceed".', + certTrustWarning: + 'The certificate is only used by this PC’s remote input service (it cannot issue other certificates). Never trust certificates from unknown sources; remove it from your phone’s settings when no longer needed.', + }, about: { tagline: 'Speak naturally, write perfectly', checkUpdate: 'Check for updates', diff --git a/openless-all/app/src/i18n/index.ts b/openless-all/app/src/i18n/index.ts index 7a2b6ef7..93ba05b6 100644 --- a/openless-all/app/src/i18n/index.ts +++ b/openless-all/app/src/i18n/index.ts @@ -16,6 +16,7 @@ import { ko } from './ko'; import { zhCN } from './zh-CN'; import { zhTW } from './zh-TW'; import type { UserPreferences } from '../lib/types'; +import { setRemoteLocale } from '../lib/ipc'; export const SUPPORTED_LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko'] as const; export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; @@ -88,9 +89,18 @@ export async function setLocalePreference( window.localStorage.setItem(LOCALE_STORAGE_KEY, pref); } await i18n.changeLanguage(resolved); + syncRemoteLocale(resolved); return resolved; } +// 远程输入 H5 录音页跟随 PC 界面语言:把已解析的 locale 推给后端(后端只存内存 +// 镜像,H5 请求首页时据此渲染)。非 Tauri(浏览器 dev)环境走 mock no-op,失败静默。 +function syncRemoteLocale(resolved: SupportedLocale): void { + void setRemoteLocale(resolved).catch(() => {}); +} +// 启动时同步一次当前语言,覆盖“开机即自动开启远程服务”的场景。 +syncRemoteLocale(i18n.language as SupportedLocale); + export const FOLLOW_SYSTEM = FOLLOW_SYSTEM_VALUE; export function outputPrefsForLocale( diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c3d695c0..bafa6309 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -854,6 +854,24 @@ export const ja: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '一部のネイティブメニュー(トレイ等)は再起動後に反映されます。', }, + remoteInput: { + title: 'リモート入力', + enableLabel: 'リモート入力を有効化', + enableDesc: 'スマホ/タブレットのブラウザから PC に接続して録音し、音声を PC のカーソル位置にリアルタイムで入力します(HTTPS が必要。初回アクセス時は証明書を信頼してください)', + portLabel: '待ち受けポート', + defaultModeLabel: '既定の録音方式', + modeToggle: 'タップで切替', + modeHold: '押し続けて話す', + urlLabel: 'アクセス URL', + pinLabel: 'ペアリングコード', + regeneratePin: '再生成', + portInUse: 'ポート {{port}} は使用中です。変更してください', + startError: 'リモート入力サービスの起動に失敗しました:{{reason}}', + securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。', + certHint: '初回アクセス時、ブラウザが証明書は信頼されていないと警告します。案内に従って「続行」を選択してください。', + certTrustWarning: + 'この証明書は本機のリモート入力サービス専用です(他の証明書を発行できません)。出所不明の証明書は信頼しないでください。不要になったらスマートフォンの設定から削除できます。', + }, about: { tagline: '自然に話し、きれいに書く', checkUpdate: 'アップデート確認', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0633be29..92122c12 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -854,6 +854,24 @@ export const ko: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.', }, + remoteInput: { + title: '원격 입력', + enableLabel: '원격 입력 활성화', + enableDesc: '휴대폰/태블릿 브라우저를 PC에 연결해 녹음하고, 음성을 PC 커서 위치에 실시간으로 입력합니다(HTTPS 필요, 첫 접속 시 인증서를 신뢰해야 함)', + portLabel: '수신 포트', + defaultModeLabel: '기본 녹음 방식', + modeToggle: '탭하여 전환', + modeHold: '눌러서 말하기', + urlLabel: '접속 URL', + pinLabel: '페어링 코드', + regeneratePin: '재생성', + portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요', + startError: '원격 입력 서비스 시작에 실패했습니다: {{reason}}', + securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.', + certHint: '첫 접속 시 브라우저가 인증서를 신뢰할 수 없다고 경고합니다. 안내에 따라 "계속 진행"을 선택하세요.', + certTrustWarning: + '이 인증서는 이 PC의 원격 입력 서비스 전용입니다(다른 인증서를 발급할 수 없음). 출처를 알 수 없는 인증서는 신뢰하지 마세요. 더 이상 사용하지 않으면 휴대폰 설정에서 제거할 수 있습니다.', + }, about: { tagline: '자연스럽게 말하고, 정확하게 작성하세요', checkUpdate: '업데이트 확인', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d7844014..6f5d4f3d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -850,6 +850,24 @@ export const zhCN = { ko: '한국어 (Beta)', restartHint: '部分原生菜单(系统托盘等)可能需要重启 App 才会切换。', }, + remoteInput: { + title: '远程输入', + enableLabel: '启用远程输入', + enableDesc: '手机/平板浏览器连到电脑录音,语音实时落到电脑光标处(需 HTTPS,首次访问要信任证书)', + portLabel: '监听端口', + defaultModeLabel: '默认录音方式', + modeToggle: '点击切换', + modeHold: '按住说话', + urlLabel: '访问网址', + pinLabel: '配对码', + regeneratePin: '重新生成', + portInUse: '端口 {{port}} 被占用,请更换', + startError: '远程输入服务启动失败:{{reason}}', + securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。', + certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。', + certTrustWarning: + '该证书仅用于本机远程输入服务(不能签发其他证书),请勿信任来源不明的证书;不再使用时可在手机系统设置中移除。', + }, about: { tagline: '自然说话,完美书写', checkUpdate: '检查更新', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f01e6be2..b825588e 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -852,6 +852,24 @@ export const zhTW: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。', }, + remoteInput: { + title: '遠端輸入', + enableLabel: '啟用遠端輸入', + enableDesc: '手機/平板瀏覽器連到電腦錄音,語音即時落到電腦游標處(需 HTTPS,首次存取要信任憑證)', + portLabel: '監聽連接埠', + defaultModeLabel: '預設錄音方式', + modeToggle: '點擊切換', + modeHold: '按住說話', + urlLabel: '存取網址', + pinLabel: '配對碼', + regeneratePin: '重新產生', + portInUse: '連接埠 {{port}} 被佔用,請更換', + startError: '遠端輸入服務啟動失敗:{{reason}}', + securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。', + certHint: '首次存取瀏覽器會提示憑證不受信任,按提示選擇「繼續存取」。', + certTrustWarning: + '該憑證僅用於本機遠端輸入服務(無法簽發其他憑證),請勿信任來源不明的憑證;不再使用時可在手機系統設定中移除。', + }, about: { tagline: '自然說話,完美書寫', checkUpdate: '檢查更新', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 8f8d246b..11edebff 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -132,6 +132,10 @@ let mockSettings: UserPreferences = { audioRecordingMaxEntries: null, marketplaceBaseUrl: "https://apic.openless.top", marketplaceDevLogin: "", + remoteInputEnabled: false, + remoteInputPort: 8443, + remoteInputPin: "000000", + remoteInputDefaultMode: "toggle", } const mockFullStylePrompts: StyleSystemPrompts = { @@ -533,6 +537,36 @@ export function setSettings(prefs: UserPreferences): Promise { }) } +// ── Remote input (局域网手机录音) ────────────────────────────────────── +export interface RemoteInputStatus { + running: boolean + port: number + pin: string + urls: string[] +} + +export function getRemoteInputStatus(): Promise { + return invokeOrMock("get_remote_input_status", undefined, () => ({ + running: false, + port: 8443, + pin: "000000", + urls: [], + })) +} + +export function listLocalIps(): Promise { + return invokeOrMock("list_local_ips", undefined, () => ["192.168.1.100"]) +} + +export function regenerateRemotePin(): Promise { + return invokeOrMock("regenerate_remote_pin", undefined, () => "123456") +} + +/** 把 PC 端界面语言同步给远程输入服务,H5 录音页据此显示对应语言。 */ +export function setRemoteLocale(locale: string): Promise { + return invokeOrMock("set_remote_locale", { locale }, () => undefined) +} + // ── Release channel (Beta opt-in) ────────────────────────────────────── // 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义; // 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。 diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index dc4d43f1..fd503121 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -80,6 +80,10 @@ const previousPrefs: UserPreferences = { audioRecordingMaxEntries: null, marketplaceBaseUrl: '', marketplaceDevLogin: '', + remoteInputEnabled: false, + remoteInputPort: 8443, + remoteInputPin: '000000', + remoteInputDefaultMode: 'toggle', }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 343c54de..408bce47 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -338,6 +338,14 @@ export interface UserPreferences { marketplaceBaseUrl: string; /** Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。 */ marketplaceDevLogin: string; + /** 是否启用远程输入(局域网手机录音)HTTPS+WS 服务。默认 false。 */ + remoteInputEnabled: boolean; + /** 远程输入服务监听端口(HTTPS)。默认 8443。 */ + remoteInputPort: number; + /** 远程输入配对码(6 位数字)。空 = server 首次启动时随机生成。 */ + remoteInputPin: string; + /** 手机录音页默认交互方式:'toggle'(点击切换)/ 'hold'(按住说话)。 */ + remoteInputDefaultMode: 'toggle' | 'hold'; } export interface MarketplaceListItem { diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx new file mode 100644 index 00000000..002a4c6c --- /dev/null +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -0,0 +1,275 @@ +// 远程输入:在局域网用手机/平板浏览器打开一个录音页,语音实时流回电脑,复用 +// 电脑现有的「录音→ASR→润色→光标落字」管线。放在「通用」标签页里,做成可折叠组 +// (与「启动」一致,默认折叠):启停开关、监听端口、访问网址(可一键复制,带配对码)、 +// 配对码(可重置)、默认录音方式,以及证书/安全提示。 + +import { useEffect, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Collapsible } from '../_atoms'; +import { SettingRow, Toggle, inputStyle } from './shared'; +import { + getRemoteInputStatus, + regenerateRemotePin, + setRemoteLocale, + isTauri, + type RemoteInputStatus, +} from '../../lib/ipc'; + +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // 退路:隐藏 textarea + execCommand,兼容个别不支持 async clipboard 的环境。 + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + } catch { + /* ignore */ + } + document.body.removeChild(ta); + } +} + +export function RemoteInputSection() { + const { t, i18n } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const [status, setStatus] = useState(null); + const [startError, setStartError] = useState<{ reason: string; port: number } | null>(null); + const [copied, setCopied] = useState(null); + // 端口编辑草稿:失焦/回车时才解析提交,避免逐键持久化导致后端服务在中间值端口反复重启。 + const [portDraft, setPortDraft] = useState(null); + + useEffect(() => { + let alive = true; + const refresh = () => + getRemoteInputStatus() + .then((s) => alive && setStatus(s)) + .catch(() => {}); + refresh(); + // 进设置页时把当前界面语言同步给远程服务,确保 H5 录音页语言与 PC 一致。 + void setRemoteLocale(i18n.language).catch(() => {}); + if (!isTauri) return; + const unsubs: Array<() => void> = []; + import('@tauri-apps/api/event').then(({ listen }) => { + listen('remote-input:running', () => { + if (!alive) return; + setStartError(null); + refresh(); + }).then((u) => { + // 异步注册完成时组件可能已卸载,立即退订避免监听器泄漏。 + if (!alive) { + u(); + } else { + unsubs.push(u); + } + }); + listen('remote-input:error', (e) => { + if (!alive) return; + const p = e.payload as { reason?: string; port?: number } | null; + setStartError({ reason: p?.reason ?? '', port: p?.port ?? 0 }); + }).then((u) => { + if (!alive) { + u(); + } else { + unsubs.push(u); + } + }); + }); + return () => { + alive = false; + unsubs.forEach((u) => u()); + }; + }, []); + + if (!prefs) return null; + const enabled = prefs.remoteInputEnabled; + const mode = prefs.remoteInputDefaultMode ?? 'toggle'; + + // 提交端口草稿:非法(非有限数/越界离谱)则丢弃还原显示,合法则取整并 clamp 到 [1024, 65535]。 + const commitPort = () => { + if (portDraft == null) return; + const n = Math.round(Number(portDraft)); + if (!Number.isFinite(n) || n <= 0) { + setPortDraft(null); + return; + } + const port = Math.max(1024, Math.min(65535, n)); + setPortDraft(null); + if (port !== prefs.remoteInputPort) { + updatePrefs({ ...prefs, remoteInputPort: port }); + } + }; + + const doCopy = async (url: string, pin: string) => { + await copyText(`${url}\n${t('settings.remoteInput.pinLabel')}:${pin}`); + setCopied(url); + window.setTimeout(() => setCopied((c) => (c === url ? null : c)), 1500); + }; + + const smallBtn: CSSProperties = { + padding: '4px 10px', + borderRadius: 8, + fontSize: 12, + cursor: 'pointer', + border: '0.5px solid var(--ol-line-strong)', + background: 'var(--ol-surface-2)', + color: 'var(--ol-ink)', + flexShrink: 0, + }; + + return ( + + + updatePrefs({ ...prefs, remoteInputEnabled: v })} + /> + + + + setPortDraft(e.currentTarget.value)} + onBlur={commitPort} + onKeyDown={(e) => { + if (e.key === 'Enter') commitPort(); + }} + /> + + + +
+ {(['toggle', 'hold'] as const).map((m) => ( + + ))} +
+
+ + {enabled && status?.running && ( + <> + +
+ {(status.urls.length + ? status.urls + : [`https://localhost:${status.port}`] + ).map((u) => ( +
+ + {u} + + +
+ ))} +
+
+ + +
+ + {status.pin} + + +
+
+ + )} + + {enabled && startError != null && ( +
+ {startError.reason === 'port-in-use' + ? t('settings.remoteInput.portInUse', { port: startError.port }) + : t('settings.remoteInput.startError', { reason: startError.reason })} +
+ )} + +
+ {t('settings.remoteInput.securityHint')} +
+ {t('settings.remoteInput.certHint')} +
+ {t('settings.remoteInput.certTrustWarning')} +
+
+ ); +} diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 32fae96b..959b909e 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { RecordingInputSection } from './RecordingInputSection'; +import { RemoteInputSection } from './RemoteInputSection'; import { ShortcutsSection } from './ShortcutsSection'; import { LanguageSection } from './LanguageSection'; import { ProvidersSection } from './ProvidersSection'; @@ -21,6 +22,7 @@ export function GeneralTab() { return ( <> +