From 03e92e74af371b1a69c4bb701838700ccd4d9190 Mon Sep 17 00:00:00 2001 From: ciddwd <572242998@qq.com> Date: Sun, 7 Jun 2026 17:45:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(remote-input):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E8=BF=9C=E7=A8=8B=E5=BD=95=E5=85=A5=20+=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20H5=20=E5=BD=95=E9=9F=B3=E5=87=86=E5=A4=87?= =?UTF-8?q?=E6=97=A0=E9=99=90=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H5 端 ensureAudio 加 10s 超时兜底:移动端 audioCtx.resume()/getUserMedia() 可能既不 resolve 也不 reject,导致 start 指令发不出、电脑端不弹胶囊、页面卡在准备麦克风;超时后 resetAudioContext 重建 AudioContext 并提示重试(新增 micTimeout 多语言文案)。其余为远程录入服务端/协调器/设置页及样式、图标等配套迭代。 --- openless-all/app/src-tauri/src/commands.rs | 6 + openless-all/app/src-tauri/src/coordinator.rs | 29 +- openless-all/app/src-tauri/src/lib.rs | 1 + .../src-tauri/src/remote_server/assets/app.js | 581 ++++++++++++++++-- .../src/remote_server/assets/icon.png | Bin 0 -> 9820 bytes .../src/remote_server/assets/index.html | 43 +- .../src/remote_server/assets/style.css | 261 +++++--- .../app/src-tauri/src/remote_server/mod.rs | 246 +++++++- openless-all/app/src/i18n/index.ts | 10 + openless-all/app/src/lib/ipc.ts | 5 + .../src/pages/settings/RemoteInputSection.tsx | 5 +- 11 files changed, 995 insertions(+), 192 deletions(-) create mode 100644 openless-all/app/src-tauri/src/remote_server/assets/icon.png diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index c727c077..96d2375a 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -301,6 +301,12 @@ 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); +} + pub(crate) fn sync_style_pack_prefs_and_persist( coord: &Coordinator, app: &AppHandle, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 24e7a2eb..f881be97 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -284,6 +284,9 @@ struct Inner { remote_server: Mutex>, /// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。 remote_pin: Mutex>, + /// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步, + /// H5 录音页据此渲染对应语言。进程内镜像,不持久化(前端会在启动/切换时重新下发)。 + remote_locale: Mutex, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -357,6 +360,7 @@ impl Coordinator { remote_audio_sink: Mutex::new(None), remote_server: Mutex::new(None), remote_pin: Mutex::new(None), + remote_locale: Mutex::new(String::from("zh-CN")), }), } } @@ -425,6 +429,7 @@ impl Coordinator { remote_audio_sink: Mutex::new(None), remote_server: Mutex::new(None), remote_pin: Mutex::new(None), + remote_locale: Mutex::new(String::from("zh-CN")), }), } } @@ -1039,10 +1044,28 @@ impl Coordinator { pub fn regenerate_remote_pin(self: &Arc) -> String { let pin = crate::remote_server::generate_pin(); *self.inner.remote_pin.lock() = Some(pin.clone()); + // 写盘持久化,否则下次启动会读回旧的持久化码、把这次重置覆盖掉。 + if let Some(app) = self.inner.app.lock().clone() { + crate::remote_server::save_pin(&app, &pin); + } self.refresh_remote_server(); pin } + /// 同步 PC 端界面语言(前端切换语言时调用)。H5 录音页据此选择显示语言。 + /// 仅接受受支持的白名单值,非法输入忽略(值会注入到 H5 的 lang,需防注入)。 + pub fn set_remote_locale(&self, locale: String) { + const SUPPORTED: [&str; 5] = ["zh-CN", "zh-TW", "en", "ja", "ko"]; + if SUPPORTED.contains(&locale.as_str()) { + *self.inner.remote_locale.lock() = locale; + } + } + + /// 当前 PC 端界面语言(供 H5 首页注入 lang)。 + pub fn remote_locale(&self) -> String { + self.inner.remote_locale.lock().clone() + } + /// 按 prefs 启停 / 重启远程输入服务。在 setup 与 prefs 变更(端口/开关)时调用。 pub fn refresh_remote_server(self: &Arc) { let coord = Arc::clone(self); @@ -1064,14 +1087,16 @@ impl Coordinator { let Some(app) = app else { return; }; - // PIN:复用进程内的 remote_pin,缺则生成。 + // PIN:进程内 remote_pin 缺失时从磁盘读持久化的(没有才新生成并写盘)—— + // 否则每次重启配对码都变,用户得反复找新码(这正是"配对码错误"的根因)。 let pin = { let mut guard = coord.inner.remote_pin.lock(); if guard.is_none() { - *guard = Some(crate::remote_server::generate_pin()); + *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, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 90e96cd0..2cfd88f1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -342,6 +342,7 @@ pub fn run() { commands::get_remote_input_status, commands::list_local_ips, commands::regenerate_remote_pin, + commands::set_remote_locale, commands::get_update_channel, commands::set_update_channel, commands::fetch_latest_beta_release, diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js index 1b47fb96..4db0c8bd 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/app.js +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -2,13 +2,298 @@ * OpenLess 远程输入 — 手机端录音页 * 纯静态,无外部依赖。通过 WSS 把 16kHz/单声道/16bit LE PCM * 实时推送给 PC 端 Rust 服务。 + * + * 显示语言跟随 PC 端界面语言:Rust 在返回首页时把 window.__OL_LANG__ + * 注入成 PC 当前 locale(前端切换语言时经 set_remote_locale 命令同步)。 * ========================================================== */ (function () { 'use strict'; + // ============================================================ + // i18n —— 文案字典(与 PC 端 src/i18n 对齐的 5 种语言) + // ============================================================ + var I18N = { + 'zh-CN': { + title: 'OpenLess 远程输入', + brandTitle: 'OpenLess 远程输入', + brandSub: '在手机上录音,实时输入到电脑', + pinFieldLabel: '配对码(电脑上显示的 6 位数字)', + btnConnect: '连接', + btnConnecting: '连接中…', + modeToggle: '点按', + modeHold: '按住', + offlineTitle: '连接已断开', + offlineSub: '与电脑的连接已中断。', + btnReconnect: '重新连接', + certTip: '首次访问浏览器会提示“连接不安全”(本地自签名证书)。Android Chrome:点“高级”→“继续前往”;iOS Safari:点“显示详情”→“访问此网站”。', + tipToggle: '点击大按钮开始录音,再次点击结束并识别。', + tipHold: '按住大按钮说话,松开结束并识别。', + labelToggleIdle: '点击开始', + labelToggleRec: '点击结束', + labelHoldIdle: '按住说话', + labelHoldRec: '松开结束', + ready: '准备就绪', + preparingMic: '正在准备麦克风…', + statusRecording: '🎤 录音中', + statusTranscribing: '🔄 识别中', + statusPolishing: '✨ 润色中', + statusDone: '✅ 已输入 {n} 字', + cancelled: '已取消', + connLost: '连接已断开', + errPinFormat: '请输入 6 位数字配对码。', + errPinWrong: '配对码错误,请重试。', + errPinLocked: '配对已锁定,请在电脑上重新生成配对码。', + errConnFail: '连接失败。多半是手机未信任电脑证书,请先信任证书后重试。', + errConnCreate: '无法建立连接,请检查网络。', + errConnTimeout: '连接超时。多半是手机未信任电脑证书,请按下方说明信任后重试。', + busy: '电脑忙:{reason}', + busyDefault: '请稍候', + micDenied: '❌ 麦克风权限被拒绝,请在浏览器设置中允许。', + micNotFound: '❌ 未找到可用麦克风。', + micBusy: '❌ 麦克风被其他应用占用。', + micTimeout: '❌ 麦克风准备超时,请重试。', + micUnknown: '❌ 无法启动录音{name}。', + errGeneric: '发生错误', + helpTitle: '连不上?多半是手机没信任证书', + helpAndroid: '① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。', + helpIos: '② iOS Safari:用无痕模式打开本页,出现“不安全”提示时点“显示详情 → 访问此网站”,再输入配对码连接(无需安装证书)。', + helpDownloadCert: '⬇ 下载并安装证书', + helpCopyLink: '⧉ 复制链接', + helpCopied: '已复制 ✓', + }, + 'zh-TW': { + title: 'OpenLess 遠端輸入', + brandTitle: 'OpenLess 遠端輸入', + brandSub: '在手機上錄音,即時輸入到電腦', + pinFieldLabel: '配對碼(電腦上顯示的 6 位數字)', + btnConnect: '連線', + btnConnecting: '連線中…', + modeToggle: '點按', + modeHold: '按住', + offlineTitle: '連線已中斷', + offlineSub: '與電腦的連線已中斷。', + btnReconnect: '重新連線', + certTip: '首次造訪瀏覽器會提示「連線不安全」(本機自簽憑證)。Android Chrome:點「進階」→「繼續前往」;iOS Safari:點「顯示詳細資訊」→「瀏覽此網站」。', + tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。', + tipHold: '按住大按鈕說話,放開結束並辨識。', + labelToggleIdle: '點擊開始', + labelToggleRec: '點擊結束', + labelHoldIdle: '按住說話', + labelHoldRec: '放開結束', + ready: '準備就緒', + preparingMic: '正在準備麥克風…', + statusRecording: '🎤 錄音中', + statusTranscribing: '🔄 辨識中', + statusPolishing: '✨ 潤飾中', + statusDone: '✅ 已輸入 {n} 字', + cancelled: '已取消', + connLost: '連線已中斷', + errPinFormat: '請輸入 6 位數字配對碼。', + errPinWrong: '配對碼錯誤,請重試。', + errPinLocked: '配對已鎖定,請在電腦上重新產生配對碼。', + errConnFail: '連線失敗。多半是手機未信任電腦憑證,請先信任憑證後重試。', + errConnCreate: '無法建立連線,請檢查網路。', + errConnTimeout: '連線逾時。多半是手機未信任電腦憑證,請依下方說明信任後重試。', + busy: '電腦忙碌:{reason}', + busyDefault: '請稍候', + micDenied: '❌ 麥克風權限遭拒,請在瀏覽器設定中允許。', + micNotFound: '❌ 找不到可用的麥克風。', + micBusy: '❌ 麥克風被其他應用程式佔用。', + micTimeout: '❌ 麥克風準備逾時,請重試。', + micUnknown: '❌ 無法啟動錄音{name}。', + errGeneric: '發生錯誤', + helpTitle: '連不上?多半是手機沒信任憑證', + helpAndroid: '① 安卓 / 一般情況:用瀏覽器無痕模式開啟本頁,出現“不安全”警告時選“繼續前往”,再輸入配對碼連線。', + helpIos: '② iOS Safari:用無痕模式開啟本頁,出現“不安全”提示時點“顯示詳細資訊 → 瀏覽此網站”,再輸入配對碼連線(無需安裝憑證)。', + helpDownloadCert: '⬇ 下載並安裝憑證', + helpCopyLink: '⧉ 複製連結', + helpCopied: '已複製 ✓', + }, + en: { + title: 'OpenLess Remote Input', + brandTitle: 'OpenLess Remote Input', + brandSub: 'Record on your phone, type to your computer in real time', + pinFieldLabel: 'Pairing code (6 digits shown on your computer)', + btnConnect: 'Connect', + btnConnecting: 'Connecting…', + modeToggle: 'Tap', + modeHold: 'Hold', + offlineTitle: 'Disconnected', + offlineSub: 'The connection to your computer was lost.', + btnReconnect: 'Reconnect', + certTip: 'On first visit the browser will warn "Not secure" (local self-signed certificate). Android Chrome: tap "Advanced" → "Proceed"; iOS Safari: tap "Show Details" → "visit this website".', + tipToggle: 'Tap the big button to start recording, tap again to finish and transcribe.', + tipHold: 'Hold the big button to talk, release to finish and transcribe.', + labelToggleIdle: 'Tap to start', + labelToggleRec: 'Tap to stop', + labelHoldIdle: 'Hold to talk', + labelHoldRec: 'Release to stop', + ready: 'Ready', + preparingMic: 'Preparing microphone…', + statusRecording: '🎤 Recording', + statusTranscribing: '🔄 Transcribing', + statusPolishing: '✨ Polishing', + statusDone: '✅ Inserted {n} chars', + cancelled: 'Cancelled', + connLost: 'Connection lost', + errPinFormat: 'Please enter the 6-digit pairing code.', + errPinWrong: 'Wrong pairing code, please try again.', + errPinLocked: 'Pairing locked. Please regenerate the code on your computer.', + errConnFail: 'Connection failed — usually the phone does not trust the computer certificate. Trust it, then retry.', + errConnCreate: 'Could not connect. Please check your network.', + errConnTimeout: 'Connection timed out — the phone likely does not trust the certificate. Follow the steps below to trust it, then retry.', + busy: 'Computer busy: {reason}', + busyDefault: 'please wait', + micDenied: '❌ Microphone permission denied. Please allow it in browser settings.', + micNotFound: '❌ No microphone available.', + micBusy: '❌ Microphone is in use by another app.', + micTimeout: '❌ Microphone setup timed out. Please try again.', + micUnknown: '❌ Could not start recording{name}.', + errGeneric: 'An error occurred', + helpTitle: "Can't connect? The phone probably doesn't trust the certificate", + helpAndroid: '① Android / general: open this page in an incognito tab, choose "Proceed" on the "Not secure" warning, then enter the pairing code.', + helpIos: '② iOS Safari: open this page in an incognito tab; on the "Not Private" warning tap "Show Details → visit this website", then enter the code (no certificate install needed).', + helpDownloadCert: '⬇ Download & install cert', + helpCopyLink: '⧉ Copy link', + helpCopied: 'Copied ✓', + }, + ja: { + title: 'OpenLess リモート入力', + brandTitle: 'OpenLess リモート入力', + brandSub: 'スマホで録音し、リアルタイムでパソコンに入力', + pinFieldLabel: 'ペアリングコード(パソコンに表示される6桁の数字)', + btnConnect: '接続', + btnConnecting: '接続中…', + modeToggle: 'タップ', + modeHold: '長押し', + offlineTitle: '接続が切断されました', + offlineSub: 'パソコンとの接続が切断されました。', + btnReconnect: '再接続', + certTip: '初回アクセス時、ブラウザに「保護されていません」と表示されます(ローカル自己署名証明書)。Android Chrome:「詳細設定」→「アクセスする」、iOS Safari:「詳細を表示」→「このWebサイトを閲覧」をタップしてください。', + tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。', + tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。', + labelToggleIdle: 'タップで開始', + labelToggleRec: 'タップで終了', + labelHoldIdle: '長押しで話す', + labelHoldRec: '離して終了', + ready: '準備完了', + preparingMic: 'マイクを準備中…', + statusRecording: '🎤 録音中', + statusTranscribing: '🔄 認識中', + statusPolishing: '✨ 整文中', + statusDone: '✅ {n}文字を入力しました', + cancelled: 'キャンセルしました', + connLost: '接続が切断されました', + errPinFormat: '6桁の数字のペアリングコードを入力してください。', + errPinWrong: 'ペアリングコードが違います。もう一度お試しください。', + errPinLocked: 'ペアリングがロックされました。パソコンでコードを再生成してください。', + errConnFail: '接続に失敗しました。多くはスマホがパソコンの証明書を信頼していないためです。証明書を信頼してから再試行してください。', + errConnCreate: '接続できません。ネットワークを確認してください。', + errConnTimeout: '接続がタイムアウトしました。多くはスマホが証明書を信頼していないためです。下の手順で信頼してから再試行してください。', + busy: 'パソコンがビジー状態です:{reason}', + busyDefault: 'お待ちください', + micDenied: '❌ マイクの許可が拒否されました。ブラウザの設定で許可してください。', + micNotFound: '❌ 利用可能なマイクが見つかりません。', + micBusy: '❌ マイクが他のアプリで使用されています。', + micTimeout: '❌ マイクの準備がタイムアウトしました。もう一度お試しください。', + micUnknown: '❌ 録音を開始できませんでした{name}。', + errGeneric: 'エラーが発生しました', + helpTitle: '接続できない?多くは証明書が信頼されていません', + helpAndroid: '① Android / 一般:ブラウザのシークレットモードで本ページを開き、「保護されていません」で「アクセスする」を選び、ペアリングコードを入力。', + helpIos: '② iOS Safari:シークレットモードで本ページを開き、「安全ではありません」で「詳細を表示 → このWebサイトにアクセス」をタップしてコードを入力(証明書のインストール不要)。', + helpDownloadCert: '⬇ 証明書をインストール', + helpCopyLink: '⧉ リンクをコピー', + helpCopied: 'コピーしました ✓', + }, + ko: { + title: 'OpenLess 원격 입력', + brandTitle: 'OpenLess 원격 입력', + brandSub: '휴대폰으로 녹음하여 실시간으로 컴퓨터에 입력', + pinFieldLabel: '페어링 코드 (컴퓨터에 표시된 6자리 숫자)', + btnConnect: '연결', + btnConnecting: '연결 중…', + modeToggle: '탭', + modeHold: '길게 누르기', + offlineTitle: '연결이 끊겼습니다', + offlineSub: '컴퓨터와의 연결이 끊겼습니다.', + btnReconnect: '다시 연결', + certTip: '처음 접속하면 브라우저에 "안전하지 않음" 경고가 표시됩니다(로컬 자체 서명 인증서). Android Chrome: "고급" → "계속 진행"; iOS Safari: "세부정보 표시" → "이 웹사이트 방문"을 탭하세요.', + tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.', + tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.', + labelToggleIdle: '탭하여 시작', + labelToggleRec: '탭하여 종료', + labelHoldIdle: '눌러서 말하기', + labelHoldRec: '떼면 종료', + ready: '준비 완료', + preparingMic: '마이크 준비 중…', + statusRecording: '🎤 녹음 중', + statusTranscribing: '🔄 인식 중', + statusPolishing: '✨ 다듬는 중', + statusDone: '✅ {n}자 입력함', + cancelled: '취소됨', + connLost: '연결이 끊겼습니다', + errPinFormat: '6자리 숫자 페어링 코드를 입력하세요.', + errPinWrong: '페어링 코드가 잘못되었습니다. 다시 시도하세요.', + errPinLocked: '페어링이 잠겼습니다. 컴퓨터에서 코드를 다시 생성하세요.', + errConnFail: '연결에 실패했습니다. 대개 휴대폰이 컴퓨터 인증서를 신뢰하지 않기 때문입니다. 인증서를 신뢰한 후 다시 시도하세요.', + errConnCreate: '연결할 수 없습니다. 네트워크를 확인하세요.', + errConnTimeout: '연결 시간이 초과되었습니다. 대개 인증서를 신뢰하지 않기 때문입니다. 아래 안내대로 신뢰 후 다시 시도하세요.', + busy: '컴퓨터가 사용 중입니다: {reason}', + busyDefault: '잠시 기다려 주세요', + micDenied: '❌ 마이크 권한이 거부되었습니다. 브라우저 설정에서 허용하세요.', + micNotFound: '❌ 사용 가능한 마이크가 없습니다.', + micBusy: '❌ 마이크가 다른 앱에서 사용 중입니다.', + micTimeout: '❌ 마이크 준비 시간이 초과되었습니다. 다시 시도하세요.', + micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.', + errGeneric: '오류가 발생했습니다', + helpTitle: '연결이 안 되나요? 대개 인증서를 신뢰하지 않아서입니다', + helpAndroid: '① Android / 일반: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "계속"을 선택한 뒤 페어링 코드를 입력하세요.', + helpIos: '② iOS Safari: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "세부사항 표시 → 이 웹사이트 방문"을 누른 뒤 코드를 입력하세요(인증서 설치 불필요).', + helpDownloadCert: '⬇ 인증서 설치', + helpCopyLink: '⧉ 링크 복사', + helpCopied: '복사됨 ✓', + }, + }; + + // 解析显示语言:优先 PC 注入的 window.__OL_LANG__,回退手机系统语言。 + var LANG = (function () { + var supported = { 'zh-CN': 1, 'zh-TW': 1, en: 1, ja: 1, ko: 1 }; + var injected = (window.__OL_LANG__ || '').trim(); + if (supported[injected]) return injected; + var nav = (navigator.language || '').toLowerCase(); + if (nav.indexOf('zh') === 0) { + if (nav.indexOf('hant') >= 0 || nav.indexOf('tw') >= 0 || nav.indexOf('hk') >= 0 || nav.indexOf('mo') >= 0) return 'zh-TW'; + return 'zh-CN'; + } + if (nav.indexOf('ja') === 0) return 'ja'; + if (nav.indexOf('ko') === 0) return 'ko'; + if (nav.indexOf('en') === 0) return 'en'; + return 'zh-CN'; + })(); + var L = I18N[LANG] || I18N['zh-CN']; + + // 极简插值:把 "{n}" / "{reason}" / "{name}" 替换成对应值。 + function fmt(tpl, vars) { + return String(tpl).replace(/\{(\w+)\}/g, function (_, k) { + return (vars && vars[k] != null) ? vars[k] : ''; + }); + } + + // 把 index.html 里带 data-i18n 的静态文案按当前语言渲染。 + function applyStaticI18n() { + try { document.title = L.title; } catch (e) {} + var nodes = document.querySelectorAll('[data-i18n]'); + for (var i = 0; i < nodes.length; i++) { + var key = nodes[i].getAttribute('data-i18n'); + if (L[key] != null) nodes[i].textContent = L[key]; + } + } + // ---------- 常量 ---------- var TARGET_SR = 16000; // 目标采样率,必须与 PC 端一致 - var MODE_KEY = 'ol_remote_mode'; // localStorage 键 + var MODE_KEY = 'ol_remote_mode'; // localStorage 键:录音方式 + var PIN_KEY = 'ol_remote_pin'; // localStorage 键:上次成功的配对码 + var MIC_PREP_TIMEOUT_MS = 10000; // 麦克风准备超时:超过则判失败让用户重试,避免无限卡"准备中" // ---------- DOM ---------- var $ = function (id) { return document.getElementById(id); }; @@ -26,11 +311,11 @@ var statusText = $('status-text'); var levelBar = $('level-bar'); var recTip = $('rec-tip'); - var connDot = $('conn-dot'); var modeSwitch = $('mode-switch'); var btnReconnect = $('btn-reconnect'); var offlineReason = $('offline-reason'); + var copyCertBtn = $('copy-cert-link'); // ---------- 状态 ---------- var ws = null; @@ -51,6 +336,22 @@ // ScriptProcessor 兜底用的重采样状态(跨块保留) var resampleState = { phase: 0, last: 0, hasLast: false }; + // ============================================================ + // 配对码持久化(localStorage) + // ============================================================ + function readPin() { + try { + var p = localStorage.getItem(PIN_KEY); + return /^\d{6}$/.test(p || '') ? p : ''; + } catch (e) { return ''; } + } + function writePin(p) { + try { if (/^\d{6}$/.test(p)) localStorage.setItem(PIN_KEY, p); } catch (e) {} + } + function clearPin() { + try { localStorage.removeItem(PIN_KEY); } catch (e) {} + } + // ============================================================ // 屏幕切换 // ============================================================ @@ -79,12 +380,12 @@ btns[i].classList.toggle('active', btns[i].getAttribute('data-mode') === mode); } if (mode === 'hold') { - recTip.textContent = '按住大按钮说话,松开结束并识别。'; - recordLabel.textContent = recording ? '松开结束' : '按住说话'; + recTip.textContent = L.tipHold; + recordLabel.textContent = recording ? L.labelHoldRec : L.labelHoldIdle; recordBtn.style.touchAction = 'none'; // hold 防滚动 } else { - recTip.textContent = '点击大按钮开始录音,再次点击结束并识别。'; - recordLabel.textContent = recording ? '点击结束' : '点击开始'; + recTip.textContent = L.tipToggle; + recordLabel.textContent = recording ? L.labelToggleRec : L.labelToggleIdle; recordBtn.style.touchAction = 'manipulation'; } } @@ -116,6 +417,16 @@ levelBar.style.width = (v * 100).toFixed(1) + '%'; } + // done 后过几秒自动回到"准备就绪",方便直接开始下一次,而不是一直停在结果上。 + var readyTimer = null; + function scheduleReady() { + if (readyTimer) clearTimeout(readyTimer); + readyTimer = setTimeout(function () { + readyTimer = null; + if (!recording && authed) setStatus(L.ready, null); + }, 2500); + } + // ============================================================ // WebSocket // ============================================================ @@ -125,6 +436,26 @@ } } + // 连接看门狗:wss 握手或认证在 12s 内没完成,几乎都是手机没信任电脑证书 + // (iOS Safari 对自签名 wss 不复用页面级证书例外)。与其无限"连接中",不如回到 + // 配对屏给出明确提示,引导用户去信任证书。 + var connectTimer = null; + function armConnectTimeout() { + clearConnectTimeout(); + connectTimer = setTimeout(function () { + connectTimer = null; + if (!authed) { + closeWS(); + showScreen('pin'); + showPinError(L.errConnTimeout); + resetConnectBtn(); + } + }, 12000); + } + function clearConnectTimeout() { + if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } + } + function connect(pin) { lastPin = pin; closeWS(); // 清理旧连接 @@ -135,11 +466,12 @@ try { ws = new WebSocket(url); } catch (e) { - showPinError('无法建立连接,请检查网络。'); + showPinError(L.errConnCreate); resetConnectBtn(); return; } ws.binaryType = 'arraybuffer'; + armConnectTimeout(); // 看门狗:握手/认证迟迟不完成 → 多半是证书没被信任 ws.onopen = function () { // 连上立即握手 @@ -158,24 +490,28 @@ }; ws.onclose = function () { + clearConnectTimeout(); var wasAuthed = authed; authed = false; recording = false; teardownAudio(); if (wasAuthed) { // 已进入录音屏后断开 → 断线屏 - offlineReason.textContent = '与电脑的连接已中断。'; + offlineReason.textContent = L.offlineSub; showScreen('offline'); - } else if (!isPinScreen()) { - // 连接过程中失败 + } else { + // 未认证就关闭(握手被拒/证书不受信任/网络中断)。无论当前是否在配对屏都给出 + // 明确提示 —— 否则(尤其安卓 Chrome 对不受信任的自签名 wss 会立刻 onclose) + // 用户只看到按钮闪一下变回"连接",完全不知道发生了什么。 showScreen('pin'); - showPinError('连接失败,请确认电脑端服务正在运行。'); + showPinError(L.errConnFail); } resetConnectBtn(); }; } function closeWS() { + clearConnectTimeout(); if (ws) { ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null; try { ws.close(); } catch (e) {} @@ -191,12 +527,13 @@ if (msg.ok) { authed = true; busy = false; + clearConnectTimeout(); + writePin(lastPin); // 配对成功 → 记住配对码,刷新后免重输 enterRecScreen(); } else { authed = false; - var reason = msg.reason === 'locked' - ? '配对已锁定,请在电脑上重新生成配对码。' - : '配对码错误,请重试。'; + clearPin(); // 配对码失效(错误/锁定)→ 清除,避免下次自动重连又失败 + var reason = msg.reason === 'locked' ? L.errPinLocked : L.errPinWrong; closeWS(); showScreen('pin'); showPinError(reason); @@ -217,12 +554,12 @@ recording = false; teardownAudioCapture(); // 停止采集但保留 ctx updateRecordBtnUI(); - setStatus('电脑忙:' + (msg.reason || '请稍候'), 'error'); + setStatus(fmt(L.busy, { reason: msg.reason || L.busyDefault }), 'error'); // 短暂后解除忙态,允许重试 setTimeout(function () { busy = false; updateRecordBtnUI(); - if (!recording) setStatus('准备就绪', null); + if (!recording) setStatus(L.ready, null); }, 1500); break; } @@ -231,21 +568,22 @@ function applyStatusKind(msg) { switch (msg.kind) { case 'recording': - setStatus('🎤 录音中', 'work'); + setStatus(L.statusRecording, 'work'); break; case 'transcribing': - setStatus('🔄 识别中', 'work'); + setStatus(L.statusTranscribing, 'work'); break; case 'polishing': - setStatus('✨ 润色中', 'work'); + setStatus(L.statusPolishing, 'work'); break; case 'done': var n = (typeof msg.insertedChars === 'number') ? msg.insertedChars : 0; - setStatus('✅ 已输入 ' + n + ' 字', 'ok'); + setStatus(fmt(L.statusDone, { n: n }), 'ok'); setLevel(0); + scheduleReady(); break; case 'error': - setStatus('❌ ' + (msg.message || '发生错误'), 'error'); + setStatus('❌ ' + (msg.message || L.errGeneric), 'error'); setLevel(0); break; default: @@ -261,10 +599,9 @@ function enterRecScreen() { showPinError(''); showScreen('rec'); - connDot.style.background = 'var(--ok)'; syncModeUI(); updateRecordBtnUI(); - setStatus('准备就绪', null); + setStatus(L.ready, null); setLevel(0); } @@ -285,12 +622,12 @@ function doConnect() { var pin = (pinInput.value || '').replace(/\D+/g, ''); if (pin.length !== 6) { - showPinError('请输入 6 位数字配对码。'); + showPinError(L.errPinFormat); return; } showPinError(''); btnConnect.disabled = true; - btnConnect.textContent = '连接中…'; + btnConnect.textContent = L.btnConnecting; connect(pin); } @@ -305,7 +642,7 @@ } function resetConnectBtn() { btnConnect.disabled = false; - btnConnect.textContent = '连接'; + btnConnect.textContent = L.btnConnect; } // 重新连接 @@ -313,11 +650,42 @@ showScreen('pin'); showPinError(''); resetConnectBtn(); - if (lastPin) { - pinInput.value = lastPin; + var p = lastPin || readPin(); + if (p) { + pinInput.value = p; + doConnect(); // 有配对码直接重连,省去再点一次 } }); + // 复制证书下载链接 —— 方便换个浏览器打开,或发给自己。 + function fallbackCopyText(text, cb) { + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + if (cb) cb(); + } catch (e) {} + } + if (copyCertBtn) { + copyCertBtn.addEventListener('click', function () { + var url = location.origin + '/cert.cer'; + var ok = function () { + copyCertBtn.textContent = L.helpCopied; + setTimeout(function () { copyCertBtn.textContent = L.helpCopyLink; }, 1500); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(ok, function () { fallbackCopyText(url, ok); }); + } else { + fallbackCopyText(url, ok); + } + }); + } + // ============================================================ // 录音按钮交互(toggle / hold) // ============================================================ @@ -325,9 +693,9 @@ recordBtn.classList.toggle('recording', recording); recordBtn.classList.toggle('busy', busy && !recording); if (recording) { - recordLabel.textContent = (mode === 'hold') ? '松开结束' : '点击结束'; + recordLabel.textContent = (mode === 'hold') ? L.labelHoldRec : L.labelToggleRec; } else { - recordLabel.textContent = (mode === 'hold') ? '按住说话' : '点击开始'; + recordLabel.textContent = (mode === 'hold') ? L.labelHoldIdle : L.labelToggleIdle; } } @@ -339,44 +707,69 @@ else startRecording(); }); - // hold 模式:按下/抬起/取消 + // hold 模式:按下开始;松开/取消结束。 + // 关键:用 document 级监听兜底"松开"事件。移动端 setPointerCapture 在动画/重排/ + // 系统权限弹窗时可能丢失,导致 recordBtn 自身的 pointerup 收不到 —— 表现为"手已 + // 松开却还在录音,得再点一下才停"。改为按下时在 document 上挂一次性的 pointerup/ + // pointercancel,无论指针最终在哪释放都能结束录音。 + var holdEndHandler = null; + function attachHoldEnd() { + if (holdEndHandler) return; + holdEndHandler = function () { + if (recording) stopRecording(); // stopRecording 内部会 detachHoldEnd + else detachHoldEnd(); + }; + document.addEventListener('pointerup', holdEndHandler, true); + document.addEventListener('pointercancel', holdEndHandler, true); + } + function detachHoldEnd() { + if (!holdEndHandler) return; + document.removeEventListener('pointerup', holdEndHandler, true); + document.removeEventListener('pointercancel', holdEndHandler, true); + holdEndHandler = null; + } + recordBtn.addEventListener('pointerdown', function (e) { if (mode !== 'hold') return; if (!authed || busy) return; e.preventDefault(); - try { recordBtn.setPointerCapture(e.pointerId); } catch (err) {} + attachHoldEnd(); if (!recording) startRecording(); }); - recordBtn.addEventListener('pointerup', function (e) { - if (mode !== 'hold') return; - e.preventDefault(); - if (recording) stopRecording(); - }); - recordBtn.addEventListener('pointercancel', function () { - if (mode !== 'hold') return; - if (recording) cancelRecording(); // 来电/切后台 → 丢弃 - }); - // hold 时指针滑出按钮也按抬起处理(防止卡在录音态) - recordBtn.addEventListener('lostpointercapture', function () { - if (mode !== 'hold') return; - if (recording) stopRecording(); - }); // ============================================================ // 录音流程 // ============================================================ + // 给可能"永久 pending"的 Promise 兜底超时。移动端 audioCtx.resume() / getUserMedia() + // 在息屏/切后台/被占用时可能既不 resolve 也不 reject,整条 ensureAudio 链就永久卡住 —— + // start 指令发不出去、电脑端不弹胶囊,H5 一直停在"正在准备麦克风…"。超时即判失败,复位 + // 状态并提示重试,而不是无限等待。 + function withTimeout(promise, ms, tag) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { + var err = new Error(tag || 'TIMEOUT'); + err.name = tag || 'TIMEOUT'; + reject(err); + }, ms); + promise.then( + function (v) { clearTimeout(timer); resolve(v); }, + function (e) { clearTimeout(timer); reject(e); } + ); + }); + } + function startRecording() { if (recording) return; if (!ws || ws.readyState !== 1) { - setStatus('连接已断开', 'error'); + setStatus(L.connLost, 'error'); return; } // 先乐观置态,保证 iOS 在手势同步栈内 resume() recording = true; updateRecordBtnUI(); - setStatus('正在准备麦克风…', 'work'); + setStatus(L.preparingMic, 'work'); - ensureAudio() + withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT') .then(function () { if (!recording) { // 期间已被取消/松手 @@ -384,26 +777,32 @@ return; } wsSendJSON({ type: 'start' }); - setStatus('🎤 录音中', 'work'); + setStatus(L.statusRecording, 'work'); }) .catch(function (err) { recording = false; + // 超时多半是 audioCtx 卡死(resume 永不 settle),彻底重建,否则下次重试会继续卡在 + // 同一个坏 ctx 上;非超时错误只需停采集链。 + if (err && err.name === 'TIMEOUT') resetAudioContext(); + else teardownAudioCapture(); updateRecordBtnUI(); - setStatus(micErrorText(err), 'error'); + setStatus(err && err.name === 'TIMEOUT' ? L.micTimeout : micErrorText(err), 'error'); }); } function stopRecording() { + detachHoldEnd(); if (!recording) return; recording = false; updateRecordBtnUI(); teardownAudioCapture(); wsSendJSON({ type: 'stop' }); - setStatus('🔄 识别中', 'work'); + setStatus(L.statusTranscribing, 'work'); setLevel(0); } function cancelRecording() { + detachHoldEnd(); if (!recording) { // 即便未在录音也确保采集停掉 teardownAudioCapture(); @@ -413,22 +812,22 @@ updateRecordBtnUI(); teardownAudioCapture(); wsSendJSON({ type: 'cancel' }); - setStatus('已取消', null); + setStatus(L.cancelled, null); setLevel(0); } function micErrorText(err) { var name = err && err.name ? err.name : ''; if (name === 'NotAllowedError' || name === 'SecurityError') { - return '❌ 麦克风权限被拒绝,请在浏览器设置中允许。'; + return L.micDenied; } if (name === 'NotFoundError' || name === 'OverconstrainedError') { - return '❌ 未找到可用麦克风。'; + return L.micNotFound; } if (name === 'NotReadableError') { - return '❌ 麦克风被其他应用占用。'; + return L.micBusy; } - return '❌ 无法启动录音' + (name ? '(' + name + ')' : '') + '。'; + return fmt(L.micUnknown, { name: name ? '(' + name + ')' : '' }); } // ============================================================ @@ -473,8 +872,9 @@ }); }) .then(function (stream) { - // 3) 建立采集图(若已建好则跳过) - if (sourceNode) return; + // 3) 建立采集图(若已建好则跳过)。audioCtx 可能在准备超时后被 resetAudioContext + // 置空(本次 getUserMedia 迟到 resolve),此时直接放弃,避免对 null ctx 建图报错。 + if (sourceNode || !audioCtx || !stream) return; sourceNode = audioCtx.createMediaStreamSource(stream); return buildCaptureGraph(); }); @@ -646,9 +1046,29 @@ if (!recording) return; if (ws && ws.readyState === 1 && buf && buf.byteLength) { try { ws.send(buf); } catch (e) {} + updateLocalLevel(buf); } } + // 本地音量可视化:直接用即将上传的 Int16 PCM 算 RMS。远程模式下 PC 端没有麦克风 + // 电平源(不开本地 cpal),所以电平条由手机端自己的音频驱动 —— 实时,且不依赖后端事件。 + var lastLevelAt = 0; + function updateLocalLevel(buf) { + var now = (window.performance && performance.now) ? performance.now() : 0; + if (now && now - lastLevelAt < 50) return; // 限到 ~20Hz,避免过度刷新 DOM + lastLevelAt = now; + var n = buf.byteLength >> 1; + if (n === 0) return; + var dv = new DataView(buf); + var sum = 0; + for (var i = 0; i < n; i++) { + var s = dv.getInt16(i * 2, true) / 32768; + sum += s * s; + } + var rms = Math.sqrt(sum / n); + setLevel(Math.min(1, rms * 3.5)); // 适度放大,让正常说话有明显跳动 + } + // ============================================================ // 音频清理 // ============================================================ @@ -694,6 +1114,24 @@ } } + // 准备超时后的硬复位:停麦克风轨道并彻底关闭 audioCtx,使下次 ensureAudio 从零重建。 + // 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉 + // (resume 永不 settle),保留它只会让下次继续卡。 + function resetAudioContext() { + teardownAudioCapture(); + if (mediaStream) { + try { + var tracks = mediaStream.getTracks(); + for (var i = 0; i < tracks.length; i++) tracks[i].stop(); + } catch (e) {} + mediaStream = null; + } + if (audioCtx) { + try { audioCtx.close(); } catch (e) {} + audioCtx = null; + } + } + // ============================================================ // 页面可见性:切后台时若在 hold 录音则取消,避免半截音频 // ============================================================ @@ -707,11 +1145,32 @@ // 初始化 // ============================================================ function init() { + // iOS Safari 怪癖兜底:页面"首次加载"后,页面内 wss 的证书信任不生效 —— 首次连接 + // 会卡在 TLS 握手→超时,手动刷新一次就好(已用日志证实:首次 TCP 到了却不升级,刷新 + // 后立刻 WS 升级成功)。这里把那一下"刷新"自动化:每个浏览器会话首次加载时静默 + // reload 一次,之后再初始化+自动连接,wss 握手就能成功。sessionStorage 标记保证只刷 + // 一次、不会死循环;手动刷新(同标签)不会重复触发,新标签/重开才会再刷。 + var reloadedOnce = false; + try { reloadedOnce = sessionStorage.getItem('ol_reloaded_once') === '1'; } catch (e) {} + if (!reloadedOnce) { + try { sessionStorage.setItem('ol_reloaded_once', '1'); } catch (e) {} + location.reload(); + return; + } + + applyStaticI18n(); syncModeUI(); showScreen('pin'); showPinError(''); - // 自动聚焦 PIN(部分移动端会被策略拦截,忽略失败) - setTimeout(function () { try { pinInput.focus(); } catch (e) {} }, 200); + // 上次成功的配对码 → 自动填充并重连,刷新/重开页面免再输一次 + var saved = readPin(); + if (saved) { + pinInput.value = saved; + doConnect(); + } else { + // 自动聚焦 PIN(部分移动端会被策略拦截,忽略失败) + setTimeout(function () { try { pinInput.focus(); } catch (e) {} }, 200); + } } init(); diff --git a/openless-all/app/src-tauri/src/remote_server/assets/icon.png b/openless-all/app/src-tauri/src/remote_server/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..358c3b3b207fb331447b929f9378ff49196fb452 GIT binary patch literal 9820 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 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 +

OpenLess 远程输入

+

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

- + - + +
+ + +
+
连不上?多半是手机没信任证书
+

① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。

+

② iOS Safari 必须装证书:点下方按钮下载并安装描述文件,然后到「设置 → 通用 → 关于本机 → 证书信任设置」里打开它的“完全信任”,再回来连接。

+
+ ⬇ 下载并安装证书 + +
- + OpenLess OpenLess
- - + +
@@ -68,20 +80,13 @@
📵
-

连接已断开

+

连接已断开

与电脑的连接已中断。

- +
- -
- 首次访问浏览器会提示“连接不安全”(本地自签名证书)。 - Android Chrome:点“高级”→“继续前往”; - iOS Safari:点“显示详情”→“访问此网站”。 -
- 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 }) => {