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/scripts/windows-runtime-smoke.ps1 b/openless-all/app/scripts/windows-runtime-smoke.ps1 index 237b153b..053dcc19 100644 --- a/openless-all/app/scripts/windows-runtime-smoke.ps1 +++ b/openless-all/app/scripts/windows-runtime-smoke.ps1 @@ -71,7 +71,7 @@ if (-not $credentialStatus.ArkConfigured) { Write-Host "[warn] Ark LLM credentials are not configured; polishing will fall back or fail depending on mode." } if ($RequireCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { - throw "Real regression requires configured Volcengine ASR and Ark LLM credentials." + Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault." } Write-Host "" diff --git a/openless-all/app/scripts/windows-smoke-suite.ps1 b/openless-all/app/scripts/windows-smoke-suite.ps1 index 55797fed..5efc5492 100644 --- a/openless-all/app/scripts/windows-smoke-suite.ps1 +++ b/openless-all/app/scripts/windows-smoke-suite.ps1 @@ -90,7 +90,6 @@ try { Invoke-Step "Runtime smoke" { Invoke-Script (Join-Path $PSScriptRoot "windows-runtime-smoke.ps1") @{ ExePath = $ExePath - RequireCredentials = $true } } } diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index c4cfc496..61d62340 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -400,6 +411,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block-sys" version = "0.2.1" @@ -594,6 +614,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.61" @@ -668,6 +697,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1053,6 +1092,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1103,6 +1160,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2039,6 +2097,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2368,6 +2444,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2600,6 +2686,23 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "secret-service", + "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 +2786,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 +2994,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -2945,6 +3058,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -2955,6 +3081,39 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2972,6 +3131,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3388,6 +3578,7 @@ dependencies = [ "ferrous-opencc", "futures-util", "global-hotkey", + "keyring", "libc", "log", "objc2 0.5.2", @@ -4369,7 +4560,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -4397,7 +4588,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 +4698,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[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" @@ -4913,6 +5136,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -5300,7 +5529,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -7248,6 +7477,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xkbcommon" version = "0.7.0" @@ -7288,6 +7527,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.15.0" @@ -7318,9 +7589,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.2", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7333,9 +7617,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -7346,7 +7641,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.2", - "zvariant", + "zvariant 5.11.0", ] [[package]] @@ -7395,6 +7690,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -7462,6 +7771,19 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.11.0" @@ -7472,8 +7794,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.2", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7486,7 +7821,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 1ee8ed13..978fddab 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-sync-persistent", "crypto-rust"] + [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..a5438b35 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,16 @@ 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"; +const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk."; +// Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores +// passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that. +const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; + static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); fn credentials_lock() -> &'static Mutex<()> { @@ -115,12 +118,11 @@ 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;payload 会按平台 +// 凭据库限制拆成多个条目,避免 Windows 单条凭据 2560 bytes 限制。 // // v1 schema: // { @@ -262,56 +264,311 @@ 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)); +fn keyring_entry() -> Result { + keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT) +} + +fn keyring_entry_for(account: &str) -> Result { + keyring::Entry::new(CredentialsVault::SERVICE_NAME, account) + .context("open system credential vault") +} + +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()); + 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 } } - Ok(()) } -fn load_credentials() -> CredsRoot { - let path = match credentials_path() { - Ok(p) => p, - Err(_) => return CredsRoot::default(), +fn remove_legacy_credentials_file() -> Result<()> { + let Ok(path) = credentials_path() else { + return Ok(()); }; - if !path.exists() { - return CredsRoot::default(); + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("remove legacy credentials file {}", path.display()))?; } - let bytes = match fs::read(&path) { - Ok(b) => b, + Ok(()) +} + +fn remove_legacy_credentials_file_best_effort() { + if let Err(e) = remove_legacy_credentials_file() { + log::warn!("[vault] remove legacy credentials file failed: {e}"); + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct CredsChunkManifest { + openless_credentials_storage: String, + version: u32, + /// 旧版本(v1 早期)每次 save 都生成新 UUID 作为 chunk account 命名前缀, + /// 这让 macOS Keychain 的「始终允许」每次保存后失效 → 反复弹 ACL 弹窗。 + /// 现在 save 总用稳定 chunk.{index} 名,此字段仅向后兼容旧 manifest 读取。 + #[serde(default, skip_serializing_if = "Option::is_none")] + generation: Option, + chunks: usize, +} + +/// 旧版(generation=Some):`credentials.v1.chunk..{index}` +/// 新版(generation=None):`credentials.v1.chunk.{index}` —— 稳定名,ACL 长期有效 +fn chunk_account(generation: Option<&str>, index: usize) -> String { + match generation { + Some(gen) => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{gen}.{index}"), + None => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{index}"), + } +} + +fn chunk_json_payload(json: &str) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_units = 0usize; + for ch in json.chars() { + let units = ch.len_utf16(); + if !current.is_empty() && current_units + units > KEYRING_CHUNK_MAX_UTF16_UNITS { + chunks.push(std::mem::take(&mut current)); + current_units = 0; + } + current.push(ch); + current_units += units; + } + if !current.is_empty() || json.is_empty() { + chunks.push(current); + } + chunks +} + +fn read_chunk_manifest(json: &str) -> Option { + let manifest = serde_json::from_str::(json).ok()?; + if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 { + Some(manifest) + } else { + None + } +} + +fn get_keyring_password(account: &str) -> Result> { + match keyring_entry_for(account)?.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), Err(e) => { - log::warn!("[vault] read {} failed: {}", path.display(), e); - return CredsRoot::default(); + Err(anyhow!(e)).with_context(|| format!("read system credential vault {account}")) } + } +} + +fn delete_keyring_password(account: &str) { + match keyring_entry_for(account).and_then(|entry| { + entry + .delete_credential() + .with_context(|| format!("delete system credential vault {account}")) + }) { + Ok(()) | Err(_) => {} + } +} + +fn load_keyring_credentials() -> Result> { + let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else { + return Ok(None); }; - serde_json::from_slice::(&bytes).unwrap_or_else(|e| { - log::warn!("[vault] parse {} failed: {}", path.display(), e); - CredsRoot::default() - }) + + let manifest = read_chunk_manifest(&json_or_manifest) + .ok_or_else(|| anyhow!("invalid system credential vault manifest"))?; + let mut json = String::new(); + for index in 0..manifest.chunks { + let account = chunk_account(manifest.generation.as_deref(), index); + let chunk = get_keyring_password(&account)? + .ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?; + json.push_str(&chunk); + } + + serde_json::from_str::(&json) + .map(Some) + .context("decode system credential vault payload") +} + +fn load_legacy_keyring_credentials() -> CredsRoot { + match load_legacy_keyring_credentials_for_update() { + Ok(root) => root, + Err(e) => { + log::warn!("[vault] read legacy vault credentials failed: {e}"); + CredsRoot::default() + } + } +} + +fn load_legacy_keyring_credentials_for_update() -> Result { + let mut root = CredsRoot::default(); + for account in CredentialAccount::all() { + let legacy_account = account.keyring_account(); + match get_keyring_password(legacy_account) { + Ok(Some(value)) => write_account(&mut root, *account, Some(value)), + Ok(None) => {} + Err(e) => return Err(e.context(format!("read legacy vault {legacy_account}"))), + } + } + Ok(clean_credentials(&root)) +} + +fn remove_legacy_keyring_credentials() { + for account in CredentialAccount::all() { + delete_keyring_password(account.keyring_account()); + } +} + +fn load_legacy_credentials() -> Option { + credentials_path() + .ok() + .and_then(|p| read_legacy_credentials_file(&p)) +} + +fn legacy_vault_has_credentials(root: &CredsRoot) -> bool { + !root.providers.asr.is_empty() || !root.providers.llm.is_empty() +} + +fn load_legacy_sources_without_migration() -> CredsRoot { + if let Some(legacy) = load_legacy_credentials() { + return legacy; + } + + let legacy_vault = load_legacy_keyring_credentials(); + if legacy_vault_has_credentials(&legacy_vault) { + return legacy_vault; + } + + CredsRoot::default() +} + +fn migrate_legacy_sources() -> CredsRoot { + match migrate_legacy_sources_for_update() { + Ok(root) => root, + Err(e) => { + log::warn!("[vault] legacy credential migration failed: {e}"); + load_legacy_sources_without_migration() + } + } +} + +fn migrate_legacy_sources_for_update() -> Result { + if let Some(legacy) = load_legacy_credentials() { + save_credentials(&legacy)?; + remove_legacy_keyring_credentials(); + return Ok(legacy); + } + + let legacy_vault = load_legacy_keyring_credentials_for_update()?; + if legacy_vault_has_credentials(&legacy_vault) { + save_credentials(&legacy_vault)?; + remove_legacy_keyring_credentials(); + return Ok(legacy_vault); + } + + Ok(CredsRoot::default()) +} + +fn load_credentials() -> CredsRoot { + match load_keyring_credentials() { + Ok(Some(root)) => { + // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对 9 个 + // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain + // 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经 + // 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑, + // 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext + // JSON)不需要 ACL,可继续 best-effort 删除。 + remove_legacy_credentials_file_best_effort(); + root + } + Ok(None) => migrate_legacy_sources(), + Err(e) => { + log::warn!("[vault] system credential read failed: {e}"); + load_legacy_sources_without_migration() + } + } +} + +fn load_credentials_for_update() -> Result { + match load_keyring_credentials() { + Ok(Some(root)) => { + // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring + // entries,避免反复触发 macOS Keychain ACL 弹窗。 + remove_legacy_credentials_file_best_effort(); + Ok(root) + } + Ok(None) => migrate_legacy_sources_for_update(), + Err(e) => Err(e), + } } fn save_credentials(root: &CredsRoot) -> Result<()> { - let path = credentials_path()?; - ensure_credentials_dir(&path)?; - // 写盘前过滤掉空 entry,保持 JSON 干净(mirrors Swift cleanedSchema)。 - 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)); + let cleaned = clean_credentials(root); + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) + .ok() + .flatten() + .and_then(|value| read_chunk_manifest(&value)); + let chunks = chunk_json_payload(&json); + + // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 + // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 + // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation + // 设计的回退)。 + for (index, chunk) in chunks.iter().enumerate() { + let account = chunk_account(None, index); + keyring_entry_for(&account)? + .set_password(chunk) + .with_context(|| format!("write system credential vault chunk {index}"))?; } + + let manifest = CredsChunkManifest { + openless_credentials_storage: "chunked".to_string(), + version: 1, + generation: None, + chunks: chunks.len(), + }; + let manifest_json = + serde_json::to_string(&manifest).context("encode credential manifest failed")?; + keyring_entry()? + .set_password(&manifest_json) + .context("write system credential vault manifest")?; + + // 清理旧 chunks: + // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) + // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx + if let Some(previous) = previous_manifest { + match previous.generation.as_deref() { + Some(prev_gen) => { + for index in 0..previous.chunks { + delete_keyring_password(&chunk_account(Some(prev_gen), index)); + } + } + None => { + for index in chunks.len()..previous.chunks { + delete_keyring_password(&chunk_account(None, index)); + } + } + } + } + + remove_legacy_credentials_file_best_effort(); Ok(()) } @@ -682,12 +939,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> { @@ -697,7 +953,7 @@ impl CredentialsVault { pub fn set(account: CredentialAccount, value: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; let v = if value.is_empty() { None } else { @@ -709,7 +965,7 @@ impl CredentialsVault { pub fn remove(account: CredentialAccount) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; write_account(&mut root, account, None); save_credentials(&root) } @@ -721,14 +977,14 @@ impl CredentialsVault { pub fn set_active_asr_provider(id: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; root.active.asr = id.to_string(); save_credentials(&root) } pub fn set_active_llm_provider(id: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; root.active.llm = id.to_string(); save_credentials(&root) } @@ -757,14 +1013,33 @@ impl CredentialsVault { #[cfg(test)] mod tests { - use super::{list_vocab_presets, save_vocab_presets}; + use super::{ + chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS, + }; use crate::types::{VocabPreset, VocabPresetStore}; use std::fs; use std::path::PathBuf; + #[test] + fn credential_payload_chunks_stay_under_windows_blob_limit() { + let payload = format!( + "{}{}{}", + "a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25), + "😀".repeat(20), + "b".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25) + ); + let chunks = chunk_json_payload(&payload); + assert!(chunks.len() > 1); + assert_eq!(chunks.concat(), payload); + assert!(chunks + .iter() + .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); + } + #[test] fn vocab_presets_roundtrip_json_file() { - let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&tmp).expect("create temp dir"); // Linux path helper uses XDG_DATA_HOME first. unsafe { @@ -783,7 +1058,10 @@ mod tests { let loaded = list_vocab_presets().expect("list presets"); assert_eq!(loaded.custom.len(), 1); assert_eq!(loaded.custom[0].id, "test"); - assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]); + assert_eq!( + loaded.custom[0].phrases, + vec!["PR".to_string(), "CI".to_string()] + ); assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]); let _ = fs::remove_dir_all(&tmp); } 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/ja.ts b/openless-all/app/src/i18n/ja.ts index c98989f6..45312c31 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -293,6 +293,7 @@ export const ja: typeof zhCN = { llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。', providerLabel: 'サプライヤー', llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。', + credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。', asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', asrTitle: 'ASR 音声(転写)', asrDesc: '口述をリアルタイムでテキストに転写。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ac669fc1..93efe33a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -293,6 +293,7 @@ export const ko: typeof zhCN = { llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.', providerLabel: '공급자', llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.', + credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.', asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', asrTitle: 'ASR 음성(전사)', asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', 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')}