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