1use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
5
6use serde::Deserialize;
7use serde_json::{json, Value};
8
9use crate::client::{enc, Client};
10use crate::error::{api_error, SolariError};
11
12const DEFAULT_POLL: Duration = Duration::from_secs(2);
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum EditorStatus {
18 Idle,
19 Launching,
20 Editing,
21 Saving,
22 Error,
23}
24
25#[derive(Debug, Clone, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct EditorState {
29 #[serde(default, deserialize_with = "status_or_idle")]
31 pub editor_status: EditorStatus,
32 #[serde(default)]
33 pub version: u64,
34}
35
36impl Default for EditorStatus {
37 fn default() -> Self {
38 EditorStatus::Idle
39 }
40}
41
42fn status_or_idle<'de, D: serde::Deserializer<'de>>(d: D) -> Result<EditorStatus, D::Error> {
43 Ok(Option::<EditorStatus>::deserialize(d)?.unwrap_or_default())
44}
45
46#[derive(Deserialize)]
47#[serde(rename_all = "camelCase")]
48struct Handoff {
49 handoff_id: String,
50 url: String,
51 expires_at: Value,
52 #[serde(default)]
53 version: u64,
54 profile_id: Option<String>,
55}
56
57#[derive(Debug, Clone)]
64pub struct ProfileEditor {
65 client: Client,
66 pub profile_id: String,
67 pub handoff_id: String,
68 pub url: String,
70 pub expires_at: SystemTime,
71 pub start_version: u64,
73 pub poll_interval: Duration,
75}
76
77impl ProfileEditor {
78 pub(crate) async fn open(
79 client: Client,
80 id: &str,
81 reason: Option<&str>,
82 ) -> Result<Self, SolariError> {
83 let path = format!("/profiles/{}/login-handoff", enc(id));
84 let body = match reason {
85 Some(r) => json!({ "reason": r }),
86 None => json!({}),
87 };
88 let res = client.http.request("POST", &path, Some(&body)).await?;
89 if !res.ok() {
90 return Err(api_error("POST", &path, res.status, &res.body));
91 }
92 let h: Handoff = serde_json::from_str(&res.body).map_err(|e| {
93 SolariError::protocol(format!("Solari: unexpected login-handoff response: {e}"))
94 })?;
95 let expires_at = parse_expires_at(&h.expires_at).ok_or_else(|| {
96 SolariError::protocol(format!(
97 "Solari: unparseable login-handoff expiresAt: {}",
98 h.expires_at
99 ))
100 })?;
101 Ok(ProfileEditor {
102 client,
103 profile_id: h.profile_id.unwrap_or_else(|| id.to_string()),
104 handoff_id: h.handoff_id,
105 url: h.url,
106 expires_at,
107 start_version: h.version,
108 poll_interval: DEFAULT_POLL,
109 })
110 }
111
112 async fn call(
113 &self,
114 method: &str,
115 suffix: &str,
116 no_retry: bool,
117 ) -> Result<String, SolariError> {
118 let path = format!("/profiles/{}/editor{}", enc(&self.profile_id), suffix);
119 let res = self
120 .client
121 .http
122 .request_full(
123 method,
124 &path,
125 None,
126 None,
127 &[("X-Handoff-Id", &self.handoff_id)],
128 no_retry,
129 )
130 .await?;
131 if !res.ok() {
132 return Err(api_error(method, &path, res.status, &res.body));
133 }
134 Ok(res.body)
135 }
136
137 pub async fn status(&self) -> Result<EditorState, SolariError> {
139 let body = self.call("GET", "", false).await?;
140 serde_json::from_str(&body).map_err(|e| {
141 SolariError::protocol(format!("Solari: unexpected editor status response: {e}"))
142 })
143 }
144
145 pub async fn save_and_close(&self) -> Result<u64, SolariError> {
148 let body = self.call("POST", "/save", true).await?;
149 #[derive(Deserialize)]
150 struct Saved {
151 version: Option<u64>,
152 }
153 let s: Saved = serde_json::from_str(&body).map_err(|e| {
154 SolariError::protocol(format!("Solari: unexpected editor save response: {e}"))
155 })?;
156 Ok(s.version.unwrap_or(0))
157 }
158
159 pub async fn exit(&self) -> Result<(), SolariError> {
161 self.call("DELETE", "", true).await.map(|_| ())
162 }
163
164 pub async fn wait_for_save(&self, timeout: Option<Duration>) -> Result<u64, SolariError> {
168 let until_expiry = self
169 .expires_at
170 .duration_since(SystemTime::now())
171 .unwrap_or(Duration::ZERO);
172 let budget = timeout.map_or(until_expiry, |t| t.min(until_expiry));
173 let deadline = Instant::now() + budget;
174 loop {
175 let list = self.client.profiles().list().await?;
176 if let Some(p) = list.iter().find(|p| p.id == self.profile_id) {
177 if p.version > self.start_version {
178 return Ok(p.version);
179 }
180 }
181 if Instant::now() + self.poll_interval >= deadline {
182 return Err(SolariError::Api {
183 status: 408,
184 code: None,
185 message: format!(
186 "Solari: timed out waiting for profile {} to be saved",
187 self.profile_id
188 ),
189 });
190 }
191 tokio::time::sleep(self.poll_interval).await;
192 }
193 }
194}
195
196fn parse_expires_at(v: &Value) -> Option<SystemTime> {
198 if let Some(ms) = v.as_u64() {
199 return Some(UNIX_EPOCH + Duration::from_millis(ms));
200 }
201 let s = v.as_str()?;
202 if let Ok(ms) = s.parse::<u64>() {
203 return Some(UNIX_EPOCH + Duration::from_millis(ms));
204 }
205 let b = s.as_bytes();
206 if b.len() < 20
207 || b[4] != b'-'
208 || b[7] != b'-'
209 || b[10] != b'T'
210 || b[13] != b':'
211 || b[16] != b':'
212 {
213 return None;
214 }
215 let n = |r: std::ops::Range<usize>| s.get(r)?.parse::<i64>().ok();
216 let (y, mo, d, h, mi, sec) = (
217 n(0..4)?,
218 n(5..7)?,
219 n(8..10)?,
220 n(11..13)?,
221 n(14..16)?,
222 n(17..19)?,
223 );
224 let mut rest = &s[19..];
225 let mut nanos = 0u32;
226 if let Some(frac) = rest.strip_prefix('.') {
227 let digits: String = frac.chars().take_while(|c| c.is_ascii_digit()).collect();
228 rest = &frac[digits.len()..];
229 nanos = format!("{:0<9}", &digits[..digits.len().min(9)])
230 .parse()
231 .ok()?;
232 }
233 let offset_secs = match rest {
234 "Z" => 0,
235 o if o.len() == 6 && (o.starts_with('+') || o.starts_with('-')) => {
236 let sign = if o.starts_with('-') { -1 } else { 1 };
237 sign * (o.get(1..3)?.parse::<i64>().ok()? * 3600
238 + o.get(4..6)?.parse::<i64>().ok()? * 60)
239 }
240 _ => return None,
241 };
242 let y2 = if mo <= 2 { y - 1 } else { y };
244 let era = y2.div_euclid(400);
245 let yoe = y2 - era * 400;
246 let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;
247 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
248 let days = era * 146097 + doe - 719468;
249 let secs = days * 86400 + h * 3600 + mi * 60 + sec - offset_secs;
250 u64::try_from(secs)
251 .ok()
252 .map(|s| UNIX_EPOCH + Duration::new(s, nanos))
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn a_null_editor_status_is_idle() {
261 let st: EditorState =
262 serde_json::from_str(r#"{"id":"p","editorStatus":null,"version":3}"#).unwrap();
263 assert_eq!((st.editor_status, st.version), (EditorStatus::Idle, 3));
264 }
265
266 #[test]
267 fn parses_iso_and_epoch_expiry() {
268 let at = |s: SystemTime| s.duration_since(UNIX_EPOCH).unwrap().as_secs();
269 assert_eq!(
270 at(parse_expires_at(&json!("2030-01-01T00:30:00.000Z")).unwrap()),
271 1893457800
272 );
273 assert_eq!(
274 at(parse_expires_at(&json!(4102444800000u64)).unwrap()),
275 4102444800
276 );
277 assert_eq!(
278 at(parse_expires_at(&json!("2030-01-01T01:30:00+01:00")).unwrap()),
279 1893457800
280 );
281 assert!(parse_expires_at(&json!("soon")).is_none());
282 }
283}