1use 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 #[serde(default)]
48 pub key_pending: bool,
49}
50
51pub fn default_relay_url() -> String {
52 "http://localhost:8080".to_string()
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RelayProfile {
57 pub id: String,
58 pub label: String,
59 pub relay_url: String,
60 pub user_id: String,
61 pub device_id: String,
62 pub hostname: String,
63 #[serde(default)]
64 pub encryption_key: String,
65 #[serde(default)]
66 pub device_private_key: String,
67 #[serde(default)]
68 pub credential_version: u64,
69 #[serde(default)]
70 pub token: String,
71 #[serde(default)]
76 pub machine_id: String,
77 #[serde(default)]
80 pub email: String,
81 #[serde(default)]
84 pub identity_provider: String,
85 #[serde(default)]
89 pub display_name: String,
90 #[serde(default)]
96 pub key_pending: bool,
97}
98
99impl RelayProfile {
100 pub fn from_config(cfg: &Config, label: Option<String>) -> Self {
101 use ulid::Ulid;
102 let id = Ulid::new().to_string();
103 let label = label.unwrap_or_else(|| {
104 url::Url::parse(&cfg.relay_url)
105 .ok()
106 .and_then(|u| u.host_str().map(|h| h.to_string()))
107 .unwrap_or_else(|| cfg.relay_url.clone())
108 });
109 Self {
110 id,
111 label,
112 relay_url: cfg.relay_url.clone(),
113 user_id: cfg.user_id.clone(),
114 device_id: cfg.active_device_id.clone(),
115 hostname: cfg.hostname.clone(),
116 encryption_key: cfg.encryption_key.clone(),
117 device_private_key: cfg.device_private_key.clone(),
118 credential_version: cfg.credential_version,
119 token: cfg.token.clone(),
120 machine_id: crate::machine::stable_machine_id(),
121 email: cfg.email.clone(),
122 identity_provider: cfg.identity_provider.clone(),
123 display_name: cfg.display_name.clone(),
124 key_pending: cfg.key_pending,
125 }
126 }
127
128 pub fn to_config(&self) -> Config {
129 Config {
130 token: self.token.clone(),
131 user_id: self.user_id.clone(),
132 relay_url: self.relay_url.clone(),
133 hostname: self.hostname.clone(),
134 active_device_id: self.device_id.clone(),
135 credential_version: self.credential_version,
136 encryption_key: self.encryption_key.clone(),
137 device_private_key: self.device_private_key.clone(),
138 email: self.email.clone(),
139 identity_provider: self.identity_provider.clone(),
140 display_name: self.display_name.clone(),
141 key_pending: self.key_pending,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, Default)]
147pub struct MultiConfig {
148 #[serde(default)]
149 pub active_relay_id: Option<String>,
150 #[serde(default)]
151 pub relays: Vec<RelayProfile>,
152}
153
154pub type MultiConfigHandle = Arc<Mutex<MultiConfig>>;
155
156impl MultiConfig {
157 pub fn load() -> Self {
158 let Some(home) = dirs::home_dir() else {
159 return Self::default();
160 };
161 let path = home.join(".cinch").join("config.json");
162 if !path.exists() {
163 return Self::default();
164 }
165 let Ok(data) = std::fs::read_to_string(&path) else {
166 return Self::default();
167 };
168 let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) else {
169 return Self::default();
170 };
171 if v.get("relays").is_some() {
172 serde_json::from_value(v).unwrap_or_default()
173 } else {
174 let old: Config = match serde_json::from_value(v) {
175 Ok(c) => c,
176 Err(_) => return Self::default(),
177 };
178 Self::from_legacy(old)
179 }
180 }
181
182 pub fn save(&self) -> Result<(), String> {
183 let home = dirs::home_dir().ok_or("cannot determine home directory")?;
184 let dir = home.join(".cinch");
185 std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {}", e))?;
186 let path = dir.join("config.json");
187 let data = serde_json::to_string_pretty(self).map_err(|e| format!("marshal: {}", e))?;
188 std::fs::write(&path, &data).map_err(|e| format!("write: {}", e))?;
189 #[cfg(unix)]
190 {
191 use std::os::unix::fs::PermissionsExt;
192 if let Ok(meta) = std::fs::metadata(&path) {
193 let mut perms = meta.permissions();
194 perms.set_mode(0o600);
195 let _ = std::fs::set_permissions(&path, perms);
196 }
197 }
198 Ok(())
199 }
200
201 pub fn active_profile(&self) -> Option<&RelayProfile> {
202 let id = self.active_relay_id.as_deref()?;
203 self.relays.iter().find(|r| r.id == id)
204 }
205
206 pub fn active_profile_mut(&mut self) -> Option<&mut RelayProfile> {
207 let id = self.active_relay_id.clone()?;
208 self.relays.iter_mut().find(|r| r.id == id)
209 }
210
211 pub fn to_active_config(&self) -> Config {
212 self.active_profile()
213 .map(|p| p.to_config())
214 .unwrap_or_default()
215 }
216
217 pub fn from_legacy_pub(old: Config) -> Self {
218 Self::from_legacy(old)
219 }
220
221 fn from_legacy(old: Config) -> Self {
222 if old.user_id.is_empty() && old.token.is_empty() {
223 return Self::default();
224 }
225 let profile = RelayProfile::from_config(&old, None);
226 let id = profile.id.clone();
227 Self {
228 active_relay_id: Some(id),
229 relays: vec![profile],
230 }
231 }
232}
233
234impl Default for Config {
235 fn default() -> Self {
236 Self {
237 token: String::new(),
238 user_id: String::new(),
239 relay_url: default_relay_url(),
240 hostname: String::new(),
241 active_device_id: String::new(),
242 credential_version: 0,
243 encryption_key: String::new(),
244 device_private_key: String::new(),
245 email: String::new(),
246 identity_provider: String::new(),
247 display_name: String::new(),
248 key_pending: false,
249 }
250 }
251}
252
253impl Config {
254 pub fn is_configured(&self) -> bool {
255 !self.user_id.is_empty() && !self.active_device_id.is_empty()
256 }
257
258 pub fn load() -> Result<Self, String> {
259 let mc = MultiConfig::load();
260 let cfg = mc.to_active_config();
261 if cfg.user_id.is_empty() && cfg.token.is_empty() {
262 return Err("no active relay configured — run: cinch auth login".to_string());
263 }
264 Ok(cfg)
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn test_is_configured_accepts_keyring_backed_config() {
274 let config = Config {
275 token: String::new(),
276 user_id: "u1".into(),
277 relay_url: "https://api.cinchcli.com".into(),
278 hostname: "macbook".into(),
279 active_device_id: "d1".into(),
280 credential_version: 1,
281 encryption_key: String::new(),
282 device_private_key: String::new(),
283 email: String::new(),
284 identity_provider: String::new(),
285 display_name: String::new(),
286 key_pending: false,
287 };
288 assert!(config.is_configured());
289 }
290
291 #[test]
292 fn relay_profile_roundtrips_display_name_through_config() {
293 let cfg = Config {
294 token: "t".into(),
295 user_id: "u".into(),
296 relay_url: "https://r".into(),
297 hostname: "h".into(),
298 active_device_id: "d".into(),
299 credential_version: 1,
300 encryption_key: String::new(),
301 device_private_key: String::new(),
302 email: "alice@example.com".into(),
303 identity_provider: "github".into(),
304 display_name: "Alice Example".into(),
305 key_pending: false,
306 };
307 let prof = RelayProfile::from_config(&cfg, Some("test".into()));
308 assert_eq!(prof.display_name, "Alice Example");
309 let back = prof.to_config();
310 assert_eq!(back.display_name, "Alice Example");
311 }
312
313 #[test]
314 fn relay_profile_defaults_display_name_when_legacy_json_missing_field() {
315 let json = r#"{
316 "id": "01HZ",
317 "label": "main",
318 "relay_url": "https://r",
319 "user_id": "u",
320 "device_id": "d",
321 "hostname": "h"
322 }"#;
323 let p: RelayProfile = serde_json::from_str(json).expect("decode");
324 assert_eq!(p.display_name, "");
325 }
326}