Skip to main content

planarian_backend_linux/
runtime.rs

1//! `RuntimeOptionBackend` (ยง12a). Phase A applies Wi-Fi selection live through
2//! NetworkManager. Font scale and keyboard layout have no live mechanism here;
3//! they take effect through their write-back (the catalog change), and `set`
4//! says so.
5
6use crate::admitted;
7use crate::cmd::run;
8use planarian_schema::{OpId, Outcome, RuntimeOptionBackend, Value};
9use std::path::PathBuf;
10use std::time::Duration;
11
12#[derive(Clone, Debug)]
13pub struct Runtime {
14    txn_dir: PathBuf,
15    /// The Wi-Fi networks the catalog permits (`services.planarian.catalog.wifiNetworks`);
16    /// empty: any saved Wi-Fi connection.
17    pub wifi_permitted: Vec<String>,
18}
19
20impl Runtime {
21    pub fn new(txn_dir: PathBuf) -> Runtime {
22        Runtime { txn_dir, wifi_permitted: vec![] }
23    }
24}
25
26/// The active Wi-Fi connection from `nmcli -t -f NAME,TYPE connection show --active`.
27pub fn active_wifi(text: &str) -> Option<String> {
28    text.lines().find_map(|l| {
29        let (name, kind) = l.rsplit_once(':')?;
30        (kind == "802-11-wireless").then(|| name.replace("\\:", ":"))
31    })
32}
33
34impl RuntimeOptionBackend for Runtime {
35    fn get(&self, path: &str) -> Option<Value> {
36        match path {
37            "wifi.network" => {
38                let o =
39                    run(&["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"], Duration::from_secs(5));
40                active_wifi(&o.stdout).map(Value::Str)
41            }
42            _ => None,
43        }
44    }
45
46    fn set(&self, path: &str, value: &Value, op: &OpId) -> Outcome {
47        if !admitted(&self.txn_dir, op) {
48            return Outcome::fenced();
49        }
50        match (path, value) {
51            ("wifi.network", Value::Str(ssid)) => {
52                // Only a saved Wi-Fi connection the catalog permits: `nmcli
53                // connection up` as root would bring up any profile (a VPN, a
54                // bridge, another user's connection).
55                if !self.wifi_permitted.is_empty() && !self.wifi_permitted.contains(ssid) {
56                    return Outcome::Failed(format!("{ssid} is not a permitted Wi-Fi network"));
57                }
58                let saved = run(&["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"], Duration::from_secs(10));
59                if !wifi_connections(&saved.stdout).contains(ssid) {
60                    return Outcome::Failed(format!("{ssid} is not a saved Wi-Fi connection"));
61                }
62                let o = run(&["nmcli", "connection", "up", "id", ssid], Duration::from_secs(60));
63                if o.ok() { Outcome::Succeeded } else { Outcome::Failed(o.tail()) }
64            }
65            _ => Outcome::Failed(format!("{path} has no live mechanism; it applies through the configuration")),
66        }
67    }
68
69    fn alternatives(&self, path: &str) -> Vec<Value> {
70        if path != "wifi.network" {
71            return vec![];
72        }
73        let t = Duration::from_secs(20);
74        let saved = run(&["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"], t);
75        let active = run(&["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"], t);
76        let scan = run(&["nmcli", "-t", "-f", "SSID", "device", "wifi", "list", "--rescan", "yes"], t);
77        let in_range: Vec<String> = scan.stdout.lines().map(|l| l.replace("\\:", ":")).collect();
78        let current = active_wifi(&active.stdout);
79        wifi_connections(&saved.stdout)
80            .into_iter()
81            .filter(|name| Some(name) != current.as_ref())
82            .filter(|name| {
83                // A connection's SSID, which is what the scan reports.
84                let ssid = run(&["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", name], t);
85                in_range.iter().any(|s| s == ssid.stdout.trim())
86            })
87            .map(Value::Str)
88            .collect()
89    }
90}
91
92/// Saved Wi-Fi connections from `nmcli -t -f NAME,TYPE connection show`.
93pub fn wifi_connections(text: &str) -> Vec<String> {
94    text.lines()
95        .filter_map(|l| {
96            let (name, kind) = l.rsplit_once(':')?;
97            (kind == "802-11-wireless").then(|| name.replace("\\:", ":"))
98        })
99        .collect()
100}
101
102#[cfg(test)]
103mod tests {
104    #[test]
105    fn wifi() {
106        assert_eq!(super::active_wifi("Wired:802-3-ethernet\nhome\\:5G:802-11-wireless\n"), Some("home:5G".into()));
107        assert_eq!(super::active_wifi(""), None);
108        assert_eq!(
109            super::wifi_connections("Wired:802-3-ethernet\ngym-ap-a:802-11-wireless\ngym-ap-b:802-11-wireless\n"),
110            vec!["gym-ap-a".to_string(), "gym-ap-b".to_string()]
111        );
112    }
113}