1use super::client::{DesktopLink, LinkPoll, Me, PlmClient, PlmError};
21use super::transport::EhttpTransport;
22use super::PlmTransport;
23use std::cell::RefCell;
24use std::rc::Rc;
25
26#[derive(Clone, Debug, PartialEq)]
28pub enum Status {
29 Connected { url: String, username: String },
31 NotConnected { url: String, reason: Option<String> },
35}
36
37#[derive(Clone, Debug, PartialEq)]
39pub enum Outcome {
40 Done(String),
42 Failed(String),
44}
45
46#[derive(Clone, Debug, PartialEq)]
49pub struct Session {
50 pub status: Status,
51 pub keep: Keep,
52}
53
54pub 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#[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 reload: bool,
80 connectable: bool,
83 transport: Rc<dyn Fn(&str) -> Rc<dyn PlmTransport>>,
86 registering: Option<Slot<Result<DesktopLink, PlmError>>>,
88 pub link: Option<BrowserLink>,
90 open_request: Option<String>,
94 clock: Rc<dyn Fn() -> f64>,
96 fresh: Slot<(String, String)>,
99 signed_out_here: Slot<()>,
102}
103
104type Slot<T> = Rc<RefCell<Option<T>>>;
105
106const LINK_EXPIRED: &str = "the browser sign-in expired before it was approved — start it again";
108
109const MAX_BACKOFF: f64 = 30.0;
111
112pub struct BrowserLink {
115 pub code: String,
116 pub url: String,
117 pub note: Option<String>,
119 secret: String,
120 poller: LinkPoller,
121 in_flight: Option<Slot<Result<LinkPoll, PlmError>>>,
122}
123
124#[derive(Clone, Debug, PartialEq)]
127pub struct LinkPoller {
128 expires_at: f64,
129 interval: f64,
130 next_at: f64,
131 failures: u32,
132}
133
134#[derive(Clone, Debug, PartialEq)]
136pub enum LinkStep {
137 Wait { note: Option<String> },
139 Approved(String),
141 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 pub fn due(&self, now: f64) -> bool {
157 !self.expired(now) && now >= self.next_at
158 }
159
160 pub fn wait(&self, now: f64) -> f64 {
162 (self.next_at.min(self.expires_at) - now).max(0.0)
163 }
164
165 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
198fn 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 pub fn busy(&self) -> bool {
242 self.pending.is_some()
243 }
244
245 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 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 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 pub fn take_signed_out_here(&mut self) -> bool {
342 self.signed_out_here.borrow_mut().take().is_some()
343 }
344
345 pub fn take_fresh_token(&mut self) -> Option<(String, String)> {
347 self.fresh.borrow_mut().take()
348 }
349
350 pub fn take_open_request(&mut self) -> Option<String> {
352 self.open_request.take()
353 }
354
355 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 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 pub fn cancel_link(&mut self) {
382 self.registering = None;
383 self.link = None;
384 self.open_request = None;
385 }
386
387 pub fn linking(&self) -> bool {
389 self.registering.is_some() || self.link.is_some()
390 }
391
392 pub fn connectable(&self) -> bool {
394 self.connectable
395 }
396
397 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 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 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 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 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 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 pub fn forget(&mut self) {
494 match self.keep.clone() {
495 Keep::Files(dir) => {
496 self.connectable = false;
497 self.cancel_link();
498 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 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
548fn 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
560fn 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
573fn os_name(os: &str) -> &str {
575 match os {
576 "windows" => "Windows",
577 "linux" => "Linux",
578 "macos" => "macOS",
579 other => other,
580 }
581}
582
583fn 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