Skip to main content

client_core/
config.rs

1//! On-disk client config: `~/.cinch/config.json`.
2//!
3//! Wire-compatible with the Go CLI's `cinch/internal/config/config.go` schema.
4//! `MultiConfig` is the canonical disk format; `Config` is the legacy single-relay
5//! shape kept for backwards compatibility (and as the in-memory shape consumers
6//! handle most often via `MultiConfig::to_active_config`).
7//!
8//! Permissions: 0700 dir, 0600 file on Unix.
9
10use std::sync::{Arc, Mutex};
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Config {
16    #[serde(default)]
17    pub token: String,
18    #[serde(default)]
19    pub user_id: String,
20    #[serde(default = "default_relay_url")]
21    pub relay_url: String,
22    #[serde(default)]
23    pub hostname: String,
24    #[serde(default)]
25    pub active_device_id: String,
26    #[serde(default)]
27    pub credential_version: u64,
28    #[serde(default)]
29    pub encryption_key: String,
30    #[serde(default)]
31    pub device_private_key: String,
32    #[serde(default)]
33    pub email: String,
34    #[serde(default)]
35    pub identity_provider: String,
36    #[serde(default)]
37    pub display_name: String,
38}
39
40pub fn default_relay_url() -> String {
41    "http://localhost:8080".to_string()
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct RelayProfile {
46    pub id: String,
47    pub label: String,
48    pub relay_url: String,
49    pub user_id: String,
50    pub device_id: String,
51    pub hostname: String,
52    #[serde(default)]
53    pub encryption_key: String,
54    #[serde(default)]
55    pub device_private_key: String,
56    #[serde(default)]
57    pub credential_version: u64,
58    #[serde(default)]
59    pub token: String,
60    /// Stable per-machine identifier (opaque hash). Used by the relay to
61    /// recognize repeat sign-ins from the same machine and reuse a single
62    /// device row instead of creating duplicates. Empty for legacy configs;
63    /// backfilled on next save via `client_core::machine::stable_machine_id`.
64    #[serde(default)]
65    pub machine_id: String,
66    /// Verified email address returned by the OAuth provider at login time.
67    /// Empty for legacy configs or when the provider did not return a verified email.
68    #[serde(default)]
69    pub email: String,
70    /// OAuth identity provider used for the most recent login ("google" or "github").
71    /// Empty for legacy configs.
72    #[serde(default)]
73    pub identity_provider: String,
74    /// Effective display name. Set by the user via `cinch auth set-name` or
75    /// the desktop settings UI; falls back to the OAuth-fetched name on
76    /// login if the user hasn't overridden it. Empty for legacy configs.
77    #[serde(default)]
78    pub display_name: String,
79}
80
81impl RelayProfile {
82    pub fn from_config(cfg: &Config, label: Option<String>) -> Self {
83        use ulid::Ulid;
84        let id = Ulid::new().to_string();
85        let label = label.unwrap_or_else(|| {
86            url::Url::parse(&cfg.relay_url)
87                .ok()
88                .and_then(|u| u.host_str().map(|h| h.to_string()))
89                .unwrap_or_else(|| cfg.relay_url.clone())
90        });
91        Self {
92            id,
93            label,
94            relay_url: cfg.relay_url.clone(),
95            user_id: cfg.user_id.clone(),
96            device_id: cfg.active_device_id.clone(),
97            hostname: cfg.hostname.clone(),
98            encryption_key: cfg.encryption_key.clone(),
99            device_private_key: cfg.device_private_key.clone(),
100            credential_version: cfg.credential_version,
101            token: cfg.token.clone(),
102            machine_id: crate::machine::stable_machine_id(),
103            email: cfg.email.clone(),
104            identity_provider: cfg.identity_provider.clone(),
105            display_name: cfg.display_name.clone(),
106        }
107    }
108
109    pub fn to_config(&self) -> Config {
110        Config {
111            token: self.token.clone(),
112            user_id: self.user_id.clone(),
113            relay_url: self.relay_url.clone(),
114            hostname: self.hostname.clone(),
115            active_device_id: self.device_id.clone(),
116            credential_version: self.credential_version,
117            encryption_key: self.encryption_key.clone(),
118            device_private_key: self.device_private_key.clone(),
119            email: self.email.clone(),
120            identity_provider: self.identity_provider.clone(),
121            display_name: self.display_name.clone(),
122        }
123    }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127pub struct MultiConfig {
128    #[serde(default)]
129    pub active_relay_id: Option<String>,
130    #[serde(default)]
131    pub relays: Vec<RelayProfile>,
132}
133
134pub type MultiConfigHandle = Arc<Mutex<MultiConfig>>;
135
136impl MultiConfig {
137    pub fn load() -> Self {
138        let Some(home) = dirs::home_dir() else {
139            return Self::default();
140        };
141        let path = home.join(".cinch").join("config.json");
142        if !path.exists() {
143            return Self::default();
144        }
145        let Ok(data) = std::fs::read_to_string(&path) else {
146            return Self::default();
147        };
148        let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) else {
149            return Self::default();
150        };
151        if v.get("relays").is_some() {
152            serde_json::from_value(v).unwrap_or_default()
153        } else {
154            let old: Config = match serde_json::from_value(v) {
155                Ok(c) => c,
156                Err(_) => return Self::default(),
157            };
158            Self::from_legacy(old)
159        }
160    }
161
162    pub fn save(&self) -> Result<(), String> {
163        let home = dirs::home_dir().ok_or("cannot determine home directory")?;
164        let dir = home.join(".cinch");
165        std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {}", e))?;
166        let path = dir.join("config.json");
167        let data = serde_json::to_string_pretty(self).map_err(|e| format!("marshal: {}", e))?;
168        std::fs::write(&path, &data).map_err(|e| format!("write: {}", e))?;
169        #[cfg(unix)]
170        {
171            use std::os::unix::fs::PermissionsExt;
172            if let Ok(meta) = std::fs::metadata(&path) {
173                let mut perms = meta.permissions();
174                perms.set_mode(0o600);
175                let _ = std::fs::set_permissions(&path, perms);
176            }
177        }
178        Ok(())
179    }
180
181    pub fn active_profile(&self) -> Option<&RelayProfile> {
182        let id = self.active_relay_id.as_deref()?;
183        self.relays.iter().find(|r| r.id == id)
184    }
185
186    pub fn active_profile_mut(&mut self) -> Option<&mut RelayProfile> {
187        let id = self.active_relay_id.clone()?;
188        self.relays.iter_mut().find(|r| r.id == id)
189    }
190
191    pub fn to_active_config(&self) -> Config {
192        self.active_profile()
193            .map(|p| p.to_config())
194            .unwrap_or_default()
195    }
196
197    pub fn from_legacy_pub(old: Config) -> Self {
198        Self::from_legacy(old)
199    }
200
201    fn from_legacy(old: Config) -> Self {
202        if old.user_id.is_empty() && old.token.is_empty() {
203            return Self::default();
204        }
205        let profile = RelayProfile::from_config(&old, None);
206        let id = profile.id.clone();
207        Self {
208            active_relay_id: Some(id),
209            relays: vec![profile],
210        }
211    }
212}
213
214impl Default for Config {
215    fn default() -> Self {
216        Self {
217            token: String::new(),
218            user_id: String::new(),
219            relay_url: default_relay_url(),
220            hostname: String::new(),
221            active_device_id: String::new(),
222            credential_version: 0,
223            encryption_key: String::new(),
224            device_private_key: String::new(),
225            email: String::new(),
226            identity_provider: String::new(),
227            display_name: String::new(),
228        }
229    }
230}
231
232impl Config {
233    pub fn is_configured(&self) -> bool {
234        !self.user_id.is_empty() && !self.active_device_id.is_empty()
235    }
236
237    pub fn load() -> Result<Self, String> {
238        let mc = MultiConfig::load();
239        let cfg = mc.to_active_config();
240        if cfg.user_id.is_empty() && cfg.token.is_empty() {
241            return Err("no active relay configured — run: cinch auth login".to_string());
242        }
243        Ok(cfg)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_is_configured_accepts_keyring_backed_config() {
253        let config = Config {
254            token: String::new(),
255            user_id: "u1".into(),
256            relay_url: "https://api.cinchcli.com".into(),
257            hostname: "macbook".into(),
258            active_device_id: "d1".into(),
259            credential_version: 1,
260            encryption_key: String::new(),
261            device_private_key: String::new(),
262            email: String::new(),
263            identity_provider: String::new(),
264            display_name: String::new(),
265        };
266        assert!(config.is_configured());
267    }
268
269    #[test]
270    fn relay_profile_roundtrips_display_name_through_config() {
271        let cfg = Config {
272            token: "t".into(),
273            user_id: "u".into(),
274            relay_url: "https://r".into(),
275            hostname: "h".into(),
276            active_device_id: "d".into(),
277            credential_version: 1,
278            encryption_key: String::new(),
279            device_private_key: String::new(),
280            email: "alice@example.com".into(),
281            identity_provider: "github".into(),
282            display_name: "Alice Example".into(),
283        };
284        let prof = RelayProfile::from_config(&cfg, Some("test".into()));
285        assert_eq!(prof.display_name, "Alice Example");
286        let back = prof.to_config();
287        assert_eq!(back.display_name, "Alice Example");
288    }
289
290    #[test]
291    fn relay_profile_defaults_display_name_when_legacy_json_missing_field() {
292        let json = r#"{
293            "id": "01HZ",
294            "label": "main",
295            "relay_url": "https://r",
296            "user_id": "u",
297            "device_id": "d",
298            "hostname": "h"
299        }"#;
300        let p: RelayProfile = serde_json::from_str(json).expect("decode");
301        assert_eq!(p.display_name, "");
302    }
303}