Merge pull request #618 from ciddwd/feat/mobile-remote-input
Feat/mobile remote input
This commit is contained in:
28 files changed
+3873
-33
No files matched your search
Generated
+190
-9
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: 'アップデート確認',
|
||||
|
||||
@@ -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: '업데이트 확인',
|
||||
|
||||
@@ -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: '检查更新',
|
||||
|
||||
@@ -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: '檢查更新',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user