Web host bridge: ready after the runner starts; the host's author name isn't stored

- wordcraft:ready is posted once WebRunner::start has succeeded, so a parent
  never hears ready and then failed.
- ?author= signs the visit but the browser keeps the user's own name in its
  stored preferences; a name chosen in File › Options is stored as usual.
- The saveAs flag is documented case by case: Save false, Save As and
  File › Export to a document format true, PDF/PNG exports false (copies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rob
2026-10-24 18:25:37 +01:00
co-authored by Claude Opus 5.5
parent 5d629a5fae
commit b9f6b33af5
4 changed files with 70 additions and 16 deletions
+10 -5
View File
@@ -2,14 +2,19 @@
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download: Save, Save As, File › Export and Export as
//! PDF. Save writes the opened file under its own name and format, as the desktop app does;
//! `saveAs` is true for Save As and File › Export. The parent stores it and reports failures
//! itself.
//! every file WordCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the document's file:
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
//! app does.
//! - Save As, and File › Export to another document format (which runs Save As): `true`. The
//! document is now that file; the next Save sends its name with `false`.
//! - Export as PDF and Export Page as PNG: `false`, with a name of their own (`report.pdf`).
//! They are copies; the document's file stays the same.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
+43 -2
View File
@@ -25,17 +25,35 @@ pub fn restore(app: &mut WordApp, stored: Option<&str>) {
#[derive(Default)]
pub struct PrefsSync {
last: Option<String>,
/// A host page's name for the user (`?author=`) and the user's own, which is stored in its
/// place while the user keeps the host's.
host_author: Option<(String, String)>,
}
impl PrefsSync {
/// Starts from what is already stored, so restoring doesn't write it straight back.
pub fn new(stored: Option<String>) -> Self {
Self { last: stored }
Self { last: stored, host_author: None }
}
/// The session signs with `host`, a host page's name for the user, but `own` stays the
/// stored name. A name the user then chooses in File › Options is stored as usual.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn keep_own_author(&mut self, host: &str, own: &str) {
if host != own {
self.host_author = Some((host.to_string(), own.to_string()));
}
}
/// The JSON to store when the app's preferences differ from what was last written.
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
let json = match serde_json::to_string(&app.prefs()) {
let mut prefs = app.prefs();
if let Some((host, own)) = &self.host_author
&& prefs.author == *host
{
prefs.author = own.clone();
}
let json = match serde_json::to_string(&prefs) {
Ok(j) => j,
Err(e) => {
log::warn!("preferences: cannot serialize: {e}");
@@ -60,6 +78,29 @@ mod tests {
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
}
/// A host page's name for the user signs the visit but isn't stored; a name the user then
/// chooses is.
#[test]
fn host_author_is_not_stored() {
let mut a = app();
a.session.author = "Grace Hopper".into();
let mut sync = PrefsSync::new(None);
let stored = sync.changed(&a).unwrap();
a.session.author = "rob".into();
sync.keep_own_author("rob", "Grace Hopper");
a.run("ui.theme", json!({"value": "dark"})).unwrap();
let json = sync.changed(&a).expect("the theme changed");
assert_ne!(json, stored);
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Grace Hopper");
a.session.author = "Rob Hones".into();
let json = sync.changed(&a).expect("the user chose a name");
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Rob Hones");
}
/// #2132: dark mode chosen on one visit is there after a reload.
#[test]
fn dark_mode_survives_reload() {
+15 -8
View File
@@ -85,23 +85,30 @@ pub fn start() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
let prefs = crate::prefs::PrefsSync::new(stored);
let mut prefs = crate::prefs::PrefsSync::new(stored);
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
if let Some(bridge) = &app_bridge {
if let Some(author) = query_param("author") {
crate::host::Bridge::set_author(&mut app, &author);
}
bridge.post_ready();
// The host's name for the user signs this visit's work, but isn't stored as the
// browser's own preference, which a visit outside the host would restore.
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
let own = app.session.author.clone();
crate::host::Bridge::set_author(&mut app, &author);
prefs.keep_own_author(&app.session.author, &own);
}
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
}),
)
.await;
if let (Err(e), Some(bridge)) = (&result, &bridge) {
bridge.post_failed(&format!("{e:?}"));
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
+2 -1
View File
@@ -129,7 +129,8 @@ pub struct Services {
pub async_saves: Option<async_saves::AsyncSaves>,
/// Web, framed by a page that keeps the files (`?host=parent`): set alongside `download`,
/// Save, Save As and Export hand the bytes to that page instead, with whether it was Save As
/// ([`HostSaveFn`]). Save then writes the opened file under its own name and format, as on
/// ([`HostSaveFn`]: true for `file.saveAs`, which File › Export to a document format runs
/// too; false for Save and for the PDF and PNG exports, which are copies). Save then writes the opened file under its own name and format, as on
/// the desktop, and a Save or Save As that keeps everything makes that name the document's
/// file, so the next Save goes to it.
pub host_save: Option<HostSaveFn>,