diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 591589e5..b6515e72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,9 @@ jobs: - name: Test Core run: cargo test --locked -p openless-core + - name: Test remote input TLS identity and Unix key permissions + run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls + cross-platform: name: ${{ matrix.label }} checks strategy: diff --git a/README.md b/README.md index b1e839a0..03ce66d9 100644 --- a/README.md +++ b/README.md @@ -198,9 +198,9 @@ Every item below is one more layer sedimented into a default — a capability yo - 🎨 **Style Pack Marketplace** — browse, install, and like community **style packs** from the in-app Marketplace, and publish your own (custom system prompt per pack, switchable by hotkey). Backed by a moderated marketplace backend; uploads are reviewed before they go public. - ⚡ **Streaming insertion** — polished text is written to the cursor character by character to reduce perceived latency, with an automatic one-shot-paste fallback. Toggle in Settings → Recording. - **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion. -- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, OrcaRouter audio-input Gemini, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). +- **Cloud ASR**: Volcengine streaming ASR (bigasr), Tencent Cloud Hunyuan realtime ASR (Hy-ASR), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, OrcaRouter audio-input Gemini, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). - **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants. -- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, OrcaRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. +- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, Tencent Cloud TokenHub, CometAPI, OpenRouter, OrcaRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. - **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). - **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). diff --git a/README.zh.md b/README.zh.md index dfd17954..a44a48f5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -203,9 +203,9 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - 🎨 **风格包市场**——在应用内的 Marketplace 浏览、安装、点赞社区**风格包**,并发布自己的(每个包一套自定义系统提示词,可用快捷键切换)。由经过审核的市场后端支撑;上传内容公开前会经过审核。 - ⚡ **流式插入**——润色后的文本逐字符写入光标以降低感知延迟,并带有自动的一次性粘贴回退。可在 设置 → 录音 中切换。 - **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。 -- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、OrcaRouter 音频输入 Gemini、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 +- **云端 ASR**:Volcengine 流式 ASR(bigasr)、腾讯云混元实时 ASR(Hy-ASR)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、OrcaRouter 音频输入 Gemini、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 - **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。 -- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、OrcaRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、腾讯云 TokenHub、CometAPI、OpenRouter、OrcaRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/docs/remote-input-certificates.md b/docs/remote-input-certificates.md new file mode 100644 index 00000000..229019bf --- /dev/null +++ b/docs/remote-input-certificates.md @@ -0,0 +1,92 @@ +# Remote input: one-time phone certificate setup + +Remote input needs HTTPS for browser microphone access. OpenLess generates a +private certificate authority (CA) on each computer and a separate server +certificate covering that computer's LAN addresses. The phone installs the +public CA certificate. The CA private key stays in the computer's user data. + +## iPhone and iPad + +1. Enable remote input on the computer. Use the address shown in settings, on + the same network as the computer. Settings also has a **Copy iPhone + certificate link** button for each address. +2. Open the address in Safari. On the initial certificate warning, check the + address against the computer, then use **Show Details → Visit This Website** + to reach your own computer's setup page. This exception is only a bootstrap + step, not the persistent trust setup. +3. Expand **First-time setup: trust this computer** and choose **iPhone: + download profile**. Alternatively, open the copied `/cert.mobileconfig` link + directly in Safari. +4. Install the downloaded profile in **Settings → General → VPN & Device + Management**. +5. In **Settings → General → About → Certificate Trust Settings**, enable full + trust for **OpenLess Remote Input CA**. Return to Safari and reload the page. +6. Enter the pairing code and allow microphone access when Safari asks. + +Installing a profile and enabling full SSL trust are separate steps. Apple +requires the latter for profiles downloaded from a website; a desktop app +cannot silently approve it on a personal iPhone. See +[Apple's certificate trust instructions](https://support.apple.com/en-gb/102390). +This setup removes certificate warnings after trust is established; browser +microphone permissions and the pairing code remain separate controls. + +Only install a CA from your own computer. A CA can issue certificates, so its +private key is sensitive. Remove the OpenLess profile from the phone when you +no longer use it. The certificate fingerprint in each profile identifier keeps +profiles for different computers from replacing one another. + +## Android + +Download `/cert.cer` using the **Android: download CA** link. Install it through +the system's **Install a certificate → CA certificate** settings, then return +to the browser. Menu names and browser support for user-installed CAs vary by +device. The download contains only the public root certificate. + +## What OpenLess automates + +- Creates and atomically persists a unique CA and server identity on first use. +- Reuses the same CA and server certificate after restarts and upgrades that + preserve application data. +- Reissues the server certificate when required LAN or virtual-adapter IPs + change, keeping the same CA and therefore the phone's trust. +- Renews the server certificate on service startup when less than 30 days + remain. Leaves are valid for at most 366 days including clock-skew allowance; + a continuously running service must restart before its leaf expires. +- Serves the public CA as a `.cer` file or iOS configuration profile on both + Tauri desktop and Linux egui hosts. The private keys are never downloaded. +- Refuses to start with a damaged, unreadable, mismatched, or expiring CA rather + than silently replacing the phone's trust anchor. + +## Upgrades, reinstalling, and recovery + +Older releases used a directly self-signed leaf (`remote-cert-v4.der`) and +regenerated it whenever a newly observed IP was absent from a sidecar SAN list. +Even a virtual-adapter change could invalidate the certificate trusted by the +phone. Some releases also hid certificate setup and packaged the non-CA leaf +as a root profile. Upgrading from this format requires the one-time setup above. +Old files are left intact for rollback; they are not promoted to a CA. + +Keep `remote-tls-identity-v1.json` with the application's user configuration +when backing up or reinstalling. On Windows it is in +`%APPDATA%\com.openless.app`; on Tauri macOS it is in the application's config +directory; on Linux egui it is in the host data directory's `remote-input` +subdirectory. This file contains private keys: do not publish it, send it to a +phone, or copy it to another computer. Unix files are owner-readable/writable +only; Windows files inherit the user's application-data ACL. + +If this file is damaged, restore the computer's own backup. If it is lost, or +the CA expires (ten years), explicitly back up/remove the old identity while +OpenLess is stopped, restart, and install/trust the newly generated CA on each +phone. Deleting all application data necessarily loses the old trust identity. + +## Regression checks + +```sh +cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml --test remote_tls +node openless-all/app/scripts/remote-input-audio-queue.test.mjs +``` + +The TLS tests exercise real rustls handshakes with only the downloaded CA as a +trust anchor, IP changes, restart reuse, renewal, corrupted keys, persistence +failure, migration, and separation between two computers. iOS installation and +microphone recording still require a physical-device smoke test. diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index 646d0d38..a4b807be 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -170,6 +170,45 @@ dependencies = [ "libloading", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -978,6 +1017,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2254,6 +2307,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -3080,6 +3139,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3122,6 +3190,7 @@ version = "0.1.0" dependencies = [ "arboard", "axum", + "base64", "cpal", "dbus", "eframe", @@ -3137,9 +3206,13 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2", + "tempfile", + "time", "tokio", "tokio-rustls", "uuid", + "x509-parser", ] [[package]] @@ -3638,6 +3711,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser", "yasna", ] @@ -3819,6 +3893,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -4396,6 +4479,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -4404,6 +4488,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -5698,6 +5792,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/openless-all/app/crates/openless-core/src/asr/mod.rs b/openless-all/app/crates/openless-core/src/asr/mod.rs index b016e1b1..a8177bfe 100644 --- a/openless-all/app/crates/openless-core/src/asr/mod.rs +++ b/openless-all/app/crates/openless-core/src/asr/mod.rs @@ -8,6 +8,7 @@ pub mod mimo; pub mod pcm; pub mod qwen_realtime; pub mod stepfun_realtime; +pub mod tencent_cloud; pub mod volcengine; pub mod wav; pub mod whisper; @@ -20,6 +21,7 @@ pub use elevenlabs::ElevenLabsBatchASR; pub use mimo::MimoBatchASR; pub use qwen_realtime::{Qwen3RealtimeASR, Qwen3RealtimeCredentials}; pub use stepfun_realtime::{StepfunRealtimeASR, StepfunRealtimeCredentials}; +pub use tencent_cloud::{TencentCloudCredentials, TencentCloudStreamingASR}; pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR}; pub use whisper::WhisperBatchASR; pub use xfyun::{XfyunCredentials, XfyunStreamingASR}; diff --git a/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs b/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs new file mode 100644 index 00000000..4832843d --- /dev/null +++ b/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs @@ -0,0 +1,1000 @@ +//! 腾讯云实时语音识别 WebSocket 客户端。 +//! +//! 官方文档:https://cloud.tencent.com/document/api/1093/48982 +//! +//! 协议要点: +//! - 端点:`wss://asr.cloud.tencent.com/asr/v2/`; +//! - 鉴权:查询参数按字典序拼接后,以 SecretKey 做 HMAC-SHA1,再 Base64; +//! - 音频:16 kHz / 16-bit / 单声道 PCM,每 200ms 发送 6400 bytes; +//! - 收尾:发送文本消息 `{"type":"end"}`,等待 `final=1`; +//! - 默认模型:`Hy-ASR-3.0-preview`(腾讯云当前最新混元 ASR Preview)。 + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine; +use futures_util::{SinkExt, StreamExt}; +use hmac::{Hmac, Mac}; +use parking_lot::Mutex as ParkingMutex; +use serde_json::Value; +use sha1::Sha1; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use uuid::Uuid; + +use crate::config::{TaskSpawner, TokioTaskSpawner}; +use crate::ports::{TextStreamChunk, TextStreamSink}; + +use super::{AudioConsumer, RawTranscript}; + +pub const PROVIDER_ID: &str = "tencent-cloud"; +pub const DEFAULT_ENDPOINT: &str = "wss://asr.cloud.tencent.com/asr/v2"; +pub const DEFAULT_MODEL: &str = "Hy-ASR-3.0-preview"; +pub const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400; + +const BYTES_PER_MS: u64 = 32; +const MAX_AUDIO_SEND_RATE: u64 = 2; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); +const FRAME_SEND_TIMEOUT: Duration = Duration::from_secs(5); +const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(15); + +type WsStream = WebSocketStream>; +type WsSink = futures_util::stream::SplitSink; +type SharedWriter = Arc>>; + +enum SendItem { + Audio(Vec), + End(oneshot::Sender>), +} + +#[derive(Clone, Debug)] +pub struct TencentCloudCredentials { + pub app_id: String, + pub secret_id: String, + pub secret_key: String, + pub model: String, +} + +impl TencentCloudCredentials { + pub fn auth_ok(&self) -> bool { + !self.app_id.trim().is_empty() + && !self.secret_id.trim().is_empty() + && !self.secret_key.trim().is_empty() + } + + fn resolved_model(&self) -> &str { + let model = self.model.trim(); + if model.is_empty() { + DEFAULT_MODEL + } else { + model + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum TencentCloudASRError { + #[error("credentials missing")] + CredentialsMissing, + #[error("connection failed")] + ConnectionFailed, + #[error("credentials rejected or service unavailable ({0})")] + AuthRejected(i64), + #[error("account unavailable ({0})")] + AccountUnavailable(i64), + #[error("rate limited")] + RateLimited, + #[error("service unavailable ({0})")] + ServiceUnavailable(i64), + #[error("request failed ({0})")] + TaskFailed(i64), + #[error("no final result")] + NoFinalResult, + #[error("final result timed out")] + FinalResultTimeout, +} + +#[derive(Default)] +struct SyncState { + pending_audio: Vec, + bytes_sent: u64, + started: bool, + finished: bool, + final_tx: Option>>, + final_segments: BTreeMap, + partial_segments: BTreeMap, + last_result_text: String, +} + +pub struct TencentCloudStreamingASR { + credentials: TencentCloudCredentials, + endpoint: String, + task_spawner: Arc, + state: ParkingMutex, + writer: SharedWriter, + final_rx: ParkingMutex>>>, + handshake_tx: ParkingMutex>>>, + send_tx: ParkingMutex>>, + partial_sink: ParkingMutex>>, +} + +impl TencentCloudStreamingASR { + pub fn new(credentials: TencentCloudCredentials) -> Self { + Self::with_task_spawner(credentials, Arc::new(TokioTaskSpawner)) + } + + pub fn with_task_spawner( + credentials: TencentCloudCredentials, + task_spawner: Arc, + ) -> Self { + Self::with_endpoint(credentials, task_spawner, DEFAULT_ENDPOINT.to_string()) + } + + fn with_endpoint( + credentials: TencentCloudCredentials, + task_spawner: Arc, + endpoint: String, + ) -> Self { + Self { + credentials, + endpoint, + task_spawner, + state: ParkingMutex::new(SyncState::default()), + writer: Arc::new(AsyncMutex::new(None)), + final_rx: ParkingMutex::new(None), + handshake_tx: ParkingMutex::new(None), + send_tx: ParkingMutex::new(None), + partial_sink: ParkingMutex::new(None), + } + } + + pub fn set_partial_sink(&self, sink: Arc) { + *self.partial_sink.lock() = Some(sink); + } + + pub fn connect_url(&self) -> String { + connect_url_at( + &self.endpoint, + &self.credentials, + chrono::Utc::now().timestamp(), + random_nonce(), + Uuid::new_v4().to_string(), + ) + } + + pub async fn open_session(self: &Arc) -> Result<(), TencentCloudASRError> { + if !self.credentials.auth_ok() { + return Err(TencentCloudASRError::CredentialsMissing); + } + + let request = self + .connect_url() + .into_client_request() + .map_err(|_| TencentCloudASRError::ConnectionFailed)?; + let (ws, _) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) + .await + .map_err(|_| TencentCloudASRError::ConnectionFailed)? + .map_err(|_| TencentCloudASRError::ConnectionFailed)?; + let (write, read) = ws.split(); + *self.writer.lock().await = Some(write); + + let (final_tx, final_rx) = oneshot::channel(); + let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); + let (handshake_tx, handshake_rx) = oneshot::channel(); + { + let mut state = self.state.lock(); + *state = SyncState::default(); + state.final_tx = Some(final_tx); + } + *self.final_rx.lock() = Some(final_rx); + *self.send_tx.lock() = Some(send_tx); + *self.handshake_tx.lock() = Some(handshake_tx); + + let writer = Arc::clone(&self.writer); + let weak_self = Arc::downgrade(self); + let task_spawner = Arc::clone(&self.task_spawner); + task_spawner.spawn(Box::pin(async move { + let mut next_audio_send_at = None; + while let Some(item) = send_rx.recv().await { + let (result, done) = match item { + SendItem::Audio(chunk) => { + if let Some(send_at) = next_audio_send_at { + tokio::time::sleep_until(send_at).await; + } + let interval_ms = + (chunk.len() as u64 / BYTES_PER_MS / MAX_AUDIO_SEND_RATE).max(1); + let result = send_binary(&writer, chunk).await; + next_audio_send_at = + Some(tokio::time::Instant::now() + Duration::from_millis(interval_ms)); + (result, None) + } + SendItem::End(done) => { + (send_text(&writer, r#"{"type":"end"}"#).await, Some(done)) + } + }; + if let Err(error) = &result { + log::error!("[tencent-cloud-asr] websocket send failed"); + if let Some(this) = weak_self.upgrade() { + this.finish_error(error.clone()); + } + } + if let Some(done) = done { + let _ = done.send(result.clone()); + } + if result.is_err() { + break; + } + } + })); + + let weak_self = Arc::downgrade(self); + let task_spawner = Arc::clone(&self.task_spawner); + task_spawner.spawn(Box::pin(async move { + let mut read = read; + while let Some(message) = read.next().await { + let Some(this) = weak_self.upgrade() else { + break; + }; + match message { + Ok(Message::Text(text)) => { + if !this.handle_text_message(&text) { + break; + } + } + Ok(Message::Close(_)) => { + this.finish_on_close(); + break; + } + Ok(_) => {} + Err(_) => { + log::error!("[tencent-cloud-asr] receive loop failed"); + this.finish_with_partial_or_error(TencentCloudASRError::ConnectionFailed); + break; + } + } + } + })); + + match tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake_rx).await { + Ok(Ok(Ok(()))) => Ok(()), + Ok(Ok(Err(error))) => { + self.cancel(); + Err(error) + } + Ok(Err(_)) => { + self.cancel(); + Err(TencentCloudASRError::ConnectionFailed) + } + Err(_) => { + self.cancel(); + Err(TencentCloudASRError::ConnectionFailed) + } + } + } + + pub async fn send_last_frame(&self) -> Result<(), TencentCloudASRError> { + self.flush_pending_audio(); + let (done_tx, done_rx) = oneshot::channel(); + let sender = self + .send_tx + .lock() + .as_ref() + .cloned() + .ok_or(TencentCloudASRError::ConnectionFailed)?; + sender + .send(SendItem::End(done_tx)) + .map_err(|_| TencentCloudASRError::ConnectionFailed)?; + done_rx + .await + .map_err(|_| TencentCloudASRError::ConnectionFailed)? + } + + pub async fn await_final_result(&self) -> Result { + self.await_final_result_with_timeout(FINAL_RESULT_TIMEOUT) + .await + } + + pub async fn await_final_result_with_timeout( + &self, + timeout: Duration, + ) -> Result { + let Some(receiver) = self.final_rx.lock().take() else { + return Err(TencentCloudASRError::NoFinalResult); + }; + match tokio::time::timeout(timeout, receiver).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err(TencentCloudASRError::NoFinalResult), + Err(_) => { + log::error!( + "[tencent-cloud-asr] final result timed out after {} ms", + timeout.as_millis() + ); + self.cancel(); + Err(TencentCloudASRError::FinalResultTimeout) + } + } + } + + pub fn cancel(&self) { + self.state.lock().pending_audio.clear(); + *self.handshake_tx.lock() = None; + *self.send_tx.lock() = None; + let writer = Arc::clone(&self.writer); + self.task_spawner.spawn(Box::pin(async move { + let mut guard = writer.lock().await; + if let Some(mut ws) = guard.take() { + let _ = ws.close().await; + } + })); + self.signal_error(TencentCloudASRError::NoFinalResult); + } + + fn flush_pending_audio(&self) { + let leftover = { + let mut state = self.state.lock(); + if state.pending_audio.is_empty() { + return; + } + let leftover = std::mem::take(&mut state.pending_audio); + state.bytes_sent += leftover.len() as u64; + leftover + }; + self.enqueue_audio(leftover); + } + + fn enqueue_audio(&self, chunk: Vec) { + let Some(sender) = self.send_tx.lock().as_ref().cloned() else { + return; + }; + if sender.send(SendItem::Audio(chunk)).is_err() { + self.signal_error(TencentCloudASRError::ConnectionFailed); + } + } + + fn handle_text_message(&self, text: &str) -> bool { + let value: Value = match serde_json::from_str(text) { + Ok(value) => value, + Err(error) => { + log::warn!("[tencent-cloud-asr] invalid json event: {error}"); + return true; + } + }; + let code = value.get("code").and_then(Value::as_i64).unwrap_or(-1); + if code != 0 { + let error = classify_server_error(code); + if let Some(sender) = self.handshake_tx.lock().take() { + let _ = sender.send(Err(error.clone())); + } + self.finish_error(error); + return false; + } + + self.mark_started(); + if let Some(result) = value.get("result") { + self.record_result(result); + } + if value.get("final").and_then(Value::as_i64) == Some(1) { + self.finish_success(); + return false; + } + true + } + + fn mark_started(&self) { + let mut state = self.state.lock(); + if !state.started { + state.started = true; + } + drop(state); + if let Some(sender) = self.handshake_tx.lock().take() { + let _ = sender.send(Ok(())); + } + } + + fn record_result(&self, result: &Value) { + let Some(text) = result.get("voice_text_str").and_then(Value::as_str) else { + return; + }; + let text = text.trim(); + if text.is_empty() { + return; + } + let index = result.get("index").and_then(Value::as_i64).unwrap_or(0); + let slice_type = result + .get("slice_type") + .and_then(Value::as_i64) + .unwrap_or(1); + let mut state = self.state.lock(); + state.last_result_text = text.to_string(); + let mut delta = None; + if slice_type == 2 { + state.final_segments.insert(index, text.to_string()); + state.partial_segments.remove(&index); + } else { + let previous = state + .partial_segments + .get(&index) + .map(String::as_str) + .unwrap_or(""); + delta = text + .strip_prefix(previous) + .filter(|suffix| !suffix.is_empty()) + .map(str::to_string); + state.partial_segments.insert(index, text.to_string()); + } + drop(state); + if let Some(delta) = delta { + if let Some(sink) = self.partial_sink.lock().clone() { + let _ = sink.publish(TextStreamChunk { + text: delta, + offset: 0, + }); + } + } + } + + fn finish_on_close(&self) { + if let Some(sender) = self.handshake_tx.lock().take() { + let _ = sender.send(Err(TencentCloudASRError::ConnectionFailed)); + return; + } + self.finish_with_partial_or_error(TencentCloudASRError::NoFinalResult); + } + + fn finish_with_partial_or_error(&self, error: TencentCloudASRError) { + let has_text = { + let state = self.state.lock(); + !state.last_result_text.trim().is_empty() + || !state.final_segments.is_empty() + || !state.partial_segments.is_empty() + }; + if has_text { + self.finish_success(); + } else { + self.finish_error(error); + } + } + + fn finish_success(&self) { + let (sender, text, duration_ms) = { + let mut state = self.state.lock(); + if state.finished { + return; + } + state.finished = true; + state.pending_audio.clear(); + let mut segments = state.final_segments.clone(); + for (index, text) in &state.partial_segments { + segments.entry(*index).or_insert_with(|| text.clone()); + } + let text = if segments.is_empty() { + state.last_result_text.clone() + } else { + super::mimo::join_transcript_chunks( + &segments.into_values().collect::>(), + ) + }; + (state.final_tx.take(), text, state.bytes_sent / BYTES_PER_MS) + }; + *self.send_tx.lock() = None; + if let Some(sender) = sender { + let _ = sender.send(Ok(RawTranscript { text, duration_ms })); + } + self.close_writer(); + } + + fn signal_error(&self, error: TencentCloudASRError) { + let sender = { + let mut state = self.state.lock(); + if state.finished { + return; + } + state.finished = true; + state.final_tx.take() + }; + *self.send_tx.lock() = None; + if let Some(sender) = sender { + let _ = sender.send(Err(error)); + } + } + + fn finish_error(&self, error: TencentCloudASRError) { + if let Some(sender) = self.handshake_tx.lock().take() { + let _ = sender.send(Err(error.clone())); + } + self.signal_error(error); + self.close_writer(); + } + + fn close_writer(&self) { + let writer = Arc::clone(&self.writer); + self.task_spawner.spawn(Box::pin(async move { + let mut guard = writer.lock().await; + if let Some(mut ws) = guard.take() { + let _ = ws.close().await; + } + })); + } +} + +impl AudioConsumer for TencentCloudStreamingASR { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + let chunks = { + let mut state = self.state.lock(); + if !state.started || state.finished { + return; + } + state.pending_audio.extend_from_slice(pcm); + let mut chunks = Vec::new(); + while state.pending_audio.len() >= TARGET_AUDIO_CHUNK_BYTES { + let chunk = state + .pending_audio + .drain(..TARGET_AUDIO_CHUNK_BYTES) + .collect::>(); + state.bytes_sent += chunk.len() as u64; + chunks.push(chunk); + } + chunks + }; + for chunk in chunks { + self.enqueue_audio(chunk); + } + } +} + +async fn send_binary(writer: &SharedWriter, data: Vec) -> Result<(), TencentCloudASRError> { + let mut guard = writer.lock().await; + let Some(ws) = guard.as_mut() else { + return Err(TencentCloudASRError::ConnectionFailed); + }; + tokio::time::timeout(FRAME_SEND_TIMEOUT, ws.send(Message::Binary(data))) + .await + .map_err(|_| TencentCloudASRError::ConnectionFailed)? + .map_err(|_| TencentCloudASRError::ConnectionFailed) +} + +async fn send_text(writer: &SharedWriter, data: &str) -> Result<(), TencentCloudASRError> { + let mut guard = writer.lock().await; + let Some(ws) = guard.as_mut() else { + return Err(TencentCloudASRError::ConnectionFailed); + }; + tokio::time::timeout(FRAME_SEND_TIMEOUT, ws.send(Message::Text(data.to_string()))) + .await + .map_err(|_| TencentCloudASRError::ConnectionFailed)? + .map_err(|_| TencentCloudASRError::ConnectionFailed) +} + +fn connect_url_at( + base_endpoint: &str, + credentials: &TencentCloudCredentials, + timestamp: i64, + nonce: u32, + voice_id: String, +) -> String { + let endpoint = format!( + "{}/{}", + base_endpoint.trim_end_matches('/'), + credentials.app_id.trim() + ); + let parsed = url::Url::parse(&endpoint).expect("static Tencent Cloud endpoint parses"); + let host_and_path = format!( + "{}{}", + parsed.host_str().expect("Tencent Cloud endpoint has host"), + parsed.path() + ); + let params = BTreeMap::from([ + ( + "engine_model_type".to_string(), + credentials.resolved_model().to_string(), + ), + ("expired".to_string(), (timestamp + 86_400).to_string()), + ("filter_dirty".to_string(), "0".to_string()), + ("filter_empty_result".to_string(), "1".to_string()), + ("filter_modal".to_string(), "1".to_string()), + ("filter_punc".to_string(), "0".to_string()), + ("needvad".to_string(), "1".to_string()), + ("nonce".to_string(), nonce.to_string()), + ( + "secretid".to_string(), + credentials.secret_id.trim().to_string(), + ), + ("timestamp".to_string(), timestamp.to_string()), + ("voice_format".to_string(), "1".to_string()), + ("voice_id".to_string(), voice_id), + ]); + let query = query_string(¶ms); + let signing_text = format!("{host_and_path}?{query}"); + let signature = compute_signature(&signing_text, &credentials.secret_key); + format!("{endpoint}?{query}&signature={}", url_encode(&signature)) +} + +fn query_string(params: &BTreeMap) -> String { + params + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&") +} + +fn url_encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +pub fn compute_signature(signing_text: &str, secret_key: &str) -> String { + let mut mac = Hmac::::new_from_slice(secret_key.trim().as_bytes()) + .expect("HMAC accepts any key length"); + mac.update(signing_text.as_bytes()); + base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()) +} + +fn random_nonce() -> u32 { + let bytes = Uuid::new_v4().into_bytes(); + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 1_000_000_000 + 1 +} + +fn classify_server_error(code: i64) -> TencentCloudASRError { + match code { + 4002 | 4003 => TencentCloudASRError::AuthRejected(code), + 4004 | 4005 => TencentCloudASRError::AccountUnavailable(code), + 4006 => TencentCloudASRError::RateLimited, + 4009 => TencentCloudASRError::ConnectionFailed, + 5000..=5002 => TencentCloudASRError::ServiceUnavailable(code), + _ => TencentCloudASRError::TaskFailed(code), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct DelayedFirstSpawner { + spawned: AtomicUsize, + release: Arc, + } + + impl TaskSpawner for DelayedFirstSpawner { + fn spawn(&self, task: futures_util::future::BoxFuture<'static, ()>) { + if self.spawned.fetch_add(1, Ordering::SeqCst) == 0 { + let release = Arc::clone(&self.release); + tokio::spawn(async move { + release.acquire_owned().await.unwrap().forget(); + task.await; + }); + } else { + tokio::spawn(task); + } + } + } + + fn credentials() -> TencentCloudCredentials { + TencentCloudCredentials { + app_id: "1259220000".into(), + secret_id: "test-secret-id".into(), + secret_key: "key".into(), + model: DEFAULT_MODEL.into(), + } + } + + #[test] + fn hmac_sha1_signature_matches_known_vector() { + assert_eq!( + compute_signature("The quick brown fox jumps over the lazy dog", "key"), + "3nybhbi3iqa8ino29wqQcBydtNk=" + ); + } + + #[test] + fn connect_url_uses_sorted_signed_query_and_encodes_signature() { + let url = connect_url_at( + DEFAULT_ENDPOINT, + &credentials(), + 1_700_000_000, + 42, + "voice-id".into(), + ); + assert!(url.starts_with( + "wss://asr.cloud.tencent.com/asr/v2/1259220000?engine_model_type=Hy-ASR-3.0-preview&expired=1700086400" + )); + assert!(url.contains("&nonce=42&secretid=test-secret-id×tamp=1700000000")); + assert!(url.contains("&voice_format=1&voice_id=voice-id&signature=")); + assert!(!url.ends_with('=')); + } + + #[test] + fn stable_segments_replace_partials_and_keep_order() { + let asr = TencentCloudStreamingASR::new(credentials()); + asr.record_result(&serde_json::json!({ + "slice_type": 1, + "index": 1, + "voice_text_str": "世界" + })); + asr.record_result(&serde_json::json!({ + "slice_type": 2, + "index": 0, + "voice_text_str": "你好" + })); + let state = asr.state.lock(); + assert_eq!( + state.final_segments.get(&0).map(String::as_str), + Some("你好") + ); + assert_eq!( + state.partial_segments.get(&1).map(String::as_str), + Some("世界") + ); + } + + #[test] + fn server_errors_are_classified_for_actions() { + assert!(matches!( + classify_server_error(4002), + TencentCloudASRError::AuthRejected(_) + )); + assert!(matches!( + classify_server_error(4006), + TencentCloudASRError::RateLimited + )); + assert!(matches!( + classify_server_error(5000), + TencentCloudASRError::ServiceUnavailable(5000) + )); + } + + #[test] + fn auth_ok_requires_all_three_keys() { + assert!(credentials().auth_ok()); + assert!(!TencentCloudCredentials { + app_id: "app".into(), + secret_id: "id".into(), + secret_key: String::new(), + model: DEFAULT_MODEL.into(), + } + .auth_ok()); + } + + #[tokio::test] + async fn websocket_session_sends_all_audio_before_end_and_returns_final_text() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text( + serde_json::json!({ "code": 0, "message": "success" }).to_string(), + )) + .await + .unwrap(); + + let first = ws.next().await.unwrap().unwrap(); + let second = ws.next().await.unwrap().unwrap(); + let end = ws.next().await.unwrap().unwrap(); + assert!(matches!(first, Message::Binary(bytes) if bytes.len() == 6_400)); + assert!(matches!(second, Message::Binary(bytes) if bytes.len() == 100)); + assert_eq!(end, Message::Text(r#"{"type":"end"}"#.to_string())); + + ws.send(Message::Text( + serde_json::json!({ + "code": 0, + "message": "success", + "result": { "slice_type": 2, "index": 0, "voice_text_str": "你好" } + }) + .to_string(), + )) + .await + .unwrap(); + ws.send(Message::Text( + serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(), + )) + .await + .unwrap(); + }); + let asr = Arc::new(TencentCloudStreamingASR::with_endpoint( + credentials(), + Arc::new(TokioTaskSpawner), + endpoint, + )); + + asr.open_session().await.unwrap(); + asr.consume_pcm_chunk(&vec![1; 6_500]); + asr.send_last_frame().await.unwrap(); + let transcript = asr.await_final_result().await.unwrap(); + + assert_eq!(transcript.text, "你好"); + assert_eq!(transcript.duration_ms, 203); + server.await.unwrap(); + } + + #[tokio::test] + async fn end_waits_for_queued_audio_even_when_writer_start_is_delayed() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap()); + let (first_tx, mut first_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text( + serde_json::json!({ "code": 0, "message": "success" }).to_string(), + )) + .await + .unwrap(); + first_tx.send(ws.next().await.unwrap().unwrap()).unwrap(); + assert!(matches!( + ws.next().await.unwrap().unwrap(), + Message::Binary(bytes) if bytes.len() == 100 + )); + assert_eq!( + ws.next().await.unwrap().unwrap(), + Message::Text(r#"{"type":"end"}"#.to_string()) + ); + ws.send(Message::Text( + serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(), + )) + .await + .unwrap(); + }); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let asr = Arc::new(TencentCloudStreamingASR::with_endpoint( + credentials(), + Arc::new(DelayedFirstSpawner { + spawned: AtomicUsize::new(0), + release: Arc::clone(&release), + }), + endpoint, + )); + asr.open_session().await.unwrap(); + asr.consume_pcm_chunk(&vec![1; 6_500]); + let finishing = { + let asr = Arc::clone(&asr); + tokio::spawn(async move { asr.send_last_frame().await }) + }; + + assert!( + tokio::time::timeout(Duration::from_millis(1_800), &mut first_rx) + .await + .is_err(), + "end must not bypass audio queued on a delayed writer" + ); + release.add_permits(1); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(2), &mut first_rx) + .await + .unwrap() + .unwrap(), + Message::Binary(bytes) if bytes.len() == 6_400 + )); + finishing.await.unwrap().unwrap(); + assert!(asr.await_final_result().await.unwrap().text.is_empty()); + server.await.unwrap(); + } + + #[tokio::test] + async fn buffered_audio_replay_stays_within_tencent_rate_limit() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap()); + let (elapsed_tx, elapsed_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text( + serde_json::json!({ "code": 0, "message": "success" }).to_string(), + )) + .await + .unwrap(); + let mut received_at = Vec::new(); + for _ in 0..3 { + assert!(matches!( + ws.next().await.unwrap().unwrap(), + Message::Binary(bytes) if bytes.len() == TARGET_AUDIO_CHUNK_BYTES + )); + received_at.push(tokio::time::Instant::now()); + } + assert_eq!( + ws.next().await.unwrap().unwrap(), + Message::Text(r#"{"type":"end"}"#.to_string()) + ); + elapsed_tx + .send(received_at[2].duration_since(received_at[0])) + .unwrap(); + ws.send(Message::Text( + serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(), + )) + .await + .unwrap(); + }); + let asr = Arc::new(TencentCloudStreamingASR::with_endpoint( + credentials(), + Arc::new(TokioTaskSpawner), + endpoint, + )); + + asr.open_session().await.unwrap(); + asr.consume_pcm_chunk(&vec![1; TARGET_AUDIO_CHUNK_BYTES * 3]); + asr.send_last_frame().await.unwrap(); + asr.await_final_result().await.unwrap(); + + assert!( + elapsed_rx.await.unwrap() >= Duration::from_millis(180), + "buffered audio must be paced instead of sent as one burst" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn websocket_rejection_does_not_expose_server_message() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text( + serde_json::json!({ + "code": 4002, + "message": "SENSITIVE_SERVER_RESPONSE" + }) + .to_string(), + )) + .await + .unwrap(); + }); + let asr = Arc::new(TencentCloudStreamingASR::with_endpoint( + credentials(), + Arc::new(TokioTaskSpawner), + endpoint, + )); + + let error = asr.open_session().await.unwrap_err(); + + assert!(matches!(error, TencentCloudASRError::AuthRejected(4002))); + assert!(!error.to_string().contains("SENSITIVE_SERVER_RESPONSE")); + server.await.unwrap(); + } + + #[tokio::test] + async fn cancel_closes_the_socket_and_unblocks_waiters() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap()); + let (closed_tx, closed_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text( + serde_json::json!({ "code": 0, "message": "success" }).to_string(), + )) + .await + .unwrap(); + let closed = matches!( + ws.next().await, + Some(Ok(Message::Close(_))) | None | Some(Err(_)) + ); + closed_tx.send(closed).unwrap(); + }); + let asr = Arc::new(TencentCloudStreamingASR::with_endpoint( + credentials(), + Arc::new(TokioTaskSpawner), + endpoint, + )); + asr.open_session().await.unwrap(); + + asr.cancel(); + + assert!(tokio::time::timeout(Duration::from_secs(2), closed_rx) + .await + .unwrap() + .unwrap()); + assert!(matches!( + asr.send_last_frame().await, + Err(TencentCloudASRError::ConnectionFailed) + )); + assert!(matches!( + asr.await_final_result().await, + Err(TencentCloudASRError::NoFinalResult) + )); + server.await.unwrap(); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index f79ccaea..ac5a06de 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -12,11 +12,13 @@ use std::time::{Duration, Instant}; use futures_util::future::BoxFuture; use parking_lot::{Mutex, RwLock}; +use crate::asr::tencent_cloud::TencentCloudASRError; use crate::asr::{ BailianCredentials, BailianRealtimeASR, DashScopeMultimodalASR, DictionaryHotword, ElevenLabsBatchASR, MimoBatchASR, Qwen3RealtimeASR, Qwen3RealtimeCredentials, - StepfunRealtimeASR, StepfunRealtimeCredentials, VolcengineCredentials, VolcengineStreamingASR, - WhisperBatchASR, XfyunCredentials, XfyunStreamingASR, + StepfunRealtimeASR, StepfunRealtimeCredentials, TencentCloudCredentials, + TencentCloudStreamingASR, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, + XfyunCredentials, XfyunStreamingASR, }; use crate::config::{TaskSpawner, TokioTaskSpawner}; use crate::credentials::{ @@ -24,7 +26,8 @@ use crate::credentials::{ ASR_API_KEY_ACCOUNT, ASR_ENDPOINT_ACCOUNT, ASR_MODEL_ACCOUNT, ASR_VOCABULARY_ID_ACCOUNT, LLM_API_KEY_ACCOUNT, LLM_ENDPOINT_ACCOUNT, LLM_EXTRA_HEADERS_ACCOUNT, LLM_TEMPERATURE_ACCOUNT, OMNI_API_KEY_ACCOUNT, OMNI_ENDPOINT_ACCOUNT, OMNI_EXTRA_HEADERS_ACCOUNT, OMNI_MODEL_ACCOUNT, - OMNI_TEMPERATURE_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT, + OMNI_TEMPERATURE_ACCOUNT, TENCENT_CLOUD_APP_ID_ACCOUNT, TENCENT_CLOUD_SECRET_ID_ACCOUNT, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT, VOLCENGINE_APP_KEY_ACCOUNT, VOLCENGINE_AUTH_MODE_ACCOUNT, VOLCENGINE_RESOURCE_ID_ACCOUNT, XFYUN_API_KEY_ACCOUNT, XFYUN_APP_ID_ACCOUNT, }; @@ -60,6 +63,7 @@ pub const SHARED_CLOUD_ASR_PROVIDER_TYPES: &[&str] = &[ "openai-compatible", "xiaomi-mimo-asr", "iflytek", + "tencent-cloud", ]; pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ @@ -79,6 +83,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "minimax", "stepfun", "opencode", + "tencentTokenHub", "custom", "custom_responses", "custom_messages", @@ -119,6 +124,7 @@ enum CloudTranscriptionSessionKind { QwenRealtime(Arc), StepfunRealtime(Arc), Xfyun(Arc), + TencentCloud(Arc), } struct CloudTranscriptionSession { @@ -169,6 +175,9 @@ impl AudioConsumer for CloudTranscriptionSession { provider.consume_pcm_chunk(pcm) } CloudTranscriptionSessionKind::Xfyun(provider) => provider.consume_pcm_chunk(pcm), + CloudTranscriptionSessionKind::TencentCloud(provider) => { + provider.consume_pcm_chunk(pcm) + } } } } @@ -235,6 +244,16 @@ impl TranscriptionSession for CloudTranscriptionSession { timeout_transcription(Duration::from_secs(120), provider.await_final_result()) .await? } + CloudTranscriptionSessionKind::TencentCloud(provider) => { + provider + .send_last_frame() + .await + .map_err(map_tencent_asr_error)?; + provider + .await_final_result() + .await + .map_err(map_tencent_asr_error)? + } }; Ok(TranscriptOutput { text: transcript.text, @@ -254,6 +273,7 @@ impl TranscriptionSession for CloudTranscriptionSession { CloudTranscriptionSessionKind::QwenRealtime(provider) => provider.cancel(), CloudTranscriptionSessionKind::StepfunRealtime(provider) => provider.cancel(), CloudTranscriptionSessionKind::Xfyun(provider) => provider.cancel(), + CloudTranscriptionSessionKind::TencentCloud(provider) => provider.cancel(), } Box::pin(async { Ok(()) }) } @@ -632,6 +652,62 @@ async fn build_cloud_transcription_session( provider.open_session().await.map_err(map_asr_error)?; (CloudTranscriptionSessionKind::Xfyun(provider), None) } + ActiveAsrProviderKind::TencentCloud => { + let app_id = read_channel_credential( + credentials, + CredentialNamespace::Asr, + channel_id, + TENCENT_CLOUD_APP_ID_ACCOUNT, + ) + .await? + .unwrap_or_default(); + let secret_id = read_channel_credential( + credentials, + CredentialNamespace::Asr, + channel_id, + TENCENT_CLOUD_SECRET_ID_ACCOUNT, + ) + .await? + .unwrap_or_default(); + let secret_key = read_channel_credential( + credentials, + CredentialNamespace::Asr, + channel_id, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + ) + .await? + .unwrap_or_default(); + require_configured(&app_id, "Tencent Cloud AppID")?; + require_configured(&secret_id, "Tencent Cloud SecretID")?; + require_configured(&secret_key, "Tencent Cloud SecretKey")?; + let model = read_channel_credential( + credentials, + CredentialNamespace::Asr, + channel_id, + ASR_MODEL_ACCOUNT, + ) + .await? + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| crate::asr::tencent_cloud::DEFAULT_MODEL.to_string()); + let provider = Arc::new(TencentCloudStreamingASR::with_task_spawner( + TencentCloudCredentials { + app_id, + secret_id, + secret_key, + model: model.clone(), + }, + Arc::clone(&task_spawner), + )); + provider.set_partial_sink(Arc::clone(&partials)); + provider + .open_session() + .await + .map_err(map_tencent_asr_error)?; + ( + CloudTranscriptionSessionKind::TencentCloud(provider), + Some(model), + ) + } }; Ok((kind, crate::AsrCallLabel::new(effective, label_model))) } @@ -676,6 +752,22 @@ fn map_asr_error(error: impl std::fmt::Display) -> BackendError { ) } +fn map_tencent_asr_error(error: TencentCloudASRError) -> BackendError { + let retryable = matches!( + &error, + TencentCloudASRError::ConnectionFailed + | TencentCloudASRError::RateLimited + | TencentCloudASRError::ServiceUnavailable(_) + | TencentCloudASRError::NoFinalResult + | TencentCloudASRError::FinalResultTimeout + ); + BackendError::new( + BackendErrorCode::Provider, + format!("Tencent Cloud ASR failed: {error}"), + ) + .retryable(retryable) +} + pub struct SharedCloudTextPolisher { credentials: Arc, active: Arc>>>, @@ -2322,6 +2414,35 @@ mod tests { assert_translation_streams_before_completion(false, false, true).await; } + #[test] + fn tencent_cloud_errors_keep_retryability_and_stable_public_messages() { + use crate::asr::tencent_cloud::TencentCloudASRError; + + for error in [ + TencentCloudASRError::ConnectionFailed, + TencentCloudASRError::RateLimited, + TencentCloudASRError::NoFinalResult, + TencentCloudASRError::FinalResultTimeout, + TencentCloudASRError::ServiceUnavailable(5000), + ] { + let error = map_tencent_asr_error(error); + assert_eq!(error.code, BackendErrorCode::Provider); + assert!(error.retryable, "{error:?}"); + assert!(error.details.is_none()); + } + for error in [ + TencentCloudASRError::CredentialsMissing, + TencentCloudASRError::AuthRejected(4002), + TencentCloudASRError::AccountUnavailable(4004), + TencentCloudASRError::TaskFailed(4001), + ] { + let error = map_tencent_asr_error(error); + assert_eq!(error.code, BackendErrorCode::Provider); + assert!(!error.retryable, "{error:?}"); + assert!(error.details.is_none()); + } + } + struct IgnoreTextStreamSink; impl TextStreamSink for IgnoreTextStreamSink { diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index db651678..dc627063 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -68,6 +68,9 @@ pub const VOLCENGINE_AUTH_MODE_ACCOUNT: &str = "volcengine.auth_mode"; pub const VOLCENGINE_API_KEY_ACCOUNT: &str = "volcengine.api_key"; pub const XFYUN_APP_ID_ACCOUNT: &str = "xfyun.app_id"; pub const XFYUN_API_KEY_ACCOUNT: &str = "xfyun.api_key"; +pub const TENCENT_CLOUD_APP_ID_ACCOUNT: &str = "tencent_cloud.app_id"; +pub const TENCENT_CLOUD_SECRET_ID_ACCOUNT: &str = "tencent_cloud.secret_id"; +pub const TENCENT_CLOUD_SECRET_KEY_ACCOUNT: &str = "tencent_cloud.secret_key"; pub const LLM_API_KEY_ACCOUNT: &str = "ark.api_key"; pub const LLM_MODEL_ACCOUNT: &str = "ark.model_id"; pub const LLM_ENDPOINT_ACCOUNT: &str = "ark.endpoint"; diff --git a/openless-all/app/crates/openless-core/src/credentials_legacy.rs b/openless-all/app/crates/openless-core/src/credentials_legacy.rs index 6b5bc878..74b3f190 100644 --- a/openless-all/app/crates/openless-core/src/credentials_legacy.rs +++ b/openless-all/app/crates/openless-core/src/credentials_legacy.rs @@ -99,6 +99,9 @@ struct LegacyEntry { advanced_config: Option, xfyun_app_id: Option, xfyun_api_key: Option, + tencent_cloud_app_id: Option, + tencent_cloud_secret_id: Option, + tencent_cloud_secret_key: Option, temperature: Option, extra_headers: Option>, request_format: Option, @@ -127,6 +130,9 @@ impl Default for LegacyEntry { advanced_config: None, xfyun_app_id: None, xfyun_api_key: None, + tencent_cloud_app_id: None, + tencent_cloud_secret_id: None, + tencent_cloud_secret_key: None, temperature: None, extra_headers: None, request_format: None, @@ -153,6 +159,9 @@ impl LegacyEntry { &self.advanced_config, &self.xfyun_app_id, &self.xfyun_api_key, + &self.tencent_cloud_app_id, + &self.tencent_cloud_secret_id, + &self.tencent_cloud_secret_key, &self.request_format, &self.messages_thinking, &self.max_tokens, @@ -379,6 +388,15 @@ fn decode_entry( (ASR_ADVANCED_CONFIG_ACCOUNT, entry.advanced_config), (XFYUN_APP_ID_ACCOUNT, entry.xfyun_app_id), (XFYUN_API_KEY_ACCOUNT, entry.xfyun_api_key), + (TENCENT_CLOUD_APP_ID_ACCOUNT, entry.tencent_cloud_app_id), + ( + TENCENT_CLOUD_SECRET_ID_ACCOUNT, + entry.tencent_cloud_secret_id, + ), + ( + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + entry.tencent_cloud_secret_key, + ), ]); } if let Some(account) = extra_headers { @@ -432,6 +450,9 @@ pub fn read_legacy_accounts( ASR_ADVANCED_CONFIG_ACCOUNT, XFYUN_APP_ID_ACCOUNT, XFYUN_API_KEY_ACCOUNT, + TENCENT_CLOUD_APP_ID_ACCOUNT, + TENCENT_CLOUD_SECRET_ID_ACCOUNT, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, ][..], ), ( diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index ee0e01e7..0c4314ba 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -39,7 +39,7 @@ impl LlmRequestFormat { } pub fn selectable(provider: &str) -> bool { - !matches!(provider, "gemini" | "codex_oauth") + !matches!(provider, "gemini" | "codex_oauth" | "tencentTokenHub") } pub fn parse(value: &str) -> Result { @@ -531,6 +531,32 @@ mod tests { .is_err()); } + #[tokio::test] + async fn tokenhub_is_fixed_to_chat_completions() { + let store = InMemoryCredentialStore::default(); + store + .write( + CredentialKey::new( + CredentialNamespace::Llm, + Some("tokenhub".into()), + REQUEST_FORMAT_ACCOUNT, + ) + .unwrap(), + SecretValue::new("messages"), + ) + .await + .unwrap(); + + assert!(!LlmRequestFormat::selectable("tencentTokenHub")); + assert_eq!( + LlmProtocolConfig::load(&store, "tokenhub", "tencentTokenHub") + .await + .unwrap() + .format, + LlmRequestFormat::ChatCompletions + ); + } + #[test] fn urls_and_auth_follow_format_without_losing_gateway_paths() { for descriptor in crate::provider_rules::provider_descriptors(crate::ProviderKind::Llm) { diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index f37c9a98..ff7c6824 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -189,6 +189,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "minimax" | "stepfun" | "opencode" + | "tencentTokenHub" ) } @@ -1799,6 +1800,10 @@ pub(crate) fn apply_openai_compatible_thinking_control( model: &str, thinking_enabled: bool, ) { + if provider_id.trim() == "tencentTokenHub" { + apply_tokenhub_chat_thinking_control(body, model, thinking_enabled); + return; + } // 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底, // 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。 // Zen 是多模型网关,仅 DeepSeek 模型使用 DeepSeek 的思考参数。 @@ -1861,6 +1866,31 @@ pub(crate) fn apply_openai_compatible_thinking_control( } } +fn apply_tokenhub_chat_thinking_control(body: &mut Value, model: &str, enabled: bool) { + use crate::provider_rules::TokenHubChatModelPolicy::*; + + match crate::provider_rules::tokenhub_chat_model_policy(model) { + Some(Hy3) => { + body["thinking"] = json!({ "type": if enabled { "enabled" } else { "disabled" } }); + if enabled { + body["reasoning_effort"] = json!("medium"); + } + } + Some(ToggleThinking) => { + body["thinking"] = json!({ "type": if enabled { "enabled" } else { "disabled" } }); + } + Some(QwenThinking) => body["enable_thinking"] = json!(enabled), + Some(AdaptiveThinking) => { + body["thinking"] = json!({ "type": if enabled { "adaptive" } else { "disabled" } }); + } + Some(AlwaysThinking) if enabled => { + body["thinking"] = json!({ "type": "enabled" }); + } + Some(KimiK3) if enabled => body["reasoning_effort"] = json!("max"), + Some(AlwaysThinking | KimiK3 | Plain) | None => {} + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ThinkingControl { ReasoningEffort, @@ -3417,6 +3447,88 @@ mod tests { assert_eq!(body["thinking"]["type"], "adaptive"); } + #[test] + fn tokenhub_chat_thinking_matches_model_capabilities() { + let body = |model: &str, enabled: bool| { + OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "tencentTokenHub", + "Tencent TokenHub", + "https://tokenhub.tencentmaas.com/v1", + "k", + model, + ) + .with_thinking_enabled(enabled), + ) + .chat_body(false, vec![json!({ "role": "user", "content": "hi" })]) + }; + + for model in [ + "hy3", + "hy4-preview", + "deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + "glm-5.2", + "glm-5v-turbo", + "kimi-k2.6", + "kimi-k2.5", + ] { + assert_eq!(body(model, true)["thinking"]["type"], "enabled", "{model}"); + assert_eq!( + body(model, false)["thinking"]["type"], + "disabled", + "{model}" + ); + } + assert_eq!(body("hy3", true)["reasoning_effort"], "medium"); + + assert_eq!(body("qwen3.5-plus", true)["enable_thinking"], true); + assert_eq!(body("qwen3.5-plus", false)["enable_thinking"], false); + + assert_eq!(body("minimax-m3", true)["thinking"]["type"], "adaptive"); + assert_eq!(body("minimax-m3", false)["thinking"]["type"], "disabled"); + + for model in ["glm-5.3", "kimi-k2.7-code", "minimax-m2.7"] { + assert!(body(model, false).get("thinking").is_none(), "{model}"); + assert_eq!(body(model, true)["thinking"]["type"], "enabled"); + } + let kimi_k3 = body("kimi-k3", true); + assert_eq!(kimi_k3["reasoning_effort"], "max"); + assert!(kimi_k3.get("thinking").is_none()); + assert!(body("kimi-k3", false).get("reasoning_effort").is_none()); + + for model in [ + "hy-mt2-pro", + "hy-role", + "hunyuan-role-latest", + "mimo-v2.5-pro", + "future-model", + ] { + let body = body(model, true); + assert!(body.get("thinking").is_none(), "{model}"); + assert!(body.get("enable_thinking").is_none(), "{model}"); + assert!(body.get("reasoning_effort").is_none(), "{model}"); + } + } + + #[test] + fn custom_tokenhub_endpoint_does_not_invent_model_policy() { + for base_url in [ + "https://tokenhub.tencentmaas.com/v1/", + "https://api.lkeap.cloud.tencent.com/plan/v3", + ] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new("custom", "Custom", base_url, "k", "hy3") + .with_thinking_enabled(false), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert!(body.get("thinking").is_none()); + assert!(body.get("reasoning_effort").is_none()); + } + } + #[test] fn openai_chat_body_falls_back_to_base_url_for_custom_minimax_endpoint() { // 用 "custom" preset + 自定义 MiniMax base_url 接入时,base_url 兜底 diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 9bcbe439..01bd1ec0 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -21,6 +21,7 @@ const MIMO_PROVIDER_ID: &str = "xiaomi-mimo-asr"; const DASHSCOPE_MULTIMODAL_PROVIDER_ID: &str = "bailian-fun-asr-flash"; const ELEVENLABS_PROVIDER_ID: &str = "elevenlabs"; const XFYUN_PROVIDER_ID: &str = "iflytek"; +const TENCENT_CLOUD_PROVIDER_ID: &str = "tencent-cloud"; const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[ ("volcengine", "asrVolcengine"), @@ -39,6 +40,7 @@ const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[ (OPENAI_COMPATIBLE_ASR_PROVIDER_ID, "asrOpenAiCompatible"), ("xiaomi-mimo-asr", "asrXiaomiMimo"), (XFYUN_PROVIDER_ID, "asrIflytek"), + (TENCENT_CLOUD_PROVIDER_ID, "asrTencentCloud"), ("foundry-local-whisper", "asrFoundryLocalWhisper"), ("local-whisper", "asrLocalWhisper"), ("sherpa-onnx-local", "asrSherpaOnnxLocal"), @@ -65,6 +67,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("minimax", "minimax"), ("stepfun", "stepfun"), ("opencode", "opencode"), + ("tencentTokenHub", "tencentTokenHub"), ("custom", "customChatCompletions"), ("custom_responses", "customResponses"), ("custom_messages", "customMessages"), @@ -112,6 +115,7 @@ pub enum AuthRequirement { ApiKeyUnlessCustomEndpoint, Volcengine, Xfyun, + TencentCloud, OAuth, } @@ -189,11 +193,14 @@ fn provider_descriptor_with_label( match id.as_str() { "volcengine" => AuthRequirement::Volcengine, XFYUN_PROVIDER_ID => AuthRequirement::Xfyun, + TENCENT_CLOUD_PROVIDER_ID => AuthRequirement::TencentCloud, OPENAI_COMPATIBLE_ASR_PROVIDER_ID => AuthRequirement::EndpointModelOptionalApiKey, _ => AuthRequirement::ApiKey, }, match id.as_str() { - "volcengine" | XFYUN_PROVIDER_ID => ValidationProbe::AsrSilenceAllowsNoFinal, + "volcengine" | XFYUN_PROVIDER_ID | TENCENT_CLOUD_PROVIDER_ID => { + ValidationProbe::AsrSilenceAllowsNoFinal + } "stepfun" => ValidationProbe::StepfunNoSpeech, DASHSCOPE_MULTIMODAL_PROVIDER_ID => ValidationProbe::AsrNonSilent, _ => ValidationProbe::AsrSilence, @@ -305,6 +312,7 @@ pub enum ActiveAsrProviderKind { WhisperCompatible, Volcengine, Xfyun, + TencentCloud, } /// Non-secret facts read by a platform credential adapter. Core evaluates @@ -321,6 +329,9 @@ pub struct CredentialConfiguration { pub volcengine_resource_id: bool, pub xfyun_app_id: bool, pub xfyun_api_key: bool, + pub tencent_cloud_app_id: bool, + pub tencent_cloud_secret_id: bool, + pub tencent_cloud_secret_key: bool, pub llm_api_key: bool, pub llm_endpoint: bool, pub llm_endpoint_matches_default: bool, @@ -404,6 +415,11 @@ pub fn auth_requirement_satisfied( } AuthRequirement::Volcengine => volcengine_configured(configuration), AuthRequirement::Xfyun => configuration.xfyun_app_id && configuration.xfyun_api_key, + AuthRequirement::TencentCloud => { + configuration.tencent_cloud_app_id + && configuration.tencent_cloud_secret_id + && configuration.tencent_cloud_secret_key + } AuthRequirement::OAuth => configuration.codex_oauth && model, } } @@ -419,7 +435,8 @@ pub fn api_key_required( match descriptor.auth_requirement { AuthRequirement::None | AuthRequirement::EndpointModelOptionalApiKey - | AuthRequirement::OAuth => false, + | AuthRequirement::OAuth + | AuthRequirement::TencentCloud => false, AuthRequirement::ApiKeyUnlessCustomEndpoint => { let Some(endpoint) = configured_endpoint.filter(|value| !value.trim().is_empty()) else { @@ -481,6 +498,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { "orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL), "zenmux" => Some(crate::asr::whisper::ZENMUX_DEFAULT_MODEL), "xiaomi-mimo-asr" => Some(crate::asr::mimo::DEFAULT_MODEL), + TENCENT_CLOUD_PROVIDER_ID => Some(crate::asr::tencent_cloud::DEFAULT_MODEL), _ => None, } } @@ -502,6 +520,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { "codingPlanX" => Some("https://api.codingplanx.ai/v1"), "minimax" => Some("https://api.minimaxi.com/v1"), "stepfun" => Some("https://api.stepfun.com/v1"), + "tencentTokenHub" => Some("https://tokenhub.tencentmaas.com/v1"), _ => None, } } @@ -522,10 +541,77 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { "codingPlanX" => Some("gpt-5-mini"), "minimax" => Some("MiniMax-M3"), "stepfun" => Some("step-1o-turbo-vision"), + "tencentTokenHub" => Some("hy3"), _ => None, } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TokenHubChatModelPolicy { + Hy3, + ToggleThinking, + QwenThinking, + AdaptiveThinking, + AlwaysThinking, + KimiK3, + Plain, +} + +pub(crate) fn tokenhub_chat_model_policy(model: &str) -> Option { + use TokenHubChatModelPolicy::*; + + // ponytail: /models lacks capability metadata; keep this one family classifier until it does. + let model = model.trim().to_ascii_lowercase(); + let policy = if matches!(model.as_str(), "hy3" | "hy3-preview") { + Hy3 + } else if model.starts_with("qwen3.5-") { + QwenThinking + } else if model == "minimax-m3" { + AdaptiveThinking + } else if model == "kimi-k3" { + KimiK3 + } else if matches!( + model.as_str(), + "glm-5.3" | "glm-5.3-flash" | "minimax-m2.7" | "minimax-m2.5" + ) || model.starts_with("kimi-k2.7-code") + { + AlwaysThinking + } else if model.starts_with("hy4-") + || model.starts_with("deepseek-") + || model.starts_with("deepseek/") + || matches!( + model.as_str(), + "glm-5.2" + | "glm-5.1" + | "glm-5" + | "glm-5-turbo" + | "glm-5v-turbo" + | "kimi-k2.6" + | "kimi-k2.5" + ) + { + ToggleThinking + } else { + let minimax_language = model + .strip_prefix("minimax-m") + .and_then(|suffix| suffix.chars().next()) + .is_some_and(|character| character.is_ascii_digit()); + if model.starts_with("hy-mt2-") + || model.starts_with("hy-role") + || model.starts_with("hunyuan-role") + || model.starts_with("glm-") + || model.starts_with("kimi-") + || minimax_language + || model.starts_with("mimo-v") + { + Plain + } else { + return None; + } + }; + Some(policy) +} + pub fn default_omni_endpoint(provider_type: &str) -> Option<&'static str> { match provider_type { "openai" => Some("https://api.openai.com/v1"), @@ -577,6 +663,7 @@ pub fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind { DASHSCOPE_MULTIMODAL_PROVIDER_ID => ActiveAsrProviderKind::DashScopeMultimodal, ELEVENLABS_PROVIDER_ID => ActiveAsrProviderKind::ElevenLabs, XFYUN_PROVIDER_ID => ActiveAsrProviderKind::Xfyun, + TENCENT_CLOUD_PROVIDER_ID => ActiveAsrProviderKind::TencentCloud, value if is_whisper_compatible_provider(value) => ActiveAsrProviderKind::WhisperCompatible, _ => ActiveAsrProviderKind::Volcengine, } @@ -610,6 +697,10 @@ pub fn is_xfyun_provider(id: &str) -> bool { id == XFYUN_PROVIDER_ID } +pub fn is_tencent_cloud_provider(id: &str) -> bool { + id == TENCENT_CLOUD_PROVIDER_ID +} + pub fn is_whisper_compatible_provider(id: &str) -> bool { matches!( id, @@ -1020,6 +1111,49 @@ mod tests { assert!(llm_configured("opencode", &configuration)); } + #[test] + fn tencent_cloud_asr_and_tokenhub_supply_defaults_and_credentials() { + assert!(crate::cloud_providers::SHARED_CLOUD_ASR_PROVIDER_TYPES.contains(&"tencent-cloud")); + assert!( + crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"tencentTokenHub") + ); + let asr = provider_descriptor(ProviderKind::Asr, "tencent-cloud").unwrap(); + assert_eq!(asr.label_key, "asrTencentCloud"); + assert_eq!( + asr.default_model.as_deref(), + Some(crate::asr::tencent_cloud::DEFAULT_MODEL) + ); + assert_eq!(asr.auth_requirement, AuthRequirement::TencentCloud); + assert_eq!( + asr.validation_probe, + ValidationProbe::AsrSilenceAllowsNoFinal + ); + let mut configuration = CredentialConfiguration::default(); + assert!(!auth_requirement_satisfied(&asr, &configuration)); + configuration.tencent_cloud_app_id = true; + configuration.tencent_cloud_secret_id = true; + configuration.tencent_cloud_secret_key = true; + assert!(auth_requirement_satisfied(&asr, &configuration)); + assert_eq!( + active_asr_provider_kind("tencent-cloud"), + ActiveAsrProviderKind::TencentCloud + ); + + let llm = provider_descriptor(ProviderKind::Llm, "tencentTokenHub").unwrap(); + assert_eq!(llm.label_key, "tencentTokenHub"); + assert_eq!( + llm.default_endpoint.as_deref(), + Some("https://tokenhub.tencentmaas.com/v1") + ); + assert_eq!(llm.default_model.as_deref(), Some("hy3")); + assert_eq!( + llm.auth_requirement, + AuthRequirement::ApiKeyUnlessCustomEndpoint + ); + assert_eq!(llm.default_request_format, None); + assert!(llm.supported_request_formats.is_empty()); + } + #[test] fn descriptors_are_the_single_source_for_defaults_auth_and_probes() { let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap(); @@ -1084,6 +1218,19 @@ mod tests { } } + #[test] + fn browser_provider_descriptor_fixture_matches_core() { + let fixture: Vec = serde_json::from_str(include_str!( + "../../../src/lib/ipc/mock-provider-descriptors.json" + )) + .unwrap(); + let actual = [ProviderKind::Llm, ProviderKind::Asr, ProviderKind::Omni] + .into_iter() + .flat_map(provider_descriptors) + .collect::>(); + assert_eq!(fixture, actual); + } + #[test] fn secret_like_volc_resource_ids_are_not_attributed() { assert_eq!( diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index a3bbd298..d4a89c3f 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -418,7 +418,7 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro ) && api_key.trim().is_empty() && !matches!( descriptor.auth_requirement, - AuthRequirement::Volcengine | AuthRequirement::Xfyun + AuthRequirement::Volcengine | AuthRequirement::Xfyun | AuthRequirement::TencentCloud ) { let label = match resolved.kind { @@ -436,7 +436,10 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro if model.is_none() && !matches!( descriptor.auth_requirement, - AuthRequirement::None | AuthRequirement::Volcengine | AuthRequirement::Xfyun + AuthRequirement::None + | AuthRequirement::Volcengine + | AuthRequirement::Xfyun + | AuthRequirement::TencentCloud ) { return Err(invalid_request("provider model is not configured")); @@ -450,7 +453,10 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro if endpoint.is_none() && !matches!( descriptor.auth_requirement, - AuthRequirement::None | AuthRequirement::Volcengine | AuthRequirement::Xfyun + AuthRequirement::None + | AuthRequirement::Volcengine + | AuthRequirement::Xfyun + | AuthRequirement::TencentCloud ) { return Err(provider_error("provider endpoint is not configured")); @@ -650,6 +656,7 @@ async fn fetch_models( .ok_or_else(|| provider_error("provider endpoint is not configured"))?; let url = models_url(endpoint)?; let is_gemini = resolved.provider_type == "gemini"; + let tokenhub_chat_only = resolved.provider_type == "tencentTokenHub"; let orcarouter_filter = (resolved.provider_type == "orcarouter").then(|| { let endpoint_type = match (resolved.kind, resolved.protocol.format) { (ProviderKind::Llm, LlmRequestFormat::Responses) => "openai-response", @@ -709,7 +716,12 @@ async fn fetch_models( if response.body.len() > MODEL_LIST_MAX_BYTES { return Err(provider_error("provider model response is too large")); } - parse_model_list(&response.body, is_gemini, orcarouter_filter) + parse_model_list( + &response.body, + is_gemini, + orcarouter_filter, + tokenhub_chat_only, + ) } #[derive(Clone, Copy)] @@ -722,6 +734,7 @@ fn parse_model_list( body: &[u8], is_gemini: bool, orcarouter_filter: Option, + tokenhub_chat_only: bool, ) -> Result, BackendError> { let value: serde_json::Value = serde_json::from_slice(body) .map_err(|_| provider_error("provider model response is invalid JSON"))?; @@ -756,6 +769,10 @@ fn parse_model_list( .and_then(serde_json::Value::as_array) .ok_or_else(|| provider_error("provider model response is missing data"))? .iter() + .filter(|item| { + !tokenhub_chat_only + || item.get("status").and_then(serde_json::Value::as_str) == Some("online") + }) .filter(|item| { orcarouter_filter.is_none_or(|filter| { let supports_endpoint = item @@ -781,6 +798,10 @@ fn parse_model_list( .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str)) .map(str::trim) .filter(|name| !name.is_empty()) + .filter(|name| { + !tokenhub_chat_only + || crate::provider_rules::tokenhub_chat_model_policy(name).is_some() + }) .filter(|name| { if !orcarouter_filter.is_some_and(|filter| filter.kind == ProviderKind::Asr) { return true; @@ -1338,6 +1359,7 @@ mod tests { br#"{"data":[{"id":"gpt-z"},{"id":""},{"id":"gpt-a"},{"id":"gpt-z"}]}"#, false, None, + false, ) .unwrap(); assert_eq!(models, vec!["gpt-a", "gpt-z"]); @@ -1349,6 +1371,7 @@ mod tests { br#"{"models":[{"name":"models/gemini-z","supportedGenerationMethods":["generateContent"]},{"name":"models/embedding","supportedGenerationMethods":["embedContent"]},{"name":"gemini-a"}]}"#, true, None, + false, ) .unwrap(); assert_eq!(models, vec!["gemini-a", "gemini-z"]); @@ -1356,11 +1379,87 @@ mod tests { #[test] fn invalid_model_response_is_a_provider_error_without_body() { - let error = parse_model_list(br#"{"error":"secret-key"}"#, false, None).unwrap_err(); + let error = parse_model_list(br#"{"error":"secret-key"}"#, false, None, false).unwrap_err(); assert_eq!(error.code, BackendErrorCode::Provider); assert!(!format!("{error:?}").contains("secret-key")); } + #[tokio::test] + async fn tokenhub_catalog_lists_only_online_language_models() { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values( + &credentials, + ChannelKind::Llm, + "tencentTokenHub", + &[(LLM_API_KEY_ACCOUNT, "fixture-key")], + ) + .await; + let transport = Arc::new(FakeProviderTransport::default()); + transport.push_response( + 200, + br#"{"object":"list","data":[ + {"id":"hy3","status":"online"}, + {"id":"hy4-preview","status":"online"}, + {"id":"hy-mt2-pro","status":"online"}, + {"id":"hy-role","status":"online"}, + {"id":"hunyuan-role-latest","status":"online"}, + {"id":"deepseek/deepseek-v4-flash","status":"online"}, + {"id":"glm-5.3","status":"online"}, + {"id":"kimi-k3","status":"online"}, + {"id":"minimax-m3","status":"online"}, + {"id":"qwen3.5-flash","status":"online"}, + {"id":"mimo-v2.5-pro","status":"online"}, + {"id":"deepseek-v4-pro","status":"pre-offline"}, + {"id":"glm-future"}, + {"id":"hy-image-v3","status":"online"}, + {"id":"hy-video-v1.5","status":"online"}, + {"id":"HY-3D-3.1","status":"online"}, + {"id":"hy-asr-3.0-preview","status":"online"}, + {"id":"minimax-speech-2.8-hd","status":"online"}, + {"id":"kinfra-text-embedding-4b","status":"online"} + ]}"# + .to_vec(), + ); + let service = ProviderService::new_with_transport( + credentials, + Arc::new(crate::TokioTaskSpawner), + transport.clone(), + ); + + let result = service + .list_models(ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + + assert_eq!( + result.models, + vec![ + "deepseek/deepseek-v4-flash", + "glm-5.3", + "hunyuan-role-latest", + "hy-mt2-pro", + "hy-role", + "hy3", + "hy4-preview", + "kimi-k3", + "mimo-v2.5-pro", + "minimax-m3", + "qwen3.5-flash", + ] + ); + assert_eq!( + transport.requests()[0].headers, + vec![( + "Authorization".to_string(), + "Bearer fixture-key".to_string() + )] + ); + } + #[tokio::test] async fn orcarouter_catalog_uses_core_channel_defaults_and_filters_capabilities() { let catalog = r#"{"data":[ diff --git a/openless-all/app/crates/openless-core/src/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index c870db84..f56c66d6 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -51,6 +51,9 @@ pub const SIDE_SPECIFIC_NON_DICTATION_MSG: &str = "Side-specific modifier shortcuts are only supported for dictation start/stop."; pub fn reject_side_specific_non_dictation(binding: &ShortcutBinding) -> Result<(), String> { + if binding.primary == "MacDictationKey" { + return Err("The Mac Dictation key is only supported for dictation start/stop.".into()); + } if binding_requires_side_aware_hook(binding) { return Err(SIDE_SPECIFIC_NON_DICTATION_MSG.to_string()); } @@ -193,6 +196,10 @@ pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { } pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { + #[cfg(target_os = "macos")] + if binding.primary == "MacDictationKey" && binding.modifiers.is_empty() { + return Ok(()); + } if legacy_modifier_trigger(binding).is_some() { return Ok(()); } @@ -278,6 +285,14 @@ fn validate_primary(raw: &str) -> Result<(), ShortcutBindingError> { | "F10" | "F11" | "F12" + | "F13" + | "F14" + | "F15" + | "F16" + | "F17" + | "F18" + | "F19" + | "F20" ) { return Ok(()); } @@ -785,7 +800,11 @@ mod tests { fn validates_shared_shortcut_grammar_without_a_native_hotkey_crate() { assert!(validate_shortcut_binding(&combo("D", &["cmd", "shift"])).is_ok()); assert!(validate_shortcut_binding(&combo("?", &["shift"])).is_ok()); - assert!(validate_shortcut_binding(&combo("F12", &[])).is_ok()); + for number in 1..=20 { + assert!(validate_shortcut_binding(&combo(&format!("F{number}"), &[])).is_ok()); + } + assert!(validate_shortcut_binding(&combo(" f20 ", &[])).is_ok()); + assert!(validate_shortcut_binding(&combo("F21", &[])).is_err()); assert_eq!( validate_shortcut_binding(&combo("D", &["hyper"])), Err(ShortcutBindingError::UnsupportedModifier("hyper".into())) diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index 2cb80cf7..1e35030d 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -27,6 +27,11 @@ libc = "0.2" axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } local-ip-address = "0.6" -rcgen = "0.13" +base64 = "0.22" +sha2 = "0.10" +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +time = "0.3" +tempfile = "3" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 123e285b..0f506728 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -5,7 +5,8 @@ use futures_util::future::BoxFuture; use openless_core::credentials::{ ASR_API_KEY_ACCOUNT, ASR_ENDPOINT_ACCOUNT, ASR_MODEL_ACCOUNT, LLM_API_KEY_ACCOUNT, LLM_ENDPOINT_ACCOUNT, LLM_MODEL_ACCOUNT, OMNI_API_KEY_ACCOUNT, OMNI_ENDPOINT_ACCOUNT, - OMNI_MODEL_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT, + OMNI_MODEL_ACCOUNT, TENCENT_CLOUD_APP_ID_ACCOUNT, TENCENT_CLOUD_SECRET_ID_ACCOUNT, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT, VOLCENGINE_APP_KEY_ACCOUNT, VOLCENGINE_AUTH_MODE_ACCOUNT, VOLCENGINE_RESOURCE_ID_ACCOUNT, XFYUN_API_KEY_ACCOUNT, XFYUN_APP_ID_ACCOUNT, }; @@ -395,6 +396,21 @@ impl CredentialStore for LinuxCredentialStore { &active_asr_provider, XFYUN_API_KEY_ACCOUNT, ), + tencent_cloud_app_id: has( + CredentialNamespace::Asr, + &active_asr_provider, + TENCENT_CLOUD_APP_ID_ACCOUNT, + ), + tencent_cloud_secret_id: has( + CredentialNamespace::Asr, + &active_asr_provider, + TENCENT_CLOUD_SECRET_ID_ACCOUNT, + ), + tencent_cloud_secret_key: has( + CredentialNamespace::Asr, + &active_asr_provider, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + ), llm_api_key: has( CredentialNamespace::Llm, &active_llm_provider, diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index d8d90f1e..bc71c24f 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -79,6 +79,7 @@ mod linux_app { model: String, auth_mode: String, resource_id: String, + app_id: String, // Secret inputs are intentionally write-only. Loading an editor never // exposes an existing key into egui state, logs or screenshots. primary_secret: String, @@ -2151,6 +2152,7 @@ mod linux_app { } openless_core::AuthRequirement::Volcengine => "火山引擎凭据", openless_core::AuthRequirement::Xfyun => "讯飞 AppID + API Key", + openless_core::AuthRequirement::TencentCloud => "腾讯云 AppID + SecretID + SecretKey", openless_core::AuthRequirement::OAuth => "OAuth", } } @@ -2230,6 +2232,19 @@ mod linux_app { } else { (String::new(), String::new()) }; + let app_id = if descriptor.auth_requirement == openless_core::AuthRequirement::TencentCloud + { + read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, + ) + .await? + .unwrap_or_default() + } else { + String::new() + }; Ok(ProviderEditor { kind, name: channel.name.clone(), @@ -2239,6 +2254,7 @@ mod linux_app { model, auth_mode, resource_id, + app_id, primary_secret: String::new(), secondary_secret: String::new(), }) @@ -2296,6 +2312,18 @@ mod linux_app { secret_edit(ui, "AppID", &mut editor.primary_secret); secret_edit(ui, "API Key", &mut editor.secondary_secret); } + openless_core::AuthRequirement::TencentCloud => { + ui.horizontal(|ui| { + ui.label("腾讯云 AppID"); + ui.text_edit_singleline(&mut editor.app_id); + }); + secret_edit(ui, "SecretID", &mut editor.primary_secret); + secret_edit(ui, "SecretKey", &mut editor.secondary_secret); + ui.horizontal(|ui| { + ui.label("Model"); + ui.text_edit_singleline(&mut editor.model); + }); + } _ => { secret_edit(ui, "API Key(留空表示不修改)", &mut editor.primary_secret); ui.horizontal(|ui| { @@ -2432,6 +2460,40 @@ mod linux_app { ) .await?; } + openless_core::AuthRequirement::TencentCloud => { + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, + &editor.app_id, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, + &editor.primary_secret, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + &editor.secondary_secret, + ) + .await?; + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + model_account(editor.kind), + &editor.model, + ) + .await?; + } _ => { write_or_remove_provider_value( &backend, @@ -2477,6 +2539,11 @@ mod linux_app { openless_core::credentials::XFYUN_APP_ID_ACCOUNT, openless_core::credentials::XFYUN_API_KEY_ACCOUNT, ], + openless_core::AuthRequirement::TencentCloud => &[ + openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, + openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, + openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + ], _ => &[api_key_account(editor.kind)], }; for account in accounts { diff --git a/openless-all/app/linux-egui/src/remote_input.rs b/openless-all/app/linux-egui/src/remote_input.rs index c2be6247..070579d9 100644 --- a/openless-all/app/linux-egui/src/remote_input.rs +++ b/openless-all/app/linux-egui/src/remote_input.rs @@ -289,71 +289,8 @@ fn access_urls(port: u16) -> Vec { } #[cfg(target_os = "linux")] -fn load_or_generate_certificate( - directory: &std::path::Path, - sans: &[String], -) -> Result<(Vec, rustls::pki_types::PrivateKeyDer<'static>), BackendError> { - use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; - - let cert_path = directory.join("remote-cert-v4.der"); - let key_path = directory.join("remote-key-v4.der"); - let sans_path = directory.join("remote-cert-sans-v4.txt"); - if let (Ok(cert), Ok(key), Ok(saved)) = ( - std::fs::read(&cert_path), - std::fs::read(&key_path), - std::fs::read_to_string(&sans_path), - ) { - let saved = saved.lines().collect::>(); - if sans.iter().all(|value| saved.contains(value.as_str())) { - return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))); - } - } - let mut params = rcgen::CertificateParams::new(sans.to_vec()) - .map_err(|error| remote_platform_error(format!("invalid TLS names: {error}")))?; - let mut name = rcgen::DistinguishedName::new(); - name.push(rcgen::DnType::CommonName, "OpenLess Remote Input"); - params.distinguished_name = name; - params - .extended_key_usages - .push(rcgen::ExtendedKeyUsagePurpose::ServerAuth); - let key = rcgen::KeyPair::generate() - .map_err(|error| remote_platform_error(format!("TLS key generation failed: {error}")))?; - let cert = params - .self_signed(&key) - .map_err(|error| remote_platform_error(format!("TLS certificate failed: {error}")))?; - let cert_der = cert.der().as_ref().to_vec(); - let key_der = key.serialize_der(); - std::fs::create_dir_all(directory) - .map_err(|error| remote_platform_error(format!("TLS directory failed: {error}")))?; - std::fs::write(&cert_path, &cert_der) - .map_err(|error| remote_platform_error(format!("TLS certificate save failed: {error}")))?; - std::fs::write(&key_path, &key_der) - .map_err(|error| remote_platform_error(format!("TLS key save failed: {error}")))?; - std::fs::write(&sans_path, sans.join("\n")) - .map_err(|error| remote_platform_error(format!("TLS names save failed: {error}")))?; - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) - .map_err(|error| remote_platform_error(format!("TLS key permissions failed: {error}")))?; - Ok(( - cert_der, - PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), - )) -} - -#[cfg(target_os = "linux")] -fn tls_config( - cert: Vec, - key: rustls::pki_types::PrivateKeyDer<'static>, -) -> Result, BackendError> { - let provider = Arc::new(rustls::crypto::ring::default_provider()); - rustls::ServerConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(|error| remote_platform_error(format!("TLS protocol failed: {error}")))? - .with_no_client_auth() - .with_single_cert(vec![rustls::pki_types::CertificateDer::from(cert)], key) - .map(Arc::new) - .map_err(|error| remote_platform_error(format!("TLS certificate failed: {error}"))) -} +#[path = "../../src-tauri/src/remote_server/tls_identity.rs"] +mod tls_identity; #[cfg(target_os = "linux")] fn router(state: Arc) -> Router { @@ -387,14 +324,32 @@ fn router(state: Arc) -> Router { "/cert.cer", get(|State(state): State>| async move { ( - [( - axum::http::header::CONTENT_TYPE, - "application/x-x509-ca-cert", - )], + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-x509-ca-cert", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], state.cert_der.clone(), ) }), ) + .route( + "/cert.mobileconfig", + get(|State(state): State>| async move { + ( + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-apple-aspen-config", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], + tls_identity::mobileconfig(&state.cert_der), + ) + }), + ) .route("/ws", get(websocket_upgrade)) .with_state(state) } @@ -444,8 +399,10 @@ async fn start_server( ) -> Result { let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; sans.extend(local_lan_ipv4s()); - let (cert_der, key) = load_or_generate_certificate(&data_dir.join("remote-input"), &sans)?; - let acceptor = TlsAcceptor::from(tls_config(cert_der.clone(), key)?); + let identity = tls_identity::load_or_create(&data_dir.join("remote-input"), &sans) + .map_err(remote_platform_error)?; + let cert_der = identity.trust_cert; + let acceptor = TlsAcceptor::from(identity.server_config); let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))) .await .map_err(|error| remote_platform_error(format!("remote input bind failed: {error}")))?; diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 46e9fe79..6cacfb09 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -180,6 +180,45 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1394,6 +1433,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -4147,6 +4200,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4260,7 +4322,9 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "thiserror 1.0.69", + "time", "tokenizers", "tokio", "tokio-rustls", @@ -4272,6 +4336,7 @@ dependencies = [ "window-vibrancy 0.7.1", "windows 0.58.0", "winreg 0.52.0", + "x509-parser", "zip 2.4.2", ] @@ -5034,6 +5099,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser", "yasna", ] @@ -5331,6 +5397,15 @@ dependencies = [ "transpose", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -6769,7 +6844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -8886,6 +8961,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 964ccf0a..e886e37a 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -81,7 +81,10 @@ tauri-plugin-autostart = "2" global-hotkey = "0.6" enigo = "0.3" arboard = { version = "3", features = ["wayland-data-control"] } -rcgen = "^0.13" +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +time = "0.3" +tempfile = "3" local-ip-address = "^0.6" rustls = { version = "^0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } tokio-rustls = { version = "^0.26", default-features = false, features = ["ring", "tls12", "logging"] } diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index e95ee7d3..a4041cda 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -52,6 +52,45 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -303,6 +342,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -840,6 +893,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -932,6 +991,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -973,12 +1038,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -988,6 +1082,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -998,8 +1101,18 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "openless-backend-tests" version = "0.1.0" dependencies = [ + "base64", "openless-core", + "rcgen", + "rustls", + "serde", + "serde_json", + "sha2", + "tempfile", + "time", "tokio", + "uuid", + "x509-parser", ] [[package]] @@ -1065,6 +1178,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1331,6 +1454,20 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1470,6 +1607,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1809,6 +1955,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -1817,6 +1964,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2358,6 +2515,24 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -2377,6 +2552,15 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index 9a307a76..4588a126 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -8,3 +8,14 @@ publish = false [dependencies] openless-core = { path = "../../crates/openless-core" } tokio = { version = "1", features = ["rt-multi-thread"] } + +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = "0.3" +tempfile = "3" +base64 = "0.22" +sha2 = "0.10" +uuid = { version = "1", features = ["v4"] } diff --git a/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs b/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs new file mode 100644 index 00000000..a2c4ea46 --- /dev/null +++ b/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs @@ -0,0 +1,3 @@ +// Exercise the production TLS implementation without linking the desktop UI. +#[path = "../../src/remote_server/tls_identity.rs"] +mod tls_identity; diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs index 6792160e..e2daaba0 100644 --- a/openless-all/app/src-tauri/src/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -50,6 +50,8 @@ pub struct ComboHotkeyMonitor { struct Inner { registered: Mutex>, + #[cfg(target_os = "macos")] + native_dictation: Mutex>, tx: Sender, } @@ -67,6 +69,18 @@ impl ComboHotkeyMonitor { binding: ShortcutBinding, tx: Sender, ) -> Result { + #[cfg(target_os = "macos")] + if is_native_dictation(&binding) { + let native = crate::macos_dictation_key::Monitor::start(tx.clone()) + .map_err(ComboHotkeyError::RegisterFailed)?; + return Ok(Self { + inner: Arc::new(Inner { + registered: Mutex::new(None), + native_dictation: Mutex::new(Some(native)), + tx, + }), + }); + } let runtime = GlobalHotkeyRuntime::shared() .map_err(|e| ComboHotkeyError::ManagerInitFailed(e.to_string()))?; @@ -87,6 +101,8 @@ impl ComboHotkeyMonitor { Ok(Self { inner: Arc::new(Inner { registered: Mutex::new(Some(registered)), + #[cfg(target_os = "macos")] + native_dictation: Mutex::new(None), tx, }), }) @@ -94,6 +110,17 @@ impl ComboHotkeyMonitor { /// 替换当前注册的组合键(用户在设置里改了组合键时)。 pub fn update_binding(&self, binding: ShortcutBinding) -> Result<(), ComboHotkeyError> { + #[cfg(target_os = "macos")] + if is_native_dictation(&binding) { + if self.native_dictation_active() { + return Ok(()); + } + let native = crate::macos_dictation_key::Monitor::start(self.inner.tx.clone()) + .map_err(ComboHotkeyError::RegisterFailed)?; + *self.inner.native_dictation.lock() = Some(native); + self.inner.registered.lock().take(); + return Ok(()); + } let next = parse_binding(&binding)?; let mut current = self.inner.registered.lock(); if let Some(prev) = current.as_ref() { @@ -115,13 +142,31 @@ impl ComboHotkeyMonitor { }) .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; *current = Some(registered); + #[cfg(target_os = "macos")] + self.inner.native_dictation.lock().take(); Ok(()) } } +#[cfg(target_os = "macos")] +fn is_native_dictation(binding: &ShortcutBinding) -> bool { + binding.primary == crate::macos_dictation_key::PRIMARY && binding.modifiers.is_empty() +} +#[cfg(target_os = "macos")] +impl ComboHotkeyMonitor { + pub fn native_dictation_active(&self) -> bool { + self.inner + .native_dictation + .lock() + .as_ref() + .is_some_and(|m| m.active()) + } +} impl Drop for ComboHotkeyMonitor { fn drop(&mut self) { self.inner.registered.lock().take(); + #[cfg(target_os = "macos")] + self.inner.native_dictation.lock().take(); } } @@ -145,6 +190,10 @@ fn forward_loop(hotkey_id: u32, rx: Receiver, tx: Sender Result<(), ComboHotkeyError> { + #[cfg(target_os = "macos")] + if is_native_dictation(binding) { + return Ok(()); + } parse_binding(binding)?; Ok(()) } diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 97ee3d31..423aeb8a 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -542,6 +542,9 @@ fn credential_configuration( volcengine_resource_id: configured(&snap.volcengine_resource_id), xfyun_app_id: configured(&snap.xfyun_app_id), xfyun_api_key: configured(&snap.xfyun_api_key), + tencent_cloud_app_id: configured(&snap.tencent_cloud_app_id), + tencent_cloud_secret_id: configured(&snap.tencent_cloud_secret_id), + tencent_cloud_secret_key: configured(&snap.tencent_cloud_secret_key), llm_api_key: configured(&snap.ark_api_key), llm_endpoint: llm_endpoint.is_some(), llm_endpoint_matches_default: llm_endpoint.is_some_and(|endpoint| { @@ -806,7 +809,10 @@ fn account_provider_kind(account: CredentialAccount) -> CredentialProviderKind { | CredentialAccount::AsrVocabularyId | CredentialAccount::AsrAdvancedConfig | CredentialAccount::XfyunAppId - | CredentialAccount::XfyunApiKey => CredentialProviderKind::Asr, + | CredentialAccount::XfyunApiKey + | CredentialAccount::TencentCloudAppId + | CredentialAccount::TencentCloudSecretId + | CredentialAccount::TencentCloudSecretKey => CredentialProviderKind::Asr, CredentialAccount::OmniApiKey | CredentialAccount::OmniEndpoint | CredentialAccount::OmniModel => CredentialProviderKind::Omni, @@ -838,6 +844,9 @@ fn parse_account(s: &str) -> Result { "asr.advanced_config" => Ok(CredentialAccount::AsrAdvancedConfig), "xfyun.app_id" => Ok(CredentialAccount::XfyunAppId), "xfyun.api_key" => Ok(CredentialAccount::XfyunApiKey), + "tencent_cloud.app_id" => Ok(CredentialAccount::TencentCloudAppId), + "tencent_cloud.secret_id" => Ok(CredentialAccount::TencentCloudSecretId), + "tencent_cloud.secret_key" => Ok(CredentialAccount::TencentCloudSecretKey), "omni.api_key" => Ok(CredentialAccount::OmniApiKey), "omni.endpoint" => Ok(CredentialAccount::OmniEndpoint), "omni.model" => Ok(CredentialAccount::OmniModel), diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index f22dbefe..aff77481 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -14,17 +14,16 @@ pub fn validate_shortcut_binding(binding: ShortcutBinding) -> Result<(), String> } #[tauri::command] -pub fn set_dictation_hotkey( +pub async fn set_dictation_hotkey( coord: CoordinatorState<'_>, binding: ShortcutBinding, ) -> Result<(), String> { - crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; - reject_bare_shift_dictation_shortcut(&binding)?; - let mut prefs = coord.backend().get_preferences(); - prefs.dictation_hotkey = binding; - sync_dictation_hotkey_legacy_fields(&mut prefs); - reject_hotkey_collisions(&prefs)?; - super::settings::persist_strict_settings(&coord, prefs) + let coord = Arc::clone(coord.inner()); + tauri::async_runtime::spawn_blocking(move || { + super::settings::replace_dictation_hotkey(&coord, binding) + }) + .await + .map_err(|error| error.to_string())? } #[tauri::command] @@ -120,19 +119,21 @@ pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> { /// 设置自定义录音组合键并热更新 monitor。 #[tauri::command] -pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> Result<(), String> { - let mut prefs = coord.backend().get_preferences(); +pub async fn set_combo_hotkey( + coord: CoordinatorState<'_>, + binding: ComboBinding, +) -> Result<(), String> { let shortcut = ShortcutBinding { - primary: binding.primary.clone(), - modifiers: binding.modifiers.clone(), + primary: binding.primary, + modifiers: binding.modifiers, }; - reject_bare_shift_dictation_shortcut(&shortcut)?; - crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())?; - prefs.custom_combo_hotkey = Some(binding); - prefs.dictation_hotkey = shortcut; - sync_dictation_hotkey_legacy_fields(&mut prefs); - reject_hotkey_collisions(&prefs)?; - super::settings::persist_strict_settings(&coord, prefs) + crate::combo_hotkey::validate_binding(&shortcut).map_err(|error| error.to_string())?; + let coord = Arc::clone(coord.inner()); + tauri::async_runtime::spawn_blocking(move || { + super::settings::replace_dictation_hotkey(&coord, shortcut) + }) + .await + .map_err(|error| error.to_string())? } #[cfg(test)] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index cac1d971..cdb1559a 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -696,3 +696,39 @@ pub async fn app_download_and_install_android_update( Err("应用内更新仅支持 Android".to_string()) } } + +/// Replace the single dictation binding under the existing settings transaction. +pub(crate) fn replace_dictation_hotkey( + coord: &Coordinator, + binding: ShortcutBinding, +) -> Result<(), String> { + let _host_guard = coord.lock_settings_host(); + let mut prefs = coord.backend().get_preferences(); + crate::shortcut_binding::validate_binding(&binding).map_err(|error| error.to_string())?; + reject_bare_shift_dictation_shortcut(&binding)?; + #[cfg(target_os = "macos")] + { + let native = crate::macos_dictation_key::PRIMARY; + if prefs.dictation_hotkey.primary == native || binding.primary == native { + if coord.dictation_shortcut_is_busy() { + return Err("macDictationKeyBusy".into()); + } + if binding == prefs.dictation_hotkey { + // No settings effect is generated for an unchanged binding. + return coord.try_update_native_dictation_binding(); + } + } + } + prefs.dictation_hotkey = binding; + sync_dictation_hotkey_legacy_fields(&mut prefs); + reject_hotkey_collisions(&prefs)?; + coord + .backend() + .update_settings( + prefs, + openless_core::SettingsUpdateOptions::STRICT, + &TauriSettingsRuntime::new(coord), + ) + .map(|_| ()) + .map_err(|error| error.to_string()) +} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c365e34b..cf7aca06 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -34,6 +34,8 @@ mod capsule_focus; #[path = "coordinator/dictation_core.rs"] mod dictation; mod hotkey_loops; +#[cfg(target_os = "macos")] +mod native_dictation_key; mod qa; #[cfg(all(not(mobile), target_os = "windows"))] pub(crate) mod selection_voice_session; @@ -1459,11 +1461,28 @@ impl Coordinator { if previous.style_packs != next.style_packs { self.try_update_style_pack_hotkey_bindings()?; } - if previous.dictation != next.dictation || previous.dictation_mode != next.dictation_mode { - self.update_hotkey_binding(); - } - if previous.dictation != next.dictation { - self.update_combo_hotkey_binding(); + #[cfg(target_os = "macos")] + let native_transition = previous.dictation.primary == crate::macos_dictation_key::PRIMARY + || next.dictation.primary == crate::macos_dictation_key::PRIMARY; + #[cfg(not(target_os = "macos"))] + let native_transition = false; + if native_transition { + #[cfg(target_os = "macos")] + if previous.dictation != next.dictation + || previous.dictation_mode != next.dictation_mode + { + self.try_update_native_dictation_binding()?; + self.update_modifier_shortcut_bindings(); + } + } else { + if previous.dictation != next.dictation + || previous.dictation_mode != next.dictation_mode + { + self.update_hotkey_binding(); + } + if previous.dictation != next.dictation { + self.update_combo_hotkey_binding(); + } } if previous.qa != next.qa { self.update_qa_hotkey_binding(); diff --git a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs new file mode 100644 index 00000000..f9176f21 --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs @@ -0,0 +1,108 @@ +//! Synchronous native-key registration participates in the Core settings transaction. +use super::*; + +impl Coordinator { + pub(crate) fn dictation_shortcut_is_busy(&self) -> bool { + !matches!( + self.backend().snapshot().dictation.phase, + openless_core::DictationPhase::Idle + | openless_core::DictationPhase::Completed + | openless_core::DictationPhase::Cancelled + | openless_core::DictationPhase::Failed + ) + } + + /// Keep the previous listener until replacement registration succeeds. The + /// caller is a worker thread; Carbon ownership changes run on the UI thread. + pub(crate) fn try_update_native_dictation_binding(&self) -> Result<(), String> { + let target = hotkey_runtime_target(&self.inner); + let inner = Arc::clone(&self.inner); + let (done_tx, done_rx) = mpsc::sync_channel(1); + // Cancel queued work on timeout. Once a callback has started, wait for + // its acknowledgement before Core can roll back the runtime target. + // This gate never nests with the target mutex on the calling thread. + let cancelled = Arc::new(Mutex::new(false)); + let callback_cancelled = Arc::clone(&cancelled); + self.inner.host.run_on_main_thread(move || { + let cancelled = callback_cancelled.lock(); + let result = (|| { + if *cancelled || hotkey_runtime_target(&inner) != target { + return Err("macDictationKeyChanged".into()); + } + let binding = target.dictation.clone(); + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&binding); + if trigger.is_some() || is_unconfigured_shortcut(&binding) { + if trigger.is_some() && inner.hotkey.lock().is_none() { + return Err("macDictationKeyUnavailable".into()); + } + inner.combo_hotkey.lock().take(); + inner.side_aware_combo.lock().take(); + } else if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) { + let mut slot = inner.side_aware_combo.lock(); + if let Some(monitor) = slot.as_ref() { + // A failed native registration leaves this route alive. + // Reuse its sender: dropping an old side-aware handle + // after creating another would clear the singleton route. + monitor + .update_binding(binding) + .map_err(|error| error.to_string())?; + } else { + let (tx, rx) = mpsc::channel(); + let monitor = + crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) + .map_err(|error| error.to_string())?; + let bridge_inner = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-side-combo-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .map_err(|error| error.to_string())?; + *slot = Some(monitor); + } + inner.combo_hotkey.lock().take(); + } else { + let mut slot = inner.combo_hotkey.lock(); + if let Some(monitor) = slot.as_ref() { + monitor + .update_binding(binding) + .map_err(|error| error.to_string())?; + } else { + let (tx, rx) = mpsc::channel(); + let monitor = ComboHotkeyMonitor::start(binding, tx) + .map_err(|error| error.to_string())?; + let bridge_inner = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .map_err(|error| error.to_string())?; + *slot = Some(monitor); + } + inner.side_aware_combo.lock().take(); + } + if let Some(monitor) = inner.hotkey.lock().as_ref() { + monitor.update_binding(crate::types::HotkeyBinding { + trigger: trigger.unwrap_or(crate::types::HotkeyTrigger::Custom), + mode: target.dictation_mode, + keys: None, + }); + } + Ok(()) + })(); + let _ = done_tx.send(result); + })?; + match done_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(result) => result, + Err(_) => { + let mut cancelled = cancelled.lock(); + // A running callback may have finished while we acquired the + // gate. Its real result takes precedence over the timeout. + match done_rx.try_recv() { + Ok(result) => result, + Err(_) => { + *cancelled = true; + Err("macDictationKeyUnavailable".into()) + } + } + } + } + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 0706a431..13a9dc5f 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -31,6 +31,8 @@ mod coordinator; mod coordinator_state; mod core_adapters; mod correction; +#[cfg(target_os = "macos")] +mod macos_dictation_key; mod qa_adapter; mod tauri_coordinator_host; // 托盘麦克风设备变更监听:macOS CoreAudio / Windows MMDevice 原生通知(空闲零唤醒), diff --git a/openless-all/app/src-tauri/src/macos_dictation_key.rs b/openless-all/app/src-tauri/src/macos_dictation_key.rs new file mode 100644 index 00000000..8acdea5c --- /dev/null +++ b/openless-all/app/src-tauri/src/macos_dictation_key.rs @@ -0,0 +1,288 @@ +//! Process-owned capture of the Mac Dictation key. Ordinary F5 is never captured. +use crate::combo_hotkey::ComboHotkeyEvent; +use std::{ + ffi::c_void, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Sender}, + Arc, + }, + thread, + time::{Duration, Instant}, +}; + +pub const PRIMARY: &str = "MacDictationKey"; +const MODIFIERS: u64 = (1 << 17) | (1 << 18) | (1 << 19) | (1 << 20) | (1 << 23); +fn effective_flags(flags: u64, fn_down: bool) -> u64 { + if fn_down { + flags + } else { + flags & !(1 << 23) + } +} +#[derive(Default)] +struct KeyState { + held: bool, +} +#[derive(Debug, PartialEq)] +enum Edge { + Pass, + Repeat, + Press, + Release, +} +impl KeyState { + fn event(&mut self, key: i64, down: bool, repeat: bool, flags: u64) -> Edge { + if key != 176 { + return Edge::Pass; + } + if self.held { + if down { + return Edge::Repeat; + } + self.held = false; + return Edge::Release; + } + if down && !repeat && flags & MODIFIERS == 0 { + self.held = true; + return Edge::Press; + } + Edge::Pass + } +} + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGEventTapCreate( + location: u32, + placement: u32, + options: u32, + mask: u64, + callback: extern "C" fn(*mut c_void, u32, *mut c_void, *mut c_void) -> *mut c_void, + context: *mut c_void, + ) -> *mut c_void; + fn CGEventTapEnable(tap: *mut c_void, enabled: bool); + fn CGEventGetIntegerValueField(event: *mut c_void, field: u32) -> i64; + fn CGEventGetFlags(event: *mut c_void) -> u64; + fn CGEventSourceKeyState(state: i32, key: u16) -> bool; +} +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFMachPortCreateRunLoopSource( + allocator: *const c_void, + port: *mut c_void, + order: isize, + ) -> *mut c_void; + fn CFMachPortInvalidate(port: *mut c_void); + fn CFRunLoopGetCurrent() -> *mut c_void; + fn CFRunLoopAddSource(runloop: *mut c_void, source: *mut c_void, mode: *const c_void); + fn CFRunLoopRemoveSource(runloop: *mut c_void, source: *mut c_void, mode: *const c_void); + fn CFRunLoopRunInMode(mode: *const c_void, seconds: f64, return_after_source: bool) -> i32; + fn CFRelease(value: *const c_void); + static kCFRunLoopDefaultMode: *const c_void; +} +struct Context { + state: KeyState, + tx: Sender, + stop: Arc, + tap: *mut c_void, +} + +fn recover_disabled_tap(ctx: &mut Context, enable: impl FnOnce(*mut c_void)) { + if ctx.state.held { + let _ = ctx + .tx + .send(ComboHotkeyEvent::Released { at: Instant::now() }); + } + ctx.state.held = false; + enable(ctx.tap); +} + +extern "C" fn callback( + _: *mut c_void, + kind: u32, + event: *mut c_void, + data: *mut c_void, +) -> *mut c_void { + if data.is_null() { + return event; + } + let ctx = unsafe { &mut *(data as *mut Context) }; + if kind == 0xffff_fffe || kind == 0xffff_ffff { + // Fail open for a held key, then restore the selected trigger in place. + recover_disabled_tap(ctx, |tap| unsafe { CGEventTapEnable(tap, true) }); + log::warn!("[dictation-key] native interception was disabled and has been re-enabled"); + return event; + } + if event.is_null() || !matches!(kind, 10 | 11) { + return event; + } + let key = unsafe { CGEventGetIntegerValueField(event, 9) }; + if key != 176 { + return event; + } + // Never start a new capture in a password field / Secure Event Input session. + if !ctx.state.held && crate::unicode_keystroke::is_secure_input_enabled() { + return event; + } + let (flags, repeat) = unsafe { + ( + CGEventGetFlags(event), + CGEventGetIntegerValueField(event, 8) != 0, + ) + }; + let fn_down = unsafe { CGEventSourceKeyState(0, 63) }; + let edge = ctx + .state + .event(key, kind == 10, repeat, effective_flags(flags, fn_down)); + let message = match edge { + Edge::Press => Some(ComboHotkeyEvent::Pressed { at: Instant::now() }), + Edge::Release => Some(ComboHotkeyEvent::Released { at: Instant::now() }), + _ => None, + }; + if let Some(message) = message { + if ctx.tx.send(message).is_err() { + ctx.stop.store(true, Ordering::SeqCst); + return event; + } + } + if edge == Edge::Pass { + event + } else { + std::ptr::null_mut() + } +} + +pub struct Monitor { + stop: Arc, + thread: Option>, +} +impl Monitor { + pub fn start(tx: Sender) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let thread = thread::Builder::new() + .name("openless-dictation-key".into()) + .spawn(move || unsafe { + let mut context = Context { + state: KeyState::default(), + tx, + stop: thread_stop.clone(), + tap: std::ptr::null_mut(), + }; + let tap = CGEventTapCreate( + 0, + 0, + 0, + (1 << 10) | (1 << 11), + callback, + &mut context as *mut _ as *mut c_void, + ); + if tap.is_null() { + let _ = ready_tx.send(Err("macDictationKeyPermission".to_string())); + return; + } + context.tap = tap; + let source = CFMachPortCreateRunLoopSource(std::ptr::null(), tap, 0); + if source.is_null() { + CFMachPortInvalidate(tap); + CFRelease(tap); + let _ = ready_tx.send(Err("macDictationKeyUnavailable".to_string())); + return; + } + let runloop = CFRunLoopGetCurrent(); + CFRunLoopAddSource(runloop, source, kCFRunLoopDefaultMode); + CGEventTapEnable(tap, true); + if ready_tx.send(Ok(())).is_ok() { + while !thread_stop.load(Ordering::SeqCst) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.5, false); + } + } + CGEventTapEnable(tap, false); + CFRunLoopRemoveSource(runloop, source, kCFRunLoopDefaultMode); + CFMachPortInvalidate(tap); + CFRelease(source); + CFRelease(tap); + }) + .map_err(|e| e.to_string())?; + match ready_rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(())) => Ok(Self { + stop, + thread: Some(thread), + }), + result => { + stop.store(true, Ordering::SeqCst); + let _ = thread.join(); + Err(match result { + Ok(Err(e)) => e, + _ => "macDictationKeyUnavailable".into(), + }) + } + } + } + pub fn active(&self) -> bool { + !self.stop.load(Ordering::SeqCst) + } +} +impl Drop for Monitor { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn function_layer_flag_is_distinct_from_physical_fn() { + assert_eq!(effective_flags(1 << 23, false), 0); + assert_eq!(effective_flags(1 << 23, true), 1 << 23); + assert_eq!(effective_flags((1 << 19) | (1 << 23), false), 1 << 19); + } + #[test] + fn ordinary_and_modified_keys_pass_through() { + let mut s = KeyState::default(); + for key in [96, 90, 59, 58, 63] { + assert_eq!(s.event(key, true, false, 0), Edge::Pass); + } + for bit in [17, 18, 19, 20, 23] { + assert_eq!(s.event(176, true, false, 1 << bit), Edge::Pass); + } + assert_eq!(s.event(176, false, false, 0), Edge::Pass); + } + #[test] + fn repeat_and_release_pairing() { + let mut s = KeyState::default(); + assert_eq!(s.event(176, true, true, 0), Edge::Pass); + assert_eq!(s.event(176, true, false, 0), Edge::Press); + assert_eq!(s.event(176, true, true, 0), Edge::Repeat); + assert_eq!(s.event(176, false, false, MODIFIERS), Edge::Release); + assert_eq!(s.event(176, false, false, 0), Edge::Pass); + assert_eq!(s.event(176, true, false, 0), Edge::Press); + } + #[test] + fn disabled_tap_releases_held_key_and_is_reenabled() { + let (tx, rx) = mpsc::channel(); + let stop = Arc::new(AtomicBool::new(false)); + let mut context = Context { + state: KeyState { held: true }, + tx, + stop: stop.clone(), + tap: std::ptr::null_mut(), + }; + let mut reenabled = false; + + recover_disabled_tap(&mut context, |_| reenabled = true); + + assert!(reenabled); + assert!(!context.state.held); + assert!(!stop.load(Ordering::SeqCst)); + assert!(matches!( + rx.try_recv(), + Ok(ComboHotkeyEvent::Released { .. }) + )); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 1d6afd05..3b1c019b 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -342,6 +342,15 @@ struct CredsAsrEntry { /// 讯飞实时语音转写 APIKey(接口密钥)。 #[serde(skip_serializing_if = "Option::is_none")] xfyunApiKey: Option, + /// 腾讯云账号 AppID(实时语音识别 WebSocket 路径参数)。 + #[serde(skip_serializing_if = "Option::is_none")] + tencentCloudAppId: Option, + /// 腾讯云 API 密钥 SecretID。 + #[serde(skip_serializing_if = "Option::is_none")] + tencentCloudSecretId: Option, + /// 腾讯云 API 密钥 SecretKey。 + #[serde(skip_serializing_if = "Option::is_none")] + tencentCloudSecretKey: Option, } impl CredsAsrEntry { @@ -371,6 +380,17 @@ impl CredsAsrEntry { && self.advancedConfig.as_deref().unwrap_or("").is_empty() && self.xfyunAppId.as_deref().unwrap_or("").is_empty() && self.xfyunApiKey.as_deref().unwrap_or("").is_empty() + && self.tencentCloudAppId.as_deref().unwrap_or("").is_empty() + && self + .tencentCloudSecretId + .as_deref() + .unwrap_or("") + .is_empty() + && self + .tencentCloudSecretKey + .as_deref() + .unwrap_or("") + .is_empty() } } @@ -1642,6 +1662,11 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option asr.and_then(|e| pick(&e.advancedConfig)), CredentialAccount::XfyunAppId => asr.and_then(|e| pick(&e.xfyunAppId)), CredentialAccount::XfyunApiKey => asr.and_then(|e| pick(&e.xfyunApiKey)), + CredentialAccount::TencentCloudAppId => asr.and_then(|e| pick(&e.tencentCloudAppId)), + CredentialAccount::TencentCloudSecretId => asr.and_then(|e| pick(&e.tencentCloudSecretId)), + CredentialAccount::TencentCloudSecretKey => { + asr.and_then(|e| pick(&e.tencentCloudSecretKey)) + } CredentialAccount::OmniApiKey => omni.and_then(|e| pick(&e.apiKey)), CredentialAccount::OmniEndpoint => omni.and_then(|e| pick(&e.baseURL)), CredentialAccount::OmniModel => omni.and_then(|e| pick(&e.model)), @@ -1751,6 +1776,18 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option let entry = root.providers.asr.entry(asr_id).or_default(); entry.xfyunApiKey = normalized; } + CredentialAccount::TencentCloudAppId => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.tencentCloudAppId = normalized; + } + CredentialAccount::TencentCloudSecretId => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.tencentCloudSecretId = normalized; + } + CredentialAccount::TencentCloudSecretKey => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.tencentCloudSecretKey = normalized; + } CredentialAccount::OmniApiKey => { let entry = root.omni.providers.entry(omni_id).or_default(); entry.apiKey = normalized; @@ -1791,6 +1828,12 @@ pub enum CredentialAccount { XfyunAppId, /// 讯飞实时语音转写 APIKey。 XfyunApiKey, + /// 腾讯云账号 AppID。 + TencentCloudAppId, + /// 腾讯云 API 密钥 SecretID。 + TencentCloudSecretId, + /// 腾讯云 API 密钥 SecretKey。 + TencentCloudSecretKey, /// 多模态(Omni)模型的 API Key。仅多模态管线读取。 OmniApiKey, /// 多模态(Omni)模型的 Base URL。 @@ -1820,6 +1863,9 @@ impl CredentialAccount { CredentialAccount::AsrAdvancedConfig => "asr.advanced_config", CredentialAccount::XfyunAppId => "xfyun.app_id", CredentialAccount::XfyunApiKey => "xfyun.api_key", + CredentialAccount::TencentCloudAppId => "tencent_cloud.app_id", + CredentialAccount::TencentCloudSecretId => "tencent_cloud.secret_id", + CredentialAccount::TencentCloudSecretKey => "tencent_cloud.secret_key", CredentialAccount::OmniApiKey => "omni.api_key", CredentialAccount::OmniEndpoint => "omni.endpoint", CredentialAccount::OmniModel => "omni.model", @@ -1843,6 +1889,9 @@ impl CredentialAccount { CredentialAccount::AsrAdvancedConfig, CredentialAccount::XfyunAppId, CredentialAccount::XfyunApiKey, + CredentialAccount::TencentCloudAppId, + CredentialAccount::TencentCloudSecretId, + CredentialAccount::TencentCloudSecretKey, CredentialAccount::OmniApiKey, CredentialAccount::OmniEndpoint, CredentialAccount::OmniModel, @@ -1863,6 +1912,9 @@ pub struct CredentialsSnapshot { pub asr_model: Option, pub xfyun_app_id: Option, pub xfyun_api_key: Option, + pub tencent_cloud_app_id: Option, + pub tencent_cloud_secret_id: Option, + pub tencent_cloud_secret_key: Option, pub ark_api_key: Option, pub ark_model_id: Option, pub ark_endpoint: Option, @@ -1917,6 +1969,9 @@ fn credentials_snapshot(root: &CredsRoot, include_omni: bool) -> CredentialsSnap asr_model: lookup_account(root, CredentialAccount::AsrModel), xfyun_app_id: lookup_account(root, CredentialAccount::XfyunAppId), xfyun_api_key: lookup_account(root, CredentialAccount::XfyunApiKey), + tencent_cloud_app_id: lookup_account(root, CredentialAccount::TencentCloudAppId), + tencent_cloud_secret_id: lookup_account(root, CredentialAccount::TencentCloudSecretId), + tencent_cloud_secret_key: lookup_account(root, CredentialAccount::TencentCloudSecretKey), ark_api_key: lookup_account(root, CredentialAccount::ArkApiKey), ark_model_id: lookup_account(root, CredentialAccount::ArkModelId), ark_endpoint: lookup_account(root, CredentialAccount::ArkEndpoint), 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 392a16da..abd692e8 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 @@ -26,8 +26,7 @@ offlineTitle: '连接已断开', offlineSub: '与电脑的连接已中断。', btnReconnect: '重新连接', - certTip: - '首次访问浏览器会提示“连接不安全”(本地自签名证书)。Android Chrome:点“高级”→“继续前往”;iOS Safari:点“显示详情”→“访问此网站”。', + certTip: "如遇证书提示,请核对地址与电脑显示一致,再按“首次设置:信任此电脑”完成安装和完全信任。", tipToggle: '点击大按钮开始录音,再次点击结束并识别。', tipHold: '按住大按钮说话,松开结束并识别。', labelToggleIdle: '点击开始', @@ -58,12 +57,12 @@ pcmQueueOverflow: '❌ 音频缓存已满,请重试。', micUnknown: '❌ 无法启动录音{name}。', errGeneric: '发生错误', - helpTitle: '连不上?多半是手机没信任证书', - helpAndroid: - '① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。', - helpIos: - '② iOS Safari:用无痕模式打开本页,出现“不安全”提示时点“显示详情 → 访问此网站”,再输入配对码连接(无需安装证书)。', - helpDownloadCert: '⬇ 下载并安装证书', + helpTitle: "首次设置:信任此电脑", + helpAndroid: "安卓:下载 CA 证书,在系统设置的“安装证书 → CA 证书”中安装,再回到本页。菜单名称因设备而异。", + helpIos: "iPhone / iPad:下载描述文件,打开“设置 → 通用 → VPN 与设备管理”安装;再到“通用 → 关于本机 → 证书信任设置”,为 OpenLess Remote Input CA 打开“完全信任”,返回 Safari 刷新。每台电脑只需设置一次;旧版证书需要重新安装。", + helpDownloadCert: "↓ iPhone:下载描述文件", + helpDownloadAndroid: "↓ 安卓:下载 CA 证书", + helpTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。", helpCopyLink: '⧉ 复制链接', helpCopied: '已复制 ✓', copy: '复制', @@ -82,8 +81,7 @@ offlineTitle: '連線已中斷', offlineSub: '與電腦的連線已中斷。', btnReconnect: '重新連線', - certTip: - '首次造訪瀏覽器會提示「連線不安全」(本機自簽憑證)。Android Chrome:點「進階」→「繼續前往」;iOS Safari:點「顯示詳細資訊」→「瀏覽此網站」。', + certTip: "如遇憑證提示,請核對位址與電腦顯示一致,再依「首次設定:信任這台電腦」完成安裝與完全信任。", tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。', tipHold: '按住大按鈕說話,放開結束並辨識。', labelToggleIdle: '點擊開始', @@ -114,12 +112,12 @@ pcmQueueOverflow: '❌ 音訊暫存已滿,請重試。', micUnknown: '❌ 無法啟動錄音{name}。', errGeneric: '發生錯誤', - helpTitle: '連不上?多半是手機沒信任憑證', - helpAndroid: - '① 安卓 / 一般情況:用瀏覽器無痕模式開啟本頁,出現“不安全”警告時選“繼續前往”,再輸入配對碼連線。', - helpIos: - '② iOS Safari:用無痕模式開啟本頁,出現“不安全”提示時點“顯示詳細資訊 → 瀏覽此網站”,再輸入配對碼連線(無需安裝憑證)。', - helpDownloadCert: '⬇ 下載並安裝憑證', + helpTitle: "首次設定:信任這台電腦", + helpAndroid: "Android:下載 CA 憑證,在系統設定的「安裝憑證 → CA 憑證」中安裝,再返回本頁。選單名稱依裝置而異。", + helpIos: "iPhone / iPad:下載描述檔,開啟「設定 → 一般 → VPN 與裝置管理」安裝;再到「一般 → 關於本機 → 憑證信任設定」,為 OpenLess Remote Input CA 開啟「完全信任」,返回 Safari 重新整理。每台電腦只需設定一次;舊版憑證需要重新安裝。", + helpDownloadCert: "↓ iPhone:下載描述檔", + helpDownloadAndroid: "↓ Android:下載 CA 憑證", + helpTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。", helpCopyLink: '⧉ 複製連結', helpCopied: '已複製 ✓', copy: '複製', @@ -138,8 +136,7 @@ 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".', + certTip: "If a certificate warning appears, check that the address matches your computer, then follow “First-time setup: trust this computer” to install and fully trust the certificate.", 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', @@ -172,12 +169,12 @@ pcmQueueOverflow: '❌ Audio buffer is full. 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', + helpTitle: "First-time setup: trust this computer", + helpAndroid: "Android: download the CA certificate, install it under system Settings → Install a certificate → CA certificate, then return here. Menu names vary by device.", + helpIos: "iPhone / iPad: download the profile and install it in Settings → General → VPN & Device Management. Then enable full trust for OpenLess Remote Input CA in General → About → Certificate Trust Settings and reload Safari. Set up once per computer; certificates from older versions need this one-time replacement.", + helpDownloadCert: "↓ iPhone: download profile", + helpDownloadAndroid: "↓ Android: download CA", + helpTrustWarning: "The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.", helpCopyLink: '⧉ Copy link', helpCopied: 'Copied ✓', copy: 'Copy', @@ -197,7 +194,7 @@ offlineSub: 'Se ha perdido la conexión con el ordenador.', btnReconnect: 'Volver a conectar', certTip: - 'En la primera visita, el navegador mostrará «No es seguro» por el certificado local autofirmado. En Chrome para Android: «Avanzado» → «Continuar». En Safari para iOS: «Mostrar detalles» → «Visitar este sitio web».', + 'Si aparece un aviso de certificado, comprueba que la dirección coincide con tu ordenador y sigue «Configuración inicial: confiar en este ordenador» para instalarlo y confiar plenamente en él.', tipToggle: 'Toca el botón grande para grabar y vuelve a tocarlo para terminar y transcribir.', tipHold: 'Mantén pulsado el botón para hablar y suéltalo para terminar y transcribir.', labelToggleIdle: 'Toca para empezar', @@ -230,12 +227,15 @@ pcmQueueOverflow: '❌ El búfer de audio está lleno. Inténtalo de nuevo.', micUnknown: '❌ No se pudo iniciar la grabación{name}.', errGeneric: 'Se ha producido un error', - helpTitle: '¿No puedes conectar? Comprueba el certificado en el móvil', + helpTitle: 'Configuración inicial: confiar en este ordenador', helpAndroid: - '① En Android u otros navegadores: abre esta página en una pestaña de incógnito, elige «Continuar» ante el aviso «No es seguro» e introduce el código.', + 'Android: descarga el certificado CA e instálalo en Ajustes del sistema → Instalar un certificado → Certificado CA, y vuelve aquí. Los nombres de los menús varían según el dispositivo.', helpIos: - '② En Safari para iOS: abre una pestaña privada, toca «Mostrar detalles → Visitar este sitio web» ante el aviso de privacidad e introduce el código. No hace falta instalar el certificado.', - helpDownloadCert: '⬇ Descargar e instalar el certificado', + 'iPhone / iPad: descarga el perfil e instálalo en Ajustes → General → VPN y gestión de dispositivos; luego activa la confianza completa para OpenLess Remote Input CA en General → Información → Ajustes de confianza de certificados y recarga Safari. Solo hay que hacerlo una vez por ordenador; los certificados de versiones anteriores deben reinstalarse.', + helpDownloadCert: '↓ iPhone: descargar perfil', + helpDownloadAndroid: '↓ Android: descargar certificado CA', + helpTrustWarning: + 'La descarga inicial del certificado no puede verificar la identidad del ordenador: un dispositivo malicioso en la LAN podría sustituir el certificado raíz en un ataque de intermediario. Instálalo solo en una red doméstica o privada de confianza, nunca en redes públicas o compartidas. La CA raíz puede emitir certificados y su clave privada permanece en este ordenador; elimínalo del teléfono cuando dejes de usarlo.', helpCopyLink: '⧉ Copiar enlace', helpCopied: 'Copiado ✓', copy: 'Copiar', @@ -255,7 +255,7 @@ offlineSub: 'La connexion à votre ordinateur a été perdue.', btnReconnect: 'Reconnecter', certTip: - 'À la première visite, le navigateur affiche « Non sécurisé » à cause du certificat local autosigné. Chrome sur Android : « Paramètres avancés » → « Continuer ». Safari sur iOS : « Afficher les détails » → « Visiter ce site web ».', + 'Si un avertissement de certificat apparaît, vérifiez que l’adresse correspond à votre ordinateur, puis suivez « Réglage initial : faire confiance à cet ordinateur » pour installer le certificat et l’approuver entièrement.', tipToggle: 'Touchez le grand bouton pour enregistrer, puis à nouveau pour terminer et transcrire.', tipHold: @@ -290,12 +290,15 @@ pcmQueueOverflow: '❌ Le tampon audio est plein. Réessayez.', micUnknown: '❌ Impossible de démarrer l’enregistrement{name}.', errGeneric: 'Une erreur est survenue', - helpTitle: 'Connexion impossible ? Vérifiez le certificat sur votre téléphone', + helpTitle: 'Réglage initial : faire confiance à cet ordinateur', helpAndroid: - '① Sur Android ou un autre navigateur : ouvrez cette page en navigation privée, choisissez « Continuer » après l’avertissement de sécurité, puis saisissez le code.', + 'Android : téléchargez le certificat CA et installez-le dans Réglages système → Installer un certificat → Certificat CA, puis revenez ici. Les noms des menus varient selon l’appareil.', helpIos: - '② Sur Safari pour iOS : ouvrez un onglet privé, touchez « Afficher les détails → Visiter ce site web » après l’avertissement, puis saisissez le code. Aucune installation de certificat n’est nécessaire.', - helpDownloadCert: '⬇ Télécharger et installer le certificat', + 'iPhone / iPad : téléchargez le profil et installez-le dans Réglages → Général → VPN et gestion des appareils ; activez ensuite la confiance complète pour OpenLess Remote Input CA dans Général → Informations → Réglages de confiance des certificats et rechargez Safari. À faire une seule fois par ordinateur ; les certificats des anciennes versions doivent être réinstallés.', + helpDownloadCert: '↓ iPhone : télécharger le profil', + helpDownloadAndroid: '↓ Android : télécharger le certificat CA', + helpTrustWarning: + 'Le téléchargement initial du certificat ne permet pas de vérifier l’identité de l’ordinateur : un appareil malveillant sur le LAN pourrait remplacer le certificat racine par une attaque de l’homme du milieu. N’installez le certificat que sur un réseau domestique ou privé de confiance, jamais sur un réseau public ou partagé. La CA racine peut émettre des certificats et sa clé privée reste sur cet ordinateur ; supprimez-la de votre téléphone lorsque vous ne l’utilisez plus.', helpCopyLink: '⧉ Copier le lien', helpCopied: 'Copié ✓', copy: 'Copier', @@ -315,7 +318,7 @@ offlineSub: 'Die Verbindung zum Computer wurde getrennt.', btnReconnect: 'Erneut verbinden', certTip: - 'Beim ersten Besuch meldet der Browser „Nicht sicher“, da das lokale Zertifikat selbst signiert ist. In Chrome für Android: „Erweitert“ → „Weiter“. In Safari für iOS: „Details einblenden“ → „Diese Website besuchen“.', + 'Falls eine Zertifikatswarnung erscheint, prüfe, dass die Adresse mit der auf dem Computer angezeigten übereinstimmt, und folge dann „Erstmalige Einrichtung: diesem Computer vertrauen“, um das Zertifikat zu installieren und vollständig zu vertrauen.', tipToggle: 'Tippe auf die große Schaltfläche, um aufzunehmen. Tippe erneut, um die Aufnahme zu beenden und zu transkribieren.', tipHold: @@ -350,12 +353,15 @@ pcmQueueOverflow: '❌ Der Audiopuffer ist voll. Versuche es erneut.', micUnknown: '❌ Die Aufnahme konnte nicht gestartet werden{name}.', errGeneric: 'Ein Fehler ist aufgetreten', - helpTitle: 'Keine Verbindung? Prüfe das Zertifikat auf deinem Smartphone', + helpTitle: 'Erstmalige Einrichtung: diesem Computer vertrauen', helpAndroid: - '① Android oder andere Browser: Öffne diese Seite in einem privaten Tab, wähle bei der Sicherheitswarnung „Weiter“ und gib den Kopplungscode ein.', + 'Android: Lade das CA-Zertifikat herunter und installiere es unter Systemeinstellungen → Zertifikat installieren → CA-Zertifikat, kehre dann hierher zurück. Die Menünamen unterscheiden sich je nach Gerät.', helpIos: - '② Safari für iOS: Öffne einen privaten Tab und tippe bei der Warnung auf „Details einblenden → Diese Website besuchen“. Gib dann den Code ein. Eine Zertifikatsinstallation ist nicht erforderlich.', - helpDownloadCert: '⬇ Zertifikat herunterladen und installieren', + 'iPhone / iPad: Lade das Konfigurationsprofil herunter und installiere es unter Einstellungen → Allgemein → VPN & Geräteverwaltung; aktiviere danach die volle Vertrauensstellung für OpenLess Remote Input CA unter Allgemein → Info → Zertifikatsvertrauenseinstellungen und lade Safari neu. Nur einmal pro Computer nötig; Zertifikate älterer Versionen müssen neu installiert werden.', + helpDownloadCert: '↓ iPhone: Profil herunterladen', + helpDownloadAndroid: '↓ Android: CA-Zertifikat herunterladen', + helpTrustWarning: + 'Beim ersten Zertifikatsdownload kann die Identität des Computers nicht geprüft werden: Ein bösartiges Gerät im LAN könnte das Root-Zertifikat in einem Man-in-the-Middle-Angriff ersetzen. Installiere es nur in einem vertrauenswürdigen Heim- oder Privatnetzwerk, niemals in öffentlichen oder geteilten Netzwerken. Die Root-CA kann Zertifikate ausstellen, ihr privater Schlüssel bleibt auf diesem Computer; entferne sie vom Smartphone, wenn du sie nicht mehr brauchst.', helpCopyLink: '⧉ Link kopieren', helpCopied: 'Kopiert ✓', copy: 'Kopieren', @@ -374,8 +380,7 @@ offlineTitle: '接続が切断されました', offlineSub: 'パソコンとの接続が切断されました。', btnReconnect: '再接続', - certTip: - '初回アクセス時、ブラウザに「保護されていません」と表示されます(ローカル自己署名証明書)。Android Chrome:「詳細設定」→「アクセスする」、iOS Safari:「詳細を表示」→「このWebサイトを閲覧」をタップしてください。', + certTip: "証明書の警告が表示されたら、アドレスがコンピュータの表示と一致することを確認し、「初回設定:このコンピュータを信頼」の手順でインストールと完全な信頼を行ってください。", tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。', tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。', labelToggleIdle: 'タップで開始', @@ -408,12 +413,12 @@ pcmQueueOverflow: '❌ 音声バッファがいっぱいです。もう一度お試しください。', micUnknown: '❌ 録音を開始できませんでした{name}。', errGeneric: 'エラーが発生しました', - helpTitle: '接続できない?多くは証明書が信頼されていません', - helpAndroid: - '① Android / 一般:ブラウザのシークレットモードで本ページを開き、「保護されていません」で「アクセスする」を選び、ペアリングコードを入力。', - helpIos: - '② iOS Safari:シークレットモードで本ページを開き、「安全ではありません」で「詳細を表示 → このWebサイトにアクセス」をタップしてコードを入力(証明書のインストール不要)。', - helpDownloadCert: '⬇ 証明書をインストール', + helpTitle: "初回設定:このコンピュータを信頼", + helpAndroid: "Android:CA 証明書をダウンロードし、システム設定の「証明書のインストール → CA 証明書」でインストールしてから戻ってください。項目名は端末により異なります。", + helpIos: "iPhone / iPad:プロファイルをダウンロードし、「設定 → 一般 → VPN とデバイス管理」でインストールします。次に「一般 → 情報 → 証明書信頼設定」で OpenLess Remote Input CA を完全に信頼し、Safari を再読み込みしてください。各コンピュータで一度だけ必要です。旧バージョンの証明書は一度入れ替えてください。", + helpDownloadCert: "↓ iPhone:プロファイルをダウンロード", + helpDownloadAndroid: "↓ Android:CA をダウンロード", + helpTrustWarning: "初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。", helpCopyLink: '⧉ リンクをコピー', helpCopied: 'コピーしました ✓', copy: 'コピー', @@ -432,8 +437,7 @@ offlineTitle: '연결이 끊겼습니다', offlineSub: '컴퓨터와의 연결이 끊겼습니다.', btnReconnect: '다시 연결', - certTip: - '처음 접속하면 브라우저에 "안전하지 않음" 경고가 표시됩니다(로컬 자체 서명 인증서). Android Chrome: "고급" → "계속 진행"; iOS Safari: "세부정보 표시" → "이 웹사이트 방문"을 탭하세요.', + certTip: "인증서 경고가 나타나면 주소가 컴퓨터에 표시된 주소와 일치하는지 확인한 뒤 “최초 설정: 이 컴퓨터 신뢰”에 따라 인증서를 설치하고 완전히 신뢰하세요.", tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.', tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.', labelToggleIdle: '탭하여 시작', @@ -466,12 +470,12 @@ pcmQueueOverflow: '❌ 오디오 버퍼가 가득 찼습니다. 다시 시도하세요.', micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.', errGeneric: '오류가 발생했습니다', - helpTitle: '연결이 안 되나요? 대개 인증서를 신뢰하지 않아서입니다', - helpAndroid: - '① Android / 일반: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "계속"을 선택한 뒤 페어링 코드를 입력하세요.', - helpIos: - '② iOS Safari: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "세부사항 표시 → 이 웹사이트 방문"을 누른 뒤 코드를 입력하세요(인증서 설치 불필요).', - helpDownloadCert: '⬇ 인증서 설치', + helpTitle: "최초 설정: 이 컴퓨터 신뢰", + helpAndroid: "Android: CA 인증서를 다운로드하고 시스템 설정 → 인증서 설치 → CA 인증서에서 설치한 뒤 돌아오세요. 메뉴 이름은 기기마다 다릅니다.", + helpIos: "iPhone / iPad: 프로파일을 다운로드하고 설정 → 일반 → VPN 및 기기 관리에서 설치하세요. 일반 → 정보 → 인증서 신뢰 설정에서 OpenLess Remote Input CA를 완전히 신뢰한 뒤 Safari를 새로고침하세요. 컴퓨터마다 한 번만 설정하면 됩니다. 이전 버전의 인증서는 한 번 교체해야 합니다.", + helpDownloadCert: "↓ iPhone: 프로파일 다운로드", + helpDownloadAndroid: "↓ Android: CA 다운로드", + helpTrustWarning: "최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.", helpCopyLink: '⧉ 링크 복사', helpCopied: '복사됨 ✓', copy: '복사', @@ -1093,7 +1097,7 @@ } if (copyCertBtn) { copyCertBtn.addEventListener('click', function () { - var url = location.origin + '/cert.cer'; + var url = location.origin + '/cert.mobileconfig'; var ok = function () { copyCertBtn.textContent = L.helpCopied; setTimeout(function () { 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 13d3b91f..16d044ed 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 @@ -44,19 +44,29 @@ - - + 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 92c310c2..14517066 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 @@ -269,7 +269,9 @@ body { font-weight: 600; color: var(--ink-2); margin-bottom: 10px; + cursor: pointer; } +.help:not([open]) .help-title { margin-bottom: 0; } .help-step { font-size: 12.5px; color: var(--ink-3); 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 6c2352fe..e78df6e9 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -5,9 +5,10 @@ //! 手机录音以 16k/单声道/16-bit LE PCM 经 WebSocket 实时推回 PC,并通过共享 //! [`openless_core::OpenLessBackend`] 的 external-audio seam 进入同一听写管线。 //! -//! 关键约束:浏览器 `getUserMedia` 仅在安全上下文可用,所以必须 HTTPS。证书用 -//! rcgen 自签名(SAN 含本机局域网 IP),手机首次访问需手动信任。TLS 走 ring -//! 后端(与项目 reqwest/tungstenite 一致,避免 aws-lc-sys 的 C 编译依赖)。 +//! Browser microphone access requires a secure context, so this server uses HTTPS. +//! A persistent per-installation CA signs a separate LAN server certificate. +//! Phones install and trust the CA once. TLS uses ring +//! (matching reqwest/tungstenite and avoiding an additional aws-lc-sys dependency). use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; @@ -172,106 +173,13 @@ pub fn access_urls(port: u16) -> Vec { // ───────────────────────── TLS ───────────────────────── -/// 自签名证书:持久化到磁盘并跨重启复用。否则每次启动证书都变 —— 手机(尤其 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)))); - } - } - } - // 生成自签名服务器证书(SAN 含本机各局域网 IP)。主路径是浏览器页面级 - // “继续访问/访问此网站”例外;/cert.cer 与 /cert.mobileconfig 是手机系统级 - // 安装信任的兜底(部分浏览器的 wss 不复用页面级例外时使用)。 - let (cert_der, key_der) = { - use rcgen::{ - CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair, - KeyUsagePurpose, - }; - let mut params = - CertificateParams::new(sans.to_vec()).map_err(|e| format!("rcgen params: {e}"))?; - // 关键:做成普通服务器证书(非 CA,rcgen 默认即 NoCa)。iOS Safari 用页面级 - // “访问此网站”即可信任、无需安装证书 —— 这正是之前一直能用的方式。把证书做成 CA - // 反而会让 iOS 拒绝页面级例外(CA 不能直接当服务器证书),导致一直超时。 - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "OpenLess Remote Input"); - dn.push(DnType::OrganizationName, "OpenLess"); - params.distinguished_name = dn; - params.key_usages.push(KeyUsagePurpose::DigitalSignature); - params - .extended_key_usages - .push(ExtendedKeyUsagePurpose::ServerAuth); - let key_pair = KeyPair::generate().map_err(|e| format!("rcgen keypair: {e}"))?; - let cert = params - .self_signed(&key_pair) - .map_err(|e| format!("rcgen self_signed: {e}"))?; - (cert.der().as_ref().to_vec(), key_pair.serialize_der()) - }; - if let Some(dir) = dir { - let _ = std::fs::create_dir_all(dir); - let _ = std::fs::write(dir.join(CERT_FILE), &cert_der); - let _ = std::fs::write(dir.join(KEY_FILE), &key_der); - // 私钥收紧为 0600:app 配置目录通常已是用户私有,但多用户/共享主机上 - // 默认 umask 可能给到组/其他用户可读。Windows 下 %APPDATA% 的 ACL - // 本身仅限本用户,无对应权限位可设。 - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - dir.join(KEY_FILE), - std::fs::Permissions::from_mode(0o600), - ); - } - let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n")); - log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})"); - } - Ok(( - cert_der, - PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), - )) -} - -fn build_server_config( - cert_der: Vec, - 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![rustls::pki_types::CertificateDer::from(cert_der)], - key_der, - ) - .map_err(|e| format!("tls cert: {e}"))?; - Ok(Arc::new(config)) -} +mod tls_identity; // ───────────────────────── 启动 ───────────────────────── struct WsState { backend: Arc, - /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 + /// Public CA certificate offered for phone installation; never the server leaf or key. cert_der: Vec, /// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。 conn_shutdown_rx: tokio::sync::watch::Receiver, @@ -336,10 +244,13 @@ fn build_router(state: Arc) -> Router { "/cert.cer", get(|State(state): State>| async move { ( - [( - axum::http::header::CONTENT_TYPE, - "application/x-x509-ca-cert", - )], + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-x509-ca-cert", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], state.cert_der.clone(), ) }), @@ -372,53 +283,17 @@ async fn index_handler(State(state): State>) -> impl IntoResponse { ) } -/// 极简标准 base64:构造 .mobileconfig 时把证书 DER 编码进 XML,避免引入额外依赖。 -fn base64_encode(data: &[u8]) -> String { - const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity((data.len() + 2) / 3 * 4); - for chunk in data.chunks(3) { - let b0 = chunk[0]; - let b1 = *chunk.get(1).unwrap_or(&0); - let b2 = *chunk.get(2).unwrap_or(&0); - out.push(T[(b0 >> 2) as usize] as char); - out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); - out.push(if chunk.len() > 1 { - T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char - } else { - '=' - }); - out.push(if chunk.len() > 2 { - T[(b2 & 0x3f) as usize] as char - } else { - '=' - }); - } - out -} - -/// iOS 配置描述文件:把证书包成 .mobileconfig。Safari 点击后凭 content-type -/// (application/x-apple-aspen-config) 直接进入“安装描述文件”流程,比裸 .cer 顺滑、 -/// 也不会把当前页面导航走。安装后仍需到「设置→通用→关于本机→证书信任设置」打开完全信任。 -/// -/// 安全边界:PayloadType `com.apple.security.root` 只是 iOS 安装证书的固定入口, -/// 证书本身是非 CA 的纯服务器证书(rcgen NoCa + EKU=ServerAuth,见 -/// load_or_generate_cert)——不含签发能力,无法用来给其他域名签证书做 MITM。 -/// 信任它的影响范围仅限「持有本机私钥者可冒充 SAN 里列出的本机局域网 IP」, -/// 私钥只存在用户 PC 的应用配置目录。设置页 certTrustWarning 同步向用户说明。 +// iOS still requires the user to install the profile and enable full trust. 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, + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-apple-aspen-config", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], + tls_identity::mobileconfig(&state.cert_der), ) } @@ -428,11 +303,15 @@ pub async fn start(cfg: RemoteServerConfig) -> Result, + ca_key: Vec, + leaf_cert: Vec, + leaf_key: Vec, +} + +pub(super) struct TlsIdentity { + /// Only this public CA certificate is offered for phone installation. + pub trust_cert: Vec, + pub server_config: Arc, +} + +fn key_der(bytes: Vec) -> PrivateKeyDer<'static> { + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(bytes)) +} + +fn matching_key(cert: &[u8], key: &[u8]) -> Result<(), String> { + rustls::sign::CertifiedKey::from_der( + vec![CertificateDer::from(cert.to_vec())], + key_der(key.to_vec()), + &rustls::crypto::ring::default_provider(), + ) + .map(|_| ()) + .map_err(|error| format!("remote TLS certificate/key mismatch: {error}")) +} + +fn parse_cert(cert: &[u8]) -> Result { + CertificateParams::from_ca_cert_der(&CertificateDer::from(cert)) + .map_err(|error| format!("invalid remote TLS certificate: {error}")) +} + +fn read_identity(path: &Path) -> Result, String> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("cannot inspect remote TLS identity: {error}")), + }; + if !metadata.is_file() || metadata.len() > MAX_IDENTITY_BYTES { + return Err("remote TLS identity must be a regular file under 64 KiB".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("cannot protect remote TLS identity: {error}"))?; + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .and_then(|file| file.take(MAX_IDENTITY_BYTES + 1).read_to_end(&mut bytes)) + .map_err(|error| format!("cannot read remote TLS identity: {error}"))?; + if bytes.len() as u64 > MAX_IDENTITY_BYTES { + return Err("remote TLS identity exceeds 64 KiB".into()); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| "remote TLS identity is damaged; restore it from backup instead of replacing phone trust".into()) +} + +fn save_identity( + path: &Path, + identity: &StoredIdentity, + first_install: bool, +) -> Result<(), String> { + let directory = path.parent().ok_or("remote TLS directory is unavailable")?; + let bytes = serde_json::to_vec(identity).map_err(|error| error.to_string())?; + // tempfile creates private files (0600 on Unix). Windows inherits the user's + // application-data ACL. Keep the key and certificates in one atomic bundle. + let mut file = tempfile::NamedTempFile::new_in(directory) + .map_err(|error| format!("cannot create remote TLS identity: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.as_file().sync_all()) + .map_err(|error| format!("cannot write remote TLS identity: {error}"))?; + if first_install { + file.persist_noclobber(path) + } else { + file.persist(path) + } + .map_err(|error| format!("cannot persist remote TLS identity: {error}"))?; + Ok(()) +} + +fn create_ca(now: OffsetDateTime) -> Result { + let mut params = CertificateParams::default(); + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "OpenLess Remote Input CA"); + name.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = name; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params.not_before = now - Duration::days(1); + params.not_after = now + Duration::days(3650); + let key = KeyPair::generate().map_err(|error| error.to_string())?; + let cert = params + .self_signed(&key) + .map_err(|error| error.to_string())?; + Ok(StoredIdentity { + ca_cert: cert.der().to_vec(), + ca_key: key.serialize_der(), + leaf_cert: Vec::new(), + leaf_key: Vec::new(), + }) +} + +fn valid_leaf(identity: &StoredIdentity, sans: &[String], now: OffsetDateTime) -> bool { + let Ok(params) = parse_cert(&identity.leaf_cert) else { + return false; + }; + let Ok(required) = CertificateParams::new(sans.to_vec()) else { + return false; + }; + if !matches!(params.is_ca, IsCa::NoCa | IsCa::ExplicitNoCa) + || params.not_before > now + || params.not_after < now + Duration::days(30) + || !required + .subject_alt_names + .iter() + .all(|san| params.subject_alt_names.contains(san)) + || matching_key(&identity.leaf_cert, &identity.leaf_key).is_err() + { + return false; + } + // Validate the cached leaf against the stored trust anchor, not a sidecar + // list of SANs. A stale or mismatched leaf must never reach the listener. + use rustls::client::danger::ServerCertVerifier; + let mut roots = rustls::RootCertStore::empty(); + if roots + .add(CertificateDer::from(identity.ca_cert.clone())) + .is_err() + { + return false; + } + let Ok(verifier) = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + Arc::new(rustls::crypto::ring::default_provider()), + ) + .build() else { + return false; + }; + let Some(name) = sans + .first() + .and_then(|name| rustls::pki_types::ServerName::try_from(name.clone()).ok()) + else { + return false; + }; + let Ok(timestamp) = u64::try_from(now.unix_timestamp()) else { + return false; + }; + verifier + .verify_server_cert( + &CertificateDer::from(identity.leaf_cert.as_slice()), + &[], + &name, + &[], + rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs( + timestamp, + )), + ) + .is_ok() +} + +pub(super) fn load_or_create(directory: &Path, sans: &[String]) -> Result { + load_at(directory, sans, OffsetDateTime::now_utc()) +} + +fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result { + if sans.is_empty() { + return Err("remote TLS requires at least one server name".into()); + } + std::fs::create_dir_all(directory) + .map_err(|error| format!("cannot create remote TLS directory: {error}"))?; + let path = directory.join(IDENTITY_FILE); + let saved = read_identity(&path)?; + let first_install = saved.is_none(); + let mut identity = match saved { + Some(identity) => identity, + None => create_ca(now)?, + }; + matching_key(&identity.ca_cert, &identity.ca_key)?; + let (remainder, ca_x509) = x509_parser::parse_x509_certificate(&identity.ca_cert) + .map_err(|_| "invalid remote TLS CA encoding")?; + if !remainder.is_empty() + || ca_x509.issuer() != ca_x509.subject() + || ca_x509.verify_signature(None).is_err() + { + return Err("remote TLS CA signature is invalid; restore the identity from backup".into()); + } + let ca_params = parse_cert(&identity.ca_cert)?; + if !matches!(ca_params.is_ca, IsCa::Ca(_)) + || !ca_params.key_usages.contains(&KeyUsagePurpose::KeyCertSign) + || ca_params.not_before > now + || ca_params.not_after <= now + Duration::days(30) + { + return Err("remote TLS CA is invalid or expires soon; restore a valid identity or explicitly reset it and trust the new certificate on each phone".into()); + } + if !valid_leaf(&identity, sans, now) { + let ca_key = KeyPair::try_from(&key_der(identity.ca_key.clone())) + .map_err(|error| error.to_string())?; + let ca_expiry = ca_params.not_after; + let ca = ca_params + .self_signed(&ca_key) + .map_err(|error| error.to_string())?; + let key = KeyPair::generate().map_err(|error| error.to_string())?; + let mut params = + CertificateParams::new(sans.to_vec()).map_err(|error| error.to_string())?; + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "OpenLess Remote Input"); + name.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = name; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params.not_before = now - Duration::days(1); + params.not_after = (now + Duration::days(365)).min(ca_expiry); + params.use_authority_key_identifier_extension = true; + let cert = params + .signed_by(&key, &ca, &ca_key) + .map_err(|error| error.to_string())?; + identity.leaf_cert = cert.der().to_vec(); + identity.leaf_key = key.serialize_der(); + if !valid_leaf(&identity, sans, now) { + return Err("generated remote TLS certificate failed validation".into()); + } + save_identity(&path, &identity, first_install)?; + } + let config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|error| error.to_string())? + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(identity.leaf_cert)], + key_der(identity.leaf_key), + ) + .map_err(|error| format!("remote TLS config: {error}"))?; + Ok(TlsIdentity { + trust_cert: identity.ca_cert, + server_config: Arc::new(config), + }) +} + +/// The profile contains a public CA only. IDs include its fingerprint so two +/// computers can be trusted without replacing each other's iOS profiles. +pub(super) fn mobileconfig(cert: &[u8]) -> String { + use base64::Engine; + use sha2::{Digest, Sha256}; + let fingerprint = format!("{:x}", Sha256::digest(cert)); + let b64 = base64::engine::general_purpose::STANDARD.encode(cert); + format!( + r#" + + +PayloadContent +PayloadCertificateFileNameopenless-ca.cer +PayloadContent{b64} +PayloadTypecom.apple.security.root +PayloadIdentifiercom.openless.remote-input.{fingerprint}.cert +PayloadUUID{cert_uuid} +PayloadVersion1 +PayloadDisplayNameOpenLess Remote Input CA ({short}) + +PayloadDisplayNameOpenLess Remote Input ({short}) +PayloadDescriptionTrust only a profile downloaded from your own computer. After installation, enable full trust in Settings > General > About > Certificate Trust Settings. This CA can issue certificates; remove this profile when you stop using remote input. +PayloadIdentifiercom.openless.remote-input.{fingerprint} +PayloadTypeConfiguration +PayloadUUID{profile_uuid} +PayloadVersion1 +"#, + short = &fingerprint[..8], + cert_uuid = uuid::Uuid::new_v4(), + profile_uuid = uuid::Uuid::new_v4(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names() -> Vec { + vec!["localhost".into(), "127.0.0.1".into(), "192.168.1.2".into()] + } + + fn stored(directory: &Path) -> StoredIdentity { + read_identity(&directory.join(IDENTITY_FILE)) + .unwrap() + .unwrap() + } + + fn verify_server(identity: &TlsIdentity, trusted_ca: &[u8], name: &str) -> bool { + let mut roots = rustls::RootCertStore::empty(); + roots + .add(CertificateDer::from(trusted_ca.to_vec())) + .unwrap(); + let client_config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let mut client = rustls::ClientConnection::new( + Arc::new(client_config), + name.to_owned().try_into().unwrap(), + ) + .unwrap(); + let mut server = + rustls::ServerConnection::new(Arc::clone(&identity.server_config)).unwrap(); + for _ in 0..10 { + let mut bytes = Vec::new(); + client.write_tls(&mut bytes).unwrap(); + server.read_tls(&mut bytes.as_slice()).unwrap(); + if server.process_new_packets().is_err() { + return false; + } + bytes.clear(); + server.write_tls(&mut bytes).unwrap(); + client.read_tls(&mut bytes.as_slice()).unwrap(); + if client.process_new_packets().is_err() { + return false; + } + if !client.is_handshaking() && !server.is_handshaking() { + return true; + } + } + false + } + + #[test] + fn restart_reuses_identity_and_tls_works_with_only_the_downloaded_ca() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let before = std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap(); + let second = load_or_create(dir.path(), &names()).unwrap(); + assert_eq!( + before, + std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() + ); + assert_eq!(first.trust_cert, second.trust_cert); + for name in names() { + assert!(verify_server(&second, &first.trust_cert, &name)); + } + assert!(!verify_server(&second, &first.trust_cert, "192.168.1.99")); + let saved = stored(dir.path()); + let ca = parse_cert(&saved.ca_cert).unwrap(); + let leaf = parse_cert(&saved.leaf_cert).unwrap(); + assert!(matches!( + ca.is_ca, + IsCa::Ca(BasicConstraints::Constrained(0)) + )); + assert!(matches!(leaf.is_ca, IsCa::NoCa | IsCa::ExplicitNoCa)); + assert!(leaf + .extended_key_usages + .contains(&ExtendedKeyUsagePurpose::ServerAuth)); + assert!(leaf.not_after - leaf.not_before <= Duration::days(366)); + } + + #[test] + fn virtual_adapter_and_lan_changes_preserve_phone_trust() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let before = stored(dir.path()); + let mut changed = names(); + changed.push("172.26.112.1".into()); + changed.push("192.168.2.3".into()); + let second = load_or_create(dir.path(), &changed).unwrap(); + let after = stored(dir.path()); + assert_eq!(before.ca_cert, after.ca_cert); + assert_eq!(before.ca_key, after.ca_key); + assert_ne!(before.leaf_cert, after.leaf_cert); + assert!(verify_server(&second, &first.trust_cert, "192.168.2.3")); + // Removing an adapter does not require another leaf or CA. + load_or_create(dir.path(), &names()).unwrap(); + assert_eq!(after.leaf_cert, stored(dir.path()).leaf_cert); + } + + #[test] + fn renews_expiring_leaf_without_rotating_ca() { + let dir = tempfile::tempdir().unwrap(); + let now = OffsetDateTime::now_utc(); + load_at(dir.path(), &names(), now).unwrap(); + let before = stored(dir.path()); + load_at(dir.path(), &names(), now + Duration::days(340)).unwrap(); + let after = stored(dir.path()); + assert_eq!(before.ca_cert, after.ca_cert); + assert_ne!(before.leaf_cert, after.leaf_cert); + assert!(parse_cert(&after.leaf_cert).unwrap().not_after > now + Duration::days(700)); + } + + #[test] + fn repairs_bad_leaf_but_never_replaces_damaged_or_mismatched_ca() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let path = dir.path().join(IDENTITY_FILE); + let mut saved = stored(dir.path()); + saved.leaf_cert = vec![0]; + save_identity(&path, &saved, false).unwrap(); + let repaired = load_or_create(dir.path(), &names()).unwrap(); + assert!(verify_server(&repaired, &first.trust_cert, "localhost")); + saved = stored(dir.path()); + let mut damaged = stored(dir.path()); + *damaged.ca_cert.last_mut().unwrap() ^= 1; + save_identity(&path, &damaged, false).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!(damaged.ca_cert, stored(dir.path()).ca_cert); + saved.ca_key = KeyPair::generate().unwrap().serialize_der(); + save_identity(&path, &saved, false).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!(before, std::fs::read(&path).unwrap()); + std::fs::write(&path, b"interrupted write").unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!( + b"interrupted write", + std::fs::read(&path).unwrap().as_slice() + ); + } + + #[test] + fn refuses_expired_ca_instead_of_silently_revoking_trust() { + let dir = tempfile::tempdir().unwrap(); + let now = OffsetDateTime::now_utc(); + load_at(dir.path(), &names(), now).unwrap(); + let before = std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap(); + assert!(load_at(dir.path(), &names(), now + Duration::days(3651)).is_err()); + assert_eq!( + before, + std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() + ); + } + + #[test] + fn migration_keeps_legacy_files_and_profiles_only_contain_public_ca() { + use base64::Engine; + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("remote-cert-v4.der"), b"legacy cert").unwrap(); + std::fs::write(dir.path().join("remote-key-v4.der"), b"legacy key").unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let profile = mobileconfig(&first.trust_cert); + let saved = stored(dir.path()); + let encoded = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + assert!(profile.contains(&format!("{}", encoded(&first.trust_cert)))); + assert!(!profile.contains(&encoded(&saved.ca_key))); + assert!(!profile.contains(&encoded(&saved.leaf_key))); + assert!(!profile.contains(&encoded(&saved.leaf_cert))); + assert_eq!( + std::fs::read(dir.path().join("remote-cert-v4.der")).unwrap(), + b"legacy cert" + ); + assert_eq!( + std::fs::read(dir.path().join("remote-key-v4.der")).unwrap(), + b"legacy key" + ); + let other = tempfile::tempdir().unwrap(); + let second = load_or_create(other.path(), &names()).unwrap(); + assert_ne!(first.trust_cert, second.trust_cert); + assert!(!verify_server(&second, &first.trust_cert, "localhost")); + assert_ne!(profile, mobileconfig(&second.trust_cert)); + } + + #[test] + fn persistence_errors_never_fall_back_to_an_ephemeral_identity() { + let dir = tempfile::tempdir().unwrap(); + let blocked = dir.path().join("blocked"); + std::fs::write(&blocked, b"not a directory").unwrap(); + assert!(load_or_create(&blocked, &names()).is_err()); + std::fs::create_dir(dir.path().join(IDENTITY_FILE)).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert!(load_or_create(dir.path(), &[]).is_err()); + } + + #[cfg(unix)] + #[test] + fn stored_private_keys_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + load_or_create(dir.path(), &names()).unwrap(); + assert_eq!( + std::fs::metadata(dir.path().join(IDENTITY_FILE)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } +} diff --git a/openless-all/app/src-tauri/src/shortcut_binding.rs b/openless-all/app/src-tauri/src/shortcut_binding.rs index 00c7a49d..a635762c 100644 --- a/openless-all/app/src-tauri/src/shortcut_binding.rs +++ b/openless-all/app/src-tauri/src/shortcut_binding.rs @@ -14,6 +14,10 @@ pub use openless_core::{ pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { openless_core::validate_shortcut_binding(binding)?; + #[cfg(target_os = "macos")] + if binding.primary == crate::macos_dictation_key::PRIMARY { + return Ok(()); + } if legacy_modifier_trigger(binding).is_some() || (binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift")) || binding_requires_side_aware_hook(binding) @@ -95,6 +99,14 @@ pub fn parse_primary(raw: &str) -> Result { "F10" => Code::F10, "F11" => Code::F11, "F12" => Code::F12, + "F13" => Code::F13, + "F14" => Code::F14, + "F15" => Code::F15, + "F16" => Code::F16, + "F17" => Code::F17, + "F18" => Code::F18, + "F19" => Code::F19, + "F20" => Code::F20, _ => return Err(ShortcutBindingError::UnsupportedKey(trimmed.to_string())), }; Ok(named) @@ -160,6 +172,49 @@ fn char_to_code(ch: char) -> Option { mod tests { use super::*; + #[test] + fn extended_function_keys_parse_without_modifiers() { + for (name, code) in [ + ("F13", Code::F13), + ("F14", Code::F14), + ("F15", Code::F15), + ("F16", Code::F16), + ("F17", Code::F17), + ("F18", Code::F18), + ("F19", Code::F19), + ("F20", Code::F20), + ] { + let binding = ShortcutBinding { + primary: name.into(), + modifiers: vec![], + }; + assert!(validate_binding(&binding).is_ok()); + let parsed = parse_global_hotkey(&binding).unwrap(); + assert_eq!(parsed.key, code); + assert!(parsed.mods.is_empty()); + assert!(legacy_modifier_trigger(&binding).is_none()); + } + assert_eq!(parse_primary(" f20 ").unwrap(), Code::F20); + assert!(parse_primary("F21").is_err()); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_dictation_key_is_bare_and_dictation_only() { + let native = ShortcutBinding { + primary: crate::macos_dictation_key::PRIMARY.into(), + modifiers: vec![], + }; + assert!(validate_binding(&native).is_ok()); + assert!(parse_global_hotkey(&native).is_err()); + assert!(reject_side_specific_non_dictation(&native).is_err()); + let modified = ShortcutBinding { + modifiers: vec!["shift".into()], + ..native + }; + assert!(validate_binding(&modified).is_err()); + } + #[test] fn parses_combo_and_single_key() { let combo = ShortcutBinding { diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index e042a85e..ab335b45 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -186,19 +186,7 @@ impl SideAwareComboMonitor { #[cfg(not(target_os = "linux"))] { - if binding.modifiers.is_empty() - || binding - .modifiers - .iter() - .any(|tag| !is_side_specific_modifier_tag(tag)) - { - return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier( - "binding is not side-specific".into(), - )); - } - crate::shortcut_binding::parse_primary(&binding.primary).map_err(|e| { - crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string()) - })?; + validate_side_binding(&binding)?; let slot = ACTIVE_MONITOR.get_or_init(|| RwLock::new(None)); let mut guard = slot.write().expect("side combo monitor lock poisoned"); @@ -211,6 +199,51 @@ impl SideAwareComboMonitor { } } +#[cfg(not(target_os = "linux"))] +fn validate_side_binding( + binding: &ShortcutBinding, +) -> Result<(), crate::combo_hotkey::ComboHotkeyError> { + if binding.modifiers.is_empty() + || binding + .modifiers + .iter() + .any(|tag| !is_side_specific_modifier_tag(tag)) + { + return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier( + "binding is not side-specific".into(), + )); + } + crate::shortcut_binding::parse_primary(&binding.primary) + .map_err(|e| crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string()))?; + + Ok(()) +} + +#[cfg(target_os = "macos")] +impl SideAwareComboMonitor { + /// Update the existing route without replacing its handle or event sender. + /// Used when a failed native-key switch restores an already-live shortcut. + pub(crate) fn update_binding( + &self, + binding: ShortcutBinding, + ) -> Result<(), crate::combo_hotkey::ComboHotkeyError> { + validate_side_binding(&binding)?; + let slot = ACTIVE_MONITOR.get().ok_or_else(|| { + crate::combo_hotkey::ComboHotkeyError::RegisterFailed( + "side-aware listener is unavailable".into(), + ) + })?; + let active = slot.read().expect("side combo monitor lock poisoned"); + let active = active.as_ref().ok_or_else(|| { + crate::combo_hotkey::ComboHotkeyError::RegisterFailed( + "side-aware listener is unavailable".into(), + ) + })?; + *active.state.lock() = SideAwareComboState::new(binding); + Ok(()) + } +} + impl Drop for SideAwareComboMonitor { fn drop(&mut self) { if let Some(slot) = ACTIVE_MONITOR.get() { @@ -270,11 +303,12 @@ pub mod platform { use super::*; use windows::Win32::UI::Input::KeyboardAndMouse::{ - VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F10, VK_F11, VK_F12, VK_F2, - VK_F3, VK_F4, VK_F5, VK_F6, VK_F7, VK_F8, VK_F9, VK_HOME, VK_INSERT, VK_LCONTROL, VK_LEFT, - VK_LMENU, VK_LSHIFT, VK_LWIN, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, - VK_OEM_7, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_RCONTROL, VK_RETURN, - VK_RIGHT, VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SPACE, VK_TAB, VK_UP, + VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F10, VK_F11, VK_F12, VK_F13, + VK_F14, VK_F15, VK_F16, VK_F17, VK_F18, VK_F19, VK_F2, VK_F20, VK_F3, VK_F4, VK_F5, VK_F6, + VK_F7, VK_F8, VK_F9, VK_HOME, VK_INSERT, VK_LCONTROL, VK_LEFT, VK_LMENU, VK_LSHIFT, + VK_LWIN, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, + VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_RCONTROL, VK_RETURN, VK_RIGHT, + VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SPACE, VK_TAB, VK_UP, }; pub fn dispatch_vk(vk_code: u32, pressed: bool) { @@ -334,6 +368,14 @@ pub mod platform { x if x == VK_F10.0 as u32 => "F10", x if x == VK_F11.0 as u32 => "F11", x if x == VK_F12.0 as u32 => "F12", + x if x == VK_F13.0 as u32 => "F13", + x if x == VK_F14.0 as u32 => "F14", + x if x == VK_F15.0 as u32 => "F15", + x if x == VK_F16.0 as u32 => "F16", + x if x == VK_F17.0 as u32 => "F17", + x if x == VK_F18.0 as u32 => "F18", + x if x == VK_F19.0 as u32 => "F19", + x if x == VK_F20.0 as u32 => "F20", x if x == VK_OEM_1.0 as u32 => ";", x if x == VK_OEM_PLUS.0 as u32 => "=", x if x == VK_OEM_COMMA.0 as u32 => ",", @@ -428,6 +470,14 @@ fn macos_keycode_to_primary(keycode: i64) -> Option<&'static str> { 109 => Some("F10"), 103 => Some("F11"), 111 => Some("F12"), + 105 => Some("F13"), + 107 => Some("F14"), + 113 => Some("F15"), + 106 => Some("F16"), + 64 => Some("F17"), + 79 => Some("F18"), + 80 => Some("F19"), + 90 => Some("F20"), _ => None, } } @@ -535,6 +585,74 @@ pub mod platform { mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn restoring_live_side_binding_preserves_its_event_route() { + use std::sync::mpsc; + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["ctrl-right".into()], + }; + let (tx, rx) = mpsc::channel(); + let monitor = SideAwareComboMonitor::start(binding.clone(), tx).unwrap(); + let press_and_release = |primary: &str| { + handle_side_modifier(SideModifier::CtrlRight, true); + handle_primary_key(primary, true); + handle_primary_key(primary, false); + handle_side_modifier(SideModifier::CtrlRight, false); + assert!(matches!( + rx.try_recv(), + Ok(ComboHotkeyEvent::Pressed { .. }) + )); + assert!(matches!( + rx.try_recv(), + Ok(ComboHotkeyEvent::Released { .. }) + )); + assert!(rx.try_recv().is_err()); + }; + press_and_release("D"); + // Native registration failed before removing this monitor. The reverse + // transaction must reuse the live route, even on repeated restoration. + monitor.update_binding(binding.clone()).unwrap(); + monitor.update_binding(binding).unwrap(); + press_and_release("D"); + assert!(monitor + .update_binding(ShortcutBinding { + primary: "F21".into(), + modifiers: vec!["ctrl-right".into()] + }) + .is_err()); + press_and_release("D"); + monitor + .update_binding(ShortcutBinding { + primary: "F20".into(), + modifiers: vec!["ctrl-right".into()], + }) + .unwrap(); + press_and_release("F20"); + drop(monitor); + assert!(matches!( + rx.try_recv(), + Err(mpsc::TryRecvError::Disconnected) + )); + } + + #[test] + fn extended_macos_function_keys_use_carbon_keycodes() { + for (keycode, primary) in [ + (105, "F13"), + (107, "F14"), + (113, "F15"), + (106, "F16"), + (64, "F17"), + (79, "F18"), + (80, "F19"), + (90, "F20"), + ] { + assert_eq!(macos_keycode_to_primary(keycode), Some(primary)); + } + } + #[test] fn macos_keycode_2_is_d() { assert_eq!(macos_keycode_to_primary(2), Some("D")); diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index fb1b1b02..5bafe591 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -32,6 +32,7 @@ import { type SettingsSectionId, } from '../pages/settings/navigation'; import { ChannelEditorHostContext } from '../pages/settings/ChannelEditorHostContext'; +import { ProviderLeaveContext, useProviderForm } from '../pages/settings/ProviderForm'; export type { SettingsSectionId } from '../pages/settings/navigation'; @@ -56,6 +57,8 @@ export function SettingsModal({ closing = false, }: SettingsModalProps) { const { t, i18n } = useTranslation(); + // 渠道表单的写入在关闭/切节前收敛(异步保存不丢草稿);版本计数不含凭据内容。 + const providerForm = useProviderForm(); const mobile = useMobileLayout(); const conservative = useConservativeLayout(); const [section, setSection] = useState(initialSettingsSection ?? 'general'); @@ -74,8 +77,10 @@ export function SettingsModal({ const [channelBackground, setChannelBackground] = useState(null); const channelCloseRef = useRef<(() => void) | null>(null); const closeSettings = () => { - channelCloseRef.current?.(); - onClose(); + void providerForm.finish(() => { + channelCloseRef.current?.(); + onClose(); + }); }; const supportsShortcuts = platformCaps?.supportsDesktopHotkey ?? os !== 'android'; const sections = visibleSettingsSections(supportsShortcuts).map((item) => ({ @@ -186,11 +191,13 @@ export function SettingsModal({ }; const selectSection = (next: SettingsSectionId, fromSearch = false) => { - channelCloseRef.current?.(); - setSection(next); - setAdvancedPage(null); - setQuery(''); - if (fromSearch) window.requestAnimationFrame(() => headingRef.current?.focus()); + void providerForm.finish(() => { + channelCloseRef.current?.(); + setSection(next); + setAdvancedPage(null); + setQuery(''); + if (fromSearch) window.requestAnimationFrame(() => headingRef.current?.focus()); + }); }; const handleKeyDown = (event: KeyboardEvent) => { @@ -283,419 +290,421 @@ export function SettingsModal({ ); return ( -
+
event.stopPropagation()} - onKeyDown={handleKeyDown} + onClick={mobile ? undefined : closeSettings} + // 打开动画:遮罩淡入 + 面板弹入(global.css ol-modal-* keyframes,纯 + // opacity/transform,合成器友好)。此前设置面板是瞬间出现的。 style={{ - width: '100%', - maxWidth: mobile ? undefined : 960, - height: '100%', - maxHeight: mobile ? undefined : 680, - minHeight: 0, - background: 'var(--ol-settings-content-bg)', - borderRadius: mobile ? 0 : 14, - border: mobile ? 'none' : '0.5px solid var(--ol-line)', - boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)', + position: mobile ? 'fixed' : 'absolute', + inset: 0, + background: mobile ? 'var(--ol-surface)' : 'var(--ol-overlay-bg)', display: 'flex', - flexDirection: 'column', - overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + padding: mobile ? 0 : '64px 28px 24px', + zIndex: mobile ? 70 : 50, animation: mobile - ? closing - ? 'ol-mobile-sheet-up 0.22s var(--ol-motion-soft) reverse both' - : 'ol-mobile-sheet-up 0.26s var(--ol-motion-spring) both' + ? undefined : closing - ? 'ol-modal-card-in 0.2s var(--ol-motion-soft) reverse both' - : 'ol-modal-card-in 0.28s var(--ol-motion-spring) both', + ? 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft) reverse both' + : 'ol-modal-backdrop-in 0.2s var(--ol-motion-soft) both', }} > - {/* 桌面端不再有横跨两栏的标题栏; - 左侧栏与右侧内容各自通到顶,标题/自动保存/关闭并入右栏顶部。 - 移动端保留整宽 header(标题 + 关闭 + 整行搜索)。 */} - {mobile && ( -
-
- {t('shell.footer.settings')} -
- -
{searchBox}
-
- )}
event.stopPropagation()} + onKeyDown={handleKeyDown} style={{ - flex: 1, + width: '100%', + maxWidth: mobile ? undefined : 960, + height: '100%', + maxHeight: mobile ? undefined : 680, minHeight: 0, + background: 'var(--ol-settings-content-bg)', + borderRadius: mobile ? 0 : 14, + border: mobile ? 'none' : '0.5px solid var(--ol-line)', + boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)', display: 'flex', - flexDirection: mobile ? 'column' : 'row', + flexDirection: 'column', + overflow: 'hidden', + animation: mobile + ? closing + ? 'ol-mobile-sheet-up 0.22s var(--ol-motion-soft) reverse both' + : 'ol-mobile-sheet-up 0.26s var(--ol-motion-spring) both' + : closing + ? 'ol-modal-card-in 0.2s var(--ol-motion-soft) reverse both' + : 'ol-modal-card-in 0.28s var(--ol-motion-spring) both', }} > - +
+ {t('shell.footer.settings')} +
+ +
{searchBox}
+ + )}
- { - channelCloseRef.current = close; - }, + +
+ { + channelCloseRef.current = close; + }, + }} + >
- {mobile && !activeAdvancedPage && ( -

+ {/* 桌面端:分类标题 + 自动保存提示 + 关闭按钮组成右栏自己的顶栏 + (仿系统设置:工具条只属于内容区,不再横跨左栏)。 */} + {(!mobile || activeAdvancedPage) && ( +
- {contentTitle} -

- )} -

- {contentDescription} -

-
-
- {searching && ( -
- {results.map((item) => ( + {activeAdvancedPage && ( - ))} - {results.length === 0 && ( -
-

- {t('modal.noResults')} -

+ )} +

+ {contentTitle} +

+ {!mobile && ( + <> + + {t('modal.autoSaveHint')} + -
+ )}
)} - {/* key={section} 重挂载 → 每次切换分类播放轻微淡入(ol-tab-fade), - 与 tab 切换动画语言一致。 */}
+ {mobile && !activeAdvancedPage && ( +

+ {contentTitle} +

+ )} +

+ {contentDescription} +

+
+
- {section === 'general' && } - {section === 'shortcuts' && } - {section === 'appearance' && } - {section === 'services' && } - {section === 'privacy' && } - {section === 'advanced' && ( - + {searching && ( +
+ {results.map((item) => ( + + ))} + {results.length === 0 && ( +
+

+ {t('modal.noResults')} +

+ +
+ )} +
)} - {section === 'about' && } + {/* key={section} 重挂载 → 每次切换分类播放轻微淡入(ol-tab-fade), + 与 tab 切换动画语言一致。 */} +
+ {section === 'general' && } + {section === 'shortcuts' && } + {section === 'appearance' && } + {section === 'services' && } + {section === 'privacy' && } + {section === 'advanced' && ( + + )} + {section === 'about' && } +
+ {mobile && !searching && }
- {mobile && !searching && }
-
-
+ +
- + ); } diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 2d410d0f..9acf5fa8 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey'; +import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder'; import { KbdGroup } from './Kbd'; import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; import type { ShortcutBinding } from '../lib/types'; @@ -28,6 +29,7 @@ export function ShortcutRecorder({ resetLabel, comboOnly = false, sideSpecificModifiers = false, + allowMacDictationKey = false, }: { value: ShortcutBinding | null; onSave: (binding: ShortcutBinding) => Promise; @@ -45,11 +47,15 @@ export function ShortcutRecorder({ comboOnly?: boolean; /** 听写 start/stop 专用:录制 cmd-left / ctrl-right 等侧向修饰键。 */ sideSpecificModifiers?: boolean; + /** macOS dictation only: choose the dedicated key as the single trigger. */ + allowMacDictationKey?: boolean; }) { const { t } = useTranslation(); const [recording, setRecording] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [error, setError] = useState(null); + const nativeSelected = allowMacDictationKey && value?.primary === 'MacDictationKey'; + const nativeError = error && ['Permission', 'Busy', 'Unavailable', 'Changed'].find(kind => error.includes(`macDictationKey${kind}`)); const pendingModifier = useRef(null); const pendingTimer = useRef(null); const pressedCodes = useRef>(new Set()); @@ -115,8 +121,9 @@ export function ShortcutRecorder({ resetRecordingState(); setRecording(false); setError(null); - } catch { - setError(t('settings.recording.comboConflict')); + } catch (reason) { + const message = String(reason); + setError(message.includes('macDictationKey') ? message : t('settings.recording.comboConflict')); } }; @@ -217,10 +224,15 @@ export function ShortcutRecorder({ } }; - const doReset = () => { + const doReset = async () => { setMenuOpen(false); setError(null); - if (onReset) void onReset(); + try { + await onReset?.(); + } catch (reason) { + const message = String(reason); + setError(message.includes('macDictationKey') ? message : t('settings.recording.comboConflict')); + } }; const doDisable = () => { @@ -371,11 +383,26 @@ export function ShortcutRecorder({ + {allowMacDictationKey &&
+ +
}
)} - {error &&
{error}
} + {error &&
{nativeError ? t(`macDictationKey.${nativeError}`) : error}
}
); } @@ -443,6 +470,8 @@ function modifierPrimaryFromCode(code: string, key: string): string { } function primaryFromKeyboardEvent(e: KeyboardEvent): string { + const functionKey = functionKeyPrimaryFromEvent(e); + if (functionKey) return functionKey; const printable = primaryFromPrintableCode(e.code); if (printable) return printable; if (e.key.length === 1) return e.key; diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 01bb432c..a502c96f 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -35,6 +35,8 @@ export interface SelectOption { value: string; label: string; disabled?: boolean; + /** 搜索期间仍保留操作项,例如自定义输入入口。 */ + alwaysVisible?: boolean; /** 可选:渲染在选项标签右侧、勾选标记左侧(如麦克风音量条)。 */ trailing?: ReactNode; } @@ -111,7 +113,7 @@ export function SelectLite({ const needle = query.trim().toLocaleLowerCase(); if (!needle) return options; return options.filter(option => ( - option.label.toLocaleLowerCase().includes(needle) + option.alwaysVisible || option.label.toLocaleLowerCase().includes(needle) || option.value.toLocaleLowerCase().includes(needle) )); }, [options, query]); @@ -263,6 +265,7 @@ export function SelectLite({ }; const selectIndex = (index: number) => { + if (disabled) return; const option = filteredOptions[index]; if (!option || option.disabled) return; onChange(option.value); @@ -319,6 +322,7 @@ export function SelectLite({ }; const handleSearchKeyDown = (event: ReactKeyboardEvent) => { + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; if (event.key === 'Escape') { event.preventDefault(); closeMenu(); @@ -459,7 +463,7 @@ export function SelectLite({ role="listbox" style={{ maxHeight: searchable ? 274 : 272, overflowY: 'auto' }} > - {filteredOptions.length === 0 && ( + {!filteredOptions.some(option => !option.alwaysVisible) && (
(actual: T, expected: T, name: string) { + if (actual !== expected) { + throw new Error(`${name}: expected ${expected}, got ${actual}`); + } +} + +function assertDeepEqual(actual: unknown, expected: unknown, name: string) { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`${name}: expected ${expectedJson}, got ${actualJson}`); + } +} + +function apply( + state = createHotkeyRecorderState(), + code: string, + pressed: boolean, +) { + const next = updateHotkeyRecorderState(state, code, pressed); + return next; +} + +{ + let result = apply(undefined, 'ControlLeft', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft'], 'tracks first pressed key'); + assertEqual(result.commitCodes, null, 'does not commit until release'); + + result = apply(result.state, 'ControlLeft', false); + assertDeepEqual(result.commitCodes, ['ControlLeft'], 'commits single key on release'); + + result = apply(createHotkeyRecorderState(), 'KeyK', true); + assertDeepEqual(result.state.draftCodes, ['KeyK'], 'starts a new recording state cleanly'); + assertEqual(result.commitCodes, null, 'new keydown does not include old released keys'); +} + +{ + let result = apply(undefined, 'ControlLeft', true); + result = apply(result.state, 'KeyK', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'records keyboard combo draft'); + + result = apply(result.state, 'ControlLeft', false); + assertEqual(result.commitCodes, null, 'keyboard combo waits for final release'); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'released combo member stays in draft only'); + + result = apply(result.state, 'KeyK', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'KeyK'], 'keyboard combo commits after all keys release'); + assertDeepEqual(result.state, createHotkeyRecorderState(), 'state resets after commit'); +} + +{ + let result = apply(undefined, 'Mouse4', true); + assertDeepEqual(result.state.draftCodes, ['Mouse4'], 'tracks mouse button as draft'); + assertEqual(result.commitCodes, null, 'mouse button does not commit on mousedown'); + + result = apply(result.state, 'ControlLeft', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse4'], 'records keyboard plus mouse combo'); + assertEqual(result.commitCodes, null, 'combo does not commit while inputs remain pressed'); + + result = apply(result.state, 'Mouse4', false); + assertEqual(result.commitCodes, null, 'releasing one combo member does not commit early'); + + result = apply(result.state, 'ControlLeft', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse4'], 'commits combo after final release'); +} + +{ + let result = apply(undefined, 'ControlLeft', true); + result = apply(result.state, 'Mouse5', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse5'], 'records mouse button pressed after keyboard'); + + result = apply(result.state, 'ControlLeft', false); + assertEqual(result.commitCodes, null, 'mouse combo keeps waiting while mouse remains pressed'); + + result = apply(result.state, 'Mouse5', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse5'], 'commits mouse-last combo after mouse release'); +} + +assertDeepEqual(orderHotkeyCodes(['Mouse4', 'ControlLeft']), ['ControlLeft', 'Mouse4'], 'orders mouse after modifiers'); + +for (let i = 1; i <= 20; i++) { + assertEqual(functionKeyPrimaryFromEvent({ code: `F${i}`, key: `F${i}` }), `F${i}`, 'function key'); +} +assertEqual(functionKeyPrimaryFromEvent({ code: 'F20', key: '\uF717' }), 'F20', 'WebKit private-use key'); +assertEqual(functionKeyPrimaryFromEvent({ code: 'F20', key: 'Unidentified' }), 'F20', 'physical F20'); +assertEqual(functionKeyPrimaryFromEvent({ code: '', key: 'F20' }), 'F20', 'named F20 fallback'); +assertEqual(functionKeyPrimaryFromEvent({ code: 'KeyA', key: 'a' }), null, 'printable key preserved'); diff --git a/openless-all/app/src/lib/hotkeyRecorder.ts b/openless-all/app/src/lib/hotkeyRecorder.ts new file mode 100644 index 00000000..8473eedc --- /dev/null +++ b/openless-all/app/src/lib/hotkeyRecorder.ts @@ -0,0 +1,78 @@ +export interface HotkeyRecorderState { + pressedCodes: string[]; + draftCodes: string[]; +} + +export interface HotkeyRecorderUpdate { + state: HotkeyRecorderState; + commitCodes: string[] | null; +} + +export function createHotkeyRecorderState(): HotkeyRecorderState { + return { + pressedCodes: [], + draftCodes: [], + }; +} + +export function updateHotkeyRecorderState( + state: HotkeyRecorderState, + code: string, + pressed: boolean, +): HotkeyRecorderUpdate { + const active = new Set(state.pressedCodes); + if (pressed) { + active.add(code); + } else { + active.delete(code); + } + + const pressedCodes = orderHotkeyCodes([...active]); + const draftCodes = pressed ? pressedCodes : state.draftCodes; + const shouldCommit = !pressed && pressedCodes.length === 0 && draftCodes.length > 0; + + return { + state: shouldCommit ? createHotkeyRecorderState() : { pressedCodes, draftCodes }, + commitCodes: shouldCommit ? draftCodes : null, + }; +} + +export function orderHotkeyCodes(codes: string[]): string[] { + const seen = new Set(); + return codes + .filter(code => { + if (!code || seen.has(code)) return false; + seen.add(code); + return true; + }) + .sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b)); +} + +function hotkeyCodeRank(code: string): number { + const index = HOTKEY_CODE_ORDER.indexOf(code); + if (index >= 0) return index; + if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3); + if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5)); + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1)); + if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6)); + return 1000; +} + +const HOTKEY_CODE_ORDER = [ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause', + 'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', + 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', + 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd', + 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', + 'Mouse4', 'Mouse5', +]; + +/** Prefer physical function-key codes; WebKit can expose a private-use key value. */ +export function functionKeyPrimaryFromEvent(event: { code: string; key: string }): string | null { + const supported = /^F([1-9]|1[0-9]|20)$/; + if (supported.test(event.code)) return event.code; + if (supported.test(event.key)) return event.key; + return null; +} diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index 4a428692..33d5943b 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -2,7 +2,7 @@ import type { CredentialsStatus } from '../types'; import { invokeOrMock, isTauri } from './shared'; import { mockCredentialsStatus, mockCredentialValues } from './mock-data'; import { invalidateMockChannelTest } from './channels'; -import type { LlmRequestFormat } from './providers'; +import { listProviderDescriptors, type LlmRequestFormat } from './providers'; export interface ProviderCheckResult { ok: boolean; @@ -13,33 +13,36 @@ export interface ProviderModelsResult { } interface OrcaRouterCatalogModel { - id?: string - supported_endpoint_types?: string[] - architecture?: { - input_modalities?: string[] - } + id?: string; + supported_endpoint_types?: string[]; + architecture?: { + input_modalities?: string[]; + }; } export function filterOrcaRouterModels( - models: OrcaRouterCatalogModel[], - kind: "llm" | "asr", - requestFormat: LlmRequestFormat = "chat_completions", + models: OrcaRouterCatalogModel[], + kind: 'llm' | 'asr', + requestFormat: LlmRequestFormat = 'chat_completions', ): string[] { - const endpointType = kind === "asr" - ? "openai" - : { - chat_completions: "openai", - responses: "openai-response", - messages: "anthropic", - }[requestFormat] - return models - .filter(model => model.supported_endpoint_types?.includes(endpointType) === true) - .filter(model => kind === "llm" || ( - model.id?.toLowerCase().startsWith("google/gemini") === true - && model.architecture?.input_modalities?.includes("audio") === true - )) - .map(model => model.id?.trim() ?? "") - .filter(Boolean) + const endpointType = + kind === 'asr' + ? 'openai' + : { + chat_completions: 'openai', + responses: 'openai-response', + messages: 'anthropic', + }[requestFormat]; + return models + .filter((model) => model.supported_endpoint_types?.includes(endpointType) === true) + .filter( + (model) => + kind === 'llm' || + (model.id?.toLowerCase().startsWith('google/gemini') === true && + model.architecture?.input_modalities?.includes('audio') === true), + ) + .map((model) => model.id?.trim() ?? '') + .filter(Boolean); } export function getCredentials(): Promise { @@ -88,6 +91,12 @@ export async function listProviderModels( channelId?: string, providerType?: string, ): Promise { + if (!isTauri) { + const descriptor = (await listProviderDescriptors(kind)).find( + (item) => item.providerType === providerType, + ); + if (descriptor?.staticModels.length) return { models: descriptor.staticModels }; + } if (!isTauri && providerType === 'orcarouter' && (kind === 'llm' || kind === 'asr')) { const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; const endpoint = mockCredentialValues.get(`${channelId ?? ''}:${endpointAccount}`); @@ -96,15 +105,21 @@ export async function listProviderModels( if (!response.ok) { throw new Error(`OrcaRouter /models returned ${response.status}`); } - const payload = await response.json() as { data?: OrcaRouterCatalogModel[] }; + const payload = (await response.json()) as { data?: OrcaRouterCatalogModel[] }; const storedFormat = mockCredentialValues.get(`${channelId ?? ''}:ark.request_format`); - const requestFormat: LlmRequestFormat = storedFormat === 'responses' || storedFormat === 'messages' - ? storedFormat - : 'chat_completions'; + const requestFormat: LlmRequestFormat = + storedFormat === 'responses' || storedFormat === 'messages' + ? storedFormat + : 'chat_completions'; return { models: filterOrcaRouterModels(payload.data ?? [], kind, requestFormat) }; } } return invokeOrMock('list_provider_models', { kind, channelId }, () => ({ - models: kind === 'llm' ? ['gpt-4o', 'deepseek-v4-flash', 'deepseek-v4-pro'] : ['whisper-1'], + models: + kind === 'llm' + ? ['gpt-4o', 'deepseek-v4-flash', 'deepseek-v4-pro'] + : kind === 'omni' + ? ['gpt-4o-audio-preview', 'qwen3-omni-flash'] + : ['whisper-1'], })); } diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json new file mode 100644 index 00000000..e5c15e31 --- /dev/null +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -0,0 +1,50 @@ +[ + {"kind":"llm","providerType":"ark","labelKey":"ark","defaultEndpoint":"https://ark.cn-beijing.volces.com/api/v3","defaultModel":"deepseek-v3-2","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"deepseek","labelKey":"deepseek","defaultEndpoint":"https://api.deepseek.com/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"siliconflow","labelKey":"siliconflow","defaultEndpoint":"https://api.siliconflow.cn/v1","defaultModel":"Qwen/Qwen2.5-7B-Instruct","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"atlascloud","labelKey":"atlascloud","defaultEndpoint":"https://api.atlascloud.ai/v1","defaultModel":"qwen/qwen3.5-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"openai","labelKey":"openai","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"gpt-4o","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"gemini","labelKey":"gemini","defaultEndpoint":"https://generativelanguage.googleapis.com/v1beta","defaultModel":"gemini-2.5-flash","authRequirement":"api_key","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"llm","providerType":"codex_oauth","labelKey":"codexOAuth","defaultEndpoint":null,"defaultModel":"gpt-5.5","authRequirement":"o_auth","validationProbe":"llm_text","staticModels":["gpt-5.5","gpt-5.3-codex","gpt-5.4","gpt-5.5"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"llm","providerType":"mimo","labelKey":"mimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"xiaomi/mimo-v2-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"cometapi","labelKey":"cometapi","defaultEndpoint":"https://api.cometapi.com/v1","defaultModel":"gpt-4o","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"openrouterFree","labelKey":"openrouterFree","defaultEndpoint":"https://openrouter.ai/api/v1","defaultModel":"qwen/qwen3-coder:free","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"orcarouter","labelKey":"orcarouter","defaultEndpoint":"https://api.orcarouter.ai/v1","defaultModel":"orcarouter/fusion-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"alibabaCoding","labelKey":"alibabaCoding","defaultEndpoint":"https://coding-intl.dashscope.aliyuncs.com/v1","defaultModel":"qwen3-coder-plus","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"codingPlanX","labelKey":"codingPlanX","defaultEndpoint":"https://api.codingplanx.ai/v1","defaultModel":"gpt-5-mini","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"minimax","labelKey":"minimax","defaultEndpoint":"https://api.minimaxi.com/v1","defaultModel":"MiniMax-M3","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"stepfun","labelKey":"stepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"step-1o-turbo-vision","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"opencode","labelKey":"opencode","defaultEndpoint":"https://opencode.ai/zen/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"tencentTokenHub","labelKey":"tencentTokenHub","defaultEndpoint":"https://tokenhub.tencentmaas.com/v1","defaultModel":"hy3","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"llm","providerType":"custom","labelKey":"customChatCompletions","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"custom_responses","labelKey":"customResponses","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"responses","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"custom_messages","labelKey":"customMessages","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"messages","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"asr","providerType":"volcengine","labelKey":"asrVolcengine","defaultEndpoint":null,"defaultModel":null,"authRequirement":"volcengine","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"elevenlabs","labelKey":"asrElevenLabs","defaultEndpoint":"https://api.elevenlabs.io/v1","defaultModel":"scribe_v2","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["scribe_v2"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian","labelKey":"asrBailian","defaultEndpoint":"wss://dashscope.aliyuncs.com/api-ws/v1/inference/","defaultModel":"fun-asr-realtime","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["fun-asr-realtime","fun-asr-flash-8k-realtime","qwen3-asr-flash-realtime","qwen3-asr-flash-realtime-2026-02-10","qwen3-asr-flash-realtime-2025-10-27","qwen-audio-3.0-asr-flash","fun-asr-flash-2026-06-15","qwen3-asr-flash","fun-asr","fun-asr-2025-11-07","fun-asr-2025-08-25","fun-asr-mtl","fun-asr-mtl-2025-08-25","paraformer-v2"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian-qwen3-realtime","labelKey":"asrBailianQwen3","defaultEndpoint":"wss://dashscope.aliyuncs.com/api-ws/v1/realtime","defaultModel":"qwen3-asr-flash-realtime","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["qwen3-asr-flash-realtime","qwen3-asr-flash-realtime-2026-02-10","qwen3-asr-flash-realtime-2025-10-27"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian-fun-asr-flash","labelKey":"asrBailianFunAsrFlash","defaultEndpoint":"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation","defaultModel":"fun-asr-flash-2026-06-15","authRequirement":"api_key","validationProbe":"asr_non_silent","staticModels":["qwen-audio-3.0-asr-flash","fun-asr-flash-2026-06-15"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"siliconflow","labelKey":"asrSiliconflow","defaultEndpoint":"https://api.siliconflow.cn/v1","defaultModel":"FunAudioLLM/SenseVoiceSmall","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"stepfun","labelKey":"asrStepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"stepaudio-2.5-asr","authRequirement":"api_key","validationProbe":"stepfun_no_speech","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"zhipu","labelKey":"asrZhipu","defaultEndpoint":"https://open.bigmodel.cn/api/paas/v4","defaultModel":"glm-asr-2512","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"groq","labelKey":"asrGroq","defaultEndpoint":"https://api.groq.com/openai/v1","defaultModel":"whisper-large-v3-turbo","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"whisper","labelKey":"asrWhisper","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"whisper-1","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"openrouter","labelKey":"asrOpenrouter","defaultEndpoint":"https://openrouter.ai/api/v1","defaultModel":"openai/whisper-large-v3-turbo","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"orcarouter","labelKey":"orcarouter","defaultEndpoint":"https://api.orcarouter.ai/v1","defaultModel":"google/gemini-2.5-flash","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"zenmux","labelKey":"asrZenmux","defaultEndpoint":"https://zenmux.ai/api/v1","defaultModel":"qwen/qwen3-asr-flash","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"openai-compatible","labelKey":"asrOpenAiCompatible","defaultEndpoint":null,"defaultModel":null,"authRequirement":"endpoint_model_optional_api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"xiaomi-mimo-asr","labelKey":"asrXiaomiMimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"mimo-v2.5-asr","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["mimo-v2.5-asr"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"iflytek","labelKey":"asrIflytek","defaultEndpoint":null,"defaultModel":null,"authRequirement":"xfyun","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"tencent-cloud","labelKey":"asrTencentCloud","defaultEndpoint":null,"defaultModel":"Hy-ASR-3.0-preview","authRequirement":"tencent_cloud","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"foundry-local-whisper","labelKey":"asrFoundryLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-whisper","labelKey":"asrLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"sherpa-onnx-local","labelKey":"asrSherpaOnnxLocal","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3-mlx","labelKey":"asrLocalQwen3Mlx","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3-c","labelKey":"asrLocalQwen3C","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3","labelKey":"asrLocalQwen3","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"apple-speech","labelKey":"asrAppleSpeech","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"openai","labelKey":"omniOpenai","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"gpt-4o-audio-preview","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"gemini","labelKey":"omniGemini","defaultEndpoint":"https://generativelanguage.googleapis.com/v1beta","defaultModel":"gemini-2.5-flash","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"dashscope-omni","labelKey":"omniDashscope","defaultEndpoint":"https://dashscope.aliyuncs.com/compatible-mode/v1","defaultModel":"qwen3-omni-flash","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"custom","labelKey":"custom","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]} +] diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 5f202a2d..7ecc08d2 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -1,4 +1,5 @@ -import { invokeOrMock, isTauri } from './shared'; +import mockDescriptors from './mock-provider-descriptors.json'; +import { invokeOrMock } from './shared'; export type ProviderKind = 'asr' | 'llm' | 'omni'; export type LlmRequestFormat = 'chat_completions' | 'responses' | 'messages'; @@ -10,6 +11,7 @@ export type AuthRequirement = | 'api_key_unless_custom_endpoint' | 'volcengine' | 'xfyun' + | 'tencent_cloud' | 'o_auth'; export interface ProviderDescriptor { @@ -26,12 +28,9 @@ export interface ProviderDescriptor { } /** Core owns protocol, defaults, and credential requirements. */ -export async function listProviderDescriptors(kind: ProviderKind): Promise { - if (!isTauri) { - // Generated by Core; keep browser previews usable without duplicating - // provider defaults or authentication rules in handwritten frontend data. - const { default: catalog } = await import('./provider-descriptors.generated.json'); - return catalog[kind] as ProviderDescriptor[]; - } - return invokeOrMock('list_provider_descriptors', { kind }, () => []); +export function listProviderDescriptors(kind: ProviderKind): Promise { + // Snapshot of Core provider_rules::provider_descriptors (all three kinds). + return invokeOrMock('list_provider_descriptors', { kind }, () => + (mockDescriptors as ProviderDescriptor[]).filter((descriptor) => descriptor.kind === kind), + ); } diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 5c247b7d..b43c244e 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -65,6 +65,7 @@ const LLM_NAME_KEY_BY_ID: Record = { minimax: 'minimax', stepfun: 'stepfun', opencode: 'opencode', + tencentTokenHub: 'tencentTokenHub', custom: 'custom', }; diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 9b303ba3..5807e0b2 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -51,6 +51,7 @@ import { LLM_LABELS, OmniChannelSection, } from './ProvidersSection'; +import { ProviderFormContext, useProviderForm } from './ProviderForm'; import { ASR_LABELS, inputStyle } from './shared'; import { ChannelEditorHostContext } from './ChannelEditorHostContext'; @@ -913,6 +914,8 @@ function ChannelModal({ onUserMutation: () => void; }) { const { t } = useTranslation(); + // 关闭 / 换供应商前收敛渠道表单未落盘的异步写入(#1044 语义)。 + const form = useProviderForm(); const [name, setName] = useState(channel.name); const [providerType, setProviderType] = useState(channel.providerType); const [changingProvider, setChangingProvider] = useState(false); @@ -926,8 +929,11 @@ function ChannelModal({ onCloseRef.current = onClose; const requestClose = () => { if (closeRequestedRef.current) return; - closeRequestedRef.current = true; - onCloseRef.current(); + void form.finish(() => { + if (closeRequestedRef.current) return; + closeRequestedRef.current = true; + onCloseRef.current(); + }); }; useEffect(() => { @@ -1011,7 +1017,8 @@ function ChannelModal({ const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; const modelAccount = kind === 'llm' ? 'ark.model_id' : 'asr.model'; if (next === 'orcarouter') { - if (preset.defaultEndpoint) await setCredential(endpointAccount, preset.defaultEndpoint, channel.id); + if (preset.defaultEndpoint) + await setCredential(endpointAccount, preset.defaultEndpoint, channel.id); if (preset.defaultModel) await setCredential(modelAccount, preset.defaultModel, channel.id); return; } @@ -1029,6 +1036,7 @@ function ChannelModal({ const changeProvider = async (next: string) => { const previous = providerType; onUserMutation(); + await form.finish(() => undefined); setChangingProvider(true); try { await setChannelProviderType(kind, channel.id, next); @@ -1064,138 +1072,146 @@ function ChannelModal({ const isLocalEngine = descriptor?.authRequirement === 'none'; const content = ( -
-
- {embedded ? ( - - ) : ( - - - - )} -
-
-

{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}

- {t(`settings.channels.${kind}Title`)} -
-

{t('settings.channels.autoSaveHint')}

-
- {!embedded && ( - - )} -
- -
-
- - - void changeProvider(next)} - options={presets.map((p) => ({ - value: p.id, - label: t(`settings.providers.presets.${p.nameKey}`), - }))} - ariaLabel={t('settings.channels.providerLabel')} - style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }} - /> - - - { - onUserMutation(); - setName(e.target.value); - }} - onBlur={() => void saveName()} - placeholder={t('settings.channels.namePlaceholder')} - style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }} - /> -

{t('settings.channels.nameHint')}

-
- - {/* 模型列表、供应商特有字段与验证结果都留在同一个滚动区。 */} - {!changingProvider && ( - void onChanged()} - onUserMutation={onUserMutation} - /> - )} - {isLocalEngine && ( -

{t('settings.channels.localEngineModelHint')}

- )} -
-
- -
- {confirmDelete ? ( -
-

{t('settings.channels.deleteConfirm')}

+ +
+
+ {embedded ? ( + + ) : ( + + + + )} +
+
+

+ {t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')} +

+ {t(`settings.channels.${kind}Title`)} +
+

{t('settings.channels.autoSaveHint')}

+
+ {!embedded && ( + + )} +
+ +
+
+ + + void changeProvider(next)} + options={presets.map((p) => ({ + value: p.id, + label: t(`settings.providers.presets.${p.nameKey}`), + }))} + ariaLabel={t('settings.channels.providerLabel')} + style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }} + /> + + + { + onUserMutation(); + setName(e.target.value); + }} + onBlur={() => void saveName()} + placeholder={t('settings.channels.namePlaceholder')} + style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }} + /> +

{t('settings.channels.nameHint')}

+
+ + {/* 模型列表、供应商特有字段与验证结果都留在同一个滚动区。 */} + {!changingProvider && ( + void onChanged()} + onUserMutation={onUserMutation} + /> + )} + {isLocalEngine && ( +

{t('settings.channels.localEngineModelHint')}

+ )} +
+
+ +
+ {confirmDelete ? ( +
+

{t('settings.channels.deleteConfirm')}

+ + +
+ ) : ( - + )} +
+ {t('modal.autoSaveHint')} + + {t(embedded ? 'settings.channels.done' : 'common.close')} +
- ) : ( - - )} -
- {t('modal.autoSaveHint')} - - {t(embedded ? 'settings.channels.done' : 'common.close')} - -
-
-
+
+
+ ); return embedded ? ( createPortal(content, editorHost!.container!) diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index a2250d28..4fe2dc9d 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -80,6 +80,17 @@ for (const id of ['opencode', 'custom', 'custom_responses', 'custom_messages']) `${id} must allow all three compatibility formats`, ); } +const tokenhub = presets.find((p) => p.id === 'tencentTokenHub'); +assert( + tokenhub && + tokenhub.defaultRequestFormat == null && + tokenhub.supportedRequestFormats?.length === 0, + 'TokenHub preset must stay fixed to Chat Completions', +); +assert( + descriptors.find((item) => item.providerType === 'codex_oauth')?.staticModels?.length, + 'OAuth static models must be available without an API key', +); for (const id of ['gemini', 'codex_oauth']) { const descriptor = descriptors.find((item) => item.providerType === id); assert(descriptor?.supportedRequestFormats?.length === 0, `${id} retains its native protocol`); diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx index 5066f7a2..1b8d1284 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SelectLite } from '../../components/ui/SelectLite'; import { readCredential, setCredential } from '../../lib/ipc'; @@ -6,6 +6,7 @@ import type { LlmRequestFormat } from '../../lib/ipc/providers'; import { emitSaved } from '../../lib/savedEvent'; import { Btn } from '../_atoms'; import { SettingRow, inputStyle } from './shared'; +import { ProviderFormContext } from './ProviderForm'; const accounts = [ 'ark.request_format', @@ -64,6 +65,7 @@ export function LlmProtocolFields({ onSaved?: (changedAccounts?: string[]) => void; }) { const { t } = useTranslation(); + const form = useContext(ProviderFormContext); const [values, setValues] = useState(emptyValues); const [saved, setSaved] = useState(emptyValues); const [loaded, setLoaded] = useState(false); @@ -71,6 +73,8 @@ export function LlmProtocolFields({ const [error, setError] = useState<'read' | 'save' | null>(null); const mounted = useRef(true); const writing = useRef(false); + const pendingWrite = useRef>(Promise.resolve(true)); + const flushRef = useRef<() => Promise>(() => Promise.resolve(true)); const dirty = accounts.some((account) => values[account] !== saved[account]); const validation = protocolValidationError(values, defaultFormat); @@ -100,34 +104,45 @@ export function LlmProtocolFields({ !loaded || saving || dirty || error !== null || validation !== null, ); }, [loaded, saving, dirty, error, validation, onBlockedChange]); + // 关闭/切换前由宿主 Modal 收敛未落盘写入;flush 永远读取最新的 dirty 状态。 + useEffect(() => form?.register('protocol', () => flushRef.current()), [form?.register]); - const save = async (next: ProtocolValues) => { - if (!loaded || writing.current || protocolValidationError(next, defaultFormat)) return; + const save = (next: ProtocolValues): Promise => { + if (writing.current) return pendingWrite.current; + if (!loaded || protocolValidationError(next, defaultFormat)) return Promise.resolve(false); writing.current = true; setSaving(true); setError(null); - try { - const changedAccounts = accounts.filter((account) => next[account] !== saved[account]); - for (const account of changedAccounts) { - await setCredential(account, next[account], channelId); + pendingWrite.current = (async () => { + try { + const changedAccounts = accounts.filter((account) => next[account] !== saved[account]); + for (const account of changedAccounts) { + await setCredential(account, next[account], channelId); + } + if (mounted.current) { + setSaved(next); + emitSaved('saved', t('common.saved')); + onSaved?.(changedAccounts); + } + return true; + } catch { + if (mounted.current) { + setError('save'); + emitSaved('failed', t('common.operationFailed')); + } + return false; + } finally { + writing.current = false; + if (mounted.current) setSaving(false); } - if (mounted.current) { - setSaved(next); - emitSaved('saved', t('common.saved')); - onSaved?.(changedAccounts); - } - } catch { - if (mounted.current) { - setError('save'); - emitSaved('failed', t('common.operationFailed')); - } - } finally { - writing.current = false; - if (mounted.current) setSaving(false); - } + })(); + return pendingWrite.current; }; + flushRef.current = () => + writing.current ? pendingWrite.current : dirty ? save(values) : Promise.resolve(true); const change = (account: Account, value: string, immediate = false) => { + if (!loaded || writing.current || form?.leaving) return; onUserMutation(); const next = { ...values, [account]: value }; setValues(next); @@ -135,7 +150,7 @@ export function LlmProtocolFields({ }; const format = values['ark.request_format'] || defaultFormat; const mode = values['ark.messages_thinking'] || 'adaptive'; - const disabled = !loaded || saving; + const disabled = !loaded || saving || form?.leaving; const numberField = (account: Account, label: string, placeholder: string) => ( Promise) => () => void) | null>(null); + +/** 同一表单的写入在关闭/切换前收敛;版本只含计数,不携带凭据内容。 */ +export function useProviderForm() { + const parent = useContext(ProviderLeaveContext); + const id = useId(); + const version = useRef(0); + const [revision, setRevision] = useState(0); + const [blocked, setBlocked] = useState>({}); + const blockers = useRef>({}); + const flushers = useRef(new Map Promise>()); + const busy = useRef(false); + const [leaving, setLeaving] = useState(false); + const invalidate = useCallback((account = '') => { + if (!['ark.model_id', 'asr.model', 'omni.model'].includes(account)) version.current += 1; + setRevision(value => value + 1); + }, []); + const track = useCallback((account: string, next: boolean) => { + blockers.current[account] = next; + setBlocked(previous => previous[account] === next ? previous : { ...previous, [account]: next }); + }, []); + const register = useCallback((account: string, flush: () => Promise) => { + flushers.current.set(account, flush); + return () => { flushers.current.delete(account); delete blockers.current[account]; + setBlocked(previous => { const next = { ...previous }; delete next[account]; return next; }); }; + }, []); + const finish = useCallback(async (action: () => void | Promise) => { + if (busy.current) return false; + busy.current = true; + setLeaving(true); + invalidate(); + try { + const results = await Promise.all([...flushers.current.values()].map(flush => flush())); + if (!results.every(Boolean) || Object.entries(blockers.current).some(([account, blocked]) => blocked && !flushers.current.has(account))) return false; + await action(); + return true; + } finally { busy.current = false; setLeaving(false); } + }, [invalidate]); + useEffect(() => parent?.(id, () => finish(() => undefined)), [parent, id, finish]); + return useMemo(() => ({ version, revision, blocked, leaving, invalidate, track, register, finish }), + [revision, blocked, leaving, invalidate, track, register, finish]); +} + +export const ProviderFormContext = createContext | null>(null); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index f4246ac5..25104bfb 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -1,4 +1,4 @@ -import { LLM_LABELS, prioritizeOrcaRouterModels } from './ProvidersSection'; +import { LLM_LABELS } from './ProvidersSection'; import { ASR_LABELS } from './shared'; import { presetsFor } from './ChannelList'; import { filterOrcaRouterModels } from '../../lib/ipc/asr-credentials'; @@ -7,6 +7,12 @@ const atlascloudPreset = LLM_LABELS.find((p) => p.id === 'atlascloud'); if (LLM_LABELS.find((p) => p.id === 'opencode')?.nameKey !== 'opencode') { throw new Error('OpenCode LLM label is missing'); } +if (LLM_LABELS.find(p => p.id === 'tencentTokenHub')?.nameKey !== 'tencentTokenHub') { + throw new Error('Tencent Cloud TokenHub LLM label is missing'); +} +if (ASR_LABELS.find(p => p.id === 'tencent-cloud')?.nameKey !== 'asrTencentCloud') { + throw new Error('Tencent Cloud ASR label is missing'); +} if (!atlascloudPreset) { throw new Error('Atlas Cloud LLM preset is missing'); @@ -45,22 +51,6 @@ if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optio ); } -const prioritizedOrcaRouterModels = prioritizeOrcaRouterModels([ - 'openai/gpt-5-mini', - 'orcarouter/fusion-mini', - 'anthropic/claude-haiku-4.5', - 'orcarouter/fusion-flash', -]); - -if (prioritizedOrcaRouterModels.join(',') !== [ - 'orcarouter/fusion-flash', - 'orcarouter/fusion-mini', - 'anthropic/claude-haiku-4.5', - 'openai/gpt-5-mini', -].join(',')) { - throw new Error(`unexpected OrcaRouter model ordering: ${prioritizedOrcaRouterModels.join(',')}`); -} - const protocolCatalog = [ { id: 'model/chat', supported_endpoint_types: ['openai'] }, { id: 'model/responses', supported_endpoint_types: ['openai-response'] }, diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 090fed13..cf4be8ec 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -3,6 +3,7 @@ import { useCallback, + useContext, useEffect, useId, useMemo, @@ -24,6 +25,7 @@ import { type ProviderDescriptor, } from '../../lib/ipc'; import { LlmProtocolFields } from './LlmProtocolFields'; +import { ProviderFormContext } from './ProviderForm'; import { emitSaved } from '../../lib/savedEvent'; import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; @@ -255,7 +257,10 @@ export function ChannelCredentialFields({ onUserMutation={onLlmMutation} onBlockedChange={trackField} onSaved={(changedAccounts) => { - if (providerType === 'orcarouter' && changedAccounts?.includes('ark.request_format')) { + if ( + providerType === 'orcarouter' && + changedAccounts?.includes('ark.request_format') + ) { setOrcarouterCatalogRevision((value) => value + 1); } onTested?.(); @@ -336,23 +341,23 @@ export function ChannelCredentialFields({ } /> ) : ( - - } - /> + + } + /> )} {['custom', 'custom_responses', 'custom_messages'].includes(providerType) && ( + + + +
+ +
+ +
+ {t('settings.providers.tencentCloudNote')} +
+ setAsrModelRevision((v) => v + 1)} + onTested={onTested} + onUserMutation={onUserMutation} + /> + + ); + } + // 本地引擎(qwen3 / sherpa / foundry / Apple 语音)没有 key 与地址;模型的下载与 // 切换仍由「高级 → 本地模型」里的 负责,这里只说明一句。 if (descriptor?.authRequirement === 'none') { @@ -533,12 +597,24 @@ export function ChannelCredentialFields({ if (providerType === 'orcarouter') { return ( <> - - + + - setAsrModelRevision(v => v + 1)} onTested={onTested} - onUserMutation={onUserMutation} showFetchModels={false} /> + setAsrModelRevision((v) => v + 1)} + onTested={onTested} + onUserMutation={onUserMutation} + showFetchModels={false} + /> ); } @@ -978,15 +1060,33 @@ function CatalogModelField({ return ( -
-
+
+
void applyModel(model)} options={models.map((model) => ({ value: model, label: model }))} - placeholder={status === 'loading' - ? t('settings.providers.loadingModels') - : t('settings.providers.selectModel')} + placeholder={ + status === 'loading' + ? t('settings.providers.loadingModels') + : t('settings.providers.selectModel') + } disabled={status === 'loading' || models.length === 0} searchable searchPlaceholder={t('settings.providers.searchModels')} @@ -1014,13 +1114,26 @@ function CatalogModelField({ {trailing}
- + {message} - {t(kind === 'asr' - ? 'settings.providers.orcarouterAsrCatalogHint' - : 'settings.providers.orcarouterCatalogHint')} + {t( + kind === 'asr' + ? 'settings.providers.orcarouterAsrCatalogHint' + : 'settings.providers.orcarouterCatalogHint', + )}
@@ -1332,6 +1445,8 @@ function CredentialField({ }: CredentialFieldProps) { const fieldId = useId(); const { t } = useTranslation(); + // 宿主 ChannelModal 关闭/切换前收敛本字段未落盘的防抖写入(#1044 语义)。 + const form = useContext(ProviderFormContext); const [value, setValue] = useState(''); const [revealed, setRevealed] = useState(false); const [loaded, setLoaded] = useState(false); @@ -1344,6 +1459,11 @@ function CredentialField({ account, !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError', ); + form?.track( + account, + !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError', + ); + // eslint-disable-next-line react-hooks/exhaustive-deps -- form?.track 与 onBlockedChange 同为稳定引用 }, [account, loaded, dirty, status, onBlockedChange]); const debounceRef = useRef(null); @@ -1351,6 +1471,14 @@ function CredentialField({ const mountedRef = useRef(true); const editRevision = useRef(0); const saveQueue = useRef>(Promise.resolve()); + const lastWriteOk = useRef(true); + const flushRef = useRef<() => Promise>(() => Promise.resolve(true)); + // 离开前 flush:冲掉防抖里未发的编辑并等待在途写完成;返回 false 阻止关闭。 + useEffect( + () => form?.register(account, () => flushRef.current()), + // eslint-disable-next-line react-hooks/exhaustive-deps -- register 为稳定引用,account 变更即重挂 + [account], + ); const markMutation = () => { editRevision.current += 1; onUserMutation?.(); @@ -1429,14 +1557,25 @@ function CredentialField({ saveQueue.current = write; await write; if (!mountedRef.current || revision !== editRevision.current) return; + lastWriteOk.current = true; setDirty(false); showTemporaryStatus('saved'); } catch (error) { if (!mountedRef.current || revision !== editRevision.current) return; + lastWriteOk.current = false; console.error('[settings] failed to save credential', account, error); showTemporaryStatus('saveError'); } }; + flushRef.current = async () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + if (loaded && dirty) await save(value, true); + await saveQueue.current.catch(() => undefined); + return lastWriteOk.current; + }; const handleChange = (e: React.ChangeEvent) => { markMutation(); @@ -1482,7 +1621,7 @@ function CredentialField({ }; const inputType = mask && !revealed ? 'password' : 'text'; - const disabled = !loaded; + const disabled = !loaded || form?.leaving; const showInsecureEndpointWarning = (account === 'ark.endpoint' || account === 'asr.endpoint' || account === 'omni.endpoint') && value.trim().toLowerCase().startsWith('http://'); diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index c02aeec0..373d8e28 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -262,17 +262,20 @@ export function RecordingInputSection() { { await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + // setDictationHotkey 后端已持久化并广播偏好变更;这里 refresh 拉新, + // 避免用本地快照整包覆盖期间的其他偏好改动。 + await refresh(); }} onReset={async () => { const binding = defaultDictationHotkey(); await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} /> diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index 163ddf55..8a7eb65b 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -102,7 +102,7 @@ export function RemoteInputSection() { const mode = prefs.remoteInputDefaultMode ?? 'toggle'; const viewState = getRemoteInputViewState(enabled, status, startError); - // 无效或非正数草稿还原显示;有效数值取整并限制在 [1024, 65535]。 + // 提交端口草稿:非法(非有限数/越界离谱)则丢弃还原显示,合法则取整并 clamp 到 [1024, 65535]。 const commitPort = () => { if (portDraft == null) return; const n = Math.round(Number(portDraft)); @@ -197,7 +197,10 @@ export function RemoteInputSection() { }} > {status.urls.map((u) => ( -
+
{copied === u ? '✓' : '⧉'} +
))}
diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 9b70918f..13b553e9 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -31,7 +31,7 @@ import { getStylePackPresentation } from '../../lib/stylePackPresentation'; export function ShortcutsSection() { const { t } = useTranslation(); const os = detectOS(); - const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, hotkey, refresh, updatePrefs: savePrefs } = useHotkeySettings(); const [platformCaps, setPlatformCaps] = useState(null); const [stylePacks, setStylePacks] = useState([]); // 新增行的草稿状态:先选风格包、再录快捷键,两者齐了才真正落库。 @@ -111,12 +111,13 @@ export function ShortcutsSection() { { await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} />
diff --git a/openless-all/app/src/pages/settings/credentialDraft.test.ts b/openless-all/app/src/pages/settings/credentialDraft.test.ts new file mode 100644 index 00000000..4a2c663c --- /dev/null +++ b/openless-all/app/src/pages/settings/credentialDraft.test.ts @@ -0,0 +1,91 @@ +import { CredentialDraft } from './credentialDraft'; + +function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +async function main() { + const writes: string[] = []; + const first = deferred(); + const second = deferred(); + const draft = new CredentialDraft(async () => 'saved', value => { + writes.push(`channel-A:ark.model_id:${value}`); + return writes.length === 1 ? first.promise : second.promise; + }); + await draft.load(); + assert(draft.snapshot().value === 'saved' && writes.length === 0, 'reading must not write'); + draft.edit('m1'); + const save1 = draft.flush(); + assert(draft.flush() === save1, 'blur and close must share the pending save'); + await tick(); + draft.edit('m2'); + const save2 = draft.flush(); + assert(writes.length === 1, 'm2 must wait for m1'); + first.reject(new Error('old failure')); + await save1; + await tick(); + assert(draft.snapshot().value === 'm2' && draft.snapshot().status === 'saving', 'old failure must not replace new status'); + assert(writes.join(',') === 'channel-A:ark.model_id:m1,channel-A:ark.model_id:m2', 'writes must capture account and order'); + second.resolve(); + assert(await save2, 'new value must save after old failure'); + assert(!draft.snapshot().dirty && draft.snapshot().value === 'm2', 'only latest save clears dirty'); + + const sequence: string[] = []; + const closeDraft = new CredentialDraft(async () => '', async value => { sequence.push(value); }); + await closeDraft.load(); + closeDraft.edit('debounced'); closeDraft.schedule(); + closeDraft.edit('selected'); await closeDraft.flush(); + await new Promise(resolve => setTimeout(resolve, 350)); + assert(sequence.join(',') === 'selected', 'selection cancels an older debounce'); + closeDraft.edit('before-switch'); closeDraft.schedule(); + if (await closeDraft.flush()) sequence.push('new-provider-default'); + closeDraft.dispose(); + assert(sequence.slice(-2).join(',') === 'before-switch,new-provider-default', 'switch must follow the pending draft'); + + let failed = true; + const retry = new CredentialDraft(async () => 'original', async () => { if (failed) throw new Error('save'); }); + await retry.load(); retry.edit('custom'); + assert(!await retry.flush() && retry.snapshot().dirty, 'failed write must block close and retain the draft'); + failed = false; + assert(await retry.flush() && !retry.snapshot().dirty, 'same edit must be retryable'); + + const authWrite = deferred(); + const advancedWrite = deferred(); + let advancedAttempts = 0; + const authMode = new CredentialDraft(async () => '', () => authWrite.promise); + const advanced = new CredentialDraft(async () => '{}', () => ( + ++advancedAttempts === 1 ? advancedWrite.promise : Promise.resolve() + )); + await Promise.all([authMode.load(), advanced.load()]); + authMode.edit('api_key'); + advanced.edit('{"verboseJson":true,"chunkDurationMs":30000,"enableItn":false}'); + let leaveFinished = false; + const leave = Promise.all([authMode.flush(), advanced.flush()]) + .then(results => results.every(Boolean)) + .finally(() => { leaveFinished = true; }); + await tick(); + assert(!leaveFinished, 'leaving must wait for every registered credential write'); + authWrite.resolve(); + advancedWrite.reject(new Error('save')); + assert(!await leave, 'one failed credential write must block leaving'); + assert(authMode.snapshot().value === 'api_key' && !authMode.snapshot().dirty, 'successful auth mode must remain saved'); + assert(advanced.snapshot().value.includes('30000') && advanced.snapshot().dirty, 'failed advanced config must retain its draft'); + assert(await advanced.flush() && !advanced.snapshot().dirty, 'advanced config must retry the retained value'); + + const oldRead = deferred(); + let reads = 0; + const reading = new CredentialDraft(() => ++reads === 1 ? oldRead.promise : Promise.resolve('new'), async () => undefined); + const initial = reading.load(); + reading.edit('too-early'); + assert(reading.snapshot().value === '', 'reading must disable editing'); + reading.dispose(); await reading.load(); reading.edit('typed'); + oldRead.resolve('stale'); await initial; + assert(reading.snapshot().value === 'typed', 'old read must not replace a new scope or edit'); + draft.dispose(); retry.dispose(); authMode.dispose(); advanced.dispose(); reading.dispose(); +} +void main(); diff --git a/openless-all/app/src/pages/settings/credentialDraft.ts b/openless-all/app/src/pages/settings/credentialDraft.ts new file mode 100644 index 00000000..65cc21ea --- /dev/null +++ b/openless-all/app/src/pages/settings/credentialDraft.ts @@ -0,0 +1,64 @@ +type Status = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError'; + +/** 一个凭据槽的草稿和串行写入;调用方在更换作用域前等待 flush。 */ +export class CredentialDraft { + private state = { value: '', loaded: false, dirty: false, status: 'idle' as Status }; + private listeners = new Set<() => void>(); + private revision = 0; + private generation = 0; + private timer: ReturnType | undefined; + private queue: Promise = Promise.resolve(); + private pending: { revision: number; promise: Promise } | undefined; + constructor(private read: () => Promise, private write: (value: string) => Promise) {} + snapshot = () => this.state; + subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + private update(next: Partial) { + this.state = { ...this.state, ...next }; + this.listeners.forEach(listener => listener()); + } + async load() { + const generation = ++this.generation; + const revision = this.revision; + try { + const value = await this.read(); + if (generation === this.generation && revision === this.revision) this.update({ value: value ?? '', loaded: true }); + } catch { + if (generation === this.generation) this.update({ status: 'readError' }); + } + } + edit(value: string) { + if (!this.state.loaded) return; + clearTimeout(this.timer); + this.revision += 1; + this.update({ value, dirty: true, status: 'idle' }); + } + schedule() { + clearTimeout(this.timer); + const revision = this.revision; + this.timer = setTimeout(() => { if (revision === this.revision) void this.flush(); }, 300); + } + pause() { clearTimeout(this.timer); } + flush = (): Promise => { + clearTimeout(this.timer); + if (!this.state.dirty) return Promise.resolve(true); + if (!this.state.loaded) return Promise.resolve(false); + const revision = this.revision; + if (this.pending?.revision === revision) return this.pending.promise; + const generation = this.generation; + const value = this.state.value; + this.update({ status: 'saving' }); + const write = this.queue.catch(() => undefined).then(() => this.write(value)); + this.queue = write; + const promise = write.then(() => { + if (revision !== this.revision || generation !== this.generation) return false; + this.update({ dirty: false, status: 'saved' }); + return true; + }, () => { + if (revision === this.revision && generation === this.generation) this.update({ status: 'saveError' }); + return false; + }).finally(() => { if (this.pending?.revision === revision) this.pending = undefined; }); + this.pending = { revision, promise }; + return promise; + }; + dispose() { this.generation += 1; clearTimeout(this.timer); } +} diff --git a/openless-all/app/src/pages/settings/modelCatalog.test.ts b/openless-all/app/src/pages/settings/modelCatalog.test.ts new file mode 100644 index 00000000..1df109ae --- /dev/null +++ b/openless-all/app/src/pages/settings/modelCatalog.test.ts @@ -0,0 +1,47 @@ +import { ModelCatalog } from './modelCatalog'; +import { CredentialDraft } from './credentialDraft'; + +function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); } +function deferred() { + let resolve!: (value: { models: string[] }) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise<{ models: string[] }>((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +async function main() { + const catalog = new ModelCatalog(); + let version = 0; + const current = () => version; + for (const failOld of [false, true]) { + const a = deferred(); const b = deferred(); + const first = catalog.load(version, () => a.promise, current); + const second = catalog.load(version, () => b.promise, current); + b.resolve({ models: ['new'] }); await second; + if (failOld) a.reject(new Error('old authentication error')); else a.resolve({ models: ['old'] }); + await first; + assert(catalog.snapshot()?.models.join() === 'new' && catalog.snapshot()?.status === 'success', 'old response or error must be ignored'); + } + const editing = deferred(); + const pending = catalog.load(version, () => editing.promise, current); + version += 1; // endpoint/key/protocol edit invalidates immediately, before saving. + editing.resolve({ models: ['stale-config'] }); await pending; + assert(catalog.snapshot()?.version !== version, 'old candidates must not belong to the edited configuration'); + const unmounted = deferred(); + const closed = catalog.load(version, () => unmounted.promise, current); + catalog.invalidate(); unmounted.reject(new Error('after close')); await closed; + assert(catalog.snapshot()?.status === 'loading', 'unmounted error must not publish'); + + const writes: string[] = []; + const draft = new CredentialDraft(async () => 'missing-from-catalog', async value => { writes.push(value); }); + await draft.load(); + const directory = deferred(); + const fetching = catalog.load(version, () => directory.promise, current); + draft.edit('typed-during-request'); await draft.flush(); + directory.resolve({ models: ['different-model'] }); await fetching; + await catalog.load(version, async () => ({ models: [] }), current); + await catalog.load(version, async () => { throw new Error('401'); }, current); + assert(draft.snapshot().value === 'typed-during-request' && writes.join() === 'typed-during-request', 'success, empty and failure cannot write or clear the model'); + assert(!draft.snapshot().dirty, 'catalog failure cannot block a saved manual model'); + draft.dispose(); +} +void main(); diff --git a/openless-all/app/src/pages/settings/modelCatalog.ts b/openless-all/app/src/pages/settings/modelCatalog.ts new file mode 100644 index 00000000..3f13e949 --- /dev/null +++ b/openless-all/app/src/pages/settings/modelCatalog.ts @@ -0,0 +1,23 @@ +type CatalogState = { version: number; models: string[]; status: 'loading' | 'success' | 'empty' | 'error'; error?: unknown }; + +/** 目录响应只改变候选;配置版本与请求序号共同决定响应是否有效。 */ +export class ModelCatalog { + private state: CatalogState | null = null; + private request = 0; + private listeners = new Set<() => void>(); + snapshot = () => this.state; + subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + private update(state: CatalogState) { this.state = state; this.listeners.forEach(listener => listener()); } + invalidate() { this.request += 1; } + async load(version: number, read: () => Promise<{ models: string[] }>, currentVersion: () => number) { + const request = ++this.request; + const current = () => request === this.request && version === currentVersion(); + this.update({ version, models: [], status: 'loading' }); + try { + const result = await read(); + if (current()) this.update({ version, models: result.models, status: result.models.length ? 'success' : 'empty' }); + } catch (error) { + if (current()) this.update({ version, models: [], status: 'error', error }); + } + } +} diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 1fb1a665..50bca83d 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -270,6 +270,7 @@ export const ASR_LABELS = [ { id: 'openai-compatible', nameKey: 'asrOpenAiCompatible' }, { id: 'xiaomi-mimo-asr', nameKey: 'asrXiaomiMimo' }, { id: 'iflytek', nameKey: 'asrIflytek' }, + { id: 'tencent-cloud', nameKey: 'asrTencentCloud' }, { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper' }, { id: 'local-whisper', nameKey: 'asrLocalWhisper' }, { id: 'sherpa-onnx-local', nameKey: 'asrSherpaOnnxLocal' },