Skip to main content

blit_webserver/
config.rs

1use axum::extract::ws::{Message, WebSocket};
2use futures_util::SinkExt;
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::{Arc, RwLock};
6use tokio::sync::broadcast;
7
8pub struct ConfigState {
9    pub tx: broadcast::Sender<String>,
10}
11
12impl Default for ConfigState {
13    fn default() -> Self {
14        Self::new()
15    }
16}
17
18impl ConfigState {
19    pub fn new() -> Self {
20        let (tx, _) = broadcast::channel::<String>(64);
21        spawn_watcher(tx.clone());
22        Self { tx }
23    }
24}
25
26fn blit_config_dir() -> PathBuf {
27    #[cfg(unix)]
28    let base = std::env::var("XDG_CONFIG_HOME")
29        .map(PathBuf::from)
30        .unwrap_or_else(|_| {
31            let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
32            PathBuf::from(home).join(".config")
33        });
34    #[cfg(windows)]
35    let base = std::env::var("APPDATA")
36        .map(PathBuf::from)
37        .unwrap_or_else(|_| PathBuf::from(r"C:\ProgramData"));
38    base.join("blit")
39}
40
41pub fn config_path() -> PathBuf {
42    if let Ok(p) = std::env::var("BLIT_CONFIG") {
43        return PathBuf::from(p);
44    }
45    blit_config_dir().join("blit.conf")
46}
47
48pub fn remotes_path() -> PathBuf {
49    if let Ok(p) = std::env::var("BLIT_REMOTES") {
50        return PathBuf::from(p);
51    }
52    blit_config_dir().join("blit.remotes")
53}
54
55/// Resolve the local blit server IPC socket path.
56///
57/// Checks `BLIT_SOCK` first (explicit override), then probes well-known
58/// paths with existence checks so we find a running server regardless of
59/// which fallback it used at startup.
60#[cfg(unix)]
61pub fn default_local_socket() -> String {
62    if let Ok(p) = std::env::var("BLIT_SOCK") {
63        return p;
64    }
65    if let Ok(dir) = std::env::var("TMPDIR") {
66        let p = format!("{dir}/blit.sock");
67        if std::path::Path::new(&p).exists() {
68            return p;
69        }
70    }
71    if let Ok(user) = std::env::var("USER") {
72        let p = format!("/tmp/blit-{user}.sock");
73        if std::path::Path::new(&p).exists() {
74            return p;
75        }
76        let sys = format!("/run/blit/{user}.sock");
77        if std::path::Path::new(&sys).exists() {
78            return sys;
79        }
80    }
81    if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
82        return format!("{dir}/blit.sock");
83    }
84    "/tmp/blit.sock".into()
85}
86
87/// Resolve the local blit server IPC pipe path (Windows).
88#[cfg(windows)]
89pub fn default_local_socket() -> String {
90    if let Ok(p) = std::env::var("BLIT_SOCK") {
91        return p;
92    }
93    let user = std::env::var("USERNAME").unwrap_or_else(|_| "default".into());
94    format!(r"\\.\pipe\blit-{user}")
95}
96
97/// Acquire an exclusive cross-process lock for the config directory.
98/// Returns a `File` whose lifetime holds the lock (released on drop).
99/// On non-Unix platforms this is a no-op that returns `None`.
100fn lock_config_dir() -> Option<std::fs::File> {
101    #[cfg(unix)]
102    {
103        use std::os::unix::fs::OpenOptionsExt;
104        let dir = blit_config_dir();
105        let _ = std::fs::create_dir_all(&dir);
106        let lock_path = dir.join("blit.lock");
107        if let Ok(f) = std::fs::OpenOptions::new()
108            .write(true)
109            .create(true)
110            .truncate(false)
111            .mode(0o600)
112            .open(&lock_path)
113        {
114            // Block until we get the lock.
115            use std::os::unix::io::AsRawFd;
116            if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX) } == 0 {
117                return Some(f);
118            }
119        }
120        None
121    }
122    #[cfg(not(unix))]
123    {
124        None
125    }
126}
127
128pub fn read_config() -> HashMap<String, String> {
129    let path = config_path();
130    let contents = match std::fs::read_to_string(&path) {
131        Ok(c) => c,
132        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
133        Err(e) => {
134            eprintln!("blit: could not read {}: {e}", path.display());
135            return HashMap::new();
136        }
137    };
138    parse_config_str(&contents)
139}
140
141/// A single entry in `blit.remotes`. `disabled` entries are persisted as
142/// `# name = uri` and are excluded from connection resolution but preserved
143/// across restarts so users can re-enable them later.
144#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct RemoteEntry {
146    pub name: String,
147    pub uri: String,
148    pub disabled: bool,
149}
150
151/// Read `blit.remotes` and return ordered enabled `(name, uri)` pairs.
152/// If the file does not exist, provisions it with `local = local` (0600).
153/// Disabled entries are filtered out — use [`read_remotes_full`] to keep them.
154pub fn read_remotes() -> Vec<(String, String)> {
155    read_remotes_full()
156        .into_iter()
157        .filter(|e| !e.disabled)
158        .map(|e| (e.name, e.uri))
159        .collect()
160}
161
162/// Read `blit.remotes` including disabled entries.
163pub fn read_remotes_full() -> Vec<RemoteEntry> {
164    let path = remotes_path();
165    let contents = match std::fs::read_to_string(&path) {
166        Ok(c) => c,
167        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
168            let default = vec![RemoteEntry {
169                name: "local".to_string(),
170                uri: "local".to_string(),
171                disabled: false,
172            }];
173            write_remotes(&default);
174            return default;
175        }
176        Err(e) => {
177            eprintln!("blit: could not read {}: {e}", path.display());
178            return vec![];
179        }
180    };
181    parse_remotes_full(&contents)
182}
183
184/// Atomically read-modify-write `blit.conf` under an exclusive flock.
185pub fn modify_config(f: impl FnOnce(&mut HashMap<String, String>)) {
186    let _lock = lock_config_dir();
187    let mut map = read_config();
188    f(&mut map);
189    write_config(&map);
190}
191
192/// Atomically read-modify-write `blit.remotes` under an exclusive flock.
193pub fn modify_remotes(f: impl FnOnce(&mut Vec<RemoteEntry>)) {
194    let _lock = lock_config_dir();
195    let mut entries = read_remotes_full();
196    f(&mut entries);
197    write_remotes(&entries);
198}
199
200/// Parse `blit.remotes` content into ordered enabled `(name, uri)` pairs.
201/// Disabled entries (`# name = uri`) are filtered out — use
202/// [`parse_remotes_full`] to keep them.
203pub fn parse_remotes_str(contents: &str) -> Vec<(String, String)> {
204    parse_remotes_full(contents)
205        .into_iter()
206        .filter(|e| !e.disabled)
207        .map(|e| (e.name, e.uri))
208        .collect()
209}
210
211/// Parse `blit.remotes` content including disabled entries.
212/// Format: `name = uri` for enabled; `# name = uri` (with optional whitespace
213/// after `#`) for disabled. Other `#` lines and blank lines are ignored.
214/// Duplicate names: last wins (same as blit.conf).
215pub fn parse_remotes_full(contents: &str) -> Vec<RemoteEntry> {
216    let mut order: Vec<String> = Vec::new();
217    let mut map: HashMap<String, RemoteEntry> = HashMap::new();
218    for line in contents.lines() {
219        let line = line.trim();
220        if line.is_empty() {
221            continue;
222        }
223        let (body, disabled) = if let Some(rest) = line.strip_prefix('#') {
224            (rest.trim_start(), true)
225        } else {
226            (line, false)
227        };
228        let Some((k, v)) = body.split_once('=') else {
229            continue;
230        };
231        let name = k.trim().to_string();
232        let uri = v.trim().to_string();
233        if name.is_empty() || uri.is_empty() {
234            continue;
235        }
236        if !map.contains_key(&name) {
237            order.push(name.clone());
238        }
239        map.insert(
240            name.clone(),
241            RemoteEntry {
242                name,
243                uri,
244                disabled,
245            },
246        );
247    }
248    order.into_iter().map(|k| map.remove(&k).unwrap()).collect()
249}
250
251fn serialize_remotes(entries: &[RemoteEntry]) -> String {
252    let mut out = String::new();
253    for e in entries {
254        if e.disabled {
255            out.push_str("# ");
256        }
257        out.push_str(&e.name);
258        out.push_str(" = ");
259        out.push_str(&e.uri);
260        out.push('\n');
261    }
262    out
263}
264
265/// Write `blit.remotes` atomically with mode 0o600 (owner read/write only).
266pub fn write_remotes(entries: &[RemoteEntry]) {
267    let path = remotes_path();
268    if let Some(parent) = path.parent() {
269        let _ = std::fs::create_dir_all(parent);
270    }
271    let contents = serialize_remotes(entries);
272    write_secret_file(&path, &contents);
273}
274
275/// Write a file with mode 0o600 (owner-only).  On Unix this is done by
276/// writing to a temp file with the right mode, then atomically renaming.
277/// On Windows we just write normally (ACLs are handled separately if needed).
278fn write_secret_file(path: &PathBuf, contents: &str) {
279    #[cfg(unix)]
280    {
281        use std::os::unix::fs::OpenOptionsExt;
282        // Write to a sibling temp file with a unique name (pid + counter)
283        // so concurrent writers don't clobber each other's temp files.
284        use std::sync::atomic::{AtomicU32, Ordering};
285        static COUNTER: AtomicU32 = AtomicU32::new(0);
286        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
287        let pid = std::process::id();
288        let tmp = path.with_extension(format!("tmp.{pid}.{seq}"));
289        let result = std::fs::OpenOptions::new()
290            .write(true)
291            .create(true)
292            .truncate(true)
293            .mode(0o600)
294            .open(&tmp)
295            .and_then(|mut f| {
296                use std::io::Write;
297                f.write_all(contents.as_bytes())
298            });
299        if result.is_ok() {
300            let _ = std::fs::rename(&tmp, path);
301        } else {
302            let _ = std::fs::remove_file(&tmp);
303        }
304    }
305    #[cfg(not(unix))]
306    {
307        let _ = std::fs::write(path, contents);
308    }
309}
310
311fn serialize_config_str(map: &HashMap<String, String>) -> String {
312    let mut lines: Vec<String> = map.iter().map(|(k, v)| format!("{k} = {v}")).collect();
313    lines.sort();
314    lines.push(String::new());
315    lines.join("\n")
316}
317
318pub fn write_config(map: &HashMap<String, String>) {
319    let path = config_path();
320    if let Some(parent) = path.parent() {
321        let _ = std::fs::create_dir_all(parent);
322    }
323    write_secret_file(&path, &serialize_config_str(map));
324}
325
326/// Watches a single file in its parent directory and calls `on_change`
327/// whenever the file is modified.  Skips access (read) events.
328fn spawn_file_watcher<F>(path: PathBuf, label: &'static str, on_change: F)
329where
330    F: Fn() + Send + 'static,
331{
332    use notify::{RecursiveMode, Watcher};
333
334    if let Some(parent) = path.parent() {
335        let _ = std::fs::create_dir_all(parent);
336    }
337
338    let watch_dir = path.parent().unwrap_or(&path).to_path_buf();
339    let file_name = path.file_name().map(|n| n.to_os_string());
340
341    std::thread::Builder::new()
342        .name(format!("{label}-watcher"))
343        .spawn(move || {
344            let (ntx, nrx) = std::sync::mpsc::channel();
345            let mut watcher = match notify::recommended_watcher(ntx) {
346                Ok(w) => w,
347                Err(e) => {
348                    eprintln!("blit: {label} watcher failed: {e}");
349                    return;
350                }
351            };
352            if let Err(e) = watcher.watch(&watch_dir, RecursiveMode::NonRecursive) {
353                eprintln!("blit: {label} watch failed: {e}");
354                return;
355            }
356            loop {
357                match nrx.recv() {
358                    Ok(Ok(event)) => {
359                        if matches!(event.kind, notify::EventKind::Access(_)) {
360                            continue;
361                        }
362                        let matches = file_name.as_ref().is_none_or(|name| {
363                            event.paths.iter().any(|p| p.file_name() == Some(name))
364                        });
365                        if matches {
366                            on_change();
367                        }
368                    }
369                    Ok(Err(_)) => continue,
370                    Err(_) => break,
371                }
372            }
373        })
374        .expect("failed to spawn file-watcher thread");
375}
376
377fn spawn_watcher(tx: broadcast::Sender<String>) {
378    let path = config_path();
379    spawn_file_watcher(path, "config", move || {
380        let map = read_config();
381        for (k, v) in &map {
382            let _ = tx.send(format!("{k}={v}"));
383        }
384        let _ = tx.send("ready".into());
385    });
386}
387
388// ---------------------------------------------------------------------------
389// RemotesState — live-reloading blit.remotes with 0o600 permissions
390// ---------------------------------------------------------------------------
391
392/// Manages `blit.remotes`: reads/writes the file, watches for external
393/// changes, and broadcasts the serialised contents to all subscribers.
394///
395/// The broadcast value is the raw file text (same as what `read_remotes`
396/// would parse), sent as a single string so receivers can re-parse it.
397/// The config WebSocket handler prefixes it with `"remotes:"`.
398#[derive(Clone)]
399pub struct RemotesState {
400    inner: Arc<RemotesInner>,
401}
402
403struct RemotesInner {
404    /// Cached current contents (raw file text, normalized).
405    contents: RwLock<String>,
406    tx: broadcast::Sender<String>,
407}
408
409impl RemotesState {
410    /// Full persistent mode: reads `blit.remotes`, watches it for changes.
411    pub fn new() -> Self {
412        let (tx, _) = broadcast::channel(64);
413        let inner = Arc::new(RemotesInner {
414            contents: RwLock::new(serialize_remotes(&read_remotes_full())),
415            tx,
416        });
417        let watcher_inner = inner.clone();
418        spawn_file_watcher(remotes_path(), "remotes", move || {
419            // Read directly — do not auto-provision. The file may be
420            // intentionally empty (user removed all remotes).
421            let text = std::fs::read_to_string(remotes_path()).unwrap_or_default();
422            *watcher_inner.contents.write().unwrap() = text.clone();
423            let _ = watcher_inner.tx.send(text);
424        });
425        Self { inner }
426    }
427
428    /// Ephemeral mode: starts with the given text, no file I/O, no watcher.
429    /// Used by `blit open` to advertise the session's destinations to the
430    /// browser without touching `blit.remotes`.
431    pub fn ephemeral(initial: String) -> Self {
432        let (tx, _) = broadcast::channel(64);
433        Self {
434            inner: Arc::new(RemotesInner {
435                contents: RwLock::new(initial),
436                tx,
437            }),
438        }
439    }
440
441    /// Returns the current serialized remotes contents.
442    pub fn get(&self) -> String {
443        self.inner.contents.read().unwrap().clone()
444    }
445
446    /// Overwrite `blit.remotes` with `entries` and broadcast the change.
447    pub fn set(&self, entries: &[RemoteEntry]) {
448        write_remotes(entries);
449        let text = serialize_remotes(entries);
450        *self.inner.contents.write().unwrap() = text.clone();
451        let _ = self.inner.tx.send(text);
452    }
453
454    /// Atomically read-modify-write `blit.remotes` under an exclusive flock,
455    /// then update the in-memory cache and broadcast.
456    pub fn modify(&self, f: impl FnOnce(&mut Vec<RemoteEntry>)) {
457        let _lock = lock_config_dir();
458        let mut entries = parse_remotes_full(&self.get());
459        f(&mut entries);
460        self.set(&entries);
461    }
462
463    pub fn subscribe(&self) -> broadcast::Receiver<String> {
464        self.inner.tx.subscribe()
465    }
466}
467
468impl Default for RemotesState {
469    fn default() -> Self {
470        Self::new()
471    }
472}
473
474fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
475    let mut diff = (a.len() ^ b.len()) as u8;
476    for i in 0..a.len().min(b.len()) {
477        diff |= a[i] ^ b[i];
478    }
479    std::hint::black_box(diff) == 0
480}
481
482fn parse_config_str(contents: &str) -> HashMap<String, String> {
483    let mut map = HashMap::new();
484    for line in contents.lines() {
485        let line = line.trim();
486        if line.is_empty() || line.starts_with('#') {
487            continue;
488        }
489        if let Some((k, v)) = line.split_once('=') {
490            map.insert(k.trim().to_string(), v.trim().to_string());
491        }
492    }
493    map
494}
495
496/// Handle the `/config` WebSocket connection.
497///
498/// Protocol (server → client, after auth):
499///   1. `"ok"` — authentication accepted.
500///   2. `"remotes:<text>"` — sent immediately (and re-sent on any change to
501///      `blit.remotes`).  `<text>` is the raw `blit.remotes` file contents:
502///      `name = uri` lines for enabled remotes, `# name = uri` lines for
503///      disabled ones.  Empty string if the file does not exist.
504///   3. Zero or more `"key=value"` messages — current browser settings.
505///   4. `"ready"` — end of initial burst; live updates follow.
506///
507/// After `"ready"`, the server pushes:
508///   - `"remotes:<text>"` when `blit.remotes` changes.
509///   - `"key=value"` when `blit.conf` changes.
510///
511/// The client may send:
512///   - `"set key value"` — persist a browser setting.
513///   - `"remotes-add name uri"` — add or update a remote; name must not
514///     contain `=` or whitespace; uri must be non-empty.  If the entry
515///     existed and was disabled, it is re-enabled.
516///   - `"remotes-remove name"` — remove a remote by name (regardless of
517///     enabled/disabled state).
518///   - `"remotes-toggle name"` — flip a remote's disabled state.  Disabled
519///     remotes are persisted as `# name = uri` and excluded from connection
520///     resolution.
521///   - `"remotes-set-default name"` — write `target = name` to `blit.conf`
522///     (or remove the key if name is empty or `"local"`).  The updated
523///     `target` value is then broadcast to all config-WS clients as a
524///     normal `"target=value"` message via the config-file watcher.
525///   - `"remotes-reorder name1 name2 …"` — reorder remotes to match the
526///     supplied name sequence; any names not listed are appended at the end
527///     in their original relative order.  Disabled state is preserved.
528pub async fn handle_config_ws(
529    mut ws: WebSocket,
530    token: &str,
531    config: &ConfigState,
532    remotes: Option<&RemotesState>,
533    remotes_transform: Option<fn(&str) -> String>,
534    extra_init: &[String],
535) {
536    let authed = loop {
537        match ws.recv().await {
538            Some(Ok(Message::Text(pass))) => {
539                if constant_time_eq(pass.trim().as_bytes(), token.as_bytes()) {
540                    let _ = ws.send(Message::Text("ok".into())).await;
541                    break true;
542                } else {
543                    let _ = ws.close().await;
544                    break false;
545                }
546            }
547            Some(Ok(Message::Ping(d))) => {
548                let _ = ws.send(Message::Pong(d)).await;
549            }
550            _ => break false,
551        }
552    };
553    if !authed {
554        return;
555    }
556
557    // Subscribe before reading the snapshot so we can't miss a concurrent write.
558    let mut remotes_rx = remotes.map(|r| r.subscribe());
559
560    // Send the current remotes snapshot (even if empty — client can rely on
561    // always receiving this message after "ok").
562    let remotes_text = remotes.map(|r| r.get()).unwrap_or_default();
563    let remotes_text = remotes_transform
564        .map(|f| f(&remotes_text))
565        .unwrap_or(remotes_text);
566    if ws
567        .send(Message::Text(format!("remotes:{remotes_text}").into()))
568        .await
569        .is_err()
570    {
571        return;
572    }
573
574    let map = read_config();
575    for (k, v) in &map {
576        if ws
577            .send(Message::Text(format!("{k}={v}").into()))
578            .await
579            .is_err()
580        {
581            return;
582        }
583    }
584    for msg in extra_init {
585        if ws.send(Message::Text(msg.clone().into())).await.is_err() {
586            return;
587        }
588    }
589    if ws.send(Message::Text("ready".into())).await.is_err() {
590        return;
591    }
592
593    let mut config_rx = config.tx.subscribe();
594
595    loop {
596        // Build the select! arms dynamically based on whether we have a
597        // destinations receiver.  We can't use an Option inside select!
598        // directly, so we use a never-resolving future as a stand-in.
599        tokio::select! {
600            msg = ws.recv() => {
601                match msg {
602                    Some(Ok(Message::Text(text))) => {
603                        let text = text.trim();
604                        if let Some(rest) = text.strip_prefix("set ")
605                            && let Some((k, v)) = rest.split_once(' ') {
606                                let k = k.trim().replace(['\n', '\r'], "");
607                                let v = v.trim().replace(['\n', '\r'], "");
608                                if k.is_empty() { continue; }
609                                modify_config(|map| {
610                                    if v.is_empty() {
611                                        map.remove(&k);
612                                    } else {
613                                        map.insert(k, v);
614                                    }
615                                });
616                        } else if let Some(rest) = text.strip_prefix("remotes-add ") {
617                            // "remotes-add <name> <uri>" — name is first whitespace-delimited
618                            // word, uri is the remainder after a single space.
619                            if let Some((raw_name, raw_uri)) = rest.split_once(' ') {
620                                let name = raw_name.trim().replace(['\n', '\r'], "");
621                                let uri = raw_uri.trim().replace(['\n', '\r'], "");
622                                if !name.is_empty()
623                                    && !name.contains('=')
624                                    && !uri.is_empty()
625                                    && let Some(r) = remotes
626                                {
627                                    r.modify(|entries| {
628                                        if let Some(pos) = entries.iter().position(|e| e.name == name) {
629                                            entries[pos].uri = uri;
630                                            // An explicit add re-enables a previously
631                                            // disabled entry.
632                                            entries[pos].disabled = false;
633                                        } else {
634                                            entries.push(RemoteEntry {
635                                                name,
636                                                uri,
637                                                disabled: false,
638                                            });
639                                        }
640                                    });
641                                }
642                            }
643                        } else if let Some(name) = text.strip_prefix("remotes-remove ") {
644                            let name = name.trim().replace(['\n', '\r'], "");
645                            if !name.is_empty()
646                                && let Some(r) = remotes
647                            {
648                                r.modify(|entries| {
649                                    entries.retain(|e| e.name != name);
650                                });
651                            }
652                        } else if let Some(name) = text.strip_prefix("remotes-toggle ") {
653                            let name = name.trim().replace(['\n', '\r'], "");
654                            if !name.is_empty()
655                                && let Some(r) = remotes
656                            {
657                                r.modify(|entries| {
658                                    if let Some(pos) =
659                                        entries.iter().position(|e| e.name == name)
660                                    {
661                                        entries[pos].disabled = !entries[pos].disabled;
662                                    }
663                                });
664                            }
665                        } else if let Some(name) = text.strip_prefix("remotes-set-default ") {
666                            // Write blit.target = <name> to blit.conf (or remove it for local/empty).
667                            let name = name.trim().replace(['\n', '\r'], "");
668                            modify_config(|map| {
669                                if name.is_empty() || name == "local" {
670                                    map.remove("blit.target");
671                                } else {
672                                    map.insert("blit.target".into(), name);
673                                }
674                            });
675                        } else if let Some(rest) = text.strip_prefix("remotes-reorder ") {
676                            // "remotes-reorder name1 name2 …" — reorder entries to match
677                            // the supplied sequence; unlisted entries are appended at end.
678                            if let Some(r) = remotes {
679                                let desired: Vec<String> = rest
680                                    .split_whitespace()
681                                    .map(|s| s.replace(['\n', '\r'], ""))
682                                    .filter(|s| !s.is_empty())
683                                    .collect();
684                                if !desired.is_empty() {
685                                    r.modify(|entries| {
686                                        let by_name: std::collections::HashMap<String, RemoteEntry> =
687                                            entries
688                                                .iter()
689                                                .map(|e| (e.name.clone(), e.clone()))
690                                                .collect();
691                                        let mut reordered: Vec<RemoteEntry> = desired
692                                            .iter()
693                                            .filter_map(|n| by_name.get(n).cloned())
694                                            .collect();
695                                        let desired_set: std::collections::HashSet<&str> =
696                                            desired.iter().map(|s| s.as_str()).collect();
697                                        for e in entries.iter() {
698                                            if !desired_set.contains(e.name.as_str()) {
699                                                reordered.push(e.clone());
700                                            }
701                                        }
702                                        *entries = reordered;
703                                    });
704                                }
705                            }
706                        }
707                    }
708                    Some(Ok(Message::Close(_))) | None => break,
709                    Some(Err(_)) => break,
710                    _ => continue,
711                }
712            }
713            broadcast = config_rx.recv() => {
714                match broadcast {
715                    Ok(line) => {
716                        if ws.send(Message::Text(line.into())).await.is_err() {
717                            break;
718                        }
719                    }
720                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
721                    Err(_) => break,
722                }
723            }
724            remotes_update = async {
725                match remotes_rx.as_mut() {
726                    Some(rx) => rx.recv().await,
727                    None => std::future::pending().await,
728                }
729            } => {
730                match remotes_update {
731                    Ok(text) => {
732                        let text = remotes_transform
733                            .map(|f| f(&text))
734                            .unwrap_or(text);
735                        if ws
736                            .send(Message::Text(format!("remotes:{text}").into()))
737                            .await
738                            .is_err()
739                        {
740                            break;
741                        }
742                    }
743                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
744                        // Missed some intermediate updates — send current snapshot.
745                        if let Some(r) = remotes {
746                            let text = r.get();
747                            let text = remotes_transform
748                                .map(|f| f(&text))
749                                .unwrap_or(text);
750                            if ws
751                                .send(Message::Text(format!("remotes:{text}").into()))
752                                .await
753                                .is_err()
754                            {
755                                break;
756                            }
757                        }
758                    }
759                    Err(_) => break,
760                }
761            }
762        }
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769
770    // ── constant_time_eq ──
771
772    #[test]
773    fn ct_eq_equal_slices() {
774        assert!(constant_time_eq(b"hello", b"hello"));
775    }
776
777    #[test]
778    fn ct_eq_different_slices() {
779        assert!(!constant_time_eq(b"hello", b"world"));
780    }
781
782    #[test]
783    fn ct_eq_different_lengths() {
784        assert!(!constant_time_eq(b"short", b"longer"));
785    }
786
787    #[test]
788    fn ct_eq_empty_slices() {
789        assert!(constant_time_eq(b"", b""));
790    }
791
792    #[test]
793    fn ct_eq_single_bit_diff() {
794        assert!(!constant_time_eq(b"\x00", b"\x01"));
795    }
796
797    #[test]
798    fn ct_eq_one_empty_one_not() {
799        assert!(!constant_time_eq(b"", b"x"));
800    }
801
802    // ── parse_config_str ──
803
804    #[test]
805    fn parse_empty_string() {
806        let map = parse_config_str("");
807        assert!(map.is_empty());
808    }
809
810    #[test]
811    fn parse_comments_and_blanks() {
812        let map = parse_config_str("# comment\n\n  # another\n");
813        assert!(map.is_empty());
814    }
815
816    #[test]
817    fn parse_key_value() {
818        let map = parse_config_str("font = Menlo\ntheme = dark\n");
819        assert_eq!(map.get("font").unwrap(), "Menlo");
820        assert_eq!(map.get("theme").unwrap(), "dark");
821    }
822
823    #[test]
824    fn parse_trims_whitespace() {
825        let map = parse_config_str("  key  =  value  ");
826        assert_eq!(map.get("key").unwrap(), "value");
827    }
828
829    #[test]
830    fn parse_line_without_equals() {
831        let map = parse_config_str("no-equals-here\nkey=val");
832        assert_eq!(map.len(), 1);
833        assert_eq!(map.get("key").unwrap(), "val");
834    }
835
836    #[test]
837    fn parse_equals_in_value() {
838        let map = parse_config_str("cmd = a=b=c");
839        assert_eq!(map.get("cmd").unwrap(), "a=b=c");
840    }
841
842    #[test]
843    fn parse_duplicate_keys_last_wins() {
844        let map = parse_config_str("key = first\nkey = second");
845        assert_eq!(map.get("key").unwrap(), "second");
846    }
847
848    #[test]
849    fn parse_mixed_content() {
850        let input = "# header\nfont = FiraCode\n\n# size\nsize = 14\ntheme=light";
851        let map = parse_config_str(input);
852        assert_eq!(map.len(), 3);
853        assert_eq!(map.get("font").unwrap(), "FiraCode");
854        assert_eq!(map.get("size").unwrap(), "14");
855        assert_eq!(map.get("theme").unwrap(), "light");
856    }
857
858    // ── write_config round-trip ──
859
860    #[test]
861    fn serialize_config_produces_sorted_output() {
862        let mut map: HashMap<String, String> = HashMap::new();
863        map.insert("z".into(), "last".into());
864        map.insert("a".into(), "first".into());
865        let output = serialize_config_str(&map);
866        assert!(output.starts_with("a = first"));
867        assert!(output.contains("z = last"));
868    }
869
870    #[test]
871    fn round_trip_parse_serialize() {
872        let input = "alpha = 1\nbeta = 2\ngamma = 3";
873        let map = parse_config_str(input);
874        let serialized = serialize_config_str(&map);
875        let reparsed = parse_config_str(&serialized);
876        assert_eq!(map, reparsed);
877    }
878
879    // ── RemotesState mutations (remotes-add / remotes-remove) ──
880
881    fn entry(name: &str, uri: &str) -> RemoteEntry {
882        RemoteEntry {
883            name: name.to_string(),
884            uri: uri.to_string(),
885            disabled: false,
886        }
887    }
888
889    #[test]
890    fn remotes_add_new_entry() {
891        let state = RemotesState::ephemeral(String::new());
892        let mut entries = parse_remotes_full(&state.get());
893        entries.push(entry("rabbit", "ssh:rabbit"));
894        state.set(&entries);
895        let got = parse_remotes_str(&state.get());
896        assert_eq!(got.len(), 1);
897        assert_eq!(got[0], ("rabbit".to_string(), "ssh:rabbit".to_string()));
898    }
899
900    #[test]
901    fn remotes_add_updates_existing() {
902        let initial = "rabbit = ssh:rabbit\n";
903        let state = RemotesState::ephemeral(initial.to_string());
904        let mut entries = parse_remotes_full(&state.get());
905        if let Some(pos) = entries.iter().position(|e| e.name == "rabbit") {
906            entries[pos].uri = "tcp:rabbit:3264".to_string();
907        }
908        state.set(&entries);
909        let got = parse_remotes_str(&state.get());
910        assert_eq!(got.len(), 1);
911        assert_eq!(got[0].1, "tcp:rabbit:3264");
912    }
913
914    #[test]
915    fn remotes_remove_existing() {
916        let initial = "rabbit = ssh:rabbit\nhound = ssh:hound\n";
917        let state = RemotesState::ephemeral(initial.to_string());
918        let mut entries = parse_remotes_full(&state.get());
919        entries.retain(|e| e.name != "rabbit");
920        state.set(&entries);
921        let got = parse_remotes_str(&state.get());
922        assert_eq!(got.len(), 1);
923        assert_eq!(got[0].0, "hound");
924    }
925
926    #[test]
927    fn remotes_remove_nonexistent_is_noop() {
928        let initial = "rabbit = ssh:rabbit\n";
929        let state = RemotesState::ephemeral(initial.to_string());
930        let mut entries = parse_remotes_full(&state.get());
931        let before = entries.len();
932        entries.retain(|e| e.name != "does-not-exist");
933        assert_eq!(entries.len(), before);
934    }
935
936    // ── Disabled remotes (commented) ──
937
938    #[test]
939    fn parse_disabled_entry() {
940        let entries = parse_remotes_full("# rabbit = ssh:rabbit\nhound = ssh:hound\n");
941        assert_eq!(entries.len(), 2);
942        assert_eq!(entries[0].name, "rabbit");
943        assert_eq!(entries[0].uri, "ssh:rabbit");
944        assert!(entries[0].disabled);
945        assert_eq!(entries[1].name, "hound");
946        assert!(!entries[1].disabled);
947    }
948
949    #[test]
950    fn parse_disabled_no_space_after_hash() {
951        let entries = parse_remotes_full("#rabbit = ssh:rabbit\n");
952        assert_eq!(entries.len(), 1);
953        assert!(entries[0].disabled);
954    }
955
956    #[test]
957    fn parse_remotes_str_filters_disabled() {
958        let active = parse_remotes_str("# rabbit = ssh:rabbit\nhound = ssh:hound\n");
959        assert_eq!(active.len(), 1);
960        assert_eq!(active[0].0, "hound");
961    }
962
963    #[test]
964    fn parse_skips_pure_comments() {
965        let entries = parse_remotes_full("# This is just a header\n# also a comment\n");
966        assert!(entries.is_empty());
967    }
968
969    #[test]
970    fn round_trip_disabled() {
971        let initial = "rabbit = ssh:rabbit\n# hound = ssh:hound\n";
972        let entries = parse_remotes_full(initial);
973        let serialized = serialize_remotes(&entries);
974        let reparsed = parse_remotes_full(&serialized);
975        assert_eq!(entries, reparsed);
976        assert!(serialized.contains("# hound = ssh:hound"));
977    }
978
979    #[test]
980    fn remotes_toggle_flips_state() {
981        let state = RemotesState::ephemeral("rabbit = ssh:rabbit\n".into());
982        state.modify(|entries| {
983            if let Some(pos) = entries.iter().position(|e| e.name == "rabbit") {
984                entries[pos].disabled = !entries[pos].disabled;
985            }
986        });
987        let entries = parse_remotes_full(&state.get());
988        assert_eq!(entries.len(), 1);
989        assert!(entries[0].disabled);
990        // Active view excludes it.
991        assert!(parse_remotes_str(&state.get()).is_empty());
992    }
993
994    #[test]
995    fn remotes_add_reenables_disabled() {
996        let state = RemotesState::ephemeral("# rabbit = ssh:old\n".into());
997        // Simulate the WS handler's add logic.
998        state.modify(|entries| {
999            let name = "rabbit".to_string();
1000            if let Some(pos) = entries.iter().position(|e| e.name == name) {
1001                entries[pos].uri = "ssh:new".to_string();
1002                entries[pos].disabled = false;
1003            } else {
1004                entries.push(RemoteEntry {
1005                    name,
1006                    uri: "ssh:new".to_string(),
1007                    disabled: false,
1008                });
1009            }
1010        });
1011        let entries = parse_remotes_full(&state.get());
1012        assert_eq!(entries.len(), 1);
1013        assert_eq!(entries[0].uri, "ssh:new");
1014        assert!(!entries[0].disabled);
1015    }
1016
1017    #[test]
1018    fn remotes_reorder_preserves_disabled() {
1019        let initial = "alpha = a\n# beta = b\ngamma = c\n";
1020        let entries = parse_remotes_full(initial);
1021        // Reorder alpha → gamma → beta.
1022        let desired = ["gamma", "alpha", "beta"];
1023        let by_name: std::collections::HashMap<String, RemoteEntry> = entries
1024            .iter()
1025            .map(|e| (e.name.clone(), e.clone()))
1026            .collect();
1027        let reordered: Vec<RemoteEntry> = desired
1028            .iter()
1029            .filter_map(|n| by_name.get(*n).cloned())
1030            .collect();
1031        let serialized = serialize_remotes(&reordered);
1032        let reparsed = parse_remotes_full(&serialized);
1033        assert_eq!(reparsed.len(), 3);
1034        assert_eq!(reparsed[0].name, "gamma");
1035        assert!(!reparsed[0].disabled);
1036        assert_eq!(reparsed[2].name, "beta");
1037        assert!(reparsed[2].disabled);
1038    }
1039
1040    #[test]
1041    fn remotes_add_rejects_empty_name() {
1042        // Simulate the validation in handle_config_ws: empty name is rejected.
1043        let name = "";
1044        assert!(name.is_empty() || name.contains('='));
1045    }
1046
1047    #[test]
1048    fn remotes_add_rejects_name_with_equals() {
1049        let name = "foo=bar";
1050        assert!(name.contains('='));
1051    }
1052
1053    // ── set-default writes blit.target key to blit.conf ──
1054
1055    #[test]
1056    fn set_default_inserts_target_key() {
1057        let mut map = parse_config_str("font = Mono\n");
1058        map.insert("blit.target".into(), "rabbit".into());
1059        let serialized = serialize_config_str(&map);
1060        let reparsed = parse_config_str(&serialized);
1061        assert_eq!(
1062            reparsed.get("blit.target").map(|s| s.as_str()),
1063            Some("rabbit")
1064        );
1065        assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
1066    }
1067
1068    #[test]
1069    fn set_default_local_removes_target_key() {
1070        let mut map = parse_config_str("blit.target = rabbit\nfont = Mono\n");
1071        // "local" or empty → remove the key
1072        map.remove("blit.target");
1073        let serialized = serialize_config_str(&map);
1074        let reparsed = parse_config_str(&serialized);
1075        assert!(!reparsed.contains_key("blit.target"));
1076        assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
1077    }
1078}