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 says the next start connects: the store is chosen before the
13//!   first frame, and settings, the dock and recovery all read it there.
14//!   Forget removes both files; the next start is the file app.
15//! - **Web.** The page is served by the PLM (D6), so a sign-in is the PLM's own
16//!   session cookie, and the page reloads onto it. Forget signs the session
17//!   out and reloads.
18use super::client::{Me, PlmClient};
19use super::transport::EhttpTransport;
20use super::PlmTransport;
21use std::cell::RefCell;
22use std::rc::Rc;
23
24/// What the tab knows about the PLM, before any action.
25#[derive(Clone, Debug, PartialEq)]
26pub enum Status {
27    /// The session's store is the PLM.
28    Connected { url: String, username: String },
29    /// A PLM is configured (native) or serving this page (web), but this
30    /// session is not on it. `reason` is why, when known: the boot's refusal,
31    /// or a check's.
32    NotConnected { url: String, reason: Option<String> },
33}
34
35/// The outcome of the last action, as the tab shows it.
36#[derive(Clone, Debug, PartialEq)]
37pub enum Outcome {
38    /// Done; the sentence says what happens next.
39    Done(String),
40    /// Refused or failed, in the server's (or the machine's) words.
41    Failed(String),
42}
43
44/// A session's PLM, as its store reports it ([`crate::store::ModelStore::plm_session`]):
45/// where it is, whether this session is on it, and where a sign-in is kept.
46#[derive(Clone, Debug, PartialEq)]
47pub struct Session {
48    pub status: Status,
49    pub keep: Keep,
50}
51
52/// The URL and user in the label [`super::backend::compose`] gives a PLM
53/// store (`PLM <url> as <user>`), or `None` for any other store.
54pub fn parse_label(label: &str) -> Option<(String, String)> {
55    let rest = label.strip_prefix("PLM ")?;
56    let (url, user) = rest.rsplit_once(" as ")?;
57    Some((url.to_string(), user.to_string()))
58}
59
60/// Where the machine's sign-in lives: the config directory on native, the
61/// PLM's own session cookie on the web.
62#[derive(Clone, Debug, PartialEq)]
63pub enum Keep {
64    Files(std::path::PathBuf),
65    BrowserSession,
66}
67
68pub struct PlmConnection {
69    pub status: Status,
70    keep: Keep,
71    pub token_input: String,
72    pub username: String,
73    pub password: String,
74    pending: Option<Rc<RefCell<Option<Outcome>>>>,
75    pub last: Option<Outcome>,
76    /// A web action finished that the page must reload onto.
77    reload: bool,
78    /// The server took this machine's sign-in (a sign-in or a check passed):
79    /// the session may switch to it live (native Connect now).
80    connectable: bool,
81    /// Builds the transport for the server's URL: ehttp in the app, the
82    /// in-process router in tests.
83    transport: Rc<dyn Fn(&str) -> Rc<dyn PlmTransport>>,
84}
85
86impl PlmConnection {
87    pub fn new(status: Status, keep: Keep) -> Self {
88        Self {
89            status,
90            keep,
91            token_input: String::new(),
92            username: String::new(),
93            password: String::new(),
94            pending: None,
95            last: None,
96            reload: false,
97            connectable: false,
98            transport: Rc::new(|url: &str| Rc::new(EhttpTransport::new(url.to_string())) as Rc<dyn PlmTransport>),
99        }
100    }
101
102    pub fn url(&self) -> &str {
103        match &self.status {
104            Status::Connected { url, .. } | Status::NotConnected { url, .. } => url,
105        }
106    }
107
108    pub fn keep(&self) -> &Keep {
109        &self.keep
110    }
111
112    /// An action is in flight.
113    pub fn busy(&self) -> bool {
114        self.pending.is_some()
115    }
116
117    /// Call once per frame: drive the queue while busy and collect a finished
118    /// action. Returns true when something changed.
119    pub fn poll(&mut self) -> bool {
120        let Some(slot) = &self.pending else { return false };
121        #[cfg(not(target_arch = "wasm32"))]
122        super::native::run_pending();
123        let Some(outcome) = slot.borrow_mut().take() else { return false };
124        self.pending = None;
125        if let (Keep::BrowserSession, Outcome::Done(_)) = (&self.keep, &outcome) {
126            self.reload = true;
127        }
128        // A native sign-in or check that passed: the server takes this
129        // machine, so Connect now is offered. Anything that failed withdraws it.
130        self.connectable = matches!(self.keep, Keep::Files(_)) && matches!(outcome, Outcome::Done(_));
131        if let (Status::NotConnected { url, .. }, Outcome::Failed(why)) = (&self.status, &outcome) {
132            self.status = Status::NotConnected { url: url.clone(), reason: Some(why.clone()) };
133        }
134        self.last = Some(outcome);
135        true
136    }
137
138    /// Connect now is offered: the server took this machine's sign-in.
139    pub fn connectable(&self) -> bool {
140        self.connectable
141    }
142
143    /// The app's live switch answered: success is the store's own news (the
144    /// next status says Connected); a refusal is shown as this tab's outcome.
145    pub fn reconnected(&mut self, outcome: Result<String, String>) {
146        self.connectable = outcome.is_err() && self.connectable;
147        self.last = Some(match outcome {
148            Ok(text) => Outcome::Done(text),
149            Err(why) => Outcome::Failed(why),
150        });
151    }
152
153    /// The store's status moved (a live switch landed): take it.
154    pub fn set_status(&mut self, status: Status) {
155        if std::mem::discriminant(&status) != std::mem::discriminant(&self.status) {
156            self.status = status;
157            self.connectable = false;
158        }
159    }
160
161    /// Take the "reload the page" request a web action left.
162    pub fn take_reload(&mut self) -> bool {
163        std::mem::take(&mut self.reload)
164    }
165
166    fn client(&self) -> Rc<PlmClient> {
167        Rc::new(PlmClient::new((self.transport)(self.url())))
168    }
169
170    fn start(&mut self, work: impl std::future::Future<Output = Outcome> + 'static) {
171        let slot: Rc<RefCell<Option<Outcome>>> = Rc::default();
172        let into = slot.clone();
173        self.pending = Some(slot);
174        self.last = None;
175        spawn(async move {
176            let outcome = work.await;
177            *into.borrow_mut() = Some(outcome);
178        });
179    }
180
181    /// Sign in with the pasted token: the server must accept it and serve
182    /// this app before it is kept.
183    pub fn use_token(&mut self) {
184        let token = self.token_input.trim().to_string();
185        let (client, keep) = (self.client(), self.keep.clone());
186        self.token_input.clear();
187        self.start(async move {
188            match client.sign_in_with_token(&token).await {
189                Ok(me) => keep_token(&keep, &token, &me),
190                Err(e) => Outcome::Failed(e.to_string()),
191            }
192        });
193    }
194
195    /// Sign in with a username and password: a token for this machine
196    /// (native), or the PLM's session (web). The password is not kept.
197    pub fn use_password(&mut self) {
198        let (username, password) = (self.username.trim().to_string(), std::mem::take(&mut self.password));
199        let (client, keep) = (self.client(), self.keep.clone());
200        self.start(async move {
201            match keep {
202                Keep::Files(_) => match client.sign_in_for_token(&username, &password, &token_name()).await {
203                    Ok((me, token)) => keep_token(&keep, &token, &me),
204                    Err(e) => Outcome::Failed(e.to_string()),
205                },
206                Keep::BrowserSession => match client.sign_in_with_password(&username, &password).await {
207                    Ok(me) => Outcome::Done(format!("signed in as {}; reloading onto the PLM", me.username)),
208                    Err(e) => Outcome::Failed(e.to_string()),
209                },
210            }
211        });
212    }
213
214    /// Ask the server again with what this machine holds: the answer (or the
215    /// refusal, naming both versions) replaces the tab's reason.
216    pub fn check(&mut self) {
217        let (client, keep) = (self.client(), self.keep.clone());
218        self.start(async move {
219            let signed_in = match &keep {
220                Keep::Files(dir) => match held_token(dir) {
221                    Ok(Some(token)) => client.sign_in_with_token(&token).await,
222                    Ok(None) => return Outcome::Failed("no token on this machine: paste one, or sign in with a password".into()),
223                    Err(e) => return Outcome::Failed(e),
224                },
225                Keep::BrowserSession => client.sign_in_with_browser_session().await,
226            };
227            match signed_in {
228                Ok(me) => Outcome::Done(format!("the server accepts this machine as {} and serves this app", me.username)),
229                Err(e) => Outcome::Failed(e.to_string()),
230            }
231        });
232    }
233
234    /// Forget this machine's sign-in: native removes `plm.json` and
235    /// `plm-token` (the next start is the file app); web signs the session
236    /// out and reloads.
237    pub fn forget(&mut self) {
238        match self.keep.clone() {
239            Keep::Files(dir) => {
240                self.connectable = false;
241                self.last = Some(match forget_files(&dir) {
242                    Ok(()) => Outcome::Done("this machine's PLM sign-in is forgotten; the next start is the file app".into()),
243                    Err(e) => Outcome::Failed(e),
244                });
245            }
246            Keep::BrowserSession => {
247                let client = self.client();
248                self.start(async move {
249                    match client.raw("POST", "/api/logout", Some(b"{}".to_vec())).await {
250                        Ok(_) => Outcome::Done("signed out; reloading".into()),
251                        Err(e) => Outcome::Failed(e.to_string()),
252                    }
253                });
254            }
255        }
256    }
257}
258
259/// The token's name on the server's token list: which app, on which machine.
260fn token_name() -> String {
261    #[cfg(not(target_arch = "wasm32"))]
262    {
263        let host = std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty());
264        format!("brep-app {}", host.unwrap_or_else(|| "desktop".into()))
265    }
266    #[cfg(target_arch = "wasm32")]
267    {
268        "brep-app web".into()
269    }
270}
271
272fn keep_token(keep: &Keep, token: &str, me: &Me) -> Outcome {
273    match keep {
274        Keep::Files(dir) => match write_token(dir, token) {
275            Ok(()) => Outcome::Done(format!(
276                "signed in as {}; the token is kept on this machine, and the next start connects",
277                me.username
278            )),
279            Err(e) => Outcome::Failed(format!("signed in, but the token could not be kept: {e}")),
280        },
281        Keep::BrowserSession => Outcome::Done(format!("signed in as {}; reloading onto the PLM", me.username)),
282    }
283}
284
285#[cfg(not(target_arch = "wasm32"))]
286fn write_token(dir: &std::path::Path, token: &str) -> Result<(), String> {
287    super::config::write_token(dir, token)
288}
289
290#[cfg(target_arch = "wasm32")]
291fn write_token(_: &std::path::Path, _: &str) -> Result<(), String> {
292    Err("a browser keeps no token file".into())
293}
294
295#[cfg(not(target_arch = "wasm32"))]
296fn held_token(dir: &std::path::Path) -> Result<Option<String>, String> {
297    Ok(super::config::resolve(dir, &|_| None, &Default::default())?.and_then(|c| c.token))
298}
299
300#[cfg(target_arch = "wasm32")]
301fn held_token(_: &std::path::Path) -> Result<Option<String>, String> {
302    Ok(None)
303}
304
305#[cfg(not(target_arch = "wasm32"))]
306fn forget_files(dir: &std::path::Path) -> Result<(), String> {
307    super::config::forget(dir).map(|_| ())
308}
309
310#[cfg(target_arch = "wasm32")]
311fn forget_files(_: &std::path::Path) -> Result<(), String> {
312    Err("a browser keeps no PLM files".into())
313}
314
315fn spawn(task: impl std::future::Future<Output = ()> + 'static) {
316    #[cfg(target_arch = "wasm32")]
317    wasm_bindgen_futures::spawn_local(task);
318    #[cfg(not(target_arch = "wasm32"))]
319    super::native::spawn(task);
320}
321