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