feat(remote-input): 完善手机远程录入 + 修复 H5 录音准备无限卡死
H5 端 ensureAudio 加 10s 超时兜底:移动端 audioCtx.resume()/getUserMedia() 可能既不 resolve 也不 reject,导致 start 指令发不出、电脑端不弹胶囊、页面卡在准备麦克风;超时后 resetAudioContext 重建 AudioContext 并提示重试(新增 micTimeout 多语言文案)。其余为远程录入服务端/协调器/设置页及样式、图标等配套迭代。
This commit is contained in:
1 parent
a6f787d7a4
commit
03e92e74af
11 files changed
+995
-192
No files matched your search
@@ -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,
|
||||
|
||||
@@ -284,6 +284,9 @@ struct Inner {
|
||||
remote_server: Mutex<Option<crate::remote_server::RemoteServerHandle>>,
|
||||
/// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。
|
||||
remote_pin: Mutex<Option<String>>,
|
||||
/// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步,
|
||||
/// H5 录音页据此渲染对应语言。进程内镜像,不持久化(前端会在启动/切换时重新下发)。
|
||||
remote_locale: Mutex<String>,
|
||||
}
|
||||
|
||||
#[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<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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
@@ -1,25 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<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>
|
||||
<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">
|
||||
<div class="brand-logo">🎙️</div>
|
||||
<h1 class="brand-title">OpenLess 远程输入</h1>
|
||||
<p class="brand-sub">在手机上录音,实时输入到电脑</p>
|
||||
<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">配对码(电脑上显示的 6 位数字)</label>
|
||||
<label class="field-label" for="pin-input" data-i18n="pinFieldLabel">配对码(电脑上显示的 6 位数字)</label>
|
||||
<input
|
||||
id="pin-input"
|
||||
class="pin-input"
|
||||
@@ -30,18 +31,29 @@
|
||||
pattern="[0-9]*"
|
||||
placeholder="••••••" />
|
||||
<p id="pin-error" class="hint-error" hidden></p>
|
||||
<button id="btn-connect" class="btn btn-primary">连接</button>
|
||||
<button id="btn-connect" class="btn btn-primary" data-i18n="btnConnect">连接</button>
|
||||
</div>
|
||||
|
||||
<!-- 连接帮助:证书信任引导(手机上直接照做) -->
|
||||
<div class="help">
|
||||
<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">
|
||||
<span class="conn-dot" id="conn-dot" title="已连接"></span>
|
||||
<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">点按</button>
|
||||
<button class="mode-btn" data-mode="hold" type="button">按住</button>
|
||||
<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>
|
||||
|
||||
@@ -68,20 +80,13 @@
|
||||
<section id="screen-offline" class="screen">
|
||||
<div class="card card-center">
|
||||
<div class="offline-icon">📵</div>
|
||||
<h2 class="offline-title">连接已断开</h2>
|
||||
<h2 class="offline-title" data-i18n="offlineTitle">连接已断开</h2>
|
||||
<p class="offline-sub" id="offline-reason">与电脑的连接已中断。</p>
|
||||
<button id="btn-reconnect" class="btn btn-primary">重新连接</button>
|
||||
<button id="btn-reconnect" class="btn btn-primary" data-i18n="btnReconnect">重新连接</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 证书信任引导(固定底部) -->
|
||||
<footer class="cert-tip">
|
||||
首次访问浏览器会提示“连接不安全”(本地自签名证书)。
|
||||
Android Chrome:点“高级”→“继续前往”;
|
||||
iOS Safari:点“显示详情”→“访问此网站”。
|
||||
</footer>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<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()
|
||||
@@ -126,23 +160,86 @@ pub fn access_urls(port: u16) -> Vec<String> {
|
||||
|
||||
// ───────────────────────── TLS ─────────────────────────
|
||||
|
||||
fn build_rustls_config() -> Result<Arc<rustls::ServerConfig>, 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<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))));
|
||||
}
|
||||
}
|
||||
}
|
||||
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<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![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<Instant>)>,
|
||||
/// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。
|
||||
cert_der: Vec<u8>,
|
||||
}
|
||||
|
||||
fn build_router(state: Arc<WsState>) -> 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<WsState>) -> 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<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 顺滑、
|
||||
/// 也不会把当前页面导航走。安装后仍需到「设置→通用→关于本机→证书信任设置」打开完全信任。
|
||||
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 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<RemoteServerHandle, String
|
||||
coordinator: cfg.coordinator,
|
||||
app: cfg.app,
|
||||
pin_fails: Mutex::new((0, None)),
|
||||
cert_der,
|
||||
});
|
||||
let router = build_router(state);
|
||||
|
||||
@@ -206,19 +382,25 @@ pub async fn start(cfg: RemoteServerConfig) -> Result<RemoteServerHandle, String
|
||||
break;
|
||||
}
|
||||
accepted = listener.accept() => {
|
||||
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<Arc<WsState>>,
|
||||
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<WsState>) {
|
||||
};
|
||||
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<WsState>) {
|
||||
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
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<WsState>) {
|
||||
}
|
||||
|
||||
// 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<WsState>, 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" => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -553,6 +553,11 @@ 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 抛错而白屏。
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
export function RemoteInputSection() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
const [status, setStatus] = useState<RemoteInputStatus | null>(null);
|
||||
const [errorPort, setErrorPort] = useState<number | null>(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 }) => {
|
||||
|
||||
Reference in new issue
Block a user