merge: 汇入 origin/beta(#1037 远程输入持久 CA、#1040 F13-F20/Mac 听写键、#1020 腾讯云实时语音、#1044 统一模型字段与异步保存隔离)并完成 2.0 UI 适配
冲突解决原则:2.0 重写的界面以本地为准,云端功能语义全部移植—— - ProviderForm 离开收敛接入 SettingsModal / ChannelList / LlmProtocolFields / CredentialField(关闭、切节、换供应商前冲掉防抖写入并等待在途写完成) - 腾讯云三段式密钥 + 固定模型档分支移植进 2.0 ChannelCredentialFields;OAuth 静态模型走描述符 - Mac 听写键进入 ShortcutRecorder(allowMacDictationKey)并改用 refresh 拉新偏好 - 远程输入手机页新证书信任流程(持久本地 CA + 描述文件/CA 下载 + 复制链接),de/es/fr 补齐全套新文案 - 纯格式分歧文件取云端后 prettier 归一;双方新增内容(i18n、测试)两侧保留
This commit is contained in:
commit
0ac0fbd391
72 files changed
+5109
-990
No files matched your search
Generated
+112
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -0,0 +1,1000 @@
|
||||
//! 腾讯云实时语音识别 WebSocket 客户端。
|
||||
//!
|
||||
//! 官方文档:https://cloud.tencent.com/document/api/1093/48982
|
||||
//!
|
||||
//! 协议要点:
|
||||
//! - 端点:`wss://asr.cloud.tencent.com/asr/v2/<appid>`;
|
||||
//! - 鉴权:查询参数按字典序拼接后,以 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<MaybeTlsStream<TcpStream>>;
|
||||
type WsSink = futures_util::stream::SplitSink<WsStream, Message>;
|
||||
type SharedWriter = Arc<AsyncMutex<Option<WsSink>>>;
|
||||
|
||||
enum SendItem {
|
||||
Audio(Vec<u8>),
|
||||
End(oneshot::Sender<Result<(), TencentCloudASRError>>),
|
||||
}
|
||||
|
||||
#[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<u8>,
|
||||
bytes_sent: u64,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
final_tx: Option<oneshot::Sender<Result<RawTranscript, TencentCloudASRError>>>,
|
||||
final_segments: BTreeMap<i64, String>,
|
||||
partial_segments: BTreeMap<i64, String>,
|
||||
last_result_text: String,
|
||||
}
|
||||
|
||||
pub struct TencentCloudStreamingASR {
|
||||
credentials: TencentCloudCredentials,
|
||||
endpoint: String,
|
||||
task_spawner: Arc<dyn TaskSpawner>,
|
||||
state: ParkingMutex<SyncState>,
|
||||
writer: SharedWriter,
|
||||
final_rx: ParkingMutex<Option<oneshot::Receiver<Result<RawTranscript, TencentCloudASRError>>>>,
|
||||
handshake_tx: ParkingMutex<Option<oneshot::Sender<Result<(), TencentCloudASRError>>>>,
|
||||
send_tx: ParkingMutex<Option<mpsc::UnboundedSender<SendItem>>>,
|
||||
partial_sink: ParkingMutex<Option<Arc<dyn TextStreamSink>>>,
|
||||
}
|
||||
|
||||
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<dyn TaskSpawner>,
|
||||
) -> Self {
|
||||
Self::with_endpoint(credentials, task_spawner, DEFAULT_ENDPOINT.to_string())
|
||||
}
|
||||
|
||||
fn with_endpoint(
|
||||
credentials: TencentCloudCredentials,
|
||||
task_spawner: Arc<dyn TaskSpawner>,
|
||||
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<dyn TextStreamSink>) {
|
||||
*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<Self>) -> 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::<SendItem>();
|
||||
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<RawTranscript, TencentCloudASRError> {
|
||||
self.await_final_result_with_timeout(FINAL_RESULT_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn await_final_result_with_timeout(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
) -> Result<RawTranscript, TencentCloudASRError> {
|
||||
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<u8>) {
|
||||
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::<Vec<String>>(),
|
||||
)
|
||||
};
|
||||
(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::<Vec<u8>>();
|
||||
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<u8>) -> 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, String>) -> String {
|
||||
params
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<String>>()
|
||||
.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::<Sha1>::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<tokio::sync::Semaphore>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<Qwen3RealtimeASR>),
|
||||
StepfunRealtime(Arc<StepfunRealtimeASR>),
|
||||
Xfyun(Arc<XfyunStreamingASR>),
|
||||
TencentCloud(Arc<TencentCloudStreamingASR>),
|
||||
}
|
||||
|
||||
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<dyn CredentialStore>,
|
||||
active: Arc<Mutex<HashMap<SessionId, Arc<ProviderCancellation>>>>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -99,6 +99,9 @@ struct LegacyEntry {
|
||||
advanced_config: Option<String>,
|
||||
xfyun_app_id: Option<String>,
|
||||
xfyun_api_key: Option<String>,
|
||||
tencent_cloud_app_id: Option<String>,
|
||||
tencent_cloud_secret_id: Option<String>,
|
||||
tencent_cloud_secret_key: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
extra_headers: Option<BTreeMap<String, String>>,
|
||||
request_format: Option<String>,
|
||||
@@ -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,
|
||||
][..],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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<Self, BackendError> {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 兜底
|
||||
|
||||
@@ -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<TokenHubChatModelPolicy> {
|
||||
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<ProviderDescriptor> = 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::<Vec<_>>();
|
||||
assert_eq!(fixture, actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_like_volc_resource_ids_are_not_attributed() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -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<OrcaRouterCatalogFilter>,
|
||||
tokenhub_chat_only: bool,
|
||||
) -> Result<Vec<String>, 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":[
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -289,71 +289,8 @@ fn access_urls(port: u16) -> Vec<String> {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn load_or_generate_certificate(
|
||||
directory: &std::path::Path,
|
||||
sans: &[String],
|
||||
) -> Result<(Vec<u8>, 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::<std::collections::HashSet<_>>();
|
||||
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<u8>,
|
||||
key: rustls::pki_types::PrivateKeyDer<'static>,
|
||||
) -> Result<Arc<rustls::ServerConfig>, 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<WebState>) -> Router {
|
||||
@@ -387,14 +324,32 @@ fn router(state: Arc<WebState>) -> Router {
|
||||
"/cert.cer",
|
||||
get(|State(state): State<Arc<WebState>>| 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<Arc<WebState>>| 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<LinuxRemoteServerHandle, BackendError> {
|
||||
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}")))?;
|
||||
|
||||
Generated
+94
-1
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+184
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
// Exercise the production TLS implementation without linking the desktop UI.
|
||||
#[path = "../../src/remote_server/tls_identity.rs"]
|
||||
mod tls_identity;
|
||||
@@ -50,6 +50,8 @@ pub struct ComboHotkeyMonitor {
|
||||
|
||||
struct Inner {
|
||||
registered: Mutex<Option<RegisteredHotkey>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_dictation: Mutex<Option<crate::macos_dictation_key::Monitor>>,
|
||||
tx: Sender<ComboHotkeyEvent>,
|
||||
}
|
||||
|
||||
@@ -67,6 +69,18 @@ impl ComboHotkeyMonitor {
|
||||
binding: ShortcutBinding,
|
||||
tx: Sender<ComboHotkeyEvent>,
|
||||
) -> Result<Self, ComboHotkeyError> {
|
||||
#[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<GlobalHotKeyEvent>, tx: Sender<Comb
|
||||
|
||||
/// 测试一个组合键是否可以注册(不实际注册,仅验证格式)。
|
||||
pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ComboHotkeyError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
if is_native_dictation(binding) {
|
||||
return Ok(());
|
||||
}
|
||||
parse_binding(binding)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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<CredentialAccount, String> {
|
||||
"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),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 原生通知(空闲零唤醒),
|
||||
|
||||
@@ -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<ComboHotkeyEvent>,
|
||||
stop: Arc<AtomicBool>,
|
||||
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<AtomicBool>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
impl Monitor {
|
||||
pub fn start(tx: Sender<ComboHotkeyEvent>) -> Result<Self, String> {
|
||||
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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,15 @@ struct CredsAsrEntry {
|
||||
/// 讯飞实时语音转写 APIKey(接口密钥)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
xfyunApiKey: Option<String>,
|
||||
/// 腾讯云账号 AppID(实时语音识别 WebSocket 路径参数)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tencentCloudAppId: Option<String>,
|
||||
/// 腾讯云 API 密钥 SecretID。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tencentCloudSecretId: Option<String>,
|
||||
/// 腾讯云 API 密钥 SecretKey。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tencentCloudSecretKey: Option<String>,
|
||||
}
|
||||
|
||||
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<String
|
||||
CredentialAccount::AsrAdvancedConfig => 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<String>,
|
||||
pub xfyun_app_id: Option<String>,
|
||||
pub xfyun_api_key: Option<String>,
|
||||
pub tencent_cloud_app_id: Option<String>,
|
||||
pub tencent_cloud_secret_id: Option<String>,
|
||||
pub tencent_cloud_secret_key: Option<String>,
|
||||
pub ark_api_key: Option<String>,
|
||||
pub ark_model_id: Option<String>,
|
||||
pub ark_endpoint: Option<String>,
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -44,19 +44,29 @@
|
||||
<button id="btn-connect" class="btn btn-primary" data-i18n="btnConnect">连接</button>
|
||||
</div>
|
||||
|
||||
<!-- 连接帮助:证书信任引导(手机上直接照做)。暂时隐藏(用户要求),需要时去掉 hidden 即可恢复。 -->
|
||||
<div class="help" hidden>
|
||||
<div class="help-title" data-i18n="helpTitle">连不上?多半是手机没信任证书</div>
|
||||
<p class="help-step" data-i18n="helpAndroid">
|
||||
① 安卓 /
|
||||
一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。
|
||||
</p>
|
||||
<!-- 首次信任设置用 <details> 折叠展示:可见但不遮挡配对表单。信任一次后
|
||||
重启 / 换 IP 都不再失效(持久本地 CA,见 tls_identity.rs)。 -->
|
||||
<details class="help">
|
||||
<summary class="help-title" data-i18n="helpTitle">首次设置:信任此电脑</summary>
|
||||
<p class="help-step" data-i18n="helpIos">
|
||||
② iOS Safari 必须装证书:点下方按钮下载并安装描述文件,然后到「设置 → 通用 → 关于本机 →
|
||||
证书信任设置」里打开它的“完全信任”,再回来连接。
|
||||
iPhone / iPad:下载描述文件,打开“设置 → 通用 → VPN 与设备管理”安装;再到“通用 → 关于本机 →
|
||||
证书信任设置”,为 OpenLess Remote Input CA 打开“完全信任”,返回 Safari
|
||||
刷新。每台电脑只需设置一次;旧版证书需要重新安装。
|
||||
</p>
|
||||
<p class="help-step" data-i18n="helpAndroid">
|
||||
安卓:下载 CA 证书,在系统设置的“安装证书 → CA 证书”中安装,再回到本页。菜单名称因设备而异。
|
||||
</p>
|
||||
<p class="help-step" data-i18n="helpTrustWarning">
|
||||
只安装你自己电脑提供的 OpenLess
|
||||
根证书。它具备签发证书的能力,私钥保存在这台电脑;不再使用时请从手机移除。
|
||||
</p>
|
||||
<div class="help-actions">
|
||||
<a class="help-link" href="/cert.cer" data-i18n="helpDownloadCert">⬇ 下载并安装证书</a>
|
||||
<a class="help-link" href="/cert.mobileconfig" data-i18n="helpDownloadCert">
|
||||
↓ iPhone:下载描述文件
|
||||
</a>
|
||||
<a class="help-link help-link-ghost" href="/cert.cer" data-i18n="helpDownloadAndroid">
|
||||
↓ 安卓:下载 CA 证书
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="help-link help-link-ghost"
|
||||
@@ -66,7 +76,7 @@
|
||||
⧉ 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- ===== 录音屏 ===== -->
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> {
|
||||
|
||||
// ───────────────────────── TLS ─────────────────────────
|
||||
|
||||
/// 自签名证书:持久化到磁盘并跨重启复用。否则每次启动证书都变 —— 手机(尤其 iOS
|
||||
/// Safari)上一次信任过的证书立刻失效,wss 握手静默挂起,表现为"连接中"卡死。仅当
|
||||
/// 磁盘无证书 / 解析失败 / 当前局域网 IP 不在已存 SAN 列表里(换了网络)时才重新生成。
|
||||
/// 返回 (证书 DER 原始字节, 私钥)。
|
||||
fn load_or_generate_cert(
|
||||
dir: Option<&std::path::Path>,
|
||||
sans: &[String],
|
||||
) -> Result<(Vec<u8>, rustls::pki_types::PrivateKeyDer<'static>), String> {
|
||||
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
// 文件名带 schema 版本:证书结构变更(v4 改回非 CA 服务器证书)时旧文件自动失效、重新生成。
|
||||
const CERT_FILE: &str = "remote-cert-v4.der";
|
||||
const KEY_FILE: &str = "remote-key-v4.der";
|
||||
const SANS_FILE: &str = "remote-cert-sans-v4.txt";
|
||||
if let Some(dir) = dir {
|
||||
if let (Ok(cert), Ok(key), Ok(saved)) = (
|
||||
std::fs::read(dir.join(CERT_FILE)),
|
||||
std::fs::read(dir.join(KEY_FILE)),
|
||||
std::fs::read_to_string(dir.join(SANS_FILE)),
|
||||
) {
|
||||
let saved_set: std::collections::HashSet<&str> = saved.lines().collect();
|
||||
// 当前需要的 SAN 都在已存证书里 → 复用,证书保持稳定(手机信任一次长期有效)。
|
||||
if sans.iter().all(|s| saved_set.contains(s.as_str())) {
|
||||
log::info!("[remote-input] reusing persisted self-signed server cert");
|
||||
return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key))));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 生成自签名服务器证书(SAN 含本机各局域网 IP)。主路径是浏览器页面级
|
||||
// “继续访问/访问此网站”例外;/cert.cer 与 /cert.mobileconfig 是手机系统级
|
||||
// 安装信任的兜底(部分浏览器的 wss 不复用页面级例外时使用)。
|
||||
let (cert_der, key_der) = {
|
||||
use rcgen::{
|
||||
CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair,
|
||||
KeyUsagePurpose,
|
||||
};
|
||||
let mut params =
|
||||
CertificateParams::new(sans.to_vec()).map_err(|e| format!("rcgen params: {e}"))?;
|
||||
// 关键:做成普通服务器证书(非 CA,rcgen 默认即 NoCa)。iOS Safari 用页面级
|
||||
// “访问此网站”即可信任、无需安装证书 —— 这正是之前一直能用的方式。把证书做成 CA
|
||||
// 反而会让 iOS 拒绝页面级例外(CA 不能直接当服务器证书),导致一直超时。
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "OpenLess Remote Input");
|
||||
dn.push(DnType::OrganizationName, "OpenLess");
|
||||
params.distinguished_name = dn;
|
||||
params.key_usages.push(KeyUsagePurpose::DigitalSignature);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ServerAuth);
|
||||
let key_pair = KeyPair::generate().map_err(|e| format!("rcgen keypair: {e}"))?;
|
||||
let cert = params
|
||||
.self_signed(&key_pair)
|
||||
.map_err(|e| format!("rcgen self_signed: {e}"))?;
|
||||
(cert.der().as_ref().to_vec(), key_pair.serialize_der())
|
||||
};
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let _ = std::fs::write(dir.join(CERT_FILE), &cert_der);
|
||||
let _ = std::fs::write(dir.join(KEY_FILE), &key_der);
|
||||
// 私钥收紧为 0600:app 配置目录通常已是用户私有,但多用户/共享主机上
|
||||
// 默认 umask 可能给到组/其他用户可读。Windows 下 %APPDATA% 的 ACL
|
||||
// 本身仅限本用户,无对应权限位可设。
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(
|
||||
dir.join(KEY_FILE),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
);
|
||||
}
|
||||
let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n"));
|
||||
log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})");
|
||||
}
|
||||
Ok((
|
||||
cert_der,
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_server_config(
|
||||
cert_der: Vec<u8>,
|
||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||
) -> Result<Arc<rustls::ServerConfig>, String> {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let config = rustls::ServerConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| format!("tls protocol: {e}"))?
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![rustls::pki_types::CertificateDer::from(cert_der)],
|
||||
key_der,
|
||||
)
|
||||
.map_err(|e| format!("tls cert: {e}"))?;
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
mod tls_identity;
|
||||
|
||||
// ───────────────────────── 启动 ─────────────────────────
|
||||
|
||||
struct WsState {
|
||||
backend: Arc<openless_core::OpenLessBackend>,
|
||||
/// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。
|
||||
/// Public CA certificate offered for phone installation; never the server leaf or key.
|
||||
cert_der: Vec<u8>,
|
||||
/// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。
|
||||
conn_shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
@@ -336,10 +244,13 @@ fn build_router(state: Arc<WsState>) -> Router {
|
||||
"/cert.cer",
|
||||
get(|State(state): State<Arc<WsState>>| 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<Arc<WsState>>) -> 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<Arc<WsState>>) -> impl IntoResponse {
|
||||
let b64 = base64_encode(&state.cert_der);
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict><key>PayloadContent</key><array><dict><key>PayloadCertificateFileName</key><string>openless.cer</string><key>PayloadContent</key><data>{b64}</data><key>PayloadType</key><string>com.apple.security.root</string><key>PayloadIdentifier</key><string>com.openless.remote-input.cert</string><key>PayloadUUID</key><string>A1B2C3D4-0001-4000-8000-000000000001</string><key>PayloadVersion</key><integer>1</integer><key>PayloadDisplayName</key><string>OpenLess Remote Input Certificate</string></dict></array><key>PayloadDisplayName</key><string>OpenLess Remote Input</string><key>PayloadIdentifier</key><string>com.openless.remote-input</string><key>PayloadType</key><string>Configuration</string><key>PayloadUUID</key><string>A1B2C3D4-0002-4000-8000-000000000002</string><key>PayloadVersion</key><integer>1</integer></dict></plist>"#,
|
||||
b64 = b64
|
||||
);
|
||||
(
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"application/x-apple-aspen-config",
|
||||
)],
|
||||
xml,
|
||||
[
|
||||
(
|
||||
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<RemoteServerHandle, String
|
||||
for ip in local_lan_ipv4s() {
|
||||
sans.push(ip.to_string());
|
||||
}
|
||||
// 证书目录用 app 配置目录(跨重启稳定);拿不到则退回内存生成(不持久化)。
|
||||
let cert_dir = cfg.app.path().app_config_dir().ok();
|
||||
let (cert_der, key_der) = load_or_generate_cert(cert_dir.as_deref(), &sans)?;
|
||||
let rustls_config = build_server_config(cert_der.clone(), key_der)?;
|
||||
let acceptor = TlsAcceptor::from(rustls_config);
|
||||
// Trust must survive restarts and upgrades; refuse ephemeral identities.
|
||||
let cert_dir = cfg
|
||||
.app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|error| format!("remote TLS config directory: {error}"))?;
|
||||
let identity = tls_identity::load_or_create(&cert_dir, &sans)?;
|
||||
let cert_der = identity.trust_cert;
|
||||
let acceptor = TlsAcceptor::from(identity.server_config);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], cfg.port));
|
||||
let listener = TcpListener::bind(addr).await.map_err(|e| {
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
//! A persistent, per-installation CA and a separate short-lived TLS leaf.
|
||||
//! Phones trust the CA once; changes to LAN or virtual-adapter IPs only reissue
|
||||
//! the leaf. Never silently replace an unreadable CA: that revokes phone trust.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa,
|
||||
KeyPair, KeyUsagePurpose,
|
||||
};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
const IDENTITY_FILE: &str = "remote-tls-identity-v1.json";
|
||||
const MAX_IDENTITY_BYTES: u64 = 64 * 1024;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StoredIdentity {
|
||||
ca_cert: Vec<u8>,
|
||||
ca_key: Vec<u8>,
|
||||
leaf_cert: Vec<u8>,
|
||||
leaf_key: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) struct TlsIdentity {
|
||||
/// Only this public CA certificate is offered for phone installation.
|
||||
pub trust_cert: Vec<u8>,
|
||||
pub server_config: Arc<rustls::ServerConfig>,
|
||||
}
|
||||
|
||||
fn key_der(bytes: Vec<u8>) -> 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, String> {
|
||||
CertificateParams::from_ca_cert_der(&CertificateDer::from(cert))
|
||||
.map_err(|error| format!("invalid remote TLS certificate: {error}"))
|
||||
}
|
||||
|
||||
fn read_identity(path: &Path) -> Result<Option<StoredIdentity>, 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<StoredIdentity, String> {
|
||||
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<TlsIdentity, String> {
|
||||
load_at(directory, sans, OffsetDateTime::now_utc())
|
||||
}
|
||||
|
||||
fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result<TlsIdentity, String> {
|
||||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>PayloadContent</key><array><dict>
|
||||
<key>PayloadCertificateFileName</key><string>openless-ca.cer</string>
|
||||
<key>PayloadContent</key><data>{b64}</data>
|
||||
<key>PayloadType</key><string>com.apple.security.root</string>
|
||||
<key>PayloadIdentifier</key><string>com.openless.remote-input.{fingerprint}.cert</string>
|
||||
<key>PayloadUUID</key><string>{cert_uuid}</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadDisplayName</key><string>OpenLess Remote Input CA ({short})</string>
|
||||
</dict></array>
|
||||
<key>PayloadDisplayName</key><string>OpenLess Remote Input ({short})</string>
|
||||
<key>PayloadDescription</key><string>Trust 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.</string>
|
||||
<key>PayloadIdentifier</key><string>com.openless.remote-input.{fingerprint}</string>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadUUID</key><string>{profile_uuid}</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
</dict></plist>"#,
|
||||
short = &fingerprint[..8],
|
||||
cert_uuid = uuid::Uuid::new_v4(),
|
||||
profile_uuid = uuid::Uuid::new_v4(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn names() -> Vec<String> {
|
||||
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!("<data>{}</data>", 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Code, ShortcutBindingError> {
|
||||
"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<Code> {
|
||||
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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<SettingsSectionId>(initialSettingsSection ?? 'general');
|
||||
@@ -74,8 +77,10 @@ export function SettingsModal({
|
||||
const [channelBackground, setChannelBackground] = useState<HTMLDivElement | null>(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<HTMLDivElement>) => {
|
||||
@@ -283,419 +290,421 @@ export function SettingsModal({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={mobile ? undefined : closeSettings}
|
||||
// 打开动画:遮罩淡入 + 面板弹入(global.css ol-modal-* keyframes,纯
|
||||
// opacity/transform,合成器友好)。此前设置面板是瞬间出现的。
|
||||
style={{
|
||||
position: mobile ? 'fixed' : 'absolute',
|
||||
inset: 0,
|
||||
background: mobile ? 'var(--ol-surface)' : 'var(--ol-overlay-bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: mobile ? 0 : '64px 28px 24px',
|
||||
zIndex: mobile ? 70 : 50,
|
||||
animation: mobile
|
||||
? undefined
|
||||
: closing
|
||||
? 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft) reverse both'
|
||||
: 'ol-modal-backdrop-in 0.2s var(--ol-motion-soft) both',
|
||||
}}
|
||||
>
|
||||
<ProviderLeaveContext.Provider value={providerForm.register}>
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
role="dialog"
|
||||
// Existing menus and child dialogs portal to document.body. Keep those
|
||||
// accessible; FloatingShell makes the covered application inert.
|
||||
aria-label={t('shell.footer.settings')}
|
||||
className="ol-settings-surface"
|
||||
data-ol-mobile={mobile ? 'true' : undefined}
|
||||
onClick={(event) => 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 && (
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
padding: 'calc(12px + env(safe-area-inset-top, 0px)) 16px 12px',
|
||||
borderBottom: '0.5px solid var(--ol-line-soft)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, order: 0, fontSize: 17, fontWeight: 650 }}>
|
||||
{t('shell.footer.settings')}
|
||||
</div>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={closeSettings}
|
||||
aria-label={t('common.close')}
|
||||
style={{ ...iconButtonStyle, order: 1 }}
|
||||
>
|
||||
<Icon name="close" size={17} />
|
||||
</button>
|
||||
<div style={{ order: 2, flex: '1 0 100%', minWidth: 0 }}>{searchBox}</div>
|
||||
</header>
|
||||
)}
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
role="dialog"
|
||||
// Existing menus and child dialogs portal to document.body. Keep those
|
||||
// accessible; FloatingShell makes the covered application inert.
|
||||
aria-label={t('shell.footer.settings')}
|
||||
className="ol-settings-surface"
|
||||
data-ol-mobile={mobile ? 'true' : undefined}
|
||||
onClick={(event) => 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',
|
||||
}}
|
||||
>
|
||||
<aside
|
||||
style={{
|
||||
width: mobile ? undefined : 214,
|
||||
flexShrink: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
padding: mobile ? '8px 12px' : '16px 12px',
|
||||
background: 'var(--ol-settings-rail-bg)',
|
||||
borderRight: mobile ? undefined : '0.5px solid var(--ol-line-soft)',
|
||||
borderBottom: mobile ? '0.5px solid var(--ol-line-soft)' : undefined,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{!mobile && searchBox}
|
||||
<nav
|
||||
ref={railNavRef}
|
||||
aria-label={t('modal.categoriesLabel')}
|
||||
className="ol-thinscroll"
|
||||
{/* 桌面端不再有横跨两栏的标题栏;
|
||||
左侧栏与右侧内容各自通到顶,标题/自动保存/关闭并入右栏顶部。
|
||||
移动端保留整宽 header(标题 + 关闭 + 整行搜索)。 */}
|
||||
{mobile && (
|
||||
<header
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: mobile ? 'row' : 'column',
|
||||
gap: 4,
|
||||
overflowX: mobile ? 'auto' : undefined,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
padding: 'calc(12px + env(safe-area-inset-top, 0px)) 16px 12px',
|
||||
borderBottom: '0.5px solid var(--ol-line-soft)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* 滑动蓝框:top/height 跟随当前分类按钮,spring 曲线过渡。 */}
|
||||
{!mobile && railThumb && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: railThumb.top,
|
||||
height: railThumb.height,
|
||||
borderRadius: 8,
|
||||
background: 'var(--ol-blue-soft)',
|
||||
pointerEvents: 'none',
|
||||
transition:
|
||||
'top 0.26s var(--ol-motion-spring), height 0.2s var(--ol-motion-soft)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sections.map((item) => {
|
||||
const active = !searching && section === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={active ? 'page' : undefined}
|
||||
ref={(el) => {
|
||||
if (el) railBtnRefs.current.set(item.id, el);
|
||||
else railBtnRefs.current.delete(item.id);
|
||||
}}
|
||||
onClick={() => selectSection(item.id)}
|
||||
className={
|
||||
mobile
|
||||
? active
|
||||
? 'ol-nav-btn ol-nav-btn-active'
|
||||
: 'ol-nav-btn'
|
||||
: 'ol-settings-rail-btn'
|
||||
}
|
||||
style={
|
||||
mobile
|
||||
? {
|
||||
...navButtonStyle,
|
||||
flexShrink: 0,
|
||||
padding: '8px 10px',
|
||||
whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--ol-blue-soft)' : 'transparent',
|
||||
color: active ? 'var(--ol-blue)' : 'var(--ol-ink-2)',
|
||||
fontWeight: active ? 600 : 400,
|
||||
}
|
||||
: {
|
||||
...navButtonStyle,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flexShrink: 0,
|
||||
padding: '10px',
|
||||
whiteSpace: 'normal',
|
||||
}
|
||||
}
|
||||
>
|
||||
{!mobile && <Icon name={item.icon} size={16} />}
|
||||
<span style={{ minWidth: 0, overflowWrap: mobile ? undefined : 'anywhere' }}>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{!mobile && <HelpLinks />}
|
||||
</aside>
|
||||
<div style={{ flex: 1, order: 0, fontSize: 17, fontWeight: 650 }}>
|
||||
{t('shell.footer.settings')}
|
||||
</div>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={closeSettings}
|
||||
aria-label={t('common.close')}
|
||||
style={{ ...iconButtonStyle, order: 1 }}
|
||||
>
|
||||
<Icon name="close" size={17} />
|
||||
</button>
|
||||
<div style={{ order: 2, flex: '1 0 100%', minWidth: 0 }}>{searchBox}</div>
|
||||
</header>
|
||||
)}
|
||||
<div
|
||||
ref={setChannelContainer}
|
||||
className="ol-settings-content-pane"
|
||||
data-ol-advanced={section === 'advanced' && !searching ? 'true' : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
flexDirection: mobile ? 'column' : 'row',
|
||||
}}
|
||||
>
|
||||
<ChannelEditorHostContext.Provider
|
||||
value={{
|
||||
container: channelContainer,
|
||||
background: channelBackground,
|
||||
registerClose: (close) => {
|
||||
channelCloseRef.current = close;
|
||||
},
|
||||
<aside
|
||||
style={{
|
||||
width: mobile ? undefined : 214,
|
||||
flexShrink: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
padding: mobile ? '8px 12px' : '16px 12px',
|
||||
background: 'var(--ol-settings-rail-bg)',
|
||||
borderRight: mobile ? undefined : '0.5px solid var(--ol-line-soft)',
|
||||
borderBottom: mobile ? '0.5px solid var(--ol-line-soft)' : undefined,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setChannelBackground}
|
||||
style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
|
||||
{!mobile && searchBox}
|
||||
<nav
|
||||
ref={railNavRef}
|
||||
aria-label={t('modal.categoriesLabel')}
|
||||
className="ol-thinscroll"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: mobile ? 'row' : 'column',
|
||||
gap: 4,
|
||||
overflowX: mobile ? 'auto' : undefined,
|
||||
}}
|
||||
>
|
||||
<SavedToast
|
||||
saveState={savedToast.state}
|
||||
message={savedToast.message}
|
||||
slideFrom="top"
|
||||
offsetStyle={{ position: 'absolute', top: 12, right: 16 }}
|
||||
/>
|
||||
{/* 桌面端:分类标题 + 自动保存提示 + 关闭按钮组成右栏自己的顶栏
|
||||
(仿系统设置:工具条只属于内容区,不再横跨左栏)。 */}
|
||||
{(!mobile || activeAdvancedPage) && (
|
||||
{/* 滑动蓝框:top/height 跟随当前分类按钮,spring 曲线过渡。 */}
|
||||
{!mobile && railThumb && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '18px 24px 0',
|
||||
flexShrink: 0,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: railThumb.top,
|
||||
height: railThumb.height,
|
||||
borderRadius: 8,
|
||||
background: 'var(--ol-blue-soft)',
|
||||
pointerEvents: 'none',
|
||||
transition:
|
||||
'top 0.26s var(--ol-motion-spring), height 0.2s var(--ol-motion-soft)',
|
||||
}}
|
||||
>
|
||||
{activeAdvancedPage && (
|
||||
<button
|
||||
type="button"
|
||||
className="ol-settings-back"
|
||||
onClick={backToAdvanced}
|
||||
aria-label={t('modal.backToAdvanced')}
|
||||
title={t('modal.backToAdvanced')}
|
||||
>
|
||||
<Icon name="chevLeft" size={19} />
|
||||
</button>
|
||||
)}
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
margin: 0,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 21,
|
||||
fontWeight: 650,
|
||||
letterSpacing: '-0.025em',
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{contentTitle}
|
||||
</h2>
|
||||
{!mobile && (
|
||||
<>
|
||||
<span style={{ color: 'var(--ol-ink-3)', fontSize: 12, flexShrink: 0 }}>
|
||||
{t('modal.autoSaveHint')}
|
||||
</span>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={closeSettings}
|
||||
aria-label={t('common.close')}
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
<Icon name="close" size={17} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
{sections.map((item) => {
|
||||
const active = !searching && section === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={active ? 'page' : undefined}
|
||||
ref={(el) => {
|
||||
if (el) railBtnRefs.current.set(item.id, el);
|
||||
else railBtnRefs.current.delete(item.id);
|
||||
}}
|
||||
onClick={() => selectSection(item.id)}
|
||||
className={
|
||||
mobile
|
||||
? active
|
||||
? 'ol-nav-btn ol-nav-btn-active'
|
||||
: 'ol-nav-btn'
|
||||
: 'ol-settings-rail-btn'
|
||||
}
|
||||
style={
|
||||
mobile
|
||||
? {
|
||||
...navButtonStyle,
|
||||
flexShrink: 0,
|
||||
padding: '8px 10px',
|
||||
whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--ol-blue-soft)' : 'transparent',
|
||||
color: active ? 'var(--ol-blue)' : 'var(--ol-ink-2)',
|
||||
fontWeight: active ? 600 : 400,
|
||||
}
|
||||
: {
|
||||
...navButtonStyle,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flexShrink: 0,
|
||||
padding: '10px',
|
||||
whiteSpace: 'normal',
|
||||
}
|
||||
}
|
||||
>
|
||||
{!mobile && <Icon name={item.icon} size={16} />}
|
||||
<span style={{ minWidth: 0, overflowWrap: mobile ? undefined : 'anywhere' }}>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{!mobile && <HelpLinks />}
|
||||
</aside>
|
||||
<div
|
||||
ref={setChannelContainer}
|
||||
className="ol-settings-content-pane"
|
||||
data-ol-advanced={section === 'advanced' && !searching ? 'true' : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<ChannelEditorHostContext.Provider
|
||||
value={{
|
||||
container: channelContainer,
|
||||
background: channelBackground,
|
||||
registerClose: (close) => {
|
||||
channelCloseRef.current = close;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ padding: mobile ? '18px 16px 12px' : '8px 28px 12px', flexShrink: 0 }}
|
||||
ref={setChannelBackground}
|
||||
style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
|
||||
>
|
||||
{mobile && !activeAdvancedPage && (
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
<SavedToast
|
||||
saveState={savedToast.state}
|
||||
message={savedToast.message}
|
||||
slideFrom="top"
|
||||
offsetStyle={{ position: 'absolute', top: 12, right: 16 }}
|
||||
/>
|
||||
{/* 桌面端:分类标题 + 自动保存提示 + 关闭按钮组成右栏自己的顶栏
|
||||
(仿系统设置:工具条只属于内容区,不再横跨左栏)。 */}
|
||||
{(!mobile || activeAdvancedPage) && (
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 20,
|
||||
fontWeight: 650,
|
||||
letterSpacing: '-0.025em',
|
||||
outline: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '18px 24px 0',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{contentTitle}
|
||||
</h2>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
margin: mobile && !activeAdvancedPage ? '7px 0 0' : 0,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--ol-ink-3)',
|
||||
}}
|
||||
aria-live="polite"
|
||||
>
|
||||
{contentDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={['ol-thinscroll', conservative ? 'ol-conservative-scope' : ''].join(
|
||||
' ',
|
||||
)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
padding: mobile
|
||||
? '0 16px calc(20px + env(safe-area-inset-bottom, 0px))'
|
||||
: '0 28px 28px',
|
||||
}}
|
||||
>
|
||||
{searching && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{results.map((item) => (
|
||||
{activeAdvancedPage && (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => selectSection(item.id, true)}
|
||||
style={{
|
||||
...navButtonStyle,
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
padding: 16,
|
||||
alignItems: 'flex-start',
|
||||
background: 'var(--ol-surface)',
|
||||
}}
|
||||
className="ol-settings-back"
|
||||
onClick={backToAdvanced}
|
||||
aria-label={t('modal.backToAdvanced')}
|
||||
title={t('modal.backToAdvanced')}
|
||||
>
|
||||
<Icon name={item.icon} size={18} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontWeight: 600, marginBottom: 5 }}>
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="chevRight" size={14} />
|
||||
<Icon name="chevLeft" size={19} />
|
||||
</button>
|
||||
))}
|
||||
{results.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '24px 16px',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
borderRadius: 10,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>
|
||||
{t('modal.noResults')}
|
||||
</p>
|
||||
)}
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
margin: 0,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 21,
|
||||
fontWeight: 650,
|
||||
letterSpacing: '-0.025em',
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{contentTitle}
|
||||
</h2>
|
||||
{!mobile && (
|
||||
<>
|
||||
<span style={{ color: 'var(--ol-ink-3)', fontSize: 12, flexShrink: 0 }}>
|
||||
{t('modal.autoSaveHint')}
|
||||
</span>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
style={{
|
||||
...navButtonStyle,
|
||||
margin: '12px auto 0',
|
||||
color: 'var(--ol-blue)',
|
||||
}}
|
||||
onClick={closeSettings}
|
||||
aria-label={t('common.close')}
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
{t('modal.clearSearch')}
|
||||
<Icon name="close" size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* key={section} 重挂载 → 每次切换分类播放轻微淡入(ol-tab-fade),
|
||||
与 tab 切换动画语言一致。 */}
|
||||
<div
|
||||
key={section}
|
||||
style={{ padding: mobile ? '18px 16px 12px' : '8px 28px 12px', flexShrink: 0 }}
|
||||
>
|
||||
{mobile && !activeAdvancedPage && (
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 20,
|
||||
fontWeight: 650,
|
||||
letterSpacing: '-0.025em',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{contentTitle}
|
||||
</h2>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
margin: mobile && !activeAdvancedPage ? '7px 0 0' : 0,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--ol-ink-3)',
|
||||
}}
|
||||
aria-live="polite"
|
||||
>
|
||||
{contentDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={['ol-thinscroll', conservative ? 'ol-conservative-scope' : ''].join(
|
||||
' ',
|
||||
)}
|
||||
style={{
|
||||
display: searching ? 'none' : 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
animation: 'ol-tab-fade 0.22s var(--ol-motion-soft) both',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
padding: mobile
|
||||
? '0 16px calc(20px + env(safe-area-inset-bottom, 0px))'
|
||||
: '0 28px 28px',
|
||||
}}
|
||||
>
|
||||
{section === 'general' && <GeneralTab />}
|
||||
{section === 'shortcuts' && <ShortcutsTab />}
|
||||
{section === 'appearance' && <AppearanceTab />}
|
||||
{section === 'services' && <ServicesTab />}
|
||||
{section === 'privacy' && <PrivacyTab />}
|
||||
{section === 'advanced' && (
|
||||
<AdvancedTab
|
||||
pages={advancedPages}
|
||||
page={advancedPage}
|
||||
onOpenPage={openAdvancedPage}
|
||||
/>
|
||||
{searching && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => selectSection(item.id, true)}
|
||||
style={{
|
||||
...navButtonStyle,
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
padding: 16,
|
||||
alignItems: 'flex-start',
|
||||
background: 'var(--ol-surface)',
|
||||
}}
|
||||
>
|
||||
<Icon name={item.icon} size={18} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontWeight: 600, marginBottom: 5 }}>
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="chevRight" size={14} />
|
||||
</button>
|
||||
))}
|
||||
{results.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '24px 16px',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
borderRadius: 10,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>
|
||||
{t('modal.noResults')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
style={{
|
||||
...navButtonStyle,
|
||||
margin: '12px auto 0',
|
||||
color: 'var(--ol-blue)',
|
||||
}}
|
||||
>
|
||||
{t('modal.clearSearch')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{section === 'about' && <AboutTab />}
|
||||
{/* key={section} 重挂载 → 每次切换分类播放轻微淡入(ol-tab-fade),
|
||||
与 tab 切换动画语言一致。 */}
|
||||
<div
|
||||
key={section}
|
||||
style={{
|
||||
display: searching ? 'none' : 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
animation: 'ol-tab-fade 0.22s var(--ol-motion-soft) both',
|
||||
}}
|
||||
>
|
||||
{section === 'general' && <GeneralTab />}
|
||||
{section === 'shortcuts' && <ShortcutsTab />}
|
||||
{section === 'appearance' && <AppearanceTab />}
|
||||
{section === 'services' && <ServicesTab />}
|
||||
{section === 'privacy' && <PrivacyTab />}
|
||||
{section === 'advanced' && (
|
||||
<AdvancedTab
|
||||
pages={advancedPages}
|
||||
page={advancedPage}
|
||||
onOpenPage={openAdvancedPage}
|
||||
/>
|
||||
)}
|
||||
{section === 'about' && <AboutTab />}
|
||||
</div>
|
||||
{mobile && !searching && <HelpLinks />}
|
||||
</div>
|
||||
{mobile && !searching && <HelpLinks />}
|
||||
</div>
|
||||
</div>
|
||||
</ChannelEditorHostContext.Provider>
|
||||
</ChannelEditorHostContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProviderLeaveContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -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<string | null>(null);
|
||||
const nativeSelected = allowMacDictationKey && value?.primary === 'MacDictationKey';
|
||||
const nativeError = error && ['Permission', 'Busy', 'Unavailable', 'Changed'].find(kind => error.includes(`macDictationKey${kind}`));
|
||||
const pendingModifier = useRef<ShortcutBinding | null>(null);
|
||||
const pendingTimer = useRef<number | null>(null);
|
||||
const pressedCodes = useRef<Set<string>>(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({
|
||||
<motion.div
|
||||
key="menu"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: MENU_HEIGHT, opacity: 1 }}
|
||||
animate={{ height: allowMacDictationKey ? MENU_HEIGHT * 2 : MENU_HEIGHT, opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.16, ease: menuEase }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{allowMacDictationKey && <div style={menuRowStyle}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={nativeSelected}
|
||||
disabled={disabled}
|
||||
title={t('macDictationKey.description')}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
void finish({ primary: 'MacDictationKey', modifiers: [] });
|
||||
}}
|
||||
style={disabled ? disabledMenuButtonStyle : nativeSelected ? menuPrimaryStyle : menuButtonStyle}
|
||||
>
|
||||
{t('macDictationKey.label')}
|
||||
</button>
|
||||
</div>}
|
||||
<div style={menuRowStyle}>
|
||||
<motion.button
|
||||
initial={{ y: 4, opacity: 0 }}
|
||||
@@ -420,7 +447,7 @@ export function ShortcutRecorder({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{error && <div style={{ fontSize: 11, color: 'var(--ol-red, #ef4444)' }}>{error}</div>}
|
||||
{error && <div role="alert" style={{ fontSize: 11, color: 'var(--ol-red, #ef4444)' }}>{nativeError ? t(`macDictationKey.${nativeError}`) : error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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) && (
|
||||
<div
|
||||
style={{
|
||||
padding: '18px 10px',
|
||||
|
||||
@@ -37,6 +37,17 @@ export const de: typeof zhCN = {
|
||||
scope:
|
||||
'Synchronisiert Wörterbucheinträge, Korrekturen, Stilsymbole und allgemeine Einstellungen. API-Schlüssel, Zugangsdaten und Geräteeinstellungen bleiben auf diesem Gerät.',
|
||||
},
|
||||
macDictationKey: {
|
||||
Changed: 'Der Shortcut wurde während des Speicherns geändert. Bitte erneut versuchen.',
|
||||
label: 'Mac-Diktat-Taste',
|
||||
description:
|
||||
'Ersetzt den aktuellen Diktat-Shortcut durch die Mikrofontaste. Beim Beenden von OpenLess wird die Taste an macOS zurückgegeben.',
|
||||
Permission:
|
||||
'Erlaube OpenLess unter macOS „Datenschutz & Sicherheit → Bedienungshilfen“ und versuche es erneut.',
|
||||
Busy: 'Beende erst die aktuelle Diktat-Sitzung, bevor du den Shortcut änderst.',
|
||||
Unavailable:
|
||||
'Der Shortcut konnte nicht aktiviert werden; die gespeicherte Zuordnung ist unverändert. Versuche es erneut oder wähle eine andere Taste.',
|
||||
},
|
||||
app: {
|
||||
name: 'OpenLess',
|
||||
tagline: 'Natürlich sprechen, klar schreiben',
|
||||
@@ -1276,6 +1287,7 @@ export const de: typeof zhCN = {
|
||||
'Die beiden Modi speichern ihre Zugangsdaten vollständig getrennt. Beim Wechsel bleiben die Daten des anderen Modus gespeichert und werden beim Zurückwechseln wieder verwendet.',
|
||||
presets: {
|
||||
opencode: 'OpenCode Zen',
|
||||
tencentTokenHub: 'Tencent Cloud TokenHub',
|
||||
customChatCompletions: 'Benutzerdefiniert · Chat Completions',
|
||||
customResponses: 'Benutzerdefiniert · Responses',
|
||||
customMessages: 'Benutzerdefiniert · Messages',
|
||||
@@ -1296,6 +1308,7 @@ export const de: typeof zhCN = {
|
||||
stepfun: 'StepFun',
|
||||
custom: 'Benutzerdefiniert',
|
||||
asrVolcengine: 'Volcengine bigasr',
|
||||
asrTencentCloud: 'Tencent Cloud Hunyuan Echtzeit-ASR',
|
||||
asrBailian: 'Alibaba Bailian Echtzeit-ASR',
|
||||
asrBailianQwen3: 'Bailian Qwen3 Realtime ASR',
|
||||
asrBailianFunAsrFlash: 'Bailian Fun-ASR-Flash (Aufnahmedatei)',
|
||||
@@ -1351,6 +1364,13 @@ export const de: typeof zhCN = {
|
||||
xfyunApiKeyLabel: 'API-Schlüssel',
|
||||
xfyunNote:
|
||||
'AppID und API-Schlüssel findest du auf der Dienstseite „Realtime ASR“ der iFlytek Open Platform. Audioformat: 16 kHz / 16 Bit / Mono-PCM. Die Standard-API hat keinen Begriffsparameter; persönliche Begriffe werden in der iFlytek-Konsole eingerichtet. Standardsprache ist Mandarin-Chinesisch.',
|
||||
tencentCloudAppIdLabel: 'Tencent Cloud AppID',
|
||||
tencentCloudSecretIdLabel: 'SecretID',
|
||||
tencentCloudSecretKeyLabel: 'SecretKey',
|
||||
tencentCloudNote:
|
||||
'Verwendet die Zugangsdaten der Tencent-Cloud-Spracherkennung. Das Standardmodell Hy-ASR-3.0-preview unterstützt Chinesisch, Englisch und 20 Dialekte; Preview nimmt nur Mono-PCM mit 16 kHz bis 60 Sekunden an und unterstützt noch keinen Kontext oder Hotword-Boosting.',
|
||||
tencentTokenHubNote:
|
||||
'Es werden nur aktuell verfügbare Sprachmodelle angezeigt. Einige Modelle nutzen immer Reasoning; das Ausschalten von Reasoning behält das feste Verhalten des jeweiligen Modells bei.',
|
||||
localAsrActiveNotice:
|
||||
'Lokale ASR ({{name}}) ist aktuell aktiv. Wechsle oder deaktiviere sie unter „Erweitert“.',
|
||||
localAsrTakeoverHint:
|
||||
@@ -1784,9 +1804,10 @@ export const de: typeof zhCN = {
|
||||
securityHint:
|
||||
'Nur im selben lokalen Netzwerk erreichbar und durch den Kopplungscode geschützt. Bei Nichtgebrauch ausschalten.',
|
||||
certHint:
|
||||
'Beim ersten Besuch warnt der Browser vor einem nicht vertrauenswürdigen Zertifikat. Wähle „Fortfahren“.',
|
||||
'Öffne auf dem Smartphone „Erstmalige Einrichtung: diesem Computer vertrauen“, um das Zertifikat zu installieren und ihm zu vertrauen. Ältere Versionen erfordern eine einmalige Einrichtung; danach bleibt die Vertrauensstellung über Neustarts und IP-Wechsel hinweg erhalten.',
|
||||
certTrustWarning:
|
||||
'Das Zertifikat wird nur vom Ferneingabedienst dieses Computers verwendet und kann keine anderen Zertifikate ausstellen. Vertraue nie Zertifikaten unbekannter Herkunft und entferne es aus den Smartphone-Einstellungen, sobald du es nicht mehr benötigst.',
|
||||
'Beim ersten Zertifikatsdownload kann die Identität des Computers nicht geprüft werden: Ein bösartiges Gerät im lokalen Netzwerk 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.',
|
||||
certSetupLink: 'iPhone-Zertifikatslink kopieren',
|
||||
waitingStart:
|
||||
'Der Dienst läuft noch nicht. Schalte die Funktion aus und wieder ein. Ein App-Neustart ist nicht erforderlich.',
|
||||
starting: 'Ferneingabedienst wird gestartet…',
|
||||
|
||||
@@ -39,6 +39,16 @@ export const en: typeof zhCN = {
|
||||
scope:
|
||||
'Sync dictionary entries, corrections, style icons, and common preferences. API keys, credentials, and device settings stay on this device.',
|
||||
},
|
||||
macDictationKey: {
|
||||
Changed: 'The shortcut changed while saving. Please try again.',
|
||||
label: 'Mac Dictation key',
|
||||
description:
|
||||
'Replaces the current dictation shortcut with the microphone key. Quitting OpenLess releases it to macOS.',
|
||||
Permission: 'Allow OpenLess in macOS Privacy & Security → Accessibility, then retry.',
|
||||
Busy: 'Finish the current dictation before changing its shortcut.',
|
||||
Unavailable:
|
||||
'Could not activate this shortcut. The saved binding is unchanged; retry or choose another key.',
|
||||
},
|
||||
app: {
|
||||
name: 'OpenLess',
|
||||
tagline: 'Speak naturally, write perfectly',
|
||||
@@ -1259,6 +1269,7 @@ export const en: typeof zhCN = {
|
||||
minimax: 'MiniMax (M3)',
|
||||
stepfun: 'StepFun',
|
||||
opencode: 'OpenCode Zen',
|
||||
tencentTokenHub: 'Tencent Cloud TokenHub',
|
||||
customChatCompletions: 'Custom · Chat Completions',
|
||||
customResponses: 'Custom · Responses',
|
||||
customMessages: 'Custom · Messages',
|
||||
@@ -1277,6 +1288,7 @@ export const en: typeof zhCN = {
|
||||
asrOpenAiCompatible: 'Custom OpenAI-compatible',
|
||||
asrXiaomiMimo: 'Xiaomi MiMo ASR',
|
||||
asrIflytek: 'iFlytek Realtime ASR',
|
||||
asrTencentCloud: 'Tencent Cloud Hunyuan Realtime ASR',
|
||||
asrElevenLabs: 'ElevenLabs Scribe',
|
||||
asrSherpaOnnxLocal: 'Local sherpa-onnx (Experimental)',
|
||||
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
|
||||
@@ -1319,6 +1331,13 @@ export const en: typeof zhCN = {
|
||||
xfyunApiKeyLabel: 'API Key',
|
||||
xfyunNote:
|
||||
'Get AppID and API Key from the iFlytek Open Platform "Realtime ASR" service page. Audio is 16 kHz / 16-bit / mono PCM; the standard API has no hotword parameter (configure personalized hotwords in the iFlytek console), and the language defaults to Mandarin Chinese.',
|
||||
tencentCloudAppIdLabel: 'Tencent Cloud AppID',
|
||||
tencentCloudSecretIdLabel: 'SecretID',
|
||||
tencentCloudSecretKeyLabel: 'SecretKey',
|
||||
tencentCloudNote:
|
||||
'Uses Tencent Cloud Speech Recognition API credentials. The default Hy-ASR-3.0-preview supports Chinese, English, and 20 dialects; Preview accepts only mono 16 kHz PCM up to 60 seconds and does not yet support context or hotword boosting.',
|
||||
tencentTokenHubNote:
|
||||
'Only online language models are listed. Some models always use reasoning; turning reasoning off keeps that model\'s fixed behavior.',
|
||||
localAsrActiveNotice:
|
||||
'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.',
|
||||
localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.',
|
||||
@@ -1729,9 +1748,11 @@ export const en: typeof zhCN = {
|
||||
startError: 'Failed to start the remote input service: {{reason}}',
|
||||
securityHint:
|
||||
'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.',
|
||||
certHint: 'On first visit the browser warns the certificate is untrusted — choose "Proceed".',
|
||||
certHint:
|
||||
'Open “First-time setup: trust this computer” on the phone to install and trust the certificate. Older versions require one-time setup; subsequent restarts and IP changes preserve trust.',
|
||||
certTrustWarning:
|
||||
'The certificate is only used by this PC’s remote input service (it cannot issue other certificates). Never trust certificates from unknown sources; remove it from your phone’s settings when no longer needed.',
|
||||
'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.',
|
||||
certSetupLink: 'Copy iPhone certificate link',
|
||||
waitingStart:
|
||||
'The service is not running yet. Turn the switch off, then on again. Do not restart the app.',
|
||||
starting: 'Starting the remote input service…',
|
||||
|
||||
@@ -37,6 +37,17 @@ export const es: typeof zhCN = {
|
||||
scope:
|
||||
'Sincroniza el diccionario, las correcciones, los iconos de estilos y las preferencias comunes. Las claves API, las credenciales y los ajustes del dispositivo permanecen aquí.',
|
||||
},
|
||||
macDictationKey: {
|
||||
Changed: 'El atajo cambió mientras se guardaba. Inténtalo de nuevo.',
|
||||
label: 'Tecla de dictado de Mac',
|
||||
description:
|
||||
'Sustituye el atajo de dictado actual por la tecla del micrófono. Al salir de OpenLess, la tecla se devuelve a macOS.',
|
||||
Permission:
|
||||
'Permite OpenLess en «Privacidad y seguridad → Accesibilidad» de macOS y vuelve a intentarlo.',
|
||||
Busy: 'Termina el dictado en curso antes de cambiar el atajo.',
|
||||
Unavailable:
|
||||
'No se pudo activar el atajo; la asignación guardada no cambió. Reinténtalo o elige otra tecla.',
|
||||
},
|
||||
app: {
|
||||
name: 'OpenLess',
|
||||
tagline: 'Habla con naturalidad, escribe con precisión',
|
||||
@@ -1268,6 +1279,7 @@ export const es: typeof zhCN = {
|
||||
'Cada modo conserva sus propias credenciales. Al cambiar, las del otro modo se guardan sin usarse y se restauran cuando vuelves.',
|
||||
presets: {
|
||||
opencode: 'OpenCode Zen',
|
||||
tencentTokenHub: 'TokenHub de Tencent Cloud',
|
||||
customChatCompletions: 'Personalizado · Chat Completions',
|
||||
customResponses: 'Personalizado · Responses',
|
||||
customMessages: 'Personalizado · Messages',
|
||||
@@ -1288,6 +1300,7 @@ export const es: typeof zhCN = {
|
||||
stepfun: 'StepFun',
|
||||
custom: 'Personalizado',
|
||||
asrVolcengine: 'Volcengine bigasr',
|
||||
asrTencentCloud: 'ASR en tiempo real Hunyuan de Tencent Cloud',
|
||||
asrBailian: 'Alibaba Bailian ASR en tiempo real',
|
||||
asrBailianQwen3: 'Bailian Qwen3 ASR en tiempo real',
|
||||
asrBailianFunAsrFlash: 'Bailian Fun-ASR-Flash (archivo grabado)',
|
||||
@@ -1343,6 +1356,13 @@ export const es: typeof zhCN = {
|
||||
xfyunApiKeyLabel: 'Clave API',
|
||||
xfyunNote:
|
||||
'Obtén AppID y API Key en la página del servicio de ASR en tiempo real de iFlytek Open Platform. El audio es PCM mono de 16 kHz / 16 bits. La API estándar no admite parámetros de palabras clave; configúralas en la consola de iFlytek. El idioma predeterminado es chino mandarín.',
|
||||
tencentCloudAppIdLabel: 'AppID de Tencent Cloud',
|
||||
tencentCloudSecretIdLabel: 'SecretID',
|
||||
tencentCloudSecretKeyLabel: 'SecretKey',
|
||||
tencentCloudNote:
|
||||
'Usa las credenciales del servicio de reconocimiento de voz de Tencent Cloud. El modelo predeterminado Hy-ASR-3.0-preview admite chino, inglés y 20 dialectos; Preview solo acepta PCM mono de 16 kHz de hasta 60 segundos y aún no admite contexto ni refuerzo de palabras clave.',
|
||||
tencentTokenHubNote:
|
||||
'Solo se muestran los modelos de lenguaje disponibles en línea. Algunos modelos usan siempre razonamiento; desactivarlo mantiene el comportamiento fijo de ese modelo.',
|
||||
localAsrActiveNotice:
|
||||
'El ASR local ({{name}}) está activo. Cámbialo o desactívalo desde la pestaña Avanzado.',
|
||||
localAsrTakeoverHint: 'Al activar «{{name}}», este sustituirá al proveedor ASR.',
|
||||
@@ -1768,9 +1788,10 @@ export const es: typeof zhCN = {
|
||||
securityHint:
|
||||
'Solo es accesible desde la misma red local y requiere el código de vinculación. Desactívalo cuando no lo uses.',
|
||||
certHint:
|
||||
'En la primera visita, el navegador avisa de que el certificado no es de confianza. Elige «Continuar».',
|
||||
'Abre en el móvil «Configuración inicial: confiar en este ordenador» para instalar el certificado y confiar en él. Las versiones anteriores requieren una configuración única; después la confianza se mantiene al reiniciar o cambiar de IP.',
|
||||
certTrustWarning:
|
||||
'Este certificado solo se usa para la entrada remota de este ordenador y no puede emitir otros certificados. No confíes en certificados de origen desconocido y elimínalo de los ajustes del móvil cuando ya no lo necesites.',
|
||||
'La descarga inicial del certificado no puede verificar la identidad del ordenador: un dispositivo malicioso en la red local 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.',
|
||||
certSetupLink: 'Copiar enlace del certificado para iPhone',
|
||||
waitingStart:
|
||||
'El servicio aún no está en ejecución. Desactiva el interruptor y vuelve a activarlo; no reinicies la aplicación.',
|
||||
starting: 'Iniciando el servicio de entrada remota…',
|
||||
|
||||
@@ -37,6 +37,17 @@ export const fr: typeof zhCN = {
|
||||
scope:
|
||||
'Synchronise le dictionnaire, les corrections, les icônes de styles et les préférences communes. Les clés API, identifiants et réglages propres à l’appareil restent ici.',
|
||||
},
|
||||
macDictationKey: {
|
||||
Changed: "Le raccourci a changé pendant l'enregistrement. Réessayez.",
|
||||
label: 'Touche de dictée Mac',
|
||||
description:
|
||||
'Remplace le raccourci de dictée actuel par la touche micro. En quittant OpenLess, la touche est rendue à macOS.',
|
||||
Permission:
|
||||
'Autorisez OpenLess dans « Confidentialité et sécurité → Accessibilité » de macOS, puis réessayez.',
|
||||
Busy: 'Terminez la dictée en cours avant de modifier le raccourci.',
|
||||
Unavailable:
|
||||
"Impossible d'activer le raccourci ; l'association enregistrée est inchangée. Réessayez ou choisissez une autre touche.",
|
||||
},
|
||||
app: {
|
||||
name: 'OpenLess',
|
||||
tagline: 'Parlez naturellement, écrivez avec précision',
|
||||
@@ -1285,6 +1296,7 @@ export const fr: typeof zhCN = {
|
||||
'Les deux modes conservent des identifiants distincts. Changer de mode garde l’autre configuration sans l’utiliser ; elle est restaurée à votre retour.',
|
||||
presets: {
|
||||
opencode: 'OpenCode Zen',
|
||||
tencentTokenHub: 'TokenHub Tencent Cloud',
|
||||
customChatCompletions: 'Personnalisé · Chat Completions',
|
||||
customResponses: 'Personnalisé · Responses',
|
||||
customMessages: 'Personnalisé · Messages',
|
||||
@@ -1305,6 +1317,7 @@ export const fr: typeof zhCN = {
|
||||
stepfun: 'StepFun',
|
||||
custom: 'Personnalisé',
|
||||
asrVolcengine: 'Volcengine bigasr',
|
||||
asrTencentCloud: 'ASR temps réel Hunyuan de Tencent Cloud',
|
||||
asrBailian: 'Alibaba Bailian ASR en temps réel',
|
||||
asrBailianQwen3: 'Bailian Qwen3 ASR en temps réel',
|
||||
asrBailianFunAsrFlash: 'Bailian Fun-ASR-Flash (fichier enregistré)',
|
||||
@@ -1360,6 +1373,13 @@ export const fr: typeof zhCN = {
|
||||
xfyunApiKeyLabel: 'Clé API',
|
||||
xfyunNote:
|
||||
'Obtenez AppID et API Key sur la page du service ASR en temps réel d’iFlytek Open Platform. L’audio est en PCM mono 16 kHz / 16 bits. L’API standard n’accepte pas de paramètre de mots-clés ; configurez-les dans la console iFlytek. La langue par défaut est le chinois mandarin.',
|
||||
tencentCloudAppIdLabel: 'AppID Tencent Cloud',
|
||||
tencentCloudSecretIdLabel: 'SecretID',
|
||||
tencentCloudSecretKeyLabel: 'SecretKey',
|
||||
tencentCloudNote:
|
||||
'Utilise les identifiants du service de reconnaissance vocale de Tencent Cloud. Le modèle par défaut Hy-ASR-3.0-preview prend en charge le chinois, l’anglais et 20 dialectes ; Preview n’accepte que le PCM mono 16 kHz jusqu’à 60 secondes et ne gère pas encore le contexte ni le renforcement de mots-clés.',
|
||||
tencentTokenHubNote:
|
||||
'Seuls les modèles de langage disponibles en ligne sont listés. Certains modèles raisonnent toujours ; désactiver le raisonnement conserve le comportement fixe du modèle.',
|
||||
localAsrActiveNotice:
|
||||
'L’ASR local ({{name}}) est actif. Changez-le ou désactivez-le dans l’onglet Avancé.',
|
||||
localAsrTakeoverHint: 'Une fois activé, « {{name}} » remplacera le fournisseur ASR.',
|
||||
@@ -1791,9 +1811,10 @@ export const fr: typeof zhCN = {
|
||||
securityHint:
|
||||
'Accessible uniquement sur le même réseau local, avec le code d’association. Désactivez-la lorsque vous ne l’utilisez pas.',
|
||||
certHint:
|
||||
'À la première visite, le navigateur signale un certificat non fiable. Choisissez « Continuer ».',
|
||||
'Ouvrez sur le téléphone « Réglage initial : faire confiance à cet ordinateur » pour installer le certificat et l’approuver. Les anciennes versions exigent un réglage unique ; ensuite la confiance est conservée après redémarrage ou changement d’adresse IP.',
|
||||
certTrustWarning:
|
||||
'Ce certificat sert uniquement à la saisie à distance de cet ordinateur et ne peut pas émettre d’autres certificats. N’approuvez pas de certificat d’origine inconnue ; supprimez-le des réglages du téléphone lorsqu’il n’est plus nécessaire.',
|
||||
'Le téléchargement initial du certificat ne permet pas de vérifier l’identité de l’ordinateur : un appareil malveillant du réseau local 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.',
|
||||
certSetupLink: 'Copier le lien du certificat iPhone',
|
||||
waitingStart:
|
||||
'Le service ne fonctionne pas encore. Désactivez puis réactivez l’interrupteur, sans redémarrer l’application.',
|
||||
starting: 'Démarrage de la saisie à distance…',
|
||||
|
||||
@@ -1256,6 +1256,7 @@ export const ja: typeof zhCN = {
|
||||
minimax: 'MiniMax(M3)',
|
||||
stepfun: 'StepFun(階躍星辰)',
|
||||
opencode: 'OpenCode Zen',
|
||||
tencentTokenHub: 'Tencent Cloud TokenHub',
|
||||
customChatCompletions: 'カスタム · Chat Completions',
|
||||
customResponses: 'カスタム · Responses',
|
||||
customMessages: 'カスタム · Messages',
|
||||
@@ -1274,6 +1275,7 @@ export const ja: typeof zhCN = {
|
||||
asrOpenAiCompatible: 'カスタム OpenAI 互換',
|
||||
asrXiaomiMimo: 'Xiaomi MiMo ASR',
|
||||
asrIflytek: 'iFlytek リアルタイム音声認識',
|
||||
asrTencentCloud: 'Tencent Cloud Hunyuan リアルタイム ASR',
|
||||
asrElevenLabs: 'ElevenLabs Scribe',
|
||||
asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)',
|
||||
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
|
||||
@@ -1316,6 +1318,13 @@ export const ja: typeof zhCN = {
|
||||
xfyunApiKeyLabel: 'API Key',
|
||||
xfyunNote:
|
||||
'iFlytek オープンプラットフォームの「リアルタイム音声認識」サービスページで AppID と API Key を取得します。音声は 16kHz / 16bit / モノラル PCM。標準版 API にホットワード引数はありません(iFlytek コンソールで個別ホットワードを設定)。言語はデフォルトで中国語(普通話)です。',
|
||||
tencentCloudAppIdLabel: 'Tencent Cloud AppID',
|
||||
tencentCloudSecretIdLabel: 'SecretID',
|
||||
tencentCloudSecretKeyLabel: 'SecretKey',
|
||||
tencentCloudNote:
|
||||
'Tencent Cloud 音声認識 API の認証情報を使用します。既定の Hy-ASR-3.0-preview は中国語・英語・20 方言に対応します。Preview は 60 秒以内の 16kHz モノラル PCM のみ対応し、コンテキストとホットワード強化は未対応です。',
|
||||
tencentTokenHubNote:
|
||||
'現在オンラインの言語モデルのみを表示します。一部のモデルは常に思考を使用し、思考をオフにしてもモデル固有の動作を維持します。',
|
||||
localAsrActiveNotice:
|
||||
'現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。',
|
||||
localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。',
|
||||
@@ -1709,9 +1718,10 @@ export const ja: typeof zhCN = {
|
||||
securityHint:
|
||||
'同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。',
|
||||
certHint:
|
||||
'初回アクセス時、ブラウザが証明書は信頼されていないと警告します。案内に従って「続行」を選択してください。',
|
||||
'スマートフォンの「初回設定:このコンピュータを信頼」で証明書をインストールし信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。',
|
||||
certTrustWarning:
|
||||
'この証明書は本機のリモート入力サービス専用です(他の証明書を発行できません)。出所不明の証明書は信頼しないでください。不要になったらスマートフォンの設定から削除できます。',
|
||||
'初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。',
|
||||
certSetupLink: 'iPhone 証明書リンクをコピー',
|
||||
waitingStart:
|
||||
'サービスはまだ起動していません。スイッチを一度オフにしてからオンにしてください。アプリを再起動しないでください。',
|
||||
starting: 'リモート入力サービスを起動しています…',
|
||||
|
||||
@@ -1248,6 +1248,7 @@ export const ko: typeof zhCN = {
|
||||
minimax: 'MiniMax (M3)',
|
||||
stepfun: 'StepFun',
|
||||
opencode: 'OpenCode Zen',
|
||||
tencentTokenHub: 'Tencent Cloud TokenHub',
|
||||
customChatCompletions: '사용자 지정 · Chat Completions',
|
||||
customResponses: '사용자 지정 · Responses',
|
||||
customMessages: '사용자 지정 · Messages',
|
||||
@@ -1266,6 +1267,7 @@ export const ko: typeof zhCN = {
|
||||
asrOpenAiCompatible: '커스텀 OpenAI 호환',
|
||||
asrXiaomiMimo: 'Xiaomi MiMo ASR',
|
||||
asrIflytek: 'iFlytek 실시간 음성 인식',
|
||||
asrTencentCloud: 'Tencent Cloud Hunyuan 실시간 ASR',
|
||||
asrElevenLabs: 'ElevenLabs Scribe',
|
||||
asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)',
|
||||
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
|
||||
@@ -1308,6 +1310,13 @@ export const ko: typeof zhCN = {
|
||||
xfyunApiKeyLabel: 'API Key',
|
||||
xfyunNote:
|
||||
'iFlytek 오픈 플랫폼 "실시간 음성 인식" 서비스 페이지에서 AppID와 API Key를 가져옵니다. 오디오는 16kHz/16bit/모노 PCM입니다. 표준 API에는 핫워드 매개변수가 없으며(iFlytek 콘솔에서 개별 핫워드 설정), 언어는 기본적으로 중국어(보통화)입니다.',
|
||||
tencentCloudAppIdLabel: 'Tencent Cloud AppID',
|
||||
tencentCloudSecretIdLabel: 'SecretID',
|
||||
tencentCloudSecretKeyLabel: 'SecretKey',
|
||||
tencentCloudNote:
|
||||
'Tencent Cloud 음성 인식 API 자격 증명을 사용합니다. 기본 Hy-ASR-3.0-preview는 중국어·영어·20개 방언을 지원합니다. Preview는 60초 이내의 16kHz 모노 PCM만 지원하며, 컨텍스트와 핫워드 강화는 아직 지원하지 않습니다.',
|
||||
tencentTokenHubNote:
|
||||
'현재 온라인인 언어 모델만 표시합니다. 일부 모델은 항상 추론을 사용하며, 추론을 꺼도 해당 모델의 고정 동작을 유지합니다.',
|
||||
localAsrActiveNotice:
|
||||
'현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.',
|
||||
localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.',
|
||||
@@ -1697,9 +1706,10 @@ export const ko: typeof zhCN = {
|
||||
securityHint:
|
||||
'같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.',
|
||||
certHint:
|
||||
'첫 접속 시 브라우저가 인증서를 신뢰할 수 없다고 경고합니다. 안내에 따라 "계속 진행"을 선택하세요.',
|
||||
'휴대폰에서 “최초 설정: 이 컴퓨터 신뢰”를 열고 인증서를 설치하고 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.',
|
||||
certTrustWarning:
|
||||
'이 인증서는 이 PC의 원격 입력 서비스 전용입니다(다른 인증서를 발급할 수 없음). 출처를 알 수 없는 인증서는 신뢰하지 마세요. 더 이상 사용하지 않으면 휴대폰 설정에서 제거할 수 있습니다.',
|
||||
'최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.',
|
||||
certSetupLink: 'iPhone 인증서 링크 복사',
|
||||
waitingStart:
|
||||
'서비스가 아직 시작되지 않았습니다. 스위치를 끈 다음 다시 켜세요. 앱을 다시 시작하지 마세요.',
|
||||
starting: '원격 입력 서비스를 시작하는 중입니다…',
|
||||
|
||||
@@ -33,6 +33,14 @@ export const zhCN = {
|
||||
confirmDelete: '删除备份',
|
||||
scope: '同步词典、纠正规则、风格图标与常用偏好。API 密钥、登录凭据及设备专属设置保留在本机。',
|
||||
},
|
||||
macDictationKey: {
|
||||
Changed: '保存期间快捷键已改变,请重试。',
|
||||
label: 'Mac 听写键',
|
||||
description: '用麦克风图标键替换当前听写快捷键。退出 OpenLess 后,此键交回 macOS。',
|
||||
Permission: '请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。',
|
||||
Busy: '请先结束当前听写,再更改快捷键。',
|
||||
Unavailable: '无法启用此快捷键,已保存的绑定未改变。请重试或选择其他键。',
|
||||
},
|
||||
app: {
|
||||
name: 'OpenLess',
|
||||
tagline: '自然说话,完美书写',
|
||||
@@ -1200,6 +1208,7 @@ export const zhCN = {
|
||||
minimax: 'MiniMax(M3)',
|
||||
stepfun: 'StepFun(阶跃星辰)',
|
||||
opencode: 'OpenCode Zen',
|
||||
tencentTokenHub: '腾讯云 TokenHub',
|
||||
customChatCompletions: '自定义 · Chat Completions',
|
||||
customResponses: '自定义 · Responses',
|
||||
customMessages: '自定义 · Messages',
|
||||
@@ -1218,6 +1227,7 @@ export const zhCN = {
|
||||
asrOpenAiCompatible: '自定义 OpenAI 兼容',
|
||||
asrXiaomiMimo: '小米 MiMo ASR',
|
||||
asrIflytek: '讯飞实时语音转写',
|
||||
asrTencentCloud: '腾讯云混元实时 ASR',
|
||||
asrElevenLabs: 'ElevenLabs Scribe',
|
||||
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
|
||||
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
|
||||
@@ -1259,6 +1269,13 @@ export const zhCN = {
|
||||
xfyunApiKeyLabel: 'API Key',
|
||||
xfyunNote:
|
||||
'在讯飞开放平台「实时语音转写」服务页获取 AppID 与 API Key。音频为 16kHz/16bit/单声道 PCM;标准版接口暂不支持热词参数(可在讯飞控制台配置个性化热词),语种默认中文普通话。',
|
||||
tencentCloudAppIdLabel: '腾讯云 AppID',
|
||||
tencentCloudSecretIdLabel: 'SecretID',
|
||||
tencentCloudSecretKeyLabel: 'SecretKey',
|
||||
tencentCloudNote:
|
||||
'使用腾讯云「语音识别」服务的 API 密钥。默认 Hy-ASR-3.0-preview 支持中英与 20 种方言;Preview 仅支持 60 秒以内的 16kHz 单声道 PCM,暂不支持上下文或热词增强。',
|
||||
tencentTokenHubNote:
|
||||
'仅显示当前在线的语言模型。部分模型始终启用思考;关闭思考开关时将沿用该模型的固定行为。',
|
||||
localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。',
|
||||
localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。',
|
||||
asrProviderTakenOver:
|
||||
@@ -1632,9 +1649,9 @@ export const zhCN = {
|
||||
portInUse: '端口 {{port}} 被占用,请更换',
|
||||
startError: '远程输入服务启动失败:{{reason}}',
|
||||
securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。',
|
||||
certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。',
|
||||
certTrustWarning:
|
||||
'该证书仅用于本机远程输入服务(不能签发其他证书),请勿信任来源不明的证书;不再使用时可在手机系统设置中移除。',
|
||||
certHint: "首次连接请在手机页面展开“首次设置:信任此电脑”,安装并信任证书。升级旧版需设置一次;以后重启和换 IP 会保留信任。",
|
||||
certTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。",
|
||||
certSetupLink: "复制 iPhone 证书链接",
|
||||
waitingStart: '服务尚未启动。请关闭开关再打开一次,不要重启软件。',
|
||||
starting: '正在启动远程输入服务…',
|
||||
urlsStale: '这些地址来自上次运行,可能已经过期。',
|
||||
|
||||
@@ -35,6 +35,14 @@ export const zhTW: typeof zhCN = {
|
||||
confirmDelete: '刪除備份',
|
||||
scope: '同步詞典、修正规則、風格圖示與常用偏好。API 金鑰、登入憑據與裝置專屬設定保留在本機。',
|
||||
},
|
||||
macDictationKey: {
|
||||
Changed: '儲存期間快捷鍵已變更,請重試。',
|
||||
label: 'Mac 聽寫鍵',
|
||||
description: '用麥克風圖示鍵替換目前的聽寫快捷鍵。結束 OpenLess 後,此鍵交回 macOS。',
|
||||
Permission: '請在 macOS「隱私權與安全性 → 輔助使用」中允許 OpenLess 後重試。',
|
||||
Busy: '請先結束目前的聽寫,再變更快捷鍵。',
|
||||
Unavailable: '無法啟用此快捷鍵,已儲存的綁定未變更。請重試或選擇其他鍵。',
|
||||
},
|
||||
app: {
|
||||
name: 'OpenLess',
|
||||
tagline: '自然說話,完美書寫',
|
||||
@@ -1202,6 +1210,7 @@ export const zhTW: typeof zhCN = {
|
||||
minimax: 'MiniMax(M3)',
|
||||
stepfun: 'StepFun(階躍星辰)',
|
||||
opencode: 'OpenCode Zen',
|
||||
tencentTokenHub: '騰訊雲 TokenHub',
|
||||
customChatCompletions: '自訂 · Chat Completions',
|
||||
customResponses: '自訂 · Responses',
|
||||
customMessages: '自訂 · Messages',
|
||||
@@ -1220,6 +1229,7 @@ export const zhTW: typeof zhCN = {
|
||||
asrOpenAiCompatible: '自訂 OpenAI 相容',
|
||||
asrXiaomiMimo: '小米 MiMo ASR',
|
||||
asrIflytek: '訊飛即時語音轉寫',
|
||||
asrTencentCloud: '騰訊雲混元即時 ASR',
|
||||
asrElevenLabs: 'ElevenLabs Scribe',
|
||||
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
|
||||
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
|
||||
@@ -1261,6 +1271,13 @@ export const zhTW: typeof zhCN = {
|
||||
xfyunApiKeyLabel: 'API Key',
|
||||
xfyunNote:
|
||||
'在訊飛開放平台「即時語音轉寫」服務頁取得 AppID 與 API Key。音訊為 16kHz/16bit/單聲道 PCM;標準版介面暫不支援熱詞參數(可在訊飛控制台設定個人化熱詞),語種預設中文普通話。',
|
||||
tencentCloudAppIdLabel: '騰訊雲 AppID',
|
||||
tencentCloudSecretIdLabel: 'SecretID',
|
||||
tencentCloudSecretKeyLabel: 'SecretKey',
|
||||
tencentCloudNote:
|
||||
'使用騰訊雲「語音辨識」服務的 API 金鑰。預設 Hy-ASR-3.0-preview 支援中英與 20 種方言;Preview 僅支援 60 秒內的 16kHz 單聲道 PCM,暫不支援上下文或熱詞增強。',
|
||||
tencentTokenHubNote:
|
||||
'僅顯示目前在線的語言模型。部分模型始終啟用思考;關閉思考開關時將沿用該模型的固定行為。',
|
||||
localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。',
|
||||
localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。',
|
||||
asrProviderTakenOver:
|
||||
@@ -1618,9 +1635,9 @@ export const zhTW: typeof zhCN = {
|
||||
portInUse: '連接埠 {{port}} 被佔用,請更換',
|
||||
startError: '遠端輸入服務啟動失敗:{{reason}}',
|
||||
securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。',
|
||||
certHint: '首次存取瀏覽器會提示憑證不受信任,按提示選擇「繼續存取」。',
|
||||
certTrustWarning:
|
||||
'該憑證僅用於本機遠端輸入服務(無法簽發其他憑證),請勿信任來源不明的憑證;不再使用時可在手機系統設定中移除。',
|
||||
certHint: "首次連線請在手機頁面展開「首次設定:信任這台電腦」,安裝並信任憑證。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。",
|
||||
certTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。",
|
||||
certSetupLink: "複製 iPhone 憑證連結",
|
||||
waitingStart: '服務尚未啟動。請關閉開關再打開一次,不要重啟軟體。',
|
||||
starting: '正在啟動遠端輸入服務…',
|
||||
urlsStale: '這些地址來自上次執行,可能已經過期。',
|
||||
|
||||
@@ -400,6 +400,8 @@ function formatPrimary(primary: string): string {
|
||||
const isMac = currentPlatform().isMac;
|
||||
if (isMac) {
|
||||
switch (trimmed.toLowerCase()) {
|
||||
case 'macdictationkey':
|
||||
return i18n.t('macDictationKey.label');
|
||||
case 'space':
|
||||
return '\u2423';
|
||||
case 'enter':
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
createHotkeyRecorderState,
|
||||
functionKeyPrimaryFromEvent,
|
||||
orderHotkeyCodes,
|
||||
updateHotkeyRecorderState,
|
||||
} from './hotkeyRecorder';
|
||||
|
||||
function assertEqual<T>(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');
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<CredentialsStatus> {
|
||||
@@ -88,6 +91,12 @@ export async function listProviderModels(
|
||||
channelId?: string,
|
||||
providerType?: string,
|
||||
): Promise<ProviderModelsResult> {
|
||||
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'],
|
||||
}));
|
||||
}
|
||||
@@ -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":[]}
|
||||
]
|
||||
@@ -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<ProviderDescriptor[]> {
|
||||
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<ProviderDescriptor[]> {
|
||||
// Snapshot of Core provider_rules::provider_descriptors (all three kinds).
|
||||
return invokeOrMock('list_provider_descriptors', { kind }, () =>
|
||||
(mockDescriptors as ProviderDescriptor[]).filter((descriptor) => descriptor.kind === kind),
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
|
||||
minimax: 'minimax',
|
||||
stepfun: 'stepfun',
|
||||
opencode: 'opencode',
|
||||
tencentTokenHub: 'tencentTokenHub',
|
||||
custom: 'custom',
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`ol-channel-dialog${embedded ? ' ol-channel-dialog-embedded' : ''}`}
|
||||
data-closing={closing ? 'true' : undefined}
|
||||
role="dialog"
|
||||
aria-label={t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onDialogKeyDown}
|
||||
>
|
||||
<header className="ol-channel-dialog-header">
|
||||
{embedded ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ol-settings-back"
|
||||
onClick={requestClose}
|
||||
aria-label={t('settings.channels.backToList')}
|
||||
title={t('settings.channels.backToList')}
|
||||
>
|
||||
<Icon name="chevLeft" size={19} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="ol-channel-dialog-icon">
|
||||
<Icon name={kind === 'llm' ? 'style' : 'mic'} size={22} />
|
||||
</span>
|
||||
)}
|
||||
<div className="ol-channel-dialog-heading">
|
||||
<div className="ol-channel-dialog-title">
|
||||
<h2>{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}</h2>
|
||||
<span>{t(`settings.channels.${kind}Title`)}</span>
|
||||
</div>
|
||||
<p>{t('settings.channels.autoSaveHint')}</p>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<button
|
||||
type="button"
|
||||
className="ol-channel-dialog-close"
|
||||
onClick={requestClose}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="ol-channel-dialog-body ol-thinscroll">
|
||||
<div className="ol-channel-form-sheet">
|
||||
<ChannelSectionHeading icon="cloud" title={t('settings.channels.connectionTitle')} />
|
||||
<ChannelFormRow label={t('settings.channels.providerLabel')}>
|
||||
<SelectLite
|
||||
value={providerType}
|
||||
disabled={changingProvider}
|
||||
onChange={(next) => 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 }}
|
||||
/>
|
||||
</ChannelFormRow>
|
||||
<ChannelFormRow label={t('settings.channels.nameLabel')} htmlFor={nameId}>
|
||||
<input
|
||||
id={nameId}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
onUserMutation();
|
||||
setName(e.target.value);
|
||||
}}
|
||||
onBlur={() => void saveName()}
|
||||
placeholder={t('settings.channels.namePlaceholder')}
|
||||
style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }}
|
||||
/>
|
||||
<p className="ol-channel-name-hint">{t('settings.channels.nameHint')}</p>
|
||||
</ChannelFormRow>
|
||||
|
||||
{/* 模型列表、供应商特有字段与验证结果都留在同一个滚动区。 */}
|
||||
{!changingProvider && (
|
||||
<ChannelCredentialFields
|
||||
key={`${channel.id}:${providerType}`}
|
||||
kind={kind}
|
||||
providerType={providerType}
|
||||
channelId={channel.id}
|
||||
descriptor={descriptor}
|
||||
onTested={() => void onChanged()}
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
)}
|
||||
{isLocalEngine && (
|
||||
<p className="ol-channel-local-hint">{t('settings.channels.localEngineModelHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="ol-channel-dialog-footer">
|
||||
{confirmDelete ? (
|
||||
<div
|
||||
className="ol-channel-delete-confirm"
|
||||
role="group"
|
||||
aria-label={t('settings.channels.delete')}
|
||||
>
|
||||
<p>{t('settings.channels.deleteConfirm')}</p>
|
||||
<ProviderFormContext.Provider value={form}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`ol-channel-dialog${embedded ? ' ol-channel-dialog-embedded' : ''}`}
|
||||
data-closing={closing ? 'true' : undefined}
|
||||
role="dialog"
|
||||
aria-label={t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onDialogKeyDown}
|
||||
>
|
||||
<header className="ol-channel-dialog-header">
|
||||
{embedded ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ol-settings-back"
|
||||
onClick={requestClose}
|
||||
aria-label={t('settings.channels.backToList')}
|
||||
title={t('settings.channels.backToList')}
|
||||
>
|
||||
<Icon name="chevLeft" size={19} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="ol-channel-dialog-icon">
|
||||
<Icon name={kind === 'llm' ? 'style' : 'mic'} size={22} />
|
||||
</span>
|
||||
)}
|
||||
<div className="ol-channel-dialog-heading">
|
||||
<div className="ol-channel-dialog-title">
|
||||
<h2>
|
||||
{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}
|
||||
</h2>
|
||||
<span>{t(`settings.channels.${kind}Title`)}</span>
|
||||
</div>
|
||||
<p>{t('settings.channels.autoSaveHint')}</p>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<button
|
||||
type="button"
|
||||
className="ol-channel-dialog-close"
|
||||
onClick={requestClose}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="ol-channel-dialog-body ol-thinscroll">
|
||||
<div className="ol-channel-form-sheet">
|
||||
<ChannelSectionHeading icon="cloud" title={t('settings.channels.connectionTitle')} />
|
||||
<ChannelFormRow label={t('settings.channels.providerLabel')}>
|
||||
<SelectLite
|
||||
value={providerType}
|
||||
disabled={changingProvider || form.leaving}
|
||||
onChange={(next) => 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 }}
|
||||
/>
|
||||
</ChannelFormRow>
|
||||
<ChannelFormRow label={t('settings.channels.nameLabel')} htmlFor={nameId}>
|
||||
<input
|
||||
id={nameId}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
onUserMutation();
|
||||
setName(e.target.value);
|
||||
}}
|
||||
onBlur={() => void saveName()}
|
||||
placeholder={t('settings.channels.namePlaceholder')}
|
||||
style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }}
|
||||
/>
|
||||
<p className="ol-channel-name-hint">{t('settings.channels.nameHint')}</p>
|
||||
</ChannelFormRow>
|
||||
|
||||
{/* 模型列表、供应商特有字段与验证结果都留在同一个滚动区。 */}
|
||||
{!changingProvider && (
|
||||
<ChannelCredentialFields
|
||||
key={`${channel.id}:${providerType}`}
|
||||
kind={kind}
|
||||
providerType={providerType}
|
||||
channelId={channel.id}
|
||||
descriptor={descriptor}
|
||||
onTested={() => void onChanged()}
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
)}
|
||||
{isLocalEngine && (
|
||||
<p className="ol-channel-local-hint">{t('settings.channels.localEngineModelHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="ol-channel-dialog-footer">
|
||||
{confirmDelete ? (
|
||||
<div
|
||||
className="ol-channel-delete-confirm"
|
||||
role="group"
|
||||
aria-label={t('settings.channels.delete')}
|
||||
>
|
||||
<p>{t('settings.channels.deleteConfirm')}</p>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
className="ol-channel-delete-button"
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
{t('settings.channels.confirmDelete')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirmDelete(false)} style={ghostBtn}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
className="ol-channel-delete-button"
|
||||
onClick={() => void remove()}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
{t('settings.channels.confirmDelete')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirmDelete(false)} style={ghostBtn}>
|
||||
{t('common.cancel')}
|
||||
{t('settings.channels.delete')}
|
||||
</button>
|
||||
)}
|
||||
<div className="ol-channel-dialog-footer-end">
|
||||
<span className="ol-channel-autosave-hint">{t('modal.autoSaveHint')}</span>
|
||||
<Btn
|
||||
variant={embedded ? 'primary' : 'blue'}
|
||||
onClick={requestClose}
|
||||
disabled={form.leaving}
|
||||
>
|
||||
{t(embedded ? 'settings.channels.done' : 'common.close')}
|
||||
</Btn>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ol-channel-delete-button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
{t('settings.channels.delete')}
|
||||
</button>
|
||||
)}
|
||||
<div className="ol-channel-dialog-footer-end">
|
||||
<span className="ol-channel-autosave-hint">{t('modal.autoSaveHint')}</span>
|
||||
<Btn variant={embedded ? 'primary' : 'blue'} onClick={requestClose}>
|
||||
{t(embedded ? 'settings.channels.done' : 'common.close')}
|
||||
</Btn>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</ProviderFormContext.Provider>
|
||||
);
|
||||
return embedded ? (
|
||||
createPortal(content, editorHost!.container!)
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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<ProtocolValues>(emptyValues);
|
||||
const [saved, setSaved] = useState<ProtocolValues>(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<boolean>>(Promise.resolve(true));
|
||||
const flushRef = useRef<() => Promise<boolean>>(() => 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<boolean> => {
|
||||
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) => (
|
||||
<SettingRow label={label}>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export const ProviderLeaveContext = createContext<((id: string, flush: () => Promise<boolean>) => () => 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<Record<string, boolean>>({});
|
||||
const blockers = useRef<Record<string, boolean>>({});
|
||||
const flushers = useRef(new Map<string, () => Promise<boolean>>());
|
||||
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<boolean>) => {
|
||||
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<void>) => {
|
||||
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<ReturnType<typeof useProviderForm> | null>(null);
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CredentialField
|
||||
key={`${channelId}:model:${llmModelRevision}`}
|
||||
label={t('settings.providers.modelLabel')}
|
||||
account="ark.model_id"
|
||||
provider={channelId}
|
||||
placeholder={defaultModel || 'model-name'}
|
||||
mono
|
||||
defaultValue={defaultModel || undefined}
|
||||
onUserMutation={onLlmMutation}
|
||||
onBlockedChange={trackField}
|
||||
trailing={
|
||||
<LlmThinkingToggle
|
||||
enabled={prefs?.llmThinkingEnabled ?? false}
|
||||
onToggle={onLlmThinkingToggle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${channelId}:model:${llmModelRevision}`}
|
||||
label={t('settings.providers.modelLabel')}
|
||||
account="ark.model_id"
|
||||
provider={channelId}
|
||||
placeholder={defaultModel || 'model-name'}
|
||||
mono
|
||||
defaultValue={defaultModel || undefined}
|
||||
onUserMutation={onLlmMutation}
|
||||
onBlockedChange={trackField}
|
||||
trailing={
|
||||
<LlmThinkingToggle
|
||||
enabled={prefs?.llmThinkingEnabled ?? false}
|
||||
onToggle={onLlmThinkingToggle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{['custom', 'custom_responses', 'custom_messages'].includes(providerType) && (
|
||||
<CredentialField
|
||||
@@ -520,6 +525,65 @@ export function ChannelCredentialFields({
|
||||
);
|
||||
}
|
||||
|
||||
// 腾讯云实时语音:三段式密钥 + 固定模型档(Preview 仅 16kHz 单声道、60 秒内)。
|
||||
if (descriptor?.authRequirement === 'tencent_cloud') {
|
||||
return (
|
||||
<>
|
||||
<CredentialField
|
||||
key={`${channelId}:app_id`}
|
||||
label={t('settings.providers.tencentCloudAppIdLabel')}
|
||||
account="tencent_cloud.app_id"
|
||||
provider={channelId}
|
||||
mono
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${channelId}:secret_id`}
|
||||
label={t('settings.providers.tencentCloudSecretIdLabel')}
|
||||
account="tencent_cloud.secret_id"
|
||||
provider={channelId}
|
||||
mono
|
||||
mask
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${channelId}:secret_key`}
|
||||
label={t('settings.providers.tencentCloudSecretKeyLabel')}
|
||||
account="tencent_cloud.secret_key"
|
||||
provider={channelId}
|
||||
mono
|
||||
mask
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
<div style={channelSectionStyle}>
|
||||
<ChannelSectionHeading icon="settings" title={t('settings.channels.modelTitle')} />
|
||||
</div>
|
||||
<CredentialField
|
||||
key={`${channelId}:model`}
|
||||
label={t('settings.providers.modelLabel')}
|
||||
account="asr.model"
|
||||
provider={channelId}
|
||||
mono
|
||||
placeholder={defaultModel || 'Hy-ASR-3.0-preview'}
|
||||
defaultValue={defaultModel || 'Hy-ASR-3.0-preview'}
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
|
||||
{t('settings.providers.tencentCloudNote')}
|
||||
</div>
|
||||
<ProviderTools
|
||||
kind="asr"
|
||||
modelAccount="asr.model"
|
||||
provider={channelId}
|
||||
showFetchModels={false}
|
||||
onModelSelected={() => setAsrModelRevision((v) => v + 1)}
|
||||
onTested={onTested}
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 本地引擎(qwen3 / sherpa / foundry / Apple 语音)没有 key 与地址;模型的下载与
|
||||
// 切换仍由「高级 → 本地模型」里的 <LocalAsr embedded /> 负责,这里只说明一句。
|
||||
if (descriptor?.authRequirement === 'none') {
|
||||
@@ -533,12 +597,24 @@ export function ChannelCredentialFields({
|
||||
if (providerType === 'orcarouter') {
|
||||
return (
|
||||
<>
|
||||
<CredentialField key={`${channelId}:api_key`} label={t('settings.providers.apiKeyLabel')}
|
||||
account="asr.api_key" provider={channelId} mono mask onUserMutation={onUserMutation} />
|
||||
<CredentialField key={`${channelId}:endpoint`} label={t('settings.providers.baseUrlLabel')}
|
||||
account="asr.endpoint" provider={channelId}
|
||||
placeholder={defaultEndpoint ?? undefined} defaultValue={defaultEndpoint ?? undefined}
|
||||
onUserMutation={onUserMutation} />
|
||||
<CredentialField
|
||||
key={`${channelId}:api_key`}
|
||||
label={t('settings.providers.apiKeyLabel')}
|
||||
account="asr.api_key"
|
||||
provider={channelId}
|
||||
mono
|
||||
mask
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${channelId}:endpoint`}
|
||||
label={t('settings.providers.baseUrlLabel')}
|
||||
account="asr.endpoint"
|
||||
provider={channelId}
|
||||
placeholder={defaultEndpoint ?? undefined}
|
||||
defaultValue={defaultEndpoint ?? undefined}
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
<CatalogModelField
|
||||
kind="asr"
|
||||
provider={channelId}
|
||||
@@ -546,9 +622,15 @@ export function ChannelCredentialFields({
|
||||
defaultModel={defaultModel ?? ''}
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
<ProviderTools kind="asr" modelAccount="asr.model" provider={channelId}
|
||||
onModelSelected={() => setAsrModelRevision(v => v + 1)} onTested={onTested}
|
||||
onUserMutation={onUserMutation} showFetchModels={false} />
|
||||
<ProviderTools
|
||||
kind="asr"
|
||||
modelAccount="asr.model"
|
||||
provider={channelId}
|
||||
onModelSelected={() => setAsrModelRevision((v) => v + 1)}
|
||||
onTested={onTested}
|
||||
onUserMutation={onUserMutation}
|
||||
showFetchModels={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -978,15 +1060,33 @@ function CatalogModelField({
|
||||
|
||||
return (
|
||||
<SettingRow label={t('settings.providers.modelLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: layoutStack ? 'wrap' : 'nowrap' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
width: '100%',
|
||||
maxWidth: layoutStack ? '100%' : 420,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexWrap: layoutStack ? 'wrap' : 'nowrap',
|
||||
}}
|
||||
>
|
||||
<SelectLite
|
||||
value={selectedModel}
|
||||
onChange={(model) => 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({
|
||||
</button>
|
||||
{trailing}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'success' ? 'var(--ol-ok)' : 'var(--ol-ink-4)', lineHeight: 1.4 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color:
|
||||
status === 'error'
|
||||
? 'var(--ol-warn)'
|
||||
: status === 'success'
|
||||
? 'var(--ol-ok)'
|
||||
: 'var(--ol-ink-4)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.4 }}>
|
||||
{t(kind === 'asr'
|
||||
? 'settings.providers.orcarouterAsrCatalogHint'
|
||||
: 'settings.providers.orcarouterCatalogHint')}
|
||||
{t(
|
||||
kind === 'asr'
|
||||
? 'settings.providers.orcarouterAsrCatalogHint'
|
||||
: 'settings.providers.orcarouterCatalogHint',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
@@ -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<number | null>(null);
|
||||
@@ -1351,6 +1471,14 @@ function CredentialField({
|
||||
const mountedRef = useRef(true);
|
||||
const editRevision = useRef(0);
|
||||
const saveQueue = useRef<Promise<void>>(Promise.resolve());
|
||||
const lastWriteOk = useRef(true);
|
||||
const flushRef = useRef<() => Promise<boolean>>(() => 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<HTMLInputElement>) => {
|
||||
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://');
|
||||
|
||||
@@ -262,17 +262,20 @@ export function RecordingInputSection() {
|
||||
<ShortcutRecorder
|
||||
value={prefs.dictationHotkey}
|
||||
sideSpecificModifiers
|
||||
allowMacDictationKey={os === 'mac'}
|
||||
// 录音快捷键是核心热键,Rust 端不接受 null,不可停用——置灰并提示。
|
||||
disableDisabled
|
||||
disableHint={t('settings.recording.comboDisableHint')}
|
||||
onSave={async (binding) => {
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -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) => (
|
||||
<div key={u} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
key={u}
|
||||
style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
@@ -215,6 +218,19 @@ export function RemoteInputSection() {
|
||||
>
|
||||
{copied === u ? '✓' : '⧉'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const link = `${u}/cert.mobileconfig`;
|
||||
await copyText(link);
|
||||
setCopied(link);
|
||||
window.setTimeout(() => setCopied((c) => (c === link ? null : c)), 1500);
|
||||
}}
|
||||
style={smallBtn}
|
||||
>
|
||||
{copied === `${u}/cert.mobileconfig`
|
||||
? '✓'
|
||||
: t('settings.remoteInput.certSetupLink')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<PlatformCapabilities | null>(null);
|
||||
const [stylePacks, setStylePacks] = useState<StylePack[]>([]);
|
||||
// 新增行的草稿状态:先选风格包、再录快捷键,两者齐了才真正落库。
|
||||
@@ -111,12 +111,13 @@ export function ShortcutsSection() {
|
||||
<ShortcutRecorder
|
||||
value={prefs.dictationHotkey}
|
||||
sideSpecificModifiers
|
||||
allowMacDictationKey={os === 'mac'}
|
||||
// 与「录音与输入」页一致:核心热键不可停用,置灰并提示。
|
||||
disableDisabled
|
||||
disableHint={t('settings.recording.comboDisableHint')}
|
||||
onSave={async (binding) => {
|
||||
await setDictationHotkey(binding);
|
||||
await savePrefs({ ...prefs, dictationHotkey: binding });
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { CredentialDraft } from './credentialDraft';
|
||||
|
||||
function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); }
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<void>();
|
||||
const second = deferred<void>();
|
||||
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<void>();
|
||||
const advancedWrite = deferred<void>();
|
||||
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<string | null>();
|
||||
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();
|
||||
@@ -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<typeof setTimeout> | undefined;
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
private pending: { revision: number; promise: Promise<boolean> } | undefined;
|
||||
constructor(private read: () => Promise<string | null>, private write: (value: string) => Promise<void>) {}
|
||||
snapshot = () => this.state;
|
||||
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
|
||||
private update(next: Partial<typeof this.state>) {
|
||||
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<boolean> => {
|
||||
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); }
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user