From ecd194cc3a231878a7df8d0e92b4d97d844b4d04 Mon Sep 17 00:00:00 2001 From: David Longman Date: Fri, 6 Mar 2026 11:34:39 -0700 Subject: [PATCH] Add backup security mode selection --- src-tauri/Cargo.lock | 11 ++ src-tauri/Cargo.toml | 1 + src-tauri/src/auth/mod.rs | 2 + src-tauri/src/auth/settings.rs | 94 +++++++++++++++ src-tauri/src/commands/account.rs | 191 ++++++++++++++---------------- src-tauri/src/lib.rs | 8 +- src-tauri/src/types.rs | 23 ++++ src/App.tsx | 125 ++++++++++++++++++- src/hooks/useAccounts.ts | 25 +++- src/types/index.ts | 7 ++ 10 files changed, 375 insertions(+), 112 deletions(-) create mode 100644 src-tauri/src/auth/settings.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a3bab35..a729845 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -511,6 +511,7 @@ dependencies = [ "dirs", "flate2", "futures", + "keyring", "pbkdf2", "rand 0.9.2", "reqwest", @@ -2001,6 +2002,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 35c6783..3b604b7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,3 +36,4 @@ url = "2" flate2 = "1" chacha20poly1305 = "0.10" pbkdf2 = "0.12" +keyring = "3" diff --git a/src-tauri/src/auth/mod.rs b/src-tauri/src/auth/mod.rs index 8827172..0470adb 100644 --- a/src-tauri/src/auth/mod.rs +++ b/src-tauri/src/auth/mod.rs @@ -1,11 +1,13 @@ //! Authentication module pub mod oauth_server; +pub mod settings; pub mod storage; pub mod switcher; pub mod token_refresh; pub use oauth_server::*; +pub use settings::*; pub use storage::*; pub use switcher::*; pub use token_refresh::*; diff --git a/src-tauri/src/auth/settings.rs b/src-tauri/src/auth/settings.rs new file mode 100644 index 0000000..d1fdca6 --- /dev/null +++ b/src-tauri/src/auth/settings.rs @@ -0,0 +1,94 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use base64::Engine; +use rand::RngCore; + +use crate::types::{AppSettings, ExportSecurityMode}; + +use super::storage::get_config_dir; + +const KEYCHAIN_SERVICE: &str = "com.lampese.codex-switcher"; +const KEYCHAIN_ACCOUNT: &str = "full-file-export-secret"; + +pub fn get_settings_file() -> Result { + Ok(get_config_dir()?.join("settings.json")) +} + +pub fn load_settings() -> Result { + let path = get_settings_file()?; + + if !path.exists() { + return Ok(AppSettings::default()); + } + + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read settings file: {}", path.display()))?; + + let settings: AppSettings = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse settings file: {}", path.display()))?; + + Ok(settings) +} + +pub fn save_settings(settings: &AppSettings) -> Result<()> { + let path = get_settings_file()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create config directory: {}", parent.display()))?; + } + let content = serde_json::to_vec_pretty(settings).context("Failed to serialize settings")?; + fs::write(&path, content) + .with_context(|| format!("Failed to write settings file: {}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } + + Ok(()) +} + +pub fn set_export_security_mode(mode: ExportSecurityMode) -> Result { + let mut settings = load_settings()?; + settings.export_security_mode = Some(mode); + save_settings(&settings)?; + Ok(settings) +} + +pub fn get_or_create_keychain_secret() -> Result { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + .context("Failed to access OS keychain entry")?; + + if let Ok(secret) = entry.get_password() { + if !secret.trim().is_empty() { + return Ok(secret); + } + } + + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + + entry + .set_password(&secret) + .context("Failed to store backup secret in OS keychain")?; + + Ok(secret) +} + +pub fn get_keychain_secret() -> Result { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + .context("Failed to access OS keychain entry")?; + let secret = entry + .get_password() + .context("No OS keychain backup secret has been created on this device yet")?; + + if secret.trim().is_empty() { + anyhow::bail!("Stored OS keychain backup secret is empty"); + } + + Ok(secret) +} diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 6743d7f..8b0ba91 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -2,10 +2,12 @@ use crate::auth::{ add_account, create_chatgpt_account_from_refresh_token, get_active_account, - import_from_auth_json, load_accounts, remove_account, save_accounts, set_active_account, + get_keychain_secret, get_or_create_keychain_secret, import_from_auth_json, load_accounts, + load_settings, remove_account, save_accounts, set_active_account, set_export_security_mode, switch_to_account, touch_account, }; use crate::types::{AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, StoredAccount}; +use crate::types::{AppSettings, ExportSecurityMode}; use anyhow::Context; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -22,23 +24,20 @@ use std::collections::HashSet; use std::fs; use std::io::{Read, Write}; -#[cfg(windows)] -use std::os::windows::process::CommandExt; - -#[cfg(windows)] -const CREATE_NO_WINDOW: u32 = 0x08000000; - const SLIM_EXPORT_PREFIX: &str = "css1."; const SLIM_FORMAT_VERSION: u8 = 1; const SLIM_AUTH_API_KEY: u8 = 0; const SLIM_AUTH_CHATGPT: u8 = 1; const FULL_FILE_MAGIC: &[u8; 4] = b"CSWF"; -const FULL_FILE_VERSION: u8 = 1; +const FULL_FILE_VERSION: u8 = 2; const FULL_SALT_LEN: usize = 16; const FULL_NONCE_LEN: usize = 24; const FULL_KDF_ITERATIONS: u32 = 210_000; const FULL_PRESET_PASSPHRASE: &str = "gT7kQ9mV2xN4pL8sR1dH6zW3cB5yF0uJ_aE7nK2tP9vM4rX1"; +const FULL_SECURITY_LESS_SECURE: u8 = 0; +const FULL_SECURITY_PASSPHRASE: u8 = 1; +const FULL_SECURITY_KEYCHAIN: u8 = 2; const MAX_IMPORT_JSON_BYTES: u64 = 2 * 1024 * 1024; const MAX_IMPORT_FILE_BYTES: u64 = 8 * 1024 * 1024; @@ -130,29 +129,19 @@ pub async fn switch_account(account_id: String) -> Result<(), String> { // Update last_used_at touch_account(&account_id).map_err(|e| e.to_string())?; - // Restart Antigravity background process if it is running - // This allows it to pick up the new authorization file seamlessly - if let Ok(pids) = find_antigravity_processes() { - for pid in pids { - #[cfg(unix)] - { - let _ = std::process::Command::new("kill") - .arg("-9") - .arg(pid.to_string()) - .output(); - } - #[cfg(windows)] - { - let _ = std::process::Command::new("taskkill") - .args(["/F", "/PID", &pid.to_string()]) - .output(); - } - } - } - Ok(()) } +#[tauri::command] +pub async fn get_app_settings() -> Result { + load_settings().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn save_export_security_mode(mode: ExportSecurityMode) -> Result { + set_export_security_mode(mode).map_err(|e| e.to_string()) +} + /// Remove an account #[tauri::command] pub async fn delete_account(account_id: String) -> Result<(), String> { @@ -205,10 +194,17 @@ pub async fn import_accounts_slim_text(payload: String) -> Result Result<(), String> { +pub async fn export_accounts_full_encrypted_file( + path: String, + passphrase: Option, +) -> Result<(), String> { let store = load_accounts().map_err(|e| e.to_string())?; - let encrypted = - encode_full_encrypted_store(&store, FULL_PRESET_PASSPHRASE).map_err(|e| e.to_string())?; + let settings = load_settings().map_err(|e| e.to_string())?; + let mode = settings + .export_security_mode + .unwrap_or(ExportSecurityMode::LessSecure); + let secret = resolve_export_secret(mode, passphrase).map_err(|e| e.to_string())?; + let encrypted = encode_full_encrypted_store(&store, mode, &secret).map_err(|e| e.to_string())?; write_encrypted_file(&path, &encrypted).map_err(|e| e.to_string())?; Ok(()) } @@ -217,9 +213,10 @@ pub async fn export_accounts_full_encrypted_file(path: String) -> Result<(), Str #[tauri::command] pub async fn import_accounts_full_encrypted_file( path: String, + passphrase: Option, ) -> Result { let encrypted = read_encrypted_file(&path).map_err(|e| e.to_string())?; - let imported = decode_full_encrypted_store(&encrypted, FULL_PRESET_PASSPHRASE) + let imported = decode_full_encrypted_store(&encrypted, passphrase) .map_err(|e| e.to_string())?; validate_imported_store(&imported).map_err(|e| e.to_string())?; @@ -229,69 +226,22 @@ pub async fn import_accounts_full_encrypted_file( Ok(summary) } -/// Find all running Antigravity codex assistant processes -fn find_antigravity_processes() -> anyhow::Result> { - let mut pids = Vec::new(); - - #[cfg(unix)] - { - // Use ps with custom format to get the pid and full command line - let output = std::process::Command::new("ps") - .args(["-eo", "pid,command"]) - .output()?; - - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines().skip(1) { - let line = line.trim(); - if line.is_empty() { - continue; - } - - if let Some((pid_str, command)) = line.split_once(' ') { - let pid_str = pid_str.trim(); - let command = command.trim(); - - // Antigravity processes have a specific path format - let is_antigravity = (command.contains(".antigravity/extensions/openai.chatgpt") - || command.contains(".vscode/extensions/openai.chatgpt")) - && (command.ends_with("codex app-server --analytics-default-enabled") - || command.contains("/codex app-server")); - - if is_antigravity { - if let Ok(pid) = pid_str.parse::() { - pids.push(pid); - } - } - } - } - } - - #[cfg(windows)] - { - // Use tasklist on Windows - // For Windows we might need a more precise WMI query to get command line args, - // but for now we look for codex.exe PIDs and verify they're not ours - let output = std::process::Command::new("tasklist") - .creation_flags(CREATE_NO_WINDOW) - .args(["/FI", "IMAGENAME eq codex.exe", "/FO", "CSV", "/NH"]) - .output()?; - - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let parts: Vec<&str> = line.split(',').collect(); - if parts.len() > 1 { - let name = parts[0].trim_matches('"').to_lowercase(); - if name == "codex.exe" { - let pid_str = parts[1].trim_matches('"'); - if let Ok(pid) = pid_str.parse::() { - pids.push(pid); - } - } +fn resolve_export_secret( + mode: ExportSecurityMode, + passphrase: Option, +) -> anyhow::Result { + match mode { + ExportSecurityMode::LessSecure => Ok(String::from(FULL_PRESET_PASSPHRASE)), + ExportSecurityMode::Passphrase => { + let passphrase = passphrase + .context("A passphrase is required for passphrase-protected backups")?; + if passphrase.trim().is_empty() { + anyhow::bail!("Passphrase cannot be empty"); } + Ok(passphrase) } + ExportSecurityMode::Keychain => get_or_create_keychain_secret(), } - - Ok(pids) } fn encode_slim_payload_from_store(store: &AccountsStore) -> anyhow::Result { @@ -490,7 +440,11 @@ async fn restore_slim_accounts( Ok(restored) } -fn encode_full_encrypted_store(store: &AccountsStore, passphrase: &str) -> anyhow::Result> { +fn encode_full_encrypted_store( + store: &AccountsStore, + mode: ExportSecurityMode, + passphrase: &str, +) -> anyhow::Result> { let json = serde_json::to_vec(store).context("Failed to serialize account store")?; let compressed = compress_bytes(&json).context("Failed to compress account store")?; @@ -506,9 +460,15 @@ fn encode_full_encrypted_store(store: &AccountsStore, passphrase: &str) -> anyho .encrypt(XNonce::from_slice(&nonce), compressed.as_slice()) .map_err(|_| anyhow::anyhow!("Failed to encrypt account store"))?; - let mut out = Vec::with_capacity(4 + 1 + FULL_SALT_LEN + FULL_NONCE_LEN + ciphertext.len()); + let mut out = + Vec::with_capacity(4 + 1 + 1 + FULL_SALT_LEN + FULL_NONCE_LEN + ciphertext.len()); out.extend_from_slice(FULL_FILE_MAGIC); out.push(FULL_FILE_VERSION); + out.push(match mode { + ExportSecurityMode::LessSecure => FULL_SECURITY_LESS_SECURE, + ExportSecurityMode::Passphrase => FULL_SECURITY_PASSPHRASE, + ExportSecurityMode::Keychain => FULL_SECURITY_KEYCHAIN, + }); out.extend_from_slice(&salt); out.extend_from_slice(&nonce); out.extend_from_slice(&ciphertext); @@ -518,14 +478,15 @@ fn encode_full_encrypted_store(store: &AccountsStore, passphrase: &str) -> anyho fn decode_full_encrypted_store( file_bytes: &[u8], - passphrase: &str, + passphrase: Option, ) -> anyhow::Result { if file_bytes.len() as u64 > MAX_IMPORT_FILE_BYTES { anyhow::bail!("Encrypted file is too large"); } - let header_len = 4 + 1 + FULL_SALT_LEN + FULL_NONCE_LEN; - if file_bytes.len() <= header_len { + let header_len_v1 = 4 + 1 + FULL_SALT_LEN + FULL_NONCE_LEN; + let header_len_v2 = 4 + 1 + 1 + FULL_SALT_LEN + FULL_NONCE_LEN; + if file_bytes.len() <= header_len_v1 { anyhow::bail!("Encrypted file is invalid or truncated"); } @@ -534,11 +495,24 @@ fn decode_full_encrypted_store( } let version = file_bytes[4]; - if version != FULL_FILE_VERSION { - anyhow::bail!("Unsupported encrypted file version: {version}"); - } + let (mode, salt_start) = match version { + 1 => (ExportSecurityMode::LessSecure, 5), + FULL_FILE_VERSION => { + if file_bytes.len() <= header_len_v2 { + anyhow::bail!("Encrypted file is invalid or truncated"); + } + + let mode = match file_bytes[5] { + FULL_SECURITY_LESS_SECURE => ExportSecurityMode::LessSecure, + FULL_SECURITY_PASSPHRASE => ExportSecurityMode::Passphrase, + FULL_SECURITY_KEYCHAIN => ExportSecurityMode::Keychain, + other => anyhow::bail!("Unsupported encrypted file security mode: {other}"), + }; + (mode, 6) + } + _ => anyhow::bail!("Unsupported encrypted file version: {version}"), + }; - let salt_start = 5; let nonce_start = salt_start + FULL_SALT_LEN; let ciphertext_start = nonce_start + FULL_NONCE_LEN; @@ -546,7 +520,20 @@ fn decode_full_encrypted_store( let nonce = &file_bytes[nonce_start..ciphertext_start]; let ciphertext = &file_bytes[ciphertext_start..]; - let key = derive_encryption_key(passphrase, salt); + let secret = match mode { + ExportSecurityMode::LessSecure => String::from(FULL_PRESET_PASSPHRASE), + ExportSecurityMode::Passphrase => { + let passphrase = + passphrase.context("This backup requires the passphrase that was used to export it")?; + if passphrase.trim().is_empty() { + anyhow::bail!("Passphrase cannot be empty"); + } + passphrase + } + ExportSecurityMode::Keychain => get_keychain_secret()?, + }; + + let key = derive_encryption_key(&secret, salt); let cipher = XChaCha20Poly1305::new((&key).into()); let compressed = cipher .decrypt(XNonce::from_slice(nonce), ciphertext) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 075cb3a..10433b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,9 +8,9 @@ pub mod types; use commands::{ add_account_from_file, cancel_login, check_codex_processes, complete_login, delete_account, export_accounts_full_encrypted_file, export_accounts_slim_text, get_active_account_info, - get_usage, import_accounts_full_encrypted_file, import_accounts_slim_text, list_accounts, - refresh_all_accounts_usage, rename_account, start_login, switch_account, warmup_account, - warmup_all_accounts, + get_app_settings, get_usage, import_accounts_full_encrypted_file, import_accounts_slim_text, + list_accounts, refresh_all_accounts_usage, rename_account, save_export_security_mode, + start_login, switch_account, warmup_account, warmup_all_accounts, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -30,6 +30,8 @@ pub fn run() { import_accounts_slim_text, export_accounts_full_encrypted_file, import_accounts_full_encrypted_file, + get_app_settings, + save_export_security_mode, // OAuth start_login, complete_login, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 9dc197b..7064dd0 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -4,6 +4,29 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExportSecurityMode { + LessSecure, + Passphrase, + Keychain, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + pub version: u32, + pub export_security_mode: Option, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + version: 1, + export_security_mode: None, + } + } +} + /// The main storage structure for all accounts #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccountsStore { diff --git a/src/App.tsx b/src/App.tsx index 13d9002..a6725f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,9 +3,36 @@ import { invoke } from "@tauri-apps/api/core"; import { open, save } from "@tauri-apps/plugin-dialog"; import { useAccounts } from "./hooks/useAccounts"; import { AccountCard, AddAccountModal } from "./components"; -import type { CodexProcessInfo } from "./types"; +import type { AppSettings, CodexProcessInfo, ExportSecurityMode } from "./types"; import "./App.css"; +const SECURITY_OPTIONS: Array<{ + mode: ExportSecurityMode; + title: string; + description: string; + badge?: string; +}> = [ + { + mode: "keychain", + title: "OS Keychain", + description: + "Best for this device. Full backups use a secret stored in your operating system keychain.", + badge: "Recommended", + }, + { + mode: "passphrase", + title: "Passphrase", + description: + "Portable encrypted backups. You will enter the passphrase when exporting and importing.", + }, + { + mode: "less_secure", + title: "Less Secure", + description: + "Keeps the current built-in fallback secret for compatibility, but it is weaker than the other options.", + }, +]; + function App() { const { accounts, @@ -26,6 +53,8 @@ function App() { startOAuthLogin, completeOAuthLogin, cancelOAuthLogin, + getAppSettings, + saveExportSecurityMode, } = useAccounts(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -56,6 +85,8 @@ function App() { "deadline_asc" | "deadline_desc" | "remaining_desc" | "remaining_asc" >("deadline_asc"); const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); + const [appSettings, setAppSettings] = useState(null); + const [isSavingSecurityMode, setIsSavingSecurityMode] = useState(false); const actionsMenuRef = useRef(null); const toggleMask = (accountId: string) => { @@ -99,6 +130,14 @@ function App() { return () => clearInterval(interval); }, [checkProcesses]); + useEffect(() => { + getAppSettings() + .then(setAppSettings) + .catch((err) => { + console.error("Failed to load app settings:", err); + }); + }, [getAppSettings]); + useEffect(() => { if (!isActionsMenuOpen) return; @@ -275,7 +314,21 @@ function App() { if (!selected) return; - await exportAccountsFullEncryptedFile(selected); + let passphrase: string | undefined; + if (appSettings?.export_security_mode === "passphrase") { + const entered = window.prompt("Enter a passphrase for this backup file:"); + if (!entered) return; + + const confirmed = window.prompt("Re-enter the passphrase to confirm:"); + if (entered !== confirmed) { + showWarmupToast("Passphrases did not match", true); + return; + } + + passphrase = entered; + } + + await exportAccountsFullEncryptedFile(selected, passphrase); showWarmupToast("Full encrypted file exported."); } catch (err) { console.error("Failed to export full encrypted file:", err); @@ -301,7 +354,20 @@ function App() { if (!selected || Array.isArray(selected)) return; - const summary = await importAccountsFullEncryptedFile(selected); + let summary; + try { + summary = await importAccountsFullEncryptedFile(selected); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!message.includes("requires the passphrase")) { + throw err; + } + + const passphrase = window.prompt("Enter the passphrase used for this backup:"); + if (!passphrase) return; + summary = await importAccountsFullEncryptedFile(selected, passphrase); + } + setMaskedAccounts(new Set()); showWarmupToast( `Imported ${summary.imported_count}, skipped ${summary.skipped_count} (total ${summary.total_in_payload})` @@ -317,6 +383,22 @@ function App() { const activeAccount = accounts.find((a) => a.is_active); const otherAccounts = accounts.filter((a) => !a.is_active); const hasRunningProcesses = processInfo && processInfo.count > 0; + const needsSecurityOnboarding = + accounts.length === 0 && appSettings && !appSettings.export_security_mode; + + const handleSelectSecurityMode = async (mode: ExportSecurityMode) => { + try { + setIsSavingSecurityMode(true); + const nextSettings = await saveExportSecurityMode(mode); + setAppSettings(nextSettings); + showWarmupToast(`Backup security mode set to ${mode.replace("_", " ")}`); + } catch (err) { + console.error("Failed to save export security mode:", err); + showWarmupToast("Failed to save backup security mode", true); + } finally { + setIsSavingSecurityMode(false); + } + }; const sortedOtherAccounts = useMemo(() => { const getResetDeadline = (resetAt: number | null | undefined) => @@ -672,6 +754,43 @@ function App() { )} + {needsSecurityOnboarding && ( +
+
+
+

+ Choose your backup security mode +

+

+ New users should choose how full backup files are protected before getting started. +

+
+
+ {SECURITY_OPTIONS.map((option) => ( + + ))} +
+
+
+ )} + {/* Add Account Modal */} { + async (path: string, passphrase?: string) => { try { - await invoke("export_accounts_full_encrypted_file", { path }); + await invoke("export_accounts_full_encrypted_file", { path, passphrase }); } catch (err) { throw err; } @@ -203,11 +204,11 @@ export function useAccounts() { ); const importAccountsFullEncryptedFile = useCallback( - async (path: string) => { + async (path: string, passphrase?: string) => { try { const summary = await invoke( "import_accounts_full_encrypted_file", - { path } + { path, passphrase } ); await loadAccounts(); await refreshUsage(); @@ -227,6 +228,20 @@ export function useAccounts() { } }, []); + const getAppSettings = useCallback(async () => { + return await invoke<{ + version: number; + export_security_mode: ExportSecurityMode | null; + }>("get_app_settings"); + }, []); + + const saveExportSecurityMode = useCallback(async (mode: ExportSecurityMode) => { + return await invoke<{ + version: number; + export_security_mode: ExportSecurityMode | null; + }>("save_export_security_mode", { mode }); + }, []); + useEffect(() => { loadAccounts().then(() => refreshUsage()); @@ -258,5 +273,7 @@ export function useAccounts() { startOAuthLogin, completeOAuthLogin, cancelOAuthLogin, + getAppSettings, + saveExportSecurityMode, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 6b2110b..f5984b3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,3 +56,10 @@ export interface ImportAccountsSummary { imported_count: number; skipped_count: number; } + +export type ExportSecurityMode = "less_secure" | "passphrase" | "keychain"; + +export interface AppSettings { + version: number; + export_security_mode: ExportSecurityMode | null; +}