From 13ccf85a48b73b489f4e3f204929414344ca5213 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 08:42:18 +0800 Subject: [PATCH] Store credentials in the system vault Provider credentials should follow the platform credential-store threat model instead of continuing normal plaintext JSON writes. The vault now stores the existing multi-provider credentials payload in the OS credential store, migrates the legacy JSON file only when the vault has no entry, removes the legacy file after a successful write, and keeps raw credential IPC restricted to the main settings window. Constraint: User explicitly chose Keychain/Credential Manager/Linux keyring for issue #230. Constraint: Preserve the existing provider schema and active-provider routing with minimal frontend churn. Rejected: Keep plaintext JSON as the documented product choice | user rejected this direction. Rejected: Split every credential field into separate vault entries | larger migration surface and risks losing active provider state. Confidence: medium Scope-risk: moderate Directive: Security-model and storage-strategy choices must be confirmed with the user before implementation. Tested: cargo check --manifest-path src-tauri/Cargo.toml; npm run -s build; git diff --check; grep for stale plaintext/Keychain fallback wording. Not-tested: Manual migration on macOS Keychain, Windows Credential Manager, or Linux desktop keyring. Related: https://github.com/appergb/openless/issues/230 --- AGENTS.md | 6 +- CLAUDE.md | 6 +- README.md | 11 +- README.zh.md | 9 +- .../windows-real-asr-insertion-smoke.ps1 | 2 +- openless-all/app/src-tauri/Cargo.lock | 45 +++++- openless-all/app/src-tauri/Cargo.toml | 15 ++ openless-all/app/src-tauri/src/commands.rs | 18 ++- openless-all/app/src-tauri/src/persistence.rs | 149 +++++++++++------- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/pages/Settings.tsx | 3 + 13 files changed, 185 insertions(+), 82 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aebd3f39..415d0cce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.) insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback -persistence.rs History/preferences/vocab JSON + Keychain credentials +persistence.rs History/preferences/vocab JSON + platform credential vault coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing permissions.rs TCC checks (Accessibility / Microphone) @@ -91,9 +91,9 @@ Invariants: ### Permissions, credentials, on-disk state -- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks Keychain lookups *and* every existing TCC grant. +- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant. - **TCC**: Microphone + Accessibility + AppleEvents. `NSMicrophoneUsageDescription` / `NSAccessibilityUsageDescription` / `NSAppleEventsUsageDescription` live in `openless-all/app/src-tauri/Info.plist`. After a fresh build that resets TCC, the app must be **fully quit and relaunched** after granting Accessibility before the global hotkey tap installs. -- **Credentials** live in Keychain under accounts in `CredentialAccount` (`volcengine.app_key`, `volcengine.access_key`, `volcengine.resource_id`, `ark.api_key`, `ark.model_id`, `ark.endpoint`). The plaintext fallback at `~/.openless/credentials.json` is read on first launch so legacy users keep their creds without re-entering. Never hard-code keys. +- **Credentials** live in the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service `com.openless.app`. The legacy plaintext JSON (`~/.openless/credentials.json` on macOS/Linux, `%APPDATA%\OpenLess\credentials.json` on Windows) is only a migration source and is removed after a successful vault write. Never hard-code keys or include legacy credential files in logs, exports, build artifacts, or bug reports. - **Per-user data**: - macOS: `~/Library/Application Support/OpenLess/{history.json, preferences.json, dictionary.json}` — capped at 200 history entries. **Do not rename `dictionary.json` to `vocab.json`** (drops user data). - Windows: `%APPDATA%\OpenLess\` diff --git a/CLAUDE.md b/CLAUDE.md index 2a1400fa..d8a70984 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.) insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback -persistence.rs History/preferences/vocab JSON + Keychain credentials +persistence.rs History/preferences/vocab JSON + platform credential vault coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing permissions.rs TCC checks (Accessibility / Microphone) @@ -91,9 +91,9 @@ Invariants: ### Permissions, credentials, on-disk state -- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks Keychain lookups *and* every existing TCC grant. +- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant. - **TCC**: Microphone + Accessibility + AppleEvents. `NSMicrophoneUsageDescription` / `NSAccessibilityUsageDescription` / `NSAppleEventsUsageDescription` live in `openless-all/app/src-tauri/Info.plist`. After a fresh build that resets TCC, the app must be **fully quit and relaunched** after granting Accessibility before the global hotkey tap installs. -- **Credentials** live in Keychain under accounts in `CredentialAccount` (`volcengine.app_key`, `volcengine.access_key`, `volcengine.resource_id`, `ark.api_key`, `ark.model_id`, `ark.endpoint`). The plaintext fallback at `~/.openless/credentials.json` is read on first launch so legacy users keep their creds without re-entering. Never hard-code keys. +- **Credentials** live in the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service `com.openless.app`. The legacy plaintext JSON (`~/.openless/credentials.json` on macOS/Linux, `%APPDATA%\OpenLess\credentials.json` on Windows) is only a migration source and is removed after a successful vault write. Never hard-code keys or include legacy credential files in logs, exports, build artifacts, or bug reports. - **Per-user data**: - macOS: `~/Library/Application Support/OpenLess/{history.json, preferences.json, dictionary.json}` — capped at 200 history entries. **Do not rename `dictionary.json` to `vocab.json`** (drops user data). - Windows: `%APPDATA%\OpenLess\` diff --git a/README.md b/README.md index 182a1838..de83b297 100644 --- a/README.md +++ b/README.md @@ -195,9 +195,14 @@ Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\ ## Credentials -Credentials live in the local Keychain (service = `com.openless.app`). A plaintext JSON file at `~/.openless/credentials.json` (mode 0600, dir 0700) is kept as a dev-mode fallback when Keychain is unavailable. +Credentials live in the OS credential vault (service = `com.openless.app`): macOS Keychain, Windows Credential Manager, or Linux keyring. A legacy plaintext JSON file is read only as a migration source and removed after a successful vault write: -The repository contains no API keys, tokens, or private endpoints. +```text +macOS / Linux: ~/.openless/credentials.json +Windows: %APPDATA%\OpenLess\credentials.json +``` + +New credential writes do not persist plaintext secrets. The repository contains no API keys, tokens, or private endpoints. You'll need: @@ -247,7 +252,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS callback asr/ Volcengine streaming ASR (WebSocket) + Whisper HTTP polish.rs OpenAI-compatible chat-completions (Ark / DeepSeek / etc.) insertion.rs AX focused-element → clipboard + Cmd+V → copy-only fallback -persistence.rs History / preferences / vocab JSON + Keychain credentials +persistence.rs History / preferences / vocab JSON + platform credential vault permissions.rs TCC checks (Accessibility / Microphone) coordinator.rs State machine: Idle → Starting → Listening → Processing commands.rs Tauri IPC surface diff --git a/README.zh.md b/README.zh.md index d3cb93de..eea10e60 100644 --- a/README.zh.md +++ b/README.zh.md @@ -198,13 +198,14 @@ npm run build ## 凭据 -凭据保存在本机 Keychain(service = `com.openless.app`)。开发期同时维护一份明文 JSON 兜底,用于在 Keychain 不可用时回退: +凭据保存在系统凭据库(service = `com.openless.app`):macOS Keychain、Windows Credential Manager 或 Linux keyring。旧版明文 JSON 只作为迁移来源读取,成功写入系统凭据库后会被删除: ```text -~/.openless/credentials.json # 0600,目录 0700 +macOS / Linux: ~/.openless/credentials.json +Windows: %APPDATA%\OpenLess\credentials.json ``` -仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。 +新的凭据写入不会继续保存明文 secrets。仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。 需要配置的字段: @@ -254,7 +255,7 @@ recorder.rs 麦克风 → 16 kHz 单声道 Int16 PCM,RMS 回调 asr/ 火山引擎流式 ASR(WebSocket)+ Whisper HTTP polish.rs OpenAI 兼容 chat-completions(Ark / DeepSeek 等) insertion.rs AX focused-element → 剪贴板 + Cmd+V → 仅复制兜底 -persistence.rs 历史记录 / 偏好设置 / 词典 JSON + Keychain 凭据 +persistence.rs 历史记录 / 偏好设置 / 词典 JSON + 系统凭据库 permissions.rs TCC 权限检查(辅助功能 / 麦克风) coordinator.rs 状态机:Idle → Starting → Listening → Processing commands.rs Tauri IPC 接口 diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index 1c11ca18..4c92d1e4 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -597,7 +597,7 @@ if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -o throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials." } if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) { - Write-Warning "Legacy credentials.json is incomplete; continuing because the app may use the OS credential vault." + Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault." } $logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log" diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index c4cfc496..da167533 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -2600,6 +2600,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2683,6 +2698,16 @@ dependencies = [ "redox_syscall 0.7.5", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2881,7 +2906,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -3388,6 +3413,7 @@ dependencies = [ "ferrous-opencc", "futures-util", "global-hotkey", + "keyring", "libc", "log", "objc2 0.5.2", @@ -4369,7 +4395,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -4397,7 +4423,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.52.0", @@ -4507,6 +4533,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys 0.8.7", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 1ee8ed13..53134d05 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -51,6 +51,21 @@ enigo = "0.2" arboard = "3" rdev = "0.5" +[target.'cfg(target_os = "macos")'.dependencies.keyring] +version = "3.6.3" +default-features = false +features = ["apple-native"] + +[target.'cfg(target_os = "windows")'.dependencies.keyring] +version = "3.6.3" +default-features = false +features = ["windows-native"] + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies.keyring] +version = "3.6.3" +default-features = false +features = ["linux-native"] + [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.5" core-foundation = "0.10" diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 93cc6cdd..c9babd4c 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use std::time::Duration; use serde::Serialize; use serde_json::Value; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, State, Window}; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; @@ -143,7 +143,8 @@ fn configured(field: &Option) -> bool { } #[tauri::command] -pub fn set_credential(account: String, value: String) -> Result<(), String> { +pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> { + ensure_main_window(&window)?; let acc = parse_account(&account)?; if value.is_empty() { CredentialsVault::remove(acc).map_err(|e| e.to_string()) @@ -173,13 +174,22 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> { } /// 读出某个账号的实际值(用于设置页预填表单)。 -/// 与 Swift `CredentialsVault.get` 同语义,先 Keychain,缺则回落 ~/.openless/credentials.json。 +/// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。 #[tauri::command] -pub fn read_credential(account: String) -> Result, String> { +pub fn read_credential(window: Window, account: String) -> Result, String> { + ensure_main_window(&window)?; let acc = parse_account(&account)?; CredentialsVault::get(acc).map_err(|e| e.to_string()) } +fn ensure_main_window(window: &Window) -> Result<(), String> { + if window.label() == "main" { + Ok(()) + } else { + Err("credential access is only allowed from the main window".to_string()) + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ProviderCheckResult { diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f5951e97..503621a4 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -1,16 +1,15 @@ //! Local persistence: history JSON, user preferences JSON, vocab JSON, and -//! Keychain-backed credentials vault. +//! platform-backed credentials vault. //! //! Storage roots: //! - macOS: `~/Library/Application Support/OpenLess` //! - Windows: `%APPDATA%\OpenLess` //! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess` //! -//! Divergence from Swift: the Swift `CredentialsVault` falls back to a JSON -//! file (`~/.openless/credentials.json`) when Keychain is unavailable. The -//! Rust port intentionally does NOT replicate that fallback — we rely solely -//! on the platform keyring. The macOS service name (`com.openless.app`) is -//! preserved so existing Keychain entries from the Swift app remain readable. +//! Credential storage policy: provider credentials are stored in the OS +//! credential vault (macOS Keychain, Windows Credential Manager, Linux keyring). +//! A legacy plaintext JSON file is read once as a migration source and removed +//! after a successful vault write; new writes never persist plaintext secrets. use std::fs; use std::path::{Path, PathBuf}; @@ -32,12 +31,12 @@ const PREFERENCES_FILE: &str = "preferences.json"; const VOCAB_FILE: &str = "dictionary.json"; const VOCAB_PRESETS_FILE: &str = "vocab-presets.json"; -/// Swift 老 `CredentialsVault` 的 JSON 备用路径。 -/// 升级到 Tauri 版后,先尝试 Keychain;Keychain 没有时回落读这个文件, -/// 让用户在 Swift 版填过的凭据无需重输。 +/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。 const LEGACY_CREDS_DIR: &str = ".openless"; const LEGACY_CREDS_FILE: &str = "credentials.json"; +const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1"; + static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); fn credentials_lock() -> &'static Mutex<()> { @@ -115,12 +114,10 @@ fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Resul .with_context(|| format!("decode failed: {}", path.display())) } -// ───────────────────────── credentials JSON store ───────────────────────── +// ───────────────────────── credentials vault ───────────────────────── // -// 与 Swift `Sources/OpenLessPersistence/CredentialsVault.swift` 同源——纯 JSON 文件, -// 路径 `~/.openless/credentials.json`,权限 0600。**故意不用 Keychain**: -// ad-hoc 签名每次构建 hash 都变,Keychain ACL 失效后会触发逐账号弹框;用户已明确 -// 选择"直接写本地文件"。 +// 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider +// schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload,而不是逐字段散落。 // // v1 schema: // { @@ -262,56 +259,87 @@ fn credentials_path() -> Result { } } -fn ensure_credentials_dir(path: &Path) -> Result<()> { - if let Some(dir) = path.parent() { - fs::create_dir_all(dir).with_context(|| format!("create dir {} failed", dir.display()))?; - // 0700 on parent so other users can't peek - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); - } - } - Ok(()) +fn keyring_entry() -> Result { + keyring::Entry::new(CredentialsVault::SERVICE_NAME, KEYRING_CREDENTIALS_ACCOUNT) + .context("open system credential vault") } -fn load_credentials() -> CredsRoot { - let path = match credentials_path() { - Ok(p) => p, - Err(_) => return CredsRoot::default(), - }; - if !path.exists() { - return CredsRoot::default(); - } - let bytes = match fs::read(&path) { - Ok(b) => b, - Err(e) => { - log::warn!("[vault] read {} failed: {}", path.display(), e); - return CredsRoot::default(); - } - }; - serde_json::from_slice::(&bytes).unwrap_or_else(|e| { - log::warn!("[vault] parse {} failed: {}", path.display(), e); - CredsRoot::default() - }) -} - -fn save_credentials(root: &CredsRoot) -> Result<()> { - let path = credentials_path()?; - ensure_credentials_dir(&path)?; - // 写盘前过滤掉空 entry,保持 JSON 干净(mirrors Swift cleanedSchema)。 +fn clean_credentials(root: &CredsRoot) -> CredsRoot { let mut cleaned = root.clone(); cleaned.providers.asr.retain(|_, v| !v.is_empty()); cleaned.providers.llm.retain(|_, v| !v.is_empty()); - let json = serde_json::to_vec_pretty(&cleaned).context("encode credentials failed")?; - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, &json).with_context(|| format!("write {} failed", tmp.display()))?; - fs::rename(&tmp, &path).with_context(|| format!("rename to {} failed", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); + cleaned +} + +fn read_legacy_credentials_file(path: &Path) -> Option { + if !path.exists() { + return None; } + let bytes = match fs::read(path) { + Ok(b) => b, + Err(e) => { + log::warn!("[vault] read legacy {} failed: {}", path.display(), e); + return None; + } + }; + match serde_json::from_slice::(&bytes) { + Ok(root) => Some(root), + Err(e) => { + log::warn!("[vault] parse legacy {} failed: {}", path.display(), e); + None + } + } +} + +fn remove_legacy_credentials_file() { + let Ok(path) = credentials_path() else { return }; + if path.exists() { + if let Err(e) = fs::remove_file(&path) { + log::warn!("[vault] remove legacy {} failed: {}", path.display(), e); + } + } +} + +fn load_keyring_credentials() -> Result> { + let entry = keyring_entry()?; + let json = match entry.get_password() { + Ok(json) => json, + Err(keyring::Error::NoEntry) => return Ok(None), + Err(e) => return Err(anyhow!(e)).context("read system credential vault"), + }; + serde_json::from_str::(&json) + .map(Some) + .context("decode system credential vault payload") +} + +fn load_credentials() -> CredsRoot { + match load_keyring_credentials() { + Ok(Some(root)) => return root, + Ok(None) => {} + Err(e) => { + log::warn!("[vault] system credential read failed: {e}"); + return CredsRoot::default(); + } + } + + let Some(legacy) = credentials_path().ok().and_then(|p| read_legacy_credentials_file(&p)) else { + return CredsRoot::default(); + }; + + if let Err(e) = save_credentials(&legacy) { + log::warn!("[vault] legacy credential migration failed: {e}"); + return CredsRoot::default(); + } + legacy +} + +fn save_credentials(root: &CredsRoot) -> Result<()> { + let cleaned = clean_credentials(root); + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + keyring_entry()? + .set_password(&json) + .context("write system credential vault")?; + remove_legacy_credentials_file(); Ok(()) } @@ -682,12 +710,11 @@ pub struct CredentialsSnapshot { pub ark_endpoint: Option, } -/// 凭据存储——纯 JSON 文件,**不**走 Keychain。详见文件头部注释。 +/// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。 pub struct CredentialsVault; impl CredentialsVault { - /// 历史保留:Swift 时代以此名作为 Keychain service。Rust 不再使用 Keychain, - /// 但暴露此常量给可能仍依赖它的代码点。 + /// 系统凭据库 service name;macOS 下对应 Keychain service。 pub const SERVICE_NAME: &'static str = "com.openless.app"; pub fn get(account: CredentialAccount) -> Result> { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index c55ee618..0e4d37ef 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -291,6 +291,7 @@ export const en: typeof zhCN = { llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.', providerLabel: 'Provider', llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', + credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.', asrProviderDesc: 'Switching providers automatically loads the matching credentials.', asrTitle: 'ASR (transcription)', asrDesc: 'Used to turn speech into text in real time.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ac6798ec..6470e4e9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -289,6 +289,7 @@ export const zhCN = { llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。', providerLabel: '供应商', llmProviderDesc: '选择后将自动填入 Base URL 默认值。', + credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。', asrProviderDesc: '切换后将自动选用对应凭据。', asrTitle: 'ASR 语音(转写)', asrDesc: '用于将口述实时转写为文本。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5491af92..e449becf 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -291,6 +291,7 @@ export const zhTW: typeof zhCN = { llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。', providerLabel: '供應商', llmProviderDesc: '選擇後將自動填入 Base URL 默認值。', + credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。', asrProviderDesc: '切換後將自動選用對應憑據。', asrTitle: 'ASR 語音(轉寫)', asrDesc: '用於將口述實時轉寫爲文本。', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index f7aa64c2..2bc27a2b 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -508,6 +508,9 @@ function ProvidersSection() { return ( <> +
+ {t('settings.providers.credentialStorageNotice')} +
{t('settings.providers.llmTitle')}