diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 3d44a7fb2..ecb1d3271 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -131,6 +131,24 @@ struct Operation { fn failure(code: &str, error: impl ToString) -> Value { json!({"ok":false,"status":"failed","code":code,"error":error.to_string()}) } +/// `{"op":"open","name":"plan.dwg","data_base64":"…"}`: the web build has no +/// filesystem path to open, so the caller sends the file itself. `name` is +/// reduced to its file name; it labels the tab and the recent-files entry. +fn open_bytes_request(req: &Value) -> Result<(String, Vec), Value> { + use base64::Engine as _; + let name = string(req, "name")?; + let name = name.rsplit(['/', '\\']).next().unwrap_or(name); + if name.is_empty() { + return Err(failure("invalid_request", "Missing name")); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(req["data_base64"].as_str().unwrap_or_default()) + .map_err(|e| failure("invalid_request", format!("data_base64: {e}")))?; + if bytes.is_empty() { + return Err(failure("invalid_request", "data_base64 is empty")); + } + Ok((name.to_string(), bytes)) +} fn string<'a>(req: &'a Value, key: &str) -> Result<&'a str, Value> { req[key] .as_str() @@ -703,6 +721,24 @@ impl OpenCADStudio { let i = self.active_tab; Ok(match req["op"].as_str().unwrap_or("") { "new" => self.update(Message::TabNew), + "open" if req.get("data_base64").is_some() => { + let (name, bytes) = open_bytes_request(req)?; + #[cfg(target_arch = "wasm32")] + { + if self.opening.is_some() { + return Err(failure("busy", "Another drawing is still opening")); + } + self.open_web_bytes(name, bytes) + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (name, bytes); + return Err(failure( + "invalid_request", + "data_base64 is for the web build; supply path", + )); + } + } "open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string( req, "path", )?))), @@ -1409,6 +1445,41 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn open_from_bytes_validates_the_request() { + let ok = open_bytes_request(&json!({"name":"C:\\plans\\plan.dwg","data_base64":"AAEC"})); + assert_eq!(ok, Ok(("plan.dwg".to_string(), vec![0, 1, 2]))); + for (req, error) in [ + (json!({"data_base64":"AAEC"}), "Missing name"), + (json!({"name":"dir/","data_base64":"AAEC"}), "Missing name"), + ( + json!({"name":"a.dwg","data_base64":""}), + "data_base64 is empty", + ), + ] { + assert_eq!( + open_bytes_request(&req).unwrap_err()["error"], + error, + "{req}" + ); + } + let bad = open_bytes_request(&json!({"name":"a.dwg","data_base64":"not base64!"})); + assert_eq!(bad.unwrap_err()["code"], "invalid_request"); + } + #[test] + fn open_from_bytes_is_refused_natively_without_touching_documents() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let tabs = app.tabs.len(); + let reply = request( + &mut app, + json!({"op":"open","name":"a.dxf","data_base64":"AAEC"}), + ); + assert_eq!(reply["code"], "invalid_request", "{reply}"); + assert_eq!(app.tabs.len(), tabs); + assert!(app.opening.is_none()); + } + #[test] fn embed_image_op_places_an_ole2frame_and_undoes_it() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index d4404fad9..f9715c9bb 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1182,31 +1182,53 @@ impl OpenCADStudio { } #[cfg(target_arch = "wasm32")] { - // `FileOpened` only installs the result when an open is in - // progress, so mark one. The browser picker + parse happen - // inside `pick_and_load_web`; the real name is unknown until - // then, so show a generic label meanwhile. - let state = std::sync::Arc::new(crate::io::OpenProgressState::new( - crate::app::OPEN_PHASE_READING, - )); - let open_id = self.next_open_id(); - self.opening = Some(crate::app::OpenProgress { - id: open_id, - name: "Opening…".into(), - source_path: None, - size_bytes: 0, - state: state.clone(), - started: Instant::now(), - recovery_error: None, - recovery_read_stats: None, - recovery_bytes: None, - }); + // The browser picker + parse happen inside `pick_and_load_web`; + // the real name is unknown until then, so show a generic label + // meanwhile. + let (open_id, state) = self.begin_web_open("Opening…".into(), 0); Task::perform(crate::io::pick_and_load_web(state), move |outcome| { Message::WebFileOpened(open_id, outcome) }) } } + /// Web: open a drawing from bytes the caller already holds, through the + /// same path as a file chosen in the browser picker. + #[cfg(target_arch = "wasm32")] + pub(in crate::app) fn open_web_bytes(&mut self, name: String, bytes: Vec) -> Task { + let (open_id, state) = self.begin_web_open(name.clone(), bytes.len() as u64); + Task::perform( + crate::io::open_bytes_web(name, std::sync::Arc::from(bytes), state), + move |outcome| Message::WebFileOpened(open_id, outcome), + ) + } + + /// `FileOpened` only installs the result when an open is in progress, so + /// mark one. + #[cfg(target_arch = "wasm32")] + fn begin_web_open( + &mut self, + name: String, + size_bytes: u64, + ) -> (u64, std::sync::Arc) { + let state = std::sync::Arc::new(crate::io::OpenProgressState::new( + crate::app::OPEN_PHASE_READING, + )); + let open_id = self.next_open_id(); + self.opening = Some(crate::app::OpenProgress { + id: open_id, + name, + source_path: None, + size_bytes, + state: state.clone(), + started: Instant::now(), + recovery_error: None, + recovery_read_stats: None, + recovery_bytes: None, + }); + (open_id, state) + } + pub(in crate::app) fn next_open_id(&mut self) -> u64 { self.open_job_serial = self.open_job_serial.wrapping_add(1).max(1); self.open_job_serial diff --git a/src/io/mod.rs b/src/io/mod.rs index 38a115cbe..67053222f 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -526,6 +526,18 @@ pub async fn pick_and_load_web( let name = handle.file_name(); progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2); let bytes: Arc<[u8]> = Arc::from(handle.read().await); + open_bytes_web(name, bytes, progress).await +} + +/// Web file open from bytes the caller already holds (the control channel's +/// `open` with `data_base64`). Same parse, caching and recovery handling as a +/// file chosen in the browser picker. +#[cfg(target_arch = "wasm32")] +pub async fn open_bytes_web( + name: String, + bytes: Arc<[u8]>, + progress: Arc, +) -> WebOpenOutcome { let size_bytes = bytes.len() as u64; let result = load_web_bytes(&name, &bytes, progress.clone(), false, "", None).await; let keep_for_recovery = result