Merge pull request #618 from ciddwd/feat/mobile-remote-input

Feat/mobile remote input
This commit is contained in:
Chris233 authored and GitHub committed 2026-06-11 16:58:16 +08:00
commit 91023ed806
28 files changed
+3873 -33

No files matched your search

+190 -9
View File
@@ -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"
+8
View File
@@ -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"
@@ -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 重导出无人引用,会触发
@@ -0,0 +1,34 @@
//! 远程输入(局域网手机录音)命令面。
//!
//! 手机在同一局域网用浏览器打开 `https://<PC-IP>:<port>` 的 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<String> {
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);
}
@@ -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(())
}
+245 -1
View File
@@ -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<Option<Arc<dyn crate::recorder::AudioConsumer>>>,
/// 远程输入 HTTPS+WS 服务句柄。None = 未启动。
remote_server: Mutex<Option<crate::remote_server::RemoteServerHandle>>,
/// 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<Option<String>>,
/// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步,
/// H5 录音页据此渲染对应语言。进程内镜像,不持久化(前端会在启动/切换时重新下发)。
remote_locale: Mutex<String>,
/// 远程「仅回传」开关: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),
}),
}
@@ -1024,6 +1063,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<Self>) -> 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<Self>) {
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 转发的备用触发路径。
@@ -1213,3 +1447,13 @@ fn set_phase_idle_if_session_matches(inner: &Arc<Inner>, 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;
}
@@ -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<Inner>,
}
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<Inner>) -> 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 {
@@ -538,13 +558,18 @@ pub(crate) async fn end_session(inner: &Arc<Inner>) -> 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}"
);
@@ -653,7 +678,14 @@ pub(crate) async fn end_session(inner: &Arc<Inner>) -> 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(),
@@ -780,6 +812,14 @@ pub(crate) async fn end_session(inner: &Arc<Inner>) -> 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;
@@ -18,13 +18,36 @@ pub(crate) fn request_stop_during_starting(inner: &Arc<Inner>, reason: &str) {
}
pub(crate) async fn begin_session(inner: &Arc<Inner>) -> 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<Inner>,
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<Inner>) -> 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<dyn crate::recorder::AudioConsumer>,
) -> 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<Inner>, 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<Inner>) {
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。
+7
View File
@@ -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,
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="%%OL_LANG%%">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0f1115">
<title>OpenLess</title>
<link rel="stylesheet" href="/style.css">
<script>window.__OL_LANG__ = "%%OL_LANG%%";</script>
</head>
<body>
<div id="app">
<!-- ===== 首屏:PIN 配对 ===== -->
<section id="screen-pin" class="screen active">
<div class="brand">
<img class="brand-logo-img" src="/icon.png" alt="OpenLess" />
<h1 class="brand-title" data-i18n="brandTitle">OpenLess 远程输入</h1>
<p class="brand-sub" data-i18n="brandSub">在手机上录音,实时输入到电脑</p>
</div>
<div class="card">
<label class="field-label" for="pin-input" data-i18n="pinFieldLabel">配对码(电脑上显示的 6 位数字)</label>
<input
id="pin-input"
class="pin-input"
type="text"
inputmode="numeric"
autocomplete="off"
maxlength="6"
pattern="[0-9]*"
placeholder="••••••" />
<p id="pin-error" class="hint-error" hidden></p>
<button id="btn-connect" class="btn btn-primary" data-i18n="btnConnect">连接</button>
</div>
<!-- 连接帮助:证书信任引导(手机上直接照做)。暂时隐藏(用户要求),需要时去掉 hidden 即可恢复。 -->
<div class="help" hidden>
<div class="help-title" data-i18n="helpTitle">连不上?多半是手机没信任证书</div>
<p class="help-step" data-i18n="helpAndroid">① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。</p>
<p class="help-step" data-i18n="helpIos">② iOS Safari 必须装证书:点下方按钮下载并安装描述文件,然后到「设置 → 通用 → 关于本机 → 证书信任设置」里打开它的“完全信任”,再回来连接。</p>
<div class="help-actions">
<a class="help-link" href="/cert.cer" data-i18n="helpDownloadCert">⬇ 下载并安装证书</a>
<button type="button" class="help-link help-link-ghost" id="copy-cert-link" data-i18n="helpCopyLink">⧉ 复制链接</button>
</div>
</div>
</section>
<!-- ===== 录音屏 ===== -->
<section id="screen-rec" class="screen">
<header class="rec-header">
<img class="app-icon" id="app-icon" src="/icon.png" alt="OpenLess" />
<span class="rec-header-title">OpenLess</span>
<div class="mode-switch" id="mode-switch" role="group" aria-label="录音方式">
<button class="mode-btn" data-mode="toggle" type="button" data-i18n="modeToggle">点按</button>
<button class="mode-btn" data-mode="hold" type="button" data-i18n="modeHold">按住</button>
</div>
</header>
<div class="rec-main">
<button id="btn-record" class="record-btn" type="button" aria-label="录音按钮">
<span class="record-btn-ring"></span>
<img class="record-btn-icon" src="/mic.png" alt="" />
<span class="record-btn-label" id="record-label">点击开始</span>
</button>
<div class="level-wrap" aria-hidden="true">
<div class="level-bar" id="level-bar"></div>
</div>
<div class="status-bar" id="status-bar">
<img class="status-icon" id="status-icon" alt="" hidden />
<span class="dots" id="status-dots" aria-hidden="true" hidden><i></i><i></i><i></i></span>
<span class="status-text" id="status-text">准备就绪</span>
</div>
<!-- 电脑落字完成后回传的最终文字,让手机端直接看到这次识别/输入的结果 -->
<div class="result-wrap" id="result-wrap" hidden>
<div class="result-text" id="result-text"></div>
<button class="result-copy" id="result-copy" type="button" data-i18n="copy">复制</button>
</div>
</div>
<p class="rec-tip" id="rec-tip">点击大按钮开始录音,再次点击结束并识别。</p>
<!-- 电脑落字开关:关掉则这次只把文字回传到手机,不落到电脑光标 -->
<label class="insert-toggle">
<span class="insert-toggle-text" data-i18n="insertLabel">电脑落字</span>
<input type="checkbox" id="insert-switch" class="insert-switch" checked />
<span class="insert-track" aria-hidden="true"></span>
</label>
</section>
<!-- ===== 断线屏 ===== -->
<section id="screen-offline" class="screen">
<div class="card card-center">
<div class="offline-icon">📵</div>
<h2 class="offline-title" data-i18n="offlineTitle">连接已断开</h2>
<p class="offline-sub" id="offline-reason">与电脑的连接已中断。</p>
<button id="btn-reconnect" class="btn btn-primary" data-i18n="btnReconnect">重新连接</button>
</div>
</section>
</div>
<script src="/app.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -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; }
}
@@ -0,0 +1,748 @@
//! 远程输入(局域网手机录音)的 HTTPS + WebSocket 服务。
//!
//! 手机在同一局域网用浏览器打开 `https://<PC-IP>:<port>`,得到一个录音页
//! (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<Coordinator>,
pub app: AppHandle,
}
/// 运行中的服务句柄。drop / shutdown 触发优雅关停。
pub struct RemoteServerHandle {
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
/// 广播给所有已建立 WS 连接的关停信号。只停 accept loop 是不够的:连接任务
/// 是独立 spawn 的,不通知它们的话,用户关掉远程输入(或重置 PIN 触发重启)
/// 后已配对的手机会话原样存活,仍能录音、向 PC 光标落字——撤销语义失效。
conn_shutdown_tx: tokio::sync::watch::Sender<bool>,
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<String>,
}
// ───────────────────────── 工具函数 ─────────────────────────
/// 生成 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<std::path::PathBuf> {
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<Ipv4Addr> {
let mut out: Vec<Ipv4Addr> = 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<String> {
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<u8>, 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<u8>,
key_der: rustls::pki_types::PrivateKeyDer<'static>,
) -> Result<Arc<rustls::ServerConfig>, 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<Coordinator>,
app: AppHandle,
/// 按源 IP 的 PIN 失败计数 + 锁定截止时刻(防爆破;TLS+6 位 PIN 已是主防线)。
pin_fails: Mutex<std::collections::HashMap<IpAddr, (u32, Option<Instant>)>>,
/// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。
cert_der: Vec<u8>,
/// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。
conn_shutdown_rx: tokio::sync::watch::Receiver<bool>,
}
/// 经 accept loop 注入的对端 IP(axum Extension)。hyper 直连 TLS 流时拿不到
/// ConnectInfo,这里在每条连接的 service 上挂一层 Extension 把 peer 传进 handler。
#[derive(Clone, Copy)]
struct PeerIp(IpAddr);
fn build_router(state: Arc<WsState>) -> 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<Arc<WsState>>| 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__ / <html lang>)选择显示语言。
async fn index_handler(State(state): State<Arc<WsState>>) -> 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<Arc<WsState>>) -> impl IntoResponse {
let b64 = base64_encode(&state.cert_der);
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>PayloadContent</key><array><dict><key>PayloadCertificateFileName</key><string>openless.cer</string><key>PayloadContent</key><data>{b64}</data><key>PayloadType</key><string>com.apple.security.root</string><key>PayloadIdentifier</key><string>com.openless.remote-input.cert</string><key>PayloadUUID</key><string>A1B2C3D4-0001-4000-8000-000000000001</string><key>PayloadVersion</key><integer>1</integer><key>PayloadDisplayName</key><string>OpenLess Remote Input Certificate</string></dict></array><key>PayloadDisplayName</key><string>OpenLess Remote Input</string><key>PayloadIdentifier</key><string>com.openless.remote-input</string><key>PayloadType</key><string>Configuration</string><key>PayloadUUID</key><string>A1B2C3D4-0002-4000-8000-000000000002</string><key>PayloadVersion</key><integer>1</integer></dict></plist>"#,
b64 = b64
);
(
[(
axum::http::header::CONTENT_TYPE,
"application/x-apple-aspen-config",
)],
xml,
)
}
pub async fn start(cfg: RemoteServerConfig) -> Result<RemoteServerHandle, String> {
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<Arc<WsState>>,
axum::Extension(PeerIp(peer_ip)): axum::Extension<PeerIp>,
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<T: Serialize>(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<String> {
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<WsState>, 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::<String>();
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::<String>(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<WsState>, 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<WsState>, 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
}
+41
View File
@@ -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(),
}
}
}
+18
View File
@@ -855,6 +855,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',
+10
View File
@@ -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(
+18
View File
@@ -857,6 +857,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: 'アップデート確認',
+18
View File
@@ -857,6 +857,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: '업데이트 확인',
+18
View File
@@ -853,6 +853,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: '检查更新',
+18
View File
@@ -855,6 +855,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: '檢查更新',
+34
View File
@@ -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<void> {
})
}
// ── Remote input (局域网手机录音) ──────────────────────────────────────
export interface RemoteInputStatus {
running: boolean
port: number
pin: string
urls: string[]
}
export function getRemoteInputStatus(): Promise<RemoteInputStatus> {
return invokeOrMock("get_remote_input_status", undefined, () => ({
running: false,
port: 8443,
pin: "000000",
urls: [],
}))
}
export function listLocalIps(): Promise<string[]> {
return invokeOrMock("list_local_ips", undefined, () => ["192.168.1.100"])
}
export function regenerateRemotePin(): Promise<string> {
return invokeOrMock("regenerate_remote_pin", undefined, () => "123456")
}
/** 把 PC 端界面语言同步给远程输入服务,H5 录音页据此显示对应语言。 */
export function setRemoteLocale(locale: string): Promise<void> {
return invokeOrMock("set_remote_locale", { locale }, () => undefined)
}
// ── Release channel (Beta opt-in) ──────────────────────────────────────
// 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义;
// 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。
@@ -80,6 +80,10 @@ const previousPrefs: UserPreferences = {
audioRecordingMaxEntries: null,
marketplaceBaseUrl: '',
marketplaceDevLogin: '',
remoteInputEnabled: false,
remoteInputPort: 8443,
remoteInputPin: '000000',
remoteInputDefaultMode: 'toggle',
};
const nextPrefs: UserPreferences = {
+8
View File
@@ -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 {
@@ -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<void> {
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<RemoteInputStatus | null>(null);
const [startError, setStartError] = useState<{ reason: string; port: number } | null>(null);
const [copied, setCopied] = useState<string | null>(null);
// 端口编辑草稿:失焦/回车时才解析提交,避免逐键持久化导致后端服务在中间值端口反复重启。
const [portDraft, setPortDraft] = useState<string | null>(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 (
<Collapsible title={t('settings.remoteInput.title')}>
<SettingRow
label={t('settings.remoteInput.enableLabel')}
desc={t('settings.remoteInput.enableDesc')}
>
<Toggle
on={enabled}
onToggle={(v) => updatePrefs({ ...prefs, remoteInputEnabled: v })}
/>
</SettingRow>
<SettingRow label={t('settings.remoteInput.portLabel')}>
<input
type="number"
min={1024}
max={65535}
style={{ ...inputStyle, maxWidth: 140 }}
value={portDraft ?? String(prefs.remoteInputPort)}
onChange={(e) => setPortDraft(e.currentTarget.value)}
onBlur={commitPort}
onKeyDown={(e) => {
if (e.key === 'Enter') commitPort();
}}
/>
</SettingRow>
<SettingRow label={t('settings.remoteInput.defaultModeLabel')}>
<div style={{ display: 'flex', gap: 6 }}>
{(['toggle', 'hold'] as const).map((m) => (
<button
key={m}
onClick={() =>
updatePrefs({ ...prefs, remoteInputDefaultMode: m })
}
style={{
padding: '5px 12px',
borderRadius: 8,
fontSize: 12.5,
cursor: 'pointer',
border: '0.5px solid var(--ol-line-strong)',
background:
mode === m ? 'var(--ol-blue)' : 'var(--ol-surface-2)',
color: mode === m ? '#fff' : 'var(--ol-ink)',
}}
>
{t(
m === 'toggle'
? 'settings.remoteInput.modeToggle'
: 'settings.remoteInput.modeHold',
)}
</button>
))}
</div>
</SettingRow>
{enabled && status?.running && (
<>
<SettingRow label={t('settings.remoteInput.urlLabel')}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
minWidth: 0,
}}
>
{(status.urls.length
? status.urls
: [`https://localhost:${status.port}`]
).map((u) => (
<div
key={u}
style={{ display: 'flex', alignItems: 'center', gap: 8 }}
>
<span
style={{
fontFamily: 'monospace',
fontSize: 12.5,
color: 'var(--ol-ink-2)',
wordBreak: 'break-all',
}}
>
{u}
</span>
<button
onClick={() => doCopy(u, status.pin)}
title={t('settings.remoteInput.urlLabel')}
style={smallBtn}
>
{copied === u ? '✓' : '⧉'}
</button>
</div>
))}
</div>
</SettingRow>
<SettingRow label={t('settings.remoteInput.pinLabel')}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<code
style={{ fontSize: 15, letterSpacing: 2, fontWeight: 600 }}
>
{status.pin}
</code>
<button
onClick={async () => {
try {
// 直接用命令返回的新 PIN 更新本地状态;后端会异步重启服务,
// 此时查询状态可能拿到 running:false 导致闪烁,刷新交给 remote-input:running 事件。
const pin = await regenerateRemotePin();
setStatus((s) => (s ? { ...s, pin } : s));
} catch (e) {
console.warn('regenerateRemotePin failed', e);
}
}}
style={smallBtn}
>
{t('settings.remoteInput.regeneratePin')}
</button>
</div>
</SettingRow>
</>
)}
{enabled && startError != null && (
<div style={{ fontSize: 12, color: '#d9534f', marginTop: 8 }}>
{startError.reason === 'port-in-use'
? t('settings.remoteInput.portInUse', { port: startError.port })
: t('settings.remoteInput.startError', { reason: startError.reason })}
</div>
)}
<div
style={{
fontSize: 11.5,
color: 'var(--ol-ink-4)',
marginTop: 10,
lineHeight: 1.6,
}}
>
{t('settings.remoteInput.securityHint')}
<br />
{t('settings.remoteInput.certHint')}
<br />
{t('settings.remoteInput.certTrustWarning')}
</div>
</Collapsible>
);
}
@@ -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 (
<>
<RecordingInputSection />
<RemoteInputSection />
<ShortcutsSection />
<LanguageSection />
</>