233 lines
9.2 KiB
Rust
233 lines
9.2 KiB
Rust
//! Settings export/import — see triple-c#35.
|
|||
|
|
//!
|
||
|
|
//! Exports the *host* environment (global `AppSettings` plus the global
|
||
|
|
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||
|
|
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||
|
|
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||
|
|
//! out of scope: per-project settings, per-project secrets, and anything
|
||
|
|
//! living in a project's Docker volumes.
|
||
|
|
//!
|
||
|
|
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||
|
|
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||
|
|
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||
|
|
//! host path string is the exact shape of bug that produced this app's past
|
||
|
|
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||
|
|
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||
|
|
//! settings_import` resolves the chosen path itself and remembers it
|
||
|
|
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||
|
|
//! the same file without the path ever crossing back over IPC.
|
||
|
|
//!
|
||
|
|
//! The password is re-entered (not cached) between preview and apply, so
|
||
|
|
//! that nothing here holds decrypted plaintext — export/import secrets
|
||
|
|
//! included — in memory for longer than one command's execution.
|
||
|
|
|
||
|
|
use std::path::PathBuf;
|
||
|
|
|
||
|
|
use tauri::State;
|
||
|
|
use tauri_plugin_dialog::DialogExt;
|
||
|
|
|
||
|
|
use crate::models::{
|
||
|
|
ExportedSecrets, SettingsExportPayload, SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||
|
|
};
|
||
|
|
use crate::storage::{secure, settings_crypto};
|
||
|
|
use crate::AppState;
|
||
|
|
|
||
|
|
const FILE_EXTENSION: &str = "triplec";
|
||
|
|
|
||
|
|
fn suggested_export_name() -> String {
|
||
|
|
// Timestamped so exporting more than once doesn't silently overwrite an
|
||
|
|
// earlier file just because the save dialog defaults to the same name.
|
||
|
|
format!(
|
||
|
|
"triple-c-settings-{}.{}",
|
||
|
|
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||
|
|
FILE_EXTENSION
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||
|
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||
|
|
window
|
||
|
|
.dialog()
|
||
|
|
.file()
|
||
|
|
.set_parent(window)
|
||
|
|
.set_title("Export Triple-C settings")
|
||
|
|
.set_file_name(suggested)
|
||
|
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||
|
|
.save_file(move |picked| {
|
||
|
|
let _ = tx.send(picked);
|
||
|
|
});
|
||
|
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||
|
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||
|
|
window
|
||
|
|
.dialog()
|
||
|
|
.file()
|
||
|
|
.set_parent(window)
|
||
|
|
.set_title("Import Triple-C settings")
|
||
|
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||
|
|
.pick_file(move |picked| {
|
||
|
|
let _ = tx.send(picked);
|
||
|
|
});
|
||
|
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Gather the current global secrets. A missing secret reads as `None` — a
|
||
|
|
/// keychain read failure is treated as "nothing to export" for that one
|
||
|
|
/// entry rather than aborting the whole export, matching how the rest of
|
||
|
|
/// this app degrades a keychain error to "absent" (`has_claude_oauth_token`,
|
||
|
|
/// `has_gateway_api_key`) rather than surfacing it as a hard failure.
|
||
|
|
fn gather_secrets() -> ExportedSecrets {
|
||
|
|
ExportedSecrets {
|
||
|
|
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||
|
|
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||
|
|
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Export the current global settings and secrets to a password-encrypted
|
||
|
|
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||
|
|
/// deliberately distinguishable from one so the frontend shows nothing
|
||
|
|
/// rather than a "failed" toast for a plain cancel.
|
||
|
|
#[tauri::command]
|
||
|
|
pub async fn export_settings(
|
||
|
|
password: String,
|
||
|
|
window: tauri::Window,
|
||
|
|
state: State<'_, AppState>,
|
||
|
|
) -> Result<bool, String> {
|
||
|
|
if password.is_empty() {
|
||
|
|
return Err("A password is required to export settings.".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||
|
|
return Ok(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
let secrets = gather_secrets();
|
||
|
|
if secrets.is_empty() {
|
||
|
|
log::info!("Exporting settings with no global secrets configured on this machine");
|
||
|
|
}
|
||
|
|
|
||
|
|
let payload = SettingsExportPayload {
|
||
|
|
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||
|
|
exported_at: chrono::Utc::now().to_rfc3339(),
|
||
|
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||
|
|
settings: state.settings_store.get(),
|
||
|
|
secrets,
|
||
|
|
};
|
||
|
|
|
||
|
|
let plaintext = serde_json::to_vec(&payload)
|
||
|
|
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?;
|
||
|
|
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||
|
|
|
||
|
|
std::fs::write(&dest, encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||
|
|
|
||
|
|
Ok(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||
|
|
/// preview (counts and presence flags only — never a secret value) for a
|
||
|
|
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||
|
|
///
|
||
|
|
/// Remembers the resolved path in `AppState::pending_settings_import` for
|
||
|
|
/// `apply_settings_import` to re-read; does **not** remember the decrypted
|
||
|
|
/// payload itself, so the password must be supplied again to actually apply
|
||
|
|
/// it — seeing the preview is not the same as committing to it.
|
||
|
|
#[tauri::command]
|
||
|
|
pub async fn preview_settings_import(
|
||
|
|
password: String,
|
||
|
|
window: tauri::Window,
|
||
|
|
state: State<'_, AppState>,
|
||
|
|
) -> Result<Option<SettingsImportPreview>, String> {
|
||
|
|
if password.is_empty() {
|
||
|
|
return Err("A password is required to open a settings export.".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
let Some(path) = pick_import_open_path(&window).await else {
|
||
|
|
return Ok(None);
|
||
|
|
};
|
||
|
|
|
||
|
|
let payload = read_and_decrypt(&path, &password)?;
|
||
|
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||
|
|
|
||
|
|
*state.pending_settings_import.lock().await = Some(path);
|
||
|
|
|
||
|
|
Ok(Some(preview))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||
|
|
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||
|
|
/// apply this file" entry point, deliberately: seeing the preview first is
|
||
|
|
/// required, not just encouraged, since it is the only place a user is told
|
||
|
|
/// what an import is about to touch before it touches it.
|
||
|
|
///
|
||
|
|
/// Global settings are replaced wholesale — an import is "restore this
|
||
|
|
/// environment," not a field-by-field merge. Global secrets are handled
|
||
|
|
/// differently and on purpose: **only secrets actually present in the
|
||
|
|
/// import are written**; a secret the export doesn't have is left alone on
|
||
|
|
/// this machine rather than cleared, because an absent secret in the export
|
||
|
|
/// means "the source machine never had this configured," not "delete this
|
||
|
|
/// on import." A user who wants to clear a secret already has dedicated UI
|
||
|
|
/// for that (signing out of shared auth, clearing the gateway key).
|
||
|
|
#[tauri::command]
|
||
|
|
pub async fn apply_settings_import(
|
||
|
|
password: String,
|
||
|
|
state: State<'_, AppState>,
|
||
|
|
) -> Result<crate::models::AppSettings, String> {
|
||
|
|
if password.is_empty() {
|
||
|
|
return Err("A password is required to import settings.".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
let path = state
|
||
|
|
.pending_settings_import
|
||
|
|
.lock()
|
||
|
|
.await
|
||
|
|
.take()
|
||
|
|
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||
|
|
|
||
|
|
let payload = read_and_decrypt(&path, &password)?;
|
||
|
|
|
||
|
|
let saved = crate::commands::settings_commands::update_settings(payload.settings, state).await?;
|
||
|
|
|
||
|
|
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
|
||
|
|
if let Err(e) = secure::store_claude_oauth_token(&token) {
|
||
|
|
log::warn!("Settings import: could not restore the shared Claude login: {}", e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
|
||
|
|
if let Err(e) = secure::store_gateway_api_key(&key) {
|
||
|
|
log::warn!("Settings import: could not restore the gateway provider API key: {}", e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
|
||
|
|
if let Err(e) = secure::store_gateway_master_key(&key) {
|
||
|
|
log::warn!("Settings import: could not restore the gateway master key: {}", e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(saved)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn non_blank(value: Option<String>) -> Option<String> {
|
||
|
|
value.filter(|v| !v.trim().is_empty())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn read_and_decrypt(path: &std::path::Path, password: &str) -> Result<SettingsExportPayload, String> {
|
||
|
|
let encrypted = std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||
|
|
let plaintext = settings_crypto::decrypt(&encrypted, password)?;
|
||
|
|
|
||
|
|
let payload: SettingsExportPayload = serde_json::from_slice(&plaintext)
|
||
|
|
.map_err(|e| format!("This file doesn't look like a valid settings export: {}", e))?;
|
||
|
|
|
||
|
|
if payload.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
|
||
|
|
return Err(format!(
|
||
|
|
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
|
||
|
|
Update Triple-C before importing it.",
|
||
|
|
payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(payload)
|
||
|
|
}
|