yK5jDjnIeim{@hIum)(z2YKY)w
z2_-`xWV;dl=mIj-#PqwkAQFAJGUR$(m4F>jm~C(Gg)l*6^Z1_R@{}!iUdAABcI=Gb
zUz0pSM_an5wE)4h*F1>{@f`Z_gcu4MFWsdL1WKqcod&2g;2A(QCOP+_}mXjB
!*LU`En$fy)a@^?R(YWuV*PseHs2&
z4o`HTk(E%OVx`emt!K)C8PY2yN)pO+yES-${#|ouMUxRzO78E`0*h_G;st(BEW>&L}5
zC>kSzTSYdvxOnHAnpRba<3MeKj15+au=+d2Tl?DExco9gsz5Lep5Kt^
zubC+Ft0GA~l2ev!$A&+Faf%zxFvK9(DTL>X5v@EU#VeVFbVq~t;{(udhWh{4)lkZM
zz8jaa;SOBu;3GM{KHbVn9No%V$q+$sC*PDVT>$RX2l@#TU#VH97U0A%Ubl~KI;sw3
zNj#Q;V^>hPzjIG;75*w7tKCLr!u}j;twRw
zctU;3Q67D_M6SeTHK?@zVdr=%2eL4E6bAsjNgWa`<0BzqE6^)Q7^h^C4u!&QGHI_@
zmnTz>mcNWGQ+OJ{rw={x^EH{_DZ#A9_Dq9Q2FLAxJj8Fb0-W&gD
zX}PbLg&s5YI((T2i)g!lwQ6mt4BWSIqkD(J#Z3){$$1(bu-Nx7{63_
zC6WS$g@nmIWjRkIiNu|MYi&iEnC5zpKk=1s
z!#yz)3`0p%+y2b?|2TezPtfJtN!{E|o;`bpt;`lfAr0vOr+0u^a2eyJ`!1&sW^|?C
z-=UrIP!AdSeXjbyxsZ?$xm6X+?6oGJDNr^sG^{oA=aWCe{o#5`U9iM%!xamkOYgl^;mclHN<|=RJN~~mtp8)s%JW!%1VF7Y`J>tJx}RKQfVXn*Wy__F
G0{#~aLb@aX
literal 0
HcmV?d00001
diff --git a/openless-all/app/src-tauri/src/remote_server/assets/index.html b/openless-all/app/src-tauri/src/remote_server/assets/index.html
index d4eb4a56..0424cf81 100644
--- a/openless-all/app/src-tauri/src/remote_server/assets/index.html
+++ b/openless-all/app/src-tauri/src/remote_server/assets/index.html
@@ -1,25 +1,26 @@
-
+
- OpenLess 远程输入
+ OpenLess
+
-
🎙️
-
OpenLess 远程输入
-
在手机上录音,实时输入到电脑
+

+
OpenLess 远程输入
+
在手机上录音,实时输入到电脑
-
+
-
+
+
+
+
+
+
连不上?多半是手机没信任证书
+
① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。
+
② iOS Safari 必须装证书:点下方按钮下载并安装描述文件,然后到「设置 → 通用 → 关于本机 → 证书信任设置」里打开它的“完全信任”,再回来连接。
+
@@ -68,20 +80,13 @@
📵
-
连接已断开
+
连接已断开
与电脑的连接已中断。
-
+
-
-
-
diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css
index d80b9c7d..92265dbb 100644
--- a/openless-all/app/src-tauri/src/remote_server/assets/style.css
+++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css
@@ -1,20 +1,48 @@
-/* ===== OpenLess 远程输入 — 移动端深色样式 ===== */
+/* ===== OpenLess 远程输入 — 移动端样式 =====
+ * 配色 / 圆角 / 阴影 / 字体对齐 PC 端 design tokens(src/styles/tokens.css):
+ * 黑 + 白 + 电光蓝,浅色 glassy 风格(与桌面端保持一致)。
+ */
:root {
- --bg: #0f1115;
- --bg-soft: #171a21;
- --card: #1c2029;
- --card-border: #2a2f3a;
- --text: #e7eaf0;
- --text-dim: #9aa3b2;
- --text-faint: #6b7280;
- --accent: #4f8cff;
- --accent-strong: #3b78f0;
- --danger: #ff5470;
- --danger-soft: #e0455f;
- --ok: #34d399;
- --warn: #fbbf24;
- --shadow: 0 10px 30px rgba(0, 0, 0, .45);
+ /* 中性色 */
+ --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);
}
@@ -31,14 +59,15 @@ html, body {
body {
background:
- radial-gradient(120% 80% at 50% -10%, #1a2030 0%, var(--bg) 60%) fixed,
+ radial-gradient(120% 80% at 50% -10%, #eef2fb 0%, var(--bg) 55%) fixed,
var(--bg);
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
- "Hiragino Sans GB", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif;
+ 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;
@@ -48,8 +77,7 @@ body {
min-height: 100%;
display: flex;
flex-direction: column;
- /* 给底部证书提示留出空间 */
- padding-bottom: calc(96px + var(--safe-bottom));
+ padding-bottom: calc(24px + var(--safe-bottom));
}
/* ===== 屏幕切换 ===== */
@@ -74,29 +102,33 @@ body {
text-align: center;
margin: 28px 0 22px;
}
-.brand-logo {
- font-size: 56px;
- line-height: 1;
+.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: 14px 0 4px;
- letter-spacing: .3px;
+ margin: 16px 0 4px;
+ letter-spacing: .2px;
+ color: var(--ink);
}
.brand-sub {
margin: 0;
- color: var(--text-dim);
+ color: var(--ink-3);
font-size: 14px;
}
/* ===== 卡片 ===== */
.card {
- background: var(--card);
- border: 1px solid var(--card-border);
- border-radius: 18px;
+ background: var(--surface);
+ border: 0.5px solid var(--line);
+ border-radius: var(--r-2xl);
padding: 22px 20px;
- box-shadow: var(--shadow);
+ box-shadow: var(--shadow-lg);
}
.card-center {
text-align: center;
@@ -105,7 +137,7 @@ body {
.field-label {
display: block;
font-size: 13px;
- color: var(--text-dim);
+ color: var(--ink-3);
margin-bottom: 10px;
}
@@ -116,20 +148,21 @@ body {
letter-spacing: 14px;
text-align: center;
padding: 16px 12px;
- color: var(--text);
- background: var(--bg-soft);
- border: 2px solid var(--card-border);
- border-radius: 14px;
+ 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;
+ transition: border-color .15s ease, box-shadow .15s ease;
}
.pin-input::placeholder {
- color: var(--text-faint);
+ color: var(--ink-4);
letter-spacing: 14px;
}
.pin-input:focus {
- border-color: var(--accent);
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px var(--blue-ring);
}
/* ===== 按钮 ===== */
@@ -144,17 +177,18 @@ body {
font-weight: 600;
color: #fff;
border: none;
- border-radius: 14px;
+ 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: .55; cursor: default; }
+.btn:disabled { opacity: .5; cursor: default; }
.btn-primary {
- background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
- box-shadow: 0 6px 18px rgba(59, 120, 240, .35);
+ background: var(--blue);
+ box-shadow: 0 6px 18px -6px var(--blue-ring);
}
+.btn-primary:active { background: var(--blue-hover); }
.hint-error {
color: var(--danger);
@@ -163,6 +197,55 @@ body {
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;
@@ -170,24 +253,24 @@ body {
gap: 10px;
padding-bottom: 8px;
}
-.conn-dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: var(--ok);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, .18);
+.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: .4px;
+ letter-spacing: .2px;
+ color: var(--ink);
}
.mode-switch {
margin-left: auto;
display: inline-flex;
- background: var(--bg-soft);
- border: 1px solid var(--card-border);
+ background: var(--surface-2);
+ border: 0.5px solid var(--line);
border-radius: 12px;
padding: 3px;
gap: 2px;
@@ -197,7 +280,7 @@ body {
appearance: none;
border: none;
background: transparent;
- color: var(--text-dim);
+ color: var(--ink-3);
font-size: 13px;
font-weight: 600;
padding: 7px 14px;
@@ -206,7 +289,7 @@ body {
transition: background .15s ease, color .15s ease;
}
.mode-btn.active {
- background: var(--accent);
+ background: var(--blue);
color: #fff;
}
@@ -220,7 +303,7 @@ body {
gap: 26px;
}
-/* 录音大按钮 */
+/* 录音大按钮 —— 默认蓝色实心(对齐 PC 主操作蓝),录音中转红 */
.record-btn {
position: relative;
width: 168px;
@@ -229,10 +312,10 @@ body {
border: none;
cursor: pointer;
color: #fff;
- background: radial-gradient(circle at 50% 38%, #2a3550 0%, #1b2136 100%);
+ background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
box-shadow:
- 0 12px 34px rgba(0, 0, 0, .5),
- inset 0 0 0 2px rgba(255, 255, 255, .04);
+ 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;
@@ -248,7 +331,7 @@ body {
position: absolute;
inset: -6px;
border-radius: 50%;
- border: 2px solid rgba(79, 140, 255, .35);
+ border: 2px solid rgba(37, 99, 235, .35);
opacity: 0;
pointer-events: none;
}
@@ -260,19 +343,20 @@ body {
.record-btn-label {
font-size: 14px;
font-weight: 600;
- color: var(--text-dim);
- letter-spacing: .5px;
+ color: rgba(255, 255, 255, .92);
+ letter-spacing: .3px;
}
/* 录音中:红色 + 呼吸脉冲动画 */
.record-btn.recording {
- background: radial-gradient(circle at 50% 38%, #ff6a82 0%, #d63a55 100%);
- box-shadow: 0 12px 34px rgba(214, 58, 85, .5);
+ 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;
}
@@ -288,7 +372,7 @@ body {
/* 忙/禁用态 */
.record-btn.busy {
- opacity: .6;
+ opacity: .5;
cursor: default;
animation: none;
}
@@ -298,16 +382,22 @@ body {
width: 78%;
max-width: 320px;
height: 8px;
- border-radius: 99px;
- background: var(--bg-soft);
- border: 1px solid var(--card-border);
+ 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: 99px;
- background: linear-gradient(90deg, var(--ok), var(--accent));
+ border-radius: var(--r-pill);
+ background: linear-gradient(90deg, var(--ok), var(--blue));
transition: width .08s linear;
}
@@ -315,31 +405,32 @@ body {
.status-bar {
min-height: 28px;
padding: 8px 18px;
- border-radius: 99px;
- background: var(--bg-soft);
- border: 1px solid var(--card-border);
+ 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(--text);
+ color: var(--ink);
text-align: center;
max-width: 90%;
}
-.status-bar.is-error { color: var(--danger); border-color: rgba(255, 84, 112, .4); }
-.status-bar.is-ok { color: var(--ok); border-color: rgba(52, 211, 153, .4); }
-.status-bar.is-work { color: var(--accent); }
+.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); }
/* ===== 提示文字 ===== */
.rec-tip {
text-align: center;
- color: var(--text-faint);
+ color: var(--ink-4);
font-size: 13px;
margin: 18px 0 0;
}
/* ===== 断线屏 ===== */
.offline-icon { font-size: 48px; }
-.offline-title { font-size: 20px; margin: 12px 0 6px; }
-.offline-sub { color: var(--text-dim); font-size: 14px; margin: 0 0 8px; }
+.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 {
@@ -350,18 +441,18 @@ body {
padding: 12px 16px calc(12px + var(--safe-bottom));
font-size: 12px;
line-height: 1.5;
- color: var(--text-faint);
- background: rgba(15, 17, 21, .92);
- backdrop-filter: blur(6px);
- -webkit-backdrop-filter: blur(6px);
- border-top: 1px solid var(--card-border);
+ 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 { font-size: 44px; }
+ .brand-logo-img { width: 56px; height: 56px; }
.record-btn { width: 148px; height: 148px; }
.record-btn-icon { font-size: 44px; }
}
diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs
index f286c3d2..46b49e31 100644
--- a/openless-all/app/src-tauri/src/remote_server/mod.rs
+++ b/openless-all/app/src-tauri/src/remote_server/mod.rs
@@ -24,7 +24,7 @@ use axum::{
use hyper_util::rt::{TokioExecutor, TokioIo};
use parking_lot::Mutex;
use serde::Serialize;
-use tauri::{AppHandle, Listener};
+use tauri::{AppHandle, Listener, Manager};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
@@ -34,6 +34,7 @@ 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");
}
const HEADER_HTML: &str = "text/html; charset=utf-8";
@@ -90,6 +91,39 @@ pub fn generate_pin() -> String {
format!("{n:06}")
}
+fn pin_path(app: &AppHandle) -> Option {
+ app.path()
+ .app_config_dir()
+ .ok()
+ .map(|d| d.join("remote-input-pin.txt"))
+}
+
+/// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定 —— 否则每次启动
+/// 都重新随机一个,用户得反复回来找新码("配对码错误"的根因)。
+pub fn load_or_create_pin(app: &AppHandle) -> String {
+ if let Some(p) = pin_path(app) {
+ if let Ok(s) = std::fs::read_to_string(&p) {
+ let s = s.trim();
+ if s.len() == 6 && s.bytes().all(|b| b.is_ascii_digit()) {
+ return s.to_string();
+ }
+ }
+ }
+ let pin = generate_pin();
+ save_pin(app, &pin);
+ pin
+}
+
+/// 写配对码到磁盘(用户点"重置配对码"时覆盖)。
+pub fn save_pin(app: &AppHandle, pin: &str) {
+ if let Some(p) = pin_path(app) {
+ if let Some(dir) = p.parent() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ let _ = std::fs::write(&p, pin);
+ }
+}
+
fn is_private_lan(ip: &Ipv4Addr) -> bool {
let o = ip.octets();
!ip.is_loopback()
@@ -126,23 +160,86 @@ pub fn access_urls(port: u16) -> Vec {
// ───────────────────────── TLS ─────────────────────────
-fn build_rustls_config() -> Result, String> {
- let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()];
- for ip in local_lan_ipv4s() {
- sans.push(ip.to_string());
+/// 自签名证书:持久化到磁盘并跨重启复用。否则每次启动证书都变 —— 手机(尤其 iOS
+/// Safari)上一次信任过的证书立刻失效,wss 握手静默挂起,表现为"连接中"卡死。仅当
+/// 磁盘无证书 / 解析失败 / 当前局域网 IP 不在已存 SAN 列表里(换了网络)时才重新生成。
+/// 返回 (证书 DER 原始字节, 私钥)。
+fn load_or_generate_cert(
+ dir: Option<&std::path::Path>,
+ sans: &[String],
+) -> Result<(Vec, rustls::pki_types::PrivateKeyDer<'static>), String> {
+ use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
+ // 文件名带 schema 版本:证书结构变更(v4 改回非 CA 服务器证书)时旧文件自动失效、重新生成。
+ const CERT_FILE: &str = "remote-cert-v4.der";
+ const KEY_FILE: &str = "remote-key-v4.der";
+ const SANS_FILE: &str = "remote-cert-sans-v4.txt";
+ if let Some(dir) = dir {
+ if let (Ok(cert), Ok(key), Ok(saved)) = (
+ std::fs::read(dir.join(CERT_FILE)),
+ std::fs::read(dir.join(KEY_FILE)),
+ std::fs::read_to_string(dir.join(SANS_FILE)),
+ ) {
+ let saved_set: std::collections::HashSet<&str> = saved.lines().collect();
+ // 当前需要的 SAN 都在已存证书里 → 复用,证书保持稳定(手机信任一次长期有效)。
+ if sans.iter().all(|s| saved_set.contains(s.as_str())) {
+ log::info!("[remote-input] reusing persisted self-signed server cert");
+ return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key))));
+ }
+ }
}
- let certified =
- rcgen::generate_simple_self_signed(sans).map_err(|e| format!("rcgen: {e}"))?;
- let cert_der = certified.cert.der().clone();
- let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(
- certified.key_pair.serialize_der(),
- );
+ // 生成自签名 CA 证书:iOS Safari / Android Chrome 的 wss 都不复用浏览器页面级的
+ // “继续访问”例外,必须把证书装进系统并信任 —— 而系统的信任开关只对 CA 证书出现。
+ // 所以做成自签名 CA(SAN 含本机各局域网 IP),用户在手机装一次并信任后 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);
+ let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n"));
+ log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})");
+ }
+ Ok((
+ cert_der,
+ PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
+ ))
+}
+
+fn build_server_config(
+ cert_der: Vec,
+ key_der: rustls::pki_types::PrivateKeyDer<'static>,
+) -> Result, String> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| format!("tls protocol: {e}"))?
.with_no_client_auth()
- .with_single_cert(vec![cert_der], key_der.into())
+ .with_single_cert(
+ vec![rustls::pki_types::CertificateDer::from(cert_der)],
+ key_der,
+ )
.map_err(|e| format!("tls cert: {e}"))?;
Ok(Arc::new(config))
}
@@ -155,11 +252,13 @@ struct WsState {
app: AppHandle,
/// 全局 PIN 失败计数 + 锁定截止时刻(简单防爆破;TLS+6 位 PIN 已是主防线)。
pin_fails: Mutex<(u32, Option)>,
+ /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。
+ cert_der: Vec,
}
fn build_router(state: Arc) -> Router {
Router::new()
- .route("/", get(|| async { Html(assets::INDEX_HTML) }))
+ .route("/", get(index_handler))
.route(
"/app.js",
get(|| async { ([(axum::http::header::CONTENT_TYPE, HEADER_JS)], assets::APP_JS) }),
@@ -170,13 +269,89 @@ fn build_router(state: Arc) -> Router {
([(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)
+ }),
+ )
+ // 证书下载:手机在浏览器打开它即可下载并安装信任(iOS Safari 的 wss 不复用
+ // 页面级证书例外,需在系统里完全信任后 wss 才稳定)。
+ .route(
+ "/cert.cer",
+ get(|State(state): State>| async move {
+ (
+ [(axum::http::header::CONTENT_TYPE, "application/x-x509-ca-cert")],
+ state.cert_der.clone(),
+ )
+ }),
+ )
+ .route("/cert.mobileconfig", get(mobileconfig_handler))
.route("/ws", get(ws_upgrade))
.with_state(state)
}
+/// 首页:按 PC 端当前界面语言把 `__OL_LANG__` 占位替换成实际 locale,
+/// H5 据此(window.__OL_LANG__ / )选择显示语言。
+async fn index_handler(State(state): State>) -> impl IntoResponse {
+ let lang = state.coordinator.remote_locale();
+ Html(assets::INDEX_HTML.replace("%%OL_LANG%%", &lang))
+}
+
+/// 极简标准 base64:构造 .mobileconfig 时把证书 DER 编码进 XML,避免引入额外依赖。
+fn base64_encode(data: &[u8]) -> String {
+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
+ for chunk in data.chunks(3) {
+ let b0 = chunk[0];
+ let b1 = *chunk.get(1).unwrap_or(&0);
+ let b2 = *chunk.get(2).unwrap_or(&0);
+ out.push(T[(b0 >> 2) as usize] as char);
+ out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
+ out.push(if chunk.len() > 1 {
+ T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char
+ } else {
+ '='
+ });
+ out.push(if chunk.len() > 2 {
+ T[(b2 & 0x3f) as usize] as char
+ } else {
+ '='
+ });
+ }
+ out
+}
+
+/// iOS 配置描述文件:把证书包成 .mobileconfig。Safari 点击后凭 content-type
+/// (application/x-apple-aspen-config) 直接进入“安装描述文件”流程,比裸 .cer 顺滑、
+/// 也不会把当前页面导航走。安装后仍需到「设置→通用→关于本机→证书信任设置」打开完全信任。
+async fn mobileconfig_handler(State(state): State>) -> impl IntoResponse {
+ let b64 = base64_encode(&state.cert_der);
+ let xml = format!(
+ r#"
+
+PayloadContentPayloadCertificateFileNameopenless.cerPayloadContent{b64}PayloadTypecom.apple.security.rootPayloadIdentifiercom.openless.remote-input.certPayloadUUIDA1B2C3D4-0001-4000-8000-000000000001PayloadVersion1PayloadDisplayNameOpenLess Remote Input CertificatePayloadDisplayNameOpenLess Remote InputPayloadIdentifiercom.openless.remote-inputPayloadTypeConfigurationPayloadUUIDA1B2C3D4-0002-4000-8000-000000000002PayloadVersion1"#,
+ b64 = b64
+ );
+ (
+ [(
+ axum::http::header::CONTENT_TYPE,
+ "application/x-apple-aspen-config",
+ )],
+ xml,
+ )
+}
+
pub async fn start(cfg: RemoteServerConfig) -> Result {
let _ = HEADER_HTML; // index 用 axum Html() 自带 content-type
- let rustls_config = build_rustls_config()?;
+ 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));
@@ -194,6 +369,7 @@ pub async fn start(cfg: RemoteServerConfig) -> Result Result {
- let (tcp, _peer) = match accepted {
+ 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();
let router = router.clone();
tokio::spawn(async move {
let tls = match acceptor.accept(tcp).await {
Ok(t) => t,
- Err(_) => return, // 客户端没装证书 / 握手失败:静默
+ Err(e) => {
+ log::warn!("[remote-input] 来自 {peer} 的 TLS 握手失败(证书没被接受):{e}");
+ return;
+ }
};
let io = TokioIo::new(tls);
let svc = hyper_util::service::TowerToHyperService::new(router);
@@ -245,6 +427,9 @@ async fn ws_upgrade(
State(state): State>,
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))
}
@@ -301,15 +486,18 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) {
};
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;
@@ -321,7 +509,11 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) {
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::();
let listener_id = {
let tx = evt_tx.clone();
- state.app.listen("capsule:state", move |event| {
+ // 必须用 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);
}
@@ -356,6 +548,7 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) {
}
// 4) 收尾:断连即取消未完成的远程会话,避免 ASR 句柄悬挂。
+ log::info!("[remote-input] WS 连接已关闭");
state.app.unlisten(listener_id);
state.coordinator.cancel_remote_dictation();
}
@@ -367,15 +560,20 @@ async fn handle_control(txt: &str, state: &Arc, socket: &mut WebSocket)
Err(_) => return true,
};
match v.get("type").and_then(|t| t.as_str()).unwrap_or("") {
- "start" => match state.coordinator.start_remote_dictation().await {
- Ok(()) => {}
- Err(reason) => {
- let _ = socket
- .send(send_json(&serde_json::json!({"type":"busy","reason":reason})))
- .await;
+ "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" => {
diff --git a/openless-all/app/src/i18n/index.ts b/openless-all/app/src/i18n/index.ts
index 7a2b6ef7..93ba05b6 100644
--- a/openless-all/app/src/i18n/index.ts
+++ b/openless-all/app/src/i18n/index.ts
@@ -16,6 +16,7 @@ import { ko } from './ko';
import { zhCN } from './zh-CN';
import { zhTW } from './zh-TW';
import type { UserPreferences } from '../lib/types';
+import { setRemoteLocale } from '../lib/ipc';
export const SUPPORTED_LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko'] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
@@ -88,9 +89,18 @@ export async function setLocalePreference(
window.localStorage.setItem(LOCALE_STORAGE_KEY, pref);
}
await i18n.changeLanguage(resolved);
+ syncRemoteLocale(resolved);
return resolved;
}
+// 远程输入 H5 录音页跟随 PC 界面语言:把已解析的 locale 推给后端(后端只存内存
+// 镜像,H5 请求首页时据此渲染)。非 Tauri(浏览器 dev)环境走 mock no-op,失败静默。
+function syncRemoteLocale(resolved: SupportedLocale): void {
+ void setRemoteLocale(resolved).catch(() => {});
+}
+// 启动时同步一次当前语言,覆盖“开机即自动开启远程服务”的场景。
+syncRemoteLocale(i18n.language as SupportedLocale);
+
export const FOLLOW_SYSTEM = FOLLOW_SYSTEM_VALUE;
export function outputPrefsForLocale(
diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts
index 30719c54..4ef570d2 100644
--- a/openless-all/app/src/lib/ipc.ts
+++ b/openless-all/app/src/lib/ipc.ts
@@ -553,6 +553,11 @@ export function regenerateRemotePin(): Promise {
return invokeOrMock("regenerate_remote_pin", undefined, () => "123456")
}
+/** 把 PC 端界面语言同步给远程输入服务,H5 录音页据此显示对应语言。 */
+export function setRemoteLocale(locale: string): Promise {
+ return invokeOrMock("set_remote_locale", { locale }, () => undefined)
+}
+
// ── Release channel (Beta opt-in) ──────────────────────────────────────
// 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义;
// 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。
diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx
index 342e4492..b549032f 100644
--- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx
+++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx
@@ -11,6 +11,7 @@ import { SettingRow, Toggle, inputStyle } from './shared';
import {
getRemoteInputStatus,
regenerateRemotePin,
+ setRemoteLocale,
isTauri,
type RemoteInputStatus,
} from '../../lib/ipc';
@@ -37,7 +38,7 @@ async function copyText(text: string): Promise {
}
export function RemoteInputSection() {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const [status, setStatus] = useState(null);
const [errorPort, setErrorPort] = useState(null);
@@ -50,6 +51,8 @@ export function RemoteInputSection() {
.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 }) => {