merge upstream main into windows local asr
This commit is contained in:
commit
4fa34b486e
18 files changed
+734
-101
No files matched your search
@@ -32,10 +32,15 @@ jobs:
|
||||
rust-target: aarch64-apple-darwin
|
||||
updater-target: darwin
|
||||
updater-arch: aarch64
|
||||
- platform: macos-13
|
||||
rust-target: x86_64-apple-darwin
|
||||
updater-target: darwin
|
||||
updater-arch: x86_64
|
||||
# 暂时搁置 Intel mac 构建:GitHub Actions 把 macos-13 标记为 deprecated
|
||||
# runner,pool 容量紧张到每次 dispatch 都 queue 1-2h 拿不到分配,多次
|
||||
# release 都因这个 job 卡住整个 run 的 conclusion。等 GH 把 macOS x86_64
|
||||
# 迁移到非 deprecated runner(macos-14 / macos-15-large)或 macos-13
|
||||
# pool 缓解后再恢复。Apple Silicon dmg 在 Intel mac 上跑 Rosetta 仍可用。
|
||||
# - platform: macos-13
|
||||
# rust-target: x86_64-apple-darwin
|
||||
# updater-target: darwin
|
||||
# updater-arch: x86_64
|
||||
- platform: windows-latest
|
||||
rust-target: x86_64-pc-windows-msvc
|
||||
updater-target: windows
|
||||
|
||||
@@ -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\`
|
||||
|
||||
@@ -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\`
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-4
@@ -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 接口
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -90,7 +90,6 @@ try {
|
||||
Invoke-Step "Runtime smoke" {
|
||||
Invoke-Script (Join-Path $PSScriptRoot "windows-runtime-smoke.ps1") @{
|
||||
ExePath = $ExePath
|
||||
RequireCredentials = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+306
-14
@@ -424,6 +424,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"
|
||||
@@ -637,6 +646,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"
|
||||
@@ -1162,6 +1180,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 = "deflate64"
|
||||
version = "0.1.12"
|
||||
@@ -2226,6 +2262,15 @@ 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"
|
||||
@@ -2573,6 +2618,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -2808,6 +2854,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"
|
||||
@@ -2891,6 +2954,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"
|
||||
@@ -3110,7 +3183,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -3174,6 +3247,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"
|
||||
@@ -3184,6 +3270,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"
|
||||
@@ -3201,6 +3320,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"
|
||||
@@ -3618,6 +3768,7 @@ dependencies = [
|
||||
"foundry-local-sdk",
|
||||
"futures-util",
|
||||
"global-hotkey",
|
||||
"keyring",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -4613,7 +4764,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4641,7 +4792,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.61.2",
|
||||
@@ -4751,6 +4902,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"
|
||||
@@ -5157,6 +5340,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"
|
||||
@@ -5565,7 +5754,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
"zbus 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7594,6 +7783,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"
|
||||
@@ -7643,6 +7842,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"
|
||||
@@ -7673,9 +7904,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]]
|
||||
@@ -7688,9 +7932,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]]
|
||||
@@ -7701,7 +7956,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.2",
|
||||
"zvariant",
|
||||
"zvariant 5.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7901,6 +8156,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"
|
||||
@@ -7911,8 +8179,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]]
|
||||
@@ -7925,7 +8206,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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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::asr::local::foundry::{
|
||||
model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus,
|
||||
@@ -148,7 +148,8 @@ fn configured(field: &Option<String>) -> 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())
|
||||
@@ -184,13 +185,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<Option<String>, String> {
|
||||
pub fn read_credential(window: Window, account: String) -> Result<Option<String>, 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 {
|
||||
|
||||
@@ -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<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn credentials_lock() -> &'static Mutex<()> {
|
||||
@@ -115,12 +118,11 @@ fn read_or_default<T: for<'de> 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:
|
||||
// {
|
||||
@@ -269,56 +271,311 @@ fn credentials_path() -> Result<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT)
|
||||
}
|
||||
|
||||
fn keyring_entry_for(account: &str) -> Result<keyring::Entry> {
|
||||
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<CredsRoot> {
|
||||
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::<CredsRoot>(&bytes) {
|
||||
Ok(root) => Some(root),
|
||||
Err(e) => {
|
||||
log::warn!("[vault] parse legacy {} failed: {}", path.display(), e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_legacy_credentials_file() -> Result<()> {
|
||||
let Ok(path) = credentials_path() else {
|
||||
return Ok(());
|
||||
};
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("remove legacy credentials file {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_credentials() -> CredsRoot {
|
||||
let path = match credentials_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return CredsRoot::default(),
|
||||
};
|
||||
if !path.exists() {
|
||||
return CredsRoot::default();
|
||||
fn remove_legacy_credentials_file_best_effort() {
|
||||
if let Err(e) = remove_legacy_credentials_file() {
|
||||
log::warn!("[vault] remove legacy credentials file failed: {e}");
|
||||
}
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("[vault] read {} failed: {}", path.display(), e);
|
||||
return CredsRoot::default();
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
chunks: usize,
|
||||
}
|
||||
|
||||
/// 旧版(generation=Some):`credentials.v1.chunk.<UUID>.{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<String> {
|
||||
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<CredsChunkManifest> {
|
||||
let manifest = serde_json::from_str::<CredsChunkManifest>(json).ok()?;
|
||||
if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 {
|
||||
Some(manifest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_keyring_password(account: &str) -> Result<Option<String>> {
|
||||
match keyring_entry_for(account)?.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => {
|
||||
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<Option<CredsRoot>> {
|
||||
let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
serde_json::from_slice::<CredsRoot>(&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::<CredsRoot>(&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<CredsRoot> {
|
||||
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<CredsRoot> {
|
||||
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<CredsRoot> {
|
||||
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<CredsRoot> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -689,12 +946,11 @@ pub struct CredentialsSnapshot {
|
||||
pub ark_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// 凭据存储——纯 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<Option<String>> {
|
||||
@@ -704,7 +960,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 {
|
||||
@@ -716,7 +972,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)
|
||||
}
|
||||
@@ -728,14 +984,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)
|
||||
}
|
||||
@@ -764,14 +1020,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 {
|
||||
@@ -790,7 +1065,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);
|
||||
}
|
||||
|
||||
@@ -147,18 +147,38 @@ function isWindowHotkeyCandidate(event: KeyboardEvent): boolean {
|
||||
}
|
||||
|
||||
function StartupShell() {
|
||||
// 用透明背景:main window 是 transparent + macOSPrivateApi(NSVisualEffectView 磨砂)。
|
||||
// 之前用 linear-gradient(rgba(245,245,247,0.96)...) 会盖过 macOS vibrancy,启动时
|
||||
// 长时间在 'checking' phase(凭据迁移 / 权限 probe 慢)会让窗口看起来「左侧白屏 +
|
||||
// 右侧磨砂」割裂。现在背景全透明,让磨砂统一展开,提示文字 + icon 用一个轻量
|
||||
// pill 卡片承载,跟 capsule 视觉一致。
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'linear-gradient(180deg, rgba(245,245,247,0.96) 0%, rgba(232,232,236,0.96) 100%)',
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-ink-3)',
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, fontWeight: 500 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
padding: '10px 16px',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255, 255, 255, 0.55)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
border: '0.5px solid rgba(0, 0, 0, 0.06)',
|
||||
boxShadow: '0 4px 14px -6px rgba(0, 0, 0, 0.18), 0 0 0 0.5px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
<img src="AppIcon.png" alt="" style={{ width: 18, height: 18, borderRadius: 4 }} />
|
||||
<span>OpenLess 正在启动</span>
|
||||
</div>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -293,6 +293,7 @@ export const ja: typeof zhCN = {
|
||||
llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。',
|
||||
providerLabel: 'サプライヤー',
|
||||
llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。',
|
||||
credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。',
|
||||
asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。',
|
||||
asrTitle: 'ASR 音声(転写)',
|
||||
asrDesc: '口述をリアルタイムでテキストに転写。',
|
||||
|
||||
@@ -293,6 +293,7 @@ export const ko: typeof zhCN = {
|
||||
llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.',
|
||||
providerLabel: '공급자',
|
||||
llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.',
|
||||
credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.',
|
||||
asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.',
|
||||
asrTitle: 'ASR 음성(전사)',
|
||||
asrDesc: '구술을 실시간으로 텍스트로 전사합니다.',
|
||||
|
||||
@@ -289,6 +289,7 @@ export const zhCN = {
|
||||
llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。',
|
||||
providerLabel: '供应商',
|
||||
llmProviderDesc: '选择后将自动填入 Base URL 默认值。',
|
||||
credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。',
|
||||
asrProviderDesc: '切换后将自动选用对应凭据。',
|
||||
asrTitle: 'ASR 语音(转写)',
|
||||
asrDesc: '用于将口述实时转写为文本。',
|
||||
|
||||
@@ -291,6 +291,7 @@ export const zhTW: typeof zhCN = {
|
||||
llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。',
|
||||
providerLabel: '供應商',
|
||||
llmProviderDesc: '選擇後將自動填入 Base URL 默認值。',
|
||||
credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。',
|
||||
asrProviderDesc: '切換後將自動選用對應憑據。',
|
||||
asrTitle: 'ASR 語音(轉寫)',
|
||||
asrDesc: '用於將口述實時轉寫爲文本。',
|
||||
|
||||
@@ -514,6 +514,9 @@ function ProvidersSection() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
|
||||
{t('settings.providers.credentialStorageNotice')}
|
||||
</div>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.providers.llmTitle')}</div>
|
||||
|
||||
Reference in new issue
Block a user