Skip to main content

brep_app/plm/
connection.rs

1//! The PLM tab's state and actions (plan S1 part 3): sign in with a pasted
2//! token or a password, check the connection, forget the machine's sign-in.
3//!
4//! Every action runs on the host's executor and lands in a slot the tab polls
5//! each frame; the tab drives the queue itself while an action is in flight,
6//! because a session on the file stores (no PLM configured yet, or a refused
7//! boot) has no store draining it.
8//!
9//! What an action leaves behind:
10//!
11//! - **Native.** A sign-in writes the token to `plm-token` beside `plm.json`
12//!   (D5) and offers Connect now, which switches the running session when it
13//!   is in a fresh start's state; otherwise the next start connects: the store
14//!   is chosen before the first frame, and settings, the dock and recovery all
15//!   read it there.
16//!   Forget removes both files; the next start is the file app.
17//! - **Web.** The page is served by the PLM (D6), so a sign-in is the PLM's own
18//!   session cookie, and the page reloads onto it. Forget signs the session
19//!   out and reloads.
20use super::client::{DesktopLink, LinkPoll, Me, PlmClient, PlmError};
21use super::transport::EhttpTransport;
22use super::PlmTransport;
23use std::cell::RefCell;
24use std::rc::Rc;
25
26/// What the tab knows about the PLM, before any action.
27#[derive(Clone, Debug, PartialEq)]
28pub enum Status {
29    /// The session's store is the PLM.
30    Connected { url: String, username: String },
31    /// A PLM is configured (native) or serving this page (web), but this
32    /// session is not on it. `reason` is why, when known: the boot's refusal,
33    /// or a check's.
34    NotConnected { url: String, reason: Option<String> },
35}
36
37/// The outcome of the last action, as the tab shows it.
38#[derive(Clone, Debug, PartialEq)]
39pub enum Outcome {
40    /// Done; the sentence says what happens next.
41    Done(String),
42    /// Refused or failed, in the server's (or the machine's) words.
43    Failed(String),
44}
45
46/// A session's PLM, as its store reports it ([`crate::store::ModelStore::plm_session`]):
47/// where it is, whether this session is on it, and where a sign-in is kept.
48#[derive(Clone, Debug, PartialEq)]
49pub struct Session {
50    pub status: Status,
51    pub keep: Keep,
52}
53
54/// Where this session's PLM server URL is kept, for the Settings PLM tab's
55/// Server URL field ([`crate::store::ModelStore::plm_url_setting`]).
56#[derive(Clone, Debug, PartialEq)]
57pub struct UrlSetting {
58    /// The config directory whose `plm.json` the field writes.
59    pub dir: std::path::PathBuf,
60    /// The URL this session uses (or, not connected, would use): `None` when
61    /// nothing names one.
62    pub url: Option<String>,
63    /// Where `url` came from: `plm.json`, `BREP_PLM_URL` or `--plm-url`.
64    pub source: Option<super::config::Source>,
65}
66
67/// A Server URL the field saved: the URL as written, and whether this
68/// machine's sign-in for the server it replaced was dropped.
69#[derive(Clone, Debug, PartialEq)]
70pub struct UrlSaved {
71    pub url: String,
72    pub token_dropped: bool,
73}
74
75/// The URL and user in the label `super::backend::compose` gives a PLM
76/// store (`PLM <url> as <user>`), or `None` for any other store.
77pub fn parse_label(label: &str) -> Option<(String, String)> {
78    let rest = label.strip_prefix("PLM ")?;
79    let (url, user) = rest.rsplit_once(" as ")?;
80    Some((url.to_string(), user.to_string()))
81}
82
83/// Where the machine's sign-in lives: the config directory on native, the
84/// PLM's own session cookie on the web.
85#[derive(Clone, Debug, PartialEq)]
86pub enum Keep {
87    Files(std::path::PathBuf),
88    BrowserSession,
89}
90
91pub struct PlmConnection {
92    pub status: Status,
93    keep: Keep,
94    pub token_input: String,
95    pub username: String,
96    pub password: String,
97    pending: Option<Rc<RefCell<Option<Outcome>>>>,
98    pub last: Option<Outcome>,
99    /// A web action finished that the page must reload onto.
100    reload: bool,
101    /// The server took this machine's sign-in (a sign-in or a check passed):
102    /// the session may switch to it live (native Connect now).
103    connectable: bool,
104    /// Builds the transport for the server's URL: ehttp in the app, the
105    /// in-process router in tests.
106    transport: Rc<dyn Fn(&str) -> Rc<dyn PlmTransport>>,
107    /// A browser sign-in's registration, in flight.
108    registering: Option<Slot<Result<DesktopLink, PlmError>>>,
109    /// A browser sign-in waiting for the person to approve it.
110    pub link: Option<BrowserLink>,
111    /// The link page a browser sign-in wants opened, for the tab to hand
112    /// to `egui::Context::open_url` (the default browser natively; nothing in
113    /// the headless automation host).
114    open_request: Option<String>,
115    /// Seconds on a monotonic clock: the poll schedule's time.
116    clock: Rc<dyn Fn() -> f64>,
117    /// A native sign-in that passed: `(username, token)`, for the shell to
118    /// hand to the live session when its own token was revoked.
119    fresh: Slot<(String, String)>,
120    /// Forget ended this machine's sign-in on the server: the live session's
121    /// token is gone, by the person's own choice.
122    signed_out_here: Slot<()>,
123}
124
125type Slot<T> = Rc<RefCell<Option<T>>>;
126
127/// What a browser sign-in found when it ended without a token.
128const LINK_EXPIRED: &str = "the browser sign-in expired before it was approved — start it again";
129
130/// The longest wait between polls after failures, in seconds.
131const MAX_BACKOFF: f64 = 30.0;
132
133/// A browser sign-in in progress (native): the code the tab shows, the link
134/// the browser was sent to, and the poll schedule.
135pub struct BrowserLink {
136    pub code: String,
137    pub url: String,
138    /// The last poll's trouble, while it keeps trying.
139    pub note: Option<String>,
140    secret: String,
141    poller: LinkPoller,
142    in_flight: Option<Slot<Result<LinkPoll, PlmError>>>,
143}
144
145/// When a browser sign-in asks next, when it stops, and what each answer
146/// means. Time is seconds on any monotonic clock.
147#[derive(Clone, Debug, PartialEq)]
148pub struct LinkPoller {
149    expires_at: f64,
150    interval: f64,
151    next_at: f64,
152    failures: u32,
153}
154
155/// Where a browser sign-in stands after an answer.
156#[derive(Clone, Debug, PartialEq)]
157pub enum LinkStep {
158    /// Keep waiting; `note` is why the last poll failed, if it did.
159    Wait { note: Option<String> },
160    /// Approved: the machine's new token.
161    Approved(String),
162    /// Over without a token; the sentence says why.
163    Ended(String),
164}
165
166impl LinkPoller {
167    pub fn new(now: f64, expires_in: u64, interval: u64) -> Self {
168        let interval = interval.max(1) as f64;
169        Self { expires_at: now + expires_in as f64, interval, next_at: now + interval, failures: 0 }
170    }
171
172    pub fn expired(&self, now: f64) -> bool {
173        now >= self.expires_at
174    }
175
176    /// Time to ask again.
177    pub fn due(&self, now: f64) -> bool {
178        !self.expired(now) && now >= self.next_at
179    }
180
181    /// Seconds until the next poll (or the end), for the repaint timer.
182    pub fn wait(&self, now: f64) -> f64 {
183        (self.next_at.min(self.expires_at) - now).max(0.0)
184    }
185
186    /// Take one poll's answer. A pending link is asked again after the
187    /// server's interval; a failure the server may recover from (no answer,
188    /// a 5xx, the rate limit) backs off, doubling up to `MAX_BACKOFF`, and
189    /// keeps trying until the link expires; anything else ends it.
190    pub fn answer(&mut self, now: f64, answer: Result<LinkPoll, PlmError>) -> LinkStep {
191        match answer {
192            Ok(LinkPoll::Approved(token)) => LinkStep::Approved(token),
193            Ok(LinkPoll::Denied) => LinkStep::Ended("the sign-in was denied in the browser".into()),
194            Ok(LinkPoll::Gone) => LinkStep::Ended(LINK_EXPIRED.into()),
195            Ok(LinkPoll::Pending) => {
196                self.failures = 0;
197                self.next_at = now + self.interval;
198                self.wait_or_end(now, None)
199            }
200            Err(error) if passing(&error) => {
201                self.failures += 1;
202                let wait = (self.interval * 2f64.powi(self.failures as i32)).min(MAX_BACKOFF);
203                self.next_at = now + wait;
204                self.wait_or_end(now, Some(format!("{error} — trying again")))
205            }
206            Err(error) => LinkStep::Ended(error.to_string()),
207        }
208    }
209
210    fn wait_or_end(&self, now: f64, note: Option<String>) -> LinkStep {
211        if self.expired(now) {
212            LinkStep::Ended(LINK_EXPIRED.into())
213        } else {
214            LinkStep::Wait { note }
215        }
216    }
217}
218
219/// A failure that may pass on its own: no answer, a server error, the rate
220/// limit.
221fn passing(error: &PlmError) -> bool {
222    match error {
223        PlmError::Unreachable(_) | PlmError::Throttled(_) => true,
224        PlmError::Refused { status, .. } => *status >= 500,
225        _ => false,
226    }
227}
228
229impl PlmConnection {
230    pub fn new(status: Status, keep: Keep) -> Self {
231        Self {
232            status,
233            keep,
234            token_input: String::new(),
235            username: String::new(),
236            password: String::new(),
237            pending: None,
238            last: None,
239            reload: false,
240            connectable: false,
241            transport: Rc::new(|url: &str| Rc::new(EhttpTransport::new(url.to_string())) as Rc<dyn PlmTransport>),
242            registering: None,
243            link: None,
244            open_request: None,
245            clock: Rc::new(monotonic_seconds),
246            fresh: Rc::default(),
247            signed_out_here: Rc::default(),
248        }
249    }
250
251    pub fn url(&self) -> &str {
252        match &self.status {
253            Status::Connected { url, .. } | Status::NotConnected { url, .. } => url,
254        }
255    }
256
257    pub fn keep(&self) -> &Keep {
258        &self.keep
259    }
260
261    /// An action is in flight.
262    pub fn busy(&self) -> bool {
263        self.pending.is_some()
264    }
265
266    /// Call once per frame: drive the queue while busy, collect a finished
267    /// action, and run a browser sign-in's polls. Returns true when something
268    /// changed.
269    pub fn poll(&mut self) -> bool {
270        let polling = self.link.as_ref().is_some_and(|l| l.in_flight.is_some());
271        if self.pending.is_none() && self.registering.is_none() && !polling && self.link.is_none() {
272            return false;
273        }
274        #[cfg(not(target_arch = "wasm32"))]
275        if self.pending.is_some() || self.registering.is_some() || polling {
276            super::native::run_pending();
277        }
278        let mut changed = self.poll_link();
279        let Some(slot) = &self.pending else { return changed };
280        let Some(outcome) = slot.borrow_mut().take() else { return changed };
281        changed = true;
282        self.pending = None;
283        if let (Keep::BrowserSession, Outcome::Done(_)) = (&self.keep, &outcome) {
284            self.reload = true;
285        }
286        // A native sign-in or check that passed: the server takes this
287        // machine, so Connect now is offered. Anything that failed withdraws it.
288        self.connectable = matches!(self.keep, Keep::Files(_)) && matches!(outcome, Outcome::Done(_));
289        if let (Status::NotConnected { url, .. }, Outcome::Failed(why)) = (&self.status, &outcome) {
290            self.status = Status::NotConnected { url: url.clone(), reason: Some(why.clone()) };
291        }
292        self.last = Some(outcome);
293        changed
294    }
295
296    /// The browser sign-in's part of a frame: a registration that answered
297    /// opens the browser; a poll that answered moves the schedule; a due poll
298    /// is sent. An approval signs in with the new token exactly as a pasted
299    /// one would (the version check first, then `plm-token`).
300    fn poll_link(&mut self) -> bool {
301        let mut changed = false;
302        if let Some(answer) = self.registering.as_ref().and_then(|slot| slot.borrow_mut().take()) {
303            self.registering = None;
304            changed = true;
305            match answer {
306                Ok(link) => self.open_link(link),
307                Err(error) => self.last = Some(Outcome::Failed(format!("could not start the browser sign-in: {error}"))),
308            }
309        }
310        let now = (self.clock)();
311        let Some(link) = self.link.as_mut() else { return changed };
312        let answered = link.in_flight.as_ref().and_then(|slot| slot.borrow_mut().take());
313        let step = match answered {
314            Some(answer) => {
315                link.in_flight = None;
316                Some(link.poller.answer(now, answer))
317            }
318            None if link.in_flight.is_none() && link.poller.expired(now) => Some(LinkStep::Ended(LINK_EXPIRED.into())),
319            None => None,
320        };
321        match step {
322            Some(LinkStep::Wait { note }) => {
323                link.note = note;
324                changed = true;
325            }
326            Some(LinkStep::Approved(token)) => {
327                self.link = None;
328                self.token_input = token;
329                self.use_token();
330                return true;
331            }
332            Some(LinkStep::Ended(why)) => {
333                self.link = None;
334                self.connectable = false;
335                self.last = Some(Outcome::Failed(why));
336                return true;
337            }
338            None => {}
339        }
340        if link.in_flight.is_none() && link.poller.due(now) {
341            let slot: Slot<Result<LinkPoll, PlmError>> = Rc::default();
342            link.in_flight = Some(slot.clone());
343            let secret = link.secret.clone();
344            let client = self.client();
345            spawn(async move {
346                let answer = client.poll_desktop_link(&secret).await;
347                *slot.borrow_mut() = Some(answer);
348            });
349            changed = true;
350        }
351        changed
352    }
353
354    fn open_link(&mut self, link: DesktopLink) {
355        let url = format!("{}{}", self.url().trim_end_matches('/'), link.path);
356        self.open_request = Some(url.clone());
357        let poller = LinkPoller::new((self.clock)(), link.expires_in, link.interval);
358        self.link = Some(BrowserLink { code: link.code, url, note: None, secret: link.poll_secret, poller, in_flight: None });
359    }
360
361    /// Forget ended this machine's sign-in on the server since the last call.
362    pub fn take_signed_out_here(&mut self) -> bool {
363        self.signed_out_here.borrow_mut().take().is_some()
364    }
365
366    /// A native sign-in that passed since the last call: `(username, token)`.
367    pub fn take_fresh_token(&mut self) -> Option<(String, String)> {
368        self.fresh.borrow_mut().take()
369    }
370
371    /// Take the link page a browser sign-in wants opened (once).
372    pub fn take_open_request(&mut self) -> Option<String> {
373        self.open_request.take()
374    }
375
376    /// Seconds until the browser sign-in wants a frame (its next poll), or
377    /// `None` when none is running.
378    pub fn link_wait(&self) -> Option<f64> {
379        let link = self.link.as_ref()?;
380        Some(if link.in_flight.is_some() { 0.25 } else { link.poller.wait((self.clock)()) })
381    }
382
383    /// Start a browser sign-in (native): register with the server, ask for
384    /// the link page to be opened in the default browser
385    /// ([`Self::take_open_request`]), and wait for the person to approve
386    /// it there. The server mints this machine's token on approval.
387    pub fn sign_in_with_browser(&mut self) {
388        let client = self.client();
389        let slot: Slot<Result<DesktopLink, PlmError>> = Rc::default();
390        self.registering = Some(slot.clone());
391        self.link = None;
392        self.last = None;
393        let (host, os) = machine();
394        spawn(async move {
395            let answer = client.register_desktop_link(&host, &os).await;
396            *slot.borrow_mut() = Some(answer);
397        });
398    }
399
400    /// Stop waiting for the browser. The link expires on the server by
401    /// itself; an approval after this is never collected.
402    pub fn cancel_link(&mut self) {
403        self.registering = None;
404        self.link = None;
405        self.open_request = None;
406    }
407
408    /// A browser sign-in is starting or waiting.
409    pub fn linking(&self) -> bool {
410        self.registering.is_some() || self.link.is_some()
411    }
412
413    /// Connect now is offered: the server took this machine's sign-in.
414    pub fn connectable(&self) -> bool {
415        self.connectable
416    }
417
418    /// The app's live switch answered: success is the store's own news (the
419    /// next status says Connected); a refusal is shown as this tab's outcome.
420    pub fn reconnected(&mut self, outcome: Result<String, String>) {
421        self.connectable = outcome.is_err() && self.connectable;
422        self.last = Some(match outcome {
423            Ok(text) => Outcome::Done(text),
424            Err(why) => Outcome::Failed(why),
425        });
426    }
427
428    /// The store's status moved (a live switch landed): take it.
429    pub fn set_status(&mut self, status: Status) {
430        if std::mem::discriminant(&status) != std::mem::discriminant(&self.status) {
431            self.status = status;
432            self.connectable = false;
433        }
434    }
435
436    /// Take the "reload the page" request a web action left.
437    pub fn take_reload(&mut self) -> bool {
438        std::mem::take(&mut self.reload)
439    }
440
441    fn client(&self) -> Rc<PlmClient> {
442        Rc::new(PlmClient::new((self.transport)(self.url())))
443    }
444
445    fn start(&mut self, work: impl std::future::Future<Output = Outcome> + 'static) {
446        let slot: Rc<RefCell<Option<Outcome>>> = Rc::default();
447        let into = slot.clone();
448        self.pending = Some(slot);
449        self.last = None;
450        spawn(async move {
451            let outcome = work.await;
452            *into.borrow_mut() = Some(outcome);
453        });
454    }
455
456    /// Sign in with the pasted token: the server must accept it and serve
457    /// this app before it is kept.
458    pub fn use_token(&mut self) {
459        self.cancel_link();
460        let token = self.token_input.trim().to_string();
461        let (client, keep, fresh) = (self.client(), self.keep.clone(), self.fresh.clone());
462        self.token_input.clear();
463        self.start(async move {
464            match client.sign_in_with_token(&token).await {
465                Ok(me) => keep_token(&keep, &token, &me, &fresh),
466                Err(e) => Outcome::Failed(e.to_string()),
467            }
468        });
469    }
470
471    /// Sign in with a username and password: a token for this machine
472    /// (native), or the PLM's session (web). The password is not kept.
473    pub fn use_password(&mut self) {
474        self.cancel_link();
475        let (username, password) = (self.username.trim().to_string(), std::mem::take(&mut self.password));
476        let (client, keep, fresh) = (self.client(), self.keep.clone(), self.fresh.clone());
477        self.start(async move {
478            match keep {
479                Keep::Files(_) => match client.sign_in_for_token(&username, &password, &token_name()).await {
480                    Ok((me, token)) => keep_token(&keep, &token, &me, &fresh),
481                    Err(e) => Outcome::Failed(e.to_string()),
482                },
483                Keep::BrowserSession => match client.sign_in_with_password(&username, &password).await {
484                    Ok(me) => Outcome::Done(format!("signed in as {}; reloading onto the PLM", me.username)),
485                    Err(e) => Outcome::Failed(e.to_string()),
486                },
487            }
488        });
489    }
490
491    /// Ask the server again with what this machine holds: the answer (or the
492    /// refusal, naming both versions) replaces the tab's reason.
493    pub fn check(&mut self) {
494        let (client, keep) = (self.client(), self.keep.clone());
495        self.start(async move {
496            let signed_in = match &keep {
497                Keep::Files(dir) => match held_token(dir) {
498                    Ok(Some(token)) => client.sign_in_with_token(&token).await,
499                    Ok(None) => return Outcome::Failed("no token on this machine: paste one, or sign in with a password".into()),
500                    Err(e) => return Outcome::Failed(e),
501                },
502                Keep::BrowserSession => client.sign_in_with_browser_session().await,
503            };
504            match signed_in {
505                Ok(me) => Outcome::Done(format!("the server accepts this machine as {} and serves this app", me.username)),
506                Err(e) => Outcome::Failed(e.to_string()),
507            }
508        });
509    }
510
511    /// Forget this machine's sign-in: native removes `plm.json` and
512    /// `plm-token` (the next start is the file app); web signs the session
513    /// out and reloads.
514    pub fn forget(&mut self) {
515        match self.keep.clone() {
516            Keep::Files(dir) => {
517                self.connectable = false;
518                self.cancel_link();
519                // A Linked desktop's token is revoked on the server first, so
520                // the sign-in ends there too (it leaves the account page's
521                // list). The files go whatever the server says: signing out
522                // must work offline.
523                let held = held_token(&dir).ok().flatten();
524                let client = self.client();
525                let signed_out_here = self.signed_out_here.clone();
526                self.start(async move {
527                    // `Ok(true)`: the server revoked it (or it was gone);
528                    // `Ok(false)`: it is a token a person made or a password
529                    // sign-in minted, which the server keeps.
530                    let told: Result<bool, String> = match held {
531                        Some(token) => {
532                            client.replace_token(&token);
533                            match client.raw("POST", "/api/desktop/sign-out", Some(b"{}".to_vec())).await {
534                                Ok(answer) if answer.status == 200 => Ok(serde_json::from_slice::<serde_json::Value>(&answer.body)
535                                    .is_ok_and(|v| v["revoked"] == true)),
536                                Ok(answer) if answer.status == 401 => Ok(true),
537                                Ok(answer) => Err(format!("the server answered {}", answer.status)),
538                                Err(e) => Err(e.to_string()),
539                            }
540                        }
541                        None => Ok(false),
542                    };
543                    if told == Ok(true) {
544                        *signed_out_here.borrow_mut() = Some(());
545                    }
546                    match (forget_files(&dir), told) {
547                        (Err(e), _) => Outcome::Failed(e),
548                        (Ok(()), Ok(true)) => Outcome::Done("signed out: this machine's PLM sign-in is forgotten and ended on the server; the next start is the file app".into()),
549                        (Ok(()), Ok(false)) => Outcome::Done("this machine's PLM sign-in is forgotten; the next start is the file app".into()),
550                        (Ok(()), Err(why)) => Outcome::Done(format!(
551                            "this machine's PLM sign-in is forgotten, but the server could not be told ({why}): revoke it under Linked desktops on your PLM account page"
552                        )),
553                    }
554                });
555            }
556            Keep::BrowserSession => {
557                let client = self.client();
558                self.start(async move {
559                    match client.raw("POST", "/api/logout", Some(b"{}".to_vec())).await {
560                        Ok(_) => Outcome::Done("signed out; reloading".into()),
561                        Err(e) => Outcome::Failed(e.to_string()),
562                    }
563                });
564            }
565        }
566    }
567}
568
569/// The token's name on the server's token list: which app, on which machine.
570fn token_name() -> String {
571    #[cfg(not(target_arch = "wasm32"))]
572    {
573        format!("brep-app {}", machine().0)
574    }
575    #[cfg(target_arch = "wasm32")]
576    {
577        "brep-app web".into()
578    }
579}
580
581/// This machine's name and OS, as a browser sign-in shows them to the person
582/// approving it (and as the server names the token it mints).
583fn machine() -> (String, String) {
584    let named = |text: String| Some(text.trim().to_string()).filter(|t| !t.is_empty());
585    let host = std::env::var("HOSTNAME")
586        .ok()
587        .and_then(named)
588        .or_else(|| std::env::var("COMPUTERNAME").ok().and_then(named));
589    #[cfg(not(target_arch = "wasm32"))]
590    let host = host.or_else(|| std::fs::read_to_string("/etc/hostname").ok().and_then(named));
591    (host.unwrap_or_else(|| "desktop".into()), os_name(std::env::consts::OS).to_string())
592}
593
594/// The system as people name it, for the account page's Linked desktops.
595fn os_name(os: &str) -> &str {
596    match os {
597        "windows" => "Windows",
598        "linux" => "Linux",
599        "macos" => "macOS",
600        other => other,
601    }
602}
603
604/// Seconds since the first call: monotonic, on native and in a browser.
605fn monotonic_seconds() -> f64 {
606    thread_local! {
607        static START: web_time::Instant = web_time::Instant::now();
608    }
609    START.with(|start| start.elapsed().as_secs_f64())
610}
611
612fn keep_token(keep: &Keep, token: &str, me: &Me, fresh: &Slot<(String, String)>) -> Outcome {
613    match keep {
614        Keep::Files(dir) => match write_token(dir, token) {
615            Ok(()) => Outcome::Done({
616                *fresh.borrow_mut() = Some((me.username.clone(), token.to_string()));
617                format!(
618                "signed in as {}; the token is kept on this machine. Connect now switches this session to the PLM; otherwise the next start does",
619                me.username
620            )
621            }),
622            Err(e) => Outcome::Failed(format!("signed in, but the token could not be kept: {e}")),
623        },
624        Keep::BrowserSession => Outcome::Done(format!("signed in as {}; reloading onto the PLM", me.username)),
625    }
626}
627
628#[cfg(not(target_arch = "wasm32"))]
629fn write_token(dir: &std::path::Path, token: &str) -> Result<(), String> {
630    super::config::write_token(dir, token)
631}
632
633#[cfg(target_arch = "wasm32")]
634fn write_token(_: &std::path::Path, _: &str) -> Result<(), String> {
635    Err("a browser keeps no token file".into())
636}
637
638#[cfg(not(target_arch = "wasm32"))]
639fn held_token(dir: &std::path::Path) -> Result<Option<String>, String> {
640    Ok(super::config::resolve(dir, &|_| None, &Default::default())?.and_then(|c| c.token))
641}
642
643#[cfg(target_arch = "wasm32")]
644fn held_token(_: &std::path::Path) -> Result<Option<String>, String> {
645    Ok(None)
646}
647
648#[cfg(not(target_arch = "wasm32"))]
649fn forget_files(dir: &std::path::Path) -> Result<(), String> {
650    super::config::forget(dir).map(|_| ())
651}
652
653#[cfg(target_arch = "wasm32")]
654fn forget_files(_: &std::path::Path) -> Result<(), String> {
655    Err("a browser keeps no PLM files".into())
656}
657
658fn spawn(task: impl std::future::Future<Output = ()> + 'static) {
659    crate::store::spawn_waking(task);
660}
661