brows3r_lib/settings/mod.rs
1//! Application settings: typed store with all v1 defaults.
2//!
3//! # Layout
4//!
5//! - `mod.rs` — `Settings` struct, sub-structs, load/save, and `SettingsHandle`.
6//! - `defaults.rs` — `Default for Settings` with every v1 default from the proposal.
7//!
8//! # Persistence
9//!
10//! `${app_config_dir}/settings.json` is the canonical backing file. On save the
11//! file is written atomically (temp file + rename) so a crash during write cannot
12//! corrupt the stored settings.
13//!
14//! # Forward-compatibility
15//!
16//! The `unknown` field uses `#[serde(flatten)]` over a `BTreeMap<String, Value>`.
17//! Any JSON key that does not map to a known field is round-tripped verbatim, so
18//! settings written by a future app version are preserved when the user downgrades.
19//!
20//! # OCP
21//!
22//! Typed sub-structs (`NotificationSettings`, `TransferConfirmations`, …) make
23//! adding a new sub-field a non-breaking change — serde simply deserialises new
24//! keys into `unknown` on older builds and propagates them back on save.
25
26use std::{
27 collections::BTreeMap,
28 path::{Path, PathBuf},
29 sync::Arc,
30};
31
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use tokio::sync::Mutex;
35
36use crate::error::AppError;
37
38mod defaults;
39
40// ---------------------------------------------------------------------------
41// Sub-structs
42// ---------------------------------------------------------------------------
43
44/// Controls in-app and OS-level notification behaviour.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct NotificationSettings {
48 /// Show notifications in the in-app notification area.
49 pub in_app: bool,
50 /// Trigger an OS notification when a background transfer completes.
51 pub os_enabled: bool,
52 /// Play a sound with OS notifications.
53 pub sound: bool,
54}
55
56/// Confirmation thresholds for potentially destructive or billable operations.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct TransferConfirmations {
60 /// Ask before deleting objects.
61 pub delete: bool,
62 /// Ask before overwriting an existing object.
63 pub overwrite: bool,
64 /// Ask before uploading a file larger than this many MiB.
65 pub large_upload_mb: u64,
66}
67
68/// A single S3-compatible endpoint entry in the endpoint registry.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct S3CompatibleEndpoint {
72 pub name: String,
73 pub endpoint_url: String,
74 pub default_region: String,
75 /// Optional compat flags template for this endpoint.
76 /// When `None` the app uses its built-in provider-detection heuristics.
77 pub compat_flags_template: Option<crate::profiles::compat_flags::CompatFlags>,
78}
79
80/// Auto-update channel and behaviour.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct AutoUpdateSettings {
84 pub enabled: bool,
85 /// `"stable"`, `"beta"`, or `"nightly"`.
86 pub channel: String,
87}
88
89/// What the app does on startup.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct StartupBehavior {
93 /// Re-open the last session (last open profile + last navigated path).
94 pub restore_session: bool,
95 /// Override the initial navigation target (`"s3://bucket/prefix"` or a
96 /// profile name). `None` means "last location" when `restore_session` is
97 /// true, or the bucket list when false.
98 pub open_to: Option<String>,
99}
100
101/// HTTP proxy mode.
102///
103/// Matches the shape of `s3::client::ProxyConfig` so the two can be converted
104/// without a full S3 client dependency cycle. Task 7's `ProxyConfig` and this
105/// enum are intentionally isomorphic — callers in `s3::client` convert via
106/// `From<ProxyMode>`.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
108#[serde(tag = "mode", rename_all = "camelCase")]
109pub enum ProxyMode {
110 /// Inherit proxy from environment variables (`HTTP_PROXY`, `HTTPS_PROXY`,
111 /// `NO_PROXY`). This is the v1 default.
112 #[default]
113 System,
114 /// Route all traffic through the given URL.
115 Explicit { url: String },
116 /// Disable proxy entirely, ignoring environment variables.
117 None,
118}
119
120// ---------------------------------------------------------------------------
121// Settings root struct
122// ---------------------------------------------------------------------------
123
124/// All application settings, versioned and forward-compatible.
125///
126/// Defaults are in `defaults.rs`; this struct must never hard-code a default
127/// inline — use `Settings::default()` as the canonical factory.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct Settings {
131 /// Schema version; currently always `1`.
132 pub schema_version: u32,
133
134 // --- storage / transfer ---
135 /// Default download directory. `None` means "resolve from
136 /// `tauri::api::path::download_dir()` at runtime".
137 pub download_dir: Option<PathBuf>,
138 /// Maximum number of concurrent S3 object transfers.
139 pub transfer_concurrency: u32,
140
141 // --- cache ---
142 /// Listing cache TTL in seconds.
143 pub cache_ttl_secs: u64,
144 /// Maximum in-memory cache size in MiB.
145 pub cache_size_cap_mb: u64,
146
147 // --- preview ---
148 /// Files larger than this (MiB) show a warning before preview.
149 pub preview_size_limit_mb: u64,
150
151 // --- view ---
152 /// Default view mode: `"Details"`, `"Icons"`, `"Gallery"`, `"Tree"`,
153 /// `"FlatKey"`, `"Column"`, or `"DualPane"`.
154 pub default_view_mode: String,
155
156 // --- notifications ---
157 pub notifications: NotificationSettings,
158
159 // --- cross-account fallback ---
160 /// Objects up to this size (MiB) auto-fall back on cross-account ops;
161 /// larger objects require confirmation.
162 pub fallback_threshold_mb: u64,
163
164 // --- transfer confirmations ---
165 pub transfer_confirmations: TransferConfirmations,
166
167 // --- S3-compatible endpoint registry ---
168 pub s3_compatible_endpoints: Vec<S3CompatibleEndpoint>,
169
170 // --- auto-update ---
171 pub auto_update: AutoUpdateSettings,
172
173 // --- diagnostics ---
174 /// Enable local log/exception collection.
175 /// Collection is always user-initiated; nothing is ever auto-uploaded.
176 pub diagnostics_enabled: bool,
177
178 // --- startup ---
179 pub startup_behavior: StartupBehavior,
180
181 // --- proxy ---
182 pub proxy: ProxyMode,
183
184 // --- appearance ---
185 /// `"light"`, `"dark"`, or `"system"`.
186 pub theme: String,
187
188 // --- keyboard shortcuts ---
189 /// Sparse map of user-overridden shortcuts. Keys are action identifiers
190 /// (e.g. `"navigate.up"`); values are key-combo strings (e.g. `"Alt+Up"`).
191 /// The frontend baseline shortcut map is canonical and is NOT stored here;
192 /// only user overrides are persisted (per Decision D3).
193 pub keyboard_shortcuts: BTreeMap<String, String>,
194
195 // --- forward-compat ---
196 /// Absorbs any JSON keys not known to this schema version. Round-tripped
197 /// verbatim on save so future-app settings survive a downgrade.
198 #[serde(flatten)]
199 pub unknown: BTreeMap<String, Value>,
200}
201
202// ---------------------------------------------------------------------------
203// Load / save
204// ---------------------------------------------------------------------------
205
206impl Settings {
207 /// Load settings from `path`.
208 ///
209 /// Returns `Settings::default()` when the file does not exist.
210 /// Returns an `AppError::Internal` if the file exists but cannot be read
211 /// or parsed.
212 pub async fn load(path: &Path) -> Result<Self, AppError> {
213 if !path.exists() {
214 return Ok(Self::default());
215 }
216 let raw = tokio::fs::read_to_string(path)
217 .await
218 .map_err(|e| AppError::Internal {
219 trace_id: format!("settings_load_read: {e}"),
220 })?;
221 serde_json::from_str(&raw).map_err(|e| AppError::Internal {
222 trace_id: format!("settings_load_parse: {e}"),
223 })
224 }
225
226 /// Synchronous variant of `load` for use in contexts where async is
227 /// unavailable (e.g. the Tauri `setup` callback, which is synchronous).
228 ///
229 /// Returns `Settings::default()` when the file does not exist.
230 pub fn load_sync(path: &Path) -> Self {
231 if !path.exists() {
232 return Self::default();
233 }
234 std::fs::read_to_string(path)
235 .ok()
236 .and_then(|raw| serde_json::from_str(&raw).ok())
237 .unwrap_or_default()
238 }
239
240 /// Persist settings to `path` atomically (write to `<path>.tmp`, then
241 /// rename), so a crash during write cannot corrupt the stored settings.
242 pub async fn save(&self, path: &Path) -> Result<(), AppError> {
243 let json = serde_json::to_string_pretty(self).map_err(|e| AppError::Internal {
244 trace_id: format!("settings_save_serialize: {e}"),
245 })?;
246
247 // Ensure the parent directory exists.
248 if let Some(parent) = path.parent() {
249 tokio::fs::create_dir_all(parent)
250 .await
251 .map_err(|e| AppError::Internal {
252 trace_id: format!("settings_save_mkdir: {e}"),
253 })?;
254 }
255
256 let tmp_path = path.with_extension("json.tmp");
257 tokio::fs::write(&tmp_path, json.as_bytes())
258 .await
259 .map_err(|e| AppError::Internal {
260 trace_id: format!("settings_save_write: {e}"),
261 })?;
262 tokio::fs::rename(&tmp_path, path)
263 .await
264 .map_err(|e| AppError::Internal {
265 trace_id: format!("settings_save_rename: {e}"),
266 })?;
267 Ok(())
268 }
269}
270
271// ---------------------------------------------------------------------------
272// SettingsHandle — shared mutable state for Tauri managed state
273// ---------------------------------------------------------------------------
274
275/// Newtype around `Arc<Mutex<Settings>>` used as Tauri managed state.
276///
277/// Commands receive `tauri::State<SettingsHandle>` and lock the inner mutex
278/// for the duration of the read or write.
279#[derive(Clone)]
280pub struct SettingsHandle {
281 pub inner: Arc<Mutex<Settings>>,
282 /// Path to `settings.json`; stored so commands can call `save()`.
283 pub path: PathBuf,
284}
285
286impl SettingsHandle {
287 pub fn new(settings: Settings, path: PathBuf) -> Self {
288 Self {
289 inner: Arc::new(Mutex::new(settings)),
290 path,
291 }
292 }
293}
294
295// ---------------------------------------------------------------------------
296// From<ProxyMode> for s3::client::ProxyConfig
297// ---------------------------------------------------------------------------
298
299impl From<ProxyMode> for crate::s3::client::ProxyConfig {
300 fn from(mode: ProxyMode) -> Self {
301 match mode {
302 ProxyMode::System => crate::s3::client::ProxyConfig::System,
303 ProxyMode::Explicit { url } => crate::s3::client::ProxyConfig::Explicit(url),
304 ProxyMode::None => crate::s3::client::ProxyConfig::None,
305 }
306 }
307}
308
309// ---------------------------------------------------------------------------
310// Patch validation — also used by settings_cmd
311// ---------------------------------------------------------------------------
312
313/// Validate a JSON patch object before applying it to `Settings`.
314///
315/// Returns `AppError::Validation` if the patch violates any constraint.
316/// This is the single validation gate; `settings_update` calls it before
317/// merging and persisting.
318pub fn validate_patch(patch: &Value) -> Result<(), AppError> {
319 if let Some(tc) = patch.get("transferConcurrency") {
320 let v = tc.as_u64().unwrap_or(0);
321 if v == 0 {
322 return Err(AppError::Validation {
323 field: "transferConcurrency".to_string(),
324 hint: "must be at least 1".to_string(),
325 });
326 }
327 }
328 if let Some(ttl) = patch.get("cacheTtlSecs") {
329 if ttl.as_u64().is_none() {
330 return Err(AppError::Validation {
331 field: "cacheTtlSecs".to_string(),
332 hint: "must be a non-negative integer".to_string(),
333 });
334 }
335 }
336 if let Some(lim) = patch.get("previewSizeLimitMb") {
337 if lim.as_u64().is_none() {
338 return Err(AppError::Validation {
339 field: "previewSizeLimitMb".to_string(),
340 hint: "must be a non-negative integer".to_string(),
341 });
342 }
343 }
344 Ok(())
345}
346
347// ---------------------------------------------------------------------------
348// Tests
349// ---------------------------------------------------------------------------
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use serde_json::json;
355
356 // (1) Every v1 default from proposal lines 190-206 is codified correctly.
357 #[test]
358 fn defaults_match_proposal() {
359 let s = Settings::default();
360
361 assert_eq!(s.schema_version, 1, "schema_version");
362 assert_eq!(s.download_dir, None, "download_dir");
363 assert_eq!(s.transfer_concurrency, 4, "transfer_concurrency");
364 assert_eq!(s.cache_ttl_secs, 30, "cache_ttl_secs");
365 assert_eq!(s.cache_size_cap_mb, 256, "cache_size_cap_mb");
366 assert_eq!(s.preview_size_limit_mb, 50, "preview_size_limit_mb");
367 assert_eq!(s.default_view_mode, "Details", "default_view_mode");
368
369 assert!(s.notifications.in_app, "notifications.in_app");
370 assert!(s.notifications.os_enabled, "notifications.os_enabled");
371 assert!(!s.notifications.sound, "notifications.sound");
372
373 assert_eq!(s.fallback_threshold_mb, 100, "fallback_threshold_mb");
374
375 assert!(
376 s.transfer_confirmations.delete,
377 "transfer_confirmations.delete"
378 );
379 assert!(
380 s.transfer_confirmations.overwrite,
381 "transfer_confirmations.overwrite"
382 );
383 assert_eq!(
384 s.transfer_confirmations.large_upload_mb, 500,
385 "transfer_confirmations.large_upload_mb"
386 );
387
388 assert!(
389 s.s3_compatible_endpoints.is_empty(),
390 "s3_compatible_endpoints"
391 );
392
393 assert!(s.auto_update.enabled, "auto_update.enabled");
394 assert_eq!(s.auto_update.channel, "stable", "auto_update.channel");
395
396 assert!(s.diagnostics_enabled, "diagnostics_enabled");
397
398 assert!(
399 s.startup_behavior.restore_session,
400 "startup_behavior.restore_session"
401 );
402 assert_eq!(s.startup_behavior.open_to, None, "startup_behavior.open_to");
403
404 assert_eq!(s.proxy, ProxyMode::System, "proxy");
405 assert_eq!(s.theme, "system", "theme");
406 assert!(s.keyboard_shortcuts.is_empty(), "keyboard_shortcuts");
407 }
408
409 // (2) Round-trip: serialize → parse → assert equality.
410 #[test]
411 fn round_trip_defaults() {
412 let original = Settings::default();
413 let json = serde_json::to_string(&original).expect("serialize");
414 let restored: Settings = serde_json::from_str(&json).expect("deserialize");
415 assert_eq!(
416 original, restored,
417 "round-trip must produce identical Settings"
418 );
419 }
420
421 // (3) Unknown keys are preserved on round-trip.
422 #[test]
423 fn unknown_keys_preserved() {
424 let input = json!({
425 "schemaVersion": 1,
426 "transferConcurrency": 4,
427 "cacheTtlSecs": 30,
428 "cacheSizeCapMb": 256,
429 "previewSizeLimitMb": 50,
430 "defaultViewMode": "Details",
431 "notifications": {
432 "inApp": true,
433 "osEnabled": true,
434 "sound": false
435 },
436 "fallbackThresholdMb": 100,
437 "transferConfirmations": {
438 "delete": true,
439 "overwrite": true,
440 "largeUploadMb": 500
441 },
442 "s3CompatibleEndpoints": [],
443 "autoUpdate": {
444 "enabled": true,
445 "channel": "stable"
446 },
447 "diagnosticsEnabled": true,
448 "startupBehavior": {
449 "restoreSession": true,
450 "openTo": null
451 },
452 "proxy": { "mode": "system" },
453 "theme": "system",
454 "keyboardShortcuts": {},
455 // future key unknown to this schema version
456 "futureFlag": true,
457 "experimentalBatch": { "size": 100 }
458 });
459
460 let settings: Settings =
461 serde_json::from_value(input).expect("deserialize with unknown keys");
462
463 // The unknown keys must survive serialization.
464 let out = serde_json::to_value(&settings).expect("serialize");
465
466 assert_eq!(
467 out["futureFlag"],
468 json!(true),
469 "futureFlag must be preserved"
470 );
471 assert_eq!(
472 out["experimentalBatch"]["size"],
473 json!(100),
474 "experimentalBatch must be preserved"
475 );
476 }
477
478 // (4) settings_update validation: transfer_concurrency = 0 is rejected.
479 // This tests the validation helper used by the command.
480 #[test]
481 fn validate_transfer_concurrency_zero() {
482 let patch = json!({ "transferConcurrency": 0 });
483 let result = validate_patch(&patch);
484 assert!(
485 result.is_err(),
486 "transferConcurrency=0 must produce a validation error"
487 );
488 if let Err(AppError::Validation { field, .. }) = result {
489 assert_eq!(field, "transferConcurrency");
490 } else {
491 panic!("expected AppError::Validation");
492 }
493 }
494
495 // (5) Valid patch passes validation.
496 #[test]
497 fn validate_patch_valid() {
498 let patch = json!({ "transferConcurrency": 8 });
499 assert!(validate_patch(&patch).is_ok());
500 }
501}