1use anyhow::{Context, Result};
2use hashtree_cli::daemon::{EmbeddedDaemonInfo, EmbeddedDaemonOptions};
3use hashtree_cli::Config;
4use hashtree_core::Cid;
5use serde::Deserialize;
6use std::path::{Path, PathBuf};
7
8const BROWSER_STORAGE_MAX_SIZE_GB: u64 = 1;
9const BROWSER_SOCIAL_GRAPH_DB_MAX_SIZE_GB: u64 = 1;
10const BROWSER_SPAMBOX_DB_MAX_SIZE_GB: u64 = 0;
11
12#[derive(Debug, Clone)]
13pub struct HostDaemonOptions {
14 pub state_root: PathBuf,
15 pub bind_address: String,
16 pub initial_tree_roots: Vec<(String, Cid)>,
17}
18
19impl HostDaemonOptions {
20 pub fn new(state_root: impl Into<PathBuf>) -> Self {
21 Self {
22 state_root: state_root.into(),
23 bind_address: "127.0.0.1:0".to_string(),
24 initial_tree_roots: Vec::new(),
25 }
26 }
27
28 pub fn with_initial_tree_roots(mut self, roots: Vec<(String, Cid)>) -> Self {
29 self.initial_tree_roots = roots;
30 self
31 }
32}
33
34#[derive(Debug, Clone)]
35pub struct HostDaemonStatus {
36 pub base_url: String,
37 pub self_npub: String,
38 pub config_dir: PathBuf,
39 pub data_dir: PathBuf,
40}
41
42pub struct HostDaemonRuntime {
43 runtime: tokio::runtime::Runtime,
44 info: Option<EmbeddedDaemonInfo>,
45 bind_address: String,
46 initial_tree_roots: Vec<(String, Cid)>,
47 config_dir: PathBuf,
48 data_dir: PathBuf,
49}
50
51#[derive(Debug, Clone, Default, Deserialize)]
52#[serde(default)]
53#[serde(rename_all = "camelCase")]
54struct BrowserSettings {
55 storage_max_size_gb: Option<u64>,
56 social_graph_db_max_size_gb: Option<u64>,
57 spambox_db_max_size_gb: Option<u64>,
58 nostr_relays: Option<Vec<String>>,
59 blossom_read_servers: Option<Vec<String>>,
60 blossom_write_servers: Option<Vec<String>>,
61 enable_webrtc: Option<bool>,
62 enable_multicast: Option<bool>,
63 max_multicast_peers: Option<usize>,
64 enable_fips: Option<bool>,
65 enable_fips_udp: Option<bool>,
66 enable_fips_webrtc: Option<bool>,
67 fetch_from_fips_peers: Option<bool>,
68 social_graph_crawl_depth: Option<u32>,
69 sync_enabled: Option<bool>,
70 sync_own: Option<bool>,
71 sync_followed: Option<bool>,
72 sync_max_concurrent: Option<usize>,
73 public_writes: Option<bool>,
74 allowed_npubs: Option<Vec<String>>,
75 socialgraph_root: Option<String>,
76}
77
78impl HostDaemonRuntime {
79 pub fn start(options: HostDaemonOptions) -> Result<Self> {
80 let runtime = tokio::runtime::Builder::new_multi_thread()
81 .enable_all()
82 .build()
83 .context("build embedded host runtime")?;
84
85 let config_dir = options.state_root.join("config");
86 let data_dir = options.state_root.join("data");
87 std::fs::create_dir_all(&config_dir).context("create embedded config dir")?;
88 std::fs::create_dir_all(&data_dir).context("create embedded data dir")?;
89
90 let config = browser_config(&data_dir, &config_dir);
91 let info = runtime
92 .block_on(hashtree_cli::daemon::start_embedded(
93 EmbeddedDaemonOptions {
94 config,
95 data_dir: data_dir.clone(),
96 config_dir: Some(config_dir.clone()),
97 bind_address: options.bind_address,
98 relays: None,
99 initial_tree_roots: options.initial_tree_roots.clone(),
100 extra_routes: None,
101 cors: None,
102 },
103 ))
104 .context("start embedded hashtree daemon")?;
105
106 let bind_address = info.addr.clone();
107
108 Ok(Self {
109 runtime,
110 info: Some(info),
111 bind_address,
112 initial_tree_roots: options.initial_tree_roots,
113 config_dir,
114 data_dir,
115 })
116 }
117
118 pub fn status(&self) -> HostDaemonStatus {
119 let info = self
120 .info
121 .as_ref()
122 .expect("host daemon status requested after shutdown");
123 HostDaemonStatus {
124 base_url: format!("http://{}", info.addr),
125 self_npub: info.npub.clone(),
126 config_dir: self.config_dir.clone(),
127 data_dir: self.data_dir.clone(),
128 }
129 }
130
131 pub fn base_url(&self) -> String {
132 self.status().base_url
133 }
134
135 pub fn self_npub(&self) -> &str {
136 &self
137 .info
138 .as_ref()
139 .expect("host daemon identity requested after shutdown")
140 .npub
141 }
142
143 pub fn reload(&mut self) -> Result<HostDaemonStatus> {
144 self.shutdown_current();
145
146 let config = browser_config(&self.data_dir, &self.config_dir);
147 let info = self
148 .runtime
149 .block_on(hashtree_cli::daemon::start_embedded(
150 EmbeddedDaemonOptions {
151 config,
152 data_dir: self.data_dir.clone(),
153 config_dir: Some(self.config_dir.clone()),
154 bind_address: self.bind_address.clone(),
155 relays: None,
156 initial_tree_roots: self.initial_tree_roots.clone(),
157 extra_routes: None,
158 cors: None,
159 },
160 ))
161 .context("reload embedded hashtree daemon")?;
162 self.bind_address = info.addr.clone();
163 self.info = Some(info);
164 Ok(self.status())
165 }
166
167 pub fn shutdown(&mut self) {
168 self.shutdown_current();
169 }
170
171 fn shutdown_current(&mut self) {
172 let Some(info) = self.info.take() else {
173 return;
174 };
175 let controller = info.daemon_controller.clone();
176 self.runtime.block_on(async move {
177 controller.shutdown().await;
178 drop(info);
179 tokio::task::yield_now().await;
180 });
181 }
182}
183
184impl Drop for HostDaemonRuntime {
185 fn drop(&mut self) {
186 self.shutdown();
187 }
188}
189
190fn browser_config(data_dir: &Path, config_dir: &Path) -> Config {
191 let mut config = Config::default();
192 let settings = load_browser_settings(config_dir).unwrap_or_else(default_browser_settings);
193
194 config.storage.max_size_gb = settings
195 .storage_max_size_gb
196 .unwrap_or(BROWSER_STORAGE_MAX_SIZE_GB)
197 .max(1);
198 config.nostr.db_max_size_gb = settings
199 .social_graph_db_max_size_gb
200 .unwrap_or(BROWSER_SOCIAL_GRAPH_DB_MAX_SIZE_GB)
201 .max(1);
202 config.nostr.spambox_max_size_gb = settings
203 .spambox_db_max_size_gb
204 .unwrap_or(BROWSER_SPAMBOX_DB_MAX_SIZE_GB);
205
206 if let Some(nostr_relays) = settings.nostr_relays {
207 config.nostr.relays = normalize_server_list(nostr_relays);
208 config.nostr.enabled = !config.nostr.relays.is_empty();
209 }
210
211 if let Some(blossom_read_servers) = settings.blossom_read_servers {
212 config.blossom.read_servers = normalize_server_list(blossom_read_servers);
213 config.blossom.servers.clear();
214 }
215 if let Some(blossom_write_servers) = settings.blossom_write_servers {
216 config.blossom.write_servers = normalize_server_list(blossom_write_servers);
217 config.blossom.servers.clear();
218 }
219 config.blossom.enabled =
220 !config.blossom.read_servers.is_empty() || !config.blossom.write_servers.is_empty();
221
222 config.server.enable_webrtc = settings.enable_webrtc.unwrap_or(false);
223 if !config.server.enable_webrtc {
224 config.server.stun_port = 0;
225 }
226
227 config.server.enable_multicast = settings.enable_multicast.unwrap_or(false);
228 if let Some(max_multicast_peers) = settings.max_multicast_peers {
229 config.server.max_multicast_peers = max_multicast_peers;
230 }
231
232 if let Some(enable_fips) = settings.enable_fips {
233 config.server.enable_fips = enable_fips;
234 }
235 if let Some(enable_fips_udp) = settings.enable_fips_udp {
236 config.server.enable_fips_udp = enable_fips_udp;
237 }
238 if let Some(enable_fips_webrtc) = settings.enable_fips_webrtc {
239 config.server.enable_fips_webrtc = enable_fips_webrtc;
240 }
241 if let Some(fetch_from_fips_peers) = settings.fetch_from_fips_peers {
242 config.server.fetch_from_fips_peers = fetch_from_fips_peers;
243 }
244 if let Some(social_graph_crawl_depth) = settings.social_graph_crawl_depth {
245 config.nostr.social_graph_crawl_depth = social_graph_crawl_depth;
246 }
247
248 config.sync.enabled = settings.sync_enabled.unwrap_or(false);
249 if let Some(sync_own) = settings.sync_own {
250 config.sync.sync_own = sync_own;
251 }
252 if let Some(sync_followed) = settings.sync_followed {
253 config.sync.sync_followed = sync_followed;
254 }
255 if let Some(sync_max_concurrent) = settings.sync_max_concurrent {
256 config.sync.max_concurrent = sync_max_concurrent.max(1);
257 }
258
259 config.server.public_writes = settings.public_writes.unwrap_or(false);
260 if let Some(allowed_npubs) = settings.allowed_npubs {
261 config.nostr.allowed_npubs = normalize_server_list(allowed_npubs);
262 }
263 if let Some(socialgraph_root) = settings.socialgraph_root {
264 let socialgraph_root = socialgraph_root.trim().to_string();
265 config.nostr.socialgraph_root = if socialgraph_root.is_empty() {
266 None
267 } else {
268 Some(socialgraph_root)
269 };
270 }
271 config.storage.data_dir = data_dir.to_string_lossy().to_string();
272 config.server.enable_auth = false;
273 config.server.enable_bluetooth = false;
274 config.server.max_bluetooth_peers = 0;
275 config
276}
277
278fn load_browser_settings(config_dir: &Path) -> Option<BrowserSettings> {
279 let settings_path = config_dir.join("browser_settings.json");
280 let raw = std::fs::read_to_string(settings_path).ok()?;
281 serde_json::from_str(&raw).ok()
282}
283
284fn default_browser_settings() -> BrowserSettings {
285 BrowserSettings {
286 storage_max_size_gb: Some(BROWSER_STORAGE_MAX_SIZE_GB),
287 social_graph_db_max_size_gb: Some(BROWSER_SOCIAL_GRAPH_DB_MAX_SIZE_GB),
288 spambox_db_max_size_gb: Some(BROWSER_SPAMBOX_DB_MAX_SIZE_GB),
289 nostr_relays: None,
290 blossom_read_servers: None,
291 blossom_write_servers: None,
292 enable_webrtc: Some(false),
293 enable_multicast: Some(false),
294 max_multicast_peers: None,
295 enable_fips: None,
296 enable_fips_udp: None,
297 enable_fips_webrtc: None,
298 fetch_from_fips_peers: None,
299 social_graph_crawl_depth: None,
300 sync_enabled: Some(false),
301 sync_own: Some(true),
302 sync_followed: Some(true),
303 sync_max_concurrent: Some(3),
304 public_writes: Some(false),
305 allowed_npubs: None,
306 socialgraph_root: None,
307 }
308}
309
310fn normalize_server_list(values: Vec<String>) -> Vec<String> {
311 let mut normalized = values
312 .into_iter()
313 .map(|value| value.trim().to_string())
314 .filter(|value| !value.is_empty())
315 .collect::<Vec<_>>();
316 normalized.sort();
317 normalized.dedup();
318 normalized
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use base64::Engine;
325 use hashtree_cli::HashtreeStore;
326 use hashtree_core::{from_hex, DirEntry, HashTree, HashTreeConfig, LinkType};
327 use nostr::{EventBuilder, Keys, Kind, Tag, TagKind, Timestamp};
328 use reqwest::blocking::Client;
329 use serde_json::json;
330 use std::io::{Read, Write};
331 use std::net::{TcpListener, TcpStream};
332 use std::sync::Arc;
333 use std::thread;
334 use tempfile::TempDir;
335
336 fn create_blossom_auth(keys: &Keys, action: &str) -> String {
337 let expiration = Timestamp::from(Timestamp::now().as_secs() + 300);
338 let tags = vec![
339 Tag::custom(TagKind::Custom("t".into()), vec![action.to_string()]),
340 Tag::custom(
341 TagKind::Custom("expiration".into()),
342 vec![expiration.to_string()],
343 ),
344 ];
345 let event = EventBuilder::new(Kind::Custom(24242), "")
346 .tags(tags)
347 .sign_with_keys(keys)
348 .expect("sign blossom auth");
349 let encoded = base64::engine::general_purpose::STANDARD
350 .encode(serde_json::to_string(&event).expect("serialize auth event"));
351 format!("Nostr {encoded}")
352 }
353
354 fn start_blob_fixture_server(store: Arc<HashtreeStore>) -> String {
355 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture blob server");
356 let addr = listener.local_addr().expect("fixture blob server addr");
357 thread::spawn(move || {
358 for stream in listener.incoming().flatten() {
359 let store = store.clone();
360 thread::spawn(move || handle_blob_fixture_request(stream, store));
361 }
362 });
363 format!("http://{addr}")
364 }
365
366 fn handle_blob_fixture_request(mut stream: TcpStream, store: Arc<HashtreeStore>) {
367 let mut buffer = [0_u8; 2048];
368 let bytes_read = match stream.read(&mut buffer) {
369 Ok(bytes_read) => bytes_read,
370 Err(_) => return,
371 };
372 let request = String::from_utf8_lossy(&buffer[..bytes_read]);
373 let path = request
374 .lines()
375 .next()
376 .and_then(|line| line.split_whitespace().nth(1))
377 .unwrap_or("/");
378 let Some(hash_hex) = path
379 .trim_start_matches('/')
380 .strip_suffix(".bin")
381 .filter(|hash_hex| hash_hex.len() == 64)
382 else {
383 let _ = stream.write_all(
384 b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
385 );
386 return;
387 };
388 let Ok(hash) = from_hex(hash_hex) else {
389 let _ = stream.write_all(
390 b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
391 );
392 return;
393 };
394 let data = match store.get_blob(&hash) {
395 Ok(Some(data)) => data,
396 _ => {
397 let _ = stream.write_all(
398 b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
399 );
400 return;
401 }
402 };
403 let header = format!(
404 "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
405 data.len()
406 );
407 let _ = stream.write_all(header.as_bytes());
408 let _ = stream.write_all(&data);
409 }
410
411 #[test]
412 fn host_runtime_starts_and_shuts_down() {
413 let temp = TempDir::new().expect("temp dir");
414 let mut runtime =
415 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
416
417 let status = runtime.status();
418 assert!(
419 status.config_dir.join("keys").exists(),
420 "expected host daemon to materialize keys in its config dir"
421 );
422
423 let response = reqwest::blocking::get(format!("{}/htree/test", status.base_url))
424 .expect("fetch test endpoint");
425 assert!(
426 response.status().is_success(),
427 "embedded daemon should answer"
428 );
429
430 runtime.shutdown();
431
432 let stopped = reqwest::blocking::get(format!("{}/htree/test", status.base_url)).is_err();
433 assert!(stopped, "expected host daemon shutdown to stop serving");
434
435 runtime.shutdown();
436 }
437
438 #[test]
439 fn host_runtime_rejects_public_blossom_uploads() {
440 let temp = TempDir::new().expect("temp dir");
441 let runtime =
442 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
443 let status = runtime.status();
444
445 let keys = Keys::generate();
446 let response = Client::new()
447 .put(format!("{}/upload", status.base_url))
448 .header("Authorization", create_blossom_auth(&keys, "upload"))
449 .header("Content-Type", "text/plain")
450 .body("browser-mode upload probe")
451 .send()
452 .expect("upload request");
453
454 assert_eq!(
455 response.status(),
456 reqwest::StatusCode::FORBIDDEN,
457 "browser-mode daemon must not accept public Blossom uploads"
458 );
459 }
460
461 #[test]
462 fn host_runtime_keeps_default_relays_and_file_servers() {
463 let temp = TempDir::new().expect("temp dir");
464 let runtime =
465 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
466 let status = runtime.status();
467
468 let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
469 .expect("fetch daemon status");
470 assert!(
471 response.status().is_success(),
472 "status endpoint should answer"
473 );
474 let payload: serde_json::Value = response.json().expect("parse daemon status");
475
476 assert!(
477 payload["upstream"]["nostr_relays"]
478 .as_u64()
479 .unwrap_or_default()
480 > 0,
481 "browser mode should keep default relays for profile lookups",
482 );
483 assert!(
484 payload["upstream"]["blossom_servers"]
485 .as_u64()
486 .unwrap_or_default()
487 > 0,
488 "browser mode should keep default file servers for content fetches",
489 );
490 assert_eq!(
491 payload["mesh"]["enabled"].as_bool(),
492 Some(false),
493 "browser mode should still keep peer discovery off until enabled",
494 );
495 }
496
497 #[test]
498 fn host_runtime_serves_initial_keyed_root_from_upstream_blossom() {
499 let temp = TempDir::new().expect("temp dir");
500 let source_dir = TempDir::new().expect("source temp dir");
501 let source_store = Arc::new(
502 HashtreeStore::new(source_dir.path().join("source-db")).expect("source store"),
503 );
504 let setup_runtime = tokio::runtime::Builder::new_current_thread()
505 .enable_all()
506 .build()
507 .expect("setup runtime");
508 let (root_cid, expected_body) = setup_runtime.block_on(async {
509 let source_tree = HashTree::new(HashTreeConfig::new(source_store.store_arc()));
510 let expected_body =
511 b"<!doctype html><title>Iris Apps</title><main>Drive Chat Contacts</main>".to_vec();
512 let (index_cid, _) = source_tree.put(&expected_body).await.expect("index cid");
513 let root_cid = source_tree
514 .put_directory(vec![
515 DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File)
516 ])
517 .await
518 .expect("root directory cid");
519 (root_cid, expected_body)
520 });
521 assert!(
522 root_cid.key.is_some(),
523 "test must cover a keyed/private site root"
524 );
525
526 let upstream = start_blob_fixture_server(source_store);
527 let config_dir = temp.path().join("config");
528 std::fs::create_dir_all(&config_dir).expect("create config dir");
529 std::fs::write(
530 config_dir.join("browser_settings.json"),
531 serde_json::to_vec_pretty(&json!({
532 "nostrRelays": [],
533 "blossomReadServers": [upstream],
534 "blossomWriteServers": [],
535 "enableFips": false,
536 "enableFipsUdp": false,
537 "enableFipsWebrtc": false,
538 "fetchFromFipsPeers": false,
539 "socialGraphCrawlDepth": 0,
540 "syncEnabled": false
541 }))
542 .expect("serialize browser settings"),
543 )
544 .expect("write browser settings");
545
546 let npub = "npub1embeddedtest000000000000000000000000000000000000000000000000";
547 let mut runtime = HostDaemonRuntime::start(
548 HostDaemonOptions::new(temp.path())
549 .with_initial_tree_roots(vec![(format!("{npub}/sites"), root_cid)]),
550 )
551 .expect("start daemon");
552 let status = runtime.status();
553
554 let response = Client::new()
555 .get(format!("{}/htree/{npub}/sites/index.html", status.base_url))
556 .send()
557 .expect("fetch initial rooted site");
558 assert_eq!(response.status(), reqwest::StatusCode::OK);
559 assert_eq!(
560 response.bytes().expect("site body").as_ref(),
561 expected_body.as_slice()
562 );
563
564 runtime.shutdown();
565 }
566
567 #[test]
568 fn host_runtime_applies_browser_settings_overrides() {
569 let temp = TempDir::new().expect("temp dir");
570 let config_dir = temp.path().join("config");
571 std::fs::create_dir_all(&config_dir).expect("create config dir");
572 std::fs::write(
573 config_dir.join("browser_settings.json"),
574 serde_json::to_vec_pretty(&json!({
575 "nostrRelays": [
576 "wss://relay.example-two",
577 "wss://relay.example-one",
578 "wss://relay.example-two"
579 ],
580 "blossomReadServers": [
581 "https://cdn.example"
582 ],
583 "blossomWriteServers": [
584 "https://upload.example"
585 ],
586 "enableWebrtc": true,
587 "enableFips": false,
588 "enableFipsUdp": false,
589 "enableFipsWebrtc": false,
590 "fetchFromFipsPeers": false,
591 "socialGraphCrawlDepth": 0,
592 "syncEnabled": false,
593 "syncOwn": false,
594 "syncFollowed": false
595 }))
596 .expect("serialize browser settings"),
597 )
598 .expect("write browser settings");
599
600 let runtime =
601 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
602 let status = runtime.status();
603
604 let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
605 .expect("fetch daemon status");
606 assert!(
607 response.status().is_success(),
608 "status endpoint should answer"
609 );
610 let payload: serde_json::Value = response.json().expect("parse daemon status");
611
612 assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(2));
613 assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
614 assert_eq!(payload["mesh"]["enabled"].as_bool(), Some(false));
615 }
616
617 #[test]
618 fn browser_config_applies_background_service_overrides() {
619 let temp = TempDir::new().expect("temp dir");
620 let config_dir = temp.path().join("config");
621 let data_dir = temp.path().join("data");
622 std::fs::create_dir_all(&config_dir).expect("create config dir");
623 std::fs::write(
624 config_dir.join("browser_settings.json"),
625 serde_json::to_vec_pretty(&json!({
626 "enableFips": false,
627 "enableFipsUdp": false,
628 "enableFipsWebrtc": false,
629 "fetchFromFipsPeers": false,
630 "socialGraphCrawlDepth": 0
631 }))
632 .expect("serialize browser settings"),
633 )
634 .expect("write browser settings");
635
636 let config = browser_config(&data_dir, &config_dir);
637
638 assert!(!config.server.enable_fips);
639 assert!(!config.server.enable_fips_udp);
640 assert!(!config.server.enable_fips_webrtc);
641 assert!(!config.server.fetch_from_fips_peers);
642 assert_eq!(config.nostr.social_graph_crawl_depth, 0);
643 }
644
645 #[test]
646 fn browser_config_uses_browser_sized_database_limits() {
647 let temp = TempDir::new().expect("temp dir");
648 let config_dir = temp.path().join("config");
649 let data_dir = temp.path().join("data");
650 std::fs::create_dir_all(&config_dir).expect("create config dir");
651
652 let config = browser_config(&data_dir, &config_dir);
653
654 assert_eq!(config.storage.max_size_gb, BROWSER_STORAGE_MAX_SIZE_GB);
655 assert_eq!(
656 config.nostr.db_max_size_gb,
657 BROWSER_SOCIAL_GRAPH_DB_MAX_SIZE_GB
658 );
659 assert_eq!(
660 config.nostr.spambox_max_size_gb,
661 BROWSER_SPAMBOX_DB_MAX_SIZE_GB
662 );
663 }
664
665 #[test]
666 fn browser_config_applies_database_limit_overrides() {
667 let temp = TempDir::new().expect("temp dir");
668 let config_dir = temp.path().join("config");
669 let data_dir = temp.path().join("data");
670 std::fs::create_dir_all(&config_dir).expect("create config dir");
671 std::fs::write(
672 config_dir.join("browser_settings.json"),
673 serde_json::to_vec_pretty(&json!({
674 "storageMaxSizeGb": 2,
675 "socialGraphDbMaxSizeGb": 2,
676 "spamboxDbMaxSizeGb": 1
677 }))
678 .expect("serialize browser settings"),
679 )
680 .expect("write browser settings");
681
682 let config = browser_config(&data_dir, &config_dir);
683
684 assert_eq!(config.storage.max_size_gb, 2);
685 assert_eq!(config.nostr.db_max_size_gb, 2);
686 assert_eq!(config.nostr.spambox_max_size_gb, 1);
687 }
688
689 #[test]
690 fn host_runtime_reload_applies_updated_browser_settings() {
691 let temp = TempDir::new().expect("temp dir");
692 let mut runtime =
693 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
694 let initial_status = runtime.status();
695
696 let config_dir = temp.path().join("config");
697 std::fs::create_dir_all(&config_dir).expect("create config dir");
698 std::fs::write(
699 config_dir.join("browser_settings.json"),
700 serde_json::to_vec_pretty(&json!({
701 "nostrRelays": ["wss://relay.example-one"],
702 "blossomReadServers": ["https://cdn.example"],
703 "blossomWriteServers": ["https://upload.example"],
704 "enableWebrtc": true
705 }))
706 .expect("serialize browser settings"),
707 )
708 .expect("write browser settings");
709
710 let reloaded_status = runtime.reload().expect("reload daemon");
711 let response = reqwest::blocking::get(format!("{}/api/status", reloaded_status.base_url))
712 .expect("fetch daemon status after reload");
713 assert!(
714 response.status().is_success(),
715 "reloaded daemon should answer"
716 );
717 let payload: serde_json::Value = response.json().expect("parse daemon status");
718
719 assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(1));
720 assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
721 assert_eq!(
722 payload["mesh"]["enabled"].as_bool(),
723 Some(false),
724 "embedded non-P2P builds keep mesh disabled even when browser settings request WebRTC",
725 );
726 assert!(
727 !reloaded_status.base_url.is_empty(),
728 "reload should keep serving from some loopback endpoint"
729 );
730 assert_eq!(
731 reloaded_status.self_npub, initial_status.self_npub,
732 "reload should keep the same browser-owned identity"
733 );
734 }
735}