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}
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 #[serde(default)]
65 pub machine_id: String,
66 #[serde(default)]
69 pub email: String,
70 #[serde(default)]
73 pub identity_provider: String,
74 #[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}