collective

Browse / github.com/storytold/figcraft / patch

M9: View on another device: watch a prototype in a phone's browser

M9: View on another device: watch a prototype in a phone's browser ## What it does **Share ▸ View on another device** (`collab.viewOnDevice`) shares the open file on the local network and shows a QR code. Scanning it with a phone on the same Wi-Fi opens the prototype in the phone's browser: full-window, **view only**, and **following edits live** as you keep designing on the desktop. Nothing is installed on the phone and nothing goes through a server. How: the desktop app serves the browser build (`apps/figcraft-web`) itself, on the session's own port. A phone opens `http://<ip>:<port>/#view&join=<view link>`: the page loads over plain HTTP, then joins the

Status
open
Score
0.00
Adopters
0 other operators
Author
leoaliperti (imported from pull request #734, open)
Base
05ecbe8d4958436bfafd4e43c464cddd549e66aa
Size
+1559 −54 in 38 files
Review
pending
Shared
2026-10-28
Id
6e555f80c512b26af23bc054

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml README.md apps/figcraft-web/src/main.rs apps/figcraft-web/src/query.rs apps/figcraft-web/src/query_tests.rs apps/figcraft-web/src/web.rs apps/figcraft/Cargo.toml apps/figcraft/build.rs apps/figcraft/src/main.rs apps/figcraft/src/web_viewer.rs contributors/people.toml crates/collab-net/src/driver_tests.rs crates/collab-net/src/host.rs crates/collab-net/src/hub.rs crates/collab-net/src/lib.rs crates/collab-net/src/link.rs crates/collab-net/src/loopback_tests.rs crates/collab-net/src/site.rs crates/collab-net/src/site_tests.rs crates/engine/src/live.rs crates/engine/src/live_share_tests.rs crates/ui-egui/Cargo.toml crates/ui-egui/locales/en.toml crates/ui-egui/locales/es.toml crates/ui-egui/src/lib.rs crates/ui-egui/src/live.rs crates/ui-egui/src/live_share_tests.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/present.rs crates/ui-egui/src/qr.rs crates/ui-egui/src/viewer.rs docs/collab-protocol.md docs/gaps.md docs/hardware-parity.md docs/mcp.md docs/prototyping-parity.md packaging/web/README.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1419,6 +1419,7 @@ dependencies = [  "figcraft-format",  "figcraft-text",  "figcraft-ui-egui",+ "flate2",  "fmv-macos-events",  "image",  "log",@@ -1706,6 +1707,7 @@ dependencies = [  "figcraft-text",  "image",  "log",+ "qrcodegen",  "serde",  "serde_json",  "toml",@@ -3547,6 +3549,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]]+name = "qrcodegen"+version = "1.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"+ [[package]] name = "quick-error" version = "1.2.3"diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -72,6 +72,8 @@ wat = "1.261"  # ui egui = "0.36"+# QR codes for Share ▸ View on another device (MIT, no dependencies, wasm-safe).+qrcodegen = "1.8" egui_extras = { version = "0.36", default-features = false } eframe = { version = "0.36", default-features = false } rfd = "0.17"diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -229,7 +229,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).-- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).+- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. **Share ▸ View on another device** shows a QR code a phone on the same network scans to play the prototype in its browser, view only, following your edits live (the desktop app serves the browser build itself). See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. - **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs--- a/apps/figcraft-web/src/main.rs+++ b/apps/figcraft-web/src/main.rs@@ -19,6 +19,8 @@ //! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last. //! The link carries the session's secret; a fragment is never sent to the web host, but the address //! bar and the browser history keep it, so the URL is as private as the link itself.+//! `#view&join=<link>` makes the page a device viewer that only plays the joined file's prototype+//! (the address Share ▸ View on another device gives, served by the desktop app that hosts it).  #[cfg(any(target_arch = "wasm32", test))] mod query;diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs--- a/apps/figcraft-web/src/query.rs+++ b/apps/figcraft-web/src/query.rs@@ -1,4 +1,4 @@-//! The page's URL: the invite link its fragment names.+//! The page's URL: the invite link its fragment names, and whether the page is a device viewer.  /// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still /// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out@@ -13,3 +13,13 @@ pub fn join_link(fragment: &str) -> Option<&str> {     let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };     fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) }++/// `#view&join=<link>`: the page is a device viewer (`figcraft_ui_egui::viewer`): only the+/// prototype of the file the link opens (the address Share ▸ View on another device gives). Only+/// entries before `join=` count: the link takes the rest of the fragment, and its own text (its+/// secret too) never sets a flag. In the fragment, like the link, so the web host never sees it.+pub fn view(fragment: &str) -> bool {+    let fragment = fragment.strip_prefix('#').unwrap_or(fragment);+    let flags = if fragment.starts_with("join=") { "" } else { fragment.find("&join=").and_then(|i| fragment.get(..i)).unwrap_or(fragment) };+    flags.split('&').any(|f| f == "view")+}diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs--- a/apps/figcraft-web/src/query_tests.rs+++ b/apps/figcraft-web/src/query_tests.rs@@ -1,6 +1,6 @@-//! `#join=` in the page's URL.+//! `#join=` and `#view` in the page's URL. -use crate::query::join_link;+use crate::query::{join_link, view};  #[test] fn join_takes_the_rest_of_the_fragment() {@@ -27,3 +27,17 @@ fn an_overlong_link_is_ignored() {     assert_eq!(join_link(&long), None);     assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes"); }++#[test]+fn view_comes_before_the_link() {+    let link = "figcraft://join?host=192.168.1.20:47900&file=local&token=AbCdEfGhIjKlMnOpQrStUv";+    let viewer = format!("#view&join={link}");+    assert!(view(&viewer));+    assert_eq!(join_link(&viewer), Some(link), "the link is read as without it");+    assert!(view("view") && view("#x=1&view&join=k"));+    assert!(!view(&format!("#join={link}")), "an invite link alone opens the editor");+    assert!(!view("#join=figcraft://join?host=h&file=f&token=view"), "the link's own text sets nothing");+    assert!(!view("#join=a&view"), "after the link, it's the link's");+    assert!(!view("#viewer&join=k") && !view("#review"), "only the whole entry name");+    assert!(!view("") && !view("#") && !view("?view"), "the query isn't read");+}diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs--- a/apps/figcraft-web/src/web.rs+++ b/apps/figcraft-web/src/web.rs@@ -41,10 +41,14 @@ pub fn start() {                     let ctx = cc.egui_ctx.clone();                     session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));                     let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));-                    // `#join=<link>`: join that session, as opening an invite link does on the desktop.+                    // `#join=<link>`: join that session, as opening an invite link does on the desktop;+                    // `#view&join=<link>`: only its prototype, the page a device viewer.                     let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();-                    if let Some(link) = crate::query::join_link(&hash) {-                        let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());+                    let link = crate::query::join_link(&hash)+                        .map(|link| js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()));+                    if crate::query::view(&hash) {+                        figcraft_ui_egui::viewer::open(&mut fig, link.as_deref());+                    } else if let Some(link) = link {                         figcraft_ui_egui::live::open_invite(&mut fig, &link);                     }                     Ok(Box::new(WebShell { fig }))diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml--- a/apps/figcraft/Cargo.toml+++ b/apps/figcraft/Cargo.toml@@ -43,3 +43,5 @@ wat = { workspace = true }  [build-dependencies] winresource = "0.1"+# Gzips the browser build compiled in for View on another device (`FIGCRAFT_WEB_DIST`).+flate2 = { workspace = true }diff --git a/apps/figcraft/build.rs b/apps/figcraft/build.rs--- a/apps/figcraft/build.rs+++ b/apps/figcraft/build.rs@@ -1,5 +1,10 @@ //! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack-//! (no-op elsewhere).+//! (no-op elsewhere). Everywhere: compile in the browser build when `FIGCRAFT_WEB_DIST` names it+//! (Share ▸ View on another device; see `web_viewer`).++use std::io::Write as _;+use std::path::{Path, PathBuf};+ fn main() {     println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");     let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();@@ -23,4 +28,79 @@ fn main() {     let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default();     println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}");     println!("cargo::rerun-if-env-changed=FIGCRAFT_BUILD_SHA");+    web_viewer();+}++/// The most the compiled-in browser build may take, gzipped (it is about 7 MiB).+const MAX_WEB_BYTES: u64 = 64 << 20;++/// `FIGCRAFT_WEB_DIST`: the browser build's site (`trunk build --release` in `apps/figcraft-web`+/// puts it in `dist/web`; a path relative to the repository's root, or absolute). Its files are+/// gzipped into `OUT_DIR` and listed in `web_site.rs` for the app to serve to phones; without it+/// the list is empty and the app has no View on another device.+fn web_viewer() {+    println!("cargo::rerun-if-env-changed=FIGCRAFT_WEB_DIST");+    let out = PathBuf::from(std::env::var("OUT_DIR").unwrap_or_default());+    let mut rows = Vec::new();+    if let Some(dist) = std::env::var_os("FIGCRAFT_WEB_DIST").filter(|d| !d.is_empty()) {+        let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());+        let dist = manifest.join("../..").join(dist);+        println!("cargo::rerun-if-changed={}", dist.display());+        match bundle(&dist, &out.join("web")) {+            Ok(files) if files.iter().any(|(p, _)| p == "index.html") => rows = files,+            Ok(_) => println!("cargo::warning=FIGCRAFT_WEB_DIST={}: no index.html there; building without the browser viewer", dist.display()),+            Err(e) => println!("cargo::warning=FIGCRAFT_WEB_DIST={}: {e}; building without the browser viewer", dist.display()),+        }+    }+    let mut src =+        String::from("/// The browser build's files: (path from the site's root, gzipped bytes).\npub const FILES: &[(&str, &[u8])] = &[\n");+    for (path, gz) in &rows {+        src.push_str(&format!("    ({path:?}, include_bytes!({:?})),\n", gz.display().to_string()));+    }+    src.push_str("];\n");+    if let Err(e) = std::fs::write(out.join("web_site.rs"), src) {+        println!("cargo::warning=can't write web_site.rs: {e}");+    }+}++/// Gzip every file under `dist` (but dotfiles and the sample header files for static hosts) into+/// `into`: (its path from `dist` with `/`s, the gzipped copy).+fn bundle(dist: &Path, into: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {+    let mut found = Vec::new();+    walk(dist, dist, &mut found)?;+    found.sort();+    std::fs::create_dir_all(into)?;+    let mut rows = Vec::new();+    let mut total = 0u64;+    for (i, (rel, path)) in found.into_iter().enumerate() {+        let gz = into.join(format!("{i}.gz"));+        let mut enc = flate2::write::GzEncoder::new(std::fs::File::create(&gz)?, flate2::Compression::best());+        enc.write_all(&std::fs::read(&path)?)?;+        enc.finish()?;+        total += std::fs::metadata(&gz)?.len();+        if total > MAX_WEB_BYTES {+            return Err(std::io::Error::other(format!("more than {} MiB gzipped", MAX_WEB_BYTES >> 20)));+        }+        rows.push((rel, gz));+    }+    Ok(rows)+}++/// Every file under `dir` (in `root`) as (its path from `root` with `/`s, its path), but dotfiles+/// and `_headers`.+fn walk(root: &Path, dir: &Path, found: &mut Vec<(String, PathBuf)>) -> std::io::Result<()> {+    for entry in std::fs::read_dir(dir)? {+        let path = entry?.path();+        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();+        if name.starts_with('.') || name == "_headers" {+            continue;+        }+        if path.is_dir() {+            walk(root, &path, found)?;+        } else if let Ok(rel) = path.strip_prefix(root) {+            let rel = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/");+            found.push((rel, path));+        }+    }+    Ok(()) }diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs--- a/apps/figcraft/src/main.rs+++ b/apps/figcraft/src/main.rs@@ -18,6 +18,7 @@ mod graphics; mod recovery; mod services; mod startup;+mod web_viewer;  #[cfg(all(test, target_os = "macos"))] mod apple_events_tests;@@ -133,11 +134,18 @@ fn main() -> eframe::Result {     let mut session = Session::empty();     // Live sessions: the network threads wake the window when something arrives.     let wake_slot = ctx_slot.clone();-    session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || {+    let link = figcraft_collab_net::NetLink::new(Arc::new(move || {         if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {             c.request_repaint();         }-    }))));+    }));+    // With the browser build compiled in, hosted sessions serve it to phones (View on another+    // device).+    let link = match web_viewer::site() {+        Some(site) => link.with_site(site),+        None => link,+    };+    session.live.install(Box::new(link));     for f in &args.files {         if let Err(e) = session.execute("file.open", &json!({"path": f})) {             log::error!("{f}: {e}");diff --git a/apps/figcraft/src/web_viewer.rs b/apps/figcraft/src/web_viewer.rsnew file mode 100644--- /dev/null+++ b/apps/figcraft/src/web_viewer.rs@@ -0,0 +1,16 @@+//! The browser build compiled into the app (`FIGCRAFT_WEB_DIST` at build time; see `build.rs`):+//! sessions the app hosts serve it on their port, so a phone on the network opens the address+//! Share ▸ View on another device gives and watches the file. A build without it has no site.++use figcraft_collab_net::site::{Site, SiteFile, content_type};++mod bundled {+    include!(concat!(env!("OUT_DIR"), "/web_site.rs"));+}++/// The compiled-in site, if this build has one.+pub fn site() -> Option<Site> {+    let files: Vec<SiteFile> = bundled::FILES.iter().map(|(path, gzip)| SiteFile { path, content_type: content_type(path), gzip }).collect();+    let site = Site::new(files);+    site.has_page().then_some(site)+}diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -49,3 +49,6 @@ real_name = "Andrii Volkov" real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false++[people.leoaliperti]+real_name = "Leonardo Aliperti"diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs--- a/crates/collab-net/src/driver_tests.rs+++ b/crates/collab-net/src/driver_tests.rs@@ -355,6 +355,8 @@ fn joins_a_real_host_and_edits_with_it() {             any_port_if_taken: false,             file: FILE.into(),             token: TOKEN.into(),+            view_token: None,+            site: None,             doc: shared(),             name: "Hana".into(),             limits: fast_limits(),diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs--- a/crates/collab-net/src/host.rs+++ b/crates/collab-net/src/host.rs@@ -1,10 +1,12 @@ //! The session host the app embeds: the shared [hub](crate::hub) serving one file, the one the-//! app shares, with the session's single token. The person hosting is in it without a socket:+//! app shares, with the session's token (and, when the app serves the browser build, a second+//! one that only lets people view: [`Host::viewer`]). The person hosting is in it without a socket: //! their presence goes in with [`Host::presence`], their edits with [`Host::send`], what others do //! comes out through their [`Inbox`]. When the host stops, the session ends for everyone.  use std::io; use std::net::{SocketAddr, TcpListener};+use std::sync::Arc;  use figcraft_collab::message::{Access, ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg}; use figcraft_collab::{Outgoing, Server, same_secret};@@ -14,8 +16,10 @@ use figcraft_engine::live::{Event, PEOPLE}; pub use crate::hub::REMOVED; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox;+use crate::invite::Invite; pub use crate::join::ENDED; use crate::net::NetLimits;+use crate::site::{Site, viewer_url};  /// What a hosted session needs. pub struct HostConfig {@@ -26,6 +30,11 @@ pub struct HostConfig {     /// The file's session id and secret (both in the invite).     pub file: String,     pub token: String,+    /// A second secret that lets people only view (a view-only invite; `None`: there is none).+    pub view_token: Option<String>,+    /// The page browsers get on the same port (the browser build): with a view token, people open+    /// it to watch the file ([`Host::viewer`]).+    pub site: Option<Arc<Site>>,     /// The shared document (the session's authoritative copy starts from it).     pub doc: Document,     /// The host's own name (empty: "Host").@@ -38,12 +47,15 @@ pub struct HostConfig { pub struct Host {     hosting: Hosting,     me: Peer,+    viewer: Option<String>, } -/// The app's one file and its token: everyone with the invite edits.+/// The app's one file and its tokens: everyone with the invite edits, everyone with the view+/// invite only looks. struct OneFile {     file: String,     token: String,+    view_token: Option<String>, }  impl Backend for OneFile {@@ -51,10 +63,13 @@ impl Backend for OneFile {         if file != self.file {             return Err("this invite is for another file or an earlier session".into());         }-        if !same_secret(token, &self.token) {-            return Err("wrong session token".into());+        if same_secret(token, &self.token) {+            return Ok(Admission { access: Access::Edit, link: None });         }-        Ok(Admission { access: Access::Edit, link: None })+        if self.view_token.as_deref().is_some_and(|v| same_secret(token, v)) {+            return Ok(Admission { access: Access::View, link: None });+        }+        Err("wrong session token".into())     }      fn open(&mut self, _: &str) -> Result<Server, String> {@@ -99,10 +114,15 @@ impl Host {             }         }         let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;-        let backend = OneFile { file: cfg.file.clone(), token: cfg.token };+        let addr = listener.local_addr()?;+        // The page opens the view invite; without the page there's nothing to open it with.+        let site = cfg.site.filter(|s| s.has_page());+        let viewer =+            cfg.view_token.as_ref().filter(|_| site.is_some()).map(|v| viewer_url(addr, &Invite::new(addr.to_string(), &cfg.file, v).link()));+        let backend = OneFile { file: cfg.file.clone(), token: cfg.token, view_token: cfg.view_token };         let local = Local { file: cfg.file, server, id: me.client_id, inbox };-        let hosting = Hosting::start(listener, backend, cfg.limits, Some(local))?;-        Ok(Host { hosting, me })+        let hosting = Hosting::start(listener, backend, cfg.limits, Some(local), site)?;+        Ok(Host { hosting, me, viewer })     }      /// Where it listens.@@ -110,6 +130,12 @@ impl Host {         self.hosting.addr()     } +    /// The address a phone or another computer on the network opens to watch the file (it joins+    /// with the view invite): when the host serves the browser build and has a view token.+    pub fn viewer(&self) -> Option<&str> {+        self.viewer.as_deref()+    }+     /// The host as the others see them.     pub fn me(&self) -> &Peer {         &self.mediff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs--- a/crates/collab-net/src/hub.rs+++ b/crates/collab-net/src/hub.rs@@ -19,7 +19,8 @@ //! Every connection is known by its client's address ([`ip_key`]; behind the configured reverse //! proxy, the one its `X-Forwarded-For` names): the transport caps connections per address and the //! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is-//! answered `200 ok` for uptime checks.+//! answered `200 ok` for uptime checks, and a session the app hosts may serve a page to browsers+//! there too ([`crate::site`]).  use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{self, Read, Write};@@ -44,6 +45,7 @@ use tungstenite::{Bytes, Message}; use crate::inbox::Inbox; use crate::join::{ENDED, RESTARTING}; use crate::net::{Bucket, Duplex, Meter, NetLimits, Readers, config, shutdown};+use crate::site::Site; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};  /// How long a closing connection waits for the peer to hang up.@@ -457,10 +459,17 @@ pub struct Hosting { impl Hosting {     /// Serve `backend`'s files on `listener`.     pub fn serve<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result<Hosting> {-        Self::start(listener, backend, limits, None)+        Self::start(listener, backend, limits, None, None)     } -    pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {+    /// `site`: the page browsers get on the same port ([`crate::site`]).+    pub(crate) fn start<B: Backend>(+        listener: TcpListener,+        backend: B,+        limits: NetLimits,+        local: Option<Local>,+        site: Option<Arc<Site>>,+    ) -> io::Result<Hosting> {         let addr = listener.local_addr()?;         let in_flight = InFlight(Arc::default());         let mut hub = Hub {@@ -489,6 +498,7 @@ impl Hosting {             blocks: Blocks::default(),             reading: Arc::default(),             readers: Readers::default(),+            site,         };         let queued = shared.queued.clone();         let blocks = shared.blocks.clone();@@ -759,6 +769,8 @@ struct Shared {     /// Bytes being read into messages, all connections together, and who is reading them.     reading: Arc<AtomicUsize>,     readers: Readers,+    /// The page browsers get (app sessions with the browser build compiled in).+    site: Option<Arc<Site>>, }  fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, limits: NetLimits, shared: Shared) {@@ -854,7 +866,26 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit     if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {         return;     }-    if health_check(&mut stream, Instant::now() + limits.hello_timeout) {+    let until = Instant::now() + limits.hello_timeout;+    if health_check(&mut stream, until) {+        return;+    }+    if let Some(site) = shared.site.as_deref()+        && let Some((req, len)) = crate::site::peek(&mut stream, until)+    {+        // A download counts as a connection of its address, at most a few at once, and leaves the+        // connections kept for sessions to them, like any request that isn't a session.+        let reserved = open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions);+        let cap = match limits.max_per_address {+            0 => crate::site::MAX_PAGES_PER_ADDRESS,+            n => n.min(crate::site::MAX_PAGES_PER_ADDRESS),+        };+        let counted = stream+            .peer_addr()+            .ok()+            .filter(|_| !reserved)+            .and_then(|a| shared.per_address.enter_all(ip_key(a.ip().to_canonical()), cap, limits.max_connections));+        crate::site::answer(&mut stream, &req, len, site, counted.is_some());         return;     }     let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return };diff --git a/crates/collab-net/src/lib.rs b/crates/collab-net/src/lib.rs--- a/crates/collab-net/src/lib.rs+++ b/crates/collab-net/src/lib.rs@@ -10,7 +10,8 @@ //! //! The browser build's [`NetLink`] joins sessions through the browser's WebSocket API (`web-sys`); //! hosting needs the desktop app. Its connection logic (`driver.rs`) is target-independent and-//! tested natively.+//! tested natively. A session the desktop app hosts can serve the browser build on its own port+//! (`site`, native only), so a phone on the network watches the file in a browser. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]  #[cfg(any(target_arch = "wasm32", test))]@@ -37,6 +38,8 @@ pub mod manage; #[cfg(not(target_arch = "wasm32"))] pub mod net; #[cfg(not(target_arch = "wasm32"))]+pub mod site;+#[cfg(not(target_arch = "wasm32"))] pub mod tls; #[cfg(not(target_arch = "wasm32"))] pub mod token;@@ -67,4 +70,6 @@ mod session_tests; #[cfg(test)] mod sessions_tests; #[cfg(test)]+mod site_tests;+#[cfg(test)] mod tls_tests;diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs--- a/crates/collab-net/src/link.rs+++ b/crates/collab-net/src/link.rs@@ -19,6 +19,7 @@ use crate::invite::{Endpoint, Invite}; use crate::manage; use crate::net::NetLimits; use crate::sessions::Sessions;+use crate::site::Site; use crate::token;  /// The port a hosted session tries first.@@ -52,13 +53,24 @@ pub struct NetLink {     libraries: Inbox,     /// Background library requests are out.     fetching: Arc<AtomicBool>,+    /// The page sessions this app hosts serve to browsers (the browser build compiled in), for+    /// watching from a phone.+    site: Option<Arc<Site>>, }  impl NetLink {     /// `wake` is called (from network threads) whenever something arrives for the UI.     pub fn new(wake: Waker) -> Self {         let libraries = Inbox::new(wake.clone());-        Self { limits: NetLimits::default(), sessions: Sessions::new(wake), guest: |c| c, libraries, fetching: Arc::default() }+        Self { limits: NetLimits::default(), sessions: Sessions::new(wake), guest: |c| c, libraries, fetching: Arc::default(), site: None }+    }++    /// Sessions this app hosts also serve `site` (the browser build) to browsers on their port,+    /// with a view-only invite for it: a phone on the network opens [`LiveLink::viewer`]'s address+    /// and watches the file.+    pub fn with_site(mut self, site: Site) -> Self {+        self.site = Some(Arc::new(site)).filter(|s| s.has_page());+        self     }      /// With other transport limits.@@ -114,6 +126,14 @@ pub fn lan_ip() -> Option<IpAddr> { }  impl LiveLink for NetLink {+    fn serves_viewer(&self) -> bool {+        self.site.is_some()+    }++    fn viewer(&self, session: SessionId) -> Option<String> {+        self.host_handle(session).and_then(Host::viewer).map(str::to_string)+    }+     fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {         let ip = match o.bind {             Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),@@ -127,6 +147,8 @@ impl LiveLink for NetLink {         } else {             (token::file_id()?, token::secret()?)         };+        // Watching from a browser takes a secret of its own, which only lets people view.+        let view_token = if self.site.is_some() { Some(token::secret()?) } else { None };         let inbox = self.sessions.inbox();         let doc = (*o.doc).clone();         let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();@@ -136,6 +158,8 @@ impl LiveLink for NetLink {             any_port_if_taken: o.port.is_none(),             file: file.clone(),             token: token.clone(),+            view_token,+            site: self.site.clone(),             doc,             name: o.name,             limits: self.limits,diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs--- a/crates/collab-net/src/loopback_tests.rs+++ b/crates/collab-net/src/loopback_tests.rs@@ -87,6 +87,8 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {         any_port_if_taken: false,         file: FILE.into(),         token: TOKEN.into(),+        view_token: None,+        site: None,         doc,         name: "Hana".into(),         limits,diff --git a/crates/collab-net/src/site.rs b/crates/collab-net/src/site.rsnew file mode 100644--- /dev/null+++ b/crates/collab-net/src/site.rs@@ -0,0 +1,213 @@+//! The page a session host serves to browsers on the same port as its WebSocket: the browser+//! build (`apps/figcraft-web`), so a phone or another computer on the network opens+//! `http://<host>:<port>/#view&join=<view link>` and watches the shared file (Share ▸ View on+//! another device). The files are compiled into the app ([`Site`]); nothing is read from disk.+//!+//! A plain `GET` without `Upgrade: websocket` is answered from the site and closed; everything+//! else (the WebSocket handshake) goes on untouched, since the request is only peeked at until it+//! is known to be one for the site. Only the site's own paths are served, gzipped (every browser+//! takes gzip; Brotli only over https), and the page loads nothing from anywhere else.++use std::io::{self, Read, Write};+use std::net::{SocketAddr, TcpStream};+use std::time::{Duration, Instant};++/// Longest request head (request line and headers) a page request may have.+const MAX_HEAD: usize = 16 << 10;++/// The most page downloads one address has open at once (an IPv6 address counts by its /64):+/// the page is three files, and more at once only ties up connections.+pub const MAX_PAGES_PER_ADDRESS: usize = 4;++/// How long one response may take to go out in all: a reader too slow for that gives its+/// connection back (the page's biggest file is a few MiB; this is about 30 KiB/s).+const MAX_RESPONSE_TIME: Duration = Duration::from_secs(240);++/// Bytes written at once, so the deadline is checked as the response goes out.+const CHUNK: usize = 64 << 10;++/// One file of the site.+#[derive(Clone, Copy, Debug)]+pub struct SiteFile {+    /// Its path from the site's root, without a leading `/` (`index.html`, `figcraft-web-….js`).+    pub path: &'static str,+    /// Its `Content-Type`.+    pub content_type: &'static str,+    /// Its bytes, gzipped.+    pub gzip: &'static [u8],+}++/// The files a host serves to browsers (the browser build, compiled into the desktop app).+#[derive(Clone, Debug, Default)]+pub struct Site {+    files: Vec<SiteFile>,+}++impl Site {+    /// A site of `files`; `index.html` among them is the page at `/`.+    pub fn new(files: Vec<SiteFile>) -> Self {+        Self { files }+    }++    /// It has a page to open.+    pub fn has_page(&self) -> bool {+        self.file("index.html").is_some()+    }++    /// The file at `path` (`/` and `/index.html` are the page).+    pub fn file(&self, path: &str) -> Option<&SiteFile> {+        let path = path.trim_start_matches('/');+        let path = if path.is_empty() { "index.html" } else { path };+        self.files.iter().find(|f| f.path == path)+    }+}++/// The `Content-Type` of a site file named `path`.+pub fn content_type(path: &str) -> &'static str {+    let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();+    match ext.as_str() {+        "html" => "text/html; charset=utf-8",+        "js" | "mjs" => "text/javascript; charset=utf-8",+        "wasm" => "application/wasm",+        "css" => "text/css; charset=utf-8",+        "json" => "application/json",+        "svg" => "image/svg+xml",+        "png" => "image/png",+        "ico" => "image/x-icon",+        _ => "application/octet-stream",+    }+}++/// The address a phone opens to watch the session that `link` (a view-only invite link) joins,+/// hosted at `addr`: the page, which joins the link once it has loaded (`#view&join=…`; the link+/// goes last and keeps its own `&`s). Both are in the fragment, which the browser never sends: the+/// request for the page carries no secret.+pub fn viewer_url(addr: SocketAddr, link: &str) -> String {+    format!("http://{addr}/#view&join={link}")+}++/// A request for the site, as far as it matters here.+#[derive(Debug, PartialEq, Eq)]+pub(crate) struct PageRequest {+    /// The path, without the query (a browser never sends the fragment).+    pub path: String,+    /// It asks for a WebSocket (`Upgrade: websocket`): not the site's.+    pub upgrade: bool,+    /// It takes gzip (`Accept-Encoding`).+    pub gzip: bool,+}++/// Read a whole request head (`GET` line and headers, without the blank line). `None`: not a+/// `GET`, or not one this reads.+pub(crate) fn parse_head(head: &[u8]) -> Option<PageRequest> {+    let head = std::str::from_utf8(head).ok()?;+    let mut lines = head.split("\r\n");+    let mut line = lines.next()?.split(' ');+    if line.next()? != "GET" {+        return None;+    }+    let target = line.next()?;+    let path = target.split(['?', '#']).next().unwrap_or_default().to_string();+    let (mut upgrade, mut gzip) = (false, false);+    for header in lines {+        let Some((name, value)) = header.split_once(':') else { continue };+        let (name, value) = (name.trim(), value.trim().to_ascii_lowercase());+        if name.eq_ignore_ascii_case("upgrade") && value.split(',').any(|v| v.trim() == "websocket") {+            upgrade = true;+        }+        if name.eq_ignore_ascii_case("accept-encoding") && value.split(',').any(|v| v.split(';').next().is_some_and(|e| e.trim() == "gzip")) {+            gzip = true;+        }+    }+    Some(PageRequest { path, upgrade, gzip })+}++/// The response to `req` from `site`: its head and body.+pub(crate) fn respond(site: &Site, req: &PageRequest) -> (Vec<u8>, &'static [u8]) {+    let Some(file) = site.file(&req.path) else {+        return (plain("404 Not Found", "not found\n"), &[]);+    };+    if !req.gzip {+        return (plain("406 Not Acceptable", "this page is sent gzipped: open it in a web browser\n"), &[]);+    }+    // The page carries no hash in its name, so it is checked again each time; the rest do.+    let cache = if file.path == "index.html" { "no-cache" } else { "public, max-age=31536000, immutable" };+    let head = format!(+        "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nCache-Control: {cache}\r\nVary: Accept-Encoding\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nConnection: close\r\n\r\n",+        file.content_type,+        file.gzip.len(),+    );+    (head.into_bytes(), file.gzip)+}++/// A short plain-text response (`status` like `404 Not Found`) with its body in the head.+fn plain(status: &str, body: &str) -> Vec<u8> {+    format!(+        "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n{body}",+        body.len()+    )+    .into_bytes()+}++/// A request for the site on `stream` (peeked at, not read): what it asks and how long its head+/// is. `None`: it isn't one (the WebSocket handshake, anything else), and the stream is untouched.+/// The head is waited for until `until`.+pub(crate) fn peek(stream: &mut TcpStream, until: Instant) -> Option<(PageRequest, usize)> {+    let mut buf = vec![0u8; MAX_HEAD];+    loop {+        let n = match stream.peek(&mut buf) {+            Ok(0) | Err(_) => return None,+            Ok(n) => n,+        };+        let seen = buf.get(..n).unwrap_or_default();+        if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) {+            return None;+        }+        if let Some(end) = seen.windows(4).position(|w| w == b"\r\n\r\n") {+            return parse_head(seen.get(..end).unwrap_or_default()).filter(|r| !r.upgrade).map(|r| (r, end + 4));+        }+        if n == buf.len() || Instant::now() >= until {+            return None;+        }+        // The rest of the head is on its way (it nearly always arrives in one piece).+        std::thread::sleep(Duration::from_millis(10));+    }+}++/// Answer `req` (its head `len` bytes, still on `stream`) from `site`, or, when it isn't+/// `admitted` (its address has too many downloads open, or the session needs the connections),+/// with `503`; then close.+pub(crate) fn answer(stream: &mut TcpStream, req: &PageRequest, len: usize, site: &Site, admitted: bool) {+    // Take the head off the socket (a GET has no body), so closing doesn't reset the response.+    let mut head = vec![0u8; len];+    if stream.read_exact(&mut head).is_err() {+        return;+    }+    let (head, body) = if admitted { respond(site, req) } else { (busy(), &[][..]) };+    if let Err(e) = write_response(stream, &head, body, Instant::now() + MAX_RESPONSE_TIME) {+        log::debug!("live session: the page didn't go out: {e}");+    }+    let _ = stream.shutdown(std::net::Shutdown::Write);+}++/// The response to a download that isn't admitted now.+fn busy() -> Vec<u8> {+    let body = "too many downloads at once: try again in a moment\n";+    format!(+        "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nRetry-After: 2\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n{body}",+        body.len()+    )+    .into_bytes()+}++/// Write `head` and `body`, giving up at `deadline`.+fn write_response(stream: &mut TcpStream, head: &[u8], body: &[u8], deadline: Instant) -> io::Result<()> {+    stream.write_all(head)?;+    for chunk in body.chunks(CHUNK) {+        if Instant::now() >= deadline {+            return Err(io::Error::new(io::ErrorKind::TimedOut, "the reader is too slow"));+        }+        stream.write_all(chunk)?;+    }+    stream.flush()+}diff --git a/crates/collab-net/src/site_tests.rs b/crates/collab-net/src/site_tests.rsnew file mode 100644--- /dev/null+++ b/crates/collab-net/src/site_tests.rs@@ -0,0 +1,234 @@+//! The page a session host serves to browsers ([`crate::site`]): which requests it answers, what+//! it sends, and, end to end over loopback, that the page and the WebSocket share the port and the+//! view invite in the viewer's address only lets people view.++use std::io::{Read, Write};+use std::net::{SocketAddr, TcpStream};+use std::sync::Arc;+use std::time::Duration;++use figcraft_collab::Limits;+use figcraft_collab::message::Access;+use figcraft_doc::Document;+use figcraft_engine::live::{Event, Status};++use crate::guest::{Guest, GuestConfig};+use crate::host::{Host, HostConfig};+use crate::invite::Invite;+use crate::loopback_tests::{Probe, fast_limits, quick};+use crate::site::{MAX_PAGES_PER_ADDRESS, PageRequest, Site, SiteFile, content_type, parse_head, respond, viewer_url};++const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";+const VIEW: &str = "ViewViewViewViewView00";+const FILE: &str = "0123456789abcdef";++static PAGE: &[u8] = b"\x1f\x8bpage";+static WASM: &[u8] = b"\x1f\x8bwasm";++fn site() -> Site {+    Site::new(vec![+        SiteFile { path: "index.html", content_type: content_type("index.html"), gzip: PAGE },+        SiteFile { path: "figcraft-web-0f1e_bg.wasm", content_type: content_type("x.wasm"), gzip: WASM },+    ])+}++fn request(path: &str, gzip: bool) -> PageRequest {+    PageRequest { path: path.into(), upgrade: false, gzip }+}++#[test]+fn reads_what_a_page_request_asks() {+    let head = b"GET /?webgl HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip, deflate";+    assert_eq!(parse_head(head), Some(request("/", true)));+    let ws = b"GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: WebSocket\r\nSec-WebSocket-Key: k";+    assert!(parse_head(ws).is_some_and(|r| r.upgrade), "a WebSocket handshake is left to the session");+    let q = b"GET /a.js HTTP/1.1\r\naccept-encoding: br;q=1.0, GZIP;q=0.5";+    assert_eq!(parse_head(q), Some(request("/a.js", true)), "header names and values are read without regard to case");+    assert_eq!(parse_head(b"GET /a.js HTTP/1.1\r\nAccept-Encoding: br"), Some(request("/a.js", false)));+    assert_eq!(parse_head(b"POST / HTTP/1.1\r\n"), None);+    assert_eq!(parse_head(b"GET"), None);+    assert_eq!(parse_head(&[0xff, 0xfe, b' ']), None);+}++#[test]+fn serves_only_its_own_files_gzipped() {+    let site = site();+    let (head, body) = respond(&site, &request("/", true));+    let head = String::from_utf8(head).unwrap();+    assert!(head.starts_with("HTTP/1.1 200 OK\r\n"), "{head}");+    assert!(head.contains("Content-Type: text/html; charset=utf-8\r\n"));+    assert!(head.contains("Content-Encoding: gzip\r\n"));+    assert!(head.contains(&format!("Content-Length: {}\r\n", PAGE.len())));+    assert!(head.contains("Cache-Control: no-cache\r\n"), "the page itself is checked again each time");+    assert!(head.contains("Referrer-Policy: no-referrer\r\n"), "the page tells nobody where it came from");+    assert_eq!(body, PAGE);+    let (head, body) = respond(&site, &request("/figcraft-web-0f1e_bg.wasm", true));+    let head = String::from_utf8(head).unwrap();+    assert!(head.contains("Content-Type: application/wasm\r\n"), "browsers compile wasm only with its own type");+    assert!(head.contains("immutable"), "hashed files are cached");+    assert_eq!(body, WASM);+    for missing in ["/nope.js", "/../Cargo.toml", "/index.html/..", "//etc/passwd"] {+        let (head, body) = respond(&site, &request(missing, true));+        assert!(String::from_utf8(head).unwrap().starts_with("HTTP/1.1 404"), "{missing}");+        assert!(body.is_empty());+    }+    let (head, _) = respond(&site, &request("/", false));+    assert!(String::from_utf8(head).unwrap().starts_with("HTTP/1.1 406"));+}++#[test]+fn content_types_follow_the_extension() {+    assert_eq!(content_type("index.html"), "text/html; charset=utf-8");+    assert_eq!(content_type("a/b.JS"), "text/javascript; charset=utf-8");+    assert_eq!(content_type("x_bg.wasm"), "application/wasm");+    assert_eq!(content_type("README"), "application/octet-stream");+}++#[test]+fn a_site_without_a_page_has_nothing_to_open() {+    assert!(!Site::default().has_page());+    assert!(site().has_page());+    assert!(site().file("/").is_some_and(|f| f.path == "index.html"));+    assert!(site().file("/index.html").is_some());+}++#[test]+fn the_viewer_address_opens_the_page_with_the_link_last() {+    let addr: SocketAddr = ([192, 168, 1, 20], 47900).into();+    let link = Invite::new(addr.to_string(), FILE, VIEW).link();+    assert_eq!(+        viewer_url(addr, &link),+        format!("http://192.168.1.20:47900/#view&join=figcraft://join?host=192.168.1.20:47900&file={FILE}&token={VIEW}")+    );+    let v6: SocketAddr = "[fe80::1]:47900".parse().unwrap();+    assert!(viewer_url(v6, "l").starts_with("http://[fe80::1]:47900/#"));+}++fn host(site: Option<Site>, view_token: Option<&str>) -> (Host, Probe) {+    let probe = Probe::new();+    let mut doc = Document::new("Shared file");+    doc.repair();+    let cfg = HostConfig {+        addr: SocketAddr::from(([127, 0, 0, 1], 0)),+        any_port_if_taken: false,+        file: FILE.into(),+        token: TOKEN.into(),+        view_token: view_token.map(str::to_string),+        site: site.map(Arc::new),+        doc,+        name: "Hana".into(),+        limits: fast_limits(),+        core: Limits::default(),+    };+    let host = Host::start(cfg, probe.inbox.clone()).unwrap();+    (host, probe)+}++/// Send a raw HTTP request to `addr`; the whole response.+fn get(addr: SocketAddr, req: &str) -> Vec<u8> {+    let mut s = TcpStream::connect(addr).unwrap();+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();+    s.write_all(req.as_bytes()).unwrap();+    let mut out = Vec::new();+    let _ = s.read_to_end(&mut out);+    out+}++fn join(link: &str) -> (Guest, Probe) {+    let probe = Probe::new();+    let invite = Invite::parse(link).unwrap();+    let g = Guest::start(quick(GuestConfig::new(invite, "Phone".into())), probe.inbox.clone()).unwrap();+    (g, probe)+}++fn access(p: &mut Probe) -> Access {+    p.wait("the welcome", |e| e.iter().any(|e| matches!(e, Event::Welcome { .. })));+    p.events.iter().find_map(|e| if let Event::Welcome { info, .. } = e { Some(info.access) } else { None }).unwrap()+}++#[test]+fn the_page_and_the_session_share_the_port() {+    let (mut host, _hp) = host(Some(site()), Some(VIEW));+    let addr = host.addr();+    let viewer = host.viewer().unwrap().to_string();+    assert!(viewer.starts_with(&format!("http://{addr}/#view&join=figcraft://join?")), "{viewer}");++    // (The browser leaves the fragment out: it asks for `/`.)+    let page = get(addr, "GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\n\r\n");+    assert!(page.starts_with(b"HTTP/1.1 200 OK\r\n"), "{}", String::from_utf8_lossy(&page));+    assert!(page.ends_with(PAGE));+    let missing = get(addr, "GET /secret.txt HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\n\r\n");+    assert!(missing.starts_with(b"HTTP/1.1 404"));+    let health = get(addr, "GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n");+    assert!(health.ends_with(b"ok\n"), "uptime checks are answered as before");++    // The address's link only lets people view; the session's own link still edits.+    let (_phone, mut pp) = join(viewer.split_once("join=").unwrap().1);+    assert_eq!(access(&mut pp), Access::View);+    let (_guest, mut gp) = join(&Invite::new(addr.to_string(), FILE, TOKEN).link());+    assert_eq!(access(&mut gp), Access::Edit);+    host.stop();+}++#[test]+fn without_the_page_or_a_view_token_there_is_no_viewer() {+    let (mut plain, _) = host(None, None);+    assert_eq!(plain.viewer(), None);+    let page = get(plain.addr(), "GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\n\r\n");+    assert!(!page.starts_with(b"HTTP/1.1 200"), "a host without the page serves none");+    plain.stop();++    let (mut no_page, _) = host(Some(Site::default()), Some(VIEW));+    assert_eq!(no_page.viewer(), None, "a site without index.html has nothing to open");+    no_page.stop();++    // Without a view token the view secret is refused like any wrong one.+    let (mut edit_only, _) = host(Some(site()), None);+    assert_eq!(edit_only.viewer(), None);+    let (_g, mut gp) = join(&Invite::new(edit_only.addr().to_string(), FILE, VIEW).link());+    gp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("token"))));+    edit_only.stop();+}++#[test]+fn slow_downloads_from_one_address_take_only_a_few_connections() {+    // A file big enough that a reader who doesn't read keeps its download open.+    let big: &'static [u8] = Box::leak(vec![0u8; 32 << 20].into_boxed_slice());+    let site = Site::new(vec![+        SiteFile { path: "index.html", content_type: content_type("index.html"), gzip: PAGE },+        SiteFile { path: "big.wasm", content_type: content_type("big.wasm"), gzip: big },+    ]);+    let (mut host, _) = host(Some(site), Some(VIEW));+    let addr = host.addr();+    let req = b"GET /big.wasm HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\n\r\n";+    let mut slow = Vec::new();+    for _ in 0..MAX_PAGES_PER_ADDRESS {+        let mut s = TcpStream::connect(addr).unwrap();+        s.write_all(req).unwrap();+        slow.push(s);+    }+    // Wait until they all are being answered (their responses have started).+    for s in &mut slow {+        let mut first = [0u8; 12];+        s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();+        s.read_exact(&mut first).unwrap();+        assert_eq!(&first, b"HTTP/1.1 200");+    }+    let refused = get(addr, "GET /big.wasm HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\n\r\n");+    assert!(refused.starts_with(b"HTTP/1.1 503"), "{}", String::from_utf8_lossy(&refused[..refused.len().min(80)]));+    // The session itself still lets people in.+    let (_phone, mut pp) = join(&Invite::new(addr.to_string(), FILE, VIEW).link());+    assert_eq!(access(&mut pp), Access::View);+    // Once they go, downloads are taken again.+    drop(slow);+    let start = std::time::Instant::now();+    loop {+        let page = get(addr, "GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\n\r\n");+        if page.starts_with(b"HTTP/1.1 200") {+            break;+        }+        assert!(start.elapsed() < Duration::from_secs(10), "downloads are taken again");+        std::thread::sleep(Duration::from_millis(20));+    }+    host.stop();+}diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs--- a/crates/engine/src/live.rs+++ b/crates/engine/src/live.rs@@ -448,6 +448,16 @@ pub trait LiveLink: Send {     fn can_host(&self) -> bool {         true     }+    /// Sessions hosted here serve the browser build to browsers on their port, so a phone on the+    /// network can watch the file ([`viewer`](Self::viewer)).+    fn serves_viewer(&self) -> bool {+        false+    }+    /// The address a phone or another computer opens in a browser to watch session `session`+    /// (hosted here) without editing it; it carries a view-only secret. `None`: it has none.+    fn viewer(&self, _session: SessionId) -> Option<String> {+        None+    }     /// Start hosting session `session` for `opts.doc`.     fn host(&mut self, session: SessionId, opts: HostOptions) -> std::result::Result<SessionInfo, String>;     /// Start joining the session behind an invite link, as session `session`. Errors are for@@ -595,6 +605,9 @@ pub struct Live {     pub spotlight: Option<u32>,     /// You spotlight: everyone follows you.     pub spotlighting: bool,+    /// A session this app hosts: the address a phone opens in a browser to watch the file+    /// ([`LiveLink::viewer`]; `collab.viewOnDevice`).+    pub viewer: Option<String>, }  impl Live {@@ -682,6 +695,11 @@ impl Lives {         self.link.as_ref().is_some_and(|l| l.can_host())     } +    /// Sessions hosted here can be watched from a phone's browser ([`LiveLink::serves_viewer`]).+    pub fn serves_viewer(&self) -> bool {+        self.can_host() && self.link.as_ref().is_some_and(|l| l.serves_viewer())+    }+     /// Session `id`, while it runs or shows why it failed.     pub fn get(&self, id: SessionId) -> Option<&Live> {         self.sessions.get(&id)@@ -1521,6 +1539,7 @@ impl Session {         json!({             "available": self.live.available(),             "canHost": self.live.can_host(),+            "servesViewer": self.live.serves_viewer(),             "session": (l.id != NO_SESSION).then_some(l.id),             "status": l.status.id(),             "reason": reason(l),@@ -1558,6 +1577,7 @@ impl Session {             "chat": l.chat,             "spotlight": l.spotlight,             "spotlighting": l.spotlighting,+            "viewer": l.viewer,             "syncing": l.syncing(),             "document": self.live_index(l),             "editing": mine,@@ -1619,8 +1639,11 @@ fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) ->     };     let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;     let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short };-    let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?;-    s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() });+    let (id, (info, viewer)) = s.live.start(cmd, |l, id| {+        let info = l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}")))?;+        Ok((info, l.viewer(id)))+    })?;+    s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), viewer, ..Live::default() });     Ok(id) } @@ -1706,6 +1729,36 @@ fn share(s: &mut Session, p: &Value) -> Result<Value> {     Ok(s.live_summary()) } +/// Why this app can't be watched from a phone.+const NO_VIEWER: &str = "this build of FigCraft doesn't include the browser viewer, so it can't be watched from a phone (it is compiled in with FIGCRAFT_WEB_DIST: see docs/collab-protocol.md)";++/// Share the open file on the local network to watch it from a phone (`collab.viewOnDevice`): the+/// session's `viewer` is the address to open there. A local share that has one already is kept.+fn view_on_device(s: &mut Session, p: &Value) -> Result<Value> {+    const CMD: &str = "collab.viewOnDevice";+    if !s.live.can_host() {+        return Err(unavailable(CMD));+    }+    if !s.live.serves_viewer() {+        return Err(EngineError::Disabled(CMD.into(), NO_VIEWER.into()));+    }+    let live = s.live();+    if live.status.active() {+        if live.viewer.is_some() {+            return Ok(s.live_summary());+        }+        return Err(bad(CMD, "this file is in a session a phone can't watch (shared online, or joined): stop it first (collab.stop)"));+    }+    let mut params = json!({"via": "local"});+    for key in ["port", "bind"] {+        if let Some(v) = get(p, key) {+            params[key] = v.clone();+        }+    }+    share(s, &params)?;+    Ok(s.live_summary())+}+ /// What others join the open file's server file with changed: in its session and its record. fn shared_now(s: &mut Session, links: Option<Shared>) {     if let Some((server, file)) = s.live().server_file() {@@ -1998,6 +2051,13 @@ pub fn specs() -> Vec<CommandSpec> {             false,             share         ),+        spec!(+            "collab.viewOnDevice",+            "View on another device",+            "{port?, bind?: \"lan\" (default) | \"local\" | an IP address} → the open file shared on the local network as `collab.share {via: \"local\"}` does, as `collab.peers` gives it, with `viewer`: the address (http://ip:port/#view&join=…) a phone or another computer on the network opens in a browser to watch the prototype live, view only (the app serves the browser build on the session's port; the address carries a view-only secret). A local share that has one already is kept. Needs a desktop build with the browser viewer compiled in (`servesViewer` in `collab.peers`)",+            false,+            view_on_device+        ),         spec!(             "collab.stop",             "Stop live session",diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs--- a/crates/engine/src/live_share_tests.rs+++ b/crates/engine/src/live_share_tests.rs@@ -30,6 +30,10 @@ struct Script {     left: Vec<u64>,     events: Vec<Event>,     session: u64,+    /// Hosted sessions serve the browser viewer (a desktop build with it compiled in).+    viewer: bool,+    /// The browser's link: it only joins.+    browser: bool, }  struct Link(Arc<Mutex<Script>>);@@ -38,6 +42,17 @@ struct Link(Arc<Mutex<Script>>); const HOST: Local = Local { ip: Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };  impl LiveLink for Link {+    fn can_host(&self) -> bool {+        !self.0.lock().unwrap().browser+    }+    fn serves_viewer(&self) -> bool {+        self.0.lock().unwrap().viewer+    }+    fn viewer(&self, session: u64) -> Option<String> {+        let s = self.0.lock().unwrap();+        (s.viewer && s.session == session && s.left.last() != Some(&session))+            .then(|| "http://192.168.1.20:47900/#view&join=figcraft://join?host=192.168.1.20:47900&file=f&token=View".to_string())+    }     fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {         let mut s = self.0.lock().unwrap();         s.session = session;@@ -412,3 +427,50 @@ fn joining_takes_a_code_of_either_kind_or_a_link() {     assert_eq!((&r["status"], &r["reason"]), (&json!("failed"), &json!("No session with that code")));     assert_eq!(s.message.as_deref(), Some("No session with that code")); }++#[test]+fn viewing_on_another_device_shares_on_the_local_network_with_a_viewer_address() {+    let (mut s, script) = session();+    // A build without the browser viewer says so, and nothing is shared.+    let e = s.execute("collab.viewOnDevice", &json!({})).unwrap_err();+    assert!(matches!(&e, EngineError::Disabled(_, why) if why.contains("browser viewer")), "{e}");+    assert!(script.lock().unwrap().hosted.is_empty());+    assert_eq!(ex(&mut s, "collab.peers", json!({}))["servesViewer"], false);++    script.lock().unwrap().viewer = true;+    assert_eq!(ex(&mut s, "collab.peers", json!({}))["servesViewer"], true);+    let r = ex(&mut s, "collab.viewOnDevice", json!({}));+    assert_eq!((&r["status"], &r["role"], &r["share"]["via"]), (&json!("live"), &json!("host"), &json!("local")));+    let viewer = r["viewer"].as_str().unwrap();+    assert!(viewer.starts_with("http://192.168.1.20:47900/#view&join="), "{viewer}");+    assert_eq!(r["code"].as_str().map(str::len), Some(code::grouped(&HOST.code()).len()), "others can still join with the code");+    {+        let sc = script.lock().unwrap();+        let [h] = sc.hosted.as_slice() else { panic!("hosted {}", sc.hosted.len()) };+        assert!(h.short && h.bind == Bind::Lan, "on the local network, where the phone is");+    }+    // Again: the same share, its address kept.+    assert_eq!(ex(&mut s, "collab.viewOnDevice", json!({}))["viewer"], json!(viewer));+    assert_eq!(script.lock().unwrap().hosted.len(), 1);+    // A local share started from the popover has the address too.+    ex(&mut s, "collab.stop", json!({}));+    assert_eq!(ex(&mut s, "collab.peers", json!({}))["viewer"], Value::Null, "stopping takes the address away");+    assert!(ex(&mut s, "collab.share", json!({"via": "local"}))["viewer"].is_string());+    assert_eq!(ex(&mut s, "collab.viewOnDevice", json!({}))["viewer"], json!(viewer));+    assert_eq!(script.lock().unwrap().hosted.len(), 2);+    ex(&mut s, "collab.stop", json!({}));+    // Where to listen passes on.+    ex(&mut s, "collab.viewOnDevice", json!({"port": 5000}));+    assert_eq!(script.lock().unwrap().hosted[2].port, Some(5000));+    ex(&mut s, "collab.stop", json!({}));+    assert!(matches!(s.execute("collab.viewOnDevice", &json!({"port": "x"})), Err(EngineError::BadParams { .. })));++    // A file shared online can't be watched this way.+    ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}));+    ex(&mut s, "collab.share", json!({}));+    assert!(err(&mut s, "collab.viewOnDevice", json!({})).contains("stop it first"));++    // The browser build only joins.+    script.lock().unwrap().browser = true;+    assert!(matches!(s.execute("collab.viewOnDevice", &json!({})), Err(EngineError::Disabled(..))));+}diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml--- a/crates/ui-egui/Cargo.toml+++ b/crates/ui-egui/Cargo.toml@@ -25,6 +25,7 @@ log = { workspace = true } base64 = { workspace = true } image = { workspace = true } egui_extras = { workspace = true }+qrcodegen = { workspace = true } web-time = "1.1.0"  [build-dependencies]diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml--- a/crates/ui-egui/locales/en.toml+++ b/crates/ui-egui/locales/en.toml@@ -68,6 +68,7 @@ name = "English" "Added" = "Added" "Added the library \"{name}\"" = "Added the library \"{name}\"" "Adding to {name}" = "Adding to {name}"+"Address copied" = "Address copied" "Adjust" = "Adjust" "Adjustment presets" = "Adjustment presets" "Advanced" = "Advanced"@@ -305,6 +306,7 @@ name = "English" "Copied as text" = "Copied as text" "Copied {value}" = "Copied {value}" "Copy" = "Copy"+"Copy address" = "Copy address" "Copy as" = "Copy as" "Copy as CSS" = "Copy as CSS" "Copy as PNG" = "Copy as PNG"@@ -316,6 +318,7 @@ name = "English" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables"+"Couldn't open the shared file." = "Couldn't open the shared file." "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count"@@ -872,6 +875,7 @@ name = "English" "Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nothing online yet. Use Share on a file to put it online and it is listed here." "Nothing to export" = "Nothing to export"+"Nothing to present yet: add a frame to the file on the computer sharing it." = "Nothing to present yet: add a frame to the file on the computer sharing it." "Nov" = "Nov" "Now" = "Now" "Nudge (Shift: big nudge)" = "Nudge (Shift: big nudge)"@@ -916,6 +920,7 @@ name = "English" "Opened as a new document" = "Opened as a new document" "Opened “{name}” in a new tab" = "Opened “{name}” in a new tab" "Opening the file…" = "Opening the file…"+"Opening the shared file…" = "Opening the shared file…" "Opening {n} files as new files" = { one = "Opening as a new file", other = "Opening {n} files as new files" } "Open|comments" = "Open" "Options" = "Options"@@ -1142,6 +1147,7 @@ name = "English" "Run" = "Run" "Run {label}" = "Run {label}" "Save files automatically" = "Save files automatically"+"Scan it with a phone on the same network, or open the address in a browser there: it plays the prototype and follows your edits, view only, while this app stays open. The address carries a secret: share it only with people you invite." = "Scan it with a phone on the same network, or open the address in a browser there: it plays the prototype and follows your edits, view only, while this app stays open. The address carries a secret: share it only with people you invite." "Search templates" = "Search templates" "See all templates" = "See all templates" "Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes"@@ -1204,6 +1210,7 @@ name = "English" "Shape builder" = "Shape builder" "Shape tools" = "Shape tools" "Share" = "Share"+"Share on your network and watch the prototype on a phone, view only" = "Share on your network and watch the prototype on a phone, view only" "Share this file" = "Share this file" "Share with a code" = "Share with a code" "Share: live session, export" = "Share: live session, export"@@ -1342,7 +1349,9 @@ name = "English" "Text styles" = "Text styles" "Texts" = "Texts" "Texture" = "Texture"+"The address is too long for a QR code: copy it instead." = "The address is too long for a QR code: copy it instead." "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3."+"The connection dropped. Trying again…" = "The connection dropped. Trying again…" "The destination document is unavailable. Nothing was imported." = "The destination document is unavailable. Nothing was imported." "The destination document was closed. Nothing was imported." = "The destination document was closed. Nothing was imported." "The destination page was removed. Nothing was imported." = "The destination page was removed. Nothing was imported."@@ -1354,12 +1363,15 @@ name = "English" "The name in your online files and on the server. The document inside keeps its own name." = "The name in your online files and on the server. The document inside keeps its own name." "The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one" "The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}."+"The session ended. Share ▸ View on another device on the computer gives a new address." = "The session ended. Share ▸ View on another device on the computer gives a new address." "The whole file" = "The whole file" "Theme" = "Theme" "There's no destination for that action" = "There's no destination for that action" "Thickness" = "Thickness"+"This address doesn't name a shared file. Open the address Share ▸ View on another device shows." = "This address doesn't name a shared file. Open the address Share ▸ View on another device shows." "This branch can't be reviewed: {e}" = "This branch can't be reviewed: {e}" "This branch was merged or archived." = "This branch was merged or archived."+"This build of FigCraft doesn't include the browser viewer a phone needs (see docs/collab-protocol.md)." = "This build of FigCraft doesn't include the browser viewer a phone needs (see docs/collab-protocol.md)." "This computer only" = "This computer only" "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." "This condition is edited as an expression" = "This condition is edited as an expression"@@ -1459,6 +1471,7 @@ name = "English" "View" = "View" "View link" = "View link" "View link copied" = "View link copied"+"View on another device" = "View on another device" "View only" = "View only" "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." "Viewing “{name}”" = "Viewing “{name}”"diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml--- a/crates/ui-egui/locales/es.toml+++ b/crates/ui-egui/locales/es.toml@@ -9,6 +9,8 @@ status = "draft — needs native review"  [ui] "Compatibility notes" = "Notas de compatibilidad"+"Address copied" = "Dirección copiada"+"Copy address" = "Copiar dirección" "Couldn't download the variables" = "No se pudieron descargar las variables"  "% of all commits" = "% del total de commits"@@ -317,6 +319,7 @@ status = "draft — needs native review" "Copy {value}" = "Copiar {value}" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas"+"Couldn't open the shared file." = "No se ha podido abrir el archivo compartido." "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." "Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número"@@ -873,6 +876,7 @@ status = "draft — needs native review" "Nothing here yet. Set a frame's status to list it." = "Aún no hay nada. Asigna un estado a un marco para verlo aquí." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Aún no hay nada en línea. Usa Compartir en un archivo para ponerlo en línea y aparecerá aquí." "Nothing to export" = "Nada que exportar"+"Nothing to present yet: add a frame to the file on the computer sharing it." = "Aún no hay nada que presentar: añade un marco al archivo en el ordenador que lo comparte." "Nov" = "nov" "Now" = "Ahora" "Nudge (Shift: big nudge)" = "Desplazar (Mayús: desplazamiento grande)"@@ -917,6 +921,7 @@ status = "draft — needs native review" "Opened as a new document" = "Abierto como un documento nuevo" "Opened “{name}” in a new tab" = "“{name}” abierta en una pestaña nueva" "Opening the file…" = "Abriendo el archivo…"+"Opening the shared file…" = "Abriendo el archivo compartido…" "Opening {n} files as new files" = { one = "Abriendo como archivo nuevo", other = "Abriendo {n} archivos como archivos nuevos" } "Open|comments" = "Abiertos" "Options" = "Opciones"@@ -1143,6 +1148,7 @@ status = "draft — needs native review" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" "Save files automatically" = "Guardar archivos automáticamente"+"Scan it with a phone on the same network, or open the address in a browser there: it plays the prototype and follows your edits, view only, while this app stays open. The address carries a secret: share it only with people you invite." = "Escanéalo con un teléfono de la misma red o abre la dirección en un navegador allí: reproduce el prototipo y sigue tus cambios, en solo lectura, mientras esta aplicación siga abierta. La dirección lleva un secreto: compártela solo con quien invites." "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" "Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido"@@ -1205,6 +1211,7 @@ status = "draft — needs native review" "Shape builder" = "Generador de formas" "Shape tools" = "Herramientas de forma" "Share" = "Compartir"+"Share on your network and watch the prototype on a phone, view only" = "Comparte en tu red y mira el prototipo en un teléfono, en solo lectura" "Share this file" = "Compartir este archivo" "Share with a code" = "Comparte con un código" "Share: live session, export" = "Compartir: sesión en vivo, exportar"@@ -1343,7 +1350,9 @@ status = "draft — needs native review" "Text styles" = "Estilos de texto" "Texts" = "Textos" "Texture" = "Textura"+"The address is too long for a QR code: copy it instead." = "La dirección es demasiado larga para un código QR: cópiala." "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "El lienzo muestra los colores en sRGB; los archivos PNG, JPG y WebP exportados se etiquetan como Display P3."+"The connection dropped. Trying again…" = "Se ha perdido la conexión. Volviendo a intentarlo…" "The destination document is unavailable. Nothing was imported." = "El documento de destino no está disponible. No se importó nada." "The destination document was closed. Nothing was imported." = "Se cerró el documento de destino. No se importó nada." "The destination page was removed. Nothing was imported." = "Se eliminó la página de destino. No se importó nada."@@ -1355,12 +1364,15 @@ status = "draft — needs native review" "The name in your online files and on the server. The document inside keeps its own name." = "El nombre en tus archivos en línea y en el servidor. El documento que contiene conserva su propio nombre." "The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno" "The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}."+"The session ended. Share ▸ View on another device on the computer gives a new address." = "La sesión ha terminado. Compartir ▸ Ver en otro dispositivo, en el ordenador, da una dirección nueva." "The whole file" = "El archivo completo" "Theme" = "Tema" "There's no destination for that action" = "No hay destino para esa acción" "Thickness" = "Grosor"+"This address doesn't name a shared file. Open the address Share ▸ View on another device shows." = "Esta dirección no indica ningún archivo compartido. Abre la que muestra Compartir ▸ Ver en otro dispositivo." "This branch can't be reviewed: {e}" = "Esta rama no se puede revisar: {e}" "This branch was merged or archived." = "Esta rama se fusionó o archivó."+"This build of FigCraft doesn't include the browser viewer a phone needs (see docs/collab-protocol.md)." = "Esta versión de FigCraft no incluye el visor del navegador que necesita un teléfono (consulta docs/collab-protocol.md)." "This computer only" = "Solo este ordenador" "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Este ordenador recuerda el token de propietario, así que el archivo se puede gestionar desde la lista En línea de la pantalla de inicio. Cópialo para gestionar el archivo desde otro lugar: es una contraseña para este archivo y no se volverá a mostrar." "This condition is edited as an expression" = "Esta condición se edita como expresión"@@ -1460,6 +1472,7 @@ status = "draft — needs native review" "View" = "Ver" "View link" = "Enlace de lectura" "View link copied" = "Enlace de lectura copiado"+"View on another device" = "Ver en otro dispositivo" "View only" = "Solo lectura" "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Solo lectura: ves cada cambio al momento, pero no puedes editar. Sal de la sesión para quedarte con una copia editable." "Viewing “{name}”" = "Viendo “{name}”"@@ -1751,6 +1764,7 @@ status = "draft — needs native review" "collab.share" = "Compartir con un código para unirse" "collab.spotlight" = "Ponerme en el foco" "collab.stop" = "Detener sesión en vivo"+"collab.viewOnDevice" = "Ver en otro dispositivo" "command.batch" = "Ejecutar varios comandos" "comment.add" = "Añadir comentario" "comment.delete" = "Eliminar comentario"diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -47,6 +47,7 @@ pub mod present; pub mod proto_canvas; pub mod proto_editor; pub mod proto_panel;+pub mod qr; pub mod render_worker; pub mod right; pub mod scale_panel;@@ -62,6 +63,7 @@ pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; pub mod versions;+pub mod viewer; pub mod widgets; pub mod windows; @@ -461,6 +463,9 @@ pub struct UiState {     pub present_view: present::PresentView,     /// The prototype preview pane floating over the canvas, when open.     pub preview: Option<present::Pane>,+    /// The window is only a device viewer: a phone's browser watching a shared file's prototype+    /// (the browser build opened with `#view&join=…`; [`viewer`]).+    pub viewer: Option<viewer::Viewer>,     pub picker: Option<colorpicker::PickerTarget>,     pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,     /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the@@ -595,6 +600,7 @@ impl Default for UiState {             toast: None,             present: None,             present_view: present::PresentView::default(),+            viewer: None,             preview: None,             picker: None,             context_menu: None,@@ -1395,6 +1401,11 @@ impl FigApp {         // The Inspect tab measures on hover without Alt.         self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect;         self.end_pen_outside_design();+        if self.ui.viewer.is_some() {+            viewer::show(self, ui);+            self.toast_ui(&ctx);+            return;+        }         if self.ui.present.is_some() {             present::show(self, ui);             // What went wrong while presenting (a widget's plugin failing) shows over it.diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs--- a/crates/ui-egui/src/live.rs+++ b/crates/ui-egui/src/live.rs@@ -48,6 +48,14 @@ const OWNER_NOTE: &str = tr_noop(     "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again.", ); +/// What the address for another device does.+const DEVICE_NOTE: &str = tr_noop(+    "Scan it with a phone on the same network, or open the address in a browser there: it plays the prototype and follows your edits, view only, while this app stays open. The address carries a secret: share it only with people you invite.",+);++/// Why this build has no View on another device.+const NO_DEVICE_NOTE: &str = tr_noop("This build of FigCraft doesn't include the browser viewer a phone needs (see docs/collab-protocol.md).");+ /// What a server open to the public can see. const PLAIN_NOTE: &str = tr_noop("Files aren't end-to-end encrypted: the server's operator can read them."); @@ -124,6 +132,9 @@ pub struct LiveUi {     chat_seen: BTreeMap<u32, (String, f64)>,     /// Your cursor chat (open when `Some`).     pub chat: Option<ChatBox>,+    /// Sharing on the local network: the part that shows the address (and its QR code) a phone+    /// opens to watch the file is open.+    pub device: bool, }  /// What the Publish to server dialog asks for.@@ -740,6 +751,15 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {         }         app.run("collab.share", params);     }+    let viewer = app.session.live.serves_viewer();+    let hint = if viewer { tr("Share on your network and watch the prototype on a phone, view only") } else { tr(NO_DEVICE_NOTE) };+    if widgets::button(ui, tr("View on another device"), false, viewer).on_hover_text(hint).clicked() {+        let mut params = json!({});+        if let Ok(port) = app.ui.live.port.trim().parse::<u16>() {+            params["port"] = json!(port);+        }+        app.ui.live.device = app.run("collab.viewOnDevice", params).is_some();+    }     join_and_advanced(app, ui, t, |app, ui, t| {         if local {             if widgets::checkbox(ui, app.ui.live.this_computer, tr("This computer only")) {@@ -868,6 +888,7 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {     let me = live.info.as_ref().map(|i| i.name.clone()).unwrap_or_default();     let names: Vec<String> = std::iter::once(trf("{name} (you)", &[("name", &me)])).chain(live.peers.values().map(|p| p.name.clone())).collect();     let hosting = live.hosting.clone();+    let viewer = live.viewer.clone().filter(|_| !online);     match share.code() {         Some(code) => {             ui.vertical_centered(|ui| {@@ -904,6 +925,9 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {         (false, _) => tr("Anyone on your network with this code can edit the file, while this app stays open."),     };     widgets::label(ui, who, t.text_secondary);+    if let Some(viewer) = viewer {+        device_ui(app, ui, t, &viewer);+    }     let here = trf("Here: {names}", &[("names", &names.join(", "))]);     ui.label(egui::RichText::new(widgets::elide(ui, &here, ui.available_width())).font(regular(11.0)).color(t.text_secondary)).on_hover_text(here);     if let Some(h) = &hosting {@@ -913,6 +937,30 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {     stop(app, ui); } +/// Sharing on the local network: View on another device opens the address a phone watches the file+/// at, as a QR code to scan and to copy.+fn device_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, viewer: &str) {+    if widgets::disclosure(ui, tr("View on another device"), app.ui.live.device) {+        app.ui.live.device = !app.ui.live.device;+    }+    if !app.ui.live.device {+        return;+    }+    ui.vertical_centered(|ui| {+        if !crate::qr::show(ui, viewer, DEVICE_QR) {+            widgets::label(ui, tr("The address is too long for a QR code: copy it instead."), t.text_secondary);+        }+    });+    if widgets::button(ui, tr("Copy address"), false, true).on_hover_text(viewer).clicked() {+        app.services.clipboard_set_text(viewer);+        app.toast(tr("Address copied"));+    }+    widgets::label(ui, tr(DEVICE_NOTE), t.text_secondary);+}++/// How wide the QR code for another device is drawn (points).+const DEVICE_QR: f32 = 196.0;+ /// Hosting: the invite link and URL to copy, and stop. fn host_ui(app: &mut FigApp, ui: &mut Ui) {     let Some(info) = app.session.live().info.clone() else { return };@@ -1281,6 +1329,8 @@ pub fn inspect(app: &FigApp) -> serde_json::Value {         "code": l.share.as_ref().and_then(Share::code),         "share": l.share.as_ref().map(|s| json!({"via": s.via, "access": s.access.id(), "shared": s.links.is_some()})),         "local": app.ui.live.local,+        "device": app.ui.live.device,+        "viewer": l.viewer,         "advanced": app.ui.live.advanced,         "publishDialog": app.ui.live.publish.is_some(),         "publishInfo": app.ui.live.publish.as_ref().and_then(|f| f.info.as_ref().map(|(_, v)| v.clone())),diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs--- a/crates/ui-egui/src/live_share_tests.rs+++ b/crates/ui-egui/src/live_share_tests.rs@@ -35,8 +35,15 @@ struct Script {     shares: usize,     removed: Vec<String>,     answers: Vec<LiveEvent>,+    /// Hosted sessions serve the browser viewer.+    viewer: bool,+    /// The browser's link: it only joins.+    browser: bool, } +/// The address the scripted host gives for watching from a phone.+const VIEWER: &str = "http://192.168.1.20:47900/#view&join=figcraft://join?host=192.168.1.20:47900&file=f&token=ViewViewViewViewView00";+ /// The links a server makes for others the `n`th time a file is shared, with their codes. fn links(n: usize, codes: Option<(&str, &str)>) -> SharedLinks {     let link = |what: &str, code: Option<&str>| SharedLink {@@ -73,6 +80,15 @@ fn info(role: Role, link: &str) -> SessionInfo { }  impl LiveLink for Link {+    fn can_host(&self) -> bool {+        !self.0.lock().unwrap().browser+    }+    fn serves_viewer(&self) -> bool {+        self.0.lock().unwrap().viewer+    }+    fn viewer(&self, _: u64) -> Option<String> {+        self.serves_viewer().then(|| VIEWER.to_string())+    }     fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {         let mut s = self.0.lock().unwrap();         s.session = session;@@ -497,3 +513,232 @@ fn an_open_popover_asks_for_no_frames() {     let (shapes, delay) = h.frame(vec![]);     assert!(has_text(&shapes, "K7M-2QX-9TD") && delay >= Duration::from_secs(1)); }++#[test]+fn view_on_another_device_shares_locally_and_shows_the_address_as_a_qr_code() {+    let (mut h, script) = harness();+    // A build without the browser viewer offers it greyed out.+    let shapes = open(&mut h);+    assert!(has_text(&shapes, "View on another device"));+    let shapes = click(&mut h, &shapes, "View on another device");+    assert!(script.lock().unwrap().hosted.is_empty(), "nothing is shared");+    assert!(has_text(&shapes, "Start sharing"));++    script.lock().unwrap().viewer = true;+    // (A frame for the button to take clicks again.)+    let shapes = h.frames(2);+    let shapes = click(&mut h, &shapes, "View on another device");+    {+        let s = script.lock().unwrap();+        let [hosted] = s.hosted.as_slice() else { panic!("hosted {}", s.hosted.len()) };+        assert!(hosted.short && hosted.bind == figcraft_engine::live::Bind::Lan, "on the local network, where the phone is");+        assert!(s.published.is_empty(), "nothing goes to a server");+    }+    for t in ["Copy address", "Copy code", "Stop sharing"] {+        assert!(has_text(&shapes, t), "sharing shows {t:?}");+    }+    assert!(has_text(+        &shapes,+        "Scan it with a phone on the same network, or open the address in a browser there: it plays the prototype and follows your edits, view only, while this app stays open. The address carries a secret: share it only with people you invite."+    ));+    let cells = crate::qr::cells(VIEWER).unwrap();+    let dark = cells.iter().flatten().filter(|c| **c).count();+    let squares = shapes.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == egui::Color32::BLACK)).count();+    assert!(squares > 0 && squares <= dark, "the QR code is drawn ({squares} dark runs for {dark} dark cells)");+    assert_eq!(crate::live::inspect(&h.app)["viewer"], VIEWER);+    let shapes = click(&mut h, &shapes, "Copy address");+    assert_eq!(script.lock().unwrap().copied, [VIEWER]);+    // It folds away; the share goes on.+    let shapes = click(&mut h, &shapes, "View on another device");+    assert!(!has_text(&shapes, "Copy address") && has_text(&shapes, "Stop sharing"));+    let shapes = click(&mut h, &shapes, "Stop sharing");+    // A share started the usual way has it too, folded.+    let shapes = click(&mut h, &shapes, "Local network");+    let shapes = click(&mut h, &shapes, "Start sharing");+    assert!(has_text(&shapes, "View on another device") && !has_text(&shapes, "Copy address"));+}++#[test]+fn a_qr_code_holds_the_address() {+    let cells = crate::qr::cells(VIEWER).unwrap();+    // Version 7 or so: square, with the three finder patterns' dark corners.+    let n = cells.len();+    assert!((21..=57).contains(&n) && cells.iter().all(|r| r.len() == n), "{n}");+    for (x, y) in [(0, 0), (n - 1, 0), (0, n - 1)] {+        assert!(cells[y][x], "finder corner at {x},{y}");+    }+    assert!(crate::qr::cells(&"x".repeat(8000)).is_none(), "too long for any QR code");+}++#[test]+fn the_device_viewer_presents_the_shared_file_and_says_when_it_ended() {+    let (mut h, script) = harness();+    // The shared file has a frame to present.+    h.app.session.execute("frame.create", &json!({"x": 0, "y": 0, "width": 390, "height": 844})).unwrap();+    crate::viewer::open(&mut h.app, Some(VIEWER.split_once("join=").unwrap().1));+    let shapes = h.frames(2);+    assert_eq!(script.lock().unwrap().joined.len(), 1, "it joins the address's link");+    assert!(has_text(&shapes, "Opening the shared file…"));+    assert!(!has_text(&shapes, "Share"), "no editor");+    script.lock().unwrap().events.push(LiveEvent::Welcome { info: SessionInfo { access: Access::View, ..info(Role::Guest, PLAIN) }, peers: vec![] });+    h.frames(2);+    document_arrives(&mut h, &script);+    let shapes = h.frames(2);+    assert!(h.app.ui.present.is_some(), "the prototype plays");+    assert_eq!(h.app.ui.present_view, crate::viewer::present_view(), "sized for the phone, no device drawn around it");+    assert!(has_text(&shapes, "Restart"));+    for t in ["Close", "Options", "Inspect", "Comments"] {+        assert!(!has_text(&shapes, t), "nothing to go back to or change: {t:?}");+    }+    // Escape has nothing to close to.+    key(&mut h, Key::Escape);+    assert!(h.app.ui.present.is_some());+    // The host stops: the copy shown isn't the shared file any more.+    script.lock().unwrap().events.push(LiveEvent::Ended { reason: "The host ended the session".into() });+    let shapes = h.frames(3);+    assert!(h.app.ui.present.is_none());+    assert!(has_text(&shapes, "The session ended. Share ▸ View on another device on the computer gives a new address."));+}++#[test]+fn a_device_viewer_without_a_link_or_frames_says_why() {+    let (mut h, script) = harness();+    crate::viewer::open(&mut h.app, None);+    let shapes = h.frames(2);+    assert!(has_text(+        &shapes,+        "Couldn't open the shared file.\n\nThis address doesn't name a shared file. Open the address Share ▸ View on another device shows."+    ));+    assert!(script.lock().unwrap().joined.is_empty());+    // A file with nothing to present.+    let (mut h, script) = harness();+    crate::viewer::open(&mut h.app, Some("figcraft://join?host=192.168.1.20:47900&file=f&token=V"));+    script.lock().unwrap().events.push(LiveEvent::Welcome { info: info(Role::Guest, PLAIN), peers: vec![] });+    h.frames(2);+    document_arrives(&mut h, &script);+    let shapes = h.frames(2);+    assert!(h.app.ui.present.is_none());+    assert!(has_text(&shapes, "Nothing to present yet: add a frame to the file on the computer sharing it."));+}++#[test]+fn the_viewer_stage_follows_the_session() {+    use crate::viewer::{Stage, stage};+    assert_eq!(stage(&Status::Off, false, false), Stage::Opening);+    assert_eq!(stage(&Status::Connecting, false, false), Stage::Opening);+    assert_eq!(stage(&Status::Live, false, true), Stage::Opening, "live, the file on its way");+    assert_eq!(stage(&Status::Live, true, true), Stage::Present);+    assert_eq!(stage(&Status::Reconnecting { attempt: 2 }, true, true), Stage::Reconnecting);+    assert_eq!(stage(&Status::Failed { reason: "gone".into() }, false, false), Stage::Failed("gone".into()));+    assert_eq!(stage(&Status::Off, false, true), Stage::Ended);+}++/// A file of two screens: a button on the first navigates to the second.+fn two_screens(h: &mut Harness) -> (u64, u64, u64) {+    let id = |v: serde_json::Value| v["id"].as_u64().unwrap();+    let s = &mut h.app.session;+    let a = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 390, "height": 844})).unwrap());+    let b = id(s.execute("frame.create", &json!({"x": 500, "y": 0, "width": 390, "height": 844})).unwrap());+    let button = id(s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 300, "height": 60, "parent": a})).unwrap());+    s.execute("proto.interaction.add", &json!({"id": button, "dest": b})).unwrap();+    s.execute("proto.flow.add", &json!({"start": a})).unwrap();+    (a, b, button)+}++fn renders(h: &FigApp) -> u64 {+    h.ui.present.as_ref().unwrap().inspect()["renders"].as_u64().unwrap()+}++use crate::FigApp;++#[test]+fn the_device_viewer_draws_the_next_screen_ahead_and_never_twice() {+    let (mut h, script) = harness();+    let (a, b, button) = two_screens(&mut h);+    crate::viewer::open(&mut h.app, Some("figcraft://join?host=192.168.1.20:47900&file=f&token=V"));+    script.lock().unwrap().events.push(LiveEvent::Welcome { info: SessionInfo { access: Access::View, ..info(Role::Guest, PLAIN) }, peers: vec![] });+    h.frames(2);+    document_arrives(&mut h, &script);+    h.frames(2);+    let pres = h.app.ui.present.as_ref().unwrap();+    assert_eq!(pres.inspect()["current"], json!(a));+    assert_eq!(pres.max_px, Some(2.0), "a phone draws at most 2 pixels a point");+    assert_eq!(renders(&h.app), 1, "the first screen, at once");+    // Nothing is drawn ahead while the screen is being used.+    h.frames(30);+    assert_eq!(renders(&h.app), 1, "not before a pause");+    // After a pause the screen the button leads to is drawn, smaller.+    h.skip(1.5);+    h.frames(3);+    assert_eq!(renders(&h.app), 2, "drawn ahead");+    h.frames(5);+    assert_eq!(renders(&h.app), 2, "once");+    // The tap shows it at once, from the drawing made ahead.+    let place = h.app.ui.present.as_ref().unwrap().shown_place().unwrap();+    let r = h.app.session.doc().unwrap().world_bounds(figcraft_doc::NodeId(button)).unwrap();+    h.click(place.rect(r).center());+    h.frames(5);+    assert_eq!(h.app.ui.present.as_ref().unwrap().inspect()["current"], json!(b));+    assert_eq!(renders(&h.app), 2, "shown without drawing");+    // At the next pause it sharpens, once.+    h.skip(1.5);+    h.frames(5);+    assert_eq!(renders(&h.app), 3, "drawn sharp");+    h.skip(1.5);+    h.frames(5);+    assert_eq!(renders(&h.app), 3, "nothing drawn twice");+}++#[test]+fn a_frame_draws_the_same_until_what_it_shows_changes() {+    use crate::present::same_picture;+    let mut s = figcraft_engine::Session::new();+    let a = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();+    let b = s.execute("frame.create", &json!({"x": 200, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();+    let before = s.doc().unwrap().clone();+    let (a, b) = (figcraft_doc::NodeId(a), figcraft_doc::NodeId(b));+    assert!(same_picture(&before, &before.clone(), a));+    s.execute("shape.rectangle", &json!({"x": 220, "y": 20, "width": 10, "height": 10, "parent": b.0})).unwrap();+    let after = s.doc().unwrap().clone();+    assert!(same_picture(&before, &after, a), "another frame changed");+    assert!(!same_picture(&before, &after, b), "a layer was added to it");+    s.execute("node.set", &json!({"id": a.0, "props": {"opacity": 0.5}})).unwrap();+    assert!(!same_picture(&after, s.doc().unwrap(), a), "the frame itself changed");+    // A variable mode set on the page (or a section around the frame) changes what it shows.+    s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();+    s.execute("variable.create", &json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#FFFFFF", "Dark": "#000000"}}))+        .unwrap();+    let light = s.doc().unwrap().clone();+    let page = s.ds().unwrap().page;+    s.execute("variable.setMode", &json!({"ids": [page.0], "collection": "Theme", "mode": "Dark"})).unwrap();+    assert!(!same_picture(&light, s.doc().unwrap(), a), "the page's mode changed");+}++/// The Actions palette rows for `q`, by key.+fn palette_keys(app: &FigApp, q: &str) -> Vec<String> {+    crate::menus::palette_sections(app, q, crate::menus::PaletteTab::All).into_iter().flat_map(|(_, rows)| rows).map(|r| r.key).collect()+}++#[test]+fn view_on_another_device_is_offered_only_where_it_can_work() {+    const KEY: &str = "command:collab.viewOnDevice";+    // A desktop build with the browser viewer: in the popover and the Actions palette.+    let (mut h, script) = harness();+    script.lock().unwrap().viewer = true;+    assert!(has_text(&open(&mut h), "View on another device"));+    assert!(palette_keys(&h.app, "another device").iter().any(|k| k == KEY));+    // Without it: greyed out in the popover (it says why), not in the palette.+    let (mut h, _) = harness();+    assert!(has_text(&open(&mut h), "View on another device"));+    assert!(!palette_keys(&h.app, "another device").iter().any(|k| k == KEY));+    // A recent action that can't run here isn't offered either.+    h.app.session.prefs.recent_actions = vec![KEY.into()];+    assert!(!palette_keys(&h.app, "").iter().any(|k| k == KEY));+    // The browser build hosts nothing: neither shows it.+    let (mut h, script) = harness();+    script.lock().unwrap().browser = true;+    let shapes = open(&mut h);+    assert!(has_text(&shapes, "Sharing a file needs the desktop app; here you can join one."));+    assert!(!has_text(&shapes, "View on another device"));+    assert!(!palette_keys(&h.app, "another device").iter().any(|k| k == KEY));+}diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -241,6 +241,12 @@ fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId     String::from_utf8(bytes).ok() } +/// A command this window can run at all, so the Actions palette offers it: watching on another+/// device needs a desktop build with the browser viewer compiled in (never the browser build).+pub fn offered(app: &FigApp, id: &str) -> bool {+    id != "collab.viewOnDevice" || app.session.live.serves_viewer()+}+ /// Whether a menu id is available (registered in the engine or handled by the UI). pub fn available(id: &str) -> bool {     !id.is_empty() && (figcraft_engine::find_command(id).is_some() || UI_COMMANDS.contains(&id))@@ -847,7 +853,8 @@ pub(crate) fn palette_sections(app: &FigApp, q: &str, tab: PaletteTab) -> Vec<(S         PaletteTab::All => {             let recents = app.session.prefs.recent_actions.iter().filter_map(|key| recent_row(app, key, q)).collect();             let mut out = vec![(tr("Recents").to_string(), recents)];-            out.extend(command_sections(q));+            let offered = |r: &ActionRow| r.key.strip_prefix("command:").is_none_or(|id| offered(app, id));+            out.extend(command_sections(q).into_iter().map(|(g, rows)| (g, rows.into_iter().filter(offered).collect())));             out         }         PaletteTab::Assets => asset_sections(app, q),@@ -1069,7 +1076,7 @@ pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option<ActionRow>         let menu_label = figcraft_engine::catalog::MENU.iter().find(|m| m.id == id);         let english = menu_label.map(|m| m.label).or_else(|| spec.map(|s| s.label)).unwrap_or(id);         let label = i18n::command(id, english);-        if !available(id) || !matches(&ql, &label, &format!("{english} {id}")) {+        if !available(id) || !offered(app, id) || !matches(&ql, &label, &format!("{english} {id}")) {             return None;         }         return Some(command_row(id, label.into_owned()));diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs--- a/crates/ui-egui/src/present.rs+++ b/crates/ui-egui/src/present.rs@@ -82,6 +82,9 @@ struct FrameTex {     tex: egui::TextureHandle,     /// Page-space rect the texture covers.     bounds: figcraft_geom::Rect,+    /// The document it was drawn from: a later revision that draws the frame the same way+    /// ([`same_picture`]) keeps the texture, so navigating doesn't draw what is already drawn.+    doc: Document, }  /// A transition in progress from a captured state to the current one.@@ -254,6 +257,17 @@ pub struct Presenter {     pub scroll: HashMap<NodeId, (f64, f64)>,     /// Milliseconds the last smart-animate frame took to build and render.     pub render_ms: f64,+    /// The most pixels per point frames are drawn at (`None`: the screen's). The device viewer+    /// caps it: a phone's browser draws on one thread, and a 3× screen has 2.25 times the pixels of+    /// a 2× one for little that shows.+    pub max_px: Option<f64>,+    /// Draw the frames the presented one navigates to ahead of a tap ([`draw_ahead`](Self::draw_ahead)):+    /// the device viewer, where drawing a frame takes long enough to feel.+    pub ahead: bool,+    /// When something last moved (a touch, scrolling): drawing ahead waits for a pause.+    still_since: f64,+    /// Frames drawn into textures so far (`ui.inspect`: how much drawing a prototype costs).+    renders: u64,     start: NodeId,     original: Option<Document>,     /// The player's document as designed, with what the prototype changed (variables set,@@ -330,6 +344,10 @@ impl Presenter {      pub fn new(start: NodeId) -> Self {         Self {+            max_px: None,+            ahead: false,+            renders: 0,+            still_since: 0.0,             current: start,             history: Vec::new(),             overlays: Vec::new(),@@ -483,19 +501,64 @@ impl Presenter {     /// when the prototype is responsive (its constraints and auto layout respond), instances     /// playing their variants' interactions and fixed layers on top.     fn relayout(&mut self) {-        let mut d = self.design.clone();-        if let Some(d) = d.as_mut() {-            if let Some(size) = self.fit {-                figcraft_engine::cmd::proto::respond(d, self.current, size);-            }-            figcraft_engine::proto_run::play_interactions(d);-            raise_fixed(d);-        }+        let d = self.laid_out(self.current);         self.uses_pads = d.as_ref().is_some_and(figcraft_doc::proto::uses_gamepad);         self.doc = d;         self.rev += 1;     } +    /// `design` laid out as it shows when `frame` is presented (see [`relayout`](Self::relayout)).+    fn laid_out(&self, frame: NodeId) -> Option<Document> {+        let mut d = self.design.clone()?;+        if let Some(size) = self.fit {+            figcraft_engine::cmd::proto::respond(&mut d, frame, size);+        }+        figcraft_engine::proto_run::play_interactions(&mut d);+        raise_fixed(&mut d);+        Some(d)+    }++    /// Draw ahead, while nothing moves, one thing at a time (drawing blocks the page): the presented+    /// frame sharp when it shows a smaller drawing made ahead (`shown`: as it shows), else a frame it+    /// navigates to that isn't drawn yet, at [`AHEAD_SCALE`] of `px` (as it will show: laid out for+    /// it, on the screen, at the top), so a tap shows it at once and it sharpens at the next pause.+    /// True when it drew something (there may be more).+    fn draw_ahead(&mut self, ctx: &egui::Context, shown: &Document, px: f64, now: f64) -> bool {+        if self.anim.is_some() || self.fling.is_some() || self.scroll_anim.is_some() || ctx.input(|i| i.pointer.any_down()) {+            return false;+        }+        if self.textures.get(&self.current).is_some_and(|t| f64::from_bits(t.key.1) < px) {+            return self.texture(ctx, shown, self.current, px, now, false).is_some();+        }+        let ahead = px * AHEAD_SCALE;+        let Some(doc) = self.doc.as_ref() else { return false };+        let mut dests: Vec<NodeId> = Vec::new();+        for id in doc.subtree(self.current) {+            let Some(n) = doc.node(id) else { continue };+            for a in n.interactions.iter().flat_map(|i| &i.actions) {+                if let Action::Navigate { dest, .. } = a+                    && *dest != self.current+                    && doc.contains(*dest)+                    && !dests.contains(dest)+                {+                    dests.push(*dest);+                }+            }+        }+        for dest in dests.into_iter().take(MAX_DRAWN_AHEAD) {+            if self.textures.get(&dest).is_some_and(|t| f64::from_bits(t.key.1) >= ahead) {+                continue;+            }+            let Some(mut d) = self.laid_out(dest) else { return false };+            if let Some(vp) = viewport(&d, dest) {+                fit_viewport(&mut d, dest, vp);+            }+            let _ = self.texture(ctx, &d, dest, ahead, now, false);+            return true;+        }+        false+    }+     /// Responsive prototypes: resize the presented frame to a screen of `size` (None: frames     /// keep their size). Lays out again only when it changes.     fn respond_to(&mut self, size: Option<(f64, f64)>) {@@ -567,12 +630,19 @@ impl Presenter {         self.rev += 1;     } +    /// Where the presented frame showed last (tests: where to tap).+    #[cfg(test)]+    pub(crate) fn shown_place(&self) -> Option<Place> {+        self.shown.map(|s| s.1)+    }+     /// The player's state for `ui.inspect`.     pub fn inspect(&self) -> Value {         let doc = self.doc.as_ref();         let pan = self.offset(Scroller::View);         json!({             "current": self.current,+            "renders": self.renders,             "name": doc.and_then(|d| d.node(self.current)).map(|n| n.name.as_str()),             "start": self.start,             "history": self.history.iter().map(|v| v.frame).collect::<Vec<_>>(),@@ -901,7 +971,9 @@ impl Presenter {         }     } -    fn texture(&mut self, ctx: &egui::Context, doc: &Document, id: NodeId, px_scale: f64, now: f64) -> Option<FrameTex> {+    /// Frame `id` of `doc` drawn at `px_scale` pixels a page unit: drawn now unless it is drawn+    /// already. `smaller`: a smaller drawing of the same picture will do for now (made ahead).+    fn texture(&mut self, ctx: &egui::Context, doc: &Document, id: NodeId, px_scale: f64, now: f64, smaller: bool) -> Option<FrameTex> {         // Animated GIFs play from when the frame arrived: a new texture when one of them moves on,         // and a wake-up for that moment (nothing else draws frames while it shows). egui wakes a         // display frame before the time asked for, a little earlier still at times: ask that much@@ -912,21 +984,60 @@ impl Presenter {             crate::repaint_in(ctx, next + f64::from(ctx.input(|i| i.predicted_dt)));         }         let key = (self.rev, px_scale.to_bits(), clock.map_or(0, |c| c.0));-        if let Some(t) = self.textures.get(&id)-            && t.key == key+        if let Some(t) = self.textures.get_mut(&id)+            && t.key.2 == key.2+            && (t.key.1 == key.1 || (smaller && f64::from_bits(t.key.1) < px_scale))+            && (t.key.0 == key.0 || same_picture(&t.doc, doc, id))         {+            t.key.0 = key.0;             return Some(t.clone());         }         let bounds = figcraft_render::export_bounds(doc, id)?;         let pix = figcraft_render::render_node_at(doc, id, texture_scale(ctx, bounds, px_scale), t)?;+        self.renders += 1;         let img = crate::render_worker::screen_image(&pix, doc.color_profile);         let tex = ctx.load_texture(format!("present-{}", id.0), img, egui::TextureOptions::LINEAR);-        let ft = FrameTex { key, tex, bounds };+        let ft = FrameTex { key, tex, bounds, doc: doc.clone() };         self.textures.insert(id, ft.clone());         Some(ft)     } } +/// How long nothing has to move (no touch, no scrolling, no frame arriving) before the device+/// viewer draws ahead (seconds): drawing blocks the page, so never while someone uses it.+const AHEAD_IDLE: f64 = 1.0;++/// The fraction of the full pixel scale frames are drawn ahead at: a quarter of the pixels, so+/// each pause blocks the page for less; the frame sharpens at the pause after it shows.+const AHEAD_SCALE: f64 = 0.5;++/// The most frames one screen leads to that [`Presenter::draw_ahead`] draws ahead (the first ones+/// its layers name).+const MAX_DRAWN_AHEAD: usize = 8;++/// Frame `id` draws the same in `a` and `b`: its layers, the variable modes its page and the+/// sections around it set, and the file's variables, styles, colour profile and images (named by+/// their content's hash) are the same.+pub(crate) fn same_picture(a: &Document, b: &Document, id: NodeId) -> bool {+    if a.variables != b.variables || a.styles != b.styles || a.color_profile != b.color_profile || !a.images.keys().eq(b.images.keys()) {+        return false;+    }+    // The modes it inherits.+    let (mut pa, mut pb) = (a.node(id).and_then(|n| n.parent), b.node(id).and_then(|n| n.parent));+    for _ in 0..MAX_DEPTH {+        match (pa.and_then(|p| a.node(p)), pb.and_then(|p| b.node(p))) {+            (None, None) => break,+            (Some(x), Some(y)) if pa == pb && x.var_modes == y.var_modes => (pa, pb) = (x.parent, y.parent),+            _ => return false,+        }+    }+    let (sa, sb) = (a.subtree(id), b.subtree(id));+    sa.len() == sb.len() && sa.iter().zip(&sb).all(|(x, y)| x == y && a.node(*x) == b.node(*y))+}++/// The most containers above a frame [`same_picture`] looks through.+const MAX_DEPTH: usize = 1024;+ /// The most pixels a presented frame's texture holds (bigger prototypes, at 100% or filling the /// window, are drawn from a smaller texture scaled up). const MAX_TEXTURE_PIXELS: f64 = 16_777_216.0;@@ -1604,9 +1715,15 @@ struct Chrome {     /// The player may take keys (the pane only while hovered, and never while the editor is     /// typing: text on the canvas or in a field).     keys: bool,+    /// The device viewer's (a phone's browser watching a shared file, [`crate::viewer`]): there+    /// is no editor to close back to, the options change nothing there, so the bar only restarts.+    device: bool, } -const FULL: Chrome = Chrome { pane: false, bar_h: 40.0, pad: 32.0, round: 0, keys: true };+/// The most pixels per point the device viewer draws frames at.+const DEVICE_MAX_PX: f64 = 2.0;++const FULL: Chrome = Chrome { pane: false, bar_h: 40.0, pad: 32.0, round: 0, keys: true, device: false };  /// What the player's top bar asked for. #[derive(Clone, Copy, Debug, PartialEq, Eq)]@@ -1625,11 +1742,16 @@ enum BarAction {     Inspect, } -/// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it.+/// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it (in the+/// device viewer, which has nothing behind it, the bar has neither). pub fn show(app: &mut FigApp, ui: &mut Ui) {     let ctx = ui.ctx().clone();     let now = ctx.input(|i| i.time);+    let device = app.ui.viewer.is_some();     let Some(mut pres) = app.ui.present.take() else { return };+    // A phone's browser draws on one thread: smaller textures, and the next frames drawn ahead.+    pres.max_px = device.then_some(DEVICE_MAX_PX);+    pres.ahead = device;     if let Ok(ds) = app.session.ds() {         pres.sync(&ds.doc, now);     }@@ -1649,7 +1771,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {             .show(ui, |ui| inspect_panel(app, ui, &pres));     }     egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {-        action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, ..FULL }).or(action);+        action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, device, ..FULL }).or(action);     });     // Keys the prototype left alone: C shows the comments, F fills the screen.     if !typing {@@ -1679,7 +1801,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {     if let Some(BarAction::Responsive(on)) = action {         app.run("proto.settings", json!({"responsive": on}));     }-    if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {+    // (The device viewer has no editor behind it: Escape leaves nothing there.)+    if action == Some(BarAction::Close) || (!device && ctx.input(|i| i.key_pressed(egui::Key::Escape))) {         return;     }     app.ui.present = Some(pres);@@ -1800,7 +1923,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) {     let canvas = app.canvas.rect;     let style = ctx.global_style();     let typing = ds.text_edit.is_some() || crate::a11y::field_focused(ctx);-    let chrome = Chrome { pane: true, bar_h: 36.0, pad: 12.0, round: style.visuals.window_corner_radius.nw, keys: !typing };+    let chrome = Chrome { pane: true, bar_h: 36.0, pad: 12.0, round: style.visuals.window_corner_radius.nw, keys: !typing, device: false };     poll_pads(app, &mut pane.player, ctx);     let mut action = None;     egui::Window::new(tr("Prototype preview"))@@ -2065,7 +2188,9 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)     let device = settings.device();     let mut action = None;     // Right: the buttons, right to left (English labels key their ids; shown translated).-    let buttons: &[(BarAction, &str, Icon)] = if chrome.pane {+    let buttons: &[(BarAction, &str, Icon)] = if chrome.device {+        &[(BarAction::Restart, tr_noop("Restart"), Icon::Reset)]+    } else if chrome.pane {         &[             (BarAction::Close, tr_noop("Close preview"), Icon::Close),             (BarAction::Present, tr_noop("Present"), Icon::Play),@@ -2547,9 +2672,10 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke  fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, now: f64) {     let ppp = f64::from(ctx.pixels_per_point());+    let ppp = p.max_px.map_or(ppp, |m| ppp.min(m));     let px = l.scale * ppp;     let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));-    let to_tex = p.texture(ctx, &l.disp, p.current, px, now);+    let to_tex = p.texture(ctx, &l.disp, p.current, px, now, p.ahead);     // Transition progress.     let mut drawn = false;     if let Some(a) = &p.anim {@@ -2645,13 +2771,25 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay             let c = crate::theme::doc_c32(ctx, bgc);             on_screen.rect_filled(l.screen, CornerRadius::ZERO, c.gamma_multiply(raw as f32));         }-        if let Some(tex) = p.texture(ctx, &l.disp, *id, px, now) {+        if let Some(tex) = p.texture(ctx, &l.disp, *id, px, now, false) {             let r = place.rect(tex.bounds).translate(vec2(orect.width() * f.to_off.0, orect.height() * f.to_off.1));             on_screen.image(tex.tex.id(), r, uv, Color32::WHITE.gamma_multiply(f.to_alpha));         }     }     // Finished closing animations go (after drawing, so they stayed paired with the layout).     p.closing.retain(|(at, o)| now - at < o.dur);+    // Once the frame that arrived has shown for a moment, draw where it leads, one per frame.+    if p.ahead {+        if ctx.input(|i| i.pointer.any_down() || i.pointer.is_moving() || i.smooth_scroll_delta != egui::Vec2::ZERO) {+            p.still_since = now;+        }+        let wait = AHEAD_IDLE - (now - p.still_since.max(p.arrived));+        if wait > 0.0 {+            crate::repaint_in(ctx, wait);+        } else if p.draw_ahead(ctx, &l.disp, px, now) {+            ctx.request_repaint();+        }+    }     // Hotspot flash after a click on nothing.     if now < p.flash_until {         let (root, place, clip) = l.active(p.current);diff --git a/crates/ui-egui/src/qr.rs b/crates/ui-egui/src/qr.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/qr.rs@@ -0,0 +1,50 @@+//! QR codes drawn in the window (Share ▸ View on another device shows the address a phone opens+//! as one). Encoded with `qrcodegen`; drawn as dark runs on a light square with the standard quiet+//! zone, in fixed black and white whatever the theme (cameras read dark on light), with cells on+//! whole pixels so nothing blurs.++use egui::{Color32, CornerRadius, Rect, Sense, Ui, pos2, vec2};+use qrcodegen::{QrCode, QrCodeEcc};++/// Light cells around the code, on each side (the standard's quiet zone).+const QUIET: i32 = 4;++/// The code's cells: `side` rows of dark (true) and light cells, without the quiet zone. `None`:+/// `text` is too long for a QR code.+pub fn cells(text: &str) -> Option<Vec<Vec<bool>>> {+    let qr = QrCode::encode_text(text, QrCodeEcc::Medium).ok()?;+    let n = qr.size();+    Some((0..n).map(|y| (0..n).map(|x| qr.get_module(x, y)).collect()).collect())+}++/// Draw the QR code of `text` at most `side` points wide; false when it is too long for one+/// (nothing is drawn).+pub fn show(ui: &mut Ui, text: &str, side: f32) -> bool {+    let Some(rows) = cells(text) else { return false };+    let n = i32::try_from(rows.len()).unwrap_or(i32::MAX).saturating_add(2 * QUIET);+    let ppp = ui.ctx().pixels_per_point();+    // Each cell a whole number of pixels.+    let cell = ((side * ppp / n as f32).floor().max(1.0)) / ppp;+    let full = cell * n as f32;+    let (rect, _) = ui.allocate_exact_size(vec2(full, full), Sense::hover());+    let painter = ui.painter_at(rect);+    painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE);+    let origin = rect.min + vec2(QUIET as f32 * cell, QUIET as f32 * cell);+    for (y, row) in rows.iter().enumerate() {+        // Each run of dark cells as one rectangle.+        let mut x = 0;+        while x < row.len() {+            if !row.get(x).copied().unwrap_or(false) {+                x += 1;+                continue;+            }+            let start = x;+            while row.get(x).copied().unwrap_or(false) {+                x += 1;+            }+            let min = origin + vec2(start as f32 * cell, y as f32 * cell);+            painter.rect_filled(Rect::from_min_max(min, pos2(origin.x + x as f32 * cell, min.y + cell)), CornerRadius::ZERO, Color32::BLACK);+        }+    }+    true+}diff --git a/crates/ui-egui/src/viewer.rs b/crates/ui-egui/src/viewer.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/viewer.rs@@ -0,0 +1,126 @@+//! Watching on another device: the browser build opened from the address Share ▸ View on another+//! device gives (`http://<host>:<port>/#view&join=<view link>`, served by the desktop app that+//! hosts the session) is only the prototype player, full-window, sized for a phone. It joins the+//! session with the view link, presents the file's prototype as soon as the file arrives and+//! follows every edit made on the desktop (the player copies the synced document each frame).+//! There is no editor to go back to: the player's bar has no Close and Escape does nothing; if the+//! presented frame goes away, the prototype starts again. Until there is something to present the+//! page says what it waits for.++use egui::{Color32, Ui};+use figcraft_engine::live::Status;+use serde_json::json;++use crate::FigApp;+use crate::i18n::tr;+use crate::present::{PresentScale, PresentView};+use crate::theme::regular;++/// The device viewer's state (the window is a viewer while `FigApp::ui.viewer` is set).+#[derive(Debug, Default)]+pub struct Viewer {+    /// The document revision presenting last failed on (nothing to present): tried again only+    /// when the file changes.+    failed: Option<u64>,+    /// The session was live once: when it is over, the file shown is no longer the shared one.+    joined: bool,+    /// Why the address couldn't be joined at all (no link in it, or one that can't be used).+    refused: Option<String>,+}++/// Make the window a device viewer and join `link` (the view link from the page's address).+pub fn open(app: &mut FigApp, link: Option<&str>) {+    app.ui.home = false;+    let refused = match link.map(str::trim).filter(|l| !l.is_empty()) {+        None => Some(tr("This address doesn't name a shared file. Open the address Share ▸ View on another device shows.").to_string()),+        Some(link) => app.session.execute("collab.join", &json!({"link": link})).err().map(|e| e.to_string()),+    };+    app.handle_ui_requests();+    app.ui.viewer = Some(Viewer { refused, ..Viewer::default() });+}++/// How the player shows a prototype on a phone: the width of the screen, and no device drawn+/// around it (the phone is the device). A tap where nothing reacts flashes the hotspots, as on the+/// desktop.+pub fn present_view() -> PresentView {+    PresentView { scale: PresentScale::FitWidth, device_frame: false, ..PresentView::default() }+}++/// What the viewer is doing, from the session's state.+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum Stage {+    /// Joining: waiting for the file.+    Opening,+    /// The connection dropped; trying again.+    Reconnecting,+    /// The file is here: present it.+    Present,+    /// It couldn't join, or the session ended.+    Failed(String),+    Ended,+}++/// The stage for session status `status` (`syncing`: the file arrived; `joined`: it was live).+pub fn stage(status: &Status, syncing: bool, joined: bool) -> Stage {+    match status {+        Status::Live if syncing => Stage::Present,+        Status::Reconnecting { .. } => Stage::Reconnecting,+        Status::Failed { reason } => Stage::Failed(reason.clone()),+        Status::Off if joined => Stage::Ended,+        Status::Off | Status::Connecting | Status::Live => Stage::Opening,+    }+}++/// One frame of the viewer.+pub fn show(app: &mut FigApp, ui: &mut Ui) {+    let live = app.session.live();+    let (status, syncing) = (live.status.clone(), live.syncing());+    let viewer = app.ui.viewer.get_or_insert_default();+    viewer.joined |= status == Status::Live;+    let stage = match viewer.refused.clone() {+        Some(why) => Stage::Failed(why),+        None => stage(&status, syncing, viewer.joined),+    };+    match stage {+        Stage::Present if app.ui.present.is_none() => start(app),+        // What is open isn't the shared file any more.+        Stage::Ended | Stage::Failed(_) => app.ui.present = None,+        _ => {}+    }+    if app.ui.present.is_some() {+        crate::present::show(app, ui);+        return;+    }+    let note = match stage {+        Stage::Opening => tr("Opening the shared file…").to_string(),+        Stage::Reconnecting => tr("The connection dropped. Trying again…").to_string(),+        Stage::Present => tr("Nothing to present yet: add a frame to the file on the computer sharing it.").to_string(),+        Stage::Failed(reason) => format!("{}\n\n{reason}", tr("Couldn't open the shared file.")),+        Stage::Ended => tr("The session ended. Share ▸ View on another device on the computer gives a new address.").to_string(),+    };+    egui::CentralPanel::default().frame(egui::Frame::NONE.fill(BACKGROUND)).show(ui, |ui| {+        let rect = ui.max_rect();+        let galley = ui.painter().layout(note, regular(14.0), TEXT, (rect.width() - 48.0).max(120.0));+        ui.painter().galley(rect.center() - galley.size() / 2.0, galley, TEXT);+    });+}++/// The page behind the notes (the full-window presenter's dark bar colour), and their text.+const BACKGROUND: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E);+const TEXT: Color32 = Color32::from_gray(200);++/// Present the session's file (its first flow, else its first frame), unless that failed on this+/// revision already.+fn start(app: &mut FigApp) {+    let Ok(rev) = app.session.ds().map(|d| d.revision) else { return };+    if app.ui.viewer.as_ref().is_some_and(|v| v.failed == Some(rev)) {+        return;+    }+    app.ui.present_view = present_view();+    let ok = app.session.execute("proto.present", &json!({})).is_ok();+    app.handle_ui_requests();+    let presenting = ok && app.ui.present.is_some();+    if let Some(v) = app.ui.viewer.as_mut() {+        v.failed = (!presenting).then_some(rev);+    }+}diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md--- a/docs/collab-protocol.md+++ b/docs/collab-protocol.md@@ -562,6 +562,24 @@ resuming with its offline edits, and leaving the session going back to snapshot   IPv4 address gets no code and shares its link. - **In the browser** a code joins like a link (the page asks the server over its own WebSocket,   then opens the session's); sharing needs the desktop app.+- **On another device** (Share ▸ View on another device, `collab.viewOnDevice`): a local share+  whose app has the browser build compiled in serves it on the session's own port, so a phone on+  the network opens `http://<ip>:<port>/#view&join=<view link>` (the popover shows it as a QR+  code) and watches the file. A plain `GET` without `Upgrade: websocket` gets the page's files+  (`figcraft_collab_net::site`: only the compiled-in paths, gzipped, `Referrer-Policy:+  no-referrer`, then the connection closes); everything else is the WebSocket handshake as+  before. A download counts as a connection of its address (at most 4 at once per address),+  leaves the connections kept for sessions to them, and gives up after 4 minutes, so slow+  readers can't take the session's connections. The session then has a second secret, 128 random bits like `collab.host`'s, which+  admits people with view access only: the page joins with it and plays the prototype+  full-window, following every edit, with nothing to edit and no editor behind it. The secret+  and `view` are in the address's fragment (`#join=`, as on any page), which the browser never+  sends, so the request for the page carries neither; the session itself runs over plain `ws://`+  on the local network, like any local share. Builds get+  the page with `FIGCRAFT_WEB_DIST=dist/web` (the `trunk build --release` output of+  `apps/figcraft-web`, built with craft-fonts: a page has no system fonts, so without them the+  design's text doesn't show) when the desktop app is compiled; without it there is no View on another+  device (`servesViewer` in `collab.peers`) and nothing else changes.  *Built* (your online files; `figcraft_engine::online`, `collab.files`, `collab.openFile`, `collab.forgetFile`, `collab.manageFile`):diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (prototypes on a phone's browser with View on another device; bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)  Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The@@ -375,7 +375,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe  ### 20. Mobile apps - **Missing:** Figma's iOS and Android apps view and comment on files and mirror prototypes.-  FigCraft's web build runs in mobile browsers but is not designed for touch.+  FigCraft's web build runs in mobile browsers but is not designed for touch. Partly there: Share ▸+  View on another device plays a prototype in a phone's browser on the same network (view only,+  following edits live, not the frame selected on the desktop). - **Estimate:** 20–40 h for a touch-first viewer and mirror.  ### 21. Menu items without a command (measured): none left@@ -400,6 +402,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe  | Date | Change | Summary | |---|---|---|+| 2026-10-17 | minor | Gap 20: Share ▸ View on another device plays a prototype in a phone's browser on the same network (view only, live). Estimates and ranking unchanged. | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged |diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md--- a/docs/hardware-parity.md+++ b/docs/hardware-parity.md@@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (prototypes on a phone's browser with View on another device; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)  How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out@@ -44,12 +44,13 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here | | FreeBSD | none | tarball | | | Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) |-| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |+| iOS / Android | apps: view, comment, mirror prototypes | no apps; a phone's browser plays a prototype shared with Share ▸ View on another device (local network, view only, live) | the web build is not touch-first; 20–40 h for a viewer app |  ## Revision history  | Date | Change | Summary | |---|---|---|+| 2026-10-17 | minor | Share ▸ View on another device (`collab.viewOnDevice`): the file is shared on the local network and the desktop app serves the browser build on the session's own port, so a phone's browser opens `http://<ip>:<port>/#view&join=…` (a QR code in the Share popover) and plays the prototype full-window, view only, following edits live (a second, view-only secret; the page is compiled into builds made with `FIGCRAFT_WEB_DIST`). The phone presents on its own: it doesn't follow the frame selected on the desktop. Numbers unchanged until it has been checked on more phones. | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -960,6 +960,11 @@ and `collab.peers` are about the **active document's** session (`doc.switch {ind one that is), and `sessions` in `collab.peers` lists them all. Closing a document ends its session only: a host stops hosting it, a guest leaves it. +- `collab.viewOnDevice {port?, bind?}` shares the active document on the local network as+  `collab.share {via: "local"}` does (a local share that has one already is kept) and gives+  `viewer` in `collab.peers`: the `http://` address a phone or another computer on the network+  opens in a browser to watch the prototype live, view only. Needs a desktop build with the+  browser viewer compiled in (`servesViewer`). - `collab.share {via?, access?, server?, name?, port?, bind?}` shares the active document with a   **join code**, the short way to do what `collab.publish` and `collab.host` do. `via: "online"`   (the default) publishes it to the server in Preferences (`pref.publishServer {url?}`: thediff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md--- a/docs/prototyping-parity.md+++ b/docs/prototyping-parity.md@@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (View on another device: prototypes on a phone's browser; core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)  **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons,@@ -92,7 +92,8 @@ case pins the current behaviour). | Edit interactions on several layers at once | done | | | Opening a link in place | missing | | | Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet |-| Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) |+| Mirror to a phone | partial | Share ▸ View on another device: a phone's browser on the same network plays the prototype, view only, following edits live; it doesn't follow the frame selected on the desktop, and needs a build with the browser viewer compiled in (`FIGCRAFT_WEB_DIST`); see [hardware-parity](hardware-parity.md) |+| Prototype in a separate window | missing | | | Video in prototypes | missing | needs video fills ([gaps](gaps.md) #12, #15) | | Share a prototype link | missing | needs multiplayer/sharing | @@ -100,6 +101,7 @@ case pins the current behaviour).  | Date | Change | Summary | |---|---|---|+| 2026-10-17 | minor | Share ▸ View on another device (`collab.viewOnDevice`): the file is shared on the local network and the desktop app serves the browser build on the session's own port, so a phone's browser opens `http://<ip>:<port>/#view&join=…` (a QR code in the Share popover) and plays the prototype full-window, view only, following edits live (a second, view-only secret; the page is compiled into builds made with `FIGCRAFT_WEB_DIST`). The phone presents on its own: it doesn't follow the frame selected on the desktop. Numbers unchanged until it has been checked on more phones. | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h | | 2026-10-11 | minor | Behaviour suite from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag follows its animation's direction and the pointer, key combinations, overlay timers from when they open, interactive components play their variant's interactions and change the right instance, physical springs that play until they settle, smart animate overshoots with the easing, fixed layers above scrolling content; added scroll to offsets. Breadth 88% → 90%, ready 70% → 74%, 5–9 h → 3–6 h |diff --git a/packaging/web/README.md b/packaging/web/README.md--- a/packaging/web/README.md+++ b/packaging/web/README.md@@ -26,6 +26,19 @@ browsers block from a page served over `https://` except to the same computer. T other machines, serve the site over `http://` on a trusted network (or use the desktop app); `wss://` comes with the standalone server. +## Served by the desktop app (View on another device)++The desktop app can carry this site itself: build it first (`trunk build --release` in+`apps/figcraft-web`, which writes `dist/web`), then the app with `FIGCRAFT_WEB_DIST=dist/web`+(a path from the repository's root, or absolute); `apps/figcraft/build.rs` gzips the files into the+binary (about 7 MiB). Build the site with craft-fonts (`CRAFT_FONTS_DIR`, as releases do): a page+has no system fonts, so without them a phone shows the design's shapes but none of its text. A+session it shares on the local network then serves the page on its own+port, and Share ▸ View on another device shows `http://<ip>:<port>/#view&join=…` as a QR code+for a phone: `#join=` joins the session as on any page, and `view` before it makes the page a+device viewer that only plays the joined file's prototype. Both are in the fragment, so the+request for the page carries no secret. Builds without it simply don't offer View on another device.+ ## Sizes  Measured on the 0.1.0 build (`trunk build --release`; gzip `-9`, Brotli quality 11):