Skip to main content

solari_browser/
profile_editor.rs

1//! Editing an existing profile through the profile editor: [`Profiles::edit`](crate::Profiles::edit)
2//! returns a [`ProfileEditor`] handle.
3
4use 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/// State of a profile editor.
15#[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/// Result of [`ProfileEditor::status`].
26#[derive(Debug, Clone, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct EditorState {
29    /// The gateway reports `null` before the editor page has been opened; that reads as `Idle`.
30    #[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/// A handle on an open profile editor. Send the person to [`url`](Self::url); it works until
58/// [`expires_at`](Self::expires_at). There is no save without closing: [`save_and_close`](Self::save_and_close)
59/// stores the profile and ends the editor, [`exit`](Self::exit) ends it without saving.
60///
61/// An expired or used link fails every call with an API error of status 404; an editor that is not running
62/// with 409; a failed save with 502 (the editor goes to error: `exit` and start a new `edit`; a second save gets 409). Save and exit are never retried automatically.
63#[derive(Debug, Clone)]
64pub struct ProfileEditor {
65    client: Client,
66    pub profile_id: String,
67    pub handoff_id: String,
68    /// The editor link to open in a browser.
69    pub url: String,
70    pub expires_at: SystemTime,
71    /// The profile's version when the editor was opened.
72    pub start_version: u64,
73    /// How often [`wait_for_save`](Self::wait_for_save) polls. Default 2 s.
74    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    /// `GET /profiles/:id/editor`.
138    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    /// `POST /profiles/:id/editor/save` — store the editor's browser state in the profile and close the
146    /// editor. Returns the new version.
147    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    /// `DELETE /profiles/:id/editor` — close the editor without saving.
160    pub async fn exit(&self) -> Result<(), SolariError> {
161        self.call("DELETE", "", true).await.map(|_| ())
162    }
163
164    /// Poll the profile list until this profile's version exceeds [`start_version`](Self::start_version)
165    /// (someone saved from the editor page) and return it. Fails with an API error of status 408 when
166    /// `timeout` passes or the link expires first. `None` waits until the link expires.
167    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
196/// ISO-8601 (`2030-01-01T00:30:00.000Z`) or epoch milliseconds.
197fn 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    // Days from civil (Howard Hinnant).
243    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}