feat: use file-based secret store in debug mode to avoid macOS Keychain prompts

During development, every Rust recompile changes the binary signature,
causing macOS to invalidate Keychain "Always Allow" and prompt for the
password on every hot reload. Switch to a JSON file store in debug
builds so dev workflow is uninterrupted; release builds still use the
system keychain.
This commit is contained in:
t8y2 2026-05-02 16:45:17 +08:00
parent 4efd7c2517
commit 40a67434ee
2 changed files with 63 additions and 11 deletions

View File

@ -4,7 +4,7 @@ use tauri::{AppHandle, Manager, State};
use tokio::sync::Mutex;
use crate::commands::connection_secrets::{
load_connections_from_file, save_connections_to_file, KeyringConnectionSecretStore,
create_secret_store, load_connections_from_file, save_connections_to_file,
};
use crate::commands::query_cancel::RunningQueries;
use crate::db;
@ -233,15 +233,15 @@ pub async fn save_connections(
configs: Vec<ConnectionConfig>,
) -> Result<(), String> {
let path = connections_file(&app)?;
let store = KeyringConnectionSecretStore;
save_connections_to_file(&path, &configs, &store)
let store = create_secret_store(&app);
save_connections_to_file(&path, &configs, &*store)
}
#[tauri::command]
pub async fn load_connections(app: AppHandle) -> Result<Vec<ConnectionConfig>, String> {
let path = connections_file(&app)?;
let store = KeyringConnectionSecretStore;
load_connections_from_file(&path, &store)
let store = create_secret_store(&app);
load_connections_from_file(&path, &*store)
}
#[tauri::command]

View File

@ -1,6 +1,8 @@
use crate::models::connection::ConnectionConfig;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager};
pub(super) const MAIN_PASSWORD_KEY: &str = "password";
pub(super) const SSH_PASSWORD_KEY: &str = "ssh_password";
@ -42,10 +44,60 @@ impl ConnectionSecretStore for KeyringConnectionSecretStore {
}
}
pub(super) struct FileSecretStore {
path: PathBuf,
}
impl FileSecretStore {
fn new(path: PathBuf) -> Self {
Self { path }
}
fn read_store(&self) -> HashMap<String, String> {
std::fs::read_to_string(&self.path)
.ok()
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default()
}
fn write_store(&self, map: &HashMap<String, String>) -> Result<(), String> {
let json = serde_json::to_string_pretty(map).map_err(|e| e.to_string())?;
std::fs::write(&self.path, json).map_err(|e| e.to_string())
}
}
impl ConnectionSecretStore for FileSecretStore {
fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String> {
let mut map = self.read_store();
map.insert(secret_account(connection_id, key), secret.to_string());
self.write_store(&map)
}
fn get_secret(&self, connection_id: &str, key: &str) -> Result<Option<String>, String> {
Ok(self.read_store().get(&secret_account(connection_id, key)).cloned())
}
fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String> {
let mut map = self.read_store();
map.remove(&secret_account(connection_id, key));
self.write_store(&map)
}
}
pub(super) fn create_secret_store(app: &AppHandle) -> Box<dyn ConnectionSecretStore> {
if cfg!(debug_assertions) {
let dir = app.path().app_data_dir().expect("failed to resolve app data dir");
std::fs::create_dir_all(&dir).ok();
Box::new(FileSecretStore::new(dir.join("secrets.json")))
} else {
Box::new(KeyringConnectionSecretStore)
}
}
pub(super) fn save_connections_to_file(
path: &Path,
configs: &[ConnectionConfig],
store: &impl ConnectionSecretStore,
store: &dyn ConnectionSecretStore,
) -> Result<(), String> {
delete_removed_connection_secrets(path, configs, store)?;
for config in configs {
@ -64,7 +116,7 @@ pub(super) fn save_connections_to_file(
pub(super) fn load_connections_from_file(
path: &Path,
store: &impl ConnectionSecretStore,
store: &dyn ConnectionSecretStore,
) -> Result<Vec<ConnectionConfig>, String> {
if !path.exists() {
return Ok(vec![]);
@ -118,7 +170,7 @@ pub(super) fn load_connections_from_file(
fn delete_removed_connection_secrets(
path: &Path,
configs: &[ConnectionConfig],
store: &impl ConnectionSecretStore,
store: &dyn ConnectionSecretStore,
) -> Result<(), String> {
if !path.exists() {
return Ok(());
@ -141,7 +193,7 @@ fn delete_removed_connection_secrets(
}
fn persist_secret(
store: &impl ConnectionSecretStore,
store: &dyn ConnectionSecretStore,
connection_id: &str,
key: &str,
secret: &str,
@ -154,7 +206,7 @@ fn persist_secret(
}
fn persist_optional_secret(
store: &impl ConnectionSecretStore,
store: &dyn ConnectionSecretStore,
connection_id: &str,
key: &str,
secret: Option<&str>,