use super::client::{DesktopLink, LinkPoll, Me, PlmClient, PlmError};
use super::transport::EhttpTransport;
use super::PlmTransport;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Clone, Debug, PartialEq)]
pub enum Status {
Connected { url: String, username: String },
NotConnected { url: String, reason: Option<String> },
}
#[derive(Clone, Debug, PartialEq)]
pub enum Outcome {
Done(String),
Failed(String),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Session {
pub status: Status,
pub keep: Keep,
}
pub fn parse_label(label: &str) -> Option<(String, String)> {
let rest = label.strip_prefix("PLM ")?;
let (url, user) = rest.rsplit_once(" as ")?;
Some((url.to_string(), user.to_string()))
}
#[derive(Clone, Debug, PartialEq)]
pub enum Keep {
Files(std::path::PathBuf),
BrowserSession,
}
pub struct PlmConnection {
pub status: Status,
keep: Keep,
pub token_input: String,
pub username: String,
pub password: String,
pending: Option<Rc<RefCell<Option<Outcome>>>>,
pub last: Option<Outcome>,
reload: bool,
connectable: bool,
transport: Rc<dyn Fn(&str) -> Rc<dyn PlmTransport>>,
registering: Option<Slot<Result<DesktopLink, PlmError>>>,
pub link: Option<BrowserLink>,
open_request: Option<String>,
clock: Rc<dyn Fn() -> f64>,
fresh: Slot<(String, String)>,
signed_out_here: Slot<()>,
}
type Slot<T> = Rc<RefCell<Option<T>>>;
const LINK_EXPIRED: &str = "the browser sign-in expired before it was approved — start it again";
const MAX_BACKOFF: f64 = 30.0;
pub struct BrowserLink {
pub code: String,
pub url: String,
pub note: Option<String>,
secret: String,
poller: LinkPoller,
in_flight: Option<Slot<Result<LinkPoll, PlmError>>>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LinkPoller {
expires_at: f64,
interval: f64,
next_at: f64,
failures: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub enum LinkStep {
Wait { note: Option<String> },
Approved(String),
Ended(String),
}
impl LinkPoller {
pub fn new(now: f64, expires_in: u64, interval: u64) -> Self {
let interval = interval.max(1) as f64;
Self { expires_at: now + expires_in as f64, interval, next_at: now + interval, failures: 0 }
}
pub fn expired(&self, now: f64) -> bool {
now >= self.expires_at
}
pub fn due(&self, now: f64) -> bool {
!self.expired(now) && now >= self.next_at
}
pub fn wait(&self, now: f64) -> f64 {
(self.next_at.min(self.expires_at) - now).max(0.0)
}
pub fn answer(&mut self, now: f64, answer: Result<LinkPoll, PlmError>) -> LinkStep {
match answer {
Ok(LinkPoll::Approved(token)) => LinkStep::Approved(token),
Ok(LinkPoll::Denied) => LinkStep::Ended("the sign-in was denied in the browser".into()),
Ok(LinkPoll::Gone) => LinkStep::Ended(LINK_EXPIRED.into()),
Ok(LinkPoll::Pending) => {
self.failures = 0;
self.next_at = now + self.interval;
self.wait_or_end(now, None)
}
Err(error) if passing(&error) => {
self.failures += 1;
let wait = (self.interval * 2f64.powi(self.failures as i32)).min(MAX_BACKOFF);
self.next_at = now + wait;
self.wait_or_end(now, Some(format!("{error} — trying again")))
}
Err(error) => LinkStep::Ended(error.to_string()),
}
}
fn wait_or_end(&self, now: f64, note: Option<String>) -> LinkStep {
if self.expired(now) {
LinkStep::Ended(LINK_EXPIRED.into())
} else {
LinkStep::Wait { note }
}
}
}
fn passing(error: &PlmError) -> bool {
match error {
PlmError::Unreachable(_) | PlmError::Throttled(_) => true,
PlmError::Refused { status, .. } => *status >= 500,
_ => false,
}
}
impl PlmConnection {
pub fn new(status: Status, keep: Keep) -> Self {
Self {
status,
keep,
token_input: String::new(),
username: String::new(),
password: String::new(),
pending: None,
last: None,
reload: false,
connectable: false,
transport: Rc::new(|url: &str| Rc::new(EhttpTransport::new(url.to_string())) as Rc<dyn PlmTransport>),
registering: None,
link: None,
open_request: None,
clock: Rc::new(monotonic_seconds),
fresh: Rc::default(),
signed_out_here: Rc::default(),
}
}
pub fn url(&self) -> &str {
match &self.status {
Status::Connected { url, .. } | Status::NotConnected { url, .. } => url,
}
}
pub fn keep(&self) -> &Keep {
&self.keep
}
pub fn busy(&self) -> bool {
self.pending.is_some()
}
pub fn poll(&mut self) -> bool {
let polling = self.link.as_ref().is_some_and(|l| l.in_flight.is_some());
if self.pending.is_none() && self.registering.is_none() && !polling && self.link.is_none() {
return false;
}
#[cfg(not(target_arch = "wasm32"))]
if self.pending.is_some() || self.registering.is_some() || polling {
super::native::run_pending();
}
let mut changed = self.poll_link();
let Some(slot) = &self.pending else { return changed };
let Some(outcome) = slot.borrow_mut().take() else { return changed };
changed = true;
self.pending = None;
if let (Keep::BrowserSession, Outcome::Done(_)) = (&self.keep, &outcome) {
self.reload = true;
}
self.connectable = matches!(self.keep, Keep::Files(_)) && matches!(outcome, Outcome::Done(_));
if let (Status::NotConnected { url, .. }, Outcome::Failed(why)) = (&self.status, &outcome) {
self.status = Status::NotConnected { url: url.clone(), reason: Some(why.clone()) };
}
self.last = Some(outcome);
changed
}
fn poll_link(&mut self) -> bool {
let mut changed = false;
if let Some(answer) = self.registering.as_ref().and_then(|slot| slot.borrow_mut().take()) {
self.registering = None;
changed = true;
match answer {
Ok(link) => self.open_link(link),
Err(error) => self.last = Some(Outcome::Failed(format!("could not start the browser sign-in: {error}"))),
}
}
let now = (self.clock)();
let Some(link) = self.link.as_mut() else { return changed };
let answered = link.in_flight.as_ref().and_then(|slot| slot.borrow_mut().take());
let step = match answered {
Some(answer) => {
link.in_flight = None;
Some(link.poller.answer(now, answer))
}
None if link.in_flight.is_none() && link.poller.expired(now) => Some(LinkStep::Ended(LINK_EXPIRED.into())),
None => None,
};
match step {
Some(LinkStep::Wait { note }) => {
link.note = note;
changed = true;
}
Some(LinkStep::Approved(token)) => {
self.link = None;
self.token_input = token;
self.use_token();
return true;
}
Some(LinkStep::Ended(why)) => {
self.link = None;
self.connectable = false;
self.last = Some(Outcome::Failed(why));
return true;
}
None => {}
}
if link.in_flight.is_none() && link.poller.due(now) {
let slot: Slot<Result<LinkPoll, PlmError>> = Rc::default();
link.in_flight = Some(slot.clone());
let secret = link.secret.clone();
let client = self.client();
spawn(async move {
let answer = client.poll_desktop_link(&secret).await;
*slot.borrow_mut() = Some(answer);
});
changed = true;
}
changed
}
fn open_link(&mut self, link: DesktopLink) {
let url = format!("{}{}", self.url().trim_end_matches('/'), link.path);
self.open_request = Some(url.clone());
let poller = LinkPoller::new((self.clock)(), link.expires_in, link.interval);
self.link = Some(BrowserLink { code: link.code, url, note: None, secret: link.poll_secret, poller, in_flight: None });
}
pub fn take_signed_out_here(&mut self) -> bool {
self.signed_out_here.borrow_mut().take().is_some()
}
pub fn take_fresh_token(&mut self) -> Option<(String, String)> {
self.fresh.borrow_mut().take()
}
pub fn take_open_request(&mut self) -> Option<String> {
self.open_request.take()
}
pub fn link_wait(&self) -> Option<f64> {
let link = self.link.as_ref()?;
Some(if link.in_flight.is_some() { 0.25 } else { link.poller.wait((self.clock)()) })
}
pub fn sign_in_with_browser(&mut self) {
let client = self.client();
let slot: Slot<Result<DesktopLink, PlmError>> = Rc::default();
self.registering = Some(slot.clone());
self.link = None;
self.last = None;
let (host, os) = machine();
spawn(async move {
let answer = client.register_desktop_link(&host, &os).await;
*slot.borrow_mut() = Some(answer);
});
}
pub fn cancel_link(&mut self) {
self.registering = None;
self.link = None;
self.open_request = None;
}
pub fn linking(&self) -> bool {
self.registering.is_some() || self.link.is_some()
}
pub fn connectable(&self) -> bool {
self.connectable
}
pub fn reconnected(&mut self, outcome: Result<String, String>) {
self.connectable = outcome.is_err() && self.connectable;
self.last = Some(match outcome {
Ok(text) => Outcome::Done(text),
Err(why) => Outcome::Failed(why),
});
}
pub fn set_status(&mut self, status: Status) {
if std::mem::discriminant(&status) != std::mem::discriminant(&self.status) {
self.status = status;
self.connectable = false;
}
}
pub fn take_reload(&mut self) -> bool {
std::mem::take(&mut self.reload)
}
fn client(&self) -> Rc<PlmClient> {
Rc::new(PlmClient::new((self.transport)(self.url())))
}
fn start(&mut self, work: impl std::future::Future<Output = Outcome> + 'static) {
let slot: Rc<RefCell<Option<Outcome>>> = Rc::default();
let into = slot.clone();
self.pending = Some(slot);
self.last = None;
spawn(async move {
let outcome = work.await;
*into.borrow_mut() = Some(outcome);
});
}
pub fn use_token(&mut self) {
self.cancel_link();
let token = self.token_input.trim().to_string();
let (client, keep, fresh) = (self.client(), self.keep.clone(), self.fresh.clone());
self.token_input.clear();
self.start(async move {
match client.sign_in_with_token(&token).await {
Ok(me) => keep_token(&keep, &token, &me, &fresh),
Err(e) => Outcome::Failed(e.to_string()),
}
});
}
pub fn use_password(&mut self) {
self.cancel_link();
let (username, password) = (self.username.trim().to_string(), std::mem::take(&mut self.password));
let (client, keep, fresh) = (self.client(), self.keep.clone(), self.fresh.clone());
self.start(async move {
match keep {
Keep::Files(_) => match client.sign_in_for_token(&username, &password, &token_name()).await {
Ok((me, token)) => keep_token(&keep, &token, &me, &fresh),
Err(e) => Outcome::Failed(e.to_string()),
},
Keep::BrowserSession => match client.sign_in_with_password(&username, &password).await {
Ok(me) => Outcome::Done(format!("signed in as {}; reloading onto the PLM", me.username)),
Err(e) => Outcome::Failed(e.to_string()),
},
}
});
}
pub fn check(&mut self) {
let (client, keep) = (self.client(), self.keep.clone());
self.start(async move {
let signed_in = match &keep {
Keep::Files(dir) => match held_token(dir) {
Ok(Some(token)) => client.sign_in_with_token(&token).await,
Ok(None) => return Outcome::Failed("no token on this machine: paste one, or sign in with a password".into()),
Err(e) => return Outcome::Failed(e),
},
Keep::BrowserSession => client.sign_in_with_browser_session().await,
};
match signed_in {
Ok(me) => Outcome::Done(format!("the server accepts this machine as {} and serves this app", me.username)),
Err(e) => Outcome::Failed(e.to_string()),
}
});
}
pub fn forget(&mut self) {
match self.keep.clone() {
Keep::Files(dir) => {
self.connectable = false;
self.cancel_link();
let held = held_token(&dir).ok().flatten();
let client = self.client();
let signed_out_here = self.signed_out_here.clone();
self.start(async move {
let told: Result<bool, String> = match held {
Some(token) => {
client.replace_token(&token);
match client.raw("POST", "/api/desktop/sign-out", Some(b"{}".to_vec())).await {
Ok(answer) if answer.status == 200 => Ok(serde_json::from_slice::<serde_json::Value>(&answer.body)
.is_ok_and(|v| v["revoked"] == true)),
Ok(answer) if answer.status == 401 => Ok(true),
Ok(answer) => Err(format!("the server answered {}", answer.status)),
Err(e) => Err(e.to_string()),
}
}
None => Ok(false),
};
if told == Ok(true) {
*signed_out_here.borrow_mut() = Some(());
}
match (forget_files(&dir), told) {
(Err(e), _) => Outcome::Failed(e),
(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()),
(Ok(()), Ok(false)) => Outcome::Done("this machine's PLM sign-in is forgotten; the next start is the file app".into()),
(Ok(()), Err(why)) => Outcome::Done(format!(
"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"
)),
}
});
}
Keep::BrowserSession => {
let client = self.client();
self.start(async move {
match client.raw("POST", "/api/logout", Some(b"{}".to_vec())).await {
Ok(_) => Outcome::Done("signed out; reloading".into()),
Err(e) => Outcome::Failed(e.to_string()),
}
});
}
}
}
}
fn token_name() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
format!("brep-app {}", machine().0)
}
#[cfg(target_arch = "wasm32")]
{
"brep-app web".into()
}
}
fn machine() -> (String, String) {
let named = |text: String| Some(text.trim().to_string()).filter(|t| !t.is_empty());
let host = std::env::var("HOSTNAME")
.ok()
.and_then(named)
.or_else(|| std::env::var("COMPUTERNAME").ok().and_then(named));
#[cfg(not(target_arch = "wasm32"))]
let host = host.or_else(|| std::fs::read_to_string("/etc/hostname").ok().and_then(named));
(host.unwrap_or_else(|| "desktop".into()), os_name(std::env::consts::OS).to_string())
}
fn os_name(os: &str) -> &str {
match os {
"windows" => "Windows",
"linux" => "Linux",
"macos" => "macOS",
other => other,
}
}
fn monotonic_seconds() -> f64 {
thread_local! {
static START: web_time::Instant = web_time::Instant::now();
}
START.with(|start| start.elapsed().as_secs_f64())
}
fn keep_token(keep: &Keep, token: &str, me: &Me, fresh: &Slot<(String, String)>) -> Outcome {
match keep {
Keep::Files(dir) => match write_token(dir, token) {
Ok(()) => Outcome::Done({
*fresh.borrow_mut() = Some((me.username.clone(), token.to_string()));
format!(
"signed in as {}; the token is kept on this machine. Connect now switches this session to the PLM; otherwise the next start does",
me.username
)
}),
Err(e) => Outcome::Failed(format!("signed in, but the token could not be kept: {e}")),
},
Keep::BrowserSession => Outcome::Done(format!("signed in as {}; reloading onto the PLM", me.username)),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_token(dir: &std::path::Path, token: &str) -> Result<(), String> {
super::config::write_token(dir, token)
}
#[cfg(target_arch = "wasm32")]
fn write_token(_: &std::path::Path, _: &str) -> Result<(), String> {
Err("a browser keeps no token file".into())
}
#[cfg(not(target_arch = "wasm32"))]
fn held_token(dir: &std::path::Path) -> Result<Option<String>, String> {
Ok(super::config::resolve(dir, &|_| None, &Default::default())?.and_then(|c| c.token))
}
#[cfg(target_arch = "wasm32")]
fn held_token(_: &std::path::Path) -> Result<Option<String>, String> {
Ok(None)
}
#[cfg(not(target_arch = "wasm32"))]
fn forget_files(dir: &std::path::Path) -> Result<(), String> {
super::config::forget(dir).map(|_| ())
}
#[cfg(target_arch = "wasm32")]
fn forget_files(_: &std::path::Path) -> Result<(), String> {
Err("a browser keeps no PLM files".into())
}
fn spawn(task: impl std::future::Future<Output = ()> + 'static) {
crate::store::spawn_waking(task);
}