CMMS embedding: load-from-URL on boot, POST-save instead of download

Additive only - standalone/opencadstudio.com use is unaffected since every
new path is gated on window.CAD_EDITOR_CONFIG being present (set by the
CMMS's cad-edit.php host page, absent everywhere else).

- src/sys.rs: cmms_config() reads window.CAD_EDITOR_CONFIG; post_bytes_to_host()
  POSTs saved bytes back via FormData instead of triggering a download.
- src/io/mod.rs: load_from_cmms_web() fetches bytes from the host-provided
  loadUrl and feeds them through the existing load_web_bytes parse path.
- src/app/mod.rs: boot_web() auto-loads via load_from_cmms_web() when
  embedded; CmmsSaveResult message variant carries the save outcome.
- src/app/update/mod.rs: CmmsSaveResult handler surfaces success/failure
  in the command line.
- src/app/update/file.rs: web save branch calls post_bytes_to_host() instead
  of download_bytes() when embedded.
- Cargo.toml: add Request/RequestInit/FormData to the web-sys feature list.
This commit is contained in:
AI Assistant 2026-08-23 14:49:00 +10:00
commit 494ddfece9
6 changed files with 221 additions and 5 deletions

View file

@ -98,6 +98,9 @@ web-sys = { version = "=0.3.85", features = [
"WritableStream",
"Url",
"Response",
"Request",
"RequestInit",
"FormData",
"Worker",
"WorkerOptions",
"WorkerType",

View file

@ -1792,6 +1792,10 @@ pub enum Message {
/// Web: an asynchronous OPFS copy written after Save is ready for recents.
#[cfg(target_arch = "wasm32")]
WebRecentStored(Result<PathBuf, String>),
/// CMMS embedding (not upstream): result of POSTing the saved bytes back
/// to the host page instead of triggering a browser download.
#[cfg(target_arch = "wasm32")]
CmmsSaveResult(Result<(), String>),
SaveFile,
SaveAs,
// ── Custom Save-As dialog ─────────────────────────────────────────────
@ -3668,9 +3672,53 @@ impl OpenCADStudio {
Message::DiscussionsFetched,
);
let thumbs_fetch = s.refresh_recent_thumbs();
// CMMS embedding (not upstream): if launched from cad-edit.php,
// window.CAD_EDITOR_CONFIG is set and the host has already validated
// the attachment belongs to the job the editor was opened from - load
// it immediately instead of waiting for File > Open. Standalone use
// (opencadstudio.com, no config present) is unaffected; the Start tab
// just shows as normal. Mirrors on_open_file's wasm branch.
let cmms_load = if crate::sys::cmms_config().is_some() {
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_READING,
));
let open_id = s.next_open_id();
s.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,
});
Task::perform(crate::io::load_from_cmms_web(state), move |outcome| {
// cmms_config() was Some a moment ago (checked just above);
// load_from_cmms_web only returns None if that vanishes
// between here and its own first line, which can't happen in
// a single-threaded wasm event loop - the fallback below
// exists so a future refactor that breaks that invariant
// fails as a visible in-app error, not a panic.
let outcome = outcome.unwrap_or_else(|| crate::io::WebOpenOutcome {
name: "CMMS attachment".to_string(),
size_bytes: 0,
result: Err(crate::io::OpenLoadError::from("CMMS integration unavailable")),
recovery_bytes: None,
cache_bytes: None,
record_recent: false,
});
Message::WebFileOpened(open_id, outcome)
})
} else {
Task::none()
};
(
s,
Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch]),
Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch, cmms_load]),
)
}
}

View file

@ -2204,13 +2204,26 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.sync_solid_models_to_acis(i);
self.stamp_thumbnail(i, version);
let mut recent_task = Task::none();
// CMMS embedding (not upstream): when launched from
// cad-edit.php, save POSTs back to the CMMS instead of
// triggering a browser download. Standalone use (no
// window.CAD_EDITOR_CONFIG) is unaffected.
let mut cmms_save_task = Task::none();
let saved = match crate::io::save_to_bytes(
&self.tabs[i].scene.document,
ext,
version,
) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
if crate::sys::cmms_config().is_some() {
let post_bytes = bytes.clone();
cmms_save_task = Task::perform(
async move { crate::sys::post_bytes_to_host(&post_bytes).await },
Message::CmmsSaveResult,
);
} else {
crate::sys::download_bytes(&filename, &bytes);
}
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
@ -2247,14 +2260,14 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
{
let cont = self.update(Message::TabClose(idx));
let rest = self.continue_tab_close_queue();
return Task::batch([close, recent_task, cont, rest]);
return Task::batch([close, recent_task, cmms_save_task, cont, rest]);
}
} else if self.pending_close.is_some() {
let retry = self.open_unsaved_dialog_window();
return Task::batch([close, recent_task, retry]);
return Task::batch([close, recent_task, cmms_save_task, retry]);
}
}
Task::batch([close, recent_task])
Task::batch([close, recent_task, cmms_save_task])
}
}

View file

@ -828,6 +828,17 @@ impl OpenCADStudio {
}
},
#[cfg(target_arch = "wasm32")]
Message::CmmsSaveResult(result) => {
match result {
Ok(()) => self.command_line.push_output("Saved to CMMS."),
Err(error) => self
.command_line
.push_error(crate::tf!("Save to CMMS failed: {error}").as_ref()),
}
Task::none()
}
Message::ImagePick => {
Task::perform(crate::io::pick_image_file(), Message::ImagePickResult)
}

View file

@ -524,6 +524,59 @@ pub async fn pick_and_load_web(
}
}
/// CMMS embedding (not upstream): load the one file the host page already
/// validated, instead of showing the browser picker. `crate::sys::cmms_config()`
/// returning `None` means this build is running standalone (opencadstudio.com
/// or a plain local file), so callers should fall back to `pick_and_load_web`
/// in that case — this never shows a picker itself.
#[cfg(target_arch = "wasm32")]
pub async fn load_from_cmms_web(progress: Arc<OpenProgressState>) -> Option<WebOpenOutcome> {
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
let cfg = crate::sys::cmms_config()?;
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let name = cfg.original_name.clone();
let window = web_sys::window()?;
let response: web_sys::Response = JsFuture::from(window.fetch_with_str(&cfg.load_url))
.await
.ok()?
.dyn_into()
.ok()?;
if !response.ok() {
return Some(WebOpenOutcome {
name,
size_bytes: 0,
result: Err(OpenLoadError::from(format!(
"CMMS load failed: HTTP {}",
response.status()
))),
recovery_bytes: None,
cache_bytes: None,
record_recent: false,
});
}
let buffer = JsFuture::from(response.array_buffer().ok()?).await.ok()?;
let bytes: Arc<[u8]> = Arc::from(js_sys::Uint8Array::new(&buffer).to_vec());
let size_bytes = bytes.len() as u64;
let result = load_web_bytes(&name, &bytes, progress.clone(), false, "", None).await;
let keep_for_recovery = result
.as_ref()
.err()
.is_some_and(|error| error.recovery_available);
let cache_bytes = result.is_ok().then(|| Arc::clone(&bytes));
Some(WebOpenOutcome {
name,
size_bytes,
result,
recovery_bytes: keep_for_recovery.then(|| Arc::clone(&bytes)),
cache_bytes,
record_recent: false,
})
}
/// Reopen a browser-private recent copy without showing the file picker.
#[cfg(target_arch = "wasm32")]
pub async fn open_recent_web(

View file

@ -300,6 +300,94 @@ pub fn download_bytes(name: &str, bytes: &[u8]) {
let _ = web_sys::Url::revoke_object_url(&url);
}
// --- CMMS embedding (not upstream) -----------------------------------------
//
// When this build is launched from the CMMS's cad-edit.php (rather than
// standalone at opencadstudio.com), the host page sets `window.CAD_EDITOR_CONFIG`
// before loading this bundle. Its presence is the sole signal that we're
// embedded: absent, every function below returns None/Err and the normal
// file-picker/download flow (pick_and_load_web / download_bytes) is
// unaffected, so the same build works standalone or embedded.
#[cfg(target_arch = "wasm32")]
pub struct CmmsConfig {
pub load_url: String,
pub save_url: String,
pub attachment_id: String,
pub job_id: String,
pub csrf_token: String,
pub original_name: String,
}
#[cfg(target_arch = "wasm32")]
pub fn cmms_config() -> Option<CmmsConfig> {
use wasm_bindgen::JsValue;
let window = web_sys::window()?;
let config = js_sys::Reflect::get(&window, &JsValue::from_str("CAD_EDITOR_CONFIG")).ok()?;
if config.is_undefined() || config.is_null() {
return None;
}
let get_str = |key: &str| -> Option<String> {
js_sys::Reflect::get(&config, &JsValue::from_str(key))
.ok()?
.as_string()
};
Some(CmmsConfig {
load_url: get_str("loadUrl")?,
save_url: get_str("saveUrl")?,
attachment_id: get_str("attachmentId")?,
job_id: get_str("jobId")?,
csrf_token: get_str("csrfToken")?,
original_name: get_str("originalName")?,
})
}
/// POST the current document's bytes back to the CMMS in place of a browser
/// download. Mirrors `download_bytes`'s Blob construction but sends it as a
/// multipart file field instead of triggering an `<a download>` click.
#[cfg(target_arch = "wasm32")]
pub async fn post_bytes_to_host(bytes: &[u8]) -> Result<(), String> {
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
let cfg = cmms_config().ok_or("not running inside the CMMS")?;
let form = web_sys::FormData::new().map_err(|_| "could not build form data")?;
form.append_with_str("action", "cad_save")
.map_err(|_| "form append failed")?;
form.append_with_str("id", &cfg.attachment_id)
.map_err(|_| "form append failed")?;
form.append_with_str("job_id", &cfg.job_id)
.map_err(|_| "form append failed")?;
form.append_with_str("csrf_token", &cfg.csrf_token)
.map_err(|_| "form append failed")?;
let array = js_sys::Uint8Array::from(bytes);
let parts = js_sys::Array::new();
parts.push(&array.buffer());
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts)
.map_err(|_| "could not build blob")?;
form.append_with_blob("file", &blob)
.map_err(|_| "form append failed")?;
let window = web_sys::window().ok_or("no window")?;
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_body(&form);
let request = web_sys::Request::new_with_str_and_init(&cfg.save_url, &opts)
.map_err(|_| "could not build request")?;
let response: web_sys::Response = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| "save request failed")?
.dyn_into()
.map_err(|_| "save response is invalid")?;
if !response.ok() {
return Err(format!("save failed: HTTP {}", response.status()));
}
Ok(())
}
/// Short platform string for bug reports: OS + architecture on the desktop,
/// the browser user-agent on the web.
#[cfg(not(target_arch = "wasm32"))]