diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 4dc4720ed85..35f426c0a5a 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -15,7 +15,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz agents` | `draft-create`, `draft-update` | | `buzz messages` | `send`, `get`, `thread`, `search` | | `buzz channels` | `list`, `get`, `create`, `join`, `members` | -| `buzz canvas` | `get`, `set` | +| `buzz canvas` | `get`, `set`, `notify` | | `buzz reactions` | `add`, `remove` | | `buzz dms` | `list`, `open` | | `buzz users` | `get`, `set-profile`, `presence` | @@ -41,10 +41,28 @@ A project is a named grouping (`kind:30621`) with a home channel. Creating a sec - To add tasks: `buzz issues create --channel --subject "…" --content "…"`. That uses this project's repository and creates one bound to the channel if none exists. `--repo-owner` / `--repo-id` remain valid once a repository exists. Session todos and markdown plans do not appear on the project. - To add another channel to this project: `buzz projects add-channel --home-channel --name "…" [--template "…"]`. This opens an owner-reviewed request in Buzz Desktop and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it. +## Projects + +A project is a named grouping (`kind:30621`) with a home channel. Creating a second project with the same name produces a duplicate card in Buzz Desktop — never do that for work that already has a project. + +- If you are in a project's home channel, or a project with that name/slug already exists, do **not** run `buzz projects create`. `` includes a Project block when this channel is a project home — tasks, repositories, and files you create belong to that project. +- To add a codebase: `buzz repos create --id --name "…" --channel `. `mkdir` in `REPOS/` is not a Buzz repository. +- To add tasks: `buzz issues create --channel --subject "…" --content "…"`. That uses this project's repository and creates one bound to the channel if none exists. `--repo-owner` / `--repo-id` remain valid once a repository exists. Session todos and markdown plans do not appear on the project. +- To add another channel to this project: `buzz projects add-channel --home-channel --name "…" [--template "…"]`. This opens an owner-reviewed request in Buzz Desktop and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it. + `buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. To assign an issue to someone, run `buzz issues assign --issue --repo-owner --repo-id --assignee --label ` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Buzz Desktop's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves. +## Project Canvas Packages + +Project widget Canvases are local packages, distinct from relay-backed channel Canvas markdown. When asked to update one, read the active nest's `CANVASES/index.json`, match the exact community and canonical `30621::` project coordinate, and edit only that entry's `sourcePath`. Never edit `index.json` or anything under `.runtime/`. + +- Widget values live in `data/*.json`; dashboard and widget placement is declared there too, but counts as a presentation change when notifying Buzz. Presentation code lives in `widgets/*.js`, `canvas.js`, and `styles/*.css`. Assets stay in `assets/`. Declare added files in `manifest.json`, keep `canvas.js` last, and do not add `index.html`, dependencies, builds, or network access. +- After changing only one widget's data, run `buzz canvas notify --source --widget --change data`. Buzz passes the new data to the live widget without replacing its iframe; object renderers may implement `update(currentElement, nextData, previousData, api)` to animate, while function renderers remount that widget's content. +- After changing JavaScript, CSS, layout, assets, the manifest, or other presentation behavior, run `buzz canvas notify --source --widget --change presentation`. Buzz validates the full package and swaps in a fresh sandboxed iframe only after it renders; the manual Reload Canvas button remains available. +- Project channel and review snapshots are authoritative. Change those values through Buzz rather than editing their bundled fixture rows. `canvas notify` is local-only, requires Buzz Desktop to be running, and does not create a relay event. + ## Conversational Agent Creation When someone asks to create an agent, ask for at most two things: its name and what it should do day-to-day. Write the `--system-prompt` yourself. Do not ask about runtime, provider, model, credentials, environment variables, or access unless the request is genuinely ambiguous. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 25c6e549052..8c79f5f95e8 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4529,6 +4529,15 @@ mod agent_draft_prompt_tests { .contains("add them explicitly with `buzz channels add-member` only when authorized")); assert!(prompt.contains("never changes membership automatically")); } + + #[test] + fn shared_base_prompt_teaches_project_canvas_notifications() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("buzz canvas notify --source")); + assert!(prompt.contains("--change data")); + assert!(prompt.contains("--change presentation")); + assert!(prompt.contains("Never edit `index.json` or anything under `.runtime/`")); + } } fn default_heartbeat_prompt() -> String { diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 8f8db4d2893..f658672eedf 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -78,6 +78,8 @@ buzz messages vote --event --direction up # Canvas buzz canvas get --channel buzz canvas set --channel --content "# Welcome" +buzz canvas notify --source --widget --change data +buzz canvas notify --source --widget --change presentation # Agent Memory (NIP-AE) buzz mem ls diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index b7fa06d2031..772540cfc52 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -184,6 +184,11 @@ echo "# Canvas from stdin" | buzz canvas set --channel "$CHANNEL_ID" --content - # canvas get buzz canvas get --channel "$CHANNEL_ID" # Expected: raw markdown string, or: null + +# Local project Canvas notification (Buzz Desktop must be running) +buzz canvas notify --source "$CANVAS_SOURCE" --widget chores --change data | jq . +buzz canvas notify --source "$CANVAS_SOURCE" --widget chores --change presentation | jq . +# Expected: accepted true; data preserves the iframe, presentation reloads it ``` ### 6.3 Messages @@ -587,41 +592,42 @@ buzz channels delete --channel "$FORUM_ID" | jq . | 22 | `channels remove-member` | ☐ | Needs admin:channels | | 23 | `canvas get` | ☐ | | | 24 | `canvas set` | ☐ | Direct and stdin | -| 25 | `reactions add` | ☐ | | -| 26 | `reactions remove` | ☐ | | -| 27 | `reactions get` | ☐ | | -| 28 | `dms list` | ☐ | | -| 29 | `dms open` | ☐ | | -| 30 | `dms add-member` | ☐ | Needs messages:write | -| 31 | `users get` | ☐ | Self, single, batch | -| 32 | `users set-profile` | ☐ | | -| 33 | `users presence` | ☐ | | -| 34 | `users set-presence` | ☐ | online, away, offline | -| 35 | `workflows list` | ☐ | | -| 36 | `workflows create` | ☐ | | -| 37 | `workflows update` | ☐ | | -| 38 | `workflows delete` | ☐ | | -| 39 | `workflows trigger` | ☐ | | -| 40 | `workflows runs` | ☐ | | -| 41 | `workflows get` | ☐ | | -| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); bare = approve, `--approved false` = deny | -| 43 | `feed get` | ☐ | | -| 44 | `social publish` | ☐ | | -| 45 | `social set-contacts` | ☐ | | -| 46 | `social event` | ☐ | | -| 47 | `social notes` | ☐ | | -| 48 | `social contacts` | ☐ | | -| 49 | `repos create` | ☐ | | -| 50 | `repos get` | ☐ | | -| 51 | `repos list` | ☐ | | -| 52 | `repos protect list` | ☐ | Empty/populated rules; unknown rules visible; malformed rule reported in validation_error | -| 53 | `repos protect set` | ☐ | Create and replace complete exact-ref rule; verify metadata is preserved | -| 54 | `repos protect remove` | ☐ | Remove exact ref; missing rule → NotFound | -| 55 | `upload file` | ☐ | | -| 56 | `pack validate` | ☐ | Local, no relay | -| 57 | `pack inspect` | ☐ | Local, no relay | -| 58 | `notes set` | ☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard | -| 59 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 | -| 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit | -| 61 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound | -| 62 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` → exit 1 | +| 25 | `canvas notify` | ☐ | Data and presentation with Desktop running | +| 26 | `reactions add` | ☐ | | +| 27 | `reactions remove` | ☐ | | +| 28 | `reactions get` | ☐ | | +| 29 | `dms list` | ☐ | | +| 30 | `dms open` | ☐ | | +| 31 | `dms add-member` | ☐ | Needs messages:write | +| 32 | `users get` | ☐ | Self, single, batch | +| 33 | `users set-profile` | ☐ | | +| 34 | `users presence` | ☐ | | +| 35 | `users set-presence` | ☐ | online, away, offline | +| 36 | `workflows list` | ☐ | | +| 37 | `workflows create` | ☐ | | +| 38 | `workflows update` | ☐ | | +| 39 | `workflows delete` | ☐ | | +| 40 | `workflows trigger` | ☐ | | +| 41 | `workflows runs` | ☐ | | +| 42 | `workflows get` | ☐ | | +| 43 | `workflows approve` | ☐ | Validation only (needs approval gate); bare = approve, `--approved false` = deny | +| 44 | `feed get` | ☐ | | +| 45 | `social publish` | ☐ | | +| 46 | `social set-contacts` | ☐ | | +| 47 | `social event` | ☐ | | +| 48 | `social notes` | ☐ | | +| 49 | `social contacts` | ☐ | | +| 50 | `repos create` | ☐ | | +| 51 | `repos get` | ☐ | | +| 52 | `repos list` | ☐ | | +| 53 | `repos protect list` | ☐ | Empty/populated rules; unknown rules visible; malformed rule reported in validation_error | +| 54 | `repos protect set` | ☐ | Create and replace complete exact-ref rule; verify metadata is preserved | +| 55 | `repos protect remove` | ☐ | Remove exact ref; missing rule → NotFound | +| 56 | `upload file` | ☐ | | +| 57 | `pack validate` | ☐ | Local, no relay | +| 58 | `pack inspect` | ☐ | Local, no relay | +| 59 | `notes set` | ☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard | +| 60 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 | +| 61 | `notes ls` | ☐ | Own, --author all, --tag, --limit | +| 62 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound | +| 63 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` → exit 1 | diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 72168793588..a688f094d31 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1580,6 +1580,9 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu match cmd { CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await, CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await, + CanvasCmd::Notify { .. } => { + unreachable!("local Canvas notifications are handled before relay setup") + } } } diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8bb24218eb5..6ff4d5e2914 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod notes; pub mod pack; pub mod patches; pub mod pr; +pub mod project_canvas; pub mod project_channel; pub mod projects; pub mod reactions; diff --git a/crates/buzz-cli/src/commands/project_canvas.rs b/crates/buzz-cli/src/commands/project_canvas.rs new file mode 100644 index 00000000000..69de4b08b41 --- /dev/null +++ b/crates/buzz-cli/src/commands/project_canvas.rs @@ -0,0 +1,338 @@ +use std::{ + fs, + io::{Read, Write}, + path::{Path, PathBuf}, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{error::CliError, CanvasChange}; + +const INDEX_FORMAT: &str = "buzz-project-canvas-index"; +const INDEX_VERSION: u32 = 1; +const IPC_FORMAT: &str = "buzz-project-canvas-update"; +const IPC_VERSION: u32 = 1; +const MAX_INDEX_BYTES: u64 = 1024 * 1024; +const MAX_INDEX_ENTRIES: usize = 4_096; +const MAX_RESPONSE_BYTES: u64 = 16 * 1024; +const SOCKET_FILE: &str = "agent-updates.sock"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CanvasIndex { + format: String, + version: u32, + canvases: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CanvasIndexEntry { + community_id: String, + project_id: String, + source_path: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CanvasUpdateRequest<'a> { + format: &'static str, + version: u32, + notification_id: String, + community_id: &'a str, + project_id: &'a str, + widget_id: &'a str, + change: &'a CanvasChange, +} + +#[derive(Deserialize)] +struct CanvasUpdateResponse { + accepted: bool, + message: String, + #[serde(flatten)] + output: serde_json::Map, +} + +struct ResolvedCanvas { + canvas_root: PathBuf, + community_id: String, + project_id: String, + source_path: PathBuf, +} + +pub fn cmd_notify(source: &Path, widget: &str, change: &CanvasChange) -> Result<(), CliError> { + validate_widget_id(widget)?; + let resolved = resolve_canvas(source)?; + notify_desktop(&resolved, widget, change) +} + +fn validate_widget_id(widget: &str) -> Result<(), CliError> { + if widget.is_empty() + || widget.len() > 128 + || !widget + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(CliError::Usage( + "--widget must be 1 to 128 ASCII letters, numbers, '.', '-', or '_'".into(), + )); + } + Ok(()) +} + +fn resolve_canvas(source: &Path) -> Result { + let source_path = source + .canonicalize() + .map_err(|error| CliError::Usage(format!("resolve Canvas source: {error}")))?; + if !source_path.is_dir() { + return Err(CliError::Usage( + "--source must identify a Canvas package directory".into(), + )); + } + let canvas_root = source_path + .ancestors() + .find(|candidate| { + candidate.file_name().and_then(|name| name.to_str()) == Some("CANVASES") + && candidate.join("index.json").is_file() + }) + .map(Path::to_path_buf) + .ok_or_else(|| { + CliError::Usage( + "--source is not listed below a Buzz CANVASES directory with index.json".into(), + ) + })?; + let index_path = canvas_root.join("index.json"); + let metadata = fs::metadata(&index_path) + .map_err(|error| CliError::Usage(format!("inspect Canvas index: {error}")))?; + if metadata.len() > MAX_INDEX_BYTES { + return Err(CliError::Usage("Canvas index exceeds 1 MiB".into())); + } + let mut raw = Vec::new(); + fs::File::open(&index_path) + .and_then(|file| { + file.take(MAX_INDEX_BYTES + 1).read_to_end(&mut raw)?; + Ok(()) + }) + .map_err(|error| CliError::Usage(format!("read Canvas index: {error}")))?; + if raw.len() as u64 > MAX_INDEX_BYTES { + return Err(CliError::Usage("Canvas index exceeds 1 MiB".into())); + } + let index: CanvasIndex = serde_json::from_slice(&raw) + .map_err(|error| CliError::Usage(format!("invalid Canvas index: {error}")))?; + if index.format != INDEX_FORMAT || index.version != INDEX_VERSION { + return Err(CliError::Usage( + "unsupported project Canvas index format".into(), + )); + } + if index.canvases.len() > MAX_INDEX_ENTRIES { + return Err(CliError::Usage("Canvas index exceeds 4096 entries".into())); + } + + let mut matched = None; + for entry in index.canvases { + let Ok(indexed_source) = PathBuf::from(&entry.source_path).canonicalize() else { + continue; + }; + if indexed_source == source_path { + if matched.is_some() { + return Err(CliError::Usage( + "Canvas index contains duplicate entries for --source".into(), + )); + } + matched = Some((entry.community_id, entry.project_id)); + } + } + let (community_id, project_id) = matched.ok_or_else(|| { + CliError::NotFound("Canvas source is not present in CANVASES/index.json".into()) + })?; + Ok(ResolvedCanvas { + canvas_root, + community_id, + project_id, + source_path, + }) +} + +#[cfg(unix)] +fn notify_desktop( + resolved: &ResolvedCanvas, + widget: &str, + change: &CanvasChange, +) -> Result<(), CliError> { + use std::os::unix::net::UnixStream; + + let socket = resolved.canvas_root.join(".runtime").join(SOCKET_FILE); + let mut stream = UnixStream::connect(&socket).map_err(|error| { + CliError::Other(format!( + "connect to Buzz Desktop Canvas update socket {}: {error}; make sure Buzz Desktop is running", + socket.display() + )) + })?; + let timeout = Some(Duration::from_secs(5)); + stream + .set_read_timeout(timeout) + .map_err(|error| CliError::Other(format!("configure Canvas update response: {error}")))?; + stream + .set_write_timeout(timeout) + .map_err(|error| CliError::Other(format!("configure Canvas update request: {error}")))?; + + let request = CanvasUpdateRequest { + format: IPC_FORMAT, + version: IPC_VERSION, + notification_id: uuid::Uuid::new_v4().simple().to_string(), + community_id: &resolved.community_id, + project_id: &resolved.project_id, + widget_id: widget, + change, + }; + serde_json::to_writer(&mut stream, &request) + .map_err(|error| CliError::Other(format!("encode Canvas update request: {error}")))?; + stream + .write_all(b"\n") + .and_then(|()| stream.flush()) + .map_err(|error| CliError::Other(format!("send Canvas update request: {error}")))?; + + let mut raw = Vec::new(); + stream + .take(MAX_RESPONSE_BYTES + 1) + .read_to_end(&mut raw) + .map_err(|error| CliError::Other(format!("read Canvas update response: {error}")))?; + if raw.len() as u64 > MAX_RESPONSE_BYTES { + return Err(CliError::Other( + "Buzz Desktop Canvas update response exceeds 16 KiB".into(), + )); + } + let response: CanvasUpdateResponse = serde_json::from_slice(&raw) + .map_err(|error| CliError::Other(format!("invalid Canvas update response: {error}")))?; + if !response.accepted { + return Err(CliError::Usage(response.message)); + } + let mut output = response.output; + output.insert("accepted".into(), serde_json::Value::Bool(true)); + output.insert("message".into(), response.message.into()); + output.insert( + "sourcePath".into(), + resolved.source_path.to_string_lossy().into_owned().into(), + ); + println!("{}", serde_json::Value::Object(output)); + Ok(()) +} + +#[cfg(not(unix))] +fn notify_desktop( + _resolved: &ResolvedCanvas, + _widget: &str, + _change: &CanvasChange, +) -> Result<(), CliError> { + Err(CliError::Other( + "sandboxed project Canvas updates are currently supported on macOS only".into(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + const OWNER: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn indexed_canvas(temp: &tempfile::TempDir) -> PathBuf { + let root = temp.path().join("CANVASES"); + let source = root.join("community").join(OWNER).join("project"); + fs::create_dir_all(&source).unwrap(); + fs::write( + root.join("index.json"), + serde_json::json!({ + "format": INDEX_FORMAT, + "version": INDEX_VERSION, + "canvases": [{ + "communityId": "community-id", + "projectId": format!("30621:{OWNER}:project"), + "sourcePath": source, + }], + }) + .to_string(), + ) + .unwrap(); + source + } + + #[test] + fn resolves_binding_from_the_index_for_an_exact_source_path() { + let temp = tempfile::TempDir::new().unwrap(); + let source = indexed_canvas(&temp); + let resolved = resolve_canvas(&source).unwrap(); + assert_eq!(resolved.community_id, "community-id"); + assert_eq!(resolved.project_id, format!("30621:{OWNER}:project")); + assert_eq!( + resolved.canvas_root, + temp.path().join("CANVASES").canonicalize().unwrap() + ); + } + + #[test] + fn rejects_unindexed_and_invalid_widget_inputs() { + let temp = tempfile::TempDir::new().unwrap(); + let source = indexed_canvas(&temp); + let other = temp.path().join("CANVASES").join("other"); + fs::create_dir(&other).unwrap(); + assert!(matches!(resolve_canvas(&other), Err(CliError::NotFound(_)))); + assert!(matches!( + validate_widget_id("bad/widget"), + Err(CliError::Usage(_)) + )); + assert!(resolve_canvas(&source.join("missing")).is_err()); + } + + #[cfg(unix)] + #[test] + fn sends_a_bounded_local_update_request_and_accepts_the_desktop_response() { + use std::os::unix::net::UnixListener; + use std::thread; + + let temp = tempfile::Builder::new() + .prefix("buzz-canvas") + .tempdir_in("/tmp") + .unwrap(); + let source = indexed_canvas(&temp); + let runtime = temp.path().join("CANVASES").join(".runtime"); + fs::create_dir(&runtime).unwrap(); + let listener = UnixListener::bind(runtime.join(SOCKET_FILE)).unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut raw = Vec::new(); + loop { + let mut byte = [0_u8; 1]; + stream.read_exact(&mut byte).unwrap(); + raw.push(byte[0]); + if byte[0] == b'\n' { + break; + } + } + let request: serde_json::Value = serde_json::from_slice(&raw).unwrap(); + assert_eq!(request["format"], IPC_FORMAT); + assert_eq!(request["version"], IPC_VERSION); + assert_eq!(request["communityId"], "community-id"); + assert_eq!(request["widgetId"], "chore-board"); + assert_eq!(request["change"], "data"); + stream + .write_all( + serde_json::json!({ + "accepted": true, + "change": "data", + "message": "Canvas update delivered", + "notificationId": request["notificationId"], + "projectId": request["projectId"], + "revision": "a".repeat(64), + "widgetId": request["widgetId"], + }) + .to_string() + .as_bytes(), + ) + .unwrap(); + }); + + cmd_notify(&source, "chore-board", &CanvasChange::Data).unwrap(); + server.join().unwrap(); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa2..4c7db1fe67a 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -72,7 +72,7 @@ Configuration (flags override env vars): BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] -The 'pack' subcommand runs locally and does not require a relay connection. +The 'pack' and 'canvas notify' subcommands run locally and do not require a relay connection. Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other 5=write conflict Errors are JSON on stderr: {\"error\": \"\", \"message\": \"\"}" @@ -731,6 +731,25 @@ pub enum CanvasCmd { #[arg(long)] content: String, }, + /// Notify the local Buzz desktop that a project widget changed + Notify { + /// Project Canvas source directory (use '.' from inside the package) + #[arg(long)] + source: std::path::PathBuf, + /// Stable widget id from the active dashboard data + #[arg(long)] + widget: String, + /// Whether presentation code or widget data changed + #[arg(long, value_enum)] + change: CanvasChange, + }, +} + +#[derive(Clone, Debug, serde::Serialize, clap::ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum CanvasChange { + Presentation, + Data, } #[derive(Subcommand)] @@ -2028,13 +2047,22 @@ fn normalize_auth_tag_input(input: &str) -> String { async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); - // Pack commands are local-only — no relay connection needed. + // Pack and project Canvas notification commands are local-only — no relay + // connection or signing identity is involved. if let Cmd::Pack(ref sub) = cli.command { return match sub { PackCmd::Validate { path } => commands::pack::cmd_validate(path), PackCmd::Inspect { path } => commands::pack::cmd_inspect(path), }; } + if let Cmd::Canvas(CanvasCmd::Notify { + ref source, + ref widget, + ref change, + }) = cli.command + { + return commands::project_canvas::cmd_notify(source, widget, change); + } // Auth: private key is required for all relay operations. // The keypair IS the identity — no tokens, no other auth. @@ -2330,7 +2358,7 @@ mod tests { "update" ] ); - assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]); + assert_eq!(names(&cmd, "canvas"), vec!["get", "notify", "set"]); assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]); assert_eq!( names(&cmd, "emoji"), @@ -2433,7 +2461,7 @@ mod tests { fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ ("agents", 5), - ("canvas", 2), + ("canvas", 3), ("channels", 16), ("dms", 4), ("emoji", 5), diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ee7d4ec9bf0..94619c26971 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -29,6 +29,8 @@ export default defineConfig({ "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", + "**/channel-project-features.spec.ts", + "**/project-channel-canvas.spec.ts", "**/channel-shared-header-backdrop.spec.ts", "**/auxiliary-pane-close-visibility.spec.ts", "**/channel-composer-overflow.spec.ts", diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/front-yard-camera.webp b/desktop/src-tauri/resources/project-canvas-template/assets/front-yard-camera.webp new file mode 100644 index 00000000000..e6fc6a99586 Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/front-yard-camera.webp differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-1.png b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-1.png new file mode 100644 index 00000000000..a9b16dccd1b Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-1.png differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-1.webm b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-1.webm new file mode 100644 index 00000000000..5f6f1671a97 Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-1.webm differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-14.png b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-14.png new file mode 100644 index 00000000000..fb1f9b1d83e Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-14.png differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-14.webm b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-14.webm new file mode 100644 index 00000000000..a5ca7c674c2 Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-14.webm differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-19.png b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-19.png new file mode 100644 index 00000000000..a937e65d538 Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-19.png differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-19.webm b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-19.webm new file mode 100644 index 00000000000..ea73045ce8d Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-19.webm differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-22.png b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-22.png new file mode 100644 index 00000000000..0955059cc00 Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-22.png differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-22.webm b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-22.webm new file mode 100644 index 00000000000..cf4961c751d Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/gloopies-22.webm differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/henry-hoover-gloopie.mp4 b/desktop/src-tauri/resources/project-canvas-template/assets/henry-hoover-gloopie.mp4 new file mode 100644 index 00000000000..88215b60c9f Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/henry-hoover-gloopie.mp4 differ diff --git a/desktop/src-tauri/resources/project-canvas-template/assets/home-schedule-house.webp b/desktop/src-tauri/resources/project-canvas-template/assets/home-schedule-house.webp new file mode 100644 index 00000000000..18bffeb6c17 Binary files /dev/null and b/desktop/src-tauri/resources/project-canvas-template/assets/home-schedule-house.webp differ diff --git a/desktop/src-tauri/resources/project-canvas-template/canvas.js b/desktop/src-tauri/resources/project-canvas-template/canvas.js new file mode 100644 index 00000000000..9ba3dd4a2ba --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/canvas.js @@ -0,0 +1,571 @@ +(() => { + const PROTOCOL_VERSION = 1; + const INTERACTIVE_SELECTOR = + "a,button,input,label,select,textarea,video[controls],[role='button'],[data-no-drag]"; + const runtime = window.buzzCanvas; + const root = document.getElementById("canvas-root"); + const widgetModules = Object.values(window.buzzCanvasWidgets || {}); + const widgetRenderers = Object.assign( + {}, + ...widgetModules.map((module) => module.renderers || {}), + ); + const companionRenderers = Object.assign( + {}, + ...widgetModules.map((module) => module.companions || {}), + ); + + if (!root) throw new Error("Canvas shell is missing #canvas-root"); + if ( + !runtime || + runtime.protocolVersion !== PROTOCOL_VERSION || + !runtime.port + ) { + throw new Error("Canvas shell did not provide a compatible MessagePort"); + } + + const state = { + activeWidget: null, + canvasId: null, + dashboard: null, + data: null, + loadId: null, + mode: "preview", + nonce: null, + positions: new Map(), + project: null, + snapshots: null, + translation: { x: 24, y: 24 }, + }; + + const port = runtime.port; + port.addEventListener("message", onHostMessage); + port.start(); + + function onHostMessage(event) { + const message = event.data; + if (!message || message.protocolVersion !== PROTOCOL_VERSION) return; + if (message.type === "host.init") initialize(message); + if (message.type === "host.mode" && matchesSession(message)) { + setMode(message.mode); + } + if (message.type === "host.dataChanged" && matchesSession(message)) { + state.snapshots = message.snapshots || {}; + renderSnapshotWidgets(); + } + if (message.type === "host.widgetDataChanged" && matchesSession(message)) { + applyWidgetDataUpdate(message.widgetId, message.data); + } + } + + function matchesSession(message) { + return message.loadId === state.loadId && message.nonce === state.nonce; + } + + function initialize(message) { + if (!isInitMessage(message)) return; + state.canvasId = message.canvasId; + state.data = message.data; + state.loadId = message.loadId; + state.nonce = message.nonce; + state.project = message.project; + state.snapshots = message.snapshots || null; + state.mode = normalizeMode(message.mode); + state.dashboard = selectDashboard(message.data, message.project); + state.translation = { x: 24, y: 24 }; + state.positions.clear(); + for (const widget of state.dashboard.widgets) { + state.positions.set(widget.id, { ...widget.position }); + } + renderCanvas(); + port.postMessage({ + type: "canvas.rendered", + protocolVersion: PROTOCOL_VERSION, + loadId: state.loadId, + nonce: state.nonce, + dashboard: state.dashboard.id, + }); + } + + function isInitMessage(message) { + if (message.type !== "host.init" || !message.loadId || !message.nonce) { + return false; + } + if (!message.project || typeof message.project.name !== "string") { + return false; + } + return Boolean(message.data?.dashboards); + } + + function normalizeMode(mode) { + return mode === "full" ? "full" : "preview"; + } + + function normalizeName(name) { + return String(name || "") + .trim() + .replace(/^#/, "") + .toLowerCase(); + } + + function selectDashboard(data, project) { + const names = [project.name, project.displayName, ...(project.names || [])]; + let dashboardId = data.defaultDashboard; + for (const name of names) { + const match = data.selectors[normalizeName(name)]; + if (match) { + dashboardId = match; + break; + } + } + const dashboard = data.dashboards[dashboardId] || data.dashboards.dev; + return { ...dashboard, id: dashboardId }; + } + + function setMode(mode) { + state.mode = normalizeMode(mode); + const canvas = root.querySelector("[data-testid='project-widget-canvas']"); + if (canvas) canvas.dataset.canvasMode = state.mode; + } + + function element(tag, className, attributes) { + const node = document.createElement(tag); + if (className) node.className = className; + for (const [name, value] of Object.entries(attributes || {})) { + if (value === undefined || value === null) continue; + if (name === "text") node.textContent = String(value); + else if (name === "testId") node.dataset.testid = String(value); + else if (name === "ariaLabel") + node.setAttribute("aria-label", String(value)); + else node.setAttribute(name, String(value)); + } + return node; + } + + function icon(glyph, tone) { + return element("span", `icon ${tone || ""}`, { + "aria-hidden": "true", + text: glyph, + }); + } + + function resolveAsset(path) { + try { + return new URL(path, window.buzzCanvas.packageBaseUrl).href; + } catch (_error) { + return path; + } + } + + function renderCanvas() { + root.replaceChildren(); + const canvas = element("section", `canvas tone-${state.dashboard.tone}`, { + ariaLabel: "Project widget canvas", + testId: "project-widget-canvas", + }); + canvas.dataset.canvasMode = state.mode; + updateTranslationData(canvas); + canvas.addEventListener("pointerdown", startCanvasPan); + + const world = element("div", "canvas-world", { + testId: "project-widget-canvas-world", + }); + updateWorldTransform(world); + for (const widget of state.dashboard.widgets) { + world.append(renderWidgetGroup(widget)); + } + canvas.append(world, renderResetButton()); + root.append(canvas, renderDialogLayer()); + } + + function renderWidgetGroup(widget) { + const position = state.positions.get(widget.id); + const group = element("div", "widget-group"); + group.dataset.widgetId = widget.id; + group.style.width = `${widget.size.width}px`; + group.style.height = `${widget.size.height}px`; + moveWidgetGroup(group, position); + + const article = element("article", "widget", { + ariaLabel: `${widget.title} widget`, + testId: `project-canvas-widget-${widget.id}`, + tabindex: "0", + }); + article.setAttribute("aria-roledescription", "movable widget"); + article.dataset.worldX = String(position.x); + article.dataset.worldY = String(position.y); + article.addEventListener("pointerdown", (event) => + startWidgetDrag(event, widget), + ); + article.addEventListener("keydown", (event) => nudgeWidget(event, widget)); + if (!widget.hideHeader) article.append(renderWidgetHeader(widget)); + article.append(renderWidgetContent(widget)); + group.append(article); + + const companion = renderCompanion(widget); + if (companion) group.append(companion); + return group; + } + + function renderWidgetHeader(widget) { + const header = element("header", "widget-header", { + testId: `project-canvas-widget-${widget.id}-header`, + }); + header.append( + icon(widgetIcon(widget.type)), + element("h2", "", { text: widget.title }), + ); + return header; + } + + function widgetIcon(type) { + return ( + { + activeChannels: "#", + choreBoard: "✓", + clientTime: "◷", + meetings: "□", + reviews: "↗", + }[type] || "•" + ); + } + + function renderWidgetContent(widget) { + const renderer = widgetRenderers[widget.type]; + const content = element("div", "widget-content"); + if (renderer) + content.append(renderWith(renderer, resolveWidgetData(widget))); + else + content.append( + element("p", "empty-state", { text: "Widget unavailable" }), + ); + return content; + } + + function renderWith(renderer, data) { + if (typeof renderer === "function") return renderer(data, widgetApi); + if (renderer && typeof renderer.render === "function") { + return renderer.render(data, widgetApi); + } + return element("p", "empty-state", { text: "Widget unavailable" }); + } + + function applyWidgetDataUpdate(widgetId, data) { + const nextDashboard = selectDashboard(data, state.project); + const currentWidget = state.dashboard.widgets.find( + (widget) => widget.id === widgetId, + ); + const nextWidget = nextDashboard.widgets.find( + (widget) => widget.id === widgetId, + ); + if (!currentWidget || !nextWidget) return; + const group = [...root.querySelectorAll("[data-widget-id]")].find( + (candidate) => candidate.dataset.widgetId === widgetId, + ); + const content = group?.querySelector(".widget-content"); + if (!content) return; + + const previousData = resolveWidgetData(currentWidget); + state.data = data; + currentWidget.data = nextWidget.data; + const nextData = resolveWidgetData(currentWidget); + const renderer = widgetRenderers[currentWidget.type]; + if ( + renderer && + typeof renderer === "object" && + typeof renderer.update === "function" + ) { + const current = content.firstElementChild; + const updated = renderer.update( + current, + nextData, + previousData, + widgetApi, + ); + if (updated && updated !== current) content.replaceChildren(updated); + return; + } + content.replaceChildren(renderWith(renderer, nextData)); + } + + function resolveWidgetData(widget) { + if (widget.type === "activeChannels") { + return resolveSnapshotWidgetData( + "channels", + widget.data, + normalizeChannels, + ); + } + if (widget.type === "reviews") { + return resolveSnapshotWidgetData( + "reviews", + widget.data, + normalizeReviews, + ); + } + return widget.data; + } + + function renderSnapshotWidgets() { + const snapshotTypes = new Set(["activeChannels", "reviews"]); + for (const widget of state.dashboard.widgets) { + if (!snapshotTypes.has(widget.type)) continue; + const group = [...root.querySelectorAll("[data-widget-id]")].find( + (candidate) => candidate.dataset.widgetId === widget.id, + ); + const content = group?.querySelector(".widget-content"); + if (content) content.replaceChildren(renderWidgetContent(widget)); + } + } + + function resolveSnapshotWidgetData(key, fixture, normalize) { + if (!state.snapshots || !Object.hasOwn(state.snapshots, key)) { + return { ...fixture, snapshotState: "unavailable", [key]: [] }; + } + const snapshot = state.snapshots[key]; + if (!snapshot || snapshot.status === "loading") { + return { ...fixture, snapshotState: "loading", [key]: [] }; + } + if (snapshot.status === "error") { + return { ...fixture, snapshotState: "error", [key]: [] }; + } + const rows = Array.isArray(snapshot.data) ? snapshot.data : []; + return { + ...fixture, + snapshotState: rows.length ? "ready" : "empty", + [key]: normalize(rows), + }; + } + + function normalizeChannels(channels) { + return channels.map((channel) => { + const updates = Array.isArray(channel.updates) + ? channel.updates.slice(0, 3).map(String) + : [ + channel.topic || channel.description, + formatLastActivity(channel.lastMessageAt), + channel.memberCount ? `${channel.memberCount} members` : null, + ] + .filter(Boolean) + .map(String); + const people = Array.isArray(channel.people) + ? channel.people + .filter((person) => person?.pubkey) + .slice(0, 5) + .map((person) => ({ + id: person.pubkey, + inlinePerson: person, + })) + : []; + return { + name: channel.name || channel.displayName || "channel", + people, + updates: updates.length ? updates : ["No recent updates"], + }; + }); + } + + function formatLastActivity(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return `Last activity ${new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date)}`; + } + + function normalizeReviews(reviews) { + return reviews.map((review, index) => ({ + agentName: review.agentName || null, + agentPubkey: review.agentPubkey || null, + avatarId: review.avatarId || (index % 2 === 0 ? 14 : 19), + branch: review.branch || review.ref || "review", + displayId: + review.displayId || (review.number ? `PR #${review.number}` : "Review"), + gloopie: + index % 2 === 0 ? "assets/gloopies-14.webm" : "assets/gloopies-19.webm", + poster: + index % 2 === 0 ? "assets/gloopies-14.png" : "assets/gloopies-19.png", + status: [ + "Approved", + "Changes requested", + "Requested", + "Reviewing", + ].includes(review.status) + ? review.status + : "Requested", + title: review.title || "Review awaiting response", + })); + } + + function renderCompanion(widget) { + const renderer = companionRenderers[widget.type]; + return renderer ? renderer(widget, widgetApi) : null; + } + + function startCanvasPan(event) { + if (event.button !== 0 || event.target !== event.currentTarget) return; + const canvas = event.currentTarget; + const start = { x: event.clientX, y: event.clientY }; + const origin = { ...state.translation }; + canvas.classList.add("dragging"); + trackPointer( + event, + (point) => { + state.translation = { + x: origin.x + point.x - start.x, + y: origin.y + point.y - start.y, + }; + updateTranslationData(canvas); + updateWorldTransform(canvas.querySelector(".canvas-world")); + }, + () => canvas.classList.remove("dragging"), + ); + } + + function startWidgetDrag(event, widget) { + if (event.button !== 0 || event.target.closest(INTERACTIVE_SELECTOR)) + return; + event.preventDefault(); + event.stopPropagation(); + const article = event.currentTarget; + const group = article.parentElement; + const start = { x: event.clientX, y: event.clientY }; + const origin = { ...state.positions.get(widget.id) }; + state.activeWidget = widget.id; + group.classList.add("active", "dragging"); + trackPointer( + event, + (point) => { + const next = { + x: origin.x + point.x - start.x, + y: origin.y + point.y - start.y, + }; + state.positions.set(widget.id, next); + moveWidgetGroup(group, next); + article.dataset.worldX = String(Math.round(next.x)); + article.dataset.worldY = String(Math.round(next.y)); + }, + () => { + const snapped = snapPoint(state.positions.get(widget.id)); + state.positions.set(widget.id, snapped); + moveWidgetGroup(group, snapped); + article.dataset.worldX = String(snapped.x); + article.dataset.worldY = String(snapped.y); + group.classList.remove("dragging"); + }, + ); + } + + function trackPointer(event, onMove, onEnd) { + const pointerId = event.pointerId; + const target = event.currentTarget; + target.setPointerCapture(pointerId); + const move = (nextEvent) => { + if (nextEvent.pointerId === pointerId) onMove(nextEvent); + }; + const end = (nextEvent) => { + if (nextEvent.pointerId !== pointerId) return; + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", end); + window.removeEventListener("pointercancel", end); + if (target.hasPointerCapture(pointerId)) + target.releasePointerCapture(pointerId); + onEnd(nextEvent); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", end); + window.addEventListener("pointercancel", end); + } + + function nudgeWidget(event, widget) { + if (event.target !== event.currentTarget) return; + const amount = event.shiftKey ? 48 : 24; + const delta = { + ArrowDown: { x: 0, y: amount }, + ArrowLeft: { x: -amount, y: 0 }, + ArrowRight: { x: amount, y: 0 }, + ArrowUp: { x: 0, y: -amount }, + }[event.key]; + if (!delta) return; + event.preventDefault(); + const current = state.positions.get(widget.id); + const next = snapPoint({ x: current.x + delta.x, y: current.y + delta.y }); + state.positions.set(widget.id, next); + const group = event.currentTarget.parentElement; + moveWidgetGroup(group, next); + event.currentTarget.dataset.worldX = String(next.x); + event.currentTarget.dataset.worldY = String(next.y); + } + + function snapPoint(point) { + return { + x: Math.round(point.x / 24) * 24, + y: Math.round(point.y / 24) * 24, + }; + } + + function moveWidgetGroup(group, position) { + group.style.transform = `translate3d(${position.x}px, ${position.y}px, 0)`; + } + + function updateWorldTransform(world) { + world.style.transform = `translate3d(${state.translation.x}px, ${state.translation.y}px, 0)`; + } + + function updateTranslationData(canvas) { + canvas.dataset.panX = String(Math.round(state.translation.x)); + canvas.dataset.panY = String(Math.round(state.translation.y)); + canvas.dataset.projectDashboard = state.dashboard ? state.dashboard.id : ""; + } + + function renderResetButton() { + const button = element("button", "reset-button", { + ariaLabel: "Reset canvas position", + testId: "project-widget-canvas-reset", + title: "Reset canvas position", + type: "button", + }); + button.append(icon("⌖")); + button.addEventListener("click", () => { + state.translation = { x: 24, y: 24 }; + const canvas = root.querySelector( + "[data-testid='project-widget-canvas']", + ); + updateTranslationData(canvas); + updateWorldTransform(canvas.querySelector(".canvas-world")); + }); + return button; + } + + function renderDialogLayer() { + return element("div", "dialog-layer", { testId: "canvas-dialog-layer" }); + } + + function showDialog(title, body, testId) { + const layer = root.querySelector(".dialog-layer"); + const backdrop = element("div", "dialog-backdrop"); + const dialog = element("section", "dialog", { testId }); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.append(element("h2", "dialog-title", { text: title }), body); + const close = element("button", "dialog-close", { + ariaLabel: "Close", + text: "×", + type: "button", + }); + close.addEventListener("click", () => layer.replaceChildren()); + dialog.prepend(close); + backdrop.append(dialog); + layer.replaceChildren(backdrop); + close.focus(); + } + + const widgetApi = Object.freeze({ + element, + icon, + resolveAsset, + showDialog, + state: () => state, + }); +})(); diff --git a/desktop/src-tauri/resources/project-canvas-template/data/dashboards.json b/desktop/src-tauri/resources/project-canvas-template/data/dashboards.json new file mode 100644 index 00000000000..1925d4a4893 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/data/dashboards.json @@ -0,0 +1,350 @@ +{ + "version": 1, + "defaultDashboard": "dev", + "selectors": { + "my-home": "home", + "my-dev-team": "dev", + "my-support-channel": "support" + }, + "people": { + "thom": { + "name": "ThomPeteMain", + "pubkey": "29ddeb07aec92535a5b38b7ea1d731bc641fd97ffcf59080ab9a2584d3cbe5c6", + "color": "#2563eb" + }, + "luis": { + "name": "Luis Padron", + "pubkey": "b7fab6a57b4a9e504b8b6a404353f557dc0dec86ef112ef6b3cae0ea9f683561", + "color": "#059669" + }, + "tho": { + "name": "tho", + "pubkey": "80c5f18be5aafa62cf6198c6335963ba3306b595288117c8ea2f805fc9bdc94a", + "color": "#d97706" + }, + "john": { + "name": "John Tennant", + "pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820", + "color": "#db2777" + }, + "morgan": { + "name": "Morgan Martin", + "pubkey": "d02a59460cd9333b73730695f0090d54a3bd0fb7840c3e1995a4968eda297047", + "color": "#7c3aed" + } + }, + "dashboards": { + "home": { + "tone": "home", + "widgets": [ + { + "id": "home-clock", + "type": "homeSchedule", + "title": "Today at home", + "hideHeader": true, + "position": { "x": 48, "y": 0 }, + "size": { "width": 264, "height": 264 }, + "data": { + "background": "assets/home-schedule-house.webp", + "gloopie": "assets/gloopies-1.webm", + "gloopiePoster": "assets/gloopies-1.png", + "updates": [ + "Sally pickup is earlier than usual, oboe practice cancelled today", + "Electrician coming between 10am and 5pm, but promises to let us know" + ] + } + }, + { + "id": "family-locations", + "type": "familyLocations", + "title": "Family locations", + "hideHeader": true, + "position": { "x": 336, "y": 0 }, + "size": { "width": 384, "height": 336 }, + "data": { + "places": ["School", "Cafe", "Library", "Work", "Shops", "Oboe"] + } + }, + { + "id": "front-yard-camera", + "type": "frontYardCamera", + "title": "Front yard", + "hideHeader": true, + "position": { "x": 744, "y": 0 }, + "size": { "width": 264, "height": 264 }, + "data": { + "image": "assets/front-yard-camera.webp", + "caption": "Small delivery arrived at 10:35am" + } + }, + { + "id": "chores", + "type": "choreBoard", + "title": "Chore board", + "position": { "x": 1032, "y": 0 }, + "size": { "width": 264, "height": 336 }, + "data": { + "gloopie": "assets/henry-hoover-gloopie.mp4", + "groups": [ + { + "member": "Maya", + "color": "#e879a9", + "chores": ["Water the herbs", "Pack the library books"], + "completed": ["Water the herbs"] + }, + { + "member": "Jon", + "color": "#4faea2", + "chores": ["Take bins to the curb", "Book the car service"], + "completed": [] + }, + { + "member": "Ellis", + "color": "#7b82d6", + "chores": ["Feed the fish", "Put away clean laundry"], + "completed": [] + } + ] + } + } + ] + }, + "dev": { + "tone": "work", + "widgets": [ + { + "id": "active-channels", + "type": "activeChannels", + "title": "Active channels", + "position": { "x": 0, "y": 0 }, + "size": { "width": 336, "height": 336 }, + "data": { + "channels": [ + { + "name": "launch-room", + "updates": [ + "RC 4 promoted to staging", + "12 launch checks passing", + "Two launch notes need sign-off" + ], + "people": [ + { "id": "thom", "activity": 5 }, + { "id": "luis", "activity": 3 }, + { "id": "tho", "activity": 4 }, + { "id": "john", "activity": 2 }, + { "id": "morgan", "activity": 1 } + ] + }, + { + "name": "client-sync", + "updates": [ + "Desktop/mobile sync approved", + "Retry patch landed" + ], + "people": [ + { "id": "luis", "activity": 5 }, + { "id": "tho", "activity": 3 }, + { "id": "john", "activity": 2 }, + { "id": "morgan", "activity": 1 } + ] + }, + { + "name": "design-review", + "updates": [ + "Canvas motion pass ready", + "Three review notes open", + "Contrast audit complete" + ], + "people": [ + { "id": "john", "activity": 4 }, + { "id": "luis", "activity": 2 }, + { "id": "tho", "activity": 3 } + ] + }, + { + "name": "incident-followup", + "updates": ["Mitigation is holding", "Two follow-ups remain"], + "people": [ + { "id": "tho", "activity": 4 }, + { "id": "thom", "activity": 2 } + ] + }, + { + "name": "docs", + "updates": ["Migration notes ready to publish"], + "people": [{ "id": "morgan", "activity": 3 }] + } + ] + } + }, + { + "id": "reviews", + "type": "reviews", + "title": "Reviews", + "position": { "x": 384, "y": 0 }, + "size": { "width": 456, "height": 320 }, + "data": { + "reviews": [ + { + "number": 2487, + "title": "Make project canvases easier to navigate", + "branch": "feat/canvas-navigation", + "status": "Approved", + "gloopie": "assets/gloopies-14.webm", + "poster": "assets/gloopies-14.png", + "avatarId": 14 + }, + { + "number": 2491, + "title": "Keep presence stable after reconnects", + "branch": "fix/reconnect-presence", + "status": "Reviewing", + "gloopie": "assets/gloopies-19.webm", + "poster": "assets/gloopies-19.png", + "avatarId": 19 + } + ] + } + }, + { + "id": "time-tracking", + "type": "clientTime", + "title": "Client time", + "position": { "x": 888, "y": 0 }, + "size": { "width": 360, "height": 320 }, + "data": { + "booked": "30h 45m", + "capacity": "40h", + "clients": [ + { + "name": "Northstar", + "project": "Checkout audit", + "time": "14h 30m", + "share": 47, + "color": "#0ea5e9" + }, + { + "name": "Cedar Labs", + "project": "Mobile release", + "time": "9h 45m", + "share": 32, + "color": "#14b8a6" + }, + { + "name": "Studio Kite", + "project": "Design system", + "time": "6h 30m", + "share": 21, + "color": "#8b5cf6" + } + ] + } + }, + { + "id": "meetings", + "type": "meetings", + "title": "Meetings", + "position": { "x": 336, "y": 456 }, + "size": { "width": 552, "height": 216 }, + "data": { + "previous": { + "title": "Weekly product review", + "time": "Yesterday, 3:00 PM", + "duration": "42 min" + }, + "upcoming": [ + { + "day": "Today", + "time": "2:30 PM", + "duration": "30 min", + "title": "Design crit" + }, + { + "day": "Tomorrow", + "time": "10:00 AM", + "duration": "45 min", + "title": "Sprint planning" + } + ] + } + } + ] + }, + "support": { + "tone": "support", + "widgets": [ + { + "id": "release-notes", + "type": "releaseNotes", + "title": "Latest release", + "hideHeader": true, + "position": { "x": 0, "y": 0 }, + "size": { "width": 408, "height": 320 }, + "data": { + "product": "Acorn 2.8", + "items": [ + { + "title": "Faster inbox", + "detail": "One-click replies now keep the full customer history in view." + }, + { + "title": "On-call schedules", + "detail": "Set coverage hours and hand off urgent conversations cleanly." + }, + { + "title": "Workspace polish", + "detail": "A calmer composer with saved views for your busiest queues." + } + ] + } + }, + { + "id": "known-issues", + "type": "knownIssues", + "title": "Known issues", + "hideHeader": true, + "position": { "x": 456, "y": 0 }, + "size": { "width": 456, "height": 360 }, + "data": { + "issues": [ + { + "id": "AC-184", + "title": "Long invoice exports", + "detail": "PDF exports may omit the final page on invoices with 50+ rows.", + "status": "Fix rolling out", + "tone": "amber" + }, + { + "id": "AC-179", + "title": "Member list refresh", + "detail": "EU workspaces can see a short delay before new teammates appear.", + "status": "Monitoring", + "tone": "sky" + }, + { + "id": "AC-171", + "title": "Call audio on Safari", + "detail": "Safari may require a second click to resume call audio.", + "status": "Workaround shared", + "tone": "violet" + } + ] + } + }, + { + "id": "bug-reporter", + "type": "bugReporter", + "title": "Bug reporter", + "hideHeader": true, + "position": { "x": 960, "y": 24 }, + "size": { "width": 384, "height": 280 }, + "data": { + "gloopie": "assets/gloopies-22.webm", + "gloopiePoster": "assets/gloopies-22.png", + "responseTime": "usually responds in 4m" + } + } + ] + } + } +} diff --git a/desktop/src-tauri/resources/project-canvas-template/manifest.json b/desktop/src-tauri/resources/project-canvas-template/manifest.json new file mode 100644 index 00000000000..7a2b8dffc31 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/manifest.json @@ -0,0 +1,23 @@ +{ + "format": "buzz-project-canvas", + "protocolVersion": 1, + "scripts": [ + "widgets/home.js", + "widgets/dev-team.js", + "widgets/support.js", + "canvas.js" + ], + "styles": [ + "styles/base.css", + "styles/home.css", + "styles/dev-team.css", + "styles/support.css", + "styles/overlays.css" + ], + "data": "data/dashboards.json", + "capabilities": [ + "project.metadata.read", + "project.channels.read", + "project.reviews.read" + ] +} diff --git a/desktop/src-tauri/resources/project-canvas-template/styles/base.css b/desktop/src-tauri/resources/project-canvas-template/styles/base.css new file mode 100644 index 00000000000..8a11b46e968 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/styles/base.css @@ -0,0 +1,233 @@ +:root { + color: #172033; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; + font-size: 16px; + font-synthesis: none; + letter-spacing: 0; +} + +* { + box-sizing: border-box; + letter-spacing: 0; +} + +html, +body, +#canvas-root { + height: 100%; + margin: 0; + min-height: 0; + overflow: hidden; + width: 100%; +} + +button, +input, +textarea { + font: inherit; + letter-spacing: 0; +} + +button { + cursor: pointer; +} + +[hidden][hidden] { + display: none; +} + +.canvas { + background-color: #f4f6f8; + background-image: radial-gradient( + circle, + rgba(84, 96, 115, 0.28) 1px, + transparent 1px + ); + background-size: 24px 24px; + cursor: grab; + height: 100%; + min-height: 0; + overflow: hidden; + position: relative; + touch-action: none; + user-select: none; + width: 100%; +} + +.canvas.dragging { + cursor: grabbing; +} + +.canvas.tone-home { + background-color: #fff7f8; +} + +.canvas.tone-work { + background-color: #f2f5f7; +} + +.canvas.tone-support { + background-color: #fffaf0; +} + +.canvas-world { + height: 0; + left: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 0; + will-change: transform; +} + +.widget-group { + left: 0; + pointer-events: auto; + position: absolute; + top: 0; + transform-origin: 0 0; + z-index: 10; +} + +.widget-group.active { + z-index: 30; +} + +.widget { + background: #ffffff; + border: 1px solid rgba(122, 133, 150, 0.3); + border-radius: 8px; + box-shadow: + 0 12px 30px rgba(31, 41, 55, 0.12), + 0 2px 7px rgba(31, 41, 55, 0.08); + cursor: grab; + display: flex; + flex-direction: column; + height: 100%; + outline: none; + overflow: hidden; + position: relative; + width: 100%; +} + +.widget:focus-visible { + box-shadow: + 0 0 0 3px rgba(37, 99, 235, 0.28), + 0 12px 30px rgba(31, 41, 55, 0.12); +} + +.widget-group.dragging .widget { + cursor: grabbing; +} + +.widget-header { + align-items: center; + border-bottom: 1px solid #e7eaf0; + display: flex; + flex: 0 0 40px; + gap: 8px; + min-width: 0; + padding: 0 12px; +} + +.widget-header h2 { + font-size: 0.875rem; + font-weight: 650; + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.widget-content { + flex: 1; + min-height: 0; +} + +.icon { + align-items: center; + color: #667085; + display: inline-flex; + flex: 0 0 auto; + font-size: 0.8rem; + font-weight: 750; + height: 20px; + justify-content: center; + line-height: 1; + width: 20px; +} + +.muted { + color: #667085; +} + +.eyebrow { + color: #667085; + font-size: 0.625rem; + font-weight: 700; + margin: 0; + text-transform: uppercase; +} + +.reset-button { + align-items: center; + background: rgba(255, 255, 255, 0.94); + border: 1px solid #d7dce4; + border-radius: 6px; + box-shadow: 0 3px 10px rgba(31, 41, 55, 0.1); + display: flex; + height: 34px; + justify-content: center; + position: absolute; + left: 12px; + top: 12px; + width: 34px; + z-index: 50; +} + +.companion { + pointer-events: none; + position: absolute; + z-index: 20; +} + +.gloopie-video, +.henry-canvas { + filter: drop-shadow(0 7px 8px rgba(31, 41, 55, 0.18)); + height: 100%; + object-fit: contain; + width: 100%; +} + +.home-schedule-companion { + bottom: -72px; + height: 144px; + left: -72px; + width: 144px; +} + +.henry-companion { + height: 176px; + right: -88px; + top: 32px; + width: 176px; +} + +.bug-companion { + height: 144px; + right: -76px; + top: 72px; + width: 112px; +} + +.henry-source { + height: 1px; + left: -9999px; + opacity: 0; + pointer-events: none; + position: fixed; + width: 1px; +} diff --git a/desktop/src-tauri/resources/project-canvas-template/styles/dev-team.css b/desktop/src-tauri/resources/project-canvas-template/styles/dev-team.css new file mode 100644 index 00000000000..58cd86fde3d --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/styles/dev-team.css @@ -0,0 +1,345 @@ +.active-channels { + height: 100%; + overflow: auto; + padding: 4px 8px; +} + +.channel-row { + align-items: flex-start; + border-bottom: 1px solid #e8ebef; + display: flex; + gap: 8px; + min-height: 59px; + padding: 6px 4px; +} + +.channel-row:last-child { + border-bottom: 0; +} + +.channel-details { + flex: 1; + min-width: 0; +} + +.channel-name { + display: block; + font-size: 0.7rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.channel-updates { + color: #667085; + font-size: 0.575rem; + line-height: 1.35; + list-style: none; + margin: 3px 0 0; + padding: 0; +} + +.channel-updates li { + align-items: center; + display: flex; + gap: 3px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.channel-updates .icon { + color: #0f766e; + font-size: 0.5rem; + height: 10px; + width: 10px; +} + +.channel-people { + align-items: center; + align-self: center; + display: flex; + flex: 0 0 auto; + padding-left: 8px; +} + +.active-person { + height: 25px; + margin-left: -6px; + width: 25px; +} + +.avatar-image { + border: 1px solid #ffffff; + border-radius: 50%; + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.reviews, +.meetings { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + padding: 4px 12px 8px; +} + +.reviews-intro { + align-items: center; + border-bottom: 1px solid #e7eaf0; + display: flex; + font-size: 0.72rem; + font-weight: 650; + justify-content: space-between; + padding: 3px 0 9px; +} + +.count-badge, +.scheduled { + background: #e7f4ff; + border-radius: 5px; + color: #075985; + font-size: 0.625rem; + padding: 4px 7px; +} + +.review-row { + align-items: center; + border-bottom: 1px solid #e7eaf0; + display: flex; + flex: 1; + gap: 10px; + min-height: 0; + padding: 8px 0; +} + +.review-row:last-child { + border-bottom: 0; +} + +.review-summary { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.review-summary strong, +.review-summary code { + font-size: 0.72rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-summary code { + color: #667085; + font-size: 0.6rem; + margin-top: 3px; +} + +.review-number { + color: #0369a1; + font-size: 0.625rem; + font-weight: 700; + margin-bottom: 2px; +} + +.review-status { + align-items: center; + display: flex; + flex: 0 0 auto; + gap: 5px; +} + +.review-gloopie { + height: 48px; + object-fit: contain; + width: 40px; +} + +.status-pill { + align-items: center; + background: #e8f5ff; + border-radius: 5px; + color: #0369a1; + display: inline-flex; + font-size: 0.625rem; + font-weight: 700; + min-height: 28px; + padding: 4px 7px; +} + +.status-pill.approved { + background: #e4f8ed; + color: #047857; + font-size: 0.9rem; + justify-content: center; + padding: 4px; + width: 28px; +} + +.status-pill.changes-requested { + background: #fff1e7; + color: #b54708; +} + +.snapshot-state { + align-items: center; + color: #667085; + display: flex; + flex: 1; + font-size: 0.75rem; + justify-content: center; + min-height: 100px; + text-align: center; +} + +.client-time { + height: 100%; + overflow: auto; + padding: 10px 12px; +} + +.time-summary { + border-bottom: 1px solid #e7eaf0; + padding-bottom: 10px; +} + +.time-total { + display: inline-block; + font-size: 1.35rem; + margin-top: 4px; +} + +.capacity-bar { + border-radius: 3px; + display: flex; + height: 9px; + margin-top: 8px; + overflow: hidden; +} + +.capacity-note { + color: #667085; + font-size: 0.625rem; + margin: 5px 0 0; +} + +.client-row { + align-items: center; + border-bottom: 1px solid #eceef2; + display: flex; + gap: 8px; + padding: 9px 0; +} + +.client-row:last-child { + border-bottom: 0; +} + +.client-mark { + border-radius: 2px; + height: 28px; + width: 4px; +} + +.client-copy { + display: flex; + flex: 1; + flex-direction: column; + font-size: 0.7rem; + min-width: 0; +} + +.client-copy span { + font-size: 0.625rem; + margin-top: 2px; +} + +.client-hours { + font-size: 0.72rem; +} + +.meetings .eyebrow { + padding: 2px 0 4px; +} + +.meeting-previous { + align-items: center; + border-bottom: 1px solid #e7eaf0; + display: flex; + gap: 9px; + padding-bottom: 8px; +} + +.meeting-copy { + display: flex; + flex: 1; + flex-direction: column; + font-size: 0.7rem; + min-width: 0; +} + +.meeting-copy span { + font-size: 0.625rem; + margin-top: 2px; +} + +.meeting-actions { + display: flex; + gap: 5px; +} + +.small-button { + background: #ffffff; + border: 1px solid #d7dce4; + border-radius: 5px; + color: #344054; + font-size: 0.625rem; + font-weight: 650; + padding: 5px 7px; +} + +.meetings .upcoming-label { + padding-top: 7px; +} + +.upcoming-meetings { + list-style: none; + margin: 0; + padding: 0; +} + +.upcoming-row { + align-items: center; + border-bottom: 1px solid #eceef2; + display: flex; + gap: 9px; + padding: 5px 0; +} + +.upcoming-row:last-child { + border-bottom: 0; +} + +.meeting-time { + display: flex; + flex: 0 0 70px; + flex-direction: column; + font-size: 0.625rem; +} + +.meeting-time strong { + color: #0369a1; +} + +.scheduled { + flex: 0 0 auto; + font-size: 0.575rem; +} diff --git a/desktop/src-tauri/resources/project-canvas-template/styles/home.css b/desktop/src-tauri/resources/project-canvas-template/styles/home.css new file mode 100644 index 00000000000..cd00036b7b2 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/styles/home.css @@ -0,0 +1,277 @@ +.chore-board { + height: 100%; + overflow: auto; + padding: 8px 12px; +} + +.chore-group { + margin: 0 0 9px; +} + +.member-heading { + align-items: center; + color: #667085; + display: flex; + font-size: 0.75rem; + gap: 6px; + margin: 0 0 3px; +} + +.avatar { + align-items: center; + border: 2px solid #ffffff; + border-radius: 50%; + color: #ffffff; + display: inline-flex; + flex: 0 0 auto; + font-size: 0.625rem; + font-weight: 750; + height: 23px; + justify-content: center; + width: 23px; +} + +.chore-row { + align-items: center; + border-radius: 5px; + cursor: pointer; + display: flex; + font-size: 0.72rem; + gap: 7px; + min-height: 28px; + padding: 2px 4px; +} + +.chore-row:hover { + background: #f4f5f7; +} + +.chore-row input { + accent-color: #7c3aed; + height: 15px; + margin: 0; + width: 15px; +} + +.completed { + color: #8a94a5; + text-decoration: line-through; +} + +.home-schedule { + height: 100%; + overflow: hidden; + padding: 12px; + position: relative; +} + +.home-background, +.home-overlay { + height: 100%; + inset: 0; + object-fit: cover; + position: absolute; + width: 100%; +} + +.home-overlay { + background: rgba(28, 32, 42, 0.23); +} + +.speech-list { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; + justify-content: center; + list-style: none; + margin: 0 0 0 auto; + padding: 0; + position: relative; + width: 82%; + z-index: 2; +} + +.speech-bubble { + background: rgba(255, 255, 255, 0.92); + border: 1px solid #ffe2e8; + border-radius: 8px; + box-shadow: 0 5px 14px rgba(31, 41, 55, 0.12); + color: #3b3f49; + font-size: 0.72rem; + font-weight: 620; + line-height: 1.4; + padding: 8px 10px; +} + +.camera-widget { + background: #18181b; + height: 100%; + margin: 0; + overflow: hidden; + position: relative; +} + +.camera-image { + height: 100%; + object-fit: cover; + width: 100%; +} + +.recording { + background: rgba(0, 0, 0, 0.64); + border-radius: 999px; + color: #ffffff; + font-size: 0.58rem; + font-weight: 750; + left: 10px; + padding: 5px 8px; + position: absolute; + text-transform: uppercase; + top: 10px; +} + +.recording::first-letter { + color: #ef4444; +} + +.camera-caption { + background: rgba(0, 0, 0, 0.7); + bottom: 0; + color: #ffffff; + font-size: 0.72rem; + font-weight: 600; + left: 0; + line-height: 1.35; + padding: 24px 12px 12px; + position: absolute; + right: 0; +} + +.family-locations { + background: #f5f7fc; + height: 100%; + overflow: hidden; + position: relative; +} + +.place { + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.95); + border-radius: 50%; + box-shadow: 0 8px 20px rgba(31, 41, 55, 0.08); + display: flex; + flex-direction: column; + font-size: 0.625rem; + font-weight: 700; + height: 72px; + justify-content: center; + position: absolute; + width: 72px; +} + +.place-school { + background: #d9f8e8; + left: 20px; + top: 20px; +} +.place-cafe { + background: #fff0c9; + left: 43%; + top: 12px; +} +.place-library { + background: #dceeff; + right: 20px; + top: 24px; +} +.place-work { + background: #dce7ff; + bottom: 20px; + right: 20px; +} +.place-shops { + background: #f6ddff; + bottom: 12px; + left: 42%; +} +.place-oboe { + background: #ffe1e8; + bottom: 24px; + left: 20px; +} + +.place-home { + background: #e7ddff; + color: #4c2b78; + font-size: 0.875rem; + height: 128px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 128px; +} + +.family-member { + align-items: center; + background: rgba(255, 255, 255, 0.96); + border: 1px solid #ffffff; + border-radius: 999px; + box-shadow: 0 4px 12px rgba(31, 41, 55, 0.13); + display: flex; + font-size: 0.625rem; + font-weight: 700; + gap: 5px; + padding: 3px 7px 3px 3px; + position: absolute; + z-index: 4; +} + +.family-member .avatar { + background: #7c3aed; + height: 20px; + width: 20px; +} + +.member-sally { + left: 12%; + top: 24%; +} +.member-you { + left: 44%; + top: 54%; +} + +.dad-route { + align-items: center; + display: flex; + gap: 4px; + left: 66%; + position: absolute; + top: 59%; + z-index: 5; +} + +.dad-route .family-member { + position: static; +} + +.dad-arrow { + color: #2563eb; + font-size: 1.1rem; + font-weight: 800; +} +.chore-board.widget-data-updated { + animation: widget-data-update 360ms ease-out; +} + +@keyframes widget-data-update { + from { + opacity: 0.72; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/desktop/src-tauri/resources/project-canvas-template/styles/overlays.css b/desktop/src-tauri/resources/project-canvas-template/styles/overlays.css new file mode 100644 index 00000000000..4a9bd836ac8 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/styles/overlays.css @@ -0,0 +1,140 @@ +.dialog-layer { + inset: 0; + pointer-events: none; + position: absolute; + z-index: 100; +} + +.dialog-backdrop { + align-items: center; + background: rgba(20, 27, 38, 0.46); + display: flex; + height: 100%; + justify-content: center; + pointer-events: auto; + width: 100%; +} + +.dialog { + background: #ffffff; + border: 1px solid #d7dce4; + border-radius: 8px; + box-shadow: 0 24px 70px rgba(20, 27, 38, 0.28); + max-width: 460px; + padding: 20px; + position: relative; + width: calc(100% - 32px); +} + +.dialog-title { + font-size: 1rem; + margin: 0 32px 12px 0; +} + +.dialog-close { + background: transparent; + border: 0; + color: #667085; + font-size: 1.4rem; + height: 30px; + position: absolute; + right: 10px; + top: 8px; + width: 30px; +} + +.meeting-detail { + color: #475467; + font-size: 0.75rem; +} + +.notes-list { + line-height: 1.55; + padding-left: 20px; +} + +.recording-screen { + align-items: center; + background: #18181b; + border-radius: 7px; + display: flex; + height: 220px; + justify-content: center; +} + +.play-button { + background: rgba(255, 255, 255, 0.13); + border: 1px solid rgba(255, 255, 255, 0.36); + border-radius: 50%; + color: #ffffff; + height: 48px; + width: 48px; +} + +.recording-time { + font-variant-numeric: tabular-nums; + text-align: center; +} + +@media (prefers-color-scheme: dark) { + :root { + color: #e7ebf2; + } + + .canvas, + .canvas.tone-work { + background-color: #15181d; + } + + .canvas.tone-home { + background-color: #241a20; + } + + .canvas.tone-support { + background-color: #211e17; + } + + .widget, + .reset-button, + .dialog { + background: #20242b; + border-color: #3a404b; + } + + .widget-header, + .channel-row, + .review-row, + .reviews-intro, + .time-summary, + .client-row, + .meeting-previous, + .upcoming-row, + .release-header, + .release-row { + border-color: #373d47; + } + + .muted, + .channel-updates, + .capacity-note, + .eyebrow { + color: #a3adbd; + } + + .known-issues { + background: #1b1d21; + } + + .bug-editor textarea, + .small-button { + background: #181b20; + border-color: #454c58; + color: #e7ebf2; + } + + .speech-bubble { + background: rgba(32, 36, 43, 0.94); + border-color: #51363e; + color: #edf0f5; + } +} diff --git a/desktop/src-tauri/resources/project-canvas-template/styles/support.css b/desktop/src-tauri/resources/project-canvas-template/styles/support.css new file mode 100644 index 00000000000..01c6b196d10 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/styles/support.css @@ -0,0 +1,307 @@ +.release-notes { + height: 100%; + overflow: auto; + padding: 12px; +} + +.release-header { + align-items: center; + border-bottom: 1px solid #e7eaf0; + display: flex; + gap: 9px; + padding-bottom: 10px; +} + +.release-header h3, +.release-header p, +.release-row h4, +.release-row p { + margin: 0; +} + +.release-header h3 { + font-size: 0.875rem; +} + +.release-header p, +.release-row p { + font-size: 0.65rem; + line-height: 1.4; + margin-top: 2px; +} + +.release-icon { + background: #172033; + border-radius: 7px; + color: #ffffff; + height: 36px; + width: 36px; +} + +.live-badge { + background: #e4f8ed; + border-radius: 999px; + color: #047857; + font-size: 0.575rem; + font-weight: 700; + margin-left: auto; + padding: 3px 7px; +} + +.release-row { + align-items: flex-start; + border-bottom: 1px solid #eceef2; + display: flex; + gap: 9px; + padding: 10px 0; +} + +.release-row:last-child { + border-bottom: 0; +} + +.release-row h4 { + font-size: 0.72rem; +} + +.release-row .icon { + border-radius: 5px; + height: 28px; + width: 28px; +} + +.release-tone-0 { + background: #cffafe; + color: #0e7490; +} +.release-tone-1 { + background: #ede9fe; + color: #6d28d9; +} +.release-tone-2 { + background: #d1fae5; + color: #047857; +} + +.known-issues { + background: #f7f7f5; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + padding: 12px; +} + +.issues-header { + align-items: center; + display: flex; + justify-content: space-between; + padding-bottom: 9px; +} + +.issues-header h3, +.issues-header p { + margin: 0; +} + +.issues-header h3 { + font-size: 0.75rem; +} + +.issues-header p, +.issues-header span { + font-size: 0.575rem; +} + +.issue-grid { + display: grid; + flex: 1; + gap: 8px; + grid-template-columns: 1fr 1fr; + min-height: 0; + overflow: auto; +} + +.issue-note { + border: 1px solid rgba(107, 114, 128, 0.22); + box-shadow: 0 3px 8px rgba(31, 41, 55, 0.08); + font-size: 0.625rem; + min-height: 108px; + padding: 12px 10px 9px; + position: relative; +} + +.issue-note::before { + background: #ef4444; + border-radius: 50%; + content: ""; + height: 6px; + left: 50%; + position: absolute; + top: 4px; + transform: translateX(-50%); + width: 6px; +} + +.issue-note.wide { + grid-column: span 2; +} + +.issue-note.tone-amber { + background: #fff0b8; + color: #5c4310; +} +.issue-note.tone-sky { + background: #d8f1ff; + color: #164e63; +} +.issue-note.tone-violet { + background: #eee3ff; + color: #4c1d95; +} + +.issue-title { + align-items: flex-start; + display: flex; + justify-content: space-between; +} + +.issue-title h4, +.issue-note p { + margin: 0; +} + +.issue-title h4 { + font-size: 0.7rem; +} + +.issue-title span { + opacity: 0.6; +} + +.issue-note p { + line-height: 1.4; + margin-top: 6px; + opacity: 0.82; +} + +.issue-status { + display: block; + font-size: 0.575rem; + margin-top: 8px; + opacity: 0.74; +} + +.bug-reporter { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 12px; +} + +.bug-header { + align-items: center; + display: flex; + gap: 9px; + padding-bottom: 9px; +} + +.bug-header h3, +.bug-header p { + margin: 0; +} + +.bug-header h3 { + font-size: 0.75rem; +} + +.bug-header p { + font-size: 0.575rem; + margin-top: 2px; +} + +.bug-icon { + background: #fce7f3; + border-radius: 7px; + color: #be185d; + height: 32px; + width: 32px; +} + +.bug-editor { + flex: 1; + min-height: 0; + position: relative; +} + +.bug-editor textarea { + background: #fbfcfd; + border: 1px solid #d7dce4; + border-radius: 7px; + color: #172033; + height: 100%; + line-height: 1.4; + padding: 10px 10px 42px; + resize: none; + touch-action: auto; + user-select: text; + width: 100%; +} + +.submit-button { + background: #172033; + border: 0; + border-radius: 5px; + bottom: 8px; + color: #ffffff; + font-size: 0.65rem; + font-weight: 700; + padding: 6px 10px; + position: absolute; + right: 8px; +} + +.submit-button:disabled { + cursor: default; + opacity: 0.38; +} + +.bug-success { + align-items: center; + background: #e9f8ef; + border: 1px solid #b8e5c9; + border-radius: 7px; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + min-height: 0; + text-align: center; +} + +.bug-success h4, +.bug-success p { + margin: 3px 0 0; +} + +.bug-success h4 { + font-size: 0.8rem; +} + +.bug-success p { + font-size: 0.625rem; +} + +.success-circle, +.success { + background: #10b981; + border-radius: 50%; + color: #ffffff; +} + +.success-circle { + height: 34px; + width: 34px; +} diff --git a/desktop/src-tauri/resources/project-canvas-template/widgets/dev-team.js b/desktop/src-tauri/resources/project-canvas-template/widgets/dev-team.js new file mode 100644 index 00000000000..6c5d4c0b4ea --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/widgets/dev-team.js @@ -0,0 +1,326 @@ +(() => { + window.buzzCanvasWidgets = window.buzzCanvasWidgets || {}; + window.buzzCanvasWidgets.devTeam = { + renderers: { + activeChannels: renderActiveChannels, + clientTime: renderClientTime, + meetings: renderMeetings, + reviews: renderReviews, + }, + }; + + function renderActiveChannels(data, api) { + const { element, icon } = api; + const list = element("div", "active-channels", { + testId: "project-canvas-active-channels", + }); + if (data.snapshotState && data.snapshotState !== "ready") { + list.append(renderSnapshotState(data.snapshotState, "channels", element)); + return list; + } + for (const channel of data.channels) { + const row = element("section", "channel-row", { + testId: `project-canvas-active-channel-${channel.name}`, + }); + const details = element("div", "channel-details"); + details.append( + element("strong", "channel-name", { text: `# ${channel.name}` }), + ); + const updates = element("ul", "channel-updates", { + ariaLabel: `${channel.name} updates`, + }); + channel.updates.forEach((update) => { + const item = element("li", "", { text: update }); + item.prepend(icon("✓")); + updates.append(item); + }); + details.append(updates); + const people = renderChannelPeople(channel, api); + row.append(details, people); + list.append(row); + } + return list; + } + + function renderChannelPeople(channel, { element, state }) { + const people = element("div", "channel-people", { + ariaLabel: `${channel.people.length} channel members`, + }); + channel.people.forEach((active, index) => { + const inline = active.inlinePerson || {}; + const person = state().data.people[active.id] || { + color: inline.color || "#64748b", + name: inline.displayName || inline.name || "Project member", + pubkey: inline.pubkey || active.id, + }; + const wrapper = element("span", "active-person", { + ariaLabel: `${person.name}, channel member`, + role: "img", + testId: `project-canvas-active-channel-${channel.name}-person-${index + 1}`, + }); + wrapper.dataset.pubkey = person.pubkey; + const safeAvatar = + typeof inline.avatarDataUrl === "string" && + inline.avatarDataUrl.startsWith("data:image/") + ? inline.avatarDataUrl + : avatarDataUri(person); + wrapper.append( + element("img", "avatar-image", { + alt: `${person.name} avatar`, + src: safeAvatar, + testId: `project-canvas-active-member-${person.pubkey}`, + }), + ); + people.append(wrapper); + }); + return people; + } + + function avatarDataUri(person) { + const initials = person.name + .split(/\s+/) + .map((part) => part[0]) + .join("") + .slice(0, 2); + const svg = `${initials}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; + } + + function renderReviews(data, { element, resolveAsset }) { + const section = element("section", "reviews", { + ariaLabel: "Reviews you are waiting on", + testId: "project-canvas-reviews", + }); + const intro = element("header", "reviews-intro"); + intro.append( + element("div", "", { text: "Waiting on review" }), + element("strong", "count-badge", { text: `${data.reviews.length} open` }), + ); + section.append(intro); + if (data.snapshotState && data.snapshotState !== "ready") { + section.append( + renderSnapshotState(data.snapshotState, "reviews", element), + ); + return section; + } + data.reviews.forEach((review, index) => { + const row = element("article", "review-row", { + testId: `project-canvas-review-${index + 1}`, + }); + const summary = element("div", "review-summary"); + summary.append( + element("span", "review-number", { text: review.displayId }), + element("strong", "", { text: review.title }), + element("code", "", { text: review.branch }), + ); + const status = element("div", "review-status"); + const reviewerName = + review.agentName || + (review.agentPubkey + ? `${review.agentPubkey.slice(0, 8)}…` + : "Reviewer"); + status.setAttribute( + "aria-label", + `${reviewerName}, ${review.status.toLowerCase()}`, + ); + const video = element("video", "review-gloopie", { + "aria-hidden": "true", + autoplay: "", + loop: "", + muted: "", + playsinline: "", + poster: resolveAsset(review.poster), + testId: + review.status === "Approved" + ? "project-canvas-review-agent-approved-video" + : "project-canvas-review-agent-working-video", + }); + video.dataset.berdAvatarId = `gloopies-${review.avatarId}`; + video.dataset.decorative = "true"; + video.muted = true; + video.src = resolveAsset(review.gloopie); + status.append( + video, + element( + "span", + `status-pill ${review.status.toLowerCase().replaceAll(" ", "-")}`, + { + text: review.status === "Approved" ? "✓" : review.status, + }, + ), + ); + row.append(summary, status); + section.append(row); + }); + return section; + } + + function renderSnapshotState(status, noun, element) { + const messages = { + empty: `No ${noun} to show`, + error: `Could not load ${noun}`, + loading: `Loading ${noun}…`, + unavailable: `${noun[0].toUpperCase()}${noun.slice(1)} access unavailable`, + }; + const container = element("div", "snapshot-state", { + ariaLabel: messages[status], + text: messages[status], + }); + container.dataset.snapshotState = status; + return container; + } + + function renderClientTime(data, { element }) { + const section = element("section", "client-time", { + ariaLabel: "Client time tracking", + testId: "project-canvas-contractor-time-tracking", + }); + const summary = element("div", "time-summary"); + summary.append( + element("p", "eyebrow", { text: "Weekly capacity" }), + element("strong", "time-total", { text: data.booked }), + element("span", "muted", { text: ` of ${data.capacity}` }), + ); + const capacity = element("div", "capacity-bar"); + for (const client of data.clients) { + const segment = element("span", "capacity-segment"); + segment.style.width = `${client.share}%`; + segment.style.backgroundColor = client.color; + capacity.append(segment); + } + summary.append( + capacity, + element("p", "capacity-note", { text: "77% booked · 9h 15m open" }), + ); + section.append(summary); + data.clients.forEach((client) => { + const row = element("div", "client-row"); + const mark = element("span", "client-mark"); + mark.style.backgroundColor = client.color; + const copy = element("div", "client-copy"); + copy.append( + element("strong", "", { text: client.name }), + element("span", "muted", { text: client.project }), + ); + row.append( + mark, + copy, + element("strong", "client-hours", { text: client.time }), + ); + section.append(row); + }); + return section; + } + + function renderMeetings(data, api) { + const { element, icon } = api; + const section = element("section", "meetings", { + ariaLabel: "Team meetings", + testId: "project-canvas-meetings", + }); + section.append(element("p", "eyebrow", { text: "Previous" })); + const previous = element("div", "meeting-previous", { + testId: "project-canvas-meeting-previous", + }); + const copy = element("div", "meeting-copy"); + copy.append( + element("strong", "", { text: data.previous.title }), + element("span", "muted", { + text: `${data.previous.time} · ${data.previous.duration}`, + }), + ); + const actions = element("div", "meeting-actions"); + const notes = element("button", "small-button", { + text: "Notes", + type: "button", + }); + const recording = element("button", "small-button", { + text: "Recording", + type: "button", + }); + notes.addEventListener("click", () => showMeetingNotes(data.previous, api)); + recording.addEventListener("click", () => + showMeetingRecording(data.previous, api), + ); + actions.append(notes, recording); + previous.append(icon("✓", "success"), copy, actions); + section.append( + previous, + element("p", "eyebrow upcoming-label", { text: "Coming up" }), + ); + const upcoming = element("ol", "upcoming-meetings", { + ariaLabel: "Upcoming meetings", + }); + data.upcoming.forEach((meeting) => { + const row = element("li", "upcoming-row", { + testId: "project-canvas-meeting-upcoming", + }); + const time = element("div", "meeting-time"); + time.append( + element("strong", "", { text: meeting.day }), + element("span", "", { text: meeting.time }), + ); + const details = element("div", "meeting-copy"); + details.append( + element("strong", "", { text: meeting.title }), + element("span", "muted", { text: meeting.duration }), + ); + row.append( + time, + details, + element("span", "scheduled", { text: "Scheduled" }), + ); + upcoming.append(row); + }); + section.append(upcoming); + return section; + } + + function showMeetingNotes(meeting, { element, showDialog }) { + const body = element("div", "meeting-detail", { + testId: "meeting-notes-detail", + }); + body.append( + element("p", "", { text: `${meeting.time} · ${meeting.duration}` }), + ); + const list = element("ul", "notes-list"); + [ + "Ship the Canvas tab in the next desktop release.", + "Keep project widgets local-only for the demo.", + "Recheck mobile spacing before the final walkthrough.", + ].forEach((note) => { + list.append(element("li", "", { text: note })); + }); + body.append(list); + showDialog(`${meeting.title} notes`, body, "meeting-detail-dialog"); + } + + function showMeetingRecording(meeting, { element, showDialog }) { + const body = element("div", "meeting-detail", { + testId: "meeting-recording-detail", + }); + body.append( + element("p", "", { text: `${meeting.time} · ${meeting.duration}` }), + ); + const screen = element("div", "recording-screen"); + const play = element("button", "play-button", { + ariaLabel: "Play recording", + text: "▶", + type: "button", + }); + play.addEventListener("click", () => { + const playing = play.getAttribute("aria-label") === "Pause recording"; + play.setAttribute( + "aria-label", + playing ? "Play recording" : "Pause recording", + ); + play.textContent = playing ? "▶" : "Ⅱ"; + }); + screen.append(play); + body.append( + screen, + element("p", "recording-time", { text: "00:00 ━━━━━━━━━ 42:18" }), + ); + showDialog(`${meeting.title} recording`, body, "meeting-detail-dialog"); + } +})(); diff --git a/desktop/src-tauri/resources/project-canvas-template/widgets/home.js b/desktop/src-tauri/resources/project-canvas-template/widgets/home.js new file mode 100644 index 00000000000..a6b086d5939 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/widgets/home.js @@ -0,0 +1,287 @@ +(() => { + window.buzzCanvasWidgets = window.buzzCanvasWidgets || {}; + window.buzzCanvasWidgets.home = { + companions: { + choreBoard: renderHenryCompanion, + homeSchedule: renderHomeScheduleCompanion, + }, + renderers: { + choreBoard: { + render: renderChoreBoard, + update: updateChoreBoard, + }, + familyLocations: renderFamilyLocations, + frontYardCamera: renderFrontYardCamera, + homeSchedule: renderHomeSchedule, + }, + }; + + function renderChoreBoard(data, { element }) { + const board = element("div", "chore-board", { + testId: "project-canvas-chore-board", + }); + for (const group of data.groups) { + const section = element("section", "chore-group"); + const heading = element("h3", "member-heading"); + const avatar = element("span", "avatar initials", { + testId: `project-canvas-chore-member-${group.member.toLowerCase()}-avatar`, + text: group.member.slice(0, 1), + }); + avatar.style.backgroundColor = group.color; + heading.append(avatar, document.createTextNode(group.member)); + section.append(heading); + for (const chore of group.chores) { + const id = `${group.member}-${chore}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-"); + const label = element("label", "chore-row"); + const input = element("input", "", { + "aria-label": `${chore} for ${group.member}`, + testId: `project-canvas-chore-${id}`, + type: "checkbox", + }); + input.checked = group.completed.includes(chore); + const text = element("span", "", { text: chore }); + input.addEventListener("change", () => { + text.classList.toggle("completed", input.checked); + group.completed = input.checked + ? [...new Set([...group.completed, chore])] + : group.completed.filter((candidate) => candidate !== chore); + }); + text.classList.toggle("completed", input.checked); + label.append(input, text); + section.append(label); + } + board.append(section); + } + return board; + } + + function updateChoreBoard(board, data, previousData, api) { + const replacement = renderChoreBoard(data, api); + board.replaceChildren(...replacement.childNodes); + board.dataset.previousCompleted = String(completedCount(previousData)); + board.dataset.completed = String(completedCount(data)); + board.classList.remove("widget-data-updated"); + void board.offsetWidth; + board.classList.add("widget-data-updated"); + board.addEventListener( + "animationend", + () => board.classList.remove("widget-data-updated"), + { once: true }, + ); + return board; + } + + function completedCount(data) { + return (data?.groups || []).reduce( + (total, group) => total + (group.completed || []).length, + 0, + ); + } + + function renderHomeSchedule(data, { element, resolveAsset }) { + const section = element("section", "home-schedule", { + ariaLabel: "Home schedule", + testId: "project-canvas-home-clock", + }); + const image = element("img", "home-background", { + alt: "", + src: resolveAsset(data.background), + testId: "project-canvas-home-clock-background", + }); + const list = element("ul", "speech-list", { + ariaLabel: "Clock Gloopie updates", + }); + data.updates.forEach((update, index) => { + list.append( + element("li", "speech-bubble", { + testId: `project-canvas-home-clock-status-${index + 1}`, + text: update, + }), + ); + }); + section.append(image, element("span", "home-overlay"), list); + return section; + } + + function renderFrontYardCamera(data, { element, resolveAsset }) { + const figure = element("figure", "camera-widget", { + ariaLabel: "Front yard camera", + testId: "project-canvas-front-yard-camera", + }); + figure.append( + element("img", "camera-image", { + alt: "Front yard security camera view with a small parcel by the door", + src: resolveAsset(data.image), + testId: "project-canvas-front-yard-camera-image", + }), + element("span", "recording", { text: "● Recording" }), + element("figcaption", "camera-caption", { + text: `📦 ${data.caption}`, + }), + ); + return figure; + } + + function renderFamilyLocations(data, { element }) { + const section = element("section", "family-locations", { + ariaLabel: "Family locations", + testId: "project-canvas-family-locations", + }); + const placeClasses = ["school", "cafe", "library", "work", "shops", "oboe"]; + data.places.forEach((place, index) => { + section.append( + element("div", `place place-${placeClasses[index]}`, { + testId: `project-canvas-family-place-${place.toLowerCase()}`, + text: place, + }), + ); + }); + section.append( + element("div", "place place-home", { + testId: "project-canvas-family-place-home", + text: "⌂ Home", + }), + familyMember("Sally", "sally", element), + familyMember("You", "you", element), + ); + const dad = element("div", "dad-route"); + dad.append( + familyMember("Dad", "dad", element), + element("span", "dad-arrow", { + ariaLabel: "Dad is heading toward Work", + role: "img", + text: "↘", + }), + ); + section.append(dad); + return section; + } + + function familyMember(name, slug, element) { + const member = element("div", `family-member member-${slug}`, { + ariaLabel: `${name} location`, + role: "img", + testId: `project-canvas-family-location-${slug}`, + }); + member.append( + element("span", "avatar initials", { text: name[0] }), + document.createTextNode(name), + ); + return member; + } + + function renderHomeScheduleCompanion(widget, api) { + return renderStandardGloopie( + widget.data.gloopie, + widget.data.gloopiePoster, + 1, + "Home schedule helper", + "companion home-schedule-companion", + "project-canvas-home-schedule-gloopie-companion", + "project-canvas-home-schedule-gloopie", + api, + ); + } + + function renderStandardGloopie( + src, + poster, + avatarId, + label, + className, + wrapperTestId, + videoTestId, + { element, resolveAsset }, + ) { + const wrapper = element("div", className, { testId: wrapperTestId }); + const video = element("video", "gloopie-video", { + ariaLabel: label, + autoplay: "", + loop: "", + muted: "", + playsinline: "", + poster: resolveAsset(poster), + testId: videoTestId, + }); + video.dataset.berdAvatarId = `gloopies-${avatarId}`; + video.muted = true; + video.src = resolveAsset(src); + wrapper.append(video); + return wrapper; + } + + function renderHenryCompanion(widget, { element, resolveAsset }) { + const wrapper = element("div", "companion henry-companion", { + testId: "project-canvas-chore-gloopie-companion", + }); + const canvas = element("canvas", "henry-canvas", { + ariaLabel: "Henry Hoover Gloopie", + role: "img", + testId: "project-canvas-henry-gloopie", + }); + const video = element("video", "henry-source", { + autoplay: "", + loop: "", + muted: "", + playsinline: "", + preload: "auto", + src: resolveAsset(widget.data.gloopie), + testId: "project-canvas-henry-gloopie-source", + }); + video.muted = true; + wrapper.append(canvas, video); + startStackedAlphaVideo(video, canvas); + return wrapper; + } + + function startStackedAlphaVideo(video, canvas) { + const maskCanvas = document.createElement("canvas"); + let frameRequest = 0; + const paint = () => { + if (!video.isConnected || !canvas.isConnected) return; + const width = video.videoWidth; + const height = Math.floor(video.videoHeight / 2); + if (!width || !height) return; + canvas.width = width; + canvas.height = height; + maskCanvas.width = width; + maskCanvas.height = height; + const context = canvas.getContext("2d"); + const maskContext = maskCanvas.getContext("2d"); + if (!context || !maskContext) return; + context.drawImage(video, 0, 0, width, height, 0, 0, width, height); + maskContext.drawImage( + video, + 0, + height, + width, + height, + 0, + 0, + width, + height, + ); + const color = context.getImageData(0, 0, width, height); + const mask = maskContext.getImageData(0, 0, width, height); + for (let index = 3; index < color.data.length; index += 4) { + color.data[index] = mask.data[index - 3]; + } + context.putImageData(color, 0, 0); + }; + const draw = () => { + paint(); + frameRequest = window.requestAnimationFrame(draw); + }; + video.addEventListener( + "loadeddata", + () => { + window.cancelAnimationFrame(frameRequest); + draw(); + video.play().catch(() => {}); + }, + { once: true }, + ); + } +})(); diff --git a/desktop/src-tauri/resources/project-canvas-template/widgets/support.js b/desktop/src-tauri/resources/project-canvas-template/widgets/support.js new file mode 100644 index 00000000000..902839ba7a9 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/widgets/support.js @@ -0,0 +1,148 @@ +(() => { + window.buzzCanvasWidgets = window.buzzCanvasWidgets || {}; + window.buzzCanvasWidgets.support = { + companions: { bugReporter: renderBugCompanion }, + renderers: { + bugReporter: renderBugReporter, + knownIssues: renderKnownIssues, + releaseNotes: renderReleaseNotes, + }, + }; + + function renderReleaseNotes(data, { element, icon }) { + const section = element("section", "release-notes", { + ariaLabel: "Latest Acorn release notes", + testId: "project-canvas-release-notes", + }); + const header = element("header", "release-header"); + const copy = element("div", ""); + copy.append( + element("h3", "", { text: data.product }), + element("p", "muted", { text: "Released today · Product update" }), + ); + header.append( + icon("↗", "release-icon"), + copy, + element("span", "live-badge", { text: "Live" }), + ); + section.append(header); + data.items.forEach((item, index) => { + const row = element("article", "release-row"); + const rowCopy = element("div", ""); + rowCopy.append( + element("h4", "", { text: item.title }), + element("p", "muted", { text: item.detail }), + ); + row.append( + icon(["⚡", "◉", "✦"][index], `release-tone-${index}`), + rowCopy, + ); + section.append(row); + }); + return section; + } + + function renderKnownIssues(data, { element }) { + const section = element("section", "known-issues", { + ariaLabel: "Known product issues", + testId: "project-canvas-known-issues", + }); + const header = element("header", "issues-header"); + const title = element("div", ""); + title.append( + element("h3", "", { text: "Known issues" }), + element("p", "muted", { text: "Support noticeboard" }), + ); + header.append(title, element("span", "muted", { text: "Updated 12m ago" })); + section.append(header); + const grid = element("div", "issue-grid"); + data.issues.forEach((issue, index) => { + const note = element( + "article", + `issue-note tone-${issue.tone}${index === 2 ? " wide" : ""}`, + ); + const noteTitle = element("div", "issue-title"); + noteTitle.append( + element("h4", "", { text: issue.title }), + element("span", "", { text: issue.id }), + ); + note.append( + noteTitle, + element("p", "", { text: issue.detail }), + element("strong", "issue-status", { text: issue.status }), + ); + grid.append(note); + }); + section.append(grid); + return section; + } + + function renderBugReporter(data, { element, icon }) { + const form = element("form", "bug-reporter", { + testId: "project-canvas-support-bug-reporter", + }); + const header = element("header", "bug-header"); + const copy = element("div", ""); + copy.append( + element("h3", "", { text: "Report a problem" }), + element("p", "muted", { + text: `Acorn support · ${data.responseTime}`, + }), + ); + header.append(icon("✦", "bug-icon"), copy); + const editor = element("div", "bug-editor"); + const textarea = element("textarea", "", { + ariaLabel: "Describe a support issue", + placeholder: "What happened? Include what you expected to see...", + testId: "project-canvas-support-bug-input", + }); + const submit = element("button", "submit-button", { + ariaLabel: "Submit support report", + testId: "project-canvas-support-bug-submit", + text: "Send", + type: "submit", + }); + submit.disabled = true; + textarea.addEventListener("input", () => { + submit.disabled = !textarea.value.trim(); + }); + editor.append(textarea, submit); + form.append(header, editor); + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (!textarea.value.trim()) return; + const success = element("div", "bug-success", { + testId: "project-canvas-support-bug-success", + }); + success.append( + icon("✓", "success-circle"), + element("h4", "", { text: "Report staged" }), + element("p", "muted", { + text: "We'll check for matching issues before filing.", + }), + ); + form.replaceChildren(header, success); + }); + return form; + } + + function renderBugCompanion(widget, { element, resolveAsset }) { + const wrapper = element("div", "companion bug-companion", { + testId: "project-canvas-bug-gloopie-companion", + }); + const video = element("video", "gloopie-video", { + ariaLabel: "Bug report helper", + autoplay: "", + loop: "", + muted: "", + playsinline: "", + poster: resolveAsset(widget.data.gloopiePoster), + testId: "project-canvas-gloopie", + }); + video.dataset.berdAvatarId = "gloopies-22"; + video.muted = true; + video.src = resolveAsset(widget.data.gloopie); + wrapper.append(video); + return wrapper; + } +})(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f2b196c41d9..c01a6c54ce3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ pub mod nostr_convert; mod observed_unread; mod persona_catalog; mod prevent_sleep; +mod project_canvas_package; mod ptt_shortcut; mod relay; mod relay_admission; @@ -133,7 +134,14 @@ pub fn run() { })) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_opener::init()); + #[cfg(target_os = "macos")] + let builder = builder.plugin( + tauri::plugin::Builder::<_, ()>::new("navigation-policy") + .on_navigation(|_, url| project_canvas_package::allow_webview_navigation(url)) + .build(), + ); + let builder = builder .plugin( tauri_plugin_window_state::Builder::default() // Visibility is excluded: the native reveal plugin below @@ -219,6 +227,9 @@ pub fn run() { responder.respond(response); }); }) + .register_uri_scheme_protocol("buzz-canvas", |ctx, request| { + project_canvas_package::handle_request(ctx.app_handle(), &request) + }) .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) @@ -232,6 +243,7 @@ pub fn run() { .manage(native_relay_client::NativeRelayClient::default()) .manage(observed_unread::ObservedUnreadStore::default()) .manage(channel_head_cache::ChannelHeadCacheStore::default()) + .manage(project_canvas_package::ProjectCanvasRuntime::default()) .setup(move |app| { let app_handle = app.handle().clone(); #[cfg(target_os = "macos")] @@ -372,6 +384,11 @@ pub fn run() { if let Err(error) = ensure_nest() { eprintln!("buzz-desktop: failed to create nest: {error}"); } + if let Err(error) = + project_canvas_package::start_agent_update_listener(app_handle.clone()) + { + eprintln!("buzz-desktop: failed to start project Canvas updates: {error}"); + } archive::spawn_warm_init(app_handle.clone()); // Resolve the REPOS symlink from the persisted repos_dir BEFORE @@ -632,6 +649,13 @@ pub fn run() { leave_channel, get_canvas, set_canvas, + project_canvas_package::get_project_canvas_package, + project_canvas_package::get_project_canvas_updates, + project_canvas_package::activate_project_canvas_package, + project_canvas_package::commit_project_canvas_package, + project_canvas_package::release_project_canvas_package, + project_canvas_package::get_project_canvas_source, + project_canvas_package::open_project_canvas_source, get_feed, search_messages, send_channel_message, diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 5f375e23c1c..fc130b9bf27 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -52,7 +52,7 @@ const NEST_AGENTS_VERSION: u32 = 5; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 5; +const NEST_SKILL_VERSION: u32 = 6; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 9aa1eeb0985..57159a0c1aa 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -41,6 +41,14 @@ fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); } +#[test] +fn nest_skill_teaches_local_project_canvas_notifications() { + assert!(BUZZ_CLI_SKILL_MD.contains("buzz canvas notify --source ")); + assert!(BUZZ_CLI_SKILL_MD.contains("--change data")); + assert!(BUZZ_CLI_SKILL_MD.contains("--change presentation")); + assert!(BUZZ_CLI_SKILL_MD.contains("does not require `BUZZ_PRIVATE_KEY`")); +} + #[test] fn nest_agents_template_separates_commit_attribution_claims() { assert_eq!(AGENTS_MD.matches("## Git Commit Attribution").count(), 1); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 01f76229158..834a954635b 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -60,6 +60,7 @@ Output varies by command group — `--help` shows flags but not response shapes. | Command | Output | |---------|--------| | `canvas get` | raw markdown string or `null` — NOT a JSON envelope | +| `canvas notify` | local JSON acknowledgment with `accepted`, `change`, `notificationId`, `projectId`, `revision`, `sourcePath`, and `widgetId` | | `social *`, `repos get/list` | raw Nostr event JSON INCLUDING `sig` — different contract than read commands above | | `repos protect list` | `{repo_id, protections: [{ref, rules}], unknown_rules, validation_error}` | | `upload file` | pretty-printed multi-line `BlobDescriptor`: `{url, sha256, size, type, uploaded}` | @@ -72,6 +73,16 @@ Output varies by command group — `--help` shows flags but not response shapes. **Errors** go to stderr as `{"error": "", "message": ""}`. Exit codes: 0 = success, 1 = input/not-found, 2 = relay/network, 3 = auth, 4 = other, 5 = write conflict (value superseded). +## Project Canvas Updates + +`canvas get/set` operate on relay-backed channel Canvas markdown. `canvas notify` instead tells the running local Buzz Desktop that an external project widget package changed. + +1. Read the active nest's `CANVASES/index.json`, match the exact community and canonical project coordinate, and edit only that entry's `sourcePath`. Never edit `index.json` or `.runtime/`. +2. For widget values, edit `data/*.json`, then run `buzz canvas notify --source --widget --change data`. The live iframe stays mounted. Object renderers may animate with `update(currentElement, nextData, previousData, api)`; function renderers receive a targeted content remount. +3. For JavaScript, CSS, layout, assets, or manifest changes, run `buzz canvas notify --source --widget --change presentation`. Buzz validates the package and activates a fresh sandboxed iframe through its last-known-good render gate. + +The source must be listed in that nest's index, the widget id must be unique in the package data, and Buzz Desktop must be running. This command is local-only and does not require `BUZZ_PRIVATE_KEY` or publish a relay event. The manual Reload Canvas button remains available. + ## Compact Format `--format compact` is a global flag — position it before the subcommand: diff --git a/desktop/src-tauri/src/project_canvas_package/ipc.rs b/desktop/src-tauri/src/project_canvas_package/ipc.rs new file mode 100644 index 00000000000..af897680e66 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/ipc.rs @@ -0,0 +1,156 @@ +#[cfg(unix)] +use std::{ + fs, + os::unix::{ + fs::{FileTypeExt, PermissionsExt}, + net::UnixStream as StdUnixStream, + }, + path::PathBuf, + time::Duration, +}; + +#[cfg(unix)] +use tauri::{AppHandle, Emitter, Manager}; + +#[cfg(unix)] +use super::path_security::{canonical_canvas_root, ensure_secure_descendant}; +use super::ProjectCanvasAgentUpdateRequest; + +pub(super) const UPDATE_FORMAT: &str = "buzz-project-canvas-update"; +pub(super) const UPDATE_VERSION: u32 = 1; +pub(crate) const UPDATE_EVENT: &str = "project-canvas-source-updated"; + +#[cfg(unix)] +const SOCKET_FILE: &str = "agent-updates.sock"; +#[cfg(unix)] +const MAX_REQUEST_BYTES: usize = 16 * 1024; + +#[cfg(unix)] +pub(super) fn start(app: AppHandle) -> Result<(), String> { + let canvas_root = crate::managed_agents::nest_dir() + .ok_or_else(|| "cannot resolve the nest directory for Canvas updates".to_string())? + .join("CANVASES"); + let canvas_root = canonical_canvas_root(&canvas_root, true)? + .ok_or_else(|| "project canvas root was not created".to_string())?; + let runtime_root = canvas_root.join(".runtime"); + ensure_secure_descendant(&canvas_root, &runtime_root, true)?; + let socket_path = runtime_root.join(SOCKET_FILE); + if socket_path.exists() { + let metadata = fs::symlink_metadata(&socket_path) + .map_err(|error| format!("inspect project canvas update socket: {error}"))?; + if !metadata.file_type().is_socket() { + return Err("project canvas update socket path is not a socket".to_string()); + } + if StdUnixStream::connect(&socket_path).is_ok() { + return Err("project canvas update socket is already in use".to_string()); + } + fs::remove_file(&socket_path) + .map_err(|error| format!("remove stale project canvas update socket: {error}"))?; + } + + let std_listener = std::os::unix::net::UnixListener::bind(&socket_path) + .map_err(|error| format!("bind project canvas update socket: {error}"))?; + fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("secure project canvas update socket: {error}"))?; + std_listener + .set_nonblocking(true) + .map_err(|error| format!("configure project canvas update socket: {error}"))?; + let listener = tokio::net::UnixListener::from_std(std_listener) + .map_err(|error| format!("start project canvas update socket: {error}"))?; + tauri::async_runtime::spawn(run(listener, app, socket_path)); + Ok(()) +} + +#[cfg(not(unix))] +pub(super) fn start(_app: tauri::AppHandle) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +async fn run(listener: tokio::net::UnixListener, app: AppHandle, socket_path: PathBuf) { + loop { + let (stream, _) = match listener.accept().await { + Ok(connection) => connection, + Err(error) => { + eprintln!("buzz-desktop: project Canvas update socket stopped: {error}"); + let _ = fs::remove_file(&socket_path); + return; + } + }; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = handle_connection(stream, app).await { + eprintln!("buzz-desktop: project Canvas update rejected: {error}"); + } + }); + } +} + +#[cfg(unix)] +async fn handle_connection(stream: tokio::net::UnixStream, app: AppHandle) -> Result<(), String> { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + + let (read, mut write) = stream.into_split(); + let mut reader = BufReader::new(read).take((MAX_REQUEST_BYTES + 1) as u64); + let mut raw = Vec::new(); + let read_result = + tokio::time::timeout(Duration::from_secs(5), reader.read_until(b'\n', &mut raw)).await; + let result = match read_result { + Ok(Ok(0)) => Err("empty project canvas update request".to_string()), + Ok(Ok(_)) if raw.len() > MAX_REQUEST_BYTES => { + Err("project canvas update request exceeds 16 KiB".to_string()) + } + Ok(Ok(_)) if raw.last() != Some(&b'\n') => { + Err("incomplete project canvas update request".to_string()) + } + Ok(Ok(_)) => { + let request: Result = serde_json::from_slice(&raw); + match request { + Ok(request) => { + let runtime = app.state::().inner().clone(); + match super::run_blocking(move || runtime.accept_agent_update(request)).await { + Ok(accepted) => app + .emit( + UPDATE_EVENT, + serde_json::json!({ + "communityId": accepted.community_id, + "projectId": accepted.project_id, + }), + ) + .map(|()| accepted) + .map_err(|error| format!("emit project canvas update: {error}")), + Err(error) => Err(error), + } + } + Err(error) => Err(format!("invalid project canvas update request: {error}")), + } + } + Ok(Err(error)) => Err(format!("read project canvas update request: {error}")), + Err(_) => Err("project canvas update request timed out".to_string()), + }; + + let response = match &result { + Ok(accepted) => { + let mut value = serde_json::to_value(accepted) + .map_err(|error| format!("encode project canvas update response: {error}"))?; + let object = value + .as_object_mut() + .ok_or_else(|| "invalid project canvas update response shape".to_string())?; + object.insert("accepted".into(), true.into()); + object.insert("message".into(), "Canvas update delivered".into()); + value + } + Err(error) => serde_json::json!({ + "accepted": false, + "message": error, + }), + }; + let mut bytes = serde_json::to_vec(&response) + .map_err(|error| format!("encode project canvas update response: {error}"))?; + bytes.push(b'\n'); + tokio::time::timeout(Duration::from_secs(5), write.write_all(&bytes)) + .await + .map_err(|_| "project canvas update response timed out".to_string())? + .map_err(|error| format!("write project canvas update response: {error}"))?; + result.map(|_| ()) +} diff --git a/desktop/src-tauri/src/project_canvas_package/manifest.rs b/desktop/src-tauri/src/project_canvas_package/manifest.rs new file mode 100644 index 00000000000..55e115b4767 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/manifest.rs @@ -0,0 +1,256 @@ +use std::{collections::BTreeMap, path::Path}; + +use serde::Deserialize; + +pub(super) const MAX_MANIFEST_BYTES: usize = 64 * 1024; +pub(super) const MAX_DATA_BYTES: usize = 256 * 1024; +pub(super) const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024; +pub(super) const MAX_FILE_BYTES: usize = 8 * 1024 * 1024; +pub(super) const MAX_PACKAGE_BYTES: usize = 32 * 1024 * 1024; +pub(super) const MAX_PACKAGE_FILES: usize = 512; +const MAX_JSON_DEPTH: usize = 32; +const MAX_JSON_NODES: usize = 10_000; + +const FORMAT: &str = "buzz-project-canvas"; +const PROTOCOL_VERSION: u32 = 1; +const ALLOWED_CAPABILITIES: &[&str] = &[ + "project.metadata.read", + "project.channels.read", + "project.reviews.read", +]; + +#[derive(Clone, Debug)] +pub(super) struct ValidatedManifest { + pub(super) scripts: Vec, + pub(super) styles: Vec, + pub(super) capabilities: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Manifest { + format: String, + protocol_version: u32, + scripts: Vec, + styles: Vec, + data: String, + capabilities: Vec, +} + +pub(super) fn validate_manifest( + files: &BTreeMap>, +) -> Result<(ValidatedManifest, serde_json::Value), String> { + let raw = files + .get("manifest.json") + .ok_or_else(|| "project canvas package is missing manifest.json".to_string())?; + if raw.len() > MAX_MANIFEST_BYTES { + return Err("project canvas manifest exceeds 64 KiB".to_string()); + } + let text = std::str::from_utf8(raw) + .map_err(|_| "project canvas manifest must be UTF-8".to_string())?; + let manifest: Manifest = serde_json::from_str(text) + .map_err(|error| format!("invalid project canvas manifest: {error}"))?; + + if manifest.format != FORMAT { + return Err(format!( + "unsupported project canvas format: {}", + manifest.format + )); + } + if manifest.protocol_version != PROTOCOL_VERSION { + return Err(format!( + "unsupported project canvas protocol version: {}", + manifest.protocol_version + )); + } + + if manifest.scripts.is_empty() || manifest.scripts.len() > 64 { + return Err("project canvas manifest must declare 1 to 64 scripts".to_string()); + } + let mut scripts = Vec::with_capacity(manifest.scripts.len()); + for raw_script in manifest.scripts { + let script = validate_relative_path(&raw_script)?; + let is_canvas_entry = script == "canvas.js"; + let is_widget = script.starts_with("widgets/") && extension(&script) == Some("js"); + if !is_canvas_entry && !is_widget { + return Err( + "project canvas scripts must be canvas.js or .js files below widgets/".to_string(), + ); + } + if !files.contains_key(&script) { + return Err(format!("project canvas script does not exist: {script}")); + } + if scripts.contains(&script) { + return Err(format!("duplicate project canvas script: {script}")); + } + scripts.push(script); + } + if scripts.last().map(String::as_str) != Some("canvas.js") { + return Err("project canvas scripts must load canvas.js last".to_string()); + } + + if manifest.styles.is_empty() || manifest.styles.len() > 8 { + return Err("project canvas manifest must declare 1 to 8 styles".to_string()); + } + let mut styles = Vec::with_capacity(manifest.styles.len()); + for raw_style in manifest.styles { + let style = validate_relative_path(&raw_style)?; + if !style.starts_with("styles/") || extension(&style) != Some("css") { + return Err("project canvas styles must be .css files below styles/".to_string()); + } + if !files.contains_key(&style) { + return Err(format!("project canvas style does not exist: {style}")); + } + if styles.contains(&style) { + return Err(format!("duplicate project canvas style: {style}")); + } + styles.push(style); + } + + let data_path = validate_relative_path(&manifest.data)?; + if !data_path.starts_with("data/") || extension(&data_path) != Some("json") { + return Err("project canvas data must be a .json file below data/".to_string()); + } + let data_bytes = files + .get(&data_path) + .ok_or_else(|| format!("project canvas data does not exist: {data_path}"))?; + if data_bytes.len() > MAX_DATA_BYTES { + return Err("project canvas data exceeds 256 KiB".to_string()); + } + let data_text = std::str::from_utf8(data_bytes) + .map_err(|_| "project canvas data must be UTF-8".to_string())?; + let data = serde_json::from_str(data_text) + .map_err(|error| format!("invalid project canvas data: {error}"))?; + let mut nodes = 0; + validate_json_shape(&data, 0, &mut nodes)?; + + if manifest.capabilities.len() > ALLOWED_CAPABILITIES.len() { + return Err("project canvas requests unsupported capabilities".to_string()); + } + let mut capabilities = Vec::with_capacity(manifest.capabilities.len()); + for capability in manifest.capabilities { + if !ALLOWED_CAPABILITIES.contains(&capability.as_str()) { + return Err(format!( + "unsupported project canvas capability: {capability}" + )); + } + if capabilities.contains(&capability) { + return Err(format!("duplicate project canvas capability: {capability}")); + } + capabilities.push(capability); + } + + for path in files.keys() { + validate_declared_file(path, &scripts, &styles, &data_path)?; + } + + Ok(( + ValidatedManifest { + scripts, + styles, + capabilities, + }, + data, + )) +} + +fn validate_json_shape( + value: &serde_json::Value, + depth: usize, + nodes: &mut usize, +) -> Result<(), String> { + *nodes += 1; + if depth > MAX_JSON_DEPTH || *nodes > MAX_JSON_NODES { + return Err("project canvas data exceeds the JSON structure limit".to_string()); + } + match value { + serde_json::Value::Array(values) => { + for value in values { + validate_json_shape(value, depth + 1, nodes)?; + } + } + serde_json::Value::Object(values) => { + for value in values.values() { + validate_json_shape(value, depth + 1, nodes)?; + } + } + _ => {} + } + Ok(()) +} + +pub(super) fn validate_relative_path(raw: &str) -> Result { + if raw.is_empty() || raw.len() > 240 || raw.contains('\\') || raw.contains('\0') { + return Err("invalid project canvas package path".to_string()); + } + + let path = Path::new(raw); + if path.is_absolute() { + return Err("project canvas package paths must be relative".to_string()); + } + for component in path.components() { + let std::path::Component::Normal(segment) = component else { + return Err(format!("invalid project canvas package path: {raw}")); + }; + let segment = segment + .to_str() + .ok_or_else(|| "project canvas package paths must be UTF-8".to_string())?; + if segment.starts_with('.') || segment.is_empty() { + return Err(format!("hidden project canvas package path: {raw}")); + } + } + Ok(raw.to_string()) +} + +pub(super) fn mime_type(path: &str) -> Option<&'static str> { + match extension(path)? { + "js" | "mjs" => Some("text/javascript; charset=utf-8"), + "css" => Some("text/css; charset=utf-8"), + "json" => Some("application/json; charset=utf-8"), + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "avif" => Some("image/avif"), + "woff" => Some("font/woff"), + "woff2" => Some("font/woff2"), + "ttf" => Some("font/ttf"), + "mp4" => Some("video/mp4"), + "webm" => Some("video/webm"), + "ogg" => Some("audio/ogg"), + "mp3" => Some("audio/mpeg"), + "wav" => Some("audio/wav"), + _ => None, + } +} + +pub(super) fn is_text(path: &str) -> bool { + matches!(extension(path), Some("js" | "mjs" | "css" | "json")) +} + +fn validate_declared_file( + path: &str, + scripts: &[String], + styles: &[String], + data_path: &str, +) -> Result<(), String> { + if path == "manifest.json" + || scripts.iter().any(|script| script == path) + || styles.iter().any(|style| style == path) + { + return Ok(()); + } + if path == data_path || path.starts_with("data/") && extension(path) == Some("json") { + return Ok(()); + } + if path.starts_with("assets/") && mime_type(path).is_some() { + return Ok(()); + } + Err(format!( + "project canvas package contains an undeclared file: {path}" + )) +} + +fn extension(path: &str) -> Option<&str> { + Path::new(path).extension()?.to_str() +} diff --git a/desktop/src-tauri/src/project_canvas_package/mod.rs b/desktop/src-tauri/src/project_canvas_package/mod.rs new file mode 100644 index 00000000000..57044230d22 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/mod.rs @@ -0,0 +1,545 @@ +mod ipc; +mod manifest; +mod path_security; +mod protocol; +mod storage; + +#[cfg(test)] +mod tests; + +use std::{ + collections::{BTreeSet, HashMap}, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, State}; +use tauri_plugin_opener::OpenerExt; + +use manifest::ValidatedManifest; +use storage::{ + active_snapshot, clear_committed_updates, commit_snapshot, pending_updates, prepare_snapshot, + project_source_location, prune_revisions, record_pending_update, record_source_binding, + snapshot_for_revision, validate_widget_id, ProjectBinding, ProjectCanvasSourceLocation, +}; + +const MAX_ACTIVE_LOADS: usize = 64; + +#[derive(Clone)] +pub(crate) struct ProjectCanvasRuntime { + root: Option, + loads: Arc>>, + activation_lock: Arc>, +} + +impl Default for ProjectCanvasRuntime { + fn default() -> Self { + Self { + // Resolve the nest lazily: setup selects `.buzz` or `.buzz-dev` + // after managed state is constructed. + root: None, + loads: Arc::new(Mutex::new(HashMap::new())), + activation_lock: Arc::new(Mutex::new(())), + } + } +} + +impl ProjectCanvasRuntime { + #[cfg(test)] + fn with_root(root: PathBuf) -> Self { + Self { + root: Some(root), + loads: Arc::new(Mutex::new(HashMap::new())), + activation_lock: Arc::new(Mutex::new(())), + } + } + + fn root(&self) -> Result { + self.root + .clone() + .or_else(|| crate::managed_agents::nest_dir().map(|root| root.join("CANVASES"))) + .ok_or_else(|| "cannot resolve the nest directory for project canvases".to_string()) + } + + fn get_or_activate( + &self, + request: ProjectCanvasPackageRequest, + template: &std::path::Path, + ) -> Result { + let binding = ProjectBinding::parse(request)?; + ensure_supported_platform()?; + let _guard = self + .activation_lock + .lock() + .map_err(|_| "project canvas activation lock is unavailable".to_string())?; + + let root = self.root()?; + let snapshot = match active_snapshot(&root, &binding)? { + Some(snapshot) => snapshot, + None => prepare_snapshot(&root, &binding, Some(template))?, + }; + let mut retained = self.referenced_revisions(&binding)?; + retained.insert(snapshot.revision.clone()); + prune_revisions(&root, &binding, &retained)?; + // The index is agent-facing discovery metadata, not runtime authority. A + // malformed or manually edited index must not block a validated package. + let _ = record_source_binding(&root, &binding); + self.issue_load(binding, snapshot) + } + + fn activate( + &self, + request: ProjectCanvasPackageRequest, + template: &std::path::Path, + ) -> Result { + let binding = ProjectBinding::parse(request)?; + ensure_supported_platform()?; + let _guard = self + .activation_lock + .lock() + .map_err(|_| "project canvas activation lock is unavailable".to_string())?; + let root = self.root()?; + let snapshot = prepare_snapshot(&root, &binding, Some(template))?; + let mut retained = self.referenced_revisions(&binding)?; + retained.insert(snapshot.revision.clone()); + prune_revisions(&root, &binding, &retained)?; + let _ = record_source_binding(&root, &binding); + self.issue_load(binding, snapshot) + } + + fn commit(&self, load_id: &str) -> Result<(), String> { + let load = self + .load(load_id)? + .ok_or_else(|| "project canvas load not found".to_string())?; + let _guard = self + .activation_lock + .lock() + .map_err(|_| "project canvas activation lock is unavailable".to_string())?; + let root = self.root()?; + commit_snapshot(&root, &load.binding, &load.revision)?; + clear_committed_updates(&root, &load.binding, &load.revision)?; + let retained = self.referenced_revisions(&load.binding)?; + prune_revisions(&root, &load.binding, &retained) + } + + fn accept_agent_update( + &self, + request: ProjectCanvasAgentUpdateRequest, + ) -> Result { + request.validate()?; + let binding = ProjectBinding::parse(ProjectCanvasPackageRequest { + community_id: request.community_id.clone(), + project_id: request.project_id.clone(), + })?; + ensure_supported_platform()?; + let _guard = self + .activation_lock + .lock() + .map_err(|_| "project canvas activation lock is unavailable".to_string())?; + let root = self.root()?; + let snapshot = prepare_snapshot(&root, &binding, None)?; + validate_widget_in_data(&snapshot.data, &request.widget_id)?; + record_pending_update( + &root, + &binding, + request.change, + &request.notification_id, + &request.widget_id, + &snapshot.revision, + )?; + let retained = self.referenced_revisions(&binding)?; + prune_revisions(&root, &binding, &retained)?; + Ok(ProjectCanvasUpdateAccepted { + change: request.change, + community_id: request.community_id, + notification_id: request.notification_id, + project_id: request.project_id, + revision: snapshot.revision, + widget_id: request.widget_id, + }) + } + + fn updates( + &self, + request: ProjectCanvasPackageRequest, + ) -> Result { + let binding = ProjectBinding::parse(request)?; + ensure_supported_platform()?; + let _guard = self + .activation_lock + .lock() + .map_err(|_| "project canvas activation lock is unavailable".to_string())?; + let root = self.root()?; + let updates = pending_updates(&root, &binding)?; + let presentation = match updates.presentation { + Some(update) => { + let snapshot = snapshot_for_revision(&root, &binding, &update.revision)?; + Some(ProjectCanvasPendingPresentation { + notification_id: update.notification_id, + package: self.issue_load(binding.clone(), snapshot)?, + widget_id: update.widget_id, + }) + } + None => None, + }; + let data = match updates.data { + Some(update) => { + let snapshot = snapshot_for_revision(&root, &binding, &update.revision)?; + Some(ProjectCanvasPendingData { + data: snapshot.data, + notification_id: update.notification_id, + revision: update.revision, + widget_id: update.widget_id, + }) + } + None => None, + }; + Ok(ProjectCanvasPendingUpdates { data, presentation }) + } + + fn source_location( + &self, + request: ProjectCanvasPackageRequest, + ) -> Result { + let binding = ProjectBinding::parse(request)?; + ensure_supported_platform()?; + let _guard = self + .activation_lock + .lock() + .map_err(|_| "project canvas activation lock is unavailable".to_string())?; + let root = self.root()?; + let location = project_source_location(&root, &binding)?; + let _ = record_source_binding(&root, &binding); + Ok(location) + } + + fn issue_load( + &self, + binding: ProjectBinding, + snapshot: storage::ValidatedSnapshot, + ) -> Result { + let load_id = uuid::Uuid::new_v4().simple().to_string(); + let nonce = uuid::Uuid::new_v4().simple().to_string(); + let manifest = snapshot.manifest.clone(); + let data = snapshot.data.clone(); + let revision = snapshot.revision.clone(); + let scope = binding.scope(); + let load = ActiveLoad { + binding, + files: snapshot.files, + nonce: nonce.clone(), + scope, + granted_capabilities: manifest.capabilities.clone(), + manifest, + revision: revision.clone(), + }; + + let mut loads = self + .loads + .lock() + .map_err(|_| "project canvas load registry is unavailable".to_string())?; + if loads.len() >= MAX_ACTIVE_LOADS { + if let Some(oldest) = loads.keys().next().cloned() { + loads.remove(&oldest); + } + } + loads.insert(load_id.clone(), load); + + Ok(ProjectCanvasPackageDescriptor { + url: protocol_url(&load_id), + load_id, + revision, + nonce, + capabilities: snapshot.manifest.capabilities, + data, + }) + } + + fn load(&self, load_id: &str) -> Result, String> { + let loads = self + .loads + .lock() + .map_err(|_| "project canvas load registry is unavailable".to_string())?; + let load = loads.get(load_id).cloned(); + if let Some(load) = &load { + if !load.scope.is_valid() || load.granted_capabilities != load.manifest.capabilities { + return Err("project canvas load binding is invalid".to_string()); + } + } + Ok(load) + } + + fn referenced_revisions(&self, binding: &ProjectBinding) -> Result, String> { + let loads = self + .loads + .lock() + .map_err(|_| "project canvas load registry is unavailable".to_string())?; + Ok(loads + .values() + .filter(|load| load.binding.matches(binding)) + .map(|load| load.revision.clone()) + .collect()) + } + + fn release(&self, load_id: &str) -> Result<(), String> { + let parsed = uuid::Uuid::parse_str(load_id) + .map_err(|_| "invalid project canvas load id".to_string())?; + let key = parsed.simple().to_string(); + let mut loads = self + .loads + .lock() + .map_err(|_| "project canvas load registry is unavailable".to_string())?; + loads.remove(&key); + Ok(()) + } +} + +#[derive(Clone)] +struct ActiveLoad { + binding: ProjectBinding, + files: Arc>>, + nonce: String, + scope: storage::CanvasScope, + granted_capabilities: Vec, + manifest: ValidatedManifest, + revision: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ProjectCanvasPackageRequest { + community_id: String, + project_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectCanvasPackageDescriptor { + load_id: String, + url: String, + revision: String, + nonce: String, + capabilities: Vec, + data: serde_json::Value, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ProjectCanvasUpdateChange { + Presentation, + Data, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectCanvasAgentUpdateRequest { + format: String, + version: u32, + notification_id: String, + community_id: String, + project_id: String, + widget_id: String, + change: ProjectCanvasUpdateChange, +} + +impl ProjectCanvasAgentUpdateRequest { + fn validate(&self) -> Result<(), String> { + if self.format != ipc::UPDATE_FORMAT || self.version != ipc::UPDATE_VERSION { + return Err("unsupported project canvas update request".to_string()); + } + let parsed = uuid::Uuid::parse_str(&self.notification_id) + .map_err(|_| "invalid project canvas update notification id".to_string())?; + if parsed.simple().to_string() != self.notification_id { + return Err("invalid project canvas update notification id".to_string()); + } + validate_widget_id(&self.widget_id) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectCanvasUpdateAccepted { + change: ProjectCanvasUpdateChange, + community_id: String, + notification_id: String, + project_id: String, + revision: String, + widget_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectCanvasPendingUpdates { + data: Option, + presentation: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectCanvasPendingData { + data: serde_json::Value, + notification_id: String, + revision: String, + widget_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectCanvasPendingPresentation { + notification_id: String, + package: ProjectCanvasPackageDescriptor, + widget_id: String, +} + +#[tauri::command] +pub(crate) async fn get_project_canvas_package( + request: ProjectCanvasPackageRequest, + app: AppHandle, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result { + let template = template_path(&app)?; + let runtime = runtime.inner().clone(); + run_blocking(move || runtime.get_or_activate(request, &template)).await +} + +#[tauri::command] +pub(crate) async fn get_project_canvas_updates( + request: ProjectCanvasPackageRequest, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result { + let runtime = runtime.inner().clone(); + run_blocking(move || runtime.updates(request)).await +} + +#[tauri::command] +pub(crate) async fn activate_project_canvas_package( + request: ProjectCanvasPackageRequest, + app: AppHandle, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result { + let template = template_path(&app)?; + let runtime = runtime.inner().clone(); + run_blocking(move || runtime.activate(request, &template)).await +} + +#[tauri::command] +pub(crate) fn release_project_canvas_package( + load_id: String, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result<(), String> { + runtime.release(&load_id) +} + +#[tauri::command] +pub(crate) async fn commit_project_canvas_package( + load_id: String, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result<(), String> { + let runtime = runtime.inner().clone(); + run_blocking(move || runtime.commit(&load_id)).await +} + +#[tauri::command] +pub(crate) async fn open_project_canvas_source( + request: ProjectCanvasPackageRequest, + app: AppHandle, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result<(), String> { + let runtime = runtime.inner().clone(); + let location = run_blocking(move || runtime.source_location(request)).await?; + app.opener() + .open_path(&location.source_path, None::<&str>) + .map_err(|error| format!("open project canvas source: {error}")) +} + +#[tauri::command] +pub(crate) async fn get_project_canvas_source( + request: ProjectCanvasPackageRequest, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result { + let runtime = runtime.inner().clone(); + run_blocking(move || runtime.source_location(request)).await +} + +pub(crate) fn handle_request( + app: &AppHandle, + request: &tauri::http::Request>, +) -> tauri::http::Response> { + let runtime = app.state::(); + protocol::handle(&runtime, request) +} + +pub(crate) fn start_agent_update_listener(app: AppHandle) -> Result<(), String> { + ipc::start(app) +} + +fn template_path(app: &AppHandle) -> Result { + app.path() + .resource_dir() + .map(|path| path.join("resources").join("project-canvas-template")) + .map_err(|error| format!("resolve project canvas template: {error}")) +} + +async fn run_blocking(task: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tauri::async_runtime::spawn_blocking(task) + .await + .map_err(|error| format!("project canvas task failed: {error}"))? +} + +fn protocol_url(load_id: &str) -> String { + if cfg!(target_os = "windows") { + format!("http://buzz-canvas.localhost/{load_id}/") + } else { + format!("buzz-canvas://localhost/{load_id}/") + } +} + +fn ensure_supported_platform() -> Result<(), String> { + if !cfg!(target_os = "macos") { + return Err( + "sandboxed project canvases are macOS-only until iframe IPC isolation is proven on this platform" + .to_string(), + ); + } + Ok(()) +} + +fn validate_widget_in_data(data: &serde_json::Value, widget_id: &str) -> Result<(), String> { + let dashboards = data + .get("dashboards") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "project canvas data must contain a dashboards object".to_string())?; + let matches = dashboards + .values() + .filter_map(|dashboard| dashboard.get("widgets")) + .filter_map(serde_json::Value::as_array) + .flatten() + .filter(|widget| widget.get("id").and_then(serde_json::Value::as_str) == Some(widget_id)) + .count(); + match matches { + 1 => Ok(()), + 0 => Err(format!( + "widget id '{widget_id}' does not exist in the Canvas data" + )), + _ => Err(format!( + "widget id '{widget_id}' must be unique across Canvas dashboards" + )), + } +} + +pub(crate) fn allow_webview_navigation(url: &tauri::Url) -> bool { + match url.scheme() { + "about" => url.as_str() == "about:blank", + "buzz-canvas" => url.host_str() == Some("localhost"), + "tauri" => url.host_str() == Some("localhost"), + "http" if cfg!(debug_assertions) => { + url.host_str() == Some("localhost") && url.port() == Some(1420) + } + _ => false, + } +} diff --git a/desktop/src-tauri/src/project_canvas_package/path_security.rs b/desktop/src-tauri/src/project_canvas_package/path_security.rs new file mode 100644 index 00000000000..d35212d1fda --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/path_security.rs @@ -0,0 +1,589 @@ +use std::{ + collections::BTreeMap, + ffi::{CStr, CString, OsStr, OsString}, + fs::{self, File}, + io::Read, + path::{Path, PathBuf}, +}; + +#[cfg(not(unix))] +use std::fs::OpenOptions; + +use super::manifest::{ + validate_relative_path, MAX_FILE_BYTES, MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES, +}; + +pub(super) fn read_package_tree( + trusted_root: &Path, + package_root: &Path, +) -> Result>, String> { + #[cfg(unix)] + { + let directory = SecureDirectory::open_beneath(trusted_root, package_root)?; + let mut files = BTreeMap::new(); + let mut budget = PackageScanBudget::new(); + scan_secure_directory(&directory, "", &mut files, &mut budget)?; + Ok(files) + } + #[cfg(not(unix))] + { + let canonical_root = package_root + .canonicalize() + .map_err(|error| format!("resolve project canvas package: {error}"))?; + if !canonical_root.starts_with(trusted_root) { + return Err("project canvas package escaped its trusted root".to_string()); + } + let mut files = BTreeMap::new(); + let mut budget = PackageScanBudget::new(); + scan_path_directory(&canonical_root, &canonical_root, &mut files, &mut budget)?; + Ok(files) + } +} + +struct PackageScanBudget { + remaining_entries: usize, + remaining_bytes: usize, +} + +impl PackageScanBudget { + fn new() -> Self { + Self { + remaining_entries: MAX_PACKAGE_FILES, + remaining_bytes: MAX_PACKAGE_BYTES, + } + } + + fn consume_entry(&mut self) -> Result<(), String> { + self.remaining_entries = self + .remaining_entries + .checked_sub(1) + .ok_or_else(package_entry_limit_error)?; + Ok(()) + } + + fn consume_bytes(&mut self, bytes: usize) -> Result<(), String> { + self.remaining_bytes = self + .remaining_bytes + .checked_sub(bytes) + .ok_or_else(package_size_limit_error)?; + Ok(()) + } +} + +fn package_entry_limit_error() -> String { + format!("project canvas package exceeds {MAX_PACKAGE_FILES} entries") +} + +fn package_size_limit_error() -> String { + "project canvas package exceeds 32 MiB".to_string() +} + +#[cfg(unix)] +fn scan_secure_directory( + directory: &SecureDirectory, + prefix: &str, + files: &mut BTreeMap>, + budget: &mut PackageScanBudget, +) -> Result<(), String> { + for name in directory.entry_names(budget.remaining_entries)? { + budget.consume_entry()?; + if name == OsStr::new(".DS_Store") { + continue; + } + let name = name + .into_string() + .map_err(|_| "project canvas package paths must be UTF-8".to_string())?; + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + let relative = validate_relative_path(&relative)?; + if let Ok(child) = directory.open_subdirectory(OsStr::new(&name)) { + scan_secure_directory(&child, &relative, files, budget)?; + continue; + } + let cap = budget.remaining_bytes.min(MAX_FILE_BYTES); + let bytes = directory + .read_regular_file(OsStr::new(&name), cap) + .map_err(|error| { + if cap < MAX_FILE_BYTES && error.contains("exceeds its size limit") { + package_size_limit_error() + } else { + error + } + })?; + budget.consume_bytes(bytes.len())?; + files.insert(relative, bytes); + } + Ok(()) +} + +#[cfg(not(unix))] +fn scan_path_directory( + canonical_root: &Path, + directory: &Path, + files: &mut BTreeMap>, + budget: &mut PackageScanBudget, +) -> Result<(), String> { + for entry in fs::read_dir(directory) + .map_err(|error| format!("read project canvas package directory: {error}"))? + { + let entry = entry.map_err(|error| format!("read project canvas package entry: {error}"))?; + budget.consume_entry()?; + if entry.file_name() == ".DS_Store" { + continue; + } + let path = entry.path(); + let relative = path + .strip_prefix(canonical_root) + .map_err(|_| "project canvas file escaped its package".to_string())?; + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("inspect project canvas package entry: {error}"))?; + if metadata.file_type().is_symlink() { + return Err("project canvas package cannot contain symlinks".to_string()); + } + if metadata.is_dir() { + scan_path_directory(canonical_root, &path, files, budget)?; + continue; + } + if !metadata.is_file() { + return Err("project canvas package contains an invalid entry".to_string()); + } + let relative = validate_relative_path( + &relative + .to_str() + .ok_or_else(|| "project canvas package paths must be UTF-8".to_string())? + .replace(std::path::MAIN_SEPARATOR, "/"), + )?; + let cap = budget.remaining_bytes.min(MAX_FILE_BYTES); + let file = OpenOptions::new() + .read(true) + .open(path) + .map_err(|error| format!("open project canvas file: {error}"))?; + let bytes = read_bounded_regular_file(file, cap).map_err(|error| { + if cap < MAX_FILE_BYTES && error.contains("exceeds its size limit") { + package_size_limit_error() + } else { + error + } + })?; + budget.consume_bytes(bytes.len())?; + files.insert(relative, bytes); + } + Ok(()) +} + +#[cfg(unix)] +struct SecureDirectory { + file: File, +} + +#[cfg(unix)] +impl SecureDirectory { + fn open_beneath(trusted_root: &Path, target: &Path) -> Result { + let relative = target + .strip_prefix(trusted_root) + .map_err(|_| "project canvas path escaped its trusted root".to_string())?; + let mut directory = Self { + file: open_directory_path(trusted_root)?, + }; + for component in relative.components() { + let std::path::Component::Normal(segment) = component else { + return Err("invalid project canvas storage path".to_string()); + }; + directory = directory.open_subdirectory(segment)?; + } + Ok(directory) + } + + fn open_subdirectory(&self, name: &OsStr) -> Result { + Ok(Self { + file: openat_file( + &self.file, + name, + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + "open project canvas directory", + )?, + }) + } + + fn read_regular_file(&self, name: &OsStr, cap: usize) -> Result, String> { + let file = openat_file( + &self.file, + name, + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK, + "open project canvas file", + )?; + read_bounded_regular_file(file, cap) + } + + fn entry_names(&self, maximum: usize) -> Result, String> { + use std::os::fd::AsRawFd; + + let duplicate = unsafe { libc::fcntl(self.file.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + if duplicate < 0 { + return Err(format!( + "duplicate project canvas directory: {}", + std::io::Error::last_os_error() + )); + } + let stream = unsafe { libc::fdopendir(duplicate) }; + if stream.is_null() { + unsafe { libc::close(duplicate) }; + return Err(format!( + "open project canvas directory stream: {}", + std::io::Error::last_os_error() + )); + } + let stream = DirectoryStream(stream); + let mut names = Vec::new(); + loop { + clear_errno(); + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + let error = current_errno(); + if error != 0 { + return Err(format!( + "read project canvas directory: {}", + std::io::Error::from_raw_os_error(error) + )); + } + break; + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + if names.len() >= maximum { + return Err(package_entry_limit_error()); + } + use std::os::unix::ffi::OsStringExt; + names.push(OsString::from_vec(name.to_bytes().to_vec())); + } + Ok(names) + } +} + +#[cfg(unix)] +struct DirectoryStream(*mut libc::DIR); + +#[cfg(unix)] +impl Drop for DirectoryStream { + fn drop(&mut self) { + unsafe { libc::closedir(self.0) }; + } +} + +#[cfg(unix)] +fn open_directory_path(path: &Path) -> Result { + use std::os::{fd::FromRawFd, unix::ffi::OsStrExt}; + + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| "project canvas paths cannot contain NUL bytes".to_string())?; + let descriptor = unsafe { + libc::open( + path.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if descriptor < 0 { + return Err(format!( + "open trusted project canvas directory: {}", + std::io::Error::last_os_error() + )); + } + Ok(unsafe { File::from_raw_fd(descriptor) }) +} + +#[cfg(unix)] +fn openat_file(parent: &File, name: &OsStr, flags: i32, context: &str) -> Result { + use std::os::{ + fd::{AsRawFd, FromRawFd}, + unix::ffi::OsStrExt, + }; + + let name = CString::new(name.as_bytes()) + .map_err(|_| "project canvas paths cannot contain NUL bytes".to_string())?; + let descriptor = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) }; + if descriptor < 0 { + return Err(format!("{context}: {}", std::io::Error::last_os_error())); + } + Ok(unsafe { File::from_raw_fd(descriptor) }) +} + +fn read_bounded_regular_file(file: File, cap: usize) -> Result, String> { + let metadata = file + .metadata() + .map_err(|error| format!("inspect project canvas file: {error}"))?; + if !metadata.is_file() { + return Err("project canvas file is not a permitted regular file".to_string()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err("project canvas files cannot be hard linked".to_string()); + } + } + if metadata.len() > cap as u64 { + return Err("project canvas file exceeds its size limit".to_string()); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(cap as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("read project canvas file: {error}"))?; + if bytes.len() > cap { + return Err("project canvas file exceeds its size limit".to_string()); + } + Ok(bytes) +} + +#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))] +fn clear_errno() { + unsafe { *libc::__error() = 0 }; +} + +#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))] +fn current_errno() -> i32 { + unsafe { *libc::__error() } +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn clear_errno() { + unsafe { *libc::__errno_location() = 0 }; +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn current_errno() -> i32 { + unsafe { *libc::__errno_location() } +} + +#[cfg(all( + unix, + not(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "linux", + target_os = "android" + )) +))] +fn clear_errno() {} + +#[cfg(all( + unix, + not(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "linux", + target_os = "android" + )) +))] +fn current_errno() -> i32 { + 0 +} + +pub(super) fn canonical_canvas_root(root: &Path, create: bool) -> Result, String> { + if !root.exists() { + if !create { + return Ok(None); + } + fs::create_dir_all(root).map_err(|error| format!("create project canvas root: {error}"))?; + } + ensure_no_symlink(root)?; + let canonical = root + .canonicalize() + .map_err(|error| format!("resolve project canvas root: {error}"))?; + if !canonical.is_dir() { + return Err("project canvas root is not a directory".to_string()); + } + Ok(Some(canonical)) +} + +pub(super) fn ensure_secure_descendant( + trusted_root: &Path, + target: &Path, + create: bool, +) -> Result<(), String> { + let relative = target + .strip_prefix(trusted_root) + .map_err(|_| "project canvas path escaped the canvas root".to_string())?; + let mut current = trusted_root.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(segment) = component else { + return Err("invalid project canvas storage path".to_string()); + }; + current.push(segment); + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "project canvas directory is not a real directory: {}", + current.display() + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && create => { + fs::create_dir(¤t) + .map_err(|error| format!("create project canvas directory: {error}"))?; + } + Err(error) => { + return Err(format!("inspect project canvas directory: {error}")); + } + } + } + let canonical = target + .canonicalize() + .map_err(|error| format!("resolve project canvas directory: {error}"))?; + if !canonical.starts_with(trusted_root) { + return Err("project canvas directory escaped the canvas root".to_string()); + } + Ok(()) +} + +pub(super) fn ensure_secure_file(trusted_root: &Path, path: &Path) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "project canvas file has no parent".to_string())?; + ensure_secure_descendant(trusted_root, parent, false)?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("inspect project canvas file: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "project canvas file is not a real file: {}", + path.display() + )); + } + let canonical = path + .canonicalize() + .map_err(|error| format!("resolve project canvas file: {error}"))?; + if !canonical.starts_with(trusted_root) { + return Err("project canvas file escaped the canvas root".to_string()); + } + Ok(()) +} + +pub(super) fn read_file_with_cap( + trusted_root: &Path, + path: &Path, + cap: usize, +) -> Result, String> { + ensure_secure_file(trusted_root, path)?; + #[cfg(unix)] + { + let parent = path + .parent() + .ok_or_else(|| "project canvas file has no parent".to_string())?; + let directory = SecureDirectory::open_beneath(trusted_root, parent)?; + let name = path + .file_name() + .ok_or_else(|| "project canvas file has no name".to_string())?; + directory.read_regular_file(name, cap) + } + #[cfg(not(unix))] + { + let metadata = + fs::metadata(path).map_err(|error| format!("inspect project canvas file: {error}"))?; + if metadata.len() > cap as u64 { + return Err("project canvas control file exceeds its size limit".to_string()); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + options + .open(path) + .map_err(|error| format!("open project canvas control file: {error}"))? + .take(cap as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("read project canvas control file: {error}"))?; + if bytes.len() > cap { + return Err("project canvas control file exceeds its size limit".to_string()); + } + Ok(bytes) + } +} + +pub(super) fn ensure_no_symlink(path: &Path) -> Result<(), String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("inspect project canvas path: {error}"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "project canvas paths cannot be symlinks: {}", + path.display() + )); + } + Ok(()) +} + +pub(super) fn make_snapshot_read_only(root: &Path) -> Result<(), String> { + for entry in + fs::read_dir(root).map_err(|error| format!("read project canvas snapshot: {error}"))? + { + let path = entry + .map_err(|error| format!("read project canvas snapshot entry: {error}"))? + .path(); + if path.is_dir() { + make_snapshot_read_only(&path)?; + } else { + let mut permissions = fs::metadata(&path) + .map_err(|error| format!("inspect project canvas snapshot: {error}"))? + .permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions) + .map_err(|error| format!("lock project canvas snapshot file: {error}"))?; + } + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(root, fs::Permissions::from_mode(0o555)) + .map_err(|error| format!("lock project canvas snapshot directory: {error}"))?; + } + Ok(()) +} + +pub(super) fn make_tree_writable(root: &Path) -> Result<(), String> { + if !root.exists() { + return Ok(()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(root, fs::Permissions::from_mode(0o755)) + .map_err(|error| format!("unlock project canvas staging directory: {error}"))?; + } + for entry in fs::read_dir(root) + .map_err(|error| format!("read project canvas staging directory: {error}"))? + { + let path = entry + .map_err(|error| format!("read project canvas staging entry: {error}"))? + .path(); + if path.is_dir() { + make_tree_writable(&path)?; + } else { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)) + .map_err(|error| format!("unlock project canvas staging file: {error}"))?; + } + #[cfg(windows)] + { + let mut permissions = fs::metadata(&path) + .map_err(|error| format!("inspect project canvas staging file: {error}"))? + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(&path, permissions) + .map_err(|error| format!("unlock project canvas staging file: {error}"))?; + } + } + } + Ok(()) +} diff --git a/desktop/src-tauri/src/project_canvas_package/protocol.rs b/desktop/src-tauri/src/project_canvas_package/protocol.rs new file mode 100644 index 00000000000..a5d143d2b85 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/protocol.rs @@ -0,0 +1,245 @@ +use percent_encoding::percent_decode_str; +use tauri::http::{self, Method, StatusCode}; + +use super::{ + manifest::{mime_type, validate_relative_path, MAX_FILE_BYTES}, + ActiveLoad, ProjectCanvasRuntime, +}; + +pub(super) const DOCUMENT_CSP: &str = "default-src 'none'; script-src 'self' buzz-canvas: http://buzz-canvas.localhost; style-src 'self' buzz-canvas: http://buzz-canvas.localhost; img-src 'self' buzz-canvas: http://buzz-canvas.localhost data: blob:; media-src 'self' buzz-canvas: http://buzz-canvas.localhost blob:; font-src 'self' buzz-canvas: http://buzz-canvas.localhost; connect-src 'none'; webrtc 'block'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; worker-src 'none'; frame-ancestors tauri: http://tauri.localhost http://localhost:*"; +pub(super) const PERMISSIONS_POLICY: &str = "accelerometer=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), screen-wake-lock=(), usb=()"; + +pub(super) fn handle( + runtime: &ProjectCanvasRuntime, + request: &http::Request>, +) -> http::Response> { + if !cfg!(target_os = "macos") { + return response( + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + b"sandboxed project canvases are unavailable on this platform".to_vec(), + ); + } + if request.method() != Method::GET && request.method() != Method::HEAD { + return response( + StatusCode::METHOD_NOT_ALLOWED, + "text/plain; charset=utf-8", + b"method not allowed".to_vec(), + ); + } + + match route(runtime, request.uri().path()) { + Ok((content_type, mut body)) => { + if request.method() == Method::HEAD { + body.clear(); + } + response(StatusCode::OK, content_type, body) + } + Err((status, message)) => { + response(status, "text/plain; charset=utf-8", message.into_bytes()) + } + } +} + +pub(super) fn route( + runtime: &ProjectCanvasRuntime, + raw_path: &str, +) -> Result<(&'static str, Vec), (StatusCode, String)> { + let decoded = percent_decode_str(raw_path) + .decode_utf8() + .map_err(|_| bad_request("request path must be UTF-8"))?; + if decoded.contains('\\') || decoded.contains('\0') { + return Err(bad_request("invalid project canvas request path")); + } + let mut parts = decoded.trim_start_matches('/').split('/'); + let load_id = parts.next().unwrap_or_default(); + if uuid::Uuid::parse_str(load_id) + .map(|id| id.simple().to_string()) + .as_deref() + != Ok(load_id) + { + return Err(bad_request("invalid project canvas load id")); + } + let load = runtime + .load(load_id) + .map_err(internal_error)? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + "project canvas load not found".to_string(), + ) + })?; + + let remainder: Vec<&str> = parts.collect(); + match remainder.as_slice() { + [] | [""] | ["index.html"] => Ok(("text/html; charset=utf-8", shell())), + ["__buzz", "bootstrap.js"] => Ok(( + "text/javascript; charset=utf-8", + bootstrap(&load).map_err(internal_error)?, + )), + ["package", rest @ ..] if !rest.is_empty() => serve_package_file(&load, rest), + _ => Err((StatusCode::NOT_FOUND, "not found".to_string())), + } +} + +fn shell() -> Vec { + br#" + + + + + Project Canvas + + +
+ + + +"# + .to_vec() +} + +fn bootstrap(load: &ActiveLoad) -> Result, String> { + let nonce = serde_json::to_string(&load.nonce) + .map_err(|error| format!("encode project canvas nonce: {error}"))?; + let scripts = load + .manifest + .scripts + .iter() + .map(|script| serde_json::to_string(&package_url(script))) + .collect::, _>>() + .map_err(|error| format!("encode project canvas script URL: {error}"))? + .join(","); + let styles = load + .manifest + .styles + .iter() + .map(|style| serde_json::to_string(&package_url(style))) + .collect::, _>>() + .map_err(|error| format!("encode project canvas style URL: {error}"))? + .join(","); + let script = format!( + r#"(() => {{ + "use strict"; + const protocolVersion = 1; + const nonce = {nonce}; + const styles = [{styles}]; + const scripts = [{scripts}]; + let connected = false; + + const connect = (event) => {{ + const message = event.data; + if (connected || event.source !== parent || !message || + message.type !== "host.connect" || + message.protocolVersion !== protocolVersion || + message.nonce !== nonce || event.ports.length !== 1) {{ + return; + }} + connected = true; + window.removeEventListener("message", connect); + const port = event.ports[0]; + Object.defineProperty(window, "buzzCanvas", {{ + value: Object.freeze({{ + packageBaseUrl: new URL("./package/", location.href).href, + protocolVersion, + port, + }}), + configurable: false, + enumerable: false, + writable: false, + }}); + for (const href of styles) {{ + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href; + document.head.append(link); + }} + let scriptIndex = 0; + const loadNextScript = () => {{ + if (scriptIndex >= scripts.length) return; + const packageScript = document.createElement("script"); + packageScript.src = scripts[scriptIndex++]; + packageScript.addEventListener("load", loadNextScript, {{ once: true }}); + packageScript.addEventListener("error", () => {{ + port.postMessage({{ type: "canvas.error", protocolVersion, message: "script failed to load" }}); + }}, {{ once: true }}); + document.body.append(packageScript); + }}; + loadNextScript(); + }}; + + window.addEventListener("message", connect); + parent.postMessage({{ type: "canvas.ready", protocolVersion, nonce }}, "*"); +}})(); +"# + ); + Ok(script.into_bytes()) +} + +fn serve_package_file( + load: &ActiveLoad, + segments: &[&str], +) -> Result<(&'static str, Vec), (StatusCode, String)> { + if segments.iter().any(|segment| segment.is_empty()) { + return Err(bad_request("invalid project canvas package path")); + } + let relative = validate_relative_path(&segments.join("/")) + .map_err(|message| (StatusCode::BAD_REQUEST, message))?; + let content_type = mime_type(&relative).ok_or_else(|| { + ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "unsupported file type".to_string(), + ) + })?; + // Active loads own the exact validated bytes. The on-disk revision is a + // recovery cache only; reopening it here would let a same-user editor + // mutate a supposedly immutable frame between activation and a request. + let bytes = load + .files + .get(&relative) + .cloned() + .ok_or_else(|| (StatusCode::NOT_FOUND, "not found".to_string()))?; + if bytes.len() > MAX_FILE_BYTES { + return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string())); + } + Ok((content_type, bytes)) +} + +fn package_url(relative: &str) -> String { + let encoded = relative + .split('/') + .map(|segment| { + percent_encoding::utf8_percent_encode(segment, percent_encoding::NON_ALPHANUMERIC) + .to_string() + }) + .collect::>() + .join("/"); + format!("./package/{encoded}") +} + +fn response( + status: StatusCode, + content_type: &'static str, + body: Vec, +) -> http::Response> { + let fallback = body.clone(); + http::Response::builder() + .status(status) + .header("content-type", content_type) + .header("content-security-policy", DOCUMENT_CSP) + .header("permissions-policy", PERMISSIONS_POLICY) + .header("referrer-policy", "no-referrer") + .header("x-content-type-options", "nosniff") + .header("x-dns-prefetch-control", "off") + .header("cache-control", "no-store") + .body(body) + .unwrap_or_else(|_| http::Response::new(fallback)) +} + +fn bad_request(message: impl Into) -> (StatusCode, String) { + (StatusCode::BAD_REQUEST, message.into()) +} + +fn internal_error(message: impl ToString) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, message.to_string()) +} diff --git a/desktop/src-tauri/src/project_canvas_package/storage.rs b/desktop/src-tauri/src/project_canvas_package/storage.rs new file mode 100644 index 00000000000..f01a14f1d52 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/storage.rs @@ -0,0 +1,848 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; + +use atomic_write_file::AtomicWriteFile; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::{ + manifest::{ + is_text, mime_type, validate_manifest, ValidatedManifest, MAX_FILE_BYTES, + MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES, MAX_TEXT_BYTES, + }, + path_security::{ + canonical_canvas_root, ensure_no_symlink, ensure_secure_descendant, ensure_secure_file, + make_snapshot_read_only, make_tree_writable, read_file_with_cap, read_package_tree, + }, + ProjectCanvasPackageRequest, +}; + +const RUNTIME_ROOT_DIR: &str = ".runtime"; +const REVISIONS_DIR: &str = "revisions"; +const ACTIVE_FILE: &str = "active.json"; +const UPDATES_FILE: &str = "updates.json"; +const INDEX_FILE: &str = "index.json"; +const INDEX_FORMAT: &str = "buzz-project-canvas-index"; +const INDEX_VERSION: u32 = 1; +const MAX_INDEX_BYTES: usize = 1024 * 1024; +const MAX_INDEX_ENTRIES: usize = 4_096; +const RECENT_REVISION_RETENTION: usize = 2; +const UPDATE_STATE_VERSION: u32 = 1; +const MAX_UPDATE_STATE_BYTES: usize = 16 * 1024; + +#[derive(Clone)] +pub(super) struct ProjectBinding { + community_id: String, + community_key: String, + owner: String, + project_key: String, + project_id: String, +} + +#[derive(Clone)] +pub(super) struct CanvasScope { + community_key: String, + project_id: String, +} + +impl CanvasScope { + pub(super) fn is_valid(&self) -> bool { + !self.community_key.is_empty() && !self.project_id.is_empty() + } +} + +impl ProjectBinding { + pub(super) fn parse(request: ProjectCanvasPackageRequest) -> Result { + validate_scope_value("community id", &request.community_id, 128)?; + + let mut coordinate = request.project_id.splitn(3, ':'); + let kind = coordinate.next(); + let owner = coordinate.next(); + let dtag = coordinate.next(); + let (Some("30621"), Some(owner), Some(dtag)) = (kind, owner, dtag) else { + return Err("project id must be a 30621:: coordinate".to_string()); + }; + if owner.len() != 64 || !owner.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("project id owner must be a 64-character hex public key".to_string()); + } + validate_scope_value("project d tag", dtag, 512)?; + + let owner = owner.to_ascii_lowercase(); + Ok(Self { + community_id: request.community_id.clone(), + community_key: scope_hash(&request.community_id), + owner: owner.clone(), + project_key: scope_hash(dtag), + project_id: format!("30621:{owner}:{dtag}"), + }) + } + + pub(super) fn scope(&self) -> CanvasScope { + CanvasScope { + community_key: self.community_key.clone(), + project_id: self.project_id.clone(), + } + } + + pub(super) fn matches(&self, other: &Self) -> bool { + self.community_key == other.community_key + && self.owner == other.owner + && self.project_key == other.project_key + } + + fn project_root(&self, canvas_root: &Path) -> PathBuf { + canvas_root + .join(&self.community_key) + .join(&self.owner) + .join(&self.project_key) + } + + fn runtime_root(&self, canvas_root: &Path) -> PathBuf { + canvas_root + .join(RUNTIME_ROOT_DIR) + .join(&self.community_key) + .join(&self.owner) + .join(&self.project_key) + } + + #[cfg(test)] + pub(super) fn project_root_for_test(&self, canvas_root: &Path) -> PathBuf { + self.project_root(canvas_root) + } + + #[cfg(test)] + pub(super) fn runtime_root_for_test(&self, canvas_root: &Path) -> PathBuf { + self.runtime_root(canvas_root) + } +} + +#[derive(Debug)] +pub(super) struct ValidatedSnapshot { + pub(super) files: Arc>>, + pub(super) revision: String, + pub(super) manifest: ValidatedManifest, + pub(super) data: serde_json::Value, +} + +struct ValidatedPackage { + files: BTreeMap>, + revision: String, + manifest: ValidatedManifest, + data: serde_json::Value, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ActiveRevision { + revision: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct PendingCanvasUpdate { + pub(super) notification_id: String, + pub(super) revision: String, + pub(super) widget_id: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct PendingCanvasUpdates { + version: u32, + pub(super) presentation: Option, + pub(super) data: Option, +} + +impl Default for PendingCanvasUpdates { + fn default() -> Self { + Self { + version: UPDATE_STATE_VERSION, + presentation: None, + data: None, + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CanvasIndex { + format: String, + version: u32, + canvases: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CanvasIndexEntry { + community_id: String, + project_id: String, + source_path: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectCanvasSourceLocation { + pub(crate) community_id: String, + pub(crate) project_id: String, + pub(crate) source_path: String, + pub(crate) index_path: String, +} + +pub(super) fn active_snapshot( + canvas_root: &Path, + binding: &ProjectBinding, +) -> Result, String> { + let Some(canvas_root) = canonical_canvas_root(canvas_root, false)? else { + return Ok(None); + }; + let project_root = binding.project_root(&canvas_root); + if !project_root.exists() { + return Ok(None); + } + ensure_secure_descendant(&canvas_root, &project_root, false)?; + let runtime_root = binding.runtime_root(&canvas_root); + if !runtime_root.exists() { + return Ok(None); + } + ensure_secure_descendant(&canvas_root, &runtime_root, false)?; + let active_path = runtime_root.join(ACTIVE_FILE); + if !active_path.exists() { + return Ok(None); + } + ensure_secure_file(&canvas_root, &active_path)?; + let raw = read_file_with_cap(&canvas_root, &active_path, 1024)?; + let active: ActiveRevision = serde_json::from_slice(&raw) + .map_err(|error| format!("invalid project canvas active revision: {error}"))?; + validate_revision(&active.revision)?; + + let revision_root = runtime_root.join(REVISIONS_DIR).join(&active.revision); + ensure_secure_descendant(&canvas_root, &revision_root, false)?; + let package = scan_package(&canvas_root, &revision_root)?; + if package.revision != active.revision { + return Err("active project canvas snapshot failed its content hash".to_string()); + } + + Ok(Some(ValidatedSnapshot { + files: Arc::new(package.files), + revision: package.revision, + manifest: package.manifest, + data: package.data, + })) +} + +pub(super) fn snapshot_for_revision( + canvas_root: &Path, + binding: &ProjectBinding, + revision: &str, +) -> Result { + validate_revision(revision)?; + let canvas_root = canonical_canvas_root(canvas_root, false)? + .ok_or_else(|| "project canvas root does not exist".to_string())?; + let runtime_root = binding.runtime_root(&canvas_root); + ensure_secure_descendant(&canvas_root, &runtime_root, false)?; + let revision_root = runtime_root.join(REVISIONS_DIR).join(revision); + ensure_secure_descendant(&canvas_root, &revision_root, false)?; + let package = scan_package(&canvas_root, &revision_root)?; + if package.revision != revision { + return Err("project canvas update snapshot failed its content hash".to_string()); + } + Ok(ValidatedSnapshot { + files: Arc::new(package.files), + revision: package.revision, + manifest: package.manifest, + data: package.data, + }) +} + +pub(super) fn prepare_snapshot( + canvas_root: &Path, + binding: &ProjectBinding, + template: Option<&Path>, +) -> Result { + let canvas_root = canonical_canvas_root(canvas_root, true)? + .ok_or_else(|| "project canvas root was not created".to_string())?; + let project_root = binding.project_root(&canvas_root); + let project_parent = project_root + .parent() + .ok_or_else(|| "project canvas directory has no parent".to_string())?; + ensure_secure_descendant(&canvas_root, project_parent, true)?; + seed_if_missing(&canvas_root, &project_root, template)?; + + // Validation reads every source byte before creating a candidate revision. + // The active pointer is advanced only after the iframe reports a successful + // render through the bound MessageChannel. + let package = scan_package(&canvas_root, &project_root)?; + let runtime_root = binding.runtime_root(&canvas_root); + let revisions_root = runtime_root.join(REVISIONS_DIR); + ensure_secure_descendant(&canvas_root, &revisions_root, true)?; + let revision_root = revisions_root.join(&package.revision); + + if revision_root.exists() { + ensure_secure_descendant(&canvas_root, &revision_root, false)?; + let existing = scan_package(&canvas_root, &revision_root)?; + if existing.revision != package.revision { + return Err("existing project canvas revision failed its content hash".to_string()); + } + } else { + create_snapshot(&revisions_root, &revision_root, &package)?; + } + + Ok(ValidatedSnapshot { + files: Arc::new(package.files), + revision: package.revision, + manifest: package.manifest, + data: package.data, + }) +} + +pub(super) fn commit_snapshot( + canvas_root: &Path, + binding: &ProjectBinding, + revision: &str, +) -> Result<(), String> { + validate_revision(revision)?; + let canvas_root = canonical_canvas_root(canvas_root, false)? + .ok_or_else(|| "project canvas root does not exist".to_string())?; + let project_root = binding.project_root(&canvas_root); + ensure_secure_descendant(&canvas_root, &project_root, false)?; + let runtime_root = binding.runtime_root(&canvas_root); + ensure_secure_descendant(&canvas_root, &runtime_root, false)?; + let revision_root = runtime_root.join(REVISIONS_DIR).join(revision); + ensure_secure_descendant(&canvas_root, &revision_root, false)?; + let package = scan_package(&canvas_root, &revision_root)?; + if package.revision != revision { + return Err("project canvas candidate failed its content hash".to_string()); + } + write_active_revision(&runtime_root.join(ACTIVE_FILE), revision) +} + +pub(super) fn record_pending_update( + canvas_root: &Path, + binding: &ProjectBinding, + change: super::ProjectCanvasUpdateChange, + notification_id: &str, + widget_id: &str, + revision: &str, +) -> Result<(), String> { + validate_notification_id(notification_id)?; + validate_widget_id(widget_id)?; + validate_revision(revision)?; + let canvas_root = canonical_canvas_root(canvas_root, false)? + .ok_or_else(|| "project canvas root does not exist".to_string())?; + let runtime_root = binding.runtime_root(&canvas_root); + ensure_secure_descendant(&canvas_root, &runtime_root, false)?; + let revision_root = runtime_root.join(REVISIONS_DIR).join(revision); + ensure_secure_descendant(&canvas_root, &revision_root, false)?; + + let mut updates = read_pending_updates_from_root(&canvas_root, &runtime_root)?; + let update = Some(PendingCanvasUpdate { + notification_id: notification_id.to_string(), + revision: revision.to_string(), + widget_id: widget_id.to_string(), + }); + match change { + super::ProjectCanvasUpdateChange::Presentation => { + updates.presentation = update; + updates.data = None; + } + super::ProjectCanvasUpdateChange::Data => updates.data = update, + } + write_pending_updates(&canvas_root, &runtime_root, &updates) +} + +pub(super) fn pending_updates( + canvas_root: &Path, + binding: &ProjectBinding, +) -> Result { + let Some(canvas_root) = canonical_canvas_root(canvas_root, false)? else { + return Ok(PendingCanvasUpdates::default()); + }; + let runtime_root = binding.runtime_root(&canvas_root); + if !runtime_root.exists() { + return Ok(PendingCanvasUpdates::default()); + } + ensure_secure_descendant(&canvas_root, &runtime_root, false)?; + read_pending_updates_from_root(&canvas_root, &runtime_root) +} + +pub(super) fn clear_committed_updates( + canvas_root: &Path, + binding: &ProjectBinding, + revision: &str, +) -> Result<(), String> { + let Some(canvas_root) = canonical_canvas_root(canvas_root, false)? else { + return Ok(()); + }; + let runtime_root = binding.runtime_root(&canvas_root); + if !runtime_root.exists() { + return Ok(()); + } + ensure_secure_descendant(&canvas_root, &runtime_root, false)?; + let mut updates = read_pending_updates_from_root(&canvas_root, &runtime_root)?; + if updates + .presentation + .as_ref() + .is_some_and(|update| update.revision == revision) + { + updates.presentation = None; + } + if let Some(update) = &updates.data { + let committed = snapshot_for_revision(&canvas_root, binding, revision)?; + let pending = snapshot_for_revision(&canvas_root, binding, &update.revision)?; + if update.revision == revision || pending.data == committed.data { + updates.data = None; + } + } + write_pending_updates(&canvas_root, &runtime_root, &updates) +} + +pub(super) fn prune_revisions( + canvas_root: &Path, + binding: &ProjectBinding, + retained: &BTreeSet, +) -> Result<(), String> { + let Some(canvas_root) = canonical_canvas_root(canvas_root, false)? else { + return Ok(()); + }; + let project_root = binding.project_root(&canvas_root); + if !project_root.exists() { + return Ok(()); + } + ensure_secure_descendant(&canvas_root, &project_root, false)?; + let runtime_root = binding.runtime_root(&canvas_root); + if !runtime_root.exists() { + return Ok(()); + } + ensure_secure_descendant(&canvas_root, &runtime_root, false)?; + let revisions_root = runtime_root.join(REVISIONS_DIR); + if !revisions_root.exists() { + return Ok(()); + } + ensure_secure_descendant(&canvas_root, &revisions_root, false)?; + + let mut keep = retained.clone(); + let updates = read_pending_updates_from_root(&canvas_root, &runtime_root)?; + keep.extend( + [updates.presentation, updates.data] + .into_iter() + .flatten() + .map(|update| update.revision), + ); + let active_path = runtime_root.join(ACTIVE_FILE); + if active_path.exists() { + ensure_secure_file(&canvas_root, &active_path)?; + let raw = read_file_with_cap(&canvas_root, &active_path, 1024)?; + let active: ActiveRevision = serde_json::from_slice(&raw) + .map_err(|error| format!("invalid project canvas active revision: {error}"))?; + validate_revision(&active.revision)?; + keep.insert(active.revision); + } + + let mut revisions = Vec::new(); + for entry in fs::read_dir(&revisions_root) + .map_err(|error| format!("read project canvas revisions: {error}"))? + { + let entry = entry.map_err(|error| format!("read project canvas revision: {error}"))?; + let name = entry + .file_name() + .into_string() + .map_err(|_| "project canvas revision names must be UTF-8".to_string())?; + if name == ".DS_Store" { + continue; + } + if let Some(id) = name.strip_prefix(".staging-") { + if uuid::Uuid::parse_str(id) + .map(|parsed| parsed.simple().to_string()) + .as_deref() + != Ok(id) + { + return Err("invalid project canvas staging revision".to_string()); + } + let path = entry.path(); + ensure_secure_descendant(&canvas_root, &path, false)?; + make_tree_writable(&path)?; + fs::remove_dir_all(&path).map_err(|error| { + format!("remove stale project canvas staging revision: {error}") + })?; + continue; + } + validate_revision(&name)?; + let path = entry.path(); + ensure_secure_descendant(&canvas_root, &path, false)?; + let modified = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .map_err(|error| format!("inspect project canvas revision: {error}"))?; + revisions.push((modified, name, path)); + } + revisions.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); + keep.extend( + revisions + .iter() + .take(RECENT_REVISION_RETENTION) + .map(|(_, revision, _)| revision.clone()), + ); + + for (_, revision, path) in revisions { + if keep.contains(&revision) { + continue; + } + make_tree_writable(&path)?; + fs::remove_dir_all(&path) + .map_err(|error| format!("remove old project canvas revision: {error}"))?; + } + Ok(()) +} + +pub(super) fn record_source_binding( + canvas_root: &Path, + binding: &ProjectBinding, +) -> Result { + let location = project_source_location(canvas_root, binding)?; + let canvas_root = canonical_canvas_root(canvas_root, false)? + .ok_or_else(|| "project canvas root does not exist".to_string())?; + let index_path = canvas_root.join(INDEX_FILE); + let mut index = if index_path.exists() { + ensure_secure_file(&canvas_root, &index_path)?; + let raw = read_file_with_cap(&canvas_root, &index_path, MAX_INDEX_BYTES)?; + serde_json::from_slice::(&raw) + .map_err(|error| format!("invalid project canvas index: {error}"))? + } else { + CanvasIndex { + format: INDEX_FORMAT.to_string(), + version: INDEX_VERSION, + canvases: Vec::new(), + } + }; + validate_index(&canvas_root, &index)?; + + index.canvases.retain(|entry| { + entry.community_id != binding.community_id || entry.project_id != binding.project_id + }); + index.canvases.push(CanvasIndexEntry { + community_id: binding.community_id.clone(), + project_id: binding.project_id.clone(), + source_path: location.source_path.clone(), + }); + index.canvases.sort_by(|left, right| { + left.community_id + .cmp(&right.community_id) + .then_with(|| left.project_id.cmp(&right.project_id)) + }); + validate_index(&canvas_root, &index)?; + let bytes = serde_json::to_vec_pretty(&index) + .map_err(|error| format!("encode project canvas index: {error}"))?; + if bytes.len() > MAX_INDEX_BYTES { + return Err("project canvas index exceeds 1 MiB".to_string()); + } + if index_path.exists() { + ensure_secure_file(&canvas_root, &index_path)?; + } + let mut file = AtomicWriteFile::open(&index_path) + .map_err(|error| format!("open project canvas index: {error}"))?; + file.write_all(&bytes) + .map_err(|error| format!("write project canvas index: {error}"))?; + file.commit() + .map_err(|error| format!("commit project canvas index: {error}"))?; + + Ok(location) +} + +pub(super) fn project_source_location( + canvas_root: &Path, + binding: &ProjectBinding, +) -> Result { + let canvas_root = canonical_canvas_root(canvas_root, false)? + .ok_or_else(|| "project canvas root does not exist".to_string())?; + let project_root = binding.project_root(&canvas_root); + ensure_secure_descendant(&canvas_root, &project_root, false)?; + Ok(ProjectCanvasSourceLocation { + community_id: binding.community_id.clone(), + project_id: binding.project_id.clone(), + source_path: project_root.to_string_lossy().into_owned(), + index_path: canvas_root.join(INDEX_FILE).to_string_lossy().into_owned(), + }) +} + +fn validate_index(canvas_root: &Path, index: &CanvasIndex) -> Result<(), String> { + if index.format != INDEX_FORMAT || index.version != INDEX_VERSION { + return Err("unsupported project canvas index format".to_string()); + } + if index.canvases.len() > MAX_INDEX_ENTRIES { + return Err("project canvas index exceeds 4096 entries".to_string()); + } + let mut seen = BTreeSet::new(); + for entry in &index.canvases { + if !seen.insert((&entry.community_id, &entry.project_id)) { + return Err("project canvas index contains a duplicate binding".to_string()); + } + let indexed = ProjectBinding::parse(ProjectCanvasPackageRequest { + community_id: entry.community_id.clone(), + project_id: entry.project_id.clone(), + })?; + let expected_path = indexed.project_root(canvas_root); + let expected = expected_path.to_string_lossy(); + if entry.source_path != expected { + return Err("project canvas index contains a mismatched source path".to_string()); + } + } + Ok(()) +} + +fn seed_if_missing( + canvas_root: &Path, + project_root: &Path, + template: Option<&Path>, +) -> Result<(), String> { + if project_root.join("manifest.json").is_file() { + ensure_secure_descendant(canvas_root, project_root, false)?; + return ensure_secure_file(canvas_root, &project_root.join("manifest.json")); + } + if project_root.exists() { + ensure_secure_descendant(canvas_root, project_root, false)?; + let has_source = fs::read_dir(project_root) + .map_err(|error| format!("read project canvas directory: {error}"))? + .filter_map(Result::ok) + .next() + .is_some(); + if has_source { + return Err( + "project canvas source is incomplete; manifest.json is missing".to_string(), + ); + } + fs::remove_dir(project_root) + .map_err(|error| format!("remove empty project canvas directory: {error}"))?; + } + + let template = template.ok_or_else(|| "project canvas template is unavailable".to_string())?; + let package = scan_package(template, template)?; + let parent = project_root + .parent() + .ok_or_else(|| "project canvas directory has no parent".to_string())?; + ensure_secure_descendant(canvas_root, parent, false)?; + let staging = parent.join(format!(".seed-{}", uuid::Uuid::new_v4().simple())); + fs::create_dir(&staging) + .map_err(|error| format!("create project canvas seed staging directory: {error}"))?; + let result = (|| { + write_package_files(&staging, &package.files)?; + fs::rename(&staging, project_root) + .map_err(|error| format!("activate seeded project canvas package: {error}"))?; + Ok(()) + })(); + if result.is_err() { + let _ = make_tree_writable(&staging); + let _ = fs::remove_dir_all(&staging); + } + result +} + +fn scan_package(trusted_root: &Path, root: &Path) -> Result { + ensure_no_symlink(root)?; + if !root.is_dir() { + return Err(format!( + "project canvas package directory does not exist: {}", + root.display() + )); + } + let files = read_package_tree(trusted_root, root)?; + if files.len() > MAX_PACKAGE_FILES { + return Err(format!( + "project canvas package exceeds {MAX_PACKAGE_FILES} files" + )); + } + + let mut total = 0usize; + for (path, bytes) in &files { + if mime_type(path).is_none() && path != "manifest.json" { + return Err(format!("unsupported project canvas file type: {path}")); + } + if bytes.len() > MAX_FILE_BYTES { + return Err(format!("project canvas file exceeds 8 MiB: {path}")); + } + if is_text(path) { + if bytes.len() > MAX_TEXT_BYTES { + return Err(format!("project canvas text file exceeds 2 MiB: {path}")); + } + std::str::from_utf8(bytes) + .map_err(|_| format!("project canvas text file must be UTF-8: {path}"))?; + } + total = total + .checked_add(bytes.len()) + .ok_or_else(|| "project canvas package size overflow".to_string())?; + } + if total > MAX_PACKAGE_BYTES { + return Err("project canvas package exceeds 32 MiB".to_string()); + } + + let (manifest, data) = validate_manifest(&files)?; + let revision = hash_files(&files); + Ok(ValidatedPackage { + files, + revision, + manifest, + data, + }) +} + +fn create_snapshot( + revisions_root: &Path, + revision_root: &Path, + package: &ValidatedPackage, +) -> Result<(), String> { + let staging = revisions_root.join(format!(".staging-{}", uuid::Uuid::new_v4().simple())); + fs::create_dir(&staging) + .map_err(|error| format!("create project canvas staging revision: {error}"))?; + let result = (|| { + write_package_files(&staging, &package.files)?; + make_snapshot_read_only(&staging)?; + fs::rename(&staging, revision_root) + .map_err(|error| format!("activate project canvas revision: {error}"))?; + Ok(()) + })(); + if result.is_err() { + let _ = make_tree_writable(&staging); + let _ = fs::remove_dir_all(&staging); + } + result +} + +fn write_package_files(root: &Path, files: &BTreeMap>) -> Result<(), String> { + for (relative, bytes) in files { + let destination = root.join(relative); + let parent = destination + .parent() + .ok_or_else(|| "project canvas file has no parent".to_string())?; + ensure_secure_descendant(root, parent, true)?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination) + .map_err(|error| format!("create project canvas file: {error}"))?; + file.write_all(bytes) + .map_err(|error| format!("write project canvas file: {error}"))?; + file.sync_all() + .map_err(|error| format!("sync project canvas file: {error}"))?; + } + Ok(()) +} + +fn write_active_revision(path: &Path, revision: &str) -> Result<(), String> { + ensure_no_symlink( + path.parent() + .ok_or_else(|| "project canvas active revision has no parent directory".to_string())?, + )?; + if path.exists() { + ensure_no_symlink(path)?; + } + let bytes = serde_json::to_vec(&ActiveRevision { + revision: revision.to_string(), + }) + .map_err(|error| format!("encode project canvas active revision: {error}"))?; + let mut file = AtomicWriteFile::open(path) + .map_err(|error| format!("open project canvas active revision: {error}"))?; + file.write_all(&bytes) + .map_err(|error| format!("write project canvas active revision: {error}"))?; + file.commit() + .map_err(|error| format!("commit project canvas active revision: {error}")) +} + +fn read_pending_updates_from_root( + canvas_root: &Path, + runtime_root: &Path, +) -> Result { + let path = runtime_root.join(UPDATES_FILE); + if !path.exists() { + return Ok(PendingCanvasUpdates::default()); + } + ensure_secure_file(canvas_root, &path)?; + let raw = read_file_with_cap(canvas_root, &path, MAX_UPDATE_STATE_BYTES)?; + let updates: PendingCanvasUpdates = serde_json::from_slice(&raw) + .map_err(|error| format!("invalid project canvas update state: {error}"))?; + if updates.version != UPDATE_STATE_VERSION { + return Err("unsupported project canvas update state version".to_string()); + } + for update in [&updates.presentation, &updates.data].into_iter().flatten() { + validate_notification_id(&update.notification_id)?; + validate_widget_id(&update.widget_id)?; + validate_revision(&update.revision)?; + let revision_root = runtime_root.join(REVISIONS_DIR).join(&update.revision); + ensure_secure_descendant(canvas_root, &revision_root, false)?; + } + Ok(updates) +} + +fn write_pending_updates( + canvas_root: &Path, + runtime_root: &Path, + updates: &PendingCanvasUpdates, +) -> Result<(), String> { + let path = runtime_root.join(UPDATES_FILE); + if path.exists() { + ensure_secure_file(canvas_root, &path)?; + } + let bytes = serde_json::to_vec(updates) + .map_err(|error| format!("encode project canvas update state: {error}"))?; + let mut file = AtomicWriteFile::open(&path) + .map_err(|error| format!("open project canvas update state: {error}"))?; + file.write_all(&bytes) + .map_err(|error| format!("write project canvas update state: {error}"))?; + file.commit() + .map_err(|error| format!("commit project canvas update state: {error}")) +} + +fn validate_notification_id(value: &str) -> Result<(), String> { + let parsed = uuid::Uuid::parse_str(value) + .map_err(|_| "invalid project canvas update notification id".to_string())?; + if parsed.simple().to_string() != value { + return Err("invalid project canvas update notification id".to_string()); + } + Ok(()) +} + +pub(super) fn validate_widget_id(value: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err( + "widget id must be 1 to 128 ASCII letters, numbers, '.', '-', or '_'".to_string(), + ); + } + Ok(()) +} + +fn hash_files(files: &BTreeMap>) -> String { + let mut hash = Sha256::new(); + for (path, bytes) in files { + hash.update((path.len() as u64).to_be_bytes()); + hash.update(path.as_bytes()); + hash.update((bytes.len() as u64).to_be_bytes()); + hash.update(bytes); + } + hex::encode(hash.finalize()) +} + +fn scope_hash(value: &str) -> String { + hex::encode(Sha256::digest(value.as_bytes())) +} + +fn validate_scope_value(label: &str, value: &str, max_len: usize) -> Result<(), String> { + if value.is_empty() || value.len() > max_len || value.chars().any(char::is_control) { + return Err(format!("invalid project canvas {label}")); + } + Ok(()) +} + +fn validate_revision(revision: &str) -> Result<(), String> { + if revision.len() != 64 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("invalid project canvas revision".to_string()); + } + Ok(()) +} diff --git a/desktop/src-tauri/src/project_canvas_package/tests.rs b/desktop/src-tauri/src/project_canvas_package/tests.rs new file mode 100644 index 00000000000..41a620961bb --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/tests.rs @@ -0,0 +1,657 @@ +use std::{collections::BTreeSet, fs, path::Path}; + +use tempfile::TempDir; + +use super::{ + manifest::{MAX_DATA_BYTES, MAX_FILE_BYTES, MAX_PACKAGE_FILES}, + protocol, + storage::{ + active_snapshot, commit_snapshot, prepare_snapshot, prune_revisions, record_source_binding, + ProjectBinding, + }, + ProjectCanvasAgentUpdateRequest, ProjectCanvasPackageRequest, ProjectCanvasRuntime, + ProjectCanvasUpdateChange, +}; + +const OWNER: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn request() -> ProjectCanvasPackageRequest { + ProjectCanvasPackageRequest { + community_id: "community-a".to_string(), + project_id: format!("30621:{OWNER}:my-project"), + } +} + +fn write_package(root: &Path, marker: &str) { + fs::create_dir_all(root.join("widgets")).unwrap(); + fs::create_dir_all(root.join("styles")).unwrap(); + fs::create_dir_all(root.join("data")).unwrap(); + fs::create_dir_all(root.join("assets")).unwrap(); + fs::write( + root.join("manifest.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "format": "buzz-project-canvas", + "protocolVersion": 1, + "scripts": ["widgets/chore-board.js", "canvas.js"], + "styles": ["styles/canvas.css"], + "data": "data/dashboards.json", + "capabilities": [ + "project.metadata.read", + "project.channels.read", + "project.reviews.read" + ] + })) + .unwrap(), + ) + .unwrap(); + fs::write( + root.join("widgets/chore-board.js"), + "globalThis.renderChores = () => {};", + ) + .unwrap(); + fs::write( + root.join("canvas.js"), + format!("globalThis.canvasMarker = {marker:?};"), + ) + .unwrap(); + fs::write(root.join("styles/canvas.css"), "body { margin: 0; }").unwrap(); + fs::write( + root.join("data/dashboards.json"), + serde_json::to_vec(&serde_json::json!({ + "marker": marker, + "dashboards": { + "test": { + "widgets": [{ + "id": "chore-board", + "data": { "marker": marker } + }] + } + } + })) + .unwrap(), + ) + .unwrap(); + fs::write(root.join("assets/pixel.png"), [137, 80, 78, 71]).unwrap(); +} + +fn source_root(temp: &TempDir, binding: &ProjectBinding) -> std::path::PathBuf { + let root = temp.path().join("CANVASES"); + fs::create_dir_all(&root).unwrap(); + let canonical = root.canonicalize().unwrap(); + binding.project_root_for_test(&canonical) +} + +#[test] +fn activation_is_content_addressed_and_preserves_last_known_good() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "first"); + + let first = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap(); + assert_eq!(first.revision.len(), 64); + assert_eq!(first.data["marker"], "first"); + commit_snapshot(&temp.path().join("CANVASES"), &binding, &first.revision).unwrap(); + + fs::write(source.join("data/dashboards.json"), b"not json").unwrap(); + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_err()); + let active = active_snapshot(&temp.path().join("CANVASES"), &binding) + .unwrap() + .unwrap(); + assert_eq!(active.revision, first.revision); + assert_eq!(active.data["marker"], "first"); +} + +#[test] +fn candidate_revision_is_not_active_until_render_commit() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "first"); + let first = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap(); + commit_snapshot(&temp.path().join("CANVASES"), &binding, &first.revision).unwrap(); + + fs::write( + source.join("canvas.js"), + "globalThis.canvasMarker = 'candidate';", + ) + .unwrap(); + let candidate = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap(); + assert_ne!(candidate.revision, first.revision); + assert_eq!( + active_snapshot(&temp.path().join("CANVASES"), &binding) + .unwrap() + .unwrap() + .revision, + first.revision + ); + + commit_snapshot(&temp.path().join("CANVASES"), &binding, &candidate.revision).unwrap(); + assert_eq!( + active_snapshot(&temp.path().join("CANVASES"), &binding) + .unwrap() + .unwrap() + .revision, + candidate.revision + ); +} + +#[test] +fn agent_updates_are_durable_delineated_and_commit_only_matching_state() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("CANVASES"); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "active"); + let active = prepare_snapshot(&root, &binding, None).unwrap(); + commit_snapshot(&root, &binding, &active.revision).unwrap(); + let runtime = ProjectCanvasRuntime::with_root(root.clone()); + + write_package(&source, "data-one"); + runtime + .accept_agent_update(ProjectCanvasAgentUpdateRequest { + change: ProjectCanvasUpdateChange::Data, + community_id: request().community_id, + format: "buzz-project-canvas-update".to_string(), + notification_id: "11111111111141118111111111111111".to_string(), + project_id: request().project_id, + version: 1, + widget_id: "chore-board".to_string(), + }) + .unwrap(); + let first_updates = runtime.updates(request()).unwrap(); + assert!(first_updates.presentation.is_none()); + assert_eq!(first_updates.data.unwrap().data["marker"], "data-one"); + assert_eq!( + active_snapshot(&root, &binding).unwrap().unwrap().data["marker"], + "active" + ); + + write_package(&source, "presentation"); + runtime + .accept_agent_update(ProjectCanvasAgentUpdateRequest { + change: ProjectCanvasUpdateChange::Presentation, + community_id: request().community_id, + format: "buzz-project-canvas-update".to_string(), + notification_id: "22222222222242228222222222222222".to_string(), + project_id: request().project_id, + version: 1, + widget_id: "chore-board".to_string(), + }) + .unwrap(); + let presentation_updates = runtime.updates(request()).unwrap(); + assert!(presentation_updates.data.is_none()); + let presentation = presentation_updates.presentation.unwrap().package; + + write_package(&source, "data-newer"); + runtime + .accept_agent_update(ProjectCanvasAgentUpdateRequest { + change: ProjectCanvasUpdateChange::Data, + community_id: request().community_id, + format: "buzz-project-canvas-update".to_string(), + notification_id: "33333333333343338333333333333333".to_string(), + project_id: request().project_id, + version: 1, + widget_id: "chore-board".to_string(), + }) + .unwrap(); + + runtime.commit(&presentation.load_id).unwrap(); + let remaining = runtime.updates(request()).unwrap(); + assert!(remaining.presentation.is_none()); + assert_eq!(remaining.data.unwrap().data["marker"], "data-newer"); + assert_eq!( + active_snapshot(&root, &binding).unwrap().unwrap().data["marker"], + "presentation" + ); +} + +#[test] +fn package_reloads_after_runtime_metadata_is_created() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("CANVASES"); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "reloadable"); + + let first = prepare_snapshot(&root, &binding, None).unwrap(); + commit_snapshot(&root, &binding, &first.revision).unwrap(); + let second = prepare_snapshot(&root, &binding, None).unwrap(); + + assert_eq!(second.revision, first.revision); + assert_eq!(second.data["marker"], "reloadable"); + assert!(!source.join(".runtime").exists()); + assert!(binding.runtime_root_for_test(&root).is_dir()); +} + +#[test] +fn revision_retention_keeps_active_live_and_recent_snapshots() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("CANVASES"); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + let mut revisions = Vec::new(); + for index in 0..8 { + write_package(&source, &format!("revision-{index}")); + let snapshot = prepare_snapshot(&root, &binding, None).unwrap(); + revisions.push(snapshot.revision); + } + commit_snapshot(&root, &binding, &revisions[0]).unwrap(); + let retained = BTreeSet::from([revisions[3].clone()]); + + prune_revisions(&root, &binding, &retained).unwrap(); + + let revisions_root = binding.runtime_root_for_test(&root).join("revisions"); + let remaining = fs::read_dir(revisions_root) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect::>(); + assert!(remaining.len() <= 4); + assert!(remaining.contains(&revisions[0])); + assert!(remaining.contains(&revisions[3])); +} + +#[test] +fn first_activation_seeds_the_validated_template() { + let temp = TempDir::new().unwrap(); + let template = temp.path().join("template"); + write_package(&template, "seeded"); + let binding = ProjectBinding::parse(request()).unwrap(); + + let snapshot = + prepare_snapshot(&temp.path().join("CANVASES"), &binding, Some(&template)).unwrap(); + + assert_eq!(snapshot.data["marker"], "seeded"); + let source = source_root(&temp, &binding); + assert!(source.join("manifest.json").is_file()); + let parent = source.parent().unwrap(); + assert!(!fs::read_dir(parent) + .unwrap() + .filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with(".seed-"))); +} + +#[test] +fn source_index_is_machine_readable_sorted_and_path_derived() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("CANVASES"); + let first = ProjectBinding::parse(request()).unwrap(); + let second = ProjectBinding::parse(ProjectCanvasPackageRequest { + community_id: "community-b".to_string(), + project_id: format!("30621:{OWNER}:another-project"), + }) + .unwrap(); + write_package(&source_root(&temp, &first), "first-indexed"); + write_package(&source_root(&temp, &second), "second-indexed"); + + let second_location = record_source_binding(&root, &second).unwrap(); + let first_location = record_source_binding(&root, &first).unwrap(); + record_source_binding(&root, &first).unwrap(); + + let index: serde_json::Value = + serde_json::from_slice(&fs::read(&first_location.index_path).unwrap()).unwrap(); + assert_eq!(index["format"], "buzz-project-canvas-index"); + assert_eq!(index["version"], 1); + let entries = index["canvases"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["communityId"], "community-a"); + assert_eq!(entries[0]["sourcePath"], first_location.source_path); + assert_eq!(entries[1]["communityId"], "community-b"); + assert_eq!(entries[1]["sourcePath"], second_location.source_path); + + let mut corrupt = index; + corrupt["canvases"][0]["sourcePath"] = serde_json::json!("/tmp/outside-canvas"); + fs::write( + &first_location.index_path, + serde_json::to_vec(&corrupt).unwrap(), + ) + .unwrap(); + let error = record_source_binding(&root, &first).unwrap_err(); + assert!(error.contains("mismatched source path")); +} + +#[test] +fn malformed_source_index_does_not_block_a_valid_canvas_load() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("CANVASES"); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "load-with-corrupt-index"); + fs::write(root.join("index.json"), b"not json").unwrap(); + let runtime = ProjectCanvasRuntime::with_root(root); + + let descriptor = runtime.get_or_activate(request(), temp.path()).unwrap(); + + assert_eq!(descriptor.data["marker"], "load-with-corrupt-index"); + assert!(runtime.source_location(request()).is_ok()); +} + +#[cfg(unix)] +#[test] +fn symlinked_source_index_is_rejected() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let root = temp.path().join("CANVASES"); + let binding = ProjectBinding::parse(request()).unwrap(); + write_package(&source_root(&temp, &binding), "indexed"); + let outside = temp.path().join("outside-index.json"); + fs::write( + &outside, + br#"{"format":"buzz-project-canvas-index","version":1,"canvases":[]}"#, + ) + .unwrap(); + symlink(outside, root.join("index.json")).unwrap(); + + assert!(record_source_binding(&root, &binding).is_err()); +} + +#[test] +fn package_data_limit_matches_the_host_descriptor_envelope() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "bounded-data"); + let overhead = r#"{"value":""}"#.len(); + let maximum = format!(r#"{{"value":"{}"}}"#, "x".repeat(MAX_DATA_BYTES - overhead)); + fs::write(source.join("data/dashboards.json"), &maximum).unwrap(); + assert_eq!(maximum.len(), MAX_DATA_BYTES); + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_ok()); + + fs::write(source.join("data/dashboards.json"), format!("{maximum} ")).unwrap(); + let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err(); + assert!(error.contains("exceeds 256 KiB")); +} + +#[test] +fn package_scan_stops_at_the_cumulative_byte_limit() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "bounded-package"); + for index in 0..4 { + let file = fs::File::create(source.join(format!("assets/large-{index}.png"))).unwrap(); + file.set_len(MAX_FILE_BYTES as u64).unwrap(); + } + + let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err(); + assert!(error.contains("exceeds 32 MiB")); +} + +#[test] +fn package_scan_bounds_empty_directory_entries() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "bounded-entries"); + for index in 0..MAX_PACKAGE_FILES { + fs::create_dir(source.join(format!("assets/empty-{index}"))).unwrap(); + } + + let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err(); + assert!(error.contains("entries")); +} + +#[test] +fn package_data_structure_limit_matches_the_host_parser() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "bounded-structure"); + let accepted = serde_json::Value::Array(vec![serde_json::Value::Null; 9_999]); + fs::write( + source.join("data/dashboards.json"), + serde_json::to_vec(&accepted).unwrap(), + ) + .unwrap(); + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_ok()); + + let rejected = serde_json::Value::Array(vec![serde_json::Value::Null; 10_000]); + fs::write( + source.join("data/dashboards.json"), + serde_json::to_vec(&rejected).unwrap(), + ) + .unwrap(); + let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err(); + assert!(error.contains("JSON structure limit")); + + let mut accepted_depth = serde_json::Value::Null; + for _ in 0..32 { + accepted_depth = serde_json::json!({ "nested": accepted_depth }); + } + fs::write( + source.join("data/dashboards.json"), + serde_json::to_vec(&accepted_depth).unwrap(), + ) + .unwrap(); + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_ok()); + + let rejected_depth = serde_json::json!({ "nested": accepted_depth }); + fs::write( + source.join("data/dashboards.json"), + serde_json::to_vec(&rejected_depth).unwrap(), + ) + .unwrap(); + let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err(); + assert!(error.contains("JSON structure limit")); +} + +#[test] +fn active_load_serves_its_validated_bytes_after_disk_mutation() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "immutable"); + let snapshot = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap(); + let revision = snapshot.revision.clone(); + let runtime = ProjectCanvasRuntime::with_root(temp.path().join("CANVASES")); + let descriptor = runtime.issue_load(binding.clone(), snapshot).unwrap(); + + let disk_entry = binding + .runtime_root_for_test(&temp.path().join("CANVASES")) + .join("revisions") + .join(revision) + .join("canvas.js"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&disk_entry, fs::Permissions::from_mode(0o644)).unwrap(); + } + #[cfg(windows)] + { + let mut permissions = fs::metadata(&disk_entry).unwrap().permissions(); + permissions.set_readonly(false); + fs::set_permissions(&disk_entry, permissions).unwrap(); + } + fs::write(&disk_entry, "globalThis.canvasMarker = 'tampered';").unwrap(); + + let path = format!("/{}/package/canvas.js", descriptor.load_id); + let (_, body) = protocol::route(&runtime, &path).unwrap(); + assert_eq!( + String::from_utf8(body).unwrap(), + "globalThis.canvasMarker = \"immutable\";" + ); + + runtime.release(&descriptor.load_id).unwrap(); + assert!(protocol::route(&runtime, &path).is_err()); +} + +#[test] +fn bootstrap_is_host_owned_and_loads_only_declared_scripts_after_connect() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "bootstrap"); + let snapshot = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap(); + let runtime = ProjectCanvasRuntime::with_root(temp.path().join("CANVASES")); + let descriptor = runtime.issue_load(binding, snapshot).unwrap(); + + let (_, shell) = protocol::route(&runtime, &format!("/{}/", descriptor.load_id)).unwrap(); + let shell = String::from_utf8(shell).unwrap(); + assert!(shell.contains("id=\"canvas-root\"")); + assert!(!shell.contains("canvasMarker")); + + let (_, bootstrap) = protocol::route( + &runtime, + &format!("/{}/__buzz/bootstrap.js", descriptor.load_id), + ) + .unwrap(); + let bootstrap = String::from_utf8(bootstrap).unwrap(); + assert!(bootstrap.contains(&descriptor.nonce)); + assert!(bootstrap.contains("message.type !== \"host.connect\"")); + assert!(bootstrap.contains("widgets/chore%2Dboard%2Ejs")); + assert!(bootstrap.contains("canvas%2Ejs")); + assert!(bootstrap.contains("window, \"buzzCanvas\"")); + assert!(bootstrap.contains("packageBaseUrl")); + assert!(bootstrap.contains("new URL(\"./package/\", location.href).href")); + assert!(!protocol::DOCUMENT_CSP.contains("'unsafe-inline'")); +} + +#[test] +fn invalid_or_undeclared_package_files_fail_closed() { + let temp = TempDir::new().unwrap(); + let template = temp.path().join("template"); + write_package(&template, "bad"); + fs::write(template.join("index.html"), "").unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + + let error = + prepare_snapshot(&temp.path().join("CANVASES"), &binding, Some(&template)).unwrap_err(); + assert!(error.contains("unsupported project canvas file type")); +} + +#[test] +fn finder_metadata_does_not_break_package_reload() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "finder"); + fs::write(source.join(".DS_Store"), b"finder metadata").unwrap(); + + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_ok()); +} + +#[test] +fn manifest_paths_cannot_traverse_the_package() { + let temp = TempDir::new().unwrap(); + let template = temp.path().join("template"); + write_package(&template, "bad-path"); + let manifest = serde_json::json!({ + "format": "buzz-project-canvas", + "protocolVersion": 1, + "scripts": ["widgets/../escape.js", "canvas.js"], + "styles": ["styles/canvas.css"], + "data": "data/dashboards.json", + "capabilities": [] + }); + fs::write( + template.join("manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, Some(&template)).is_err()); +} + +#[cfg(unix)] +#[test] +fn symlinked_storage_ancestor_is_rejected() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let root = temp.path().join("CANVASES"); + fs::create_dir(&root).unwrap(); + let root = root.canonicalize().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let project = binding.project_root_for_test(&root); + let community = root.join( + project + .strip_prefix(&root) + .unwrap() + .components() + .next() + .unwrap(), + ); + let outside = temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, &community).unwrap(); + + let error = prepare_snapshot(&root, &binding, None).unwrap_err(); + assert!(error.contains("not a real directory")); +} + +#[cfg(unix)] +#[test] +fn package_symlinks_are_rejected() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "symlink"); + let outside = temp.path().join("outside.png"); + fs::write(&outside, "secret").unwrap(); + symlink(&outside, source.join("assets/leak.png")).unwrap(); + + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_err()); +} + +#[cfg(unix)] +#[test] +fn package_hard_links_are_rejected() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "hard-link"); + let outside = temp.path().join("outside.png"); + fs::write(&outside, "secret").unwrap(); + fs::hard_link(&outside, source.join("assets/leak.png")).unwrap(); + + let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err(); + assert!(error.contains("hard linked")); +} + +#[test] +fn project_coordinate_and_community_are_validated_before_path_derivation() { + let mut invalid = request(); + invalid.community_id = "../other".to_string(); + // Community values are hashed, so punctuation cannot become a path. + assert!(ProjectBinding::parse(invalid).is_ok()); + + let mut invalid = request(); + invalid.project_id = "30621:not-hex:project".to_string(); + assert!(ProjectBinding::parse(invalid).is_err()); + + let mut invalid = request(); + invalid.project_id = format!("30621:{OWNER}:"); + assert!(ProjectBinding::parse(invalid).is_err()); +} + +#[test] +fn protocol_security_policy_has_no_network_or_tauri_ipc_source() { + assert!(protocol::DOCUMENT_CSP.contains("connect-src 'none'")); + assert!(protocol::DOCUMENT_CSP.contains("webrtc 'block'")); + assert!(!protocol::DOCUMENT_CSP.contains(" ipc:")); + assert!(!protocol::PERMISSIONS_POLICY.contains("camera=(*")); + assert!(!protocol::PERMISSIONS_POLICY.contains("microphone=(*")); +} + +#[test] +fn native_navigation_policy_blocks_external_document_navigation() { + assert!(super::allow_webview_navigation( + &"buzz-canvas://localhost/load/".parse().unwrap() + )); + assert!(super::allow_webview_navigation( + &"tauri://localhost/".parse().unwrap() + )); + assert!(super::allow_webview_navigation( + &"about:blank".parse().unwrap() + )); + assert!(!super::allow_webview_navigation( + &"https://example.com/leak?snapshot=secret".parse().unwrap() + )); + assert!(!super::allow_webview_navigation( + &"file:///tmp/secret".parse().unwrap() + )); +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 05dc5553397..c7e0ee90ce6 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -36,7 +36,7 @@ ], "macOSPrivateApi": true, "security": { - "csp": "default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/@mediapipe/; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost buzz-media: http://buzz-media.localhost https: http: wss: ws:; img-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; media-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; worker-src 'self' blob:" + "csp": "default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; frame-src buzz-canvas: http://buzz-canvas.localhost; object-src 'none'; script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/@mediapipe/; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost buzz-media: http://buzz-media.localhost https: http: wss: ws:; img-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; media-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; worker-src 'self' blob:" } }, "plugins": { @@ -52,6 +52,7 @@ "bundle": { "active": true, "targets": "all", + "resources": ["resources/project-canvas-template/**/*"], "externalBin": [ "binaries/buzz-acp", "binaries/buzz-agent", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eb5ab5a95d8..48ddb8a10f7 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -147,7 +147,6 @@ export function AppShell() { goChannel, goHome, goNewMessage, - goProjects, goPulse, goSettings, goWorkflows, @@ -886,7 +885,6 @@ export function AppShell() { scopeSearchFocusRequest, ]} onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} @@ -903,9 +901,6 @@ export function AppShell() { }) } profile={profileQuery.data} - projectsOverviewActive={ - location.pathname === "/projects" - } selfUserStatus={ deferredPubkey ? (selfStatusQuery.data?.[ diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 50371bc369f..df766065b74 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -295,11 +295,13 @@ export function ChannelRouteScreen({ ); } - if (projectHome && !isHuddleTranscript) { + if (projectHome && activeChannel && !isHuddleTranscript) { return ( { - const module = await import("@/features/projects/ui/ProjectsScreen"); - return { default: module.ProjectsScreen }; -}); +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/projects")({ - component: ProjectsRouteComponent, + beforeLoad: () => { + throw redirect({ to: "/" }); + }, }); - -function ProjectsRouteComponent() { - usePreviewFeatureWarning("projects"); - return ( - }> - - - ); -} diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts index a82916d0449..37fdcf77ab8 100644 --- a/desktop/src/app/useHuddlePresentation.ts +++ b/desktop/src/app/useHuddlePresentation.ts @@ -14,6 +14,7 @@ import { channelMessagesKey, channelWindowKey, } from "@/features/messages/lib/messageQueryKeys"; +import { safeUnlisten } from "@/shared/lib/safeUnlisten"; type HuddleTranscriptRouteState = { phase: @@ -80,13 +81,14 @@ export function useHuddlePresentation() { void listen("huddle-state-changed", (event) => syncRoute(event.payload), ).then((cleanup) => { - if (cancelled) cleanup(); + if (cancelled) safeUnlisten(cleanup); else unlisten = cleanup; }); return () => { cancelled = true; - unlisten?.(); + safeUnlisten(unlisten); + unlisten = null; }; }, [huddleRoomChannelId, isHuddleRoom]); @@ -368,12 +370,13 @@ export function useHuddlePresentation() { console.error("Failed to open huddle in the main app:", error); }); }).then((cleanup) => { - if (cancelled) cleanup(); + if (cancelled) safeUnlisten(cleanup); else unlisten = cleanup; }); return () => { cancelled = true; - unlisten?.(); + safeUnlisten(unlisten); + unlisten = null; }; }, [isHuddleRoom, showHuddleInMainApp]); @@ -429,12 +432,13 @@ export function useHuddlePresentation() { void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); } }).then((cleanup) => { - if (cancelled) cleanup(); + if (cancelled) safeUnlisten(cleanup); else unlisten = cleanup; }); return () => { cancelled = true; - unlisten?.(); + safeUnlisten(unlisten); + unlisten = null; }; }, [ hideHuddleChannel, diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 8a1aaf6a02e..54bd71effa1 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -82,6 +82,7 @@ import { useChannelModerationCapabilities, } from "./ChannelManagementModerationActions"; import { ChannelMemberAvatarStack } from "./ChannelMemberAvatarStack"; +import { ChannelProjectFeaturesSettings } from "@/features/projects/ui/ChannelProjectFeaturesSettings"; type ChannelManagementSheetProps = { channel: Channel | null; @@ -207,14 +208,10 @@ export function ChannelManagementSheet({ setActiveView("summary"); return; } - if (!detail) { - return; - } + if (!detail) return; const key = detail.id; - if (syncedForRef.current === key) { - return; - } + if (syncedForRef.current === key) return; syncedForRef.current = key; setNameDraft(detail.name); @@ -226,9 +223,7 @@ export function ChannelManagementSheet({ setActiveView("summary"); }, [cancelDeferredModalOpen, detail, open]); - if (!channel) { - return null; - } + if (!channel) return null; function handleDeleteDialogOpenChange(next: boolean) { deleteChannelMutation.reset(); @@ -247,9 +242,7 @@ export function ChannelManagementSheet({ } function handlePanelOpenChange(next: boolean) { - if (!next) { - handleDeleteDialogOpenChange(false); - } + if (!next) handleDeleteDialogOpenChange(false); onOpenChange(next); } @@ -816,6 +809,13 @@ function ChannelManagementPanelContent({ /> + {canEditChannel && resolvedChannel.channelType !== "dm" ? ( + + ) : null} + {canOpenCanvas ? (
) : null} -
+ {isNonMemberView ? ( -
-
- {activeChannel ? ( - - ) : null} - - Viewing{" "} - - #{activeChannel?.name} - - -
- -
+ ) : (
) : null} -
+
+ ) : null} {/* Serialize replacements so focus drawers keep one travel direction. */} diff --git a/desktop/src/features/channels/ui/ChannelPaneMainColumn.tsx b/desktop/src/features/channels/ui/ChannelPaneMainColumn.tsx new file mode 100644 index 00000000000..e7a77456ce1 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPaneMainColumn.tsx @@ -0,0 +1,63 @@ +import type * as React from "react"; + +import { useChannelViewOverride } from "@/features/channels/ui/ChannelViewOverrideContext"; +import { channelChrome } from "@/shared/layout/chromeLayout"; +import { cn } from "@/shared/lib/cn"; + +const IN_FLOW_CHANNEL_CONTENT_STYLE = { + "--buzz-channel-content-top-padding": "0rem", + "--channel-top-chrome-height": "0.25rem", +} as React.CSSProperties; + +export function ChannelPaneMainColumn({ + children, +}: { + children: React.ReactNode; +}) { + const channelView = useChannelViewOverride(); + const mainColumnHeader = channelView?.mainColumnHeader; + const className = cn( + "relative isolate flex min-h-0 min-w-0 flex-1 flex-col", + channelView?.mainContent && "hidden", + ); + + if (!mainColumnHeader) return
{children}
; + + return ( +
+
+
+ {mainColumnHeader} +
+ {children} +
+
+
+
+ ); +} + +export function ChannelPaneMainContent() { + const mainContent = useChannelViewOverride()?.mainContent; + if (!mainContent) return null; + + return ( +
+ {mainContent} +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 44e4d891dc1..c6798e10ebe 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -9,6 +9,8 @@ import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDi import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; +import { useChannelViewOverride } from "@/features/channels/ui/ChannelViewOverrideContext"; +import { ChannelProjectFeatureBar } from "@/features/projects/ui/ChannelProjectFeatureBar"; import { DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, ProfileAvatarWithStatus, @@ -70,6 +72,7 @@ export function ChannelScreenHeader({ onManageChannel, onToggleMembers, }: ChannelScreenHeaderProps) { + const channelView = useChannelViewOverride(); const isGroupDm = activeChannel?.channelType === "dm" && activeDmHeaderParticipants.length > 1; @@ -195,7 +198,19 @@ export function ChannelScreenHeader({ ephemeralDisplay={activeChannelEphemeralDisplay} /> } + secondaryNavigation={ + !channelView && activeChannel ? ( + + ) : null + } title={activeChannelTitle} + titleActive={channelView?.isChannelViewActive} + titleNavigation={channelView?.headerNavigation} + onTitleClick={channelView?.onSelectChannelView} transparentChrome={transparentChrome} visibility={activeChannel?.visibility} /> diff --git a/desktop/src/features/channels/ui/ChannelViewOverrideContext.tsx b/desktop/src/features/channels/ui/ChannelViewOverrideContext.tsx new file mode 100644 index 00000000000..5be245c6281 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelViewOverrideContext.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; + +type ChannelViewOverride = { + headerNavigation: React.ReactNode; + hideMainColumnBody?: boolean; + isChannelViewActive: boolean; + mainColumnHeader?: React.ReactNode; + mainContent: React.ReactNode; + onSelectChannelView: () => void; +}; + +const ChannelViewOverrideContext = + React.createContext(null); + +export function ChannelViewOverrideProvider({ + children, + value, +}: { + children: React.ReactNode; + value: ChannelViewOverride; +}) { + return ( + + {children} + + ); +} + +export function useChannelViewOverride() { + return React.useContext(ChannelViewOverrideContext); +} diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx index 78269386653..63451836eee 100644 --- a/desktop/src/features/channels/ui/ForumChannelContent.tsx +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -4,6 +4,8 @@ import { ForumView, UserProfilePanel, } from "@/features/channels/ui/ChannelScreenLazyViews"; +import { ChannelPaneMainColumn } from "@/features/channels/ui/ChannelPaneMainColumn"; +import { useChannelViewOverride } from "@/features/channels/ui/ChannelViewOverrideContext"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import type { ProfilePanelTab, @@ -76,6 +78,8 @@ export function ForumChannelContent({ targetSearchMessageId, targetSearchQuery, }: ForumChannelContentProps) { + const mainContent = useChannelViewOverride()?.mainContent; + return ( <> {header} @@ -84,18 +88,25 @@ export function ForumChannelContent({ aria-label="Forum posts" className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" > - }> - - + + }> + + + + {mainContent ? ( +
+ {mainContent} +
+ ) : null} {profilePanelPubkey ? ( Promise; +}) { + return ( +
+
+ + + Viewing{" "} + #{channel.name} + +
+ +
+ ); +} diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 9ced5067513..c0459a30ef3 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -31,8 +31,12 @@ type ChatHeaderProps = { visibility?: ChannelVisibility; leadingContent?: React.ReactNode; mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; + onTitleClick?: () => void; overlaysContent?: boolean; + secondaryNavigation?: React.ReactNode; statusBadge?: React.ReactNode; + titleActive?: boolean; + titleNavigation?: React.ReactNode; /** Render the chrome wrapper without an individual backdrop when a parent supplies shared blur. */ transparentChrome?: boolean; }; @@ -94,11 +98,16 @@ export function ChatHeader({ visibility, leadingContent, mode = "channel", + onTitleClick, overlaysContent = false, + secondaryNavigation, statusBadge, + titleActive = true, + titleNavigation, transparentChrome = false, }: ChatHeaderProps) { const trimmedDescription = description?.trim() ?? ""; + const titleActsAsTab = Boolean(onTitleClick && !titleNavigation); async function handleCopyTitle() { const value = title.trim(); @@ -122,8 +131,18 @@ export function ChatHeader({ data-tauri-drag-region >
-
-
+
+
{leadingContent ?? ( - {title} + {onTitleClick ? ( + + ) : ( + title + )}
) : null}
+ {titleNavigation ? ( +
+ {titleNavigation} +
+ ) : null}
@@ -186,6 +230,7 @@ export function ChatHeader({ )} > {header} + {secondaryNavigation}
); } diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index d5a0423cf7c..a5f56da1750 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -22,6 +22,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { KIND_HUDDLE_REACTION } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { safeUnlisten } from "@/shared/lib/safeUnlisten"; import { useDocumentVisible } from "@/shared/lib/useDocumentVisible"; import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; @@ -248,7 +249,7 @@ export function HuddleBar({ applyIncomingState(event.payload); } }).then((fn) => { - if (cancelled) fn(); + if (cancelled) safeUnlisten(fn); else unlisten = fn; }); @@ -263,7 +264,8 @@ export function HuddleBar({ return () => { cancelled = true; - unlisten?.(); + safeUnlisten(unlisten); + unlisten = null; if (id !== null) window.clearInterval(id); }; }, [applyIncomingState, documentVisible]); diff --git a/desktop/src/features/huddle/components/HuddleIndicator.tsx b/desktop/src/features/huddle/components/HuddleIndicator.tsx index 6f11d84731f..e3878ded101 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { safeUnlisten } from "@/shared/lib/safeUnlisten"; import { HUDDLE_SHORTCUT_EVENT, type HuddleShortcutDetail, @@ -206,13 +207,14 @@ export function HuddleIndicator({ setActiveHuddle(null); } }).then((fn) => { - if (cancelled) fn(); + if (cancelled) safeUnlisten(fn); else unlisten = fn; }); return () => { cancelled = true; - unlisten?.(); + safeUnlisten(unlisten); + unlisten = null; }; }, []); diff --git a/desktop/src/features/huddle/components/HuddleProfileControl.tsx b/desktop/src/features/huddle/components/HuddleProfileControl.tsx index dbf3d5f6b84..9cd583e50e0 100644 --- a/desktop/src/features/huddle/components/HuddleProfileControl.tsx +++ b/desktop/src/features/huddle/components/HuddleProfileControl.tsx @@ -4,6 +4,7 @@ import { Headphones } from "lucide-react"; import * as React from "react"; import type { Channel } from "@/shared/api/types"; +import { safeUnlisten } from "@/shared/lib/safeUnlisten"; import { Button } from "@/shared/ui/button"; import { useHuddle, useHuddleLevels } from "../HuddleContext"; import { MicControls } from "./MicControls"; @@ -69,13 +70,14 @@ export function HuddleProfileControl({ void listen("huddle-state-changed", (event) => { if (!disposed) setState(event.payload); }).then((cleanup) => { - if (disposed) cleanup(); + if (disposed) safeUnlisten(cleanup); else unlisten = cleanup; }); return () => { disposed = true; - unlisten?.(); + safeUnlisten(unlisten); + unlisten = null; }; }, []); diff --git a/desktop/src/features/huddle/components/HuddleRoomHeader.tsx b/desktop/src/features/huddle/components/HuddleRoomHeader.tsx index 0cf9c735da9..f45875ea063 100644 --- a/desktop/src/features/huddle/components/HuddleRoomHeader.tsx +++ b/desktop/src/features/huddle/components/HuddleRoomHeader.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { useProfileQuery, useSelfProfileCache } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { safeUnlisten } from "@/shared/lib/safeUnlisten"; import { useHuddle, useHuddleLevels } from "../HuddleContext"; import { useHuddleParticipantRoster } from "../hooks/useHuddleParticipantRoster"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; @@ -92,13 +93,14 @@ export function HuddleRoomHeader() { void listen("huddle-state-changed", (event) => { if (!disposed) setState(event.payload); }).then((cleanup) => { - if (disposed) cleanup(); + if (disposed) safeUnlisten(cleanup); else unlisten = cleanup; }); return () => { disposed = true; - unlisten?.(); + safeUnlisten(unlisten); + unlisten = null; }; }, []); diff --git a/desktop/src/features/huddle/lib/useTtsSubscription.ts b/desktop/src/features/huddle/lib/useTtsSubscription.ts index 1744cd7c1bc..f6235763b14 100644 --- a/desktop/src/features/huddle/lib/useTtsSubscription.ts +++ b/desktop/src/features/huddle/lib/useTtsSubscription.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import * as React from "react"; +import { safeUnlisten } from "@/shared/lib/safeUnlisten"; import { isDocumentVisible, subscribeDocumentVisibility, @@ -253,7 +254,7 @@ export function useTtsSubscription( }) .then((unlisten) => { if (disposed) { - unlisten(); + safeUnlisten(unlisten); return; } unlistenHuddleState = unlisten; @@ -324,7 +325,8 @@ export function useTtsSubscription( disposed = true; speakInOrder.setEnabled(false); cleanup?.(); - unlistenHuddleState?.(); + safeUnlisten(unlistenHuddleState); + unlistenHuddleState = null; unsubscribeDocumentVisibility(); if (agentRefreshId !== null) window.clearInterval(agentRefreshId); if (agentVerificationRetryId !== null) { diff --git a/desktop/src/features/messages/lib/mountedEditorView.test.mjs b/desktop/src/features/messages/lib/mountedEditorView.test.mjs new file mode 100644 index 00000000000..39b0da8b977 --- /dev/null +++ b/desktop/src/features/messages/lib/mountedEditorView.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getMountedView } from "./mountedEditorView.ts"; + +// Mirrors tiptap v3's unmounted-view proxy: it stubs a few keys and throws for +// everything else, so reading `dom` is what blows up in production. +function unmountedViewProxy() { + const stubs = { state: {}, composing: false, editable: true }; + return new Proxy(stubs, { + get: (target, key) => { + if (key in target) return Reflect.get(target, key); + throw new Error( + `[tiptap error]: The editor view is not available. Cannot access view['${String(key)}'].`, + ); + }, + }); +} + +test("returns the view once it is mounted", () => { + const view = { dom: { nodeType: 1 } }; + const editor = { isDestroyed: false, view }; + + assert.equal(getMountedView(editor), view); +}); + +test("returns null instead of throwing while the view is unmounted", () => { + const editor = { isDestroyed: false, view: unmountedViewProxy() }; + + assert.equal(getMountedView(editor), null); +}); + +test("returns null for a destroyed editor without touching the view", () => { + let viewReads = 0; + const editor = { + isDestroyed: true, + get view() { + viewReads += 1; + throw new Error("view read on a destroyed editor"); + }, + }; + + assert.equal(getMountedView(editor), null); + assert.equal(viewReads, 0); +}); + +test("returns null when the view has no dom element", () => { + const editor = { isDestroyed: false, view: { dom: null } }; + + assert.equal(getMountedView(editor), null); +}); diff --git a/desktop/src/features/messages/lib/mountedEditorView.ts b/desktop/src/features/messages/lib/mountedEditorView.ts new file mode 100644 index 00000000000..c86ec5f7417 --- /dev/null +++ b/desktop/src/features/messages/lib/mountedEditorView.ts @@ -0,0 +1,21 @@ +import type { EditorView } from "@tiptap/pm/view"; +import type { Editor } from "@tiptap/react"; + +/** + * Resolve an editor's ProseMirror view, or `null` when it is not mounted. + * + * A tiptap v3 `Editor` outlives its view: before `EditorContent` mounts it and + * after the subtree unmounts, `editor.view` is a proxy that *throws* for every + * key it does not stub — including `dom`, `domAtPos`, and `coordsAtPos`. A + * non-null `editor` therefore does not imply a usable view, so any code that + * reaches past the editor into the view must go through this guard. + */ +export function getMountedView(editor: Editor): EditorView | null { + if (editor.isDestroyed) return null; + try { + return editor.view.dom ? editor.view : null; + } catch { + // Throwing proxy — the view is detached right now. + return null; + } +} diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx index ab092b57562..86ce11f566d 100644 --- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -1,8 +1,10 @@ import * as React from "react"; import { createPortal } from "react-dom"; +import type { EditorView } from "@tiptap/pm/view"; import type { Editor } from "@tiptap/react"; import { cn } from "@/shared/lib/cn"; +import { getMountedView } from "../lib/mountedEditorView"; import { FormattingToolbar } from "./FormattingToolbar"; import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom"; @@ -26,13 +28,13 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -function getSelectionRect(editor: Editor): DOMRect | null { +function getSelectionRect(editor: Editor, view: EditorView): DOMRect | null { const { from, to } = editor.state.selection; try { const range = document.createRange(); - const start = editor.view.domAtPos(from); - const end = editor.view.domAtPos(to); + const start = view.domAtPos(from); + const end = view.domAtPos(to); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); @@ -47,19 +49,25 @@ function getSelectionRect(editor: Editor): DOMRect | null { // Fall back to the caret coordinates below. } - const startCoords = editor.view.coordsAtPos(from); - const endCoords = editor.view.coordsAtPos(to); - const left = Math.min(startCoords.left, endCoords.left); - const right = Math.max(startCoords.right, endCoords.right); - const top = Math.min(startCoords.top, endCoords.top); - const bottom = Math.max(startCoords.bottom, endCoords.bottom); - - if (right <= left && bottom <= top) return null; - return new DOMRect(left, top, Math.max(1, right - left), bottom - top); + try { + const startCoords = view.coordsAtPos(from); + const endCoords = view.coordsAtPos(to); + const left = Math.min(startCoords.left, endCoords.left); + const right = Math.max(startCoords.right, endCoords.right); + const top = Math.min(startCoords.top, endCoords.top); + const bottom = Math.max(startCoords.bottom, endCoords.bottom); + + if (right <= left && bottom <= top) return null; + return new DOMRect(left, top, Math.max(1, right - left), bottom - top); + } catch { + // The view detached mid-measurement; leave the tray hidden. + return null; + } } function getTrayPosition( editor: Editor, + view: EditorView, trayWidth: number, ): TrayPosition | null { const { selection } = editor.state; @@ -73,7 +81,7 @@ function getTrayPosition( ); if (selectedText.trim().length === 0) return null; - const rect = getSelectionRect(editor); + const rect = getSelectionRect(editor, view); if (!rect) return null; const viewportWidth = window.innerWidth; @@ -119,6 +127,9 @@ export function SelectionFormattingTray({ const suppressRightClickUpdatesRef = React.useRef(false); const trayRef = React.useRef(null); const [trayWidth, setTrayWidth] = React.useState(0); + // The view attaches and detaches independently of the editor, so track it as + // state rather than reading `editor.view` at wiring time. + const [mountedView, setMountedView] = React.useState(null); const cancelScheduledUpdate = React.useCallback(() => { if (rafRef.current === null) return; @@ -138,8 +149,12 @@ export function SelectionFormattingTray({ setPosition(null); return; } - setPosition(getTrayPosition(editor, trayWidth)); - }, [disabled, editor, trayWidth]); + if (!mountedView) { + setPosition(null); + return; + } + setPosition(getTrayPosition(editor, mountedView, trayWidth)); + }, [disabled, editor, mountedView, trayWidth]); const scheduleUpdate = React.useCallback(() => { if (suppressRightClickUpdatesRef.current) { @@ -154,10 +169,30 @@ export function SelectionFormattingTray({ }); }, [cancelScheduledUpdate, updatePosition]); + // React can reconnect these effects while the composer subtree is hidden, at + // which point `EditorContent` has already torn the view down. Follow tiptap's + // mount/unmount events instead of reading the throwing view proxy on demand. + React.useEffect(() => { + if (!editor) { + setMountedView(null); + return; + } + + const syncView = () => setMountedView(getMountedView(editor)); + syncView(); + editor.on("mount", syncView); + editor.on("unmount", syncView); + + return () => { + editor.off("mount", syncView); + editor.off("unmount", syncView); + }; + }, [editor]); + React.useEffect(() => { suppressRightClickUpdatesRef.current = false; - if (!editor) { + if (!editor || !mountedView) { cancelScheduledUpdate(); setPosition(null); return; @@ -219,7 +254,7 @@ export function SelectionFormattingTray({ window.removeEventListener("resize", scheduleUpdate); window.removeEventListener("scroll", scheduleUpdate, true); }; - }, [cancelScheduledUpdate, editor, scheduleUpdate]); + }, [cancelScheduledUpdate, editor, mountedView, scheduleUpdate]); React.useLayoutEffect(() => { if (!position || !trayRef.current) return; diff --git a/desktop/src/features/projects/channelProjectFeatures.test.mjs b/desktop/src/features/projects/channelProjectFeatures.test.mjs new file mode 100644 index 00000000000..87d935f1f18 --- /dev/null +++ b/desktop/src/features/projects/channelProjectFeatures.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { after, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { + channelProjectFeatureEnabled, + findChannelProject, + parseChannelProjectFeatureStore, + projectPrimaryRepository, + projectRelatedChannelIds, + projectRelatedRepositories, + readChannelProjectFeaturePreferences, + writeChannelProjectFeaturePreferences, +} from "./channelProjectFeatures.ts"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + globalThis.window = dom.window; +}); +beforeEach(() => dom.window.localStorage.clear()); +after(() => dom.window.close()); + +test("feature preferences are scoped by viewer, relay, and channel", () => { + writeChannelProjectFeaturePreferences( + "viewer-a", + "wss://relay-a.example/", + "channel-a", + { reviews: true, tasks: true }, + ); + + assert.deepEqual( + readChannelProjectFeaturePreferences( + "viewer-a", + "wss://relay-a.example", + "channel-a", + ), + { reviews: true, tasks: true }, + ); + assert.deepEqual( + readChannelProjectFeaturePreferences( + "viewer-b", + "wss://relay-a.example", + "channel-a", + ), + {}, + ); + assert.deepEqual( + readChannelProjectFeaturePreferences( + "viewer-a", + "wss://relay-b.example", + "channel-a", + ), + {}, + ); + assert.deepEqual( + readChannelProjectFeaturePreferences( + "viewer-a", + "wss://relay-a.example", + "channel-b", + ), + {}, + ); +}); + +test("malformed feature storage fails closed", () => { + assert.deepEqual(parseChannelProjectFeatureStore(null), { + version: 1, + channels: {}, + }); + assert.deepEqual( + parseChannelProjectFeatureStore({ + version: 1, + channels: { + valid: { reviews: true, tasks: true, repositories: "yes" }, + empty: null, + }, + }), + { + version: 1, + channels: { valid: { reviews: true, tasks: true }, empty: {} }, + }, + ); +}); + +test("existing data keeps a locally disabled feature enabled", () => { + assert.equal( + channelProjectFeatureEnabled({ + feature: "tasks", + hasExistingData: true, + preferences: { tasks: false }, + }), + true, + ); + assert.equal( + channelProjectFeatureEnabled({ + feature: "tasks", + hasExistingData: false, + preferences: { tasks: false }, + }), + false, + ); +}); + +test("channel project helpers hide the primary repository and dedupe breakout channels", () => { + const primary = { + id: "primary", + repoAddress: "30617:owner:primary", + channelId: "root", + }; + const related = { + id: "related", + repoAddress: "30617:owner:related", + channelId: "breakout", + }; + const project = { + id: "project", + legacy: false, + projectChannelId: "root", + primaryRepositoryAddress: primary.repoAddress, + relatedChannelIds: ["breakout", "extra", "root"], + repositories: [primary, related, { ...related, id: "duplicate" }], + }; + + assert.equal(findChannelProject([project], "root"), project); + assert.equal(projectPrimaryRepository(project), primary); + assert.deepEqual(projectRelatedRepositories(project), [ + related, + { + ...related, + id: "duplicate", + }, + ]); + assert.deepEqual(projectRelatedChannelIds(project, "root"), [ + "breakout", + "extra", + ]); +}); diff --git a/desktop/src/features/projects/channelProjectFeatures.ts b/desktop/src/features/projects/channelProjectFeatures.ts new file mode 100644 index 00000000000..6e81cfd122c --- /dev/null +++ b/desktop/src/features/projects/channelProjectFeatures.ts @@ -0,0 +1,212 @@ +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; + +import type { Project, Repository } from "./projectModels"; + +const STORAGE_KEY_PREFIX = "buzz-channel-project-features.v1"; +const MAX_CHANNEL_PREFERENCES = 1_000; +export const CHANNEL_PROJECT_FEATURES_CHANGED_EVENT = + "buzz:channel-project-features-changed"; + +export type ChannelProjectFeature = + | "tasks" + | "breakouts" + | "reviews" + | "repositories"; + +export type ChannelProjectFeaturePreferences = { + tasks?: boolean; + breakouts?: boolean; + reviews?: boolean; + repositories?: boolean; + breakoutSectionId?: string; +}; + +type ChannelProjectFeatureStore = { + version: 1; + channels: Record; +}; + +const EMPTY_PREFERENCES: ChannelProjectFeaturePreferences = Object.freeze({}); + +export function channelProjectFeatureStorageKey( + pubkey: string, + relayUrl: string, +) { + return `${STORAGE_KEY_PREFIX}:${pubkey.toLowerCase()}:${encodeURIComponent( + normalizeRelayUrl(relayUrl), + )}`; +} + +function parsePreferences(value: unknown): ChannelProjectFeaturePreferences { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const candidate = value as Record; + return { + ...(typeof candidate.tasks === "boolean" ? { tasks: candidate.tasks } : {}), + ...(typeof candidate.breakouts === "boolean" + ? { breakouts: candidate.breakouts } + : {}), + ...(typeof candidate.reviews === "boolean" + ? { reviews: candidate.reviews } + : {}), + ...(typeof candidate.repositories === "boolean" + ? { repositories: candidate.repositories } + : {}), + ...(typeof candidate.breakoutSectionId === "string" && + candidate.breakoutSectionId.length > 0 + ? { breakoutSectionId: candidate.breakoutSectionId } + : {}), + }; +} + +export function parseChannelProjectFeatureStore( + value: unknown, +): ChannelProjectFeatureStore { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { version: 1, channels: {} }; + } + const candidate = value as Record; + if ( + candidate.version !== 1 || + !candidate.channels || + typeof candidate.channels !== "object" || + Array.isArray(candidate.channels) + ) { + return { version: 1, channels: {} }; + } + return { + version: 1, + channels: Object.fromEntries( + Object.entries(candidate.channels as Record) + .filter(([channelId]) => channelId.length > 0) + .slice(-MAX_CHANNEL_PREFERENCES) + .map(([channelId, preferences]) => [ + channelId, + parsePreferences(preferences), + ]), + ), + }; +} + +function readStore(pubkey: string, relayUrl: string) { + try { + const raw = window.localStorage.getItem( + channelProjectFeatureStorageKey(pubkey, relayUrl), + ); + return parseChannelProjectFeatureStore(raw ? JSON.parse(raw) : null); + } catch { + return { version: 1, channels: {} } satisfies ChannelProjectFeatureStore; + } +} + +export function readChannelProjectFeaturePreferences( + pubkey: string | undefined, + relayUrl: string | undefined, + channelId: string, +) { + if (!pubkey || !relayUrl) return EMPTY_PREFERENCES; + return readStore(pubkey, relayUrl).channels[channelId] ?? EMPTY_PREFERENCES; +} + +export function writeChannelProjectFeaturePreferences( + pubkey: string, + relayUrl: string, + channelId: string, + patch: Partial, +) { + const key = channelProjectFeatureStorageKey(pubkey, relayUrl); + try { + const store = readStore(pubkey, relayUrl); + const next = parsePreferences({ + ...store.channels[channelId], + ...patch, + }); + // TODO: Replace this browser-local POC state with shared persisted + // capability metadata if the channel-first model is validated. + window.localStorage.setItem( + key, + JSON.stringify( + parseChannelProjectFeatureStore({ + version: 1, + channels: { ...store.channels, [channelId]: next }, + }), + ), + ); + window.dispatchEvent( + new window.CustomEvent(CHANNEL_PROJECT_FEATURES_CHANGED_EVENT, { + detail: { key }, + }), + ); + return next; + } catch { + return null; + } +} + +export function findChannelProject( + projects: readonly Project[], + channelId: string, +) { + return ( + projects.find((project) => project.projectChannelId === channelId) ?? + projects.find( + (project) => + project.legacy && + project.repositories.some( + (repository) => repository.channelId === channelId, + ), + ) ?? + null + ); +} + +export function projectPrimaryRepository(project: Project | null) { + if (!project) return null; + return ( + project.repositories.find( + (repository) => + repository.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null + ); +} + +export function projectRelatedRepositories(project: Project | null) { + if (!project) return []; + const primary = projectPrimaryRepository(project); + return project.repositories.filter( + (repository) => repository.repoAddress !== primary?.repoAddress, + ); +} + +export function projectRelatedChannelIds( + project: Project | null, + rootChannelId: string, +) { + if (!project) return []; + return [ + ...new Set( + [ + ...(project.relatedChannelIds ?? []), + ...project.repositories.map((repository: Repository) => + repository.channelId?.trim(), + ), + ].filter( + (channelId): channelId is string => + Boolean(channelId) && channelId !== rootChannelId, + ), + ), + ]; +} + +export function channelProjectFeatureEnabled({ + feature, + hasExistingData, + preferences, +}: { + feature: ChannelProjectFeature; + hasExistingData: boolean; + preferences: ChannelProjectFeaturePreferences; +}) { + return hasExistingData || preferences[feature] === true; +} diff --git a/desktop/src/features/projects/createProject.ts b/desktop/src/features/projects/createProject.ts index 8adfe777a96..085287b92d0 100644 --- a/desktop/src/features/projects/createProject.ts +++ b/desktop/src/features/projects/createProject.ts @@ -30,6 +30,7 @@ import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; export type CreateProjectInput = { name: string; description?: string; + homeChannel?: Channel; channelVisibility?: ChannelVisibility; projectVisibility?: ProjectListingVisibility; agents?: readonly CreateChannelManagedAgentInput[]; @@ -215,7 +216,7 @@ async function finishCreate( return { channel, project }; } -/** Creates the home channel, a bound default repository, and the NIP-MP project. */ +/** Creates or reuses the home channel, then binds a default repository and project. */ export async function createProject( input: CreateProjectInput, resume: CreateProjectResumeState, @@ -238,7 +239,8 @@ export async function createProject( throw new Error(`You already have a project named "${dtagPreview}".`); } if (existingProject && !existingProject.legacy) { - const cachedChannel = resume.channels.get(projectId) ?? null; + const cachedChannel = + resume.channels.get(projectId) ?? input.homeChannel ?? null; const channelId = cachedChannel?.id ?? existingProject.projectChannelId ?? ""; const project = channelId @@ -263,7 +265,7 @@ export async function createProject( } resume.projectIds.add(projectId); - let channel = resume.channels.get(projectId); + let channel = resume.channels.get(projectId) ?? input.homeChannel; if (!channel) { channel = await createChannel({ channelType: "stream", @@ -271,8 +273,8 @@ export async function createProject( name: input.name.trim(), visibility: input.channelVisibility ?? "open", }); - resume.channels.set(projectId, channel); } + resume.channels.set(projectId, channel); const templates = buildProjectBootstrapTemplates({ description: input.description, diff --git a/desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx b/desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx new file mode 100644 index 00000000000..dab464edf06 --- /dev/null +++ b/desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx @@ -0,0 +1,343 @@ +import { + ArrowLeft, + GitBranch, + GitPullRequest, + Hash, + ListTodo, + MessagesSquare, + Plus, +} from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useCreateChannelMutation } from "@/features/channels/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import type { Channel } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +import { useCreateProjectIssueMutation } from "../issueMutations"; +import { useChannelProjectFeatures } from "../useChannelProjectFeatures"; +import { CreateProjectWorkItemDialog } from "./CreateProjectWorkItemDialog"; +import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; + +type OpenTool = "tasks" | "breakouts" | "repositories" | null; + +export function ChannelProjectFeatureBar({ + channel, + currentPubkey, +}: { + channel: Channel; + currentPubkey?: string; +}) { + const { activeCommunity } = useCommunities(); + const { goChannel, goProject } = useAppNavigation(); + const context = useChannelProjectFeatures({ + channel, + currentPubkey, + relayUrl: activeCommunity?.relayUrl, + }); + const createChannelMutation = useCreateChannelMutation(); + const createTaskMutation = useCreateProjectIssueMutation( + context.primaryRepository, + ); + const [openTool, setOpenTool] = React.useState(null); + const [createTaskOpen, setCreateTaskOpen] = React.useState(false); + const [createChannelOpen, setCreateChannelOpen] = React.useState(false); + const [selectedIssueId, setSelectedIssueId] = React.useState( + null, + ); + const project = context.project; + + if ( + !project || + project.projectChannelId === channel.id || + !Object.values(context.enabled).some(Boolean) || + channel.channelType === "dm" + ) { + return null; + } + + const breakoutChannels = context.breakoutChannelIds.flatMap((channelId) => { + const result = context.channels.find( + (candidate) => candidate.id === channelId, + ); + return result ? [result] : []; + }); + + return ( + <> + + + + {selectedIssueId ? ( + + ) : null} + + + } + onOpenChange={(open) => { + setOpenTool(open ? "tasks" : null); + if (!open) setSelectedIssueId(null); + }} + open={openTool === "tasks"} + testId="channel-tasks-dialog" + title="Tasks" + > + {context.primaryRepository ? ( + + ) : ( + Tasks are unavailable. + )} + + + { + await createTaskMutation.mutateAsync(input); + context.setFeatureEnabled("tasks", true); + }} + onOpenChange={setCreateTaskOpen} + open={createTaskOpen} + submitDisabled={!context.primaryRepository} + title="Create task" + titlePlaceholder="Task title" + /> + + setCreateChannelOpen(true)} + size="icon" + type="button" + variant="outline" + > + + + } + onOpenChange={(open) => setOpenTool(open ? "breakouts" : null)} + open={openTool === "breakouts"} + testId="channel-breakouts-dialog" + title="Breakout channels" + > + {breakoutChannels.length > 0 ? ( +
+ {breakoutChannels.map((breakoutChannel) => ( + + ))} +
+ ) : ( + No breakout channels yet. + )} +
+ + { + const createdChannel = await createChannelMutation.mutateAsync({ + ...input, + channelType: "stream", + }); + const section = context.ensureBreakoutSection(); + if (!section) throw new Error("Could not create the channel group."); + context.channelSections.assignChannel(createdChannel.id, section.id); + context.setFeatureEnabled("breakouts", true); + }} + onOpenChange={setCreateChannelOpen} + /> + + context.setFeatureEnabled("repositories", true)} + project={project} + projects={context.projects} + repository={context.primaryRepository} + showAccessManagement={false} + /> + ) : null + } + onOpenChange={(open) => setOpenTool(open ? "repositories" : null)} + open={openTool === "repositories"} + testId="channel-repositories-dialog" + title="Related repositories" + > + {context.relatedRepositories.length > 0 ? ( +
+ {context.relatedRepositories.map((repository) => ( +
+ + {repository.name} +
+ ))} +
+ ) : ( + No related repositories yet. + )} +
+ + ); +} + +function FeatureButton({ + icon: Icon, + label, + onClick, + testId, +}: { + icon: typeof ListTodo; + label: string; + onClick: () => void; + testId: string; +}) { + return ( + + ); +} + +function ToolDialog({ + actions, + children, + onOpenChange, + open, + testId, + title, +}: { + actions?: React.ReactNode; + children: React.ReactNode; + onOpenChange: (open: boolean) => void; + open: boolean; + testId: string; + title: string; +}) { + return ( + + + +
+ {title} + + {title} for this channel + +
+
{actions}
+
+
{children}
+
+
+ ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/desktop/src/features/projects/ui/ChannelProjectFeaturesSettings.tsx b/desktop/src/features/projects/ui/ChannelProjectFeaturesSettings.tsx new file mode 100644 index 00000000000..cdb5c974223 --- /dev/null +++ b/desktop/src/features/projects/ui/ChannelProjectFeaturesSettings.tsx @@ -0,0 +1,130 @@ +import { + GitBranch, + GitPullRequest, + ListTodo, + MessagesSquare, + type LucideIcon, +} from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import type { Channel } from "@/shared/api/types"; +import { Switch } from "@/shared/ui/switch"; + +import type { ChannelProjectFeature } from "../channelProjectFeatures"; +import { useChannelProjectFeatures } from "../useChannelProjectFeatures"; +import { useCreateProjectMutation } from "../useCreateProject"; +import { FieldGroup } from "@/features/channels/ui/ChannelManagementSheetRows"; + +const FEATURES: Array<{ + feature: ChannelProjectFeature; + icon: LucideIcon; + label: string; +}> = [ + { feature: "tasks", icon: ListTodo, label: "Tasks" }, + { feature: "breakouts", icon: MessagesSquare, label: "Breakout channels" }, + { feature: "reviews", icon: GitPullRequest, label: "Reviews" }, + { feature: "repositories", icon: GitBranch, label: "Related repositories" }, +]; + +export function ChannelProjectFeaturesSettings({ + channel, + currentPubkey, +}: { + channel: Channel; + currentPubkey?: string; +}) { + const { activeCommunity } = useCommunities(); + const context = useChannelProjectFeatures({ + channel, + currentPubkey, + relayUrl: activeCommunity?.relayUrl, + }); + const createProjectMutation = useCreateProjectMutation(); + const [pendingFeature, setPendingFeature] = + React.useState(null); + + async function ensureProject() { + if (context.project) return context.project; + const input = { + description: channel.description, + homeChannel: channel, + name: channel.name, + }; + try { + return (await createProjectMutation.mutateAsync(input)).project; + } catch (error) { + if ( + !(error instanceof Error) || + !/already have a project/i.test(error.message) + ) { + throw error; + } + return ( + await createProjectMutation.mutateAsync({ + ...input, + name: `${channel.name} ${channel.id.slice(0, 8)}`, + }) + ).project; + } + } + + async function handleFeatureChange( + feature: ChannelProjectFeature, + checked: boolean, + ) { + setPendingFeature(feature); + try { + if (checked) await ensureProject(); + context.setFeatureEnabled(feature, checked); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not update channel features.", + ); + } finally { + setPendingFeature(null); + } + } + + if (!currentPubkey || !activeCommunity?.relayUrl) return null; + + return ( + + {FEATURES.map(({ feature, icon: Icon, label }) => { + const forcedOn = context.existing[feature]; + const labelId = `channel-feature-${feature}-label`; + return ( +
+ + + {label} + + { + void handleFeatureChange(feature, checked); + }} + /> +
+ ); + })} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index dc526b1f0ef..3edd8a8d584 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -1,35 +1,42 @@ +import { useQueries } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; -import { Maximize2, Plus } from "lucide-react"; +import { ArrowLeft, Maximize2, Plus } from "lucide-react"; import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; -import { useProfileQuery } from "@/features/profile/hooks"; -import type { Project } from "@/features/projects/hooks"; +import { ChannelViewOverrideProvider } from "@/features/channels/ui/ChannelViewOverrideContext"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; +import { fetchAvatarDataUrl } from "@/features/profile/lib/selfProfileStorage"; +import { + type Project, + useProjectPullRequestsQuery, +} from "@/features/projects/hooks"; import { - isProjectHomeWorkspaceSheetTab, projectHomeWorkspaceSheetExpandTab, projectHomeWorkspaceSheetTitle, - type ProjectHomeWorkspaceSheetTab, } from "@/features/projects/lib/projectHomeWorkspaceSheet"; import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; +import { useChannelProjectFeatures } from "@/features/projects/useChannelProjectFeatures"; import { useHealProjectHomeRepositories } from "@/features/projects/useHealProjectHomeRepositories"; import { useIdentityQuery } from "@/shared/api/hooks"; -import type { RelayEvent } from "@/shared/api/types"; -import type { EntityLinkTab } from "@/shared/lib/entityLink"; -import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; -import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout"; -import { cn } from "@/shared/lib/cn"; +import type { Channel, RelayEvent } from "@/shared/api/types"; +import { getAvatarSnapshotUrl } from "@/shared/lib/animatedAvatar"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Button } from "@/shared/ui/button"; -import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; -import { useOptionalSidebar } from "@/shared/ui/sidebar"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; -import { ProjectContextRail } from "./ProjectContextRail"; +import { ProjectChannelResourcesView } from "./ProjectChannelResourcesView"; +import { ProjectCanvasSurface } from "./project-canvas/ProjectCanvasSurface"; +import type { ProjectCanvasSnapshots } from "./project-canvas/projectCanvasProtocol"; +import { + ProjectChannelTabs, + projectChannelViewEnabled, + type ProjectChannelView, +} from "./ProjectChannelTabs"; import { ProjectDetailChrome } from "./ProjectDetailChrome"; -import { ProjectHomeColumn } from "./ProjectHomeColumn"; -import { ProjectHomeContextPanel } from "./ProjectHomeContextPanel"; import { ProjectHomeWorkspaceSheet, type ProjectHomeWorkspaceCreateAction, @@ -38,8 +45,17 @@ import { import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; const EMPTY_TARGET_MESSAGE_EVENTS: RelayEvent[] = []; -const PROJECT_HOME_SUMMARY_WIDTH_KEY = - "buzz.desktop.project-home-summary-width"; +const MAX_CANVAS_CHANNELS = 64; +const MAX_CANVAS_MEMBER_PROFILES = 128; +const MAX_CANVAS_PEOPLE_PER_CHANNEL = 5; +const MAX_CANVAS_REPOSITORIES = 64; +const MAX_CANVAS_REVIEWS = 32; +const MAX_CANVAS_AVATARS = 8; +const MAX_CANVAS_AVATAR_DATA_URL_LENGTH = 48 * 1_024; + +function boundedCanvasText(value: string, maxLength: number): string { + return value.slice(0, maxLength); +} const ChannelScreenView = React.lazy(async () => { const module = await import("@/features/channels/ui/ChannelScreen"); @@ -49,44 +65,10 @@ const ChannelScreenView = React.lazy(async () => { function ignoreForumPost() {} function ignoreForumPostSelect() {} -function ProjectHomeHeaderToggle({ - children, - label, - onClick, - open, - testId, -}: { - children: React.ReactNode; - label: string; - onClick: () => void; - open: boolean; - testId: string; -}) { - return ( - - - - - {label} - - ); -} - export function ProjectChannelHome({ allowRepositoryHealing, autoSendDraftKey, + channel, project, projects, targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS, @@ -94,13 +76,14 @@ export function ProjectChannelHome({ }: { allowRepositoryHealing: boolean; autoSendDraftKey?: string | null; + channel: Channel; project: Project; projects: Project[]; targetMessageEvents?: RelayEvent[]; targetMessageId?: string | null; }) { - const { goChannel, goProject, goProjects } = useAppNavigation(); - const sidebar = useOptionalSidebar(); + const { goChannel, goProject } = useAppNavigation(); + const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const channelsQuery = useChannelsQuery(); @@ -108,10 +91,9 @@ export function ProjectChannelHome({ autoSend?: string; messageId?: string; }; - const [summaryOpen, setSummaryOpen] = React.useState(true); + const [activeView, setActiveView] = + React.useState("chat"); const [addRepositoryOpen, setAddRepositoryOpen] = React.useState(false); - const [workspaceSheetTab, setWorkspaceSheetTab] = - React.useState(null); const [workspaceRepositoryId, setWorkspaceRepositoryId] = React.useState< string | null >(null); @@ -119,75 +101,295 @@ export function ProjectChannelHome({ React.useState(null); const [workspaceDetail, setWorkspaceDetail] = React.useState(null); - const summaryWidth = useThreadPanelWidth(undefined, { - minWidthPx: SIDEBAR_WIDTH_MIN, - sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY, + const channelFeatures = useChannelProjectFeatures({ + channel, + currentPubkey: identityQuery.data?.pubkey, + relayUrl: activeCommunity?.relayUrl, }); + const canvasReviewsQuery = useProjectPullRequestsQuery( + channelFeatures.primaryRepository, + ); const homeChannel = channelsQuery.data?.find( - (channel) => channel.id === project.projectChannelId, + (candidate) => candidate.id === project.projectChannelId, ) ?? null; + const canvasChannels = React.useMemo(() => { + const relatedIds = new Set(channelFeatures.breakoutChannelIds); + return [ + ...(homeChannel ? [homeChannel] : []), + ...(channelsQuery.data ?? []).filter( + (candidate) => + candidate.id !== homeChannel?.id && relatedIds.has(candidate.id), + ), + ].slice(0, MAX_CANVAS_CHANNELS); + }, [channelFeatures.breakoutChannelIds, channelsQuery.data, homeChannel]); + const canvasReviewRows = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey.toLowerCase(); + if (!currentPubkey) return []; + return (canvasReviewsQuery.data ?? []) + .filter( + (review) => + review.status === "Open" && + review.author.toLowerCase() === currentPubkey, + ) + .flatMap((review) => { + const decisions = [ + ...review.approvals.map((decision) => ({ + ...decision, + status: "Approved" as const, + })), + ...review.changeRequests.map((decision) => ({ + ...decision, + status: "Changes requested" as const, + })), + ].sort( + (left, right) => + right.createdAt - left.createdAt || right.id.localeCompare(left.id), + ); + const latestDecision = decisions[0] ?? null; + const requestedReviewers = new Set( + review.reviewers.map((reviewer) => reviewer.toLowerCase()), + ); + const latestReviewerActivity = + review.comments + .filter( + (comment) => + requestedReviewers.has(comment.author.toLowerCase()) && + !comment.isTrustedReviewRequest && + !comment.reviewDecision && + comment.inlineCommentStatus !== "outdated", + ) + .sort( + (left, right) => + right.createdAt - left.createdAt || + right.id.localeCompare(left.id), + )[0] ?? null; + const agentPubkey = + latestDecision?.author.toLowerCase() ?? + latestReviewerActivity?.author.toLowerCase() ?? + [...requestedReviewers][0] ?? + null; + if (!agentPubkey) return []; + return [ + { + agentPubkey, + branch: review.branchName + ? boundedCanvasText(review.branchName, 256) + : null, + displayId: boundedCanvasText(review.id.slice(0, 8), 8), + id: boundedCanvasText(review.id, 256), + status: + latestDecision?.status ?? + (latestReviewerActivity + ? ("Reviewing" as const) + : ("Requested" as const)), + title: boundedCanvasText(review.title, 256), + }, + ]; + }) + .slice(0, MAX_CANVAS_REVIEWS); + }, [canvasReviewsQuery.data, identityQuery.data?.pubkey]); + const canvasProfilePubkeys = React.useMemo( + () => + [ + ...new Set( + [ + ...canvasChannels.flatMap((candidate) => candidate.memberPubkeys), + ...canvasReviewRows.flatMap((review) => + review.agentPubkey ? [review.agentPubkey] : [], + ), + ].map((pubkey) => pubkey.toLowerCase()), + ), + ].slice(0, MAX_CANVAS_MEMBER_PROFILES), + [canvasChannels, canvasReviewRows], + ); + const canvasProfilesQuery = useUsersBatchQuery(canvasProfilePubkeys, { + enabled: canvasProfilePubkeys.length > 0, + }); + const canvasAvatarCandidates = React.useMemo( + () => + canvasProfilePubkeys + .flatMap((pubkey) => { + const avatarUrl = + canvasProfilesQuery.data?.profiles[pubkey]?.avatarUrl ?? null; + const snapshotUrl = getAvatarSnapshotUrl(avatarUrl); + return snapshotUrl ? [{ pubkey, snapshotUrl }] : []; + }) + .slice(0, MAX_CANVAS_AVATARS), + [canvasProfilePubkeys, canvasProfilesQuery.data], + ); + const canvasAvatarQueries = useQueries({ + queries: canvasAvatarCandidates.map(({ pubkey, snapshotUrl }) => ({ + gcTime: 10 * 60_000, + queryFn: () => fetchAvatarDataUrl(rewriteRelayUrl(snapshotUrl)), + queryKey: ["project-canvas-avatar", pubkey, snapshotUrl], + staleTime: 10 * 60_000, + })), + }); + const canvasAvatarDataByPubkey = React.useMemo(() => { + const avatars = new Map(); + canvasAvatarCandidates.forEach((candidate, index) => { + const dataUrl = canvasAvatarQueries[index]?.data; + if ( + dataUrl?.startsWith("data:image/") && + dataUrl.length <= MAX_CANVAS_AVATAR_DATA_URL_LENGTH + ) { + avatars.set(candidate.pubkey, dataUrl); + } + }); + return avatars; + }, [canvasAvatarCandidates, canvasAvatarQueries]); + const canvasSnapshots = React.useMemo(() => { + const projectSummary = { + description: boundedCanvasText(project.description, 2_048), + id: boundedCanvasText(project.projectAddress, 1_024), + name: boundedCanvasText(project.name, 256), + owner: boundedCanvasText(project.owner, 64), + repositories: project.repositories + .slice(0, MAX_CANVAS_REPOSITORIES) + .map((repository) => ({ + defaultBranch: boundedCanvasText(repository.defaultBranch, 256), + description: boundedCanvasText(repository.description, 1_024), + id: boundedCanvasText(repository.repoAddress, 1_024), + name: boundedCanvasText(repository.name, 256), + owner: boundedCanvasText(repository.owner, 64), + status: boundedCanvasText(repository.status, 64), + })), + }; + + const emittedCanvasAvatarPubkeys = new Set(); + const visibleChannels = canvasChannels.map((candidate) => ({ + description: boundedCanvasText(candidate.description, 1_024), + id: boundedCanvasText(candidate.id, 256), + lastMessageAt: candidate.lastMessageAt, + memberCount: Math.max(0, candidate.memberCount), + name: boundedCanvasText(candidate.name, 256), + people: candidate.memberPubkeys + .slice(0, MAX_CANVAS_PEOPLE_PER_CHANNEL) + .map((pubkey) => { + const normalizedPubkey = pubkey.toLowerCase(); + const profile = canvasProfilesQuery.data?.profiles[normalizedPubkey]; + const displayName = profile?.displayName ?? profile?.name ?? null; + const avatarDataUrl = + canvasAvatarDataByPubkey.get(normalizedPubkey) ?? null; + const includeAvatar = + avatarDataUrl !== null && + emittedCanvasAvatarPubkeys.size < MAX_CANVAS_AVATARS && + !emittedCanvasAvatarPubkeys.has(normalizedPubkey); + if (includeAvatar) { + emittedCanvasAvatarPubkeys.add(normalizedPubkey); + } + return { + avatarDataUrl: includeAvatar ? avatarDataUrl : null, + displayName: displayName + ? boundedCanvasText(displayName, 128) + : null, + pubkey: boundedCanvasText(normalizedPubkey, 64), + }; + }), + relationship: + candidate.id === homeChannel?.id + ? ("home" as const) + : ("related" as const), + topic: candidate.topic ? boundedCanvasText(candidate.topic, 512) : null, + })); + const channelsState: ProjectCanvasSnapshots["channels"] = + channelsQuery.isPending + ? { data: null, status: "loading" } + : channelsQuery.isError + ? { data: null, status: "error" } + : { data: visibleChannels, status: "ready" }; + + const reviewsState: ProjectCanvasSnapshots["reviews"] = + !channelFeatures.primaryRepository + ? { data: [], status: "ready" } + : canvasReviewsQuery.isPending || identityQuery.isPending + ? { data: null, status: "loading" } + : canvasReviewsQuery.isError + ? { data: null, status: "error" } + : { + data: canvasReviewRows.map((review) => { + const profile = review.agentPubkey + ? canvasProfilesQuery.data?.profiles[review.agentPubkey] + : null; + const agentName = + profile?.displayName ?? profile?.name ?? null; + return { + ...review, + agentName: agentName + ? boundedCanvasText(agentName, 256) + : null, + }; + }), + status: "ready", + }; + + return { + channels: channelsState, + project: { data: projectSummary, status: "ready" }, + reviews: reviewsState, + }; + }, [ + canvasReviewsQuery.isError, + canvasReviewsQuery.isPending, + canvasAvatarDataByPubkey, + canvasReviewRows, + channelFeatures.primaryRepository, + canvasChannels, + canvasProfilesQuery.data, + channelsQuery.isError, + channelsQuery.isPending, + homeChannel, + identityQuery.isPending, + project, + ]); const waitingForChannel = channelsQuery.isPending && !homeChannel; + const workspaceTab = + activeView === "issues" + ? "issues" + : activeView === "reviews" + ? "prs" + : null; const workspaceRepository = project.repositories.find( (repository) => repository.id === workspaceRepositoryId, ) ?? project.repositories[0] ?? null; - const workspaceSheetOpen = - workspaceSheetTab != null && workspaceRepository != null; - const previousWorkspaceSheetOpenRef = React.useRef(workspaceSheetOpen); - const workspaceSheetVisibilityChanged = - previousWorkspaceSheetOpenRef.current !== workspaceSheetOpen; - React.useEffect(() => { - previousWorkspaceSheetOpenRef.current = workspaceSheetOpen; - }, [workspaceSheetOpen]); - const summaryVisible = summaryOpen && !workspaceSheetOpen; - const openWorkspaceSheet = React.useCallback( - (tab: ProjectHomeWorkspaceSheetTab, repositoryId?: string) => { - if (repositoryId) { - setWorkspaceRepositoryId(repositoryId); + const selectView = React.useCallback( + (view: ProjectChannelView) => { + if ((view === "issues" || view === "reviews") && !workspaceRepository) { + setAddRepositoryOpen(true); + return; } setWorkspaceCreateAction(null); setWorkspaceDetail(null); - setWorkspaceSheetTab((current) => (current === tab ? null : tab)); - }, - [], - ); - const closeWorkspaceSheet = React.useCallback(() => { - setWorkspaceCreateAction(null); - setWorkspaceDetail(null); - setWorkspaceSheetTab(null); - }, []); - const handleOpenWorkspace = React.useCallback( - (repositoryId: string, tab?: EntityLinkTab) => { - if (!isProjectHomeWorkspaceSheetTab(tab)) { - void goProject(project.id, { repositoryId, tab }); - return; - } - openWorkspaceSheet(tab, repositoryId); + setActiveView(view); }, - [goProject, openWorkspaceSheet, project.id], + [workspaceRepository], ); + React.useEffect(() => { + if (!projectChannelViewEnabled(activeView, channelFeatures.enabled)) { + selectView("chat"); + } + }, [activeView, channelFeatures.enabled, selectView]); + const handleOpenRepository = React.useCallback( (repositoryId: string) => { void goProject(project.id, { repositoryId }); }, [goProject, project.id], ); - const handleRepositoryChange = React.useCallback(() => { - void goProject(project.id); - }, [goProject, project.id]); const handleAddFiles = React.useCallback(() => { setAddRepositoryOpen(true); }, []); - const handleFilesAdded = React.useCallback((repositoryId: string) => { - setWorkspaceCreateAction(null); - setWorkspaceDetail(null); - setWorkspaceRepositoryId(repositoryId); - setWorkspaceSheetTab("files"); - }, []); + const handleFilesAdded = React.useCallback( + (repositoryId: string) => { + void goProject(project.id, { repositoryId, tab: "files" }); + }, + [goProject, project.id], + ); const handleWorkspaceRepositoryChange = React.useCallback( (repositoryId: string) => { setWorkspaceCreateAction(null); @@ -213,178 +415,224 @@ export function ProjectChannelHome({ [goProject, project.id, workspaceRepository], ); const handleExpandWorkspace = React.useCallback(() => { - if (!workspaceRepository || !workspaceSheetTab) return; + if (!workspaceRepository || !workspaceTab) return; void goProject(project.id, { repositoryId: workspaceRepository.id, ...workspaceDetail?.navigation, - tab: projectHomeWorkspaceSheetExpandTab(workspaceSheetTab), + tab: projectHomeWorkspaceSheetExpandTab(workspaceTab), }); }, [ goProject, project.id, workspaceDetail?.navigation, workspaceRepository, - workspaceSheetTab, + workspaceTab, ]); - const expandLabel = workspaceSheetTab - ? `Open ${projectHomeWorkspaceSheetTitle(workspaceSheetTab)} in repository` - : "Open in repository"; - const workspaceSheet = - workspaceSheetOpen && workspaceSheetTab && workspaceRepository ? ( - +
+
+ {workspaceDetail ? ( + + ) : null} + + {projectHomeWorkspaceSheetTitle(workspaceTab)} + +
+
+ {workspaceCreateAction ? ( + + + + + {workspaceCreateAction.label} + + ) : null} + + + + + Open in repository + +
+
+
+ +
+
+ ) : null; + const mainContent = + activeView === "channels" ? ( + void goChannel(channelId)} + onOpenRepository={handleOpenRepository} + onSelectChat={() => selectView("chat")} project={project} projects={projects} - repository={workspaceRepository} - tab={workspaceSheetTab} + relatedChannelIds={channelFeatures.breakoutChannelIds} + view="channels" /> - ) : null; + ) : activeView === "repos" ? ( + void goChannel(channelId)} + onOpenRepository={handleOpenRepository} + onSelectChat={() => selectView("chat")} + project={project} + projects={projects} + view="repos" + /> + ) : ( + workspaceContent + ); return ( - +
-
+
{ - if (workspaceSheetOpen) { - closeWorkspaceSheet(); - return; - } - setSummaryOpen((open) => !open); - }} - open={summaryVisible} - testId="project-home-drawer-toggle" - > - - - } activeTabCrumb={null} activeWorkItemCrumb={null} onGoProjectHome={() => undefined} - onGoProjects={() => { - void goProjects(); + onGoRootChannel={() => { + if (project.projectChannelId) { + void goChannel(project.projectChannelId); + } }} project={project} /> {waitingForChannel ? ( ) : homeChannel ? ( - - } +
- - {workspaceCreateAction ? ( - - - - - - {workspaceCreateAction.label} - - - ) : null} - - - - - {expandLabel} - - - ), - backLabel: workspaceDetail?.backLabel, - onBack: workspaceDetail?.onBack, - }} - idleAuxiliaryOverridesThread={workspaceSheetOpen} - idleAuxiliaryTitle={ - workspaceSheetTab - ? projectHomeWorkspaceSheetTitle(workspaceSheetTab) - : "" - } - onAddFiles={handleAddFiles} - onCloseIdleAuxiliaryPanel={closeWorkspaceSheet} - onCloseForumPost={ignoreForumPost} - onSelectForumPost={ignoreForumPostSelect} - selectedForumPostId={null} - targetForumReplyId={null} - targetMessageEvents={targetMessageEvents} - targetMessageId={ - targetMessageId === undefined - ? (search.messageId ?? null) - : targetMessageId - } - /> - +
+ + } + > + + ), + hideMainColumnBody: activeView === "canvas", + isChannelViewActive: activeView === "chat", + mainColumnHeader: + activeView === "chat" || activeView === "canvas" ? ( + selectView("canvas")} + projectId={project.projectAddress} + projectName={project.name} + projectNames={[channel.name, project.name]} + snapshots={canvasSnapshots} + /> + ) : null, + mainContent, + onSelectChannelView: () => selectView("chat"), + }} + > + + + +
+
) : (

@@ -402,41 +650,6 @@ export function ProjectChannelHome({ project={project} projects={projects} /> - - {summaryVisible ? ( - - { - void goChannel(channelId); - }} - onOpenRepository={handleOpenRepository} - onOpenWorkspace={handleOpenWorkspace} - onRepositoryChange={handleRepositoryChange} - project={project} - projects={projects} - /> - - ) : null} -

); diff --git a/desktop/src/features/projects/ui/ProjectChannelResourcesView.tsx b/desktop/src/features/projects/ui/ProjectChannelResourcesView.tsx new file mode 100644 index 00000000000..88cc69363e8 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelResourcesView.tsx @@ -0,0 +1,170 @@ +import { FolderGit2, Hash } from "lucide-react"; +import type * as React from "react"; + +import type { Project } from "@/features/projects/hooks"; +import { listProjectBoundChannels } from "@/features/projects/lib/projectRelatedChannels"; +import type { Channel } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { ProjectChannelIcon } from "./ProjectChannelIcon"; +import { ProjectChannelManagement } from "./ProjectChannelManagement"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; + +const RESOURCE_ROW_CLASS = + "h-11 w-full justify-start gap-3 rounded-none border-b border-border/60 px-1 text-left font-normal"; + +export function ProjectChannelResourcesView({ + channels, + identityPubkey, + onOpenChannel, + onOpenRepository, + onSelectChat, + project, + projects, + relatedChannelIds, + view, +}: { + channels: Channel[]; + identityPubkey?: string; + onOpenChannel: (channelId: string) => void; + onOpenRepository: (repositoryId: string) => void; + onSelectChat: () => void; + project: Project; + projects: Project[]; + relatedChannelIds?: readonly string[]; + view: "channels" | "repos"; +}) { + if (view === "channels") { + const channelsById = new Map( + channels.map((candidate) => [candidate.id, candidate]), + ); + const boundChannels = listProjectBoundChannels({ + ...project, + relatedChannelIds: relatedChannelIds ?? project.relatedChannelIds, + }).flatMap((binding) => { + const channel = channelsById.get(binding.channelId); + return channel ? [{ ...binding, channel }] : []; + }); + + return ( + + } + description="Streams grouped with this project" + testId="project-channel-content-channels" + title="Channels" + > + {boundChannels.length > 0 ? ( + boundChannels.map((binding) => { + const home = binding.role === "home"; + const Icon = home ? ProjectChannelIcon : Hash; + return ( + + ); + }) + ) : ( + No channels are available. + )} + + ); + } + + return ( + + } + description="Repositories related to this channel" + testId="project-channel-content-repos" + title="Repos" + > + {project.repositories.length > 0 ? ( + project.repositories.map((repository) => ( + + )) + ) : ( + + No repositories are related yet. + + )} + + ); +} + +function ResourceViewShell({ + action, + children, + description, + testId, + title, +}: { + action: React.ReactNode; + children: React.ReactNode; + description: string; + testId: string; + title: string; +}) { + return ( +
+
+
+
+

{title}

+

+ {description} +

+
+
{action}
+
+
{children}
+
+
+ ); +} + +function EmptyResourceState({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/desktop/src/features/projects/ui/ProjectChannelTabs.tsx b/desktop/src/features/projects/ui/ProjectChannelTabs.tsx new file mode 100644 index 00000000000..a2467898d6c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelTabs.tsx @@ -0,0 +1,126 @@ +import type { ChannelProjectFeature } from "@/features/projects/channelProjectFeatures"; +import { cn } from "@/shared/lib/cn"; +import * as React from "react"; + +export type ProjectChannelView = + | "chat" + | "canvas" + | "issues" + | "channels" + | "reviews" + | "repos"; + +const PROJECT_CHANNEL_CHAT_TAB = { + label: "Chat", + testId: "project-channel-tab-chat", + value: "chat", +} as const; + +const PROJECT_CHANNEL_EXTRA_TABS: Array<{ + feature?: ChannelProjectFeature; + label: string; + testId: string; + value: Exclude; +}> = [ + { + label: "Canvas", + testId: "project-channel-tab-canvas", + value: "canvas", + }, + { + feature: "tasks", + label: "Tasks", + testId: "project-channel-tab-tasks", + value: "issues", + }, + { + feature: "breakouts", + label: "Channels", + testId: "project-channel-tab-channels", + value: "channels", + }, + { + feature: "reviews", + label: "Reviews", + testId: "project-channel-tab-reviews", + value: "reviews", + }, + { + feature: "repositories", + label: "Repos", + testId: "project-channel-tab-repos", + value: "repos", + }, +]; + +export function projectChannelViewEnabled( + view: ProjectChannelView, + enabledFeatures: Record, +) { + if (view === "chat" || view === "canvas") return true; + const tab = PROJECT_CHANNEL_EXTRA_TABS.find( + (candidate) => candidate.value === view, + ); + return tab?.feature ? enabledFeatures[tab.feature] : false; +} + +export function ProjectChannelTabs({ + activeView, + enabledFeatures, + onSelect, +}: { + activeView: ProjectChannelView; + enabledFeatures: Record; + onSelect: (view: ProjectChannelView) => void; +}) { + const activeTabRef = React.useRef(null); + const extraTabs = PROJECT_CHANNEL_EXTRA_TABS.filter( + (tab) => !tab.feature || enabledFeatures[tab.feature], + ); + const setActiveTabRef = React.useCallback((tab: HTMLButtonElement | null) => { + activeTabRef.current = tab; + tab?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, []); + + React.useEffect(() => { + const revealActiveTab = () => { + activeTabRef.current?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }; + window.addEventListener("resize", revealActiveTab); + return () => window.removeEventListener("resize", revealActiveTab); + }, []); + + if (extraTabs.length === 0) return null; + + const tabs = [PROJECT_CHANNEL_CHAT_TAB, ...extraTabs]; + + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index 0293d1b2b93..9c1d4629290 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, Folders } from "lucide-react"; +import { ChevronRight, Folders, Hash } from "lucide-react"; import type * as React from "react"; import { AppTopChromePortal } from "@/app/AppTopChromePortal"; @@ -15,7 +15,7 @@ export function ProjectDetailChrome({ activeTabCrumb, activeWorkItemCrumb, onGoProjectHome, - onGoProjects, + onGoRootChannel, project, repository, }: { @@ -24,7 +24,7 @@ export function ProjectDetailChrome({ activeTabCrumb: string | null; activeWorkItemCrumb: ProjectDetailWorkItemCrumb | null; onGoProjectHome: () => void; - onGoProjects: () => void; + onGoRootChannel: () => void; project: Project; repository?: Repository | null; }) { @@ -99,11 +99,11 @@ export function ProjectDetailChrome({ > {repositoryCrumb ? ( diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 78c236e676d..6593d431d24 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -48,7 +48,6 @@ import { import { wantsProjectRepositorySurface } from "@/features/projects/lib/projectDetailSearch"; import { hasAuthoritativeHomeBinding } from "@/features/projects/lib/projectHomeChannel"; import { selectProjectRepository } from "@/features/projects/projectModels"; -import { isProjectRelayValidated } from "@/features/projects/projectSnapshot"; import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess"; import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; @@ -64,7 +63,7 @@ import { ProjectDetailChrome } from "./ProjectDetailChrome"; import { ProjectConversationPanelController } from "./ProjectConversationPanelContext"; import { ProjectDetailRightPanel } from "./ProjectDetailRightPanel"; import { ProjectDetailUnavailableState } from "./ProjectDetailUnavailableState"; -import { ProjectChannelHome } from "./ProjectChannelHome"; +import { ProjectHomeChannelRedirect } from "./ProjectHomeChannelRedirect"; import { ProjectRightPanelControls } from "./ProjectRightPanelControls"; import { buildProjectDetailCrumbs } from "./useProjectDetailCrumbs"; import { useProjectDetailPeople } from "./useProjectDetailPeople"; @@ -95,7 +94,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { repositoryId, tab, } = props; - const { goProject, goProjects } = useAppNavigation(); + const { goChannel, goHome, goProject } = useAppNavigation(); const { activeCommunity } = useCommunities(); const projectQuery = useProjectQuery(projectId); const projectsQuery = useProjectsQuery(); @@ -667,7 +666,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { return ( void goProjects()} + onBack={() => void goHome()} onRetry={() => void projectQuery.refetch()} /> ); @@ -676,7 +675,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { return ( void goProjects()} + onBack={() => void goHome()} /> ); } @@ -693,11 +692,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }); if (showChannelHome) { return ( - + ); } if (!repository) { @@ -749,7 +744,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }); const goChannelHome = () => { if (project.projectChannelId) { - void goProject(project.id); + void goChannel(project.projectChannelId); return; } handleGoToProjectHome(); @@ -869,8 +864,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { activeTabCrumb={activeTabCrumb} activeWorkItemCrumb={activeWorkItemCrumb} onGoProjectHome={goChannelHome} - onGoProjects={() => { - void goProjects(); + onGoRootChannel={() => { + const rootChannelId = + project.projectChannelId ?? repository.channelId; + if (rootChannelId) { + void goChannel(rootChannelId); + } else { + void goHome(); + } }} project={project} repository={repository} diff --git a/desktop/src/features/projects/ui/ProjectDetailUnavailableState.tsx b/desktop/src/features/projects/ui/ProjectDetailUnavailableState.tsx index 8090e536be0..6aa63373478 100644 --- a/desktop/src/features/projects/ui/ProjectDetailUnavailableState.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailUnavailableState.tsx @@ -33,7 +33,7 @@ export function ProjectDetailUnavailableState(
@@ -49,7 +49,7 @@ export function ProjectDetailUnavailableState(

); diff --git a/desktop/src/features/projects/ui/ProjectHomeChannelRedirect.tsx b/desktop/src/features/projects/ui/ProjectHomeChannelRedirect.tsx new file mode 100644 index 00000000000..b789bd29a3f --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeChannelRedirect.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +export function ProjectHomeChannelRedirect({ + channelId, +}: { + channelId: string; +}) { + const { goChannel, goHome } = useAppNavigation(); + + React.useEffect(() => { + if (channelId) { + void goChannel(channelId, { replace: true }); + } else { + void goHome({ replace: true }); + } + }, [channelId, goChannel, goHome]); + + return ; +} diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx index 63245ab632c..98691eac0f5 100644 --- a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx @@ -14,6 +14,7 @@ import { presentContextCount } from "@/features/projects/lib/projectHomeSummary" import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; import { listProjectBoundChannels } from "@/features/projects/lib/projectRelatedChannels"; +import type { ChannelProjectFeature } from "@/features/projects/channelProjectFeatures"; import { useProjectActivitySummariesQuery, useProjectRepoSnapshotQuery, @@ -182,6 +183,7 @@ export function ProjectHomeContextPanel({ activeWorkspaceTab, channel, channels = [], + enabledFeatures, identityPubkey, onAddRepository, onOpenChannel, @@ -194,6 +196,7 @@ export function ProjectHomeContextPanel({ activeWorkspaceTab?: ProjectHomeWorkspaceSheetTab | null; channel: Channel | null; channels?: Channel[]; + enabledFeatures: Record; identityPubkey?: string; onAddRepository?: () => void; onOpenChannel?: (channelId: string) => void; @@ -255,143 +258,161 @@ export function ProjectHomeContextPanel({ }, ] : []; + const workspaceEnabled = + enabledFeatures.tasks || + enabledFeatures.reviews || + enabledFeatures.repositories; return (
- - } - onClick={() => openWorkspace("issues")} - pressed={activeWorkspaceTab === "issues"} - testId="project-home-context-tasks" - title={addRepositoryTitle} - > - Tasks - - } - onClick={() => openWorkspace("prs")} - pressed={activeWorkspaceTab === "prs"} - testId="project-home-context-reviews" - title={addRepositoryTitle} - > - Reviews - - } - onClick={() => openWorkspace("commits")} - pressed={activeWorkspaceTab === "commits"} - testId="project-home-context-commits" - title={addRepositoryTitle} - > - Commits - - } - onClick={() => openWorkspace("files")} - pressed={activeWorkspaceTab === "files"} - testId="project-home-context-files" - title={addRepositoryTitle} - > - Files - - } - onClick={() => - firstRepository && - onOpenWorkspace(firstRepository.id, "contributors") - } - pressed={activeWorkspaceTab === "contributors"} - testId="project-home-context-people" - title={addRepositoryTitle} - > - People - - - - } - testId="project-home-context-channel" - title="Channels" - > - {listedChannels.length > 0 ? ( - listedChannels.map((binding) => { - const isHome = binding.role === "home"; - return ( - onOpenChannel(binding.channel.id) - } - projectHome={isHome} - testId={ - isHome - ? "project-home-context-home-channel" - : `project-home-context-channel-${binding.channel.name}` - } - /> - ); - }) - ) : ( -

- }>Unavailable -

- )} -
- - } - testId="project-home-context-codebase" - title="Codebase" - > - {project.repositories.length > 0 ? ( - project.repositories.map((repository) => ( + {workspaceEnabled ? ( + + {enabledFeatures.tasks ? ( } - key={repository.id} - onClick={() => onOpenRepository(repository.id)} - testId={`project-home-context-repo-${repository.dtag}`} + count={presentContextCount(activity?.issueCount)} + disabled={!firstRepository && !onAddRepository} + icon={} + onClick={() => openWorkspace("issues")} + pressed={activeWorkspaceTab === "issues"} + testId="project-home-context-tasks" + title={addRepositoryTitle} > - {repository.name} + Tasks - )) - ) : ( -

- None yet -

- )} -
+ ) : null} + {enabledFeatures.reviews ? ( + } + onClick={() => openWorkspace("prs")} + pressed={activeWorkspaceTab === "prs"} + testId="project-home-context-reviews" + title={addRepositoryTitle} + > + Reviews + + ) : null} + {enabledFeatures.repositories ? ( + <> + } + onClick={() => openWorkspace("commits")} + pressed={activeWorkspaceTab === "commits"} + testId="project-home-context-commits" + title={addRepositoryTitle} + > + Commits + + } + onClick={() => openWorkspace("files")} + pressed={activeWorkspaceTab === "files"} + testId="project-home-context-files" + title={addRepositoryTitle} + > + Files + + } + onClick={() => + firstRepository && + onOpenWorkspace(firstRepository.id, "contributors") + } + pressed={activeWorkspaceTab === "contributors"} + testId="project-home-context-people" + title={addRepositoryTitle} + > + People + + + ) : null} +
+ ) : null} + {enabledFeatures.breakouts ? ( + + } + testId="project-home-context-channel" + title="Channels" + > + {listedChannels.length > 0 ? ( + listedChannels.map((binding) => { + const isHome = binding.role === "home"; + return ( + onOpenChannel(binding.channel.id) + } + projectHome={isHome} + testId={ + isHome + ? "project-home-context-home-channel" + : `project-home-context-channel-${binding.channel.name}` + } + /> + ); + }) + ) : ( +

+ }>Unavailable +

+ )} +
+ ) : null} + {enabledFeatures.repositories ? ( + + } + testId="project-home-context-codebase" + title="Codebase" + > + {project.repositories.length > 0 ? ( + project.repositories.map((repository) => ( + } + key={repository.id} + onClick={() => onOpenRepository(repository.id)} + testId={`project-home-context-repo-${repository.dtag}`} + > + {repository.name} + + )) + ) : ( +

+ None yet +

+ )} +
+ ) : null}
); } diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx index c31db004e55..56fdf14fc66 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -31,6 +31,7 @@ export function ProjectRepositoryManagement({ project, projects, repository, + showAccessManagement = true, }: { compact?: boolean; createOpen?: boolean; @@ -41,6 +42,7 @@ export function ProjectRepositoryManagement({ project: Project; projects: Project[]; repository?: Repository | null; + showAccessManagement?: boolean; }) { const [uncontrolledCreateOpen, setUncontrolledCreateOpen] = React.useState(false); @@ -185,7 +187,7 @@ export function ProjectRepositoryManagement({ ) : null} - {canManageAccess ? ( + {showAccessManagement && canManageAccess ? ( + + Open Canvas files + + + + + + Reload Canvas + +
+ ) : null} + {descriptor && loadError ? ( +
+
+ + {loadError} + +
+
+ ) : null} + {descriptor ? ( +
+ Local Canvas +
+ ) : null} + + ); +} + +function CanvasFailure({ + message, + onReload, +}: { + message: string; + onReload: () => void; +}) { + return ( +
+ +
+

Canvas could not load

+

{message}

+
+ +
+ ); +} + +function ProjectCanvasFrame({ + dataUpdate, + descriptor, + mode, + onFailure, + onRendered, + projectId, + projectName, + projectNames, + snapshots, +}: { + dataUpdate: ProjectCanvasPendingUpdates["data"]; + descriptor: ProjectCanvasPackageDescriptor; + mode: ProjectCanvasMode; + onFailure: (loadId: string, message: string) => void; + onRendered: (loadId: string) => void; + projectId: string; + projectName: string; + projectNames: readonly string[]; + snapshots: ProjectCanvasSnapshots; +}) { + const frameRef = React.useRef(null); + const portRef = React.useRef(null); + const modeRef = React.useRef(mode); + const snapshotsRef = React.useRef(snapshots); + const projectNameRef = React.useRef(projectName); + const projectNamesRef = React.useRef(projectNames); + const connectedRef = React.useRef(false); + const loadCountRef = React.useRef(0); + const lastSnapshotsJsonRef = React.useRef(null); + const lastWidgetDataNotificationRef = React.useRef(null); + const [connected, setConnected] = React.useState(false); + const [rendered, setRendered] = React.useState(false); + const [failed, setFailed] = React.useState(false); + const [frameSource, setFrameSource] = React.useState(); + + modeRef.current = mode; + snapshotsRef.current = snapshots; + projectNameRef.current = projectName; + projectNamesRef.current = projectNames; + + const fail = React.useCallback( + (message: string) => { + connectedRef.current = false; + portRef.current?.close(); + portRef.current = null; + setConnected(false); + setRendered(false); + setFailed(true); + setFrameSource(undefined); + void releaseProjectCanvasPackage(descriptor.loadId).catch(() => {}); + onFailure(descriptor.loadId, message); + }, + [descriptor.loadId, onFailure], + ); + + React.useLayoutEffect(() => { + const frameWindow = frameRef.current?.contentWindow; + if (!frameWindow) { + fail("Canvas frame could not be created."); + return; + } + + const capabilities = grantedProjectCanvasCapabilities( + descriptor.capabilities, + ); + const rateLimiter = new ProjectCanvasMessageRateLimiter(); + let invalidMessageCount = 0; + let handshakeComplete = false; + let renderAcknowledged = false; + let stopped = false; + let timeoutId = 0; + const stop = (message: string) => { + if (stopped) return; + stopped = true; + window.clearTimeout(timeoutId); + fail(message); + }; + timeoutId = window.setTimeout(() => { + stop("Canvas did not complete its secure handshake and render."); + }, PROJECT_CANVAS_HANDSHAKE_TIMEOUT_MS); + + const handleReady = (event: MessageEvent) => { + if (stopped) return; + if (event.source !== frameWindow) return; + if ( + typeof event.data !== "object" || + event.data === null || + !("type" in event.data) || + event.data.type !== "canvas.ready" + ) { + return; + } + if (!parseProjectCanvasReady(event.data, descriptor.nonce)) { + stop("Canvas sent an invalid handshake."); + return; + } + if (handshakeComplete || connectedRef.current) { + stop("Canvas attempted to reconnect unexpectedly."); + return; + } + + const channel = new MessageChannel(); + const grantedSnapshots = selectGrantedProjectCanvasSnapshots( + snapshotsRef.current, + capabilities, + ); + const initMessage = { + canvasId: projectId, + capabilities, + data: descriptor.data, + loadId: descriptor.loadId, + mode: modeRef.current, + nonce: descriptor.nonce, + project: { + displayName: projectNameRef.current, + id: projectId, + name: projectNameRef.current, + names: [...projectNamesRef.current].slice(0, 8), + }, + protocolVersion: PROJECT_CANVAS_PROTOCOL_VERSION, + snapshots: grantedSnapshots, + type: "host.init", + } as const; + if ( + !isMessageWithinSizeLimit( + initMessage, + PROJECT_CANVAS_MAX_INIT_MESSAGE_BYTES, + ) + ) { + channel.port1.close(); + channel.port2.close(); + stop("Canvas initialization exceeds the host size limit."); + return; + } + + channel.port1.addEventListener("message", (portEvent) => { + if (!rateLimiter.accept(performance.now())) { + stop("Canvas exceeded the host message rate limit."); + return; + } + const message = parseProjectCanvasChildMessage(portEvent.data, { + loadId: descriptor.loadId, + nonce: descriptor.nonce, + }); + if (!message) { + invalidMessageCount += 1; + if (invalidMessageCount >= MAX_INVALID_PORT_MESSAGES) { + stop("Canvas sent repeated invalid messages."); + } + return; + } + invalidMessageCount = 0; + if (message.type === "canvas.rendered") { + if (renderAcknowledged) { + stop("Canvas reported completion more than once."); + return; + } + renderAcknowledged = true; + void commitProjectCanvasPackage(descriptor.loadId) + .then(() => { + if (stopped) return; + window.clearTimeout(timeoutId); + setRendered(true); + onRendered(descriptor.loadId); + }) + .catch((error: unknown) => { + stop(errorMessage(error)); + }); + } + // canvas.rendered is the only child-to-host message in this POC. + }); + channel.port1.addEventListener("messageerror", () => { + stop("Canvas sent an unreadable message."); + }); + channel.port1.start(); + portRef.current = channel.port1; + + frameWindow.postMessage( + { + loadId: descriptor.loadId, + nonce: descriptor.nonce, + protocolVersion: PROJECT_CANVAS_PROTOCOL_VERSION, + type: "host.connect", + }, + "*", + [channel.port2], + ); + channel.port1.postMessage(initMessage); + lastSnapshotsJsonRef.current = JSON.stringify(grantedSnapshots); + handshakeComplete = true; + connectedRef.current = true; + setConnected(true); + }; + + window.addEventListener("message", handleReady); + setFrameSource(descriptor.url); + return () => { + stopped = true; + handshakeComplete = true; + window.clearTimeout(timeoutId); + window.removeEventListener("message", handleReady); + connectedRef.current = false; + portRef.current?.close(); + portRef.current = null; + }; + }, [descriptor, fail, onRendered, projectId]); + + React.useEffect(() => { + if (!connectedRef.current || !portRef.current) return; + portRef.current.postMessage({ + loadId: descriptor.loadId, + mode, + nonce: descriptor.nonce, + protocolVersion: PROJECT_CANVAS_PROTOCOL_VERSION, + type: "host.mode", + }); + }, [descriptor.loadId, descriptor.nonce, mode]); + + React.useEffect(() => { + const port = portRef.current; + if (!connected || !port) return; + const capabilities = grantedProjectCanvasCapabilities( + descriptor.capabilities, + ); + const grantedSnapshots = selectGrantedProjectCanvasSnapshots( + snapshots, + capabilities, + ); + const serialized = JSON.stringify(grantedSnapshots); + if (serialized === lastSnapshotsJsonRef.current) return; + const message = { + loadId: descriptor.loadId, + nonce: descriptor.nonce, + protocolVersion: PROJECT_CANVAS_PROTOCOL_VERSION, + snapshots: grantedSnapshots, + type: "host.dataChanged", + } as const; + if ( + !isMessageWithinSizeLimit(message, PROJECT_CANVAS_MAX_INIT_MESSAGE_BYTES) + ) { + fail("Canvas data update exceeds the host size limit."); + return; + } + port.postMessage(message); + lastSnapshotsJsonRef.current = serialized; + }, [connected, descriptor, fail, snapshots]); + + React.useEffect(() => { + const port = portRef.current; + if ( + !connected || + !port || + !dataUpdate || + dataUpdate.notificationId === lastWidgetDataNotificationRef.current + ) { + return; + } + const message = { + data: dataUpdate.data, + loadId: descriptor.loadId, + nonce: descriptor.nonce, + notificationId: dataUpdate.notificationId, + protocolVersion: PROJECT_CANVAS_PROTOCOL_VERSION, + type: "host.widgetDataChanged", + widgetId: dataUpdate.widgetId, + } as const; + if ( + !isMessageWithinSizeLimit(message, PROJECT_CANVAS_MAX_INIT_MESSAGE_BYTES) + ) { + fail("Canvas widget data update exceeds the host size limit."); + return; + } + port.postMessage(message); + lastWidgetDataNotificationRef.current = dataUpdate.notificationId; + }, [connected, dataUpdate, descriptor.loadId, descriptor.nonce, fail]); + + return ( +
+ {!failed ? ( +