Skip to main content

brep_app/panels/
plm_launch.rs

1//! "Open in CAD" from the PLM's web page: the hosted CAD app started with
2//! `?open=part/<part>/rev/<revision>` opens that revision.
3//!
4//! The PLM page opens `/cad/app/web/index.html?open=<key>` in a new tab. By the
5//! time the app's first frame runs, the web boot has already signed in with the
6//! page's PLM session cookie and hydrated the store's index
7//! (`plm::backend::open_web_session`). [`Launch`] then opens the key through the
8//! one door every open lane uses (`FileDialog::open_document`, under the store's
9//! own spelling of the key), so S3's PLM pane applies: read-only until Check out.
10//!
11//! * **A revision with a document** opens once its bytes are resident.
12//! * **A revision with no document yet** is not in the index. The server is asked
13//!   about the part; if the revision is there, a new empty document of the part's
14//!   class opens at that key.
15//! * **An unknown or forbidden key** shows the server's own sentence.
16//! * **A hosted page that is not signed in** opens Settings on the PLM tab. Its
17//!   sign-in reloads the page, and the query carries the key through the reload.
18//! * **Anywhere else** (the native app, the public site) says the link needs the
19//!   app the PLM hosts.
20//!
21//! Once the open is settled, the address bar loses `?open=`
22//! (`history.replaceState`), so reloading the tab does not reopen the key on
23//! top of the user's work. Only the sign-in case keeps it.
24
25use crate::document::{Document, Documents, EMPTY_DOCUMENT};
26use crate::panels::file::FileDialog;
27use crate::plm::PlmFuture;
28use crate::store::{ModelStore, Residency};
29
30/// A revision key (`part/<part>/rev/<revision>`) from a page's query string
31/// (`?open=…`, percent-encoded or not), or `None` when there is none or it is
32/// not that shape.
33pub fn open_key(search: &str) -> Option<String> {
34    let query = search.strip_prefix('?').unwrap_or(search);
35    let raw = query.split('&').find_map(|pair| pair.strip_prefix("open="))?;
36    let key = decode(raw);
37    let parts: Vec<&str> = key.split('/').collect();
38    match parts.as_slice() {
39        ["part", part, "rev", revision] if !part.is_empty() && !revision.is_empty() => Some(key.clone()),
40        _ => None,
41    }
42}
43
44fn decode(text: &str) -> String {
45    let bytes = text.as_bytes();
46    let mut out = Vec::with_capacity(bytes.len());
47    let mut i = 0;
48    while i < bytes.len() {
49        let hex = bytes
50            .get(i + 1..i + 3)
51            .and_then(|h| std::str::from_utf8(h).ok())
52            .and_then(|h| u8::from_str_radix(h, 16).ok());
53        match (bytes[i], hex) {
54            (b'%', Some(byte)) => {
55                out.push(byte);
56                i += 3;
57            }
58            (b'+', _) => {
59                out.push(b' ');
60                i += 1;
61            }
62            (byte, _) => {
63                out.push(byte);
64                i += 1;
65            }
66        }
67    }
68    String::from_utf8_lossy(&out).into_owned()
69}
70
71/// What one [`Launch::step`] asks of the shell.
72#[derive(Debug, PartialEq)]
73pub enum Step {
74    /// Still waiting: for the document's bytes, or for the server.
75    Pending,
76    /// Nothing to do (settled earlier).
77    Idle,
78    /// The revision is open (or was already), under this name.
79    Opened(String),
80    /// The revision had no document yet: an empty one opened under this name.
81    OpenedEmpty(String),
82    /// Not signed in on a hosted page: open Settings on the PLM tab and show
83    /// this sentence. Keep `?open=`, which the sign-in's reload carries.
84    SignIn(String),
85    /// Could not open: show this sentence (the server's, where it answered).
86    Refused(String),
87}
88
89impl Step {
90    /// Whether the address bar should drop `?open=` now.
91    pub fn settles(&self) -> bool {
92        matches!(self, Step::Opened(_) | Step::OpenedEmpty(_) | Step::Refused(_))
93    }
94}
95
96/// The part as far as an empty-revision open needs it.
97#[derive(Debug, Default, serde::Deserialize)]
98#[serde(default)]
99struct PartCheck {
100    number: String,
101    document_class: String,
102    revisions: Vec<RevisionCheck>,
103}
104
105#[derive(Debug, Default, serde::Deserialize)]
106#[serde(default)]
107struct RevisionCheck {
108    id: String,
109    label: String,
110}
111
112enum State {
113    Waiting,
114    Checking(PlmFuture<PartCheck>),
115    Done,
116}
117
118/// One `?open=` request, driven once a frame until it settles.
119pub struct Launch {
120    pub key: String,
121    state: State,
122}
123
124impl Launch {
125    pub fn new(key: impl Into<String>) -> Self {
126        Launch { key: key.into(), state: State::Waiting }
127    }
128
129    /// The launch from the page's own address (wasm), if it carries one.
130    #[cfg(target_arch = "wasm32")]
131    pub fn from_page() -> Option<Self> {
132        let search = web_sys::window()?.location().search().ok()?;
133        open_key(&search).map(Launch::new)
134    }
135
136    pub fn is_done(&self) -> bool {
137        matches!(self.state, State::Done)
138    }
139
140    /// Advance: open the revision when it can be, and say what the shell must
141    /// show when it cannot.
142    pub fn step(&mut self, store: &dyn ModelStore, docs: &mut Documents, file: &mut FileDialog) -> Step {
143        if self.is_done() {
144            return Step::Idle;
145        }
146        let Some(client) = store.plm_client() else {
147            self.state = State::Done;
148            return match store.plm_session() {
149                Some(_) => Step::SignIn(format!(
150                    "Sign in to the PLM to open {} — the page reloads onto it after you sign in.",
151                    self.key
152                )),
153                None => Step::Refused(format!(
154                    "{} can be opened only in the CAD app the PLM hosts (its Open in CAD button)",
155                    self.key
156                )),
157            };
158        };
159        let name = store.canonical_identity(&self.key);
160        match store.residency(&name) {
161            Residency::Resident => {
162                self.state = State::Done;
163                file.open_document(docs, store, &name);
164                // `open_document` focuses a tab that holds it already, or
165                // opens it; either way the document is the active one now.
166                return if docs.active().name() == Some(name.as_str()) {
167                    Step::Opened(name)
168                } else {
169                    Step::Refused(format!("{} could not be opened: its document did not load", self.key))
170                };
171            }
172            Residency::OnDisk => {
173                // The read asks the mirror for the bytes; the next frames wait.
174                let _ = store.read(&name);
175                return Step::Pending;
176            }
177            Residency::Absent => {}
178        }
179        // Not in the index: no such revision, not ours to read, or a revision
180        // with no document yet. The server says which.
181        if let State::Waiting = self.state {
182            let (part, _) = split(&self.key);
183            let path = crate::plm::identity::part_path(&part);
184            let client = client.clone();
185            self.state = State::Checking(Box::pin(async move {
186                let response = client.call("GET", &path, None).await.map_err(|e| e.to_string())?;
187                serde_json::from_slice(&response.body).map_err(|e| format!("the PLM's answer was not understood: {e}"))
188            }));
189        }
190        let State::Checking(future) = &mut self.state else { return Step::Pending };
191        let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
192        let std::task::Poll::Ready(answer) = future.as_mut().poll(&mut cx) else { return Step::Pending };
193        self.state = State::Done;
194        let (_, revision) = split(&self.key);
195        match answer {
196            Err(sentence) => Step::Refused(format!("{} could not be opened: {sentence}", self.key)),
197            Ok(part) => match part.revisions.iter().find(|r| r.id == revision) {
198                None => Step::Refused(format!("{} could not be opened: part {} has no revision {revision}", self.key, part.number)),
199                Some(found) => {
200                    let mut engine = docs.spawn_engine();
201                    if let Err(e) = engine.set_history_json(EMPTY_DOCUMENT) {
202                        return Step::Refused(format!("{} could not be opened: {e}", self.key));
203                    }
204                    let class = match part.document_class.as_str() {
205                        "family" => crate::document_class::DocumentClass::Family,
206                        "template" => crate::document_class::DocumentClass::Template,
207                        _ => crate::document_class::DocumentClass::Normal,
208                    };
209                    crate::document_class::set_document_class(&mut engine, class);
210                    let mut doc = Document::new(engine);
211                    doc.set_name(Some(name.clone()));
212                    docs.open_document(doc);
213                    let _ = found;
214                    Step::OpenedEmpty(name)
215                }
216            },
217        }
218    }
219}
220
221fn split(key: &str) -> (String, String) {
222    crate::plm::family::key_ids(key).unwrap_or_default()
223}
224
225/// Drop `?open=` from the address bar without reloading (wasm).
226pub fn forget_query() {
227    #[cfg(target_arch = "wasm32")]
228    {
229        use wasm_bindgen::{JsCast, JsValue};
230        let Some(window) = web_sys::window() else { return };
231        let location = window.location();
232        let (Ok(path), Ok(search), Ok(hash)) = (location.pathname(), location.search(), location.hash()) else { return };
233        let kept: Vec<&str> = search
234            .trim_start_matches('?')
235            .split('&')
236            .filter(|pair| !pair.is_empty() && !pair.starts_with("open="))
237            .collect();
238        let query = if kept.is_empty() { String::new() } else { format!("?{}", kept.join("&")) };
239        // `history.replaceState(null, "", url)`, reached by name: web-sys's
240        // `History` feature is not enabled for this crate, and one call does
241        // not warrant it.
242        let url = JsValue::from_str(&format!("{path}{query}{hash}"));
243        let Ok(history) = js_sys::Reflect::get(&window, &JsValue::from_str("history")) else { return };
244        let Ok(replace) = js_sys::Reflect::get(&history, &JsValue::from_str("replaceState")) else { return };
245        if let Ok(replace) = replace.dyn_into::<js_sys::Function>() {
246            let _ = replace.call3(&history, &JsValue::NULL, &JsValue::from_str(""), &url);
247        }
248    }
249}
250