diff --git a/.github/workflows/editor.yml b/.github/workflows/editor.yml index 8e91fbf..b08a365 100644 --- a/.github/workflows/editor.yml +++ b/.github/workflows/editor.yml @@ -15,6 +15,13 @@ jobs: targets: wasm32-unknown-unknown components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v4 + with: + repository: pace-kit/pace_server + ref: 66203da9bfeda679dac0669fe4eae7ced9d9e59e + path: integration-server + - uses: dtolnay/rust-toolchain@stable + - run: cargo +stable build --locked --manifest-path integration-server/Cargo.toml - run: cargo fmt --all -- --check - run: cargo test --workspace --locked - run: cargo clippy --workspace --all-targets --locked -- -D warnings @@ -32,6 +39,8 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run test:e2e + env: + PACE_SERVER_BIN: integration-server/target/debug/pace_server - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.gitignore b/.gitignore index fe50fe7..33dc0fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/integration-server /dist /node_modules /test-results diff --git a/Cargo.lock b/Cargo.lock index 2b16dc2..b3fda02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,13 +30,15 @@ checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" name = "app" version = "0.1.0" dependencies = [ - "base64 0.22.1", "dioxus", - "imagesize", "js-sys", + "pace_editor_format", "serde", + "serde-wasm-bindgen", "serde_json", + "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -1393,6 +1395,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "pace_editor_format" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "imagesize", + "serde", + "serde_json", +] + [[package]] name = "parking" version = "2.2.0" diff --git a/Cargo.toml b/Cargo.toml index 87fe6bf..2127b06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["app", "web_view"] +members = ["app", "web_view", "project_format"] # The initial supported platform is browser/WASM. Legacy desktop scaffolding is # retained outside the workspace until its platform adapter is implemented. -exclude = ["base_resolver", "desktop_resolver", "desktop_view", "web_resolver"] +exclude = ["base_resolver", "desktop_resolver", "desktop_view", "web_resolver", "integration-server"] diff --git a/README.md b/README.md index 9e44192..71f7d13 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,33 @@ cargo check -p web_view --target wasm32-unknown-unknown cargo fmt --all -- --check ``` -## Core dependency — blocked +## Project sharing -The inspected `pace_core` commit is -[`6ccfd6ea60534f3c3312882458e1c83308a76b81`](https://github.com/pace-kit/pace_core/tree/6ccfd6ea60534f3c3312882458e1c83308a76b81). -It contains empty build/debug modules and Bevy templates, but no versioned scene -format, loader, executable runtime, or three-room fixture. No other branches or -PRs were available during implementation. `runtime.lock.json` records this -fact; its null runtime revision is **not** a runnable runtime pin. +Open **Project sharing** to connect to `pace_server`, upload a private snapshot, +grant reader/writer access, and download another user's project. Uploads require +the revision you downloaded; stale writes keep your edits and show a conflict. +Downloads are staged for review before replacing the open project. Tokens stay +in memory and are cleared on reload. Local save/recovery works without a server. +See [setup and the two-client walkthrough](docs/project-sharing.md). + +This is **project sharing, not real-time co-editing**. There is no automatic +upload, merge, polling or live presence. + +## Core and draft formats + +`pace_core` at +[`1aa2b36270b68ea6fc04c268c3d5661ec09790aa`](https://github.com/pace-kit/pace_core/tree/1aa2b36270b68ea6fc04c268c3d5661ec09790aa) +now defines core scene v1 and a runnable native adventure runtime. The server +validates core scenes with that pinned loader, independently of editor drafts. +The editor does not yet convert drafts to core scenes or run the core in a browser; +`runtime.lock.json`'s null runtime revision is **not** a runnable web runtime pin. Projects currently use **`pace-editor-draft`, version 1**, an explicitly -editor-owned contract, not a claimed implementation of the missing core format. -Core save/import, runtime preview, and game export cannot be completed until -that dependency exists. Draft files are deliberately named `.pace-draft.json`. -No upstream compatibility is implied. +editor-owned contract, with embedded images and editable incomplete references. +The model and loader are shared with the server through the `pace_editor_format` +crate. Sharing does not change that schema or convert it into a core scene. +Draft files remain `.pace-draft.json`; core conversion, preview and game export +require a separate integration that can account for their different features. ## Project files @@ -73,6 +86,8 @@ Invalid drafts cannot be rehearsed. npm ci npx playwright install --with-deps chromium trunk build +# Build the compatible server (see docs/project-sharing.md), then: +export PACE_SERVER_BIN=/absolute/path/to/pace_server/target/debug/pace_server npm run test:e2e ``` @@ -94,4 +109,5 @@ reproduces the walkthrough, including save/reopen equality for the whole project links, dialogue branches and item gates. Restart clears its session. It never modifies the source project and is **not** the PACE core runtime. **Core preview** and **Export game** remain disabled. This implementation does not yet meet the -core-format serialization or pinned-runtime preview/export requirements. +core-format conversion or pinned-runtime preview/export requirements. Snapshot +sharing is available for the editor format independently of runtime integration. diff --git a/app/Cargo.toml b/app/Cargo.toml index d0e407b..4bb2f0b 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -7,13 +7,15 @@ edition = "2021" dioxus = { version = "=0.5.6", default-features = false, features = ["macro", "html", "signals", "hooks"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" -base64 = "0.22" -imagesize = "0.13" +pace_editor_format = { path = "../project_format" } +url = "2" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2" web-sys = { version = "0.3", features = ["Window", "Storage", "Document", "Element", "DomRect", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url"] } js-sys = "0.3" +wasm-bindgen-futures = "0.4" +serde-wasm-bindgen = "0.5" [features] web = [] diff --git a/app/src/editor.css b/app/src/editor.css index 967ae5c..1d2671c 100644 --- a/app/src/editor.css +++ b/app/src/editor.css @@ -1,3 +1,4 @@ :root{font-family:'DM Sans',system-ui,sans-serif;color:#263d3c;background:#f5f6f3;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid #d9dfda;background:#fff;color:#263d3c;border-radius:7px;padding:9px 13px;cursor:pointer;text-decoration:none;font-weight:600}button:hover,.button:hover{background:#edf2e9;border-color:#9cae9d}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,.stage:focus-visible{outline:3px solid #85b2ea;outline-offset:2px}button:disabled{opacity:.45;cursor:not-allowed}.primary{background:#224c43;color:#fff;border-color:#224c43}.primary:hover{background:#16392f}.button input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.button:focus-within{outline:3px solid #85b2ea}h1,h2,h3,p{margin:0}h1,h2,h3{font-family:Manrope,system-ui,sans-serif}h2{font-size:17px}h3{font-size:12px;letter-spacing:.06em;margin:24px 0 12px}p{line-height:1.65;color:#6d7d76}label{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:600;margin:16px 0}input:not([type=file]),select,textarea{width:100%;padding:10px;border:1px solid #d9dfda;border-radius:6px;background:#fff;color:#263d3c}textarea{min-height:90px;resize:vertical}.app{display:flex;flex-direction:column;min-height:100vh}.topbar{height:76px;display:flex;align-items:center;gap:12px;padding:0 24px;background:#fff;border-bottom:1px solid #e1e6e0}.brand{font-family:Manrope,sans-serif;font-weight:800;background:#224c43;color:#d9e8aa;width:38px;height:42px;border-radius:12px 12px 12px 2px;font-size:27px;text-align:center;padding-top:2px}.brand-copy{display:flex;flex-direction:column;gap:2px}.brand-copy strong{font-size:16px;letter-spacing:.13em}.brand-copy span{font-size:10px;color:#78867f}.project-title{margin:auto;font-weight:600}.dependency-banner{font-size:11px;padding:9px 24px;background:#edf0df;color:#69734b;border-bottom:1px solid #dfe3ce}.workspace{display:grid;grid-template-columns:220px minmax(320px,1fr) 290px;flex:1;min-height:calc(100vh - 149px)}.sidebar{background:#f9faf7;border-right:1px solid #e0e5dc;padding:24px 14px;display:flex;flex-direction:column}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:28px 10px 12px}.section-heading h2{font-family:inherit;font-size:10px;letter-spacing:.12em;color:#849087}.section-heading button{padding:0;border:0;background:none;font-size:21px}.project-link{justify-content:flex-start;background:none;border:0;font-size:12px}.tree-row{display:flex;justify-content:flex-start;gap:9px;text-align:left;width:100%;border:0;background:none;margin:3px 0;padding:12px}.tree-row.active{background:#e8eddf;color:#234b40}.sidebar-foot{margin-top:auto;padding:48px 10px 0;font-size:11px;color:#849087;line-height:1.5}.main-panel{padding:20px 28px;min-width:0;background:radial-gradient(#dce3d9 .7px,transparent .7px);background-size:16px 16px}.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:24px}.eyebrow{margin-right:auto;color:#859086;font-size:10px;letter-spacing:.12em;font-weight:700}.toolbar button{font-size:11px;padding:7px 11px}.empty-state{padding:90px 20px;text-align:center;max-width:530px;margin:auto}.empty-state h1{font-size:25px;line-height:1.5;margin:18px 0 12px}.empty-icon{display:inline-flex;align-items:center;justify-content:center;font-size:52px;width:90px;height:90px;border-radius:50%;background:#e5ecdd;color:#506c4a}.inspector{padding:26px 22px;background:#fff;border-left:1px solid #e0e5dc;overflow-wrap:anywhere}.issue{display:flex;flex-direction:column;align-items:flex-start;text-align:left;width:100%;font-size:11px;background:#fff8ef;border-color:#f1dfc5;color:#805d28;margin:8px 0;line-height:1.5}.issue span{font-weight:400}.success{color:#357350;font-size:12px}footer{min-height:36px;background:#fff;border-top:1px solid #e0e5dc;padding:9px 24px;font-size:11px;color:#64756c}.muted{font-size:11px;color:#829087;line-height:1.5}.danger{color:#a4473d;border-color:#e9d2cb;margin-top:18px}.room-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.room-heading h1{font-size:25px}.room-heading p{font-size:12px;margin-top:6px}.stage{position:relative;box-shadow:0 12px 40px #263d3c18;background:#e2e8df;border-radius:8px;overflow:hidden;user-select:none;touch-action:none}.stage>img{display:block;width:100%;height:100%;pointer-events:none;object-fit:fill}.hotspot{position:absolute;background:#e5f29530;border:2px solid #e5f295;border-radius:3px;padding:0;color:#fff;text-shadow:0 1px 3px #000;font-size:11px;overflow:hidden;min-width:0}.hotspot:hover,.hotspot.selected{background:#e5f29555;border-color:#fff}.stage.drawing .hotspot{pointer-events:none}.ghost{position:absolute;border:2px dashed #fff;background:#e5f29555;pointer-events:none}.stage-caption{display:flex;justify-content:space-between;font-size:11px;color:#829087;margin-top:14px}.hotspot-list{margin-top:30px}.hotspot-row{display:flex;gap:12px;align-items:center;background:#fff;border:1px solid #e1e6dc;border-radius:7px;margin:7px 0;padding:12px;width:100%;text-align:left;font-size:12px;justify-content:flex-start}.hotspot-row span{margin-left:auto;color:#849087;font-size:10px}.fields-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}.fields-grid label{margin:8px 0}.segmented{display:flex;gap:7px;margin:16px 0}.segmented button{font-size:11px}.card{background:#fff;border:1px solid #e1e6dc;border-radius:10px;padding:24px;margin-bottom:18px}.choice{padding:14px;background:#f6f8f1;border-radius:8px;margin:14px 0}.preview-overlay{position:fixed;inset:0;z-index:20;background:#132624e8;display:flex;align-items:center;justify-content:center;padding:24px}.preview{background:#f8f9f4;border-radius:12px;padding:24px;width:min(960px,95vw);max-height:95vh;overflow:auto}.preview .stage{max-height:56vh}.preview-head{display:flex;align-items:center;gap:12px;margin-bottom:16px}.preview-head h2{margin-right:auto}.inventory{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:16px 0;font-size:12px}.chip{background:#e6eddb;padding:7px 11px;border-radius:20px}.dialogue{padding:18px;background:#fff;border:1px solid #d9dfda;border-radius:8px}.dialogue p{margin:8px 0 14px}.dialogue button{margin-right:8px}.preview-note{font-size:11px;color:#93734e;margin:10px 0}.error{color:#a4473d}.inline-actions{display:flex;gap:8px;flex-wrap:wrap}@media(max-width:1050px){.workspace{grid-template-columns:180px minmax(280px,1fr) 250px}.main-panel{padding:18px}.topbar{gap:8px;padding:0 15px}.project-title{font-size:12px}.brand-copy span{display:none}}@media(max-width:760px){.workspace{grid-template-columns:150px 1fr}.inspector{grid-column:1/-1;border-top:1px solid #e0e5dc}.topbar{height:auto;min-height:76px;flex-wrap:wrap;padding:12px}.project-title{flex:1}.brand-copy{display:none}.topbar button,.topbar .button{font-size:11px}.sidebar{padding:12px 6px}.main-panel{padding:14px}.room-heading{align-items:flex-start;gap:8px}.toolbar{flex-wrap:wrap}.eyebrow{width:100%}.empty-state{padding:40px 10px}} .preview-overlay{width:100vw;height:100vh;max-width:none;max-height:none;border:0;margin:0;color:inherit}.preview-overlay::backdrop{background:transparent} +.sharing-panel{background:#fff;border-bottom:1px solid #d9dfda;padding:12px 24px}.sharing-panel summary{cursor:pointer;font-weight:600}.sharing-panel>p{margin:12px 0;font-size:12px}.sharing-fields{display:grid;grid-template-columns:repeat(3,minmax(180px,1fr));gap:16px;align-items:center}.sharing-download{margin:14px 0;padding:14px;background:#edf2e9;border-radius:8px}.sharing-download p{margin-bottom:10px}@media(max-width:760px){.sharing-fields{grid-template-columns:1fr}.sharing-panel{padding:12px}} diff --git a/app/src/lib.rs b/app/src/lib.rs index d4304c5..dff3d8d 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -3,6 +3,7 @@ mod browser; pub mod editor; pub mod model; mod rooms; +pub mod sharing; mod ui; pub use ui::App; diff --git a/app/src/model.rs b/app/src/model.rs index 6112864..49a48e9 100644 --- a/app/src/model.rs +++ b/app/src/model.rs @@ -1,476 +1,2 @@ -//! Editor-owned draft contract. This is deliberately NOT named the PACE core -//! format: the inspected upstream revision has no serializable model or runtime. -use base64::{engine::general_purpose::STANDARD, Engine}; -use serde::{Deserialize, Serialize}; -use std::{collections::HashSet, sync::Arc}; - -pub const FORMAT: &str = "pace-editor-draft"; -pub const VERSION: u32 = 1; -pub const MAX_IMAGE_BYTES: usize = 8 * 1024 * 1024; -pub const MAX_PROJECT_BYTES: usize = 64 * 1024 * 1024; - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Project { - pub format: String, - pub version: u32, - pub title: String, - pub start_room: Option, - pub rooms: Vec, - pub items: Vec, - pub dialogues: Vec, - next_id: u64, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Room { - pub id: String, - pub name: String, - pub background: Option, - pub hotspots: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Background { - pub name: String, - pub data_url: Arc, - pub width: u32, - pub height: u32, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Rect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -impl Rect { - /// Coordinates are normalized to the original image, independent of zoom. - pub fn from_drag(start: (f64, f64), end: (f64, f64)) -> Option { - let (ax, ay) = (start.0.clamp(0., 1.), start.1.clamp(0., 1.)); - let (bx, by) = (end.0.clamp(0., 1.), end.1.clamp(0., 1.)); - let rect = Self { - x: ax.min(bx), - y: ay.min(by), - width: (ax - bx).abs(), - height: (ay - by).abs(), - }; - (rect.valid() && rect.width >= 0.005 && rect.height >= 0.005).then_some(rect) - } - - pub fn valid(&self) -> bool { - [self.x, self.y, self.width, self.height] - .iter() - .all(|n| n.is_finite()) - && self.x >= 0. - && self.y >= 0. - && self.width > 0. - && self.height > 0. - && self.x + self.width <= 1.000001 - && self.y + self.height <= 1.000001 - } - - pub fn translated(self, dx: f64, dy: f64) -> Self { - Self { - x: (self.x + dx).clamp(0., (1. - self.width).max(0.)), - y: (self.y + dy).clamp(0., (1. - self.height).max(0.)), - ..self - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Hotspot { - pub id: String, - pub name: String, - pub rect: Rect, - pub action: Action, - pub requires_item: Option, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum Action { - Look { text: String }, - GoTo { room_id: String }, - Take { item_id: String }, - Talk { dialogue_id: String }, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Item { - pub id: String, - pub name: String, - pub description: String, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Dialogue { - pub id: String, - pub name: String, - pub speaker: String, - pub text: String, - pub choices: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Choice { - pub id: String, - pub text: String, - pub next: Option, - pub give_item: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Issue { - pub object_id: Option, - pub field: String, - pub message: String, -} - -impl Default for Project { - fn default() -> Self { - Self { - format: FORMAT.into(), - version: VERSION, - title: "Untitled adventure".into(), - start_room: None, - rooms: vec![], - items: vec![], - dialogues: vec![], - next_id: 1, - } - } -} - -impl Project { - pub fn to_json(&self) -> Result { - if self.rooms.iter().flat_map(|r| &r.hotspots).any(|h| { - [h.rect.x, h.rect.y, h.rect.width, h.rect.height] - .iter() - .any(|n| !n.is_finite()) - }) { - return Err( - "Cannot save a hotspot with non-finite coordinates. Correct its bounds first." - .into(), - ); - } - let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; - if text.len() > MAX_PROJECT_BYTES { - return Err("Project exceeds the 64 MiB file limit. Reduce background sizes.".into()); - } - Ok(text) - } - - pub fn from_json(text: &str) -> Result { - if text.len() > MAX_PROJECT_BYTES { - return Err("Project exceeds the 64 MiB file limit.".into()); - } - let value: serde_json::Value = - serde_json::from_str(text).map_err(|e| format!("Invalid project JSON: {e}"))?; - if value.get("format").and_then(|v| v.as_str()) != Some(FORMAT) { - return Err( - "This is not a PACE editor draft. Core-format import awaits the upstream contract." - .into(), - ); - } - if value.get("version").and_then(|v| v.as_u64()) != Some(VERSION as u64) { - return Err("Unsupported draft version. Open it with the editor that created it; no data was replaced.".into()); - } - // Strict decoding prevents silent loss of fields from a newer writer. - let project: Self = serde_json::from_str(text) - .map_err(|e| format!("Unsupported or malformed project: {e}"))?; - // Invalid references are editable, but ambiguous IDs cannot safely be edited. - let mut ids = HashSet::new(); - for id in project.ids() { - if id.trim().is_empty() || !ids.insert(id) { - return Err(format!( - "Empty or duplicate object ID: {id:?}. No data was replaced." - )); - } - } - Ok(project) - } - - fn ids(&self) -> Vec<&str> { - self.rooms - .iter() - .flat_map(|r| { - std::iter::once(r.id.as_str()).chain(r.hotspots.iter().map(|h| h.id.as_str())) - }) - .chain(self.items.iter().map(|i| i.id.as_str())) - .chain(self.dialogues.iter().flat_map(|d| { - std::iter::once(d.id.as_str()).chain(d.choices.iter().map(|c| c.id.as_str())) - })) - .collect() - } - - pub fn allocate_id(&mut self, kind: &str) -> String { - loop { - let id = format!("{kind}-{}", self.next_id); - self.next_id = self.next_id.wrapping_add(1); - if !self.ids().contains(&id.as_str()) { - return id; - } - } - } - - pub fn add_room(&mut self) -> String { - let id = self.allocate_id("room"); - self.rooms.push(Room { - id: id.clone(), - name: format!("Room {}", self.rooms.len() + 1), - background: None, - hotspots: vec![], - }); - if self.start_room.is_none() { - self.start_room = Some(id.clone()); - } - id - } - - pub fn room(&self, id: &str) -> Option<&Room> { - self.rooms.iter().find(|r| r.id == id) - } - pub fn room_mut(&mut self, id: &str) -> Option<&mut Room> { - self.rooms.iter_mut().find(|r| r.id == id) - } - - pub fn add_hotspot(&mut self, room_id: &str, rect: Rect) -> Option { - if !rect.valid() || self.room(room_id)?.background.is_none() { - return None; - } - let id = self.allocate_id("hotspot"); - let room = self.room_mut(room_id)?; - room.hotspots.push(Hotspot { - id: id.clone(), - name: format!("Hotspot {}", room.hotspots.len() + 1), - rect, - action: Action::Look { - text: "Something catches your eye.".into(), - }, - requires_item: None, - }); - Some(id) - } - - pub fn add_item(&mut self) -> String { - let id = self.allocate_id("item"); - self.items.push(Item { - id: id.clone(), - name: "New item".into(), - description: String::new(), - }); - id - } - - pub fn add_dialogue(&mut self) -> String { - let id = self.allocate_id("dialogue"); - self.dialogues.push(Dialogue { - id: id.clone(), - name: "New dialogue".into(), - speaker: "Narrator".into(), - text: "Hello, traveler.".into(), - choices: vec![], - }); - id - } - - pub fn validate(&self) -> Vec { - let mut issues = vec![]; - let mut push = |id: Option<&str>, field: &str, message: String| { - issues.push(Issue { - object_id: id.map(str::to_owned), - field: field.into(), - message, - }) - }; - if self.title.trim().is_empty() { - push(None, "title", "Give the project a title.".into()); - } - if self - .start_room - .as_deref() - .is_none_or(|id| self.room(id).is_none()) - { - push( - None, - "start_room", - "Choose an existing starting room.".into(), - ); - } - let has_item = |id: &str| self.items.iter().any(|i| i.id == id); - let has_dialogue = |id: &str| self.dialogues.iter().any(|d| d.id == id); - for r in &self.rooms { - if r.name.trim().is_empty() { - push(Some(&r.id), "name", "Room name is empty.".into()); - } - match &r.background { - None => push( - Some(&r.id), - "background", - "Import a background for this room.".into(), - ), - Some(b) => { - if let Err(e) = b.validate() { - push(Some(&r.id), "background", e); - } - } - } - for h in &r.hotspots { - if h.name.trim().is_empty() { - push(Some(&h.id), "name", "Hotspot name is empty.".into()); - } - if !h.rect.valid() { - push( - Some(&h.id), - "rect", - "Hotspot must have a positive size and fit inside the background.".into(), - ); - } - if let Some(id) = &h.requires_item { - if !has_item(id) { - push( - Some(&h.id), - "requires_item", - format!("Required item {id:?} does not exist."), - ); - } - } - match &h.action { - Action::GoTo { room_id } if self.room(room_id).is_none() => push( - Some(&h.id), - "action.room_id", - format!("Linked room {room_id:?} does not exist."), - ), - Action::Take { item_id } if !has_item(item_id) => push( - Some(&h.id), - "action.item_id", - format!("Item {item_id:?} does not exist."), - ), - Action::Talk { dialogue_id } if !has_dialogue(dialogue_id) => push( - Some(&h.id), - "action.dialogue_id", - format!("Dialogue {dialogue_id:?} does not exist."), - ), - Action::Look { text } if text.trim().is_empty() => { - push(Some(&h.id), "action.text", "Enter inspection text.".into()) - } - _ => {} - } - } - } - for item in &self.items { - if item.name.trim().is_empty() { - push(Some(&item.id), "name", "Item name is empty.".into()); - } - } - for d in &self.dialogues { - if d.name.trim().is_empty() { - push(Some(&d.id), "name", "Dialogue name is empty.".into()); - } - if d.text.trim().is_empty() { - push(Some(&d.id), "text", "Enter dialogue text.".into()); - } - for c in &d.choices { - if c.text.trim().is_empty() { - push(Some(&c.id), "text", "Enter choice text.".into()); - } - if let Some(id) = &c.next { - if !has_dialogue(id) { - push( - Some(&c.id), - "next", - format!("Next dialogue {id:?} does not exist."), - ); - } - } - if let Some(id) = &c.give_item { - if !has_item(id) { - push( - Some(&c.id), - "give_item", - format!("Reward item {id:?} does not exist."), - ); - } - } - } - } - issues - } -} - -impl Background { - /// Only embedded raster data is rendered; malformed imports cannot load a - /// remote URL or an active document through the image element. - pub fn display_url(&self) -> &str { - if [ - "data:image/png;base64,", - "data:image/jpeg;base64,", - "data:image/webp;base64,", - ] - .iter() - .any(|prefix| self.data_url.starts_with(prefix)) - { - &self.data_url - } else { - "" - } - } - - pub fn import(name: String, bytes: &[u8]) -> Result { - if bytes.len() > MAX_IMAGE_BYTES { - return Err("Background exceeds 8 MiB. Resize it before importing.".into()); - } - let mime = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { - "image/png" - } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { - "image/jpeg" - } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { - "image/webp" - } else { - return Err("Use a PNG, JPEG, or WebP image.".into()); - }; - let size = imagesize::blob_size(bytes) - .map_err(|_| "Cannot read this image. It may be corrupt.".to_string())?; - if size.width == 0 || size.height == 0 || size.width > 16384 || size.height > 16384 { - return Err("Image dimensions must be between 1 and 16384 pixels.".into()); - } - Ok(Self { - name, - data_url: format!("data:{mime};base64,{}", STANDARD.encode(bytes)).into(), - width: size.width as u32, - height: size.height as u32, - }) - } - - fn validate(&self) -> Result<(), String> { - let (header, data) = self - .data_url - .split_once(',') - .ok_or("Invalid embedded background.")?; - if data.len() > MAX_IMAGE_BYTES * 4 / 3 + 4 { - return Err("Embedded background exceeds 8 MiB.".into()); - } - let bytes = STANDARD - .decode(data) - .map_err(|_| "Invalid background encoding.")?; - let parsed = Self::import(self.name.clone(), &bytes)?; - if parsed.data_url.split_once(',').map(|p| p.0) != Some(header) - || parsed.width != self.width - || parsed.height != self.height - { - return Err("Background type or dimensions do not match its image data.".into()); - } - Ok(()) - } -} +//! The editor and project-sharing server use the same draft contract/loader. +pub use pace_editor_format::*; diff --git a/app/src/sharing/http.js b/app/src/sharing/http.js new file mode 100644 index 0000000..88a472f --- /dev/null +++ b/app/src/sharing/http.js @@ -0,0 +1,34 @@ +// Explicit, bounded requests. Credentials live only in component/request memory. +export async function projectRequest(method, url, token, etag, body, limit) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + try { + const headers = { Authorization: `Bearer ${token}` }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (etag !== undefined) headers['If-Match'] = etag; + const response = await fetch(url, { + method, headers, body, signal: controller.signal, + credentials: 'omit', redirect: 'error', cache: 'no-store', + referrerPolicy: 'no-referrer', mode: 'cors', + }); + if (Number(response.headers.get('Content-Length')) > limit) throw new Error('too_large'); + const reader = response.body?.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + const parts = []; + let size = 0; + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) throw new Error('too_large'); + parts.push(decoder.decode(value, { stream: true })); + } + parts.push(decoder.decode()); + } + return { status: response.status, etag: response.headers.get('ETag'), text: parts.join('') }; + } finally { + clearTimeout(timeout); + controller.abort(); + } +} diff --git a/app/src/sharing/mod.rs b/app/src/sharing/mod.rs new file mode 100644 index 0000000..461388b --- /dev/null +++ b/app/src/sharing/mod.rs @@ -0,0 +1,329 @@ +//! Snapshot sharing of the existing editor format; no live edits or automatic retries. +mod ui; +pub use ui::SharingPanel; + +use crate::{editor::Editor, model::Project}; +use serde::Deserialize; + +#[derive(Clone, Debug, PartialEq)] +pub struct RemoteSnapshot { + pub server: String, + pub id: String, + pub etag: String, + pub project: Project, +} + +#[derive(Clone, Default)] +pub struct SharingSession { + linked: Option<(usize, RemoteSnapshot)>, + staged: Option<(usize, RemoteSnapshot)>, +} + +impl SharingSession { + pub fn linked(&self, editor: &Editor) -> Option<&RemoteSnapshot> { + self.linked + .as_ref() + .filter(|(id, _)| *id == editor.document_id) + .map(|(_, v)| v) + } + pub fn staged(&self, editor: &Editor) -> Option<&RemoteSnapshot> { + self.staged + .as_ref() + .filter(|(id, _)| *id == editor.document_id) + .map(|(_, v)| v) + } + pub fn uploaded( + &mut self, + document_id: usize, + editor: &Editor, + snapshot: RemoteSnapshot, + ) -> bool { + if document_id != editor.document_id { + return false; + } + self.linked = Some((document_id, snapshot)); + self.staged = None; + true + } + pub fn downloaded( + &mut self, + document_id: usize, + editor: &Editor, + snapshot: RemoteSnapshot, + ) -> bool { + if document_id != editor.document_id { + return false; + } + self.staged = Some((document_id, snapshot)); + true + } + /// Downloading never changes the open document or its upload precondition. + pub fn open_downloaded(&mut self, editor: &mut Editor) -> Result<(), &'static str> { + let snapshot = self + .staged(editor) + .cloned() + .ok_or("Download the project first")?; + *editor = Editor::new(snapshot.project.clone()); + editor.dirty = true; // Still needs a local file/recovery save. + self.linked = Some((editor.document_id, snapshot)); + self.staged = None; + Ok(()) + } + pub fn can_keep_local(&self, editor: &Editor) -> bool { + matches!((self.linked(editor), self.staged(editor)), (Some(a), Some(b)) if a.server == b.server && a.id == b.id) + } + /// Only an explicit reconciliation decision advances the revision for local edits. + pub fn keep_local(&mut self, editor: &Editor) -> Result<(), &'static str> { + if !self.can_keep_local(editor) { + return Err("Download the linked project's latest revision first"); + } + self.linked = self.staged.take(); + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShareError { + Conflict(i64), + Message(String), +} + +impl std::fmt::Display for ShareError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Conflict(revision) => write!(f, "Conflict: the server has revision {revision}. Your edits are safe. Download the latest project, then choose which version to keep."), + Self::Message(message) => f.write_str(message), + } + } +} + +fn message(text: &str) -> ShareError { + ShareError::Message(text.into()) +} + +pub fn server_url(value: &str) -> Result { + let mut url = + url::Url::parse(value.trim()).map_err(|_| message("Enter an absolute server URL"))?; + let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if (url.scheme() != "https" && !(url.scheme() == "http" && loopback)) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(message( + "Use HTTPS (HTTP is allowed on localhost), without credentials, query or fragment", + )); + } + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + Ok(url.to_string()) +} + +fn valid_id(id: &str) -> bool { + id.len() == 36 + && id.bytes().enumerate().all(|(i, b)| { + if [8, 13, 18, 23].contains(&i) { + b == b'-' + } else { + b.is_ascii_hexdigit() + } + }) +} + +fn revision(etag: Option) -> Result { + let etag = etag.ok_or_else(|| { + message("Server did not expose its revision ETag; check the server's allowed origins") + })?; + let number = etag + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .and_then(|s| s.parse::().ok()); + if !number.is_some_and(|n| n > 0 && etag == format!("\"{n}\"")) { + return Err(message("Server returned an invalid revision ETag")); + } + Ok(etag) +} + +#[derive(Deserialize)] +struct HttpResponse { + status: u16, + etag: Option, + text: String, +} + +impl HttpResponse { + fn expect(self, expected: u16) -> Result { + if self.status == expected { + return Ok(self); + } + if self.status == 412 { + let error: serde_json::Value = serde_json::from_str(&self.text).unwrap_or_default(); + if let Some(revision) = error["current_revision"].as_i64().filter(|n| *n > 0) { + return Err(ShareError::Conflict(revision)); + } + } + Err(message(match self.status { + 401 => "Authentication failed. Check your bearer token.", + 403 => "You have read-only access to this project.", + 404 => "Project not found or access has not been granted.", + 413 => "Project exceeds the server's upload limit (64 MiB on the current server).", + 400 | 415 => "The server rejected the project or request. Check its format and server version.", + 412 | 428 => "The project revision is missing or outdated. Download the latest project before uploading.", + _ => "The server could not complete the request. Keep your edits and check the remote project before retrying.", + })) + } +} + +pub struct RemoteClient { + server: String, + token: String, +} + +impl RemoteClient { + pub fn new(server: &str, token: &str) -> Result { + if token.is_empty() + || token.len() > 512 + || !token + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-._~+/=".contains(&b)) + { + return Err(message("Enter a valid bearer token")); + } + Ok(Self { + server: server_url(server)?, + token: token.to_owned(), + }) + } + fn project_path(&self, id: &str) -> Result { + if !valid_id(id) { + return Err(message("Enter the shared project's UUID")); + } + Ok(format!("v1/projects/{id}")) + } + async fn request( + &self, + method: &str, + path: &str, + etag: Option<&str>, + body: Option<&str>, + ) -> Result { + let url = format!("{}{path}", self.server); + #[cfg(target_arch = "wasm32")] + { + use wasm_bindgen::prelude::*; + #[wasm_bindgen(module = "/src/sharing/http.js")] + extern "C" { + #[wasm_bindgen(catch, js_name = projectRequest)] + async fn request( + method: &str, + url: &str, + token: &str, + etag: Option, + body: Option, + limit: usize, + ) -> Result; + } + let value = request(method, &url, &self.token, etag.map(str::to_owned), body.map(str::to_owned), crate::model::MAX_PROJECT_BYTES).await + .map_err(|_| message("Request failed or timed out. Check the connection and allowed origins. An upload may have reached the server; download before retrying."))?; + serde_wasm_bindgen::from_value(value).map_err(|_| message("Invalid server response")) + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (method, url, &self.token, etag, body); + Err(message("Project sharing requires the web platform")) + } + } + pub async fn create(&self, project: Project) -> Result { + let body = project.to_json().map_err(ShareError::Message)?; + let response = self + .request("POST", "v1/projects", None, Some(&body)) + .await? + .expect(201)?; + #[derive(Deserialize)] + struct Created { + id: String, + revision: i64, + } + let created: Created = + serde_json::from_str(&response.text).map_err(|_| message("Invalid create response"))?; + let etag = revision(response.etag)?; + if !valid_id(&created.id) || etag != format!("\"{}\"", created.revision) { + return Err(message("Invalid create revision")); + } + Ok(RemoteSnapshot { + server: self.server.clone(), + id: created.id, + etag, + project, + }) + } + pub async fn upload( + &self, + previous: &RemoteSnapshot, + project: Project, + ) -> Result { + if self.server != previous.server { + return Err(message("Download the project from this server first")); + } + let body = project.to_json().map_err(ShareError::Message)?; + let response = self + .request( + "PUT", + &self.project_path(&previous.id)?, + Some(&previous.etag), + Some(&body), + ) + .await? + .expect(204)?; + Ok(RemoteSnapshot { + etag: revision(response.etag)?, + project, + ..previous.clone() + }) + } + pub async fn download(&self, id: &str) -> Result { + let response = self + .request("GET", &self.project_path(id)?, None, None) + .await? + .expect(200)?; + let etag = revision(response.etag)?; + let project = Project::from_json(&response.text).map_err(|_| message("Downloaded data is not a supported editor draft. Your open project is unchanged; core scenes use the core loader."))?; + Ok(RemoteSnapshot { + server: self.server.clone(), + id: id.to_owned(), + etag, + project, + }) + } + pub async fn set_access( + &self, + id: &str, + principal: &str, + role: Option<&str>, + ) -> Result<(), ShareError> { + if principal.is_empty() + || principal.len() > 128 + || !principal + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.@".contains(&b)) + { + return Err(message("Enter a valid member ID")); + } + if role.is_some_and(|r| !matches!(r, "reader" | "writer")) { + return Err(message("Choose reader or writer access")); + } + let path = format!("{}/members/{principal}", self.project_path(id)?); + let body = role.map(|role| serde_json::json!({"role":role}).to_string()); + self.request( + if role.is_some() { "PUT" } else { "DELETE" }, + &path, + None, + body.as_deref(), + ) + .await? + .expect(204)?; + Ok(()) + } +} diff --git a/app/src/sharing/ui.rs b/app/src/sharing/ui.rs new file mode 100644 index 0000000..5058990 --- /dev/null +++ b/app/src/sharing/ui.rs @@ -0,0 +1,153 @@ +use super::{server_url, RemoteClient, SharingSession}; +use crate::{browser, editor::Editor}; +use dioxus::prelude::*; + +#[component] +pub fn SharingPanel(mut editor: Signal) -> Element { + let mut server = use_signal(|| "http://127.0.0.1:3000".to_owned()); + let mut token = use_signal(String::new); + let mut project_id = use_signal(String::new); + let mut member = use_signal(String::new); + let mut role = use_signal(|| "reader".to_owned()); + let mut session = use_signal(SharingSession::default); + let mut busy = use_signal(|| false); + let mut status = use_signal(String::new); + let linked = session + .read() + .linked(&editor.read()) + .cloned() + .filter(|remote| { + server_url(&server.read()).as_ref() == Ok(&remote.server) + && *project_id.read() == remote.id + }); + let staged = session + .read() + .staged(&editor.read()) + .cloned() + .filter(|remote| { + server_url(&server.read()).as_ref() == Ok(&remote.server) + && *project_id.read() == remote.id + }); + let can_upload = linked.is_some(); + let can_keep = linked.is_some() && session.read().can_keep_local(&editor.read()); + rsx! { + details { class: "sharing-panel", + summary { "Project sharing" } + p { "Share complete project snapshots. Changes are sent only when you upload; other clients download them explicitly." } + div { class: "sharing-fields", + label { "Server URL" input { value: "{server}", disabled: busy(), oninput: move |e| server.set(e.value()) } } + label { "Bearer token" input { r#type: "password", autocomplete: "off", value: "{token}", disabled: busy(), oninput: move |e| token.set(e.value()) } } + label { "Shared project ID" input { value: "{project_id}", disabled: busy(), oninput: move |e| project_id.set(e.value()) } } + } + div { class: "inline-actions", + button { disabled: busy(), onclick: move |_| async move { + let client = match RemoteClient::new(&server.read(), &token.read()) { Ok(c) => c, Err(e) => { status.set(e.to_string()); return; } }; + let document_id = editor.read().document_id; + let project = editor.read().project.clone(); + busy.set(true); + status.set("Uploading a new project…".into()); + let result = client.create(project).await; + busy.set(false); + match result { + Ok(snapshot) => { + let id = snapshot.id.clone(); + if session.write().uploaded(document_id, &editor.read(), snapshot) { + project_id.set(id); + status.set("Project uploaded. Grant access to a member, then share the project ID.".into()); + } else { status.set(format!("Uploaded project {id}. Your open document changed during the request and was not linked.")); } + } + Err(e) => status.set(e.to_string()), + } + }, "Upload as new project" } + button { disabled: busy() || !can_upload, onclick: move |_| async move { + let client = match RemoteClient::new(&server.read(), &token.read()) { Ok(c) => c, Err(e) => { status.set(e.to_string()); return; } }; + let Some(previous) = session.read().linked(&editor.read()).cloned() else { return; }; + let document_id = editor.read().document_id; + let project = editor.read().project.clone(); + busy.set(true); + status.set("Uploading changes…".into()); + let result = client.upload(&previous, project).await; + busy.set(false); + match result { + Ok(snapshot) => { + if session.write().uploaded(document_id, &editor.read(), snapshot) { + status.set("Changes uploaded. Edits made while uploading remain open; save a local file to keep a copy.".into()); + } else { status.set("Upload completed for the previous document; your open document is unchanged.".into()); } + } + Err(e) => status.set(e.to_string()), + } + }, "Upload changes" } + button { disabled: busy(), onclick: move |_| async move { + let client = match RemoteClient::new(&server.read(), &token.read()) { Ok(c) => c, Err(e) => { status.set(e.to_string()); return; } }; + let id = project_id.read().clone(); + let document_id = editor.read().document_id; + session.write().staged = None; + busy.set(true); + status.set("Downloading project…".into()); + let result = client.download(&id).await; + busy.set(false); + match result { + Ok(snapshot) => { + if session.write().downloaded(document_id, &editor.read(), snapshot) { + status.set("Download ready. Your open project is unchanged. Choose whether to open it or keep your local edits.".into()); + } else { status.set("Download discarded because a different document was opened. Download again to continue.".into()); } + } + Err(e) => status.set(e.to_string()), + } + }, "Download project" } + button { disabled: busy(), onclick: move |_| { token.set(String::new()); status.set("Token cleared from this editor session.".into()); }, "Clear token" } + } + if let Some(remote) = linked { + p { class: "muted", "Linked revision {remote.etag}. " + if remote.project == editor.read().project { "The open project matches this snapshot." } + else { "You have local edits to upload." } + } + } + if let Some(remote) = staged { + div { class: "sharing-download", + p { "Downloaded “{remote.project.title}” · revision {remote.etag}" } + div { class: "inline-actions", + button { disabled: busy(), onclick: move |_| { + if editor.read().dirty && !browser::confirm("Replace your open project with the downloaded snapshot? Save a local file first to keep your edits.") { return; } + match session.write().open_downloaded(&mut editor.write()) { + Ok(()) => status.set("Downloaded project opened. Save a project file to keep a local copy.".into()), + Err(e) => status.set(e.into()), + } + }, "Open downloaded project" } + button { disabled: busy() || !can_keep, onclick: move |_| { + if !browser::confirm("Keep your local edits against this downloaded revision? Your next upload will replace that remote snapshot.") { return; } + match session.write().keep_local(&editor.read()) { + Ok(()) => status.set("Local edits kept against the downloaded revision. Review them, then choose Upload changes.".into()), + Err(e) => status.set(e.into()), + } + }, "Keep local edits against downloaded revision" } + } + } + } + div { class: "sharing-fields", + label { "Member ID" input { value: "{member}", disabled: busy(), oninput: move |e| member.set(e.value()) } } + label { "Member access" select { "aria-label": "Member access", value: "{role}", disabled: busy(), onchange: move |e| role.set(e.value()), option { value: "reader", "Reader" } option { value: "writer", "Writer" } } } + div { class: "inline-actions", + button { disabled: busy(), onclick: move |_| async move { + let client = match RemoteClient::new(&server.read(), &token.read()) { Ok(c) => c, Err(e) => { status.set(e.to_string()); return; } }; + let (id, member, role) = (project_id.read().clone(), member.read().clone(), role.read().clone()); + busy.set(true); + let result = client.set_access(&id, &member, Some(&role)).await; + busy.set(false); + status.set(match result { Ok(()) => format!("Granted {role} access to {member}."), Err(e) => e.to_string() }); + }, "Grant access" } + button { disabled: busy(), onclick: move |_| async move { + let client = match RemoteClient::new(&server.read(), &token.read()) { Ok(c) => c, Err(e) => { status.set(e.to_string()); return; } }; + let (id, member) = (project_id.read().clone(), member.read().clone()); + busy.set(true); + let result = client.set_access(&id, &member, None).await; + busy.set(false); + status.set(match result { Ok(()) => format!("Revoked access for {member}."), Err(e) => e.to_string() }); + }, "Revoke access" } + } + } + p { class: "muted", "Only the owner can change access. Tokens are held in memory and cleared when you reload. Sharing does not save your local file." } + p { role: "status", "data-testid": "sharing-status", "{status}" } + } + } +} diff --git a/app/src/ui.rs b/app/src/ui.rs index 0fd8583..df8869e 100644 --- a/app/src/ui.rs +++ b/app/src/ui.rs @@ -58,7 +58,8 @@ pub fn App() -> Element { } }, "Save project" } } - div { class: "dependency-banner", "Draft format v1 · PACE core integration pending. Your project files include all imported images." } + div { class: "dependency-banner", "Editor draft v1 · Your project files include all imported images." } + crate::sharing::SharingPanel { editor } div { class: "workspace", aside { class: "sidebar", button { class: "project-link", onclick: move |_| editor.write().selection = Selection::Project, "Project settings" } @@ -90,7 +91,7 @@ pub fn App() -> Element { button { disabled: has_issues, onclick: move |_| { match crate::rehearsal::Rehearsal::new(&editor.read().project) { Ok(state) => session.set(Some(state)), Err(e) => notice.set(e) } }, "Rehearse draft" } - button { disabled: true, title: "No PACE core runtime is available at the inspected revision.", "Core preview" } + button { disabled: true, title: "Draft conversion and a browser build of the PACE core runtime are not integrated yet.", "Core preview" } button { disabled: true, title: "Game export requires a pinned PACE core runtime.", "Export game" } } if matches!(editor.read().selection, Selection::Item(_) | Selection::Dialogue(_)) { crate::story::StoryWorkspace { editor } } else { diff --git a/app/tests/sharing.rs b/app/tests/sharing.rs new file mode 100644 index 0000000..6e6f776 --- /dev/null +++ b/app/tests/sharing.rs @@ -0,0 +1,115 @@ +use app::{ + editor::Editor, + model::Project, + sharing::{server_url, RemoteSnapshot, SharingSession}, +}; + +fn snapshot(title: &str, etag: &str) -> RemoteSnapshot { + let mut project = Project::default(); + project.title = title.into(); + RemoteSnapshot { + server: "https://projects.example/".into(), + id: "12345678-1234-1234-1234-123456789abc".into(), + etag: etag.into(), + project, + } +} + +#[test] +fn download_requires_an_explicit_choice_and_preserves_local_edits() { + let mut editor = Editor::default(); + let mut session = SharingSession::default(); + session.uploaded(editor.document_id, &editor, snapshot("Original", "\"1\"")); + editor.edit(|p| p.title = "Local edits".into()); + session.downloaded( + editor.document_id, + &editor, + snapshot("Someone else's edits", "\"2\""), + ); + assert_eq!(editor.project.title, "Local edits"); + assert_eq!(session.linked(&editor).unwrap().etag, "\"1\""); + session.keep_local(&editor).unwrap(); + assert_eq!(editor.project.title, "Local edits"); + assert!(editor.dirty); + assert_eq!(session.linked(&editor).unwrap().etag, "\"2\""); + editor.undo(); + assert_eq!(editor.project.title, "Untitled adventure"); +} + +#[test] +fn opening_downloaded_snapshot_links_its_revision_without_changing_the_format() { + let project = + Project::from_json(include_str!("../../fixtures/three-rooms.pace-draft.json")).unwrap(); + let remote = RemoteSnapshot { + project: project.clone(), + ..snapshot("", "\"7\"") + }; + let mut editor = Editor::default(); + let mut session = SharingSession::default(); + session.downloaded(editor.document_id, &editor, remote); + session.open_downloaded(&mut editor).unwrap(); + assert_eq!(editor.project, project); + assert_eq!(session.linked(&editor).unwrap().etag, "\"7\""); + assert!(editor.dirty); + assert!(!editor.can_undo()); +} + +#[test] +fn late_results_cannot_link_or_replace_a_different_document() { + let original = Editor::default(); + let mut current = Editor::default(); + let mut session = SharingSession::default(); + assert!(!session.uploaded( + original.document_id, + ¤t, + snapshot("Late upload", "\"1\"") + )); + assert!(!session.downloaded( + original.document_id, + ¤t, + snapshot("Late download", "\"2\"") + )); + assert!(session.linked(¤t).is_none()); + assert!(session.open_downloaded(&mut current).is_err()); + session.uploaded(current.document_id, ¤t, snapshot("Uploaded", "\"1\"")); + let replacement = Editor::new(current.project.clone()); + assert!(session.linked(&replacement).is_none()); +} + +#[test] +fn edits_during_upload_stay_dirty_and_unrelated_revisions_cannot_be_adopted() { + let mut editor = Editor::default(); + let document_id = editor.document_id; + let sent = snapshot("Sent", "\"2\""); + editor.edit(|p| p.title = "Edited during upload".into()); + let mut session = SharingSession::default(); + assert!(session.uploaded(document_id, &editor, sent)); + assert_ne!(session.linked(&editor).unwrap().project, editor.project); + assert!(editor.dirty); + let other = RemoteSnapshot { + server: "https://other.example/".into(), + ..snapshot("Other", "\"10\"") + }; + session.downloaded(document_id, &editor, other); + assert!(session.keep_local(&editor).is_err()); + assert_eq!(session.linked(&editor).unwrap().etag, "\"2\""); +} + +#[test] +fn server_urls_require_tls_except_loopback_and_never_accept_embedded_credentials() { + assert_eq!( + server_url("https://projects.example/pace").unwrap(), + "https://projects.example/pace/" + ); + assert!(server_url("http://127.0.0.1:3000").is_ok()); + assert!(server_url("http://[::1]:3000").is_ok()); + for url in [ + "http://projects.example", + "file:///tmp/project", + "https://user:secret@example.com", + "https://example.com?token=x", + "https://example.com#token", + ] { + assert!(server_url(url).is_err(), "{url}"); + } +} diff --git a/docs/core-integration.md b/docs/core-integration.md index 457ce45..6729056 100644 --- a/docs/core-integration.md +++ b/docs/core-integration.md @@ -1,9 +1,11 @@ # Core integration boundary -Do not reinterpret the draft schema as an upstream PACE format. The dependency -must supply a versioned project contract, validation diagnostics with object IDs, -an asset-loading contract, and a runnable web target before runtime integration -can be completed. +Do not reinterpret the draft schema as an upstream PACE format. Core at +`1aa2b36270b68ea6fc04c268c3d5661ec09790aa` now supplies a versioned scene contract, +validation with JSON paths and a native runtime. Core v1 has no asset-loading +contract for the editor's embedded backgrounds. Draft conversion and a runnable +web target still need integration before runtime preview/export can be enabled. +Project sharing reuses the unchanged draft loader; it does not perform conversion. 1. Set `runtime.lock.json.runtime_revision` to the actual immutable core commit. 2. Add the core crate using `git` + `rev` (and commit Cargo.lock), or pin its WASM diff --git a/docs/project-sharing.md b/docs/project-sharing.md new file mode 100644 index 0000000..d401a26 --- /dev/null +++ b/docs/project-sharing.md @@ -0,0 +1,92 @@ +# Sharing project snapshots + +Keep using **Save project** and **Open project** for portable local files. +Sharing sends the same `pace-editor-draft` v1 JSON, including embedded backgrounds, +object IDs, dialogue, inventory and unfinished references. It never adds credentials, +server IDs or revisions to that document. The server stores the original upload +bytes. A later editor save uses its normal pretty JSON serializer. + +## Server setup + +Use a `pace_server` build with scene/draft validation and browser-origin support +([server PR #3](https://github.com/pace-kit/pace_server/pull/3), revision +`66203da9bfeda679dac0669fe4eae7ced9d9e59e` or a later compatible release). +See its README for operator token provisioning and persistent SQLite storage. +Each person receives a token mapped to a stable member ID such as `alice` or `bob`. +Run the server with the editor's exact browser origin in `PACE_ALLOWED_ORIGINS`: + +```sh +PACE_AUTH_FILE=./auth.json PACE_DATABASE=./projects.sqlite3 \ +PACE_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080 cargo run --locked +``` + +In **Project sharing**, enter the server URL and your bearer token. HTTPS is +required except on loopback (`localhost`, `127.0.0.1`, `[::1]`). A reverse proxy +may serve the API under a URL prefix. There are no wildcard origins or cookies; +project requests use bearer authentication. Redirects are refused so credentials +cannot follow a redirected request. Tokens are kept in component memory only, +with **Clear token** and reload clearing the input. Do not paste a token into +the project itself. No server login/account creation is supplied by this editor. + +## Two clients + +1. Alice opens or authors a draft and chooses **Upload as new project**. Copy the + resulting **Shared project ID**. +2. Alice enters Bob's member ID, selects Reader or Writer, and chooses **Grant + access**. Give Bob the project ID and server URL through your usual channel. +3. Bob enters his own token, the same server URL and project ID, then chooses + **Download project**. The current local document remains open. Choose **Open + downloaded project** to replace it, with confirmation if there are unsaved edits. +4. Bob edits locally and chooses **Upload changes**. This sends `If-Match` with + the revision of the snapshot he opened. Alice can explicitly download his update. +5. Use **Save project** on each client for a local backup. Uploading does not clear + the local unsaved-file indicator. Only the owner can grant/revoke access; a writer + cannot change permissions. Reader uploads are denied. + +## Conflicts and failed requests + +If both clients edit revision 1, the first successful upload creates revision 2. +The other's upload returns HTTP `412` and a conflict message. Its edits, undo +history and upload precondition remain intact; there is no automatic retry. +**Download project** stages the latest snapshot without advancing the revision +attached to local edits. Then explicitly choose one of: + +- **Open downloaded project**: use the downloaded copy after saving any local work. +- **Keep local edits against downloaded revision**: after reviewing/reconciling, + confirm that the next upload should replace that downloaded snapshot. This does + not upload automatically. Another intervening server update will still conflict. + +Network errors/timeouts leave the outcome of an upload uncertain. Keep your local +file and inspect/download the remote snapshot before retrying. Requests time out +after 30 seconds, with a 64 MiB response/upload limit. Missing/invalid tokens, +denied access, unsupported drafts and malformed responses leave the open document +unchanged. Opening a different document while a request is in flight prevents the +late result from attaching its revision to the new document. Edits made during +an upload remain open and can be uploaded explicitly afterward. + +Connection details, linked revision and staged downloads are session state. +After reload or opening another local file, download/open the remote project to +establish its revision again. Keep a local copy before switching documents. + +## Scope and verification + +This is **project sharing, not real-time co-editing**. There is no live presence, +operation stream, background polling, automatic merge or last-writer-wins update. +The server also supports core scene v1 through its HTTP API; this editor continues +to open its own draft format. Sharing does not convert either format or enable +core preview/export. + +The Playwright suite starts a real `pace_server` process with a temporary database +and test-only identities. The server revision used in CI is pinned in +`.github/workflows/editor.yml`. Locally, build the server with Rust 1.97.1+: + +```sh +cargo +stable build --locked --manifest-path ../pace_server/Cargo.toml +NO_COLOR=true trunk build --locked +PACE_SERVER_BIN=../pace_server/target/debug/pace_server npm run test:e2e +``` + +It tests two independent browser contexts, full draft/file equality including +images, stale writes, explicit reconciliation, invalid tokens, access revocation, +malformed downloads and late responses. Existing local authoring/rehearsal checks +continue to run. Native tests cover the session transitions independently of UI. diff --git a/playwright.config.ts b/playwright.config.ts index 4806be4..9cec549 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,5 +11,8 @@ export default defineConfig({ trace: 'retain-on-failure', screenshot: 'only-on-failure', }, - webServer: { command: 'python3 -m http.server 8080 --bind 127.0.0.1 --directory dist', url: 'http://127.0.0.1:8080', reuseExistingServer: !process.env.CI }, + webServer: [ + { command: 'python3 -m http.server 8080 --bind 127.0.0.1 --directory dist', url: 'http://127.0.0.1:8080', reuseExistingServer: !process.env.CI }, + { command: 'node scripts/sharing-server.mjs', url: 'http://127.0.0.1:3100/health', reuseExistingServer: false }, + ], }); diff --git a/project_format/Cargo.toml b/project_format/Cargo.toml new file mode 100644 index 0000000..047594c --- /dev/null +++ b/project_format/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pace_editor_format" +version = "0.1.0" +edition = "2021" +rust-version = "1.90" + +[dependencies] +serde = { version = "1", features = ["derive", "rc"] } +serde_json = "1" +base64 = "0.22" +imagesize = "0.13" diff --git a/project_format/src/lib.rs b/project_format/src/lib.rs new file mode 100644 index 0000000..42b1b97 --- /dev/null +++ b/project_format/src/lib.rs @@ -0,0 +1,476 @@ +//! Editor-owned draft contract. This is deliberately NOT named the PACE core +//! runtime scene format. Embedded backgrounds and editable drafts are preserved. +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, sync::Arc}; + +pub const FORMAT: &str = "pace-editor-draft"; +pub const VERSION: u32 = 1; +pub const MAX_IMAGE_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_PROJECT_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Project { + pub format: String, + pub version: u32, + pub title: String, + pub start_room: Option, + pub rooms: Vec, + pub items: Vec, + pub dialogues: Vec, + next_id: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Room { + pub id: String, + pub name: String, + pub background: Option, + pub hotspots: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Background { + pub name: String, + pub data_url: Arc, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl Rect { + /// Coordinates are normalized to the original image, independent of zoom. + pub fn from_drag(start: (f64, f64), end: (f64, f64)) -> Option { + let (ax, ay) = (start.0.clamp(0., 1.), start.1.clamp(0., 1.)); + let (bx, by) = (end.0.clamp(0., 1.), end.1.clamp(0., 1.)); + let rect = Self { + x: ax.min(bx), + y: ay.min(by), + width: (ax - bx).abs(), + height: (ay - by).abs(), + }; + (rect.valid() && rect.width >= 0.005 && rect.height >= 0.005).then_some(rect) + } + + pub fn valid(&self) -> bool { + [self.x, self.y, self.width, self.height] + .iter() + .all(|n| n.is_finite()) + && self.x >= 0. + && self.y >= 0. + && self.width > 0. + && self.height > 0. + && self.x + self.width <= 1.000001 + && self.y + self.height <= 1.000001 + } + + pub fn translated(self, dx: f64, dy: f64) -> Self { + Self { + x: (self.x + dx).clamp(0., (1. - self.width).max(0.)), + y: (self.y + dy).clamp(0., (1. - self.height).max(0.)), + ..self + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Hotspot { + pub id: String, + pub name: String, + pub rect: Rect, + pub action: Action, + pub requires_item: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Action { + Look { text: String }, + GoTo { room_id: String }, + Take { item_id: String }, + Talk { dialogue_id: String }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Item { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Dialogue { + pub id: String, + pub name: String, + pub speaker: String, + pub text: String, + pub choices: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Choice { + pub id: String, + pub text: String, + pub next: Option, + pub give_item: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Issue { + pub object_id: Option, + pub field: String, + pub message: String, +} + +impl Default for Project { + fn default() -> Self { + Self { + format: FORMAT.into(), + version: VERSION, + title: "Untitled adventure".into(), + start_room: None, + rooms: vec![], + items: vec![], + dialogues: vec![], + next_id: 1, + } + } +} + +impl Project { + pub fn to_json(&self) -> Result { + if self.rooms.iter().flat_map(|r| &r.hotspots).any(|h| { + [h.rect.x, h.rect.y, h.rect.width, h.rect.height] + .iter() + .any(|n| !n.is_finite()) + }) { + return Err( + "Cannot save a hotspot with non-finite coordinates. Correct its bounds first." + .into(), + ); + } + let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + if text.len() > MAX_PROJECT_BYTES { + return Err("Project exceeds the 64 MiB file limit. Reduce background sizes.".into()); + } + Ok(text) + } + + pub fn from_json(text: &str) -> Result { + if text.len() > MAX_PROJECT_BYTES { + return Err("Project exceeds the 64 MiB file limit.".into()); + } + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("Invalid project JSON: {e}"))?; + if value.get("format").and_then(|v| v.as_str()) != Some(FORMAT) { + return Err( + "This is not a PACE editor draft. Core-format import awaits the upstream contract." + .into(), + ); + } + if value.get("version").and_then(|v| v.as_u64()) != Some(VERSION as u64) { + return Err("Unsupported draft version. Open it with the editor that created it; no data was replaced.".into()); + } + // Strict decoding prevents silent loss of fields from a newer writer. + let project: Self = serde_json::from_str(text) + .map_err(|e| format!("Unsupported or malformed project: {e}"))?; + // Invalid references are editable, but ambiguous IDs cannot safely be edited. + let mut ids = HashSet::new(); + for id in project.ids() { + if id.trim().is_empty() || !ids.insert(id) { + return Err(format!( + "Empty or duplicate object ID: {id:?}. No data was replaced." + )); + } + } + Ok(project) + } + + fn ids(&self) -> Vec<&str> { + self.rooms + .iter() + .flat_map(|r| { + std::iter::once(r.id.as_str()).chain(r.hotspots.iter().map(|h| h.id.as_str())) + }) + .chain(self.items.iter().map(|i| i.id.as_str())) + .chain(self.dialogues.iter().flat_map(|d| { + std::iter::once(d.id.as_str()).chain(d.choices.iter().map(|c| c.id.as_str())) + })) + .collect() + } + + pub fn allocate_id(&mut self, kind: &str) -> String { + loop { + let id = format!("{kind}-{}", self.next_id); + self.next_id = self.next_id.wrapping_add(1); + if !self.ids().contains(&id.as_str()) { + return id; + } + } + } + + pub fn add_room(&mut self) -> String { + let id = self.allocate_id("room"); + self.rooms.push(Room { + id: id.clone(), + name: format!("Room {}", self.rooms.len() + 1), + background: None, + hotspots: vec![], + }); + if self.start_room.is_none() { + self.start_room = Some(id.clone()); + } + id + } + + pub fn room(&self, id: &str) -> Option<&Room> { + self.rooms.iter().find(|r| r.id == id) + } + pub fn room_mut(&mut self, id: &str) -> Option<&mut Room> { + self.rooms.iter_mut().find(|r| r.id == id) + } + + pub fn add_hotspot(&mut self, room_id: &str, rect: Rect) -> Option { + if !rect.valid() || self.room(room_id)?.background.is_none() { + return None; + } + let id = self.allocate_id("hotspot"); + let room = self.room_mut(room_id)?; + room.hotspots.push(Hotspot { + id: id.clone(), + name: format!("Hotspot {}", room.hotspots.len() + 1), + rect, + action: Action::Look { + text: "Something catches your eye.".into(), + }, + requires_item: None, + }); + Some(id) + } + + pub fn add_item(&mut self) -> String { + let id = self.allocate_id("item"); + self.items.push(Item { + id: id.clone(), + name: "New item".into(), + description: String::new(), + }); + id + } + + pub fn add_dialogue(&mut self) -> String { + let id = self.allocate_id("dialogue"); + self.dialogues.push(Dialogue { + id: id.clone(), + name: "New dialogue".into(), + speaker: "Narrator".into(), + text: "Hello, traveler.".into(), + choices: vec![], + }); + id + } + + pub fn validate(&self) -> Vec { + let mut issues = vec![]; + let mut push = |id: Option<&str>, field: &str, message: String| { + issues.push(Issue { + object_id: id.map(str::to_owned), + field: field.into(), + message, + }) + }; + if self.title.trim().is_empty() { + push(None, "title", "Give the project a title.".into()); + } + if self + .start_room + .as_deref() + .is_none_or(|id| self.room(id).is_none()) + { + push( + None, + "start_room", + "Choose an existing starting room.".into(), + ); + } + let has_item = |id: &str| self.items.iter().any(|i| i.id == id); + let has_dialogue = |id: &str| self.dialogues.iter().any(|d| d.id == id); + for r in &self.rooms { + if r.name.trim().is_empty() { + push(Some(&r.id), "name", "Room name is empty.".into()); + } + match &r.background { + None => push( + Some(&r.id), + "background", + "Import a background for this room.".into(), + ), + Some(b) => { + if let Err(e) = b.validate() { + push(Some(&r.id), "background", e); + } + } + } + for h in &r.hotspots { + if h.name.trim().is_empty() { + push(Some(&h.id), "name", "Hotspot name is empty.".into()); + } + if !h.rect.valid() { + push( + Some(&h.id), + "rect", + "Hotspot must have a positive size and fit inside the background.".into(), + ); + } + if let Some(id) = &h.requires_item { + if !has_item(id) { + push( + Some(&h.id), + "requires_item", + format!("Required item {id:?} does not exist."), + ); + } + } + match &h.action { + Action::GoTo { room_id } if self.room(room_id).is_none() => push( + Some(&h.id), + "action.room_id", + format!("Linked room {room_id:?} does not exist."), + ), + Action::Take { item_id } if !has_item(item_id) => push( + Some(&h.id), + "action.item_id", + format!("Item {item_id:?} does not exist."), + ), + Action::Talk { dialogue_id } if !has_dialogue(dialogue_id) => push( + Some(&h.id), + "action.dialogue_id", + format!("Dialogue {dialogue_id:?} does not exist."), + ), + Action::Look { text } if text.trim().is_empty() => { + push(Some(&h.id), "action.text", "Enter inspection text.".into()) + } + _ => {} + } + } + } + for item in &self.items { + if item.name.trim().is_empty() { + push(Some(&item.id), "name", "Item name is empty.".into()); + } + } + for d in &self.dialogues { + if d.name.trim().is_empty() { + push(Some(&d.id), "name", "Dialogue name is empty.".into()); + } + if d.text.trim().is_empty() { + push(Some(&d.id), "text", "Enter dialogue text.".into()); + } + for c in &d.choices { + if c.text.trim().is_empty() { + push(Some(&c.id), "text", "Enter choice text.".into()); + } + if let Some(id) = &c.next { + if !has_dialogue(id) { + push( + Some(&c.id), + "next", + format!("Next dialogue {id:?} does not exist."), + ); + } + } + if let Some(id) = &c.give_item { + if !has_item(id) { + push( + Some(&c.id), + "give_item", + format!("Reward item {id:?} does not exist."), + ); + } + } + } + } + issues + } +} + +impl Background { + /// Only embedded raster data is rendered; malformed imports cannot load a + /// remote URL or an active document through the image element. + pub fn display_url(&self) -> &str { + if [ + "data:image/png;base64,", + "data:image/jpeg;base64,", + "data:image/webp;base64,", + ] + .iter() + .any(|prefix| self.data_url.starts_with(prefix)) + { + &self.data_url + } else { + "" + } + } + + pub fn import(name: String, bytes: &[u8]) -> Result { + if bytes.len() > MAX_IMAGE_BYTES { + return Err("Background exceeds 8 MiB. Resize it before importing.".into()); + } + let mime = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + "image/jpeg" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else { + return Err("Use a PNG, JPEG, or WebP image.".into()); + }; + let size = imagesize::blob_size(bytes) + .map_err(|_| "Cannot read this image. It may be corrupt.".to_string())?; + if size.width == 0 || size.height == 0 || size.width > 16384 || size.height > 16384 { + return Err("Image dimensions must be between 1 and 16384 pixels.".into()); + } + Ok(Self { + name, + data_url: format!("data:{mime};base64,{}", STANDARD.encode(bytes)).into(), + width: size.width as u32, + height: size.height as u32, + }) + } + + fn validate(&self) -> Result<(), String> { + let (header, data) = self + .data_url + .split_once(',') + .ok_or("Invalid embedded background.")?; + if data.len() > MAX_IMAGE_BYTES * 4 / 3 + 4 { + return Err("Embedded background exceeds 8 MiB.".into()); + } + let bytes = STANDARD + .decode(data) + .map_err(|_| "Invalid background encoding.")?; + let parsed = Self::import(self.name.clone(), &bytes)?; + if parsed.data_url.split_once(',').map(|p| p.0) != Some(header) + || parsed.width != self.width + || parsed.height != self.height + { + return Err("Background type or dimensions do not match its image data.".into()); + } + Ok(()) + } +} diff --git a/runtime.lock.json b/runtime.lock.json index d93276e..d97fba7 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,9 +1,9 @@ { "platform": "web-wasm", "core_repository": "https://github.com/pace-kit/pace_core", - "inspected_core_revision": "6ccfd6ea60534f3c3312882458e1c83308a76b81", + "inspected_core_revision": "1aa2b36270b68ea6fc04c268c3d5661ec09790aa", "runtime_revision": null, - "status": "blocked: upstream contains no format or runtime API", + "status": "core scene v1 and native runtime available; draft conversion and web runtime integration pending", "editor_format": "pace-editor-draft", "editor_format_version": 1 } diff --git a/scripts/sharing-server.mjs b/scripts/sharing-server.mjs new file mode 100644 index 0000000..08d135d --- /dev/null +++ b/scripts/sharing-server.mjs @@ -0,0 +1,21 @@ +// Real server process for browser integration tests; isolated credentials and DB. +import { spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; + +const binary = resolve(process.env.PACE_SERVER_BIN || '../pace_server/target/debug/pace_server'); +if (!existsSync(binary)) throw new Error('Build pace_server and set PACE_SERVER_BIN before running browser tests'); +const directory = mkdtempSync(join(tmpdir(), 'pace-sharing-e2e-')); +const tokens = ['alice', 'bob', 'eve'].map(principal => ({ + principal, sha256: createHash('sha256').update(`${principal}-browser-test-token-00000000000000000000`).digest('hex'), +})); +writeFileSync(join(directory, 'auth.json'), JSON.stringify({ tokens }), { mode: 0o600 }); +const child = spawn(binary, [], { stdio: 'inherit', env: { ...process.env, + PACE_BIND: '127.0.0.1:3100', PACE_DATABASE: join(directory, 'projects.sqlite3'), + PACE_AUTH_FILE: join(directory, 'auth.json'), PACE_ALLOWED_ORIGINS: 'http://127.0.0.1:8080', +} }); +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal)); +child.on('exit', code => { rmSync(directory, { recursive: true, force: true }); process.exit(code ?? 0); }); +child.on('error', error => { console.error(error.message); rmSync(directory, { recursive: true, force: true }); process.exit(1); }); diff --git a/tests/e2e/sharing.spec.ts b/tests/e2e/sharing.spec.ts new file mode 100644 index 0000000..a4561fb --- /dev/null +++ b/tests/e2e/sharing.spec.ts @@ -0,0 +1,132 @@ +import { test, expect, Page } from '@playwright/test'; +import fs from 'node:fs/promises'; + +const server = 'http://127.0.0.1:3100'; +const token = (name: string) => `${name}-browser-test-token-00000000000000000000`; +const status = (page: Page) => page.getByTestId('sharing-status'); + +async function connect(page: Page, name: string, id = '') { + await page.goto('/'); + await page.getByText('Project sharing', { exact: true }).click(); + await page.getByLabel('Server URL', { exact: true }).fill(server); + await page.getByLabel('Bearer token', { exact: true }).fill(token(name)); + await page.getByLabel('Shared project ID', { exact: true }).fill(id); + page.on('dialog', dialog => dialog.accept()); +} +async function download(page: Page) { + await page.getByRole('button', { name: 'Download project', exact: true }).click(); + await expect(status(page)).toContainText('Download ready'); +} +async function save(page: Page, path: string) { + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save project', exact: true }).click(); + await (await download).saveAs(path); + return fs.readFile(path, 'utf8'); +} + +test('two browser clients exchange the complete draft, resolve stale writes, and enforce access', async ({ browser, request }, info) => { + test.setTimeout(90_000); + const first = await browser.newContext(); + const second = await browser.newContext(); + const alice = await first.newPage(); + const bob = await second.newPage(); + const errors: string[] = []; + alice.on('pageerror', e => errors.push(e.message)); + bob.on('pageerror', e => errors.push(e.message)); + await connect(alice, 'alice'); + await alice.getByLabel('Open project', { exact: true }).setInputFiles('fixtures/three-rooms.pace-draft.json'); + await expect(alice.locator('.project-title')).toContainText('The Garden Key'); + await alice.getByRole('button', { name: 'Project settings', exact: true }).click(); + await expect(alice.getByLabel('Project title', { exact: true })).toHaveValue('The Garden Key'); + await alice.getByRole('button', { name: 'Upload as new project', exact: true }).click(); + await expect(status(alice)).toContainText('Project uploaded'); + const id = await alice.getByLabel('Shared project ID', { exact: true }).inputValue(); + expect(id).toMatch(/^[a-f0-9-]{36}$/); + await alice.getByLabel('Member ID', { exact: true }).fill('bob'); + await alice.getByLabel('Member access', { exact: true }).selectOption('writer'); + await alice.getByRole('button', { name: 'Grant access', exact: true }).click(); + await expect(status(alice)).toContainText('Granted writer access to bob'); + + await connect(bob, 'bob', id); + await download(bob); + await expect(bob.getByLabel('Project title', { exact: true })).toHaveValue('Untitled adventure'); + await bob.getByRole('button', { name: 'Open downloaded project', exact: true }).click(); + await bob.getByRole('button', { name: 'Project settings', exact: true }).click(); + const fixture = JSON.parse(await fs.readFile('fixtures/three-rooms.pace-draft.json', 'utf8')); + const saved = await save(bob, info.outputPath('downloaded.pace-draft.json')); + expect(JSON.parse(saved)).toEqual(fixture); + expect(saved).not.toContain(token('bob')); + expect(saved).not.toContain(id); + await expect(bob.getByText('Validation · 0', { exact: true })).toBeVisible(); + + await bob.getByLabel('Project title', { exact: true }).fill('Bob changed the title'); + await bob.getByRole('button', { name: 'Upload changes', exact: true }).click(); + await expect(status(bob)).toContainText('Changes uploaded'); + await alice.getByLabel('Project title', { exact: true }).fill('Alice kept her edits'); + await alice.getByRole('button', { name: 'Upload changes', exact: true }).click(); + await expect(status(alice)).toContainText('Conflict: the server has revision 2'); + await expect(alice.getByLabel('Project title', { exact: true })).toHaveValue('Alice kept her edits'); + const headers = { Authorization: `Bearer ${token('alice')}` }; + expect((await (await request.get(`${server}/v1/projects/${id}`, { headers })).json()).title).toBe('Bob changed the title'); + await download(alice); + await expect(alice.getByLabel('Project title', { exact: true })).toHaveValue('Alice kept her edits'); + await alice.getByRole('button', { name: 'Keep local edits against downloaded revision', exact: true }).click(); + await alice.getByRole('button', { name: 'Upload changes', exact: true }).click(); + await expect(status(alice)).toContainText('Changes uploaded'); + const latest = await request.get(`${server}/v1/projects/${id}`, { headers }); + expect(latest.headers().etag).toBe('"3"'); + expect(await latest.json()).toEqual({ ...fixture, title: 'Alice kept her edits' }); + + await download(bob); + await bob.getByRole('button', { name: 'Open downloaded project', exact: true }).click(); + await bob.getByRole('button', { name: 'Project settings', exact: true }).click(); + expect(JSON.parse(await save(bob, info.outputPath('after-conflict.pace-draft.json')))).toEqual({ ...fixture, title: 'Alice kept her edits' }); + await bob.getByLabel('Bearer token', { exact: true }).fill('invalid-token-00000000000000000000000000'); + await bob.getByRole('button', { name: 'Download project', exact: true }).click(); + await expect(status(bob)).toContainText('Authentication failed'); + await expect(bob.getByLabel('Project title', { exact: true })).toHaveValue('Alice kept her edits'); + await bob.getByLabel('Bearer token', { exact: true }).fill(token('eve')); + await bob.getByRole('button', { name: 'Download project', exact: true }).click(); + await expect(status(bob)).toContainText('Project not found or access has not been granted'); + await bob.getByLabel('Bearer token', { exact: true }).fill(token('bob')); + await alice.getByRole('button', { name: 'Revoke access', exact: true }).click(); + await expect(status(alice)).toContainText('Revoked access for bob'); + await bob.getByRole('button', { name: 'Download project', exact: true }).click(); + await expect(status(bob)).toContainText('Project not found or access has not been granted'); + const storage = await bob.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); + expect(storage).not.toContain(token('bob')); + await alice.screenshot({ path: info.outputPath('project-sharing.png'), fullPage: true }); + await bob.reload(); + await bob.getByText('Project sharing', { exact: true }).click(); + await expect(bob.getByLabel('Bearer token', { exact: true })).toHaveValue(''); + expect(errors).toEqual([]); + await first.close(); await second.close(); +}); + +test('failed and late downloads cannot replace local work', async ({ page, request }) => { + const fixture = JSON.parse(await fs.readFile('fixtures/three-rooms.pace-draft.json', 'utf8')); + const response = await request.post(`${server}/v1/projects`, { headers: { Authorization: `Bearer ${token('alice')}` }, data: fixture }); + expect(response.status()).toBe(201); + const id = (await response.json()).id; + await connect(page, 'alice', id); + await page.getByLabel('Project title', { exact: true }).fill('Keep my local work'); + const url = `${server}/v1/projects/${id}`; + await page.route(url, route => route.fulfill({ status: 200, headers: { + ETag: '"1"', 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': 'http://127.0.0.1:8080', 'Access-Control-Expose-Headers': 'ETag', + }, body: '{"version":999}' })); + await page.getByRole('button', { name: 'Download project', exact: true }).click(); + await expect(status(page)).toContainText('not a supported editor draft'); + await expect(page.getByLabel('Project title', { exact: true })).toHaveValue('Keep my local work'); + await page.unroute(url); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + await page.route(url, async route => { await gate; await route.continue(); }); + await page.getByRole('button', { name: 'Download project', exact: true }).click(); + await expect(status(page)).toContainText('Downloading project'); + await page.getByRole('button', { name: 'New project', exact: true }).click(); + release(); + await expect(status(page)).toContainText('different document was opened'); + await expect(page.getByLabel('Project title', { exact: true })).toHaveValue('Untitled adventure'); + await expect(page.getByRole('button', { name: 'Upload changes', exact: true })).toBeDisabled(); +});