brows3r_lib/commands/settings_cmd.rs
1//! Tauri commands for reading and updating application settings.
2//!
3//! # Commands
4//!
5//! - `settings_get` — return the current `Settings` snapshot.
6//! - `settings_update` — apply a JSON patch, validate, persist atomically.
7//!
8//! Both commands take `tauri::State<SettingsHandle>` so they share the same
9//! `Arc<Mutex<Settings>>` that was seeded at app start.
10//!
11//! ## Hot-reload
12//!
13//! `settings_update` also pushes runtime-affecting changes into the live
14//! services:
15//! - `proxy` → `ClientPool::set_proxy` (rebuilds connectors).
16//! - `transfer_concurrency`→ `TransferQueue::rebuild_semaphore`.
17//!
18//! Cache TTL changes still require an app restart — the cache stores them as
19//! immutable config at open time.
20
21use serde_json::Value;
22use tauri::State;
23
24use crate::{
25 error::AppError,
26 s3::S3ClientPoolHandle,
27 settings::{validate_patch, Settings, SettingsHandle},
28 transfers::TransferQueueHandle,
29};
30
31/// Return the current settings as a serialised `Settings` value.
32///
33/// The frontend can call this on startup to hydrate its settings state.
34#[tauri::command]
35pub async fn settings_get(handle: State<'_, SettingsHandle>) -> Result<Settings, AppError> {
36 let settings = handle.inner.lock().await;
37 Ok(settings.clone())
38}
39
40/// Apply a JSON patch to the current settings, validate, and persist.
41///
42/// `patch` is a partial JSON object; only the keys present in `patch` are
43/// updated. The merge strategy is a shallow JSON merge (RFC 7396 spirit):
44/// top-level keys in `patch` overwrite the corresponding fields in the stored
45/// settings. Sub-structs are replaced wholesale when their key appears in
46/// `patch` (standard `serde_json::Value` merge semantics).
47///
48/// Returns the updated `Settings` on success, or `AppError::Validation` if
49/// the patch violates a constraint (e.g. `transferConcurrency = 0`).
50///
51/// Pass `force: true` to bypass shortcut-conflict checks (future use; ignored
52/// in v1 but accepted so callers compiled against this signature do not need
53/// updating).
54#[tauri::command]
55pub async fn settings_update(
56 handle: State<'_, SettingsHandle>,
57 pool: State<'_, S3ClientPoolHandle>,
58 queue: State<'_, TransferQueueHandle>,
59 patch: Value,
60 #[allow(unused_variables)] force: Option<bool>,
61) -> Result<Settings, AppError> {
62 // Validate before acquiring the lock so we fail fast without blocking.
63 validate_patch(&patch)?;
64
65 let mut settings = handle.inner.lock().await;
66
67 // Capture the pre-patch values so we only push side-effects when the
68 // relevant fields actually changed.
69 let prev_proxy = settings.proxy.clone();
70 let prev_concurrency = settings.transfer_concurrency;
71
72 // Merge the patch into the current settings via JSON round-trip.
73 // Strategy: serialize current → merge patch → deserialize.
74 let mut current_value =
75 serde_json::to_value(settings.clone()).map_err(|e| AppError::Internal {
76 trace_id: format!("settings_update_serialize: {e}"),
77 })?;
78
79 json_merge(&mut current_value, patch);
80
81 let updated: Settings =
82 serde_json::from_value(current_value).map_err(|e| AppError::Internal {
83 trace_id: format!("settings_update_deserialize: {e}"),
84 })?;
85
86 // Persist atomically before updating in-memory state so a failed write
87 // does not leave the in-memory state ahead of disk.
88 updated.save(&handle.path).await?;
89
90 *settings = updated.clone();
91 // Drop the lock before touching the live services to avoid holding it
92 // across their awaits.
93 drop(settings);
94
95 // ---------- hot-reload: proxy ----------
96 if updated.proxy != prev_proxy {
97 pool.inner.set_proxy(updated.proxy.clone().into()).await;
98 }
99
100 // ---------- hot-reload: transfer concurrency ----------
101 if updated.transfer_concurrency != prev_concurrency {
102 queue.0.rebuild_semaphore(updated.transfer_concurrency);
103 }
104
105 Ok(updated)
106}
107
108/// Recursive JSON merge (RFC 7396 spirit).
109///
110/// Keys from `patch` overwrite keys in `base`. When both `base[key]` and
111/// `patch[key]` are objects, the merge recurses. Otherwise `patch[key]`
112/// replaces `base[key]`.
113fn json_merge(base: &mut Value, patch: Value) {
114 match (base, patch) {
115 (Value::Object(base_map), Value::Object(patch_map)) => {
116 for (k, v) in patch_map {
117 let entry = base_map.entry(k).or_insert(Value::Null);
118 json_merge(entry, v);
119 }
120 }
121 (base, patch) => {
122 *base = patch;
123 }
124 }
125}