diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 6cee0b603d6..b17121667a7 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -9,7 +9,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` | @@ -40,6 +40,19 @@ A project is a named grouping (`kind:30621`) with a home channel. Creating a sec 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. +- Live project data is authoritative and comes through the host SDK at `window.buzzCanvas.sdk` (loaded before every package script). Render from `sdk.data.liveQuery(name, params, onUpdate)` (returns a stop function; call it before re-subscribing) or one-shot `sdk.data.query(name, params)`; results are `{status: "loading"|"ready"|"error", data}`. Queries: `project.metadata`, `project.channels.list`, `project.reviews.list`, `project.tasks.list`, `project.tasks.get`, `people.lookup` (≤32 pubkeys), `people.search`. Mutations use `sdk.data.command(name, params)`: `tasks.setStatus` (`{id, status: "open"|"done"|"closed"|"draft"}`), `tasks.assign`/`tasks.unassign` (`{id, assignee?}`, defaulting to the viewing user), and `dm.send` (`{pubkey, message}`, ≤2000 chars — sends a direct message as the viewing user). Navigation uses `sdk.app.open({type: "channel"|"task"|"review", id})` or `{type: "user", pubkey}`. All data is scoped to the hosting project; packages cannot widen it. Change values through Buzz rather than hardcoding demo rows. +- Every query maps to a manifest capability: `project.metadata.read`, `project.channels.read`, `project.reviews.read`, `project.tasks.read`, `project.people.read`, plus `project.tasks.write` for task commands, `app.open` for navigation, and `app.dm.send` for `dm.send`. Check `sdk.capabilities()` and render a fallback state when one is missing. `project.tasks.write`, `app.open`, and `app.dm.send` additionally require a one-time user approval per package revision; denial keeps read queries working. +- Widget placement and size the user changes by dragging, nudging, or resizing are persisted by the host, not by the package: call `sdk.layout.save({dashboard, pan, widgets, sizes})` after a drag, nudge, resize, pan, or reset, passing only the widgets that differ from their `data/*.json` position in `widgets` and from their `data/*.json` size in `sizes` (`{width, height}` per widget id; `pan: null` when it is the default). Position and size overrides are independent — resizing a widget must not add a position entry for it. The host replays them as `layouts` on `host.init`, keyed by dashboard and widget id, so seed positions and sizes from `layouts[dashboardId]` before falling back to the package. It needs no capability, sends are debounced, and untouched widgets keep following the package, so a later revision can still move or resize them. +- Budgets: ≤16 concurrent live queries, ≤10 commands per minute, ≤3 opens per 10 seconds, 64 KiB per message. A violation fails that one request with `error.code === "rate-limited"` — it never kills the canvas. +- Prefer the standard components `sdk.ui.avatar({name, pubkey, agent, size})`, `sdk.ui.reviewRow({review, onOpen})`, and `sdk.ui.channelRow({channel, onOpen})`, themed by host `--buzz-*` CSS variables, so people and rows render consistently with the app. Pass a person's `pubkey` to `sdk.ui.avatar` and the frame loads their real picture from the host, uncapped; the legacy `avatarUrl` data URL still works but only a few fit in one response, so prefer `pubkey`. An unknown person falls back to initials on their own. `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 af504a11768..14dcae6f046 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -5119,6 +5119,33 @@ 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/`")); + } + + #[test] + fn shared_base_prompt_documents_the_canvas_sdk_surface() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("window.buzzCanvas.sdk")); + assert!(prompt.contains("sdk.data.liveQuery")); + assert!(prompt.contains("tasks.setStatus")); + assert!(prompt.contains("sdk.capabilities()")); + assert!(prompt.contains("project.tasks.write")); + assert!(prompt.contains("`app.open`")); + assert!(prompt.contains("`dm.send`")); + assert!(prompt.contains("`app.dm.send`")); + assert!(prompt.contains("rate-limited")); + assert!(prompt.contains("sdk.ui.avatar")); + assert!(prompt.contains("sdk.layout.save({dashboard, pan, widgets, sizes})")); + assert!(prompt.contains("`layouts`")); + assert!(prompt.contains("resizing a widget must not add a position entry")); + } } 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 701c767b329..00c99fc673f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -29,6 +29,9 @@ export default defineConfig({ "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", + "**/channel-project-features.spec.ts", + "**/project-channel-canvas.spec.ts", + "**/project-canvas-layout.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/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7a3669f440e..71a3c10525b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1111,6 +1111,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "image", + "include_dir", "infer", "iroh", "keyring", @@ -4260,6 +4261,25 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0bc32817881..43bd786aaf1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -81,6 +81,11 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" infer = "0.19" hex = "0.4" +# Compile-time embedding of the Project Canvas seed template. Keep the +# `cargo:rerun-if-changed` line in build.rs alongside it: `include_dir!` expands +# to `include_bytes!` per file, so rustc notices edits to existing files but not +# files being added or removed. +include_dir = "0.7" ed25519-dalek = "=3.0.0-rc.0" tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "net", "io-util"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 8b0e63f12bc..fed9c774c3e 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -20,6 +20,11 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DEMO_SLUG"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + // `include_dir!` embeds the Project Canvas seed template through one + // `include_bytes!` per file, so rustc already tracks edits to existing + // files — but not a file being added or removed. Without this the binary + // can silently ship a stale file list. + println!("cargo:rerun-if-changed=resources/project-canvas-template"); if let Ok(slug) = std::env::var("BUZZ_BUILD_DEMO_SLUG") { let valid = !slug.is_empty() 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-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..7fd2e989cce --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/canvas.js @@ -0,0 +1,647 @@ +(() => { + const PROTOCOL_VERSION = 1; + const INTERACTIVE_SELECTOR = + "a,button,input,label,select,textarea,video[controls],[role='button'],[data-no-drag]"; + const DEFAULT_PAN = { x: 24, y: 24 }; + const GRID = 24; + const MIN_WIDGET_WIDTH = 192; + const MIN_WIDGET_HEIGHT = 144; + 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, + sizes: new Map(), + snapshots: null, + translation: { ...DEFAULT_PAN }, + }; + + 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 || {}; + } + 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); + const stored = storedLayout(message.layouts, state.dashboard.id); + state.translation = sanitizePoint(stored?.pan) || { ...DEFAULT_PAN }; + state.positions.clear(); + state.sizes.clear(); + for (const widget of state.dashboard.widgets) { + const override = sanitizePoint(stored?.widgets?.[widget.id]); + state.positions.set(widget.id, override || { ...widget.position }); + const sizeOverride = sanitizeSize(stored?.sizes?.[widget.id]); + state.sizes.set(widget.id, sizeOverride || { ...widget.size }); + } + 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 storedLayout(layouts, dashboardId) { + if (!layouts || typeof layouts !== "object") return null; + const layout = layouts[dashboardId]; + return layout && typeof layout === "object" ? layout : null; + } + + function sanitizePoint(point) { + if (!point || typeof point !== "object") return null; + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null; + return { x: point.x, y: point.y }; + } + + function sanitizeSize(size) { + if (!size || typeof size !== "object") return null; + if (!Number.isFinite(size.width) || !Number.isFinite(size.height)) { + return null; + } + if (size.width < MIN_WIDGET_WIDTH || size.height < MIN_WIDGET_HEIGHT) { + return null; + } + return { width: size.width, height: size.height }; + } + + // Persist only what the user changed: widgets still sitting on their package + // default are left out, so a later package revision can still move or + // resize them. Position and size overrides are independent for the same + // reason — resizing a widget must not pin where the package put it. + function saveLayout() { + if (!state.dashboard || !runtime.sdk?.layout) return; + const widgets = {}; + const sizes = {}; + for (const widget of state.dashboard.widgets) { + const position = state.positions.get(widget.id); + const fallback = sanitizePoint(widget.position) || { x: 0, y: 0 }; + if ( + position && + (position.x !== fallback.x || position.y !== fallback.y) + ) { + widgets[widget.id] = { x: position.x, y: position.y }; + } + const size = state.sizes.get(widget.id); + if ( + size && + (size.width !== widget.size.width || size.height !== widget.size.height) + ) { + sizes[widget.id] = { width: size.width, height: size.height }; + } + } + const pan = { + x: Math.round(state.translation.x), + y: Math.round(state.translation.y), + }; + runtime.sdk.layout.save({ + dashboard: state.dashboard.id, + pan: pan.x === DEFAULT_PAN.x && pan.y === DEFAULT_PAN.y ? null : pan, + sizes, + widgets, + }); + } + + 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 size = state.sizes.get(widget.id); + const group = element("div", "widget-group"); + group.dataset.widgetId = widget.id; + 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, renderResizeHandle(widget)); + applyWidgetSize(group, size); + + const companion = renderCompanion(widget); + if (companion) group.append(companion); + return group; + } + + function renderResizeHandle(widget) { + const handle = element("button", "resize-handle", { + ariaLabel: `Resize ${widget.title} widget`, + testId: `project-canvas-widget-${widget.id}-resize`, + title: "Resize widget (drag or arrow keys)", + type: "button", + }); + handle.addEventListener("pointerdown", (event) => + startWidgetResize(event, widget), + ); + handle.addEventListener("keydown", (event) => resizeWidget(event, widget)); + return handle; + } + + 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: "↗", + tasks: "☑", + }[type] || "•" + ); + } + + function renderWidgetContent(widget) { + const renderer = widgetRenderers[widget.type]; + const content = element("div", "widget-content"); + if (renderer) content.append(renderWith(renderer, widget.data)); + 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 = currentWidget.data; + state.data = data; + currentWidget.data = nextWidget.data; + const nextData = currentWidget.data; + 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 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"); + saveLayout(); + }, + ); + } + + 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"); + saveLayout(); + }, + ); + } + + 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); + saveLayout(); + } + + function startWidgetResize(event, widget) { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const group = event.currentTarget.parentElement; + const start = { x: event.clientX, y: event.clientY }; + const origin = { ...state.sizes.get(widget.id) }; + group.classList.add("active", "resizing"); + trackPointer( + event, + (point) => { + const next = clampSize({ + width: origin.width + point.x - start.x, + height: origin.height + point.y - start.y, + }); + state.sizes.set(widget.id, next); + applyWidgetSize(group, next); + }, + () => { + const snapped = snapSize(state.sizes.get(widget.id)); + state.sizes.set(widget.id, snapped); + applyWidgetSize(group, snapped); + group.classList.remove("resizing"); + saveLayout(); + }, + ); + } + + function resizeWidget(event, widget) { + const amount = event.shiftKey ? 48 : 24; + const delta = { + ArrowDown: { width: 0, height: amount }, + ArrowLeft: { width: -amount, height: 0 }, + ArrowRight: { width: amount, height: 0 }, + ArrowUp: { width: 0, height: -amount }, + }[event.key]; + if (!delta) return; + event.preventDefault(); + const current = state.sizes.get(widget.id); + const next = snapSize({ + width: current.width + delta.width, + height: current.height + delta.height, + }); + state.sizes.set(widget.id, next); + applyWidgetSize(event.currentTarget.parentElement, next); + saveLayout(); + } + + function snapPoint(point) { + return { + x: Math.round(point.x / GRID) * GRID, + y: Math.round(point.y / GRID) * GRID, + }; + } + + // The minimums are grid multiples, so snapping then clamping stays on grid. + function clampSize(size) { + return { + width: Math.max(MIN_WIDGET_WIDTH, size.width), + height: Math.max(MIN_WIDGET_HEIGHT, size.height), + }; + } + + function snapSize(size) { + return clampSize({ + width: Math.round(size.width / GRID) * GRID, + height: Math.round(size.height / GRID) * GRID, + }); + } + + function moveWidgetGroup(group, position) { + group.style.transform = `translate3d(${position.x}px, ${position.y}px, 0)`; + } + + function applyWidgetSize(group, size) { + group.style.width = `${size.width}px`; + group.style.height = `${size.height}px`; + const article = group.querySelector(".widget"); + if (!article) return; + article.dataset.worldWidth = String(Math.round(size.width)); + article.dataset.worldHeight = String(Math.round(size.height)); + } + + 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 layout", + testId: "project-widget-canvas-reset", + title: "Reset canvas layout", + type: "button", + }); + button.append(icon("⌖")); + button.addEventListener("click", resetLayout); + return button; + } + + // Always reachable recovery: restores the package's pan and every widget + // position and size, then clears the stored overrides. Widget elements are + // moved in place rather than re-rendered so live subscriptions survive the + // reset. + function resetLayout() { + state.translation = { ...DEFAULT_PAN }; + const groups = new Map( + [...root.querySelectorAll("[data-widget-id]")].map((group) => [ + group.dataset.widgetId, + group, + ]), + ); + for (const widget of state.dashboard.widgets) { + const position = sanitizePoint(widget.position) || { x: 0, y: 0 }; + state.positions.set(widget.id, position); + const size = { ...widget.size }; + state.sizes.set(widget.id, size); + const group = groups.get(widget.id); + if (!group) continue; + moveWidgetGroup(group, position); + applyWidgetSize(group, size); + const article = group.querySelector(".widget"); + if (!article) continue; + article.dataset.worldX = String(position.x); + article.dataset.worldY = String(position.y); + } + const canvas = root.querySelector("[data-testid='project-widget-canvas']"); + if (canvas) { + updateTranslationData(canvas); + updateWorldTransform(canvas.querySelector(".canvas-world")); + } + saveLayout(); + } + + 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..55cfe06067a --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/data/dashboards.json @@ -0,0 +1,280 @@ +{ + "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": {} + }, + { + "id": "reviews", + "type": "reviews", + "title": "Reviews", + "position": { "x": 384, "y": 0 }, + "size": { "width": 456, "height": 320 }, + "data": {} + }, + { + "id": "tasks", + "type": "tasks", + "title": "Tasks", + "position": { "x": 0, "y": 384 }, + "size": { "width": 336, "height": 288 }, + "data": {} + }, + { + "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..010bdfc4790 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/manifest.json @@ -0,0 +1,28 @@ +{ + "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", + "project.tasks.read", + "project.people.read", + "project.tasks.write", + "app.open", + "app.dm.send" + ] +} 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..ff97c960feb --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/styles/base.css @@ -0,0 +1,278 @@ +: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; +} + +.resize-handle { + background: transparent; + border: 0; + bottom: 2px; + color: #98a2b3; + cursor: nwse-resize; + height: 18px; + opacity: 0; + padding: 0; + position: absolute; + right: 2px; + touch-action: none; + width: 18px; + z-index: 20; +} + +.resize-handle::after { + border-bottom: 2px solid currentColor; + border-bottom-right-radius: 5px; + border-right: 2px solid currentColor; + bottom: 3px; + content: ""; + height: 9px; + position: absolute; + right: 3px; + width: 9px; +} + +.widget-group:hover .resize-handle, +.widget-group:focus-within .resize-handle { + opacity: 1; +} + +.resize-handle:focus-visible { + border-radius: 5px; + opacity: 1; + outline: 2px solid rgba(37, 99, 235, 0.6); + outline-offset: -1px; +} + +.widget-group.resizing .resize-handle { + color: #475467; + opacity: 1; +} + +.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..c5c9d0d993e --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/styles/dev-team.css @@ -0,0 +1,262 @@ +.active-channels { + display: flex; + flex-direction: column; + gap: 6px; + height: 100%; + overflow: auto; + padding: 8px; +} + +.reviews, +.tasks, +.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; +} + +.reviews .buzz-review-row, +.active-channels .buzz-channel-row { + flex: none; +} + +.reviews .buzz-review-row { + margin-top: 6px; +} + +.task-row { + align-items: center; + border-bottom: 1px solid #e7eaf0; + display: flex; + gap: 8px; + padding: 7px 0; +} + +.task-row:last-child { + border-bottom: 0; +} + +.task-summary { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.task-id { + color: #0369a1; + font-size: 0.625rem; + font-weight: 700; +} + +.task-title { + font-size: 0.72rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-actions { + display: flex; + flex: 0 0 auto; + gap: 5px; +} + +.status-pill { + align-items: center; + background: #e8f5ff; + border-radius: 5px; + color: #0369a1; + display: inline-flex; + flex: none; + font-size: 0.625rem; + font-weight: 700; + padding: 4px 7px; +} + +.status-pill[data-status="done"] { + background: #e4f8ed; + color: #047857; +} + +.status-pill[data-status="closed"] { + 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..3d07a51d812 --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/widgets/dev-team.js @@ -0,0 +1,383 @@ +(() => { + window.buzzCanvasWidgets = window.buzzCanvasWidgets || {}; + window.buzzCanvasWidgets.devTeam = { + renderers: { + activeChannels: renderActiveChannels, + clientTime: renderClientTime, + meetings: renderMeetings, + reviews: renderReviews, + tasks: renderTasks, + }, + }; + + // One live subscription per widget type: re-rendering a widget replaces its + // subscription instead of leaking the previous one. + const liveStops = new Map(); + + function sdkApi() { + const sdk = window.buzzCanvas?.sdk; + return sdk?.data && typeof sdk.data.liveQuery === "function" ? sdk : null; + } + + function startLiveList(options, element) { + const previousStop = liveStops.get(options.key); + if (previousStop) previousStop(); + const sdk = sdkApi(); + if (!sdk?.capabilities().includes(options.capability)) { + liveStops.delete(options.key); + options.container.replaceChildren( + renderSnapshotState("unavailable", options.noun, element), + ); + return; + } + options.container.replaceChildren( + renderSnapshotState("loading", options.noun, element), + ); + const stop = sdk.data.liveQuery(options.query, options.params, (result) => { + if (!result || result.status === "loading") { + options.container.replaceChildren( + renderSnapshotState("loading", options.noun, element), + ); + return; + } + if (result.status === "error") { + options.container.replaceChildren( + renderSnapshotState("error", options.noun, element), + ); + return; + } + const rows = Array.isArray(result.data) ? result.data : []; + if (rows.length === 0) { + options.container.replaceChildren( + renderSnapshotState("empty", options.noun, element), + ); + return; + } + options.container.replaceChildren(); + options.render(rows, options.container, sdk); + }); + liveStops.set(options.key, stop); + } + + function renderActiveChannels(_data, api) { + const { element } = api; + const list = element("div", "active-channels", { + testId: "project-canvas-active-channels", + }); + startLiveList( + { + capability: "project.channels.read", + container: list, + key: "activeChannels", + noun: "channels", + params: {}, + query: "project.channels.list", + render: (channels, container, sdk) => { + for (const channel of channels) { + container.append(sdk.ui.channelRow({ channel })); + } + }, + }, + element, + ); + return list; + } + + function renderReviews(_data, api) { + const { element } = api; + const section = element("section", "reviews", { + ariaLabel: "Reviews waiting on the team", + testId: "project-canvas-reviews", + }); + startLiveList( + { + capability: "project.reviews.read", + container: section, + key: "reviews", + noun: "reviews", + params: { status: "Open" }, + query: "project.reviews.list", + render: (reviews, container, sdk) => { + const intro = element("header", "reviews-intro"); + intro.append( + element("div", "", { text: "Waiting on review" }), + element("strong", "count-badge", { + text: `${reviews.length} open`, + }), + ); + container.append(intro); + for (const review of reviews) { + container.append(sdk.ui.reviewRow({ review })); + } + }, + }, + element, + ); + return section; + } + + function renderTasks(_data, api) { + const { element } = api; + const section = element("section", "tasks", { + ariaLabel: "Project tasks", + testId: "project-canvas-tasks", + }); + startLiveList( + { + capability: "project.tasks.read", + container: section, + key: "tasks", + noun: "tasks", + params: { limit: 8 }, + query: "project.tasks.list", + render: (tasks, container, sdk) => { + for (const task of tasks) { + container.append(renderTaskRow(task, sdk, element)); + } + }, + }, + element, + ); + return section; + } + + function renderTaskRow(task, sdk, element) { + const title = String(task.title || "Untitled task"); + const status = String(task.status || "Triage"); + const row = element("article", "task-row", { + testId: `project-canvas-task-${task.displayId || task.id.slice(0, 8)}`, + }); + const summary = element("div", "task-summary"); + summary.append( + element("span", "task-id", { text: task.displayId }), + element("strong", "task-title", { text: title }), + ); + const pill = element("span", "status-pill", { text: status }); + pill.dataset.status = status.toLowerCase().replaceAll(" ", "-"); + row.append(summary, pill); + + const actions = element("div", "task-actions"); + if (sdk.capabilities().includes("app.open")) { + actions.append( + taskActionButton(element, `Open ${title}`, "Open", () => + sdk.app.open({ id: task.id, type: "task" }), + ), + ); + } + if (sdk.capabilities().includes("project.tasks.write")) { + const finished = status === "Done" || status === "Closed"; + actions.append( + taskActionButton( + element, + finished ? `Reopen ${title}` : `Mark ${title} done`, + finished ? "Reopen" : "Mark done", + () => + sdk.data.command("tasks.setStatus", { + id: task.id, + status: finished ? "open" : "done", + }), + ), + ); + if (!finished && (task.assignees || []).length === 0) { + actions.append( + taskActionButton( + element, + `Assign ${title} to me`, + "Assign to me", + () => sdk.data.command("tasks.assign", { id: task.id }), + ), + ); + } + } + if (actions.childElementCount > 0) row.append(actions); + return row; + } + + function taskActionButton(element, ariaLabel, label, run) { + const button = element("button", "small-button", { + ariaLabel, + text: label, + type: "button", + }); + button.addEventListener("click", () => { + button.disabled = true; + // Failures surface through the host's command toast; re-enable so the + // action stays retryable. + run() + .catch(() => {}) + .finally(() => { + button.disabled = false; + }); + }); + return button; + } + + 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..11099ae8bbb --- /dev/null +++ b/desktop/src-tauri/resources/project-canvas-template/widgets/support.js @@ -0,0 +1,186 @@ +(() => { + 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", + maxlength: "1900", + 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); + function showOutcome(title, detail) { + const success = element("div", "bug-success", { + testId: "project-canvas-support-bug-success", + }); + success.append( + icon("✓", "success-circle"), + element("h4", "", { text: title }), + element("p", "muted", { text: detail }), + ); + form.replaceChildren(header, success); + } + form.addEventListener("submit", (event) => { + event.preventDefault(); + const report = textarea.value.trim(); + if (!report) return; + const sdk = window.buzzCanvas?.sdk; + const canDm = + !!sdk?.data && + sdk.capabilities().includes("app.dm.send") && + sdk.capabilities().includes("project.metadata.read"); + if (!canDm) { + showOutcome( + "Report staged", + "We'll check for matching issues before filing.", + ); + return; + } + submit.disabled = true; + sdk.data + .query("project.metadata") + .then((result) => { + const owner = result?.data ? String(result.data.owner || "") : ""; + if (!/^[0-9a-f]{64}$/i.test(owner)) { + throw new Error("Project owner is unavailable."); + } + return sdk.data.command("dm.send", { + message: `Support report: ${report}`, + pubkey: owner, + }); + }) + .then(() => { + showOutcome( + "Report sent", + "Delivered to the project owner as a direct message.", + ); + }) + .catch(() => { + // Failures surface through the host's command toast; re-enable so + // the report stays retryable. + submit.disabled = false; + }); + }); + 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 fe2bba5024b..80a8c03f4c3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -36,6 +36,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; @@ -135,7 +136,19 @@ 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(|webview, url| { + project_canvas_package::allow_webview_navigation( + url, + webview.config().build.dev_url.as_ref(), + ) + }) + .build(), + ); + let builder = builder .plugin( tauri_plugin_window_state::Builder::default() // Visibility is excluded: the native reveal plugin below @@ -221,6 +234,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()) @@ -234,6 +250,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")] @@ -374,6 +391,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 @@ -637,6 +659,14 @@ 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::publish_project_canvas_avatars, + 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 46f36212cea..345ecd0a44f 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 7d54c5a7b07..d5869b54893 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..3576156c52d 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,29 @@ 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. + +### Canvas SDK + +Package scripts run against a host SDK at `window.buzzCanvas.sdk`, loaded before every package script. Use it instead of bundling fixture rows: + +- `sdk.data.query(name, params)` / `sdk.data.liveQuery(name, params, onUpdate)` — one-shot or live reads returning `{status: "loading"|"ready"|"error", data}`. Live queries return a stop function; stop the old one before re-subscribing. Queries: `project.metadata`, `project.channels.list`, `project.reviews.list`, `project.tasks.list`, `project.tasks.get`, `people.lookup` (≤32 pubkeys), `people.search`. +- `sdk.data.command(name, params)` — `tasks.setStatus` (`{id, status: "open"|"done"|"closed"|"draft"}`), `tasks.assign`/`tasks.unassign` (`{id, assignee?}`, assignee defaults to the viewing user), `dm.send` (`{pubkey, message}`, ≤2000 chars — sends a direct message as the viewing user). +- `sdk.app.open(target)` — `{type: "channel"|"task"|"review", id}` or `{type: "user", pubkey}`. +- `sdk.layout.save({dashboard, pan, widgets, sizes})` — persists the user's widget arrangement for one dashboard. Send only the widgets that differ from their `data/*.json` position in `widgets` and from their `data/*.json` size in `sizes` (`{width, height}` per widget id), with `pan: null` when it matches the package default; position and size overrides are independent, and the host replays them as `layouts` on `host.init`. No capability, debounced, and a wholesale replace per dashboard. +- `sdk.ui.avatar/reviewRow/channelRow` — standard components themed by host `--buzz-*` CSS variables. Give `sdk.ui.avatar` a `pubkey` and the frame loads that person's real picture from the host — any number of them, since the image never travels in an RPC message. `avatarUrl` (a `data:` URL from a people row) still renders, but only a handful fit in one response. Either way a person with no picture falls back to their initials. +- `sdk.capabilities()` — the granted subset of the manifest capabilities (`project.metadata.read`, `project.channels.read`, `project.reviews.read`, `project.tasks.read`, `project.people.read`, `project.tasks.write`, `app.open`, `app.dm.send`). Render a fallback when a capability is missing; `project.tasks.write`, `app.open`, and `app.dm.send` need a one-time user approval per package revision. + +Budgets: ≤16 concurrent live queries, ≤10 commands/minute, ≤3 opens/10s, 64 KiB per message. Violations fail the single request with `error.code === "rate-limited"`; the canvas keeps running. All reads are scoped to the hosting project — widget-supplied parameters cannot widen them. + ## 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..a81d9c80a7c --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/ipc.rs @@ -0,0 +1,182 @@ +#[cfg(unix)] +use std::{ + fs, + os::unix::{ + fs::{FileTypeExt, PermissionsExt}, + net::{UnixListener as StdUnixListener, 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 = StdUnixListener::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 serve_path = socket_path.clone(); + spawn_serving(std_listener, socket_path, move |listener| { + run(listener, app, serve_path) + }); + Ok(()) +} + +/// Hand a bound socket to the async runtime and serve it there. +/// +/// `tokio::net::UnixListener::from_std` registers the socket with the Tokio +/// reactor, so it only works from inside the runtime. `start` runs on the main +/// thread from Tauri's `setup` hook, which is not in runtime context — doing +/// the conversion there panics, and the panic crosses the non-unwinding +/// `did_finish_launching` boundary, aborting the app before it opens a window. +/// So the conversion has to happen in the spawned task, not at the call site. +#[cfg(unix)] +pub(super) fn spawn_serving(std_listener: StdUnixListener, socket_path: PathBuf, serve: F) +where + F: FnOnce(tokio::net::UnixListener) -> Fut + Send + 'static, + Fut: std::future::Future + Send + 'static, +{ + tauri::async_runtime::spawn(async move { + match tokio::net::UnixListener::from_std(std_listener) { + Ok(listener) => serve(listener).await, + Err(error) => { + eprintln!("buzz-desktop: project Canvas update socket stopped: {error}"); + let _ = fs::remove_file(&socket_path); + } + } + }); +} + +#[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..3e5089a0249 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/manifest.rs @@ -0,0 +1,263 @@ +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; +// Must stay in sync with `capabilitySchema` in +// desktop/src/features/projects/ui/project-canvas/projectCanvasProtocol.ts. +const ALLOWED_CAPABILITIES: &[&str] = &[ + "project.metadata.read", + "project.channels.read", + "project.reviews.read", + "project.tasks.read", + "project.people.read", + "project.tasks.write", + "app.open", + "app.dm.send", +]; + +#[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..66009da8782 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/mod.rs @@ -0,0 +1,798 @@ +mod ipc; +mod manifest; +mod path_security; +mod protocol; +mod storage; +mod template; + +#[cfg(test)] +mod tests; + +use std::{ + collections::{BTreeSet, HashMap, VecDeque}, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + +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, + ValidatedPackage, +}; +use template::bundled_template; + +const MAX_ACTIVE_LOADS: usize = 64; +/// Avatars the host may keep published for one project. A people lookup +/// returns at most 32 rows, so this holds two full lookups before the oldest +/// face falls back to initials. +const MAX_PUBLISHED_AVATARS: usize = 64; +/// Per-avatar byte ceiling. The host re-encodes to a small square before +/// publishing — a 96px WebP lands near 2 KiB — so this exists only to keep a +/// mistake from becoming a memory problem. +const MAX_PUBLISHED_AVATAR_BYTES: usize = 32 * 1024; +/// Combined byte ceiling for one project, evicted oldest-first. Holds even if +/// every avatar arrives at the per-avatar maximum. +const MAX_PUBLISHED_AVATAR_BYTES_PER_PROJECT: usize = 512 * 1024; +/// Projects that may hold published avatars, evicted least-recently-published. +const MAX_AVATAR_PROJECTS: usize = 4; + +#[derive(Clone)] +pub(crate) struct ProjectCanvasRuntime { + root: Option, + loads: Arc>>, + /// Avatar bytes the host has published, keyed by project then pubkey. + /// + /// Sandboxed frames run with `connect-src 'none'` and cannot fetch + /// anything themselves, so avatars used to ride inside the RPC payload as + /// base64 — which put every face in a people lookup under one 64 KiB + /// message ceiling. The host instead fetches an avatar on its own audited + /// webview path and hands the bytes here; the frame then loads + /// `./__buzz/avatar/` like any ordinary image, outside any + /// message. Nothing in this process fetches, so a hostile `picture` URL in + /// a kind:0 profile never becomes a backend request. + /// + /// Deliberately *not* tied to load lifetime. Publishing and frame creation + /// race in both directions, and a frame that requests an avatar before its + /// bytes land gets a 404 it will never retry. Keying by project instead + /// makes the order irrelevant; the ceilings above are what bound the store. + avatars: 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())), + avatars: Arc::new(Mutex::new(AvatarRegistry::default())), + 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())), + avatars: Arc::new(Mutex::new(AvatarRegistry::default())), + 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: Option<&ValidatedPackage>, + ) -> 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, 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: Option<&ValidatedPackage>, + ) -> 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, 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) + } + + /// Publishes avatar bytes that frames bound to `request`'s project may load + /// from `__buzz/avatar/`. + /// + /// Every entry is validated before the lock is taken, so a malformed batch + /// leaves the store exactly as it was rather than half-applied. + fn publish_avatars( + &self, + request: ProjectCanvasPackageRequest, + avatars: Vec, + ) -> Result<(), String> { + let binding = ProjectBinding::parse(request)?; + ensure_supported_platform()?; + if avatars.len() > MAX_PUBLISHED_AVATARS { + return Err(format!( + "at most {MAX_PUBLISHED_AVATARS} project canvas avatars may be published at once" + )); + } + let decoded = avatars + .into_iter() + .map(ProjectCanvasAvatarInput::validate) + .collect::, _>>()?; + let mut registry = self + .avatars + .lock() + .map_err(|_| "project canvas avatar registry is unavailable".to_string())?; + registry.publish(&binding.cache_key(), decoded); + Ok(()) + } + + fn avatar( + &self, + binding: &ProjectBinding, + pubkey: &str, + ) -> Result, String> { + let registry = self + .avatars + .lock() + .map_err(|_| "project canvas avatar registry is unavailable".to_string())?; + Ok(registry.get(&binding.cache_key(), pubkey)) + } + + 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, +} + +/// An avatar published for one pubkey, ready to serve verbatim. +#[derive(Clone)] +pub(super) struct CanvasAvatar { + /// Always one of the allowlisted image types, never a caller-supplied + /// string — so the frame cannot be handed a content type of its choosing. + pub(super) content_type: &'static str, + pub(super) bytes: Arc>, +} + +/// Published avatars for every project, bounded by project count. +#[derive(Default)] +struct AvatarRegistry { + projects: HashMap, + order: VecDeque, +} + +impl AvatarRegistry { + fn publish(&mut self, project: &str, avatars: Vec<(String, CanvasAvatar)>) { + if !self.projects.contains_key(project) { + self.projects + .insert(project.to_string(), AvatarCache::default()); + self.order.push_back(project.to_string()); + while self.order.len() > MAX_AVATAR_PROJECTS { + let Some(evicted) = self.order.pop_front() else { + break; + }; + self.projects.remove(&evicted); + } + } + let Some(cache) = self.projects.get_mut(project) else { + return; + }; + for (pubkey, avatar) in avatars { + cache.insert(pubkey, avatar); + } + } + + fn get(&self, project: &str, pubkey: &str) -> Option { + self.projects.get(project)?.entries.get(pubkey).cloned() + } +} + +/// One project's published avatars, bounded by both count and total bytes and +/// evicted oldest-first. +#[derive(Default)] +struct AvatarCache { + entries: HashMap, + order: VecDeque, + bytes: usize, +} + +impl AvatarCache { + fn insert(&mut self, pubkey: String, avatar: CanvasAvatar) { + let added = avatar.bytes.len(); + match self.entries.insert(pubkey.clone(), avatar) { + Some(replaced) => self.bytes = self.bytes.saturating_sub(replaced.bytes.len()), + None => self.order.push_back(pubkey), + } + self.bytes = self.bytes.saturating_add(added); + while self.order.len() > MAX_PUBLISHED_AVATARS + || self.bytes > MAX_PUBLISHED_AVATAR_BYTES_PER_PROJECT + { + let Some(evicted) = self.order.pop_front() else { + break; + }; + if let Some(removed) = self.entries.remove(&evicted) { + self.bytes = self.bytes.saturating_sub(removed.bytes.len()); + } + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ProjectCanvasAvatarInput { + pubkey: String, + content_type: String, + /// Standard base64 of the image bytes. Sent as text because the Tauri IPC + /// encodes a `Vec` as a JSON number array — several times worse than + /// the base64 it would be replacing. + data: String, +} + +impl ProjectCanvasAvatarInput { + fn validate(self) -> Result<(String, CanvasAvatar), String> { + let pubkey = normalized_pubkey(&self.pubkey)?; + let content_type = image_content_type(&self.content_type).ok_or_else(|| { + format!( + "unsupported project canvas avatar type '{}'", + self.content_type + ) + })?; + // Check the encoded length first: decoding is what would allocate. + if self.data.len() > MAX_PUBLISHED_AVATAR_BYTES.div_ceil(3) * 4 + 4 { + return Err("project canvas avatar is too large".to_string()); + } + let bytes = BASE64 + .decode(self.data.as_bytes()) + .map_err(|_| "project canvas avatar data must be base64".to_string())?; + if bytes.is_empty() || bytes.len() > MAX_PUBLISHED_AVATAR_BYTES { + return Err("project canvas avatar is too large".to_string()); + } + // `nosniff` already stops the webview reinterpreting these bytes, but + // this is the sandbox boundary and the frame is untrusted: bytes that + // do not open with their declared type's signature are not an image. + if !image_bytes_match(content_type, &bytes) { + return Err(format!( + "project canvas avatar bytes are not {content_type} data" + )); + } + Ok(( + pubkey, + CanvasAvatar { + content_type, + bytes: Arc::new(bytes), + }, + )) + } +} + +#[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, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result { + let runtime = runtime.inner().clone(); + run_blocking(move || { + let template = bundled_template()?; + runtime.get_or_activate(request, Some(&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, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result { + let runtime = runtime.inner().clone(); + run_blocking(move || { + let template = bundled_template()?; + runtime.activate(request, Some(&template)) + }) + .await +} + +/// Publishes avatar bytes for a project's canvas frames to load by URL. +/// +/// Synchronous on purpose: it only decodes and stores, adding no IO, which is +/// what lets the protocol handler that reads it stay synchronous too. +#[tauri::command] +pub(crate) fn publish_project_canvas_avatars( + request: ProjectCanvasPackageRequest, + avatars: Vec, + runtime: State<'_, ProjectCanvasRuntime>, +) -> Result<(), String> { + runtime.publish_avatars(request, avatars) +} + +#[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) +} + +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}/") + } +} + +pub(super) fn normalized_pubkey(value: &str) -> Result { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("project canvas avatar pubkey must be 64 hex characters".to_string()); + } + Ok(value.to_ascii_lowercase()) +} + +/// Maps a caller-supplied media type onto the fixed set a canvas may serve, +/// discarding any parameters. Returning `'static` is what keeps a +/// caller-controlled string out of the response headers. +fn image_content_type(value: &str) -> Option<&'static str> { + match value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "image/png" => Some("image/png"), + "image/jpeg" => Some("image/jpeg"), + "image/webp" => Some("image/webp"), + "image/gif" => Some("image/gif"), + _ => None, + } +} + +fn image_bytes_match(content_type: &str, bytes: &[u8]) -> bool { + match content_type { + "image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "image/jpeg" => bytes.starts_with(b"\xff\xd8\xff"), + "image/gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"), + "image/webp" => bytes.len() > 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP", + _ => false, + } +} + +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" + )), + } +} + +/// Decides whether the main webview may commit a top-level navigation. +/// +/// `dev_url` is the dev server origin the app was actually built against +/// (`webview.config().build.dev_url`). Every `just` desktop recipe derives a +/// per-worktree Vite port via `scripts/instance-env.sh`, so the origin cannot +/// be hardcoded — and because the dev server load *is* the initial navigation, +/// cancelling it leaves a blank window rather than a blocked link. +pub(crate) fn allow_webview_navigation(url: &tauri::Url, dev_url: Option<&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"), + // Debug builds serve the frontend from a dev server; release builds + // have none, so plain http stays blocked there. + "http" if cfg!(debug_assertions) => { + dev_url.is_some_and(|dev_url| dev_url.origin() == url.origin()) + } + _ => 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..4fc72cff4db --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/protocol.rs @@ -0,0 +1,296 @@ +use percent_encoding::percent_decode_str; +use tauri::http::{self, Method, StatusCode}; + +use super::{ + manifest::{mime_type, validate_relative_path, MAX_FILE_BYTES}, + normalized_pubkey, ActiveLoad, ProjectCanvasRuntime, +}; + +const PEOPLE_READ_CAPABILITY: &str = "project.people.read"; + +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)?, + )), + ["__buzz", "sdk.js"] => Ok(( + "text/javascript; charset=utf-8", + include_str!("sdk.js").as_bytes().to_vec(), + )), + ["__buzz", "sdk.css"] => Ok(( + "text/css; charset=utf-8", + include_str!("sdk.css").as_bytes().to_vec(), + )), + ["__buzz", "avatar", pubkey] => serve_avatar(runtime, &load, pubkey), + ["package", rest @ ..] if !rest.is_empty() => serve_package_file(&load, rest), + _ => Err((StatusCode::NOT_FOUND, "not found".to_string())), + } +} + +/// Serves an avatar the host published for this frame's project. +/// +/// The bytes never enter the RPC port, so a people lookup stays inside its +/// message ceiling however many faces it carries, and each image is fetched +/// only when a widget actually renders it. Nothing here reaches the network: a +/// pubkey the host has not published is a 404, which the SDK leaves as +/// initials. +/// +/// The project comes from the load, never from the request, so a frame can +/// only ever read avatars published for the project it is bound to. +fn serve_avatar( + runtime: &ProjectCanvasRuntime, + load: &ActiveLoad, + pubkey: &str, +) -> Result<(&'static str, Vec), (StatusCode, String)> { + if !load + .granted_capabilities + .iter() + .any(|capability| capability == PEOPLE_READ_CAPABILITY) + { + return Err(( + StatusCode::FORBIDDEN, + format!("project canvas package was not granted {PEOPLE_READ_CAPABILITY}"), + )); + } + let pubkey = normalized_pubkey(pubkey).map_err(bad_request)?; + let avatar = runtime + .avatar(&load.binding, &pubkey) + .map_err(internal_error)? + .ok_or_else(|| (StatusCode::NOT_FOUND, "not found".to_string()))?; + Ok((avatar.content_type, avatar.bytes.as_ref().clone())) +} + +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}"))?; + // The host-owned SDK loads before every package resource so packages can + // rely on `window.buzzCanvas.sdk` from their first statement. + let scripts = std::iter::once(Ok("\"./__buzz/sdk.js\"".to_string())) + .chain( + 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 = std::iter::once(Ok("\"./__buzz/sdk.css\"".to_string())) + .chain( + 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, + sdk: {{}}, + }}), + 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/sdk.css b/desktop/src-tauri/src/project_canvas_package/sdk.css new file mode 100644 index 00000000000..b0b2eedfb70 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/sdk.css @@ -0,0 +1,239 @@ +/* Host-owned Project Canvas SDK styles. Served from /__buzz/sdk.css and + * loaded before every package stylesheet. Packages should build on the + * --buzz-* custom properties so their surfaces track the host theme. */ + +:root { + --buzz-background: #ffffff; + --buzz-foreground: #18181b; + --buzz-muted: #f4f4f5; + --buzz-muted-foreground: #71717a; + --buzz-border: #e4e4e7; + --buzz-primary: #2563eb; + --buzz-primary-foreground: #ffffff; + --buzz-success: #059669; + --buzz-destructive: #dc2626; + --buzz-radius: 8px; + --buzz-avatar-tone-0: #3b82f6; + --buzz-avatar-tone-0-fg: #ffffff; + --buzz-avatar-tone-1: #10b981; + --buzz-avatar-tone-1-fg: #ffffff; + --buzz-avatar-tone-2: #fbbf24; + --buzz-avatar-tone-2-fg: #451a03; + --buzz-avatar-tone-3: #f43f5e; + --buzz-avatar-tone-3-fg: #ffffff; + --buzz-avatar-tone-4: #22d3ee; + --buzz-avatar-tone-4-fg: #083344; + --buzz-avatar-tone-5: #8b5cf6; + --buzz-avatar-tone-5-fg: #ffffff; + --buzz-avatar-tone-6: #f97316; + --buzz-avatar-tone-6-fg: #ffffff; +} + +@media (prefers-color-scheme: dark) { + :root { + --buzz-background: #18181b; + --buzz-foreground: #fafafa; + --buzz-muted: #27272a; + --buzz-muted-foreground: #a1a1aa; + --buzz-border: #3f3f46; + --buzz-primary: #60a5fa; + --buzz-primary-foreground: #0c0a09; + } +} + +.buzz-avatar { + align-items: center; + background: var(--buzz-muted); + color: var(--buzz-muted-foreground); + display: inline-flex; + flex: none; + justify-content: center; + overflow: hidden; + position: relative; + vertical-align: middle; +} + +.buzz-avatar[data-shape="circle"] { + border-radius: 9999px; +} + +.buzz-avatar[data-shape="squircle"] { + border-radius: 30%; +} + +.buzz-avatar[data-size="xs"] { + font-size: 8px; + height: 20px; + width: 20px; +} + +.buzz-avatar[data-size="sm"] { + font-size: 11px; + height: 24px; + width: 24px; +} + +.buzz-avatar[data-size="md"] { + font-size: 12px; + height: 36px; + width: 36px; +} + +/* Stacked over the initials rather than beside them: the picture is present + from the first paint but only covers the fallback once it has decoded, so a + person whose avatar is still loading — or was never published — shows + initials instead of an empty box. */ +.buzz-avatar-image { + height: 100%; + inset: 0; + object-fit: cover; + position: absolute; + width: 100%; +} + +.buzz-avatar-fallback { + font-weight: 600; + line-height: 1; +} + +.buzz-avatar[data-tone="0"] { + background: var(--buzz-avatar-tone-0); + color: var(--buzz-avatar-tone-0-fg); +} +.buzz-avatar[data-tone="1"] { + background: var(--buzz-avatar-tone-1); + color: var(--buzz-avatar-tone-1-fg); +} +.buzz-avatar[data-tone="2"] { + background: var(--buzz-avatar-tone-2); + color: var(--buzz-avatar-tone-2-fg); +} +.buzz-avatar[data-tone="3"] { + background: var(--buzz-avatar-tone-3); + color: var(--buzz-avatar-tone-3-fg); +} +.buzz-avatar[data-tone="4"] { + background: var(--buzz-avatar-tone-4); + color: var(--buzz-avatar-tone-4-fg); +} +.buzz-avatar[data-tone="5"] { + background: var(--buzz-avatar-tone-5); + color: var(--buzz-avatar-tone-5-fg); +} +.buzz-avatar[data-tone="6"] { + background: var(--buzz-avatar-tone-6); + color: var(--buzz-avatar-tone-6-fg); +} + +.buzz-review-row, +.buzz-channel-row { + align-items: center; + background: var(--buzz-background); + border: 1px solid var(--buzz-border); + border-radius: var(--buzz-radius); + color: var(--buzz-foreground); + display: flex; + font: inherit; + gap: 10px; + justify-content: space-between; + padding: 8px 10px; + text-align: left; + width: 100%; +} + +button.buzz-review-row, +button.buzz-channel-row { + cursor: pointer; +} + +button.buzz-review-row:hover, +button.buzz-channel-row:hover { + background: var(--buzz-muted); +} + +button.buzz-review-row:focus-visible, +button.buzz-channel-row:focus-visible { + outline: 2px solid var(--buzz-primary); + outline-offset: 1px; +} + +.buzz-review-summary, +.buzz-channel-details { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.buzz-review-id { + color: var(--buzz-muted-foreground); + font-size: 11px; +} + +.buzz-review-title, +.buzz-channel-name { + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.buzz-review-branch { + color: var(--buzz-muted-foreground); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.buzz-channel-meta { + color: var(--buzz-muted-foreground); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.buzz-review-status { + align-items: center; + display: inline-flex; + flex: none; + gap: 6px; +} + +.buzz-channel-people { + display: inline-flex; + flex: none; +} + +.buzz-channel-people .buzz-avatar + .buzz-avatar { + margin-left: -6px; +} + +.buzz-status-pill { + background: var(--buzz-muted); + border-radius: 9999px; + color: var(--buzz-muted-foreground); + flex: none; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; +} + +.buzz-status-pill[data-status="open"], +.buzz-status-pill[data-status="approved"] { + background: color-mix(in srgb, var(--buzz-success) 15%, transparent); + color: var(--buzz-success); +} + +.buzz-status-pill[data-status="merged"] { + background: color-mix(in srgb, var(--buzz-primary) 15%, transparent); + color: var(--buzz-primary); +} + +.buzz-status-pill[data-status="closed"], +.buzz-status-pill[data-status="changes-requested"] { + background: color-mix(in srgb, var(--buzz-destructive) 15%, transparent); + color: var(--buzz-destructive); +} diff --git a/desktop/src-tauri/src/project_canvas_package/sdk.js b/desktop/src-tauri/src/project_canvas_package/sdk.js new file mode 100644 index 00000000000..0b5ef919ba9 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/sdk.js @@ -0,0 +1,441 @@ +// Host-owned Project Canvas SDK. Served from /__buzz/sdk.js and loaded before +// every package script, so packages can rely on `window.buzzCanvas.sdk`. +// +// The SDK never starts the MessagePort: the package entry (canvas.js) owns +// starting it, and listeners registered before the port starts lose nothing. +// RPC calls issued before host.init arrives are queued and flushed with the +// session envelope once the host binds the load. +(() => { + const PROTOCOL_VERSION = 1; + const runtime = window.buzzCanvas; + if ( + !runtime || + runtime.protocolVersion !== PROTOCOL_VERSION || + !runtime.port || + !runtime.sdk + ) { + return; + } + const port = runtime.port; + const session = { + capabilities: [], + loadId: null, + nonce: null, + ready: false, + }; + let requestCounter = 0; + const pending = new Map(); + const subscriptions = new Map(); + const queued = []; + + function nextId(prefix) { + requestCounter += 1; + return `${prefix}-${requestCounter}`; + } + + function envelope() { + return { + loadId: session.loadId, + nonce: session.nonce, + protocolVersion: PROTOCOL_VERSION, + }; + } + + function send(message) { + if (session.ready) port.postMessage(Object.assign(envelope(), message)); + else queued.push(message); + } + + function rpcFailure(error) { + const failure = new Error( + error && typeof error.message === "string" + ? error.message + : "Canvas request failed", + ); + failure.code = + error && typeof error.code === "string" ? error.code : "failed"; + return failure; + } + + function settle(id, message) { + const entry = pending.get(id); + if (!entry) return; + pending.delete(id); + if (message.error) entry.reject(rpcFailure(message.error)); + else + entry.resolve( + message.result !== undefined ? message.result : { ok: true }, + ); + } + + port.addEventListener("message", (event) => { + const message = event.data; + if (!message || message.protocolVersion !== PROTOCOL_VERSION) return; + if (message.type === "host.init") { + if (session.ready) return; + session.ready = true; + session.loadId = message.loadId; + session.nonce = message.nonce; + session.capabilities = Array.isArray(message.capabilities) + ? message.capabilities.slice() + : []; + for (const queuedMessage of queued.splice(0)) { + port.postMessage(Object.assign(envelope(), queuedMessage)); + } + return; + } + if (message.loadId !== session.loadId || message.nonce !== session.nonce) { + return; + } + if (message.type === "host.queryResult") settle(message.queryId, message); + else if (message.type === "host.commandResult") { + settle(message.commandId, message); + } else if (message.type === "host.openResult") { + settle(message.openId, message); + } else if (message.type === "host.subscriptionUpdate") { + const subscription = subscriptions.get(message.subscriptionId); + if (subscription) subscription(message.result); + } else if (message.type === "host.subscriptionEnded") { + const subscription = subscriptions.get(message.subscriptionId); + subscriptions.delete(message.subscriptionId); + if (subscription && message.error) { + subscription({ data: null, error: message.error, status: "error" }); + } + } + }); + + function request(prefix, build) { + return new Promise((resolve, reject) => { + const id = nextId(prefix); + pending.set(id, { reject, resolve }); + send(build(id)); + }); + } + + const data = Object.freeze({ + query(name, params) { + return request("q", (queryId) => ({ + query: { name, params: params || {} }, + queryId, + type: "canvas.query", + })); + }, + liveQuery(name, params, onUpdate) { + const subscriptionId = nextId("s"); + subscriptions.set(subscriptionId, onUpdate); + send({ + query: { name, params: params || {} }, + subscriptionId, + type: "canvas.subscribe", + }); + return () => { + if (!subscriptions.delete(subscriptionId)) return; + send({ subscriptionId, type: "canvas.unsubscribe" }); + }; + }, + command(name, params) { + return request("c", (commandId) => ({ + command: { name, params: params || {} }, + commandId, + type: "canvas.command", + })); + }, + }); + + const app = Object.freeze({ + open(target) { + return request("o", (openId) => ({ + openId, + target, + type: "canvas.open", + })); + }, + }); + + // --- Layout persistence -------------------------------------------------- + // The host persists widget placement per dashboard; no capability is needed + // because it only records direct user manipulation of host-rendered chrome. + // Sends are debounced here so a held arrow key cannot trip the host's port + // rate limit, which tears the frame down rather than failing one message. + + const LAYOUT_DEBOUNCE_MS = 300; + const LAYOUT_COORDINATE_LIMIT = 100000; + const LAYOUT_MIN_WIDGET_SIZE = 16; + const LAYOUT_MAX_WIDGETS = 256; + let layoutTimer = 0; + let layoutPending = null; + + function layoutCoordinate(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return Math.max( + -LAYOUT_COORDINATE_LIMIT, + Math.min(LAYOUT_COORDINATE_LIMIT, numeric), + ); + } + + function layoutPoint(value) { + if (!value || typeof value !== "object") return null; + const x = layoutCoordinate(value.x); + const y = layoutCoordinate(value.y); + return x === null || y === null ? null : { x, y }; + } + + // Clamped into the host's accepted range so a save can never produce an + // invalid port message (those count toward frame teardown). + function layoutDimension(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return Math.max( + LAYOUT_MIN_WIDGET_SIZE, + Math.min(LAYOUT_COORDINATE_LIMIT, numeric), + ); + } + + function layoutSize(value) { + if (!value || typeof value !== "object") return null; + const width = layoutDimension(value.width); + const height = layoutDimension(value.height); + return width === null || height === null ? null : { height, width }; + } + + function layoutOverrides(value, sanitize) { + const overrides = {}; + if (!value || typeof value !== "object") return overrides; + let count = 0; + for (const [widgetId, entry] of Object.entries(value)) { + if (count >= LAYOUT_MAX_WIDGETS) break; + if (!/^[A-Za-z0-9._-]{1,128}$/.test(widgetId)) continue; + const sanitized = sanitize(entry); + if (!sanitized) continue; + Object.defineProperty(overrides, widgetId, { + configurable: true, + enumerable: true, + value: sanitized, + writable: true, + }); + count += 1; + } + return overrides; + } + + function flushLayout() { + layoutTimer = 0; + const next = layoutPending; + layoutPending = null; + if (next) send(next); + } + + const layout = Object.freeze({ + save(next) { + const options = next || {}; + const dashboard = String(options.dashboard || ""); + if (!dashboard || dashboard.length > 128) return; + // Last write wins: only the final arrangement of a burst is sent. + layoutPending = { + dashboard, + pan: layoutPoint(options.pan), + sizes: layoutOverrides(options.sizes, layoutSize), + type: "canvas.layout", + widgets: layoutOverrides(options.widgets, layoutPoint), + }; + clearTimeout(layoutTimer); + layoutTimer = setTimeout(flushLayout, LAYOUT_DEBOUNCE_MS); + }, + }); + + // --- Standard components ------------------------------------------------- + // Identity semantics mirror the app's UserAvatar: same initials derivation + // and the same 7-tone hash, so a person renders identically inside and + // outside the canvas. + + function initialsFor(name) { + return String(name || "") + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .trim() + .split(/\s+/) + .map((part) => part[0] || "") + .join("") + .slice(0, 2) + .toUpperCase(); + } + + function toneFor(name) { + let hash = 0; + for (const character of String(name || "") + .trim() + .toLowerCase()) { + hash = (hash * 31 + (character.codePointAt(0) || 0)) >>> 0; + } + return hash % 7; + } + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = String(text); + return node; + } + + const PUBKEY_PATTERN = /^[0-9a-f]{64}$/; + + /** + * Resolves where an avatar's picture comes from, if anywhere. + * + * A `pubkey` uses the host's avatar route, which the frame fetches like any + * ordinary image — so the picture costs nothing in the RPC payload that + * carried the person's row. `avatarUrl` stays supported for data URLs the + * host inlined directly, and is what a widget written before the route + * existed keeps using. + * + * The path is relative, so it resolves against the frame's document URL; + * `base-uri 'none'` in the canvas CSP means a package cannot repoint it. + */ + function avatarImageSrc(options) { + const pubkey = String(options.pubkey || "").toLowerCase(); + if (PUBKEY_PATTERN.test(pubkey)) return `./__buzz/avatar/${pubkey}`; + return typeof options.avatarUrl === "string" && + options.avatarUrl.startsWith("data:image/") + ? options.avatarUrl + : null; + } + + function avatar(props) { + const options = props || {}; + const name = String(options.name || ""); + const size = ["xs", "sm", "md"].includes(options.size) + ? options.size + : "md"; + const node = el("span", "buzz-avatar"); + node.dataset.buzzComponent = "avatar"; + node.dataset.size = size; + node.dataset.shape = options.agent ? "squircle" : "circle"; + node.setAttribute("role", "img"); + node.setAttribute("aria-label", name || "Unknown person"); + node.dataset.tone = String(toneFor(name)); + node.append(el("span", "buzz-avatar-fallback", initialsFor(name) || "?")); + const src = avatarImageSrc(options); + if (!src) return node; + // The picture is stacked over the initials (see `.buzz-avatar-image`) and + // dropped if it fails. The route 404s for anyone the host has no avatar + // for, which is ordinary rather than an error, so that case has to leave + // the initials showing instead of a broken-image glyph. + const image = el("img", "buzz-avatar-image"); + image.alt = ""; + image.decoding = "async"; + image.addEventListener( + "error", + () => { + image.remove(); + }, + { once: true }, + ); + image.src = src; + node.append(image); + return node; + } + + function openable(row, target, onOpen) { + const handler = + typeof onOpen === "function" + ? onOpen + : target + ? () => { + app.open(target).catch(() => {}); + } + : null; + if (!handler) return row; + const button = el("button", row.className); + button.type = "button"; + for (const [key, value] of Object.entries(row.dataset)) { + button.dataset[key] = value; + } + button.append(...row.childNodes); + button.addEventListener("click", handler); + return button; + } + + function reviewRow(props) { + const options = props || {}; + const review = options.review || {}; + const row = el("div", "buzz-review-row"); + row.dataset.buzzComponent = "review-row"; + const summary = el("span", "buzz-review-summary"); + summary.append( + el("span", "buzz-review-id", review.displayId || ""), + el("strong", "buzz-review-title", review.title || "Untitled review"), + ); + if (review.branch) { + summary.append(el("code", "buzz-review-branch", review.branch)); + } + const status = String(review.status || "Open"); + const pill = el("span", "buzz-status-pill", status); + pill.dataset.status = status.toLowerCase().replaceAll(" ", "-"); + const trailing = el("span", "buzz-review-status"); + if (review.authorName) { + trailing.append( + avatar({ + agent: Boolean(review.authorIsAgent), + avatarUrl: review.authorAvatarUrl || null, + pubkey: review.authorPubkey || null, + name: review.authorName, + size: "xs", + }), + ); + } + trailing.append(pill); + row.append(summary, trailing); + return openable( + row, + review.id ? { id: review.id, type: "review" } : null, + options.onOpen, + ); + } + + function channelRow(props) { + const options = props || {}; + const channel = options.channel || {}; + const row = el("div", "buzz-channel-row"); + row.dataset.buzzComponent = "channel-row"; + const details = el("span", "buzz-channel-details"); + details.append( + el("strong", "buzz-channel-name", `# ${channel.name || "channel"}`), + ); + const meta = channel.topic || channel.description || ""; + if (meta) details.append(el("span", "buzz-channel-meta", meta)); + row.append(details); + const people = Array.isArray(channel.people) + ? channel.people.slice(0, 5) + : []; + if (people.length > 0) { + const cluster = el("span", "buzz-channel-people"); + for (const person of people) { + cluster.append( + avatar({ + agent: Boolean(person.isAgent), + avatarUrl: person.avatarDataUrl || null, + name: person.displayName || person.pubkey || "", + pubkey: person.pubkey || null, + size: "xs", + }), + ); + } + row.append(cluster); + } + return openable( + row, + channel.id ? { id: channel.id, type: "channel" } : null, + options.onOpen, + ); + } + + Object.assign(runtime.sdk, { + app, + capabilities: () => session.capabilities.slice(), + data, + layout, + ui: Object.freeze({ avatar, channelRow, reviewRow }), + version: 1, + }); + Object.freeze(runtime.sdk); +})(); 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..970b735111b --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/storage.rs @@ -0,0 +1,893 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, OpenOptions}, + io::{ErrorKind, 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 + } + + /// Key for in-memory state scoped to this project, agreeing exactly with + /// [`Self::matches`]. Every component is a hash or hex, so the key carries + /// no raw community id or `d` tag. + pub(super) fn cache_key(&self) -> String { + format!("{}/{}/{}", self.community_key, self.owner, self.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, +} + +pub(super) 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<&ValidatedPackage>, +) -> 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<&ValidatedPackage>, +) -> 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 package = template.ok_or_else(|| "project canvas template is unavailable".to_string())?; + 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_package_dir(root)?; + validate_package_files(read_package_tree(trusted_root, root)?) +} + +#[cfg(test)] +pub(super) fn scan_package_for_test(trusted_root: &Path, root: &Path) -> Result<(), String> { + scan_package(trusted_root, root).map(|_| ()) +} + +/// One non-following inspection covering existence, symlink, and directory. +/// +/// `symlink_metadata` names the path it failed on, where the previous +/// `ensure_no_symlink` + `Path::is_dir()` pair died inside the metadata call +/// with an unnamed `os error 2`. It is also strictly stronger: the directory +/// test now reads off the non-following metadata rather than `Path::is_dir()`, +/// which follows links. +fn ensure_package_dir(root: &Path) -> Result<(), String> { + let metadata = match fs::symlink_metadata(root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Err(format!( + "project canvas package directory does not exist: {}", + root.display() + )) + } + Err(error) => return Err(format!("inspect project canvas path: {error}")), + }; + if metadata.file_type().is_symlink() { + return Err(format!( + "project canvas paths cannot be symlinks: {}", + root.display() + )); + } + if !metadata.is_dir() { + return Err(format!( + "project canvas package is not a directory: {}", + root.display() + )); + } + Ok(()) +} + +/// The single validation gate for every canvas package, read off disk or +/// embedded in the binary. There is no second, weaker path. +pub(super) fn validate_package_files( + files: BTreeMap>, +) -> Result { + 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/template.rs b/desktop/src-tauri/src/project_canvas_package/template.rs new file mode 100644 index 00000000000..801b376b895 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/template.rs @@ -0,0 +1,73 @@ +//! The seed package a project's first canvas is created from, embedded in the +//! binary at compile time. +//! +//! Seeding used to read `resources/project-canvas-template` from disk, resolved +//! against the running executable. In dev builds that directory lives inside +//! the cargo target directory — shared across worktrees, rewritten by sibling +//! builds, and removed wholesale by toolchain cleanups — so when it disappeared +//! under a running app the first-ever activation of a project failed with an +//! unnamed `os error 2` and could not self-heal. There is no runtime lookup +//! left to go stale: the bytes are part of the binary, and the on-disk copy is +//! no longer bundled. + +use std::{ + collections::BTreeMap, + sync::{Arc, OnceLock}, +}; + +use include_dir::{include_dir, Dir, DirEntry}; + +use super::{ + manifest::validate_relative_path, + storage::{validate_package_files, ValidatedPackage}, +}; + +static TEMPLATE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/resources/project-canvas-template"); + +/// The embedded template, validated through the same gate every on-disk canvas +/// package passes. +/// +/// Validation hashes 2.7 MB, so the result is computed once per process and +/// shared. A template that fails validation fails every seed identically — +/// `bundled_template_seeds_a_valid_snapshot` keeps that from reaching a build. +pub(super) fn bundled_template() -> Result, String> { + static VALIDATED: OnceLock, String>> = OnceLock::new(); + VALIDATED + .get_or_init(|| validate_package_files(template_files()?).map(Arc::new)) + .clone() +} + +/// The embedded file tree, keyed by package-relative path. +/// +/// `include_dir` normalizes to `/` separators on every host, and each key is +/// still run through `validate_relative_path` so the embedded template gets no +/// weaker a path gate than a package read off disk. +pub(super) fn template_files() -> Result>, String> { + let mut files = BTreeMap::new(); + collect_entries(TEMPLATE.entries(), &mut files)?; + Ok(files) +} + +fn collect_entries( + entries: &[DirEntry<'_>], + files: &mut BTreeMap>, +) -> Result<(), String> { + for entry in entries { + match entry { + DirEntry::Dir(directory) => collect_entries(directory.entries(), files)?, + DirEntry::File(file) => { + let path = file + .path() + .to_str() + .ok_or_else(|| "project canvas package paths must be UTF-8".to_string())?; + // Matches the on-disk scan, which ignores Finder metadata rather + // than failing the whole package on it. + if path.rsplit('/').next() == Some(".DS_Store") { + continue; + } + files.insert(validate_relative_path(path)?, file.contents().to_vec()); + } + } + } + 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..8015691132e --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/tests.rs @@ -0,0 +1,93 @@ +use std::{collections::BTreeMap, fs, path::Path}; + +use tempfile::TempDir; + +use super::{ + storage::{validate_package_files, ProjectBinding, ValidatedPackage}, + ProjectCanvasPackageRequest, +}; + +mod avatars; +mod containment; +mod packaging; + +const OWNER: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn request() -> ProjectCanvasPackageRequest { + ProjectCanvasPackageRequest { + community_id: "community-a".to_string(), + project_id: format!("30621:{OWNER}:my-project"), + } +} + +fn package_files(marker: &str) -> BTreeMap> { + BTreeMap::from([ + ( + "manifest.json".to_string(), + 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(), + ), + ( + "widgets/chore-board.js".to_string(), + b"globalThis.renderChores = () => {};".to_vec(), + ), + ( + "canvas.js".to_string(), + format!("globalThis.canvasMarker = {marker:?};").into_bytes(), + ), + ( + "styles/canvas.css".to_string(), + b"body { margin: 0; }".to_vec(), + ), + ( + "data/dashboards.json".to_string(), + serde_json::to_vec(&serde_json::json!({ + "marker": marker, + "dashboards": { + "test": { + "widgets": [{ + "id": "chore-board", + "data": { "marker": marker } + }] + } + } + })) + .unwrap(), + ), + ("assets/pixel.png".to_string(), vec![137, 80, 78, 71]), + ]) +} + +fn write_package(root: &Path, marker: &str) { + write_package_files(root, &package_files(marker)); +} + +fn write_package_files(root: &Path, files: &BTreeMap>) { + for (relative, bytes) in files { + let destination = root.join(relative); + fs::create_dir_all(destination.parent().unwrap()).unwrap(); + fs::write(destination, bytes).unwrap(); + } +} + +fn template_package(marker: &str) -> ValidatedPackage { + validate_package_files(package_files(marker)).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) +} diff --git a/desktop/src-tauri/src/project_canvas_package/tests/avatars.rs b/desktop/src-tauri/src/project_canvas_package/tests/avatars.rs new file mode 100644 index 00000000000..6fbc717ccbe --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/tests/avatars.rs @@ -0,0 +1,336 @@ +//! Published avatars. Avatars reach a frame over `__buzz/avatar/` +//! rather than as base64 inside an RPC message, so these bind the containment +//! that route depends on: the capability gate, the project scope, and what +//! counts as an image. + +use std::path::Path; + +use tempfile::TempDir; + +use super::super::{ + protocol, storage::ProjectBinding, template::bundled_template, ProjectCanvasAvatarInput, + ProjectCanvasPackageRequest, ProjectCanvasRuntime, MAX_PUBLISHED_AVATARS, +}; +use super::{package_files, request, source_root, write_package, write_package_files, OWNER}; + +fn avatar_pubkey(index: usize) -> String { + format!("{index:064x}") +} + +fn png_bytes(len: usize) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + bytes.resize(len.max(bytes.len()), b'x'); + bytes +} + +fn avatar_input(pubkey: &str, content_type: &str, bytes: &[u8]) -> ProjectCanvasAvatarInput { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + ProjectCanvasAvatarInput { + content_type: content_type.to_string(), + data: STANDARD.encode(bytes), + pubkey: pubkey.to_string(), + } +} + +/// Writes the standard test package with `project.people.read` granted. +fn write_people_package(root: &Path, marker: &str) { + let mut files = package_files(marker); + let mut manifest: serde_json::Value = + serde_json::from_slice(files.get("manifest.json").unwrap()).unwrap(); + manifest["capabilities"] = serde_json::json!([ + "project.metadata.read", + "project.channels.read", + "project.reviews.read", + "project.people.read" + ]); + files.insert( + "manifest.json".to_string(), + serde_json::to_vec(&manifest).unwrap(), + ); + write_package_files(root, &files); +} + +fn people_runtime(temp: &TempDir, request: ProjectCanvasPackageRequest) -> ProjectCanvasRuntime { + let binding = ProjectBinding::parse(request).unwrap(); + write_people_package(&source_root(temp, &binding), "avatars"); + ProjectCanvasRuntime::with_root(temp.path().join("CANVASES")) +} + +#[test] +fn published_avatars_are_served_by_pubkey_and_missing_ones_are_not_found() { + let temp = TempDir::new().unwrap(); + let runtime = people_runtime(&temp, request()); + let descriptor = runtime + .get_or_activate(request(), Some(&bundled_template().unwrap())) + .unwrap(); + let present = avatar_pubkey(1); + let absent = avatar_pubkey(2); + let bytes = png_bytes(64); + runtime + .publish_avatars(request(), vec![avatar_input(&present, "image/png", &bytes)]) + .unwrap(); + + let (content_type, body) = protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/{present}", descriptor.load_id), + ) + .unwrap(); + assert_eq!(content_type, "image/png"); + assert_eq!(body, bytes); + + // An unpublished person is an ordinary outcome, not an error: the SDK + // leaves their initials in place. + let (status, _) = protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/{absent}", descriptor.load_id), + ) + .unwrap_err(); + assert_eq!(status, tauri::http::StatusCode::NOT_FOUND); +} + +#[test] +fn the_avatar_route_survives_a_reload_of_the_same_project() { + let temp = TempDir::new().unwrap(); + let runtime = people_runtime(&temp, request()); + let first = runtime + .get_or_activate(request(), Some(&bundled_template().unwrap())) + .unwrap(); + let pubkey = avatar_pubkey(7); + runtime + .publish_avatars( + request(), + vec![avatar_input(&pubkey, "image/webp", b"RIFF\0\0\0\0WEBPxx")], + ) + .unwrap(); + runtime.release(&first.load_id).unwrap(); + + // Binds the reason the store is keyed by project rather than by load: a + // frame that reloads must not lose every avatar until something happens to + // republish, because a 404 here is never retried. + let second = runtime + .get_or_activate(request(), Some(&bundled_template().unwrap())) + .unwrap(); + let (content_type, _) = protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/{pubkey}", second.load_id), + ) + .unwrap(); + assert_eq!(content_type, "image/webp"); +} + +#[test] +fn the_avatar_route_requires_the_people_read_capability() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + // The default package grants metadata/channels/reviews but not people. + write_package(&source_root(&temp, &binding), "no-people"); + let runtime = ProjectCanvasRuntime::with_root(temp.path().join("CANVASES")); + let descriptor = runtime + .get_or_activate(request(), Some(&bundled_template().unwrap())) + .unwrap(); + let pubkey = avatar_pubkey(3); + runtime + .publish_avatars( + request(), + vec![avatar_input(&pubkey, "image/png", &png_bytes(32))], + ) + .unwrap(); + + let (status, _) = protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/{pubkey}", descriptor.load_id), + ) + .unwrap_err(); + assert_eq!(status, tauri::http::StatusCode::FORBIDDEN); +} + +#[test] +fn a_frame_cannot_read_another_projects_published_avatars() { + let temp = TempDir::new().unwrap(); + let other = ProjectCanvasPackageRequest { + community_id: "community-b".to_string(), + project_id: format!("30621:{OWNER}:other-project"), + }; + let runtime = people_runtime(&temp, request()); + let other_binding = ProjectBinding::parse(other.clone()).unwrap(); + write_people_package(&source_root(&temp, &other_binding), "other"); + let pubkey = avatar_pubkey(4); + runtime + .publish_avatars( + request(), + vec![avatar_input(&pubkey, "image/png", &png_bytes(32))], + ) + .unwrap(); + + let foreign = runtime + .get_or_activate(other, Some(&bundled_template().unwrap())) + .unwrap(); + let (status, _) = protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/{pubkey}", foreign.load_id), + ) + .unwrap_err(); + assert_eq!(status, tauri::http::StatusCode::NOT_FOUND); +} + +#[test] +fn an_uppercase_pubkey_in_the_url_resolves_and_a_malformed_one_is_rejected() { + let temp = TempDir::new().unwrap(); + let runtime = people_runtime(&temp, request()); + let descriptor = runtime + .get_or_activate(request(), Some(&bundled_template().unwrap())) + .unwrap(); + let pubkey = avatar_pubkey(0xabc); + runtime + .publish_avatars( + request(), + vec![avatar_input( + &pubkey.to_uppercase(), + "image/png", + &png_bytes(32), + )], + ) + .unwrap(); + + assert!(protocol::route( + &runtime, + &format!( + "/{}/__buzz/avatar/{}", + descriptor.load_id, + pubkey.to_uppercase() + ), + ) + .is_ok()); + let (status, _) = protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/not-a-pubkey", descriptor.load_id), + ) + .unwrap_err(); + assert_eq!(status, tauri::http::StatusCode::BAD_REQUEST); +} + +#[test] +fn publishing_rejects_types_and_bytes_that_are_not_the_image_they_claim() { + let temp = TempDir::new().unwrap(); + let runtime = people_runtime(&temp, request()); + runtime + .get_or_activate(request(), Some(&bundled_template().unwrap())) + .unwrap(); + let pubkey = avatar_pubkey(5); + + let error = runtime + .publish_avatars( + request(), + vec![avatar_input(&pubkey, "image/svg+xml", b"")], + ) + .unwrap_err(); + assert!(error.contains("unsupported project canvas avatar type")); + + // A declared type the bytes do not match is the case `nosniff` alone would + // let through if it ever regressed. + let error = runtime + .publish_avatars( + request(), + vec![avatar_input(&pubkey, "image/png", b"hi")], + ) + .unwrap_err(); + assert!(error.contains("are not image/png data")); + + let error = runtime + .publish_avatars( + request(), + vec![avatar_input(&pubkey, "image/png", &png_bytes(64 * 1024))], + ) + .unwrap_err(); + assert!(error.contains("too large")); + + let error = runtime + .publish_avatars( + request(), + vec![avatar_input("beef", "image/png", &png_bytes(32))], + ) + .unwrap_err(); + assert!(error.contains("64 hex characters")); +} + +#[test] +fn a_malformed_entry_leaves_the_previously_published_avatars_intact() { + let temp = TempDir::new().unwrap(); + let runtime = people_runtime(&temp, request()); + let descriptor = runtime + .get_or_activate(request(), Some(&bundled_template().unwrap())) + .unwrap(); + let good = avatar_pubkey(6); + runtime + .publish_avatars( + request(), + vec![avatar_input(&good, "image/png", &png_bytes(32))], + ) + .unwrap(); + + // The batch is validated before the store is touched, so one bad entry + // must not cost the people already published their pictures. + assert!(runtime + .publish_avatars( + request(), + vec![ + avatar_input(&avatar_pubkey(8), "image/png", &png_bytes(32)), + avatar_input("nope", "image/png", &png_bytes(32)), + ], + ) + .is_err()); + assert!(protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/{good}", descriptor.load_id) + ) + .is_ok()); + assert!(protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/{}", descriptor.load_id, avatar_pubkey(8)) + ) + .is_err()); +} + +#[test] +fn the_avatar_store_evicts_the_oldest_entries_past_its_ceiling() { + let temp = TempDir::new().unwrap(); + let runtime = people_runtime(&temp, request()); + let descriptor = runtime + .get_or_activate(request(), Some(&bundled_template().unwrap())) + .unwrap(); + let oldest = avatar_pubkey(100); + runtime + .publish_avatars( + request(), + vec![avatar_input(&oldest, "image/png", &png_bytes(32))], + ) + .unwrap(); + for index in 0..MAX_PUBLISHED_AVATARS { + runtime + .publish_avatars( + request(), + vec![avatar_input( + &avatar_pubkey(200 + index), + "image/png", + &png_bytes(32), + )], + ) + .unwrap(); + } + + let (status, _) = protocol::route( + &runtime, + &format!("/{}/__buzz/avatar/{oldest}", descriptor.load_id), + ) + .unwrap_err(); + assert_eq!(status, tauri::http::StatusCode::NOT_FOUND); + assert!(protocol::route( + &runtime, + &format!( + "/{}/__buzz/avatar/{}", + descriptor.load_id, + avatar_pubkey(200 + MAX_PUBLISHED_AVATARS - 1) + ) + ) + .is_ok()); +} diff --git a/desktop/src-tauri/src/project_canvas_package/tests/containment.rs b/desktop/src-tauri/src/project_canvas_package/tests/containment.rs new file mode 100644 index 00000000000..e3ac0a8cc8b --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/tests/containment.rs @@ -0,0 +1,378 @@ +//! What the host serves a canvas frame and what it refuses: declared-script +//! bootstrap, capability gating, path containment, the document security +//! policy, and the native navigation and update-socket seams. + +use std::fs; + +use tempfile::TempDir; + +use super::super::{ + allow_webview_navigation, ipc, protocol, + storage::{prepare_snapshot, ProjectBinding}, + ProjectCanvasRuntime, +}; +use super::{request, source_root, write_package, OWNER}; + +#[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!(bootstrap.contains("sdk: {}")); + assert!(!protocol::DOCUMENT_CSP.contains("'unsafe-inline'")); + + // The host SDK loads before any package resource so packages can use + // window.buzzCanvas.sdk from their first statement. + let scripts_list = bootstrap + .split("const scripts = [") + .nth(1) + .and_then(|rest| rest.split(']').next()) + .unwrap(); + assert!(scripts_list.starts_with("\"./__buzz/sdk.js\",")); + let styles_list = bootstrap + .split("const styles = [") + .nth(1) + .and_then(|rest| rest.split(']').next()) + .unwrap(); + assert!(styles_list.starts_with("\"./__buzz/sdk.css\",")); +} + +#[test] +fn host_sdk_routes_serve_the_bundled_sources() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "sdk"); + 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 (content_type, sdk_js) = + protocol::route(&runtime, &format!("/{}/__buzz/sdk.js", descriptor.load_id)).unwrap(); + assert_eq!(content_type, "text/javascript; charset=utf-8"); + let sdk_js = String::from_utf8(sdk_js).unwrap(); + assert!(sdk_js.contains("canvas.subscribe")); + assert!(sdk_js.contains("host.subscriptionUpdate")); + // The SDK must not start the port: the package entry owns port.start(), + // and starting it early would drop host.init for later listeners. + assert!(!sdk_js.contains("port.start()")); + + let (content_type, sdk_css) = + protocol::route(&runtime, &format!("/{}/__buzz/sdk.css", descriptor.load_id)).unwrap(); + assert_eq!(content_type, "text/css; charset=utf-8"); + assert!(String::from_utf8(sdk_css) + .unwrap() + .contains("--buzz-background")); +} + +#[test] +fn manifest_accepts_the_full_capability_set_and_rejects_unknown_ones() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "capabilities"); + let manifest_path = source.join("manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap(); + // Must accept every capability the desktop protocol schema recognizes. + manifest["capabilities"] = serde_json::json!([ + "project.metadata.read", + "project.channels.read", + "project.reviews.read", + "project.tasks.read", + "project.people.read", + "project.tasks.write", + "app.open", + "app.dm.send" + ]); + fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_ok()); + + manifest["capabilities"] = serde_json::json!(["network"]); + fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err(); + assert!(error.contains("unsupported project canvas capability")); +} + +#[test] +fn invalid_or_undeclared_package_files_fail_closed() { + let temp = TempDir::new().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "bad"); + fs::write(source.join("index.html"), "").unwrap(); + + let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).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 binding = ProjectBinding::parse(request()).unwrap(); + let source = source_root(&temp, &binding); + write_package(&source, "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( + source.join("manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).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!(allow_webview_navigation( + &"buzz-canvas://localhost/load/".parse().unwrap(), + None + )); + assert!(allow_webview_navigation( + &"tauri://localhost/".parse().unwrap(), + None + )); + assert!(allow_webview_navigation( + &"about:blank".parse().unwrap(), + None + )); + assert!(!allow_webview_navigation( + &"https://example.com/leak?snapshot=secret".parse().unwrap(), + None + )); + assert!(!allow_webview_navigation( + &"file:///tmp/secret".parse().unwrap(), + None + )); +} + +// The dev server load is the webview's *initial* navigation, so a policy that +// does not recognise the configured origin opens a blank window. Every `just` +// desktop recipe derives a per-worktree Vite port, so the origin the app is +// launched on is never the `tauri.conf.json` default. +#[test] +fn native_navigation_policy_allows_the_configured_dev_server() { + let dev_url: tauri::Url = "http://localhost:30164".parse().unwrap(); + + assert!(allow_webview_navigation( + &"http://localhost:30164/".parse().unwrap(), + Some(&dev_url) + )); + assert!(allow_webview_navigation( + &"http://localhost:30164/index.html".parse().unwrap(), + Some(&dev_url) + )); +} + +#[test] +fn native_navigation_policy_blocks_other_localhost_origins() { + let dev_url: tauri::Url = "http://localhost:30164".parse().unwrap(); + + // Some other server on the loopback interface is not the frontend. + assert!(!allow_webview_navigation( + &"http://localhost:1420/".parse().unwrap(), + Some(&dev_url) + )); + assert!(!allow_webview_navigation( + &"http://127.0.0.1:30164/".parse().unwrap(), + Some(&dev_url) + )); + // Release builds have no dev server, so plain http stays blocked. + assert!(!allow_webview_navigation( + &"http://localhost:30164/".parse().unwrap(), + None + )); +} + +// `start` runs on the main thread from Tauri's `setup` hook, with no Tokio +// runtime in context. Registering the socket with the reactor there aborts the +// whole app on launch, so the handoff has to survive a plain sync caller. +#[cfg(unix)] +#[test] +fn agent_update_socket_serves_when_started_outside_the_async_runtime() { + use std::{ + os::unix::net::{UnixListener as StdUnixListener, UnixStream as StdUnixStream}, + sync::mpsc, + time::Duration, + }; + + let temp = TempDir::new().unwrap(); + let socket_path = temp.path().join("agent-updates.sock"); + let listener = StdUnixListener::bind(&socket_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + + let (accepted, wait) = mpsc::channel(); + ipc::spawn_serving(listener, socket_path.clone(), move |listener| async move { + if listener.accept().await.is_ok() { + let _ = accepted.send(()); + } + }); + + let _client = StdUnixStream::connect(&socket_path).unwrap(); + wait.recv_timeout(Duration::from_secs(5)) + .expect("socket bound before the runtime handoff should still accept connections"); +} diff --git a/desktop/src-tauri/src/project_canvas_package/tests/packaging.rs b/desktop/src-tauri/src/project_canvas_package/tests/packaging.rs new file mode 100644 index 00000000000..a1e71155a63 --- /dev/null +++ b/desktop/src-tauri/src/project_canvas_package/tests/packaging.rs @@ -0,0 +1,475 @@ +//! Package activation, the bundled template, and the validation and byte +//! limits every package must clear before it is served. + +use std::{collections::BTreeSet, fs, path::Path}; + +use tempfile::TempDir; + +use super::super::{ + manifest::{validate_relative_path, MAX_DATA_BYTES, MAX_FILE_BYTES, MAX_PACKAGE_FILES}, + storage::{ + active_snapshot, commit_snapshot, prepare_snapshot, prune_revisions, record_source_binding, + scan_package_for_test, ProjectBinding, + }, + template::{bundled_template, template_files}, + ProjectCanvasAgentUpdateRequest, ProjectCanvasPackageRequest, ProjectCanvasRuntime, + ProjectCanvasUpdateChange, +}; +use super::{request, source_root, template_package, write_package, OWNER}; + +#[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 = template_package("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-"))); +} + +// Binds the embedded seed bytes to the loader, so a template edit that fails +// manifest validation surfaces here instead of on the first canvas load. +#[test] +fn bundled_template_seeds_a_valid_snapshot() { + let temp = TempDir::new().unwrap(); + let template = bundled_template().unwrap(); + let binding = ProjectBinding::parse(request()).unwrap(); + + let snapshot = + prepare_snapshot(&temp.path().join("CANVASES"), &binding, Some(&template)).unwrap(); + + assert!(snapshot.data["dashboards"].is_object()); +} + +// `include_dir!` expands to one `include_bytes!` per file, so a file added to +// or removed from the template only reaches the binary if the build script's +// `rerun-if-changed` fired. Compare the embedded key set against the tree on +// disk to catch a stale or partial embed. +#[test] +fn bundled_template_contains_the_expected_tree() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("project-canvas-template"); + let mut expected = BTreeSet::new(); + collect_relative_paths(&root, &root, &mut expected); + + let embedded = template_files() + .unwrap() + .into_keys() + .collect::>(); + + assert_eq!(embedded, expected); +} + +fn collect_relative_paths(root: &Path, directory: &Path, paths: &mut BTreeSet) { + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + collect_relative_paths(root, &path, paths); + continue; + } + if path.file_name() == Some(std::ffi::OsStr::new(".DS_Store")) { + continue; + } + paths.insert( + path.strip_prefix(root) + .unwrap() + .to_str() + .unwrap() + .replace(std::path::MAIN_SEPARATOR, "/"), + ); + } +} + +// The embedded template must clear the same path gate as any package read off +// disk — it is seeded through the identical validation, not a weaker one. +#[test] +fn bundled_template_paths_are_package_safe() { + for path in template_files().unwrap().keys() { + assert_eq!( + validate_relative_path(path).as_deref(), + Ok(path.as_str()), + "{path}" + ); + assert!(!Path::new(path).is_absolute(), "{path}"); + assert!(!path.contains('\\'), "{path}"); + assert!(!path.split('/').any(|segment| segment == ".."), "{path}"); + } +} + +// The missing-directory arm has to run before the symlink inspection, or an +// absent package dies inside `symlink_metadata` with an unnamed `os error 2` — +// exactly the failure that made a vanished template unreadable to debug. +#[test] +fn missing_package_directory_names_the_path() { + let temp = TempDir::new().unwrap(); + let absent = temp.path().join("absent-revision"); + + let error = scan_package_for_test(temp.path(), &absent).unwrap_err(); + + assert!(error.contains("does not exist"), "{error}"); + assert!(error.contains("absent-revision"), "{error}"); +} + +// A symlinked package root is refused even when it points at a valid package: +// the existence check reads non-following metadata, so it cannot mask the +// symlink arm behind a `Path::is_dir()` that follows links. +#[cfg(unix)] +#[test] +fn package_root_symlink_is_rejected() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let real = temp.path().join("real-package"); + write_package(&real, "root-symlink"); + let linked = temp.path().join("linked-package"); + symlink(&real, &linked).unwrap(); + + assert!(scan_package_for_test(temp.path(), &real).is_ok()); + let error = scan_package_for_test(temp.path(), &linked).unwrap_err(); + assert!(error.contains("cannot be symlinks"), "{error}"); +} + +#[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(), None).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")); +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 05dc5553397..995297963a8 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": { 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 ( { @@ -16,7 +15,6 @@ export const Route = createFileRoute("/projects/$projectId")({ }); function ProjectDetailRouteComponent() { - usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); const { commitHash, filePath, pullRequestId, issueId, repositoryId, tab } = Route.useSearch(); diff --git a/desktop/src/app/routes/projects.tsx b/desktop/src/app/routes/projects.tsx index cc9f215490b..bd6a7064a96 100644 --- a/desktop/src/app/routes/projects.tsx +++ b/desktop/src/app/routes/projects.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const ProjectsScreen = React.lazy(async () => { @@ -14,7 +13,6 @@ export const Route = createFileRoute("/projects")({ }); 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..b93d2a64738 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPaneMainColumn.tsx @@ -0,0 +1,93 @@ +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, + hideRightHeader = false, +}: { + children: React.ReactNode; + hideRightHeader?: boolean; +}) { + const channelView = useChannelViewOverride(); + const mainColumnHeader = channelView?.mainColumnHeader; + const headerOnRight = + Boolean(mainColumnHeader) && + channelView?.mainColumnHeaderPlacement === "right"; + 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 3ccabdf7536..ede9cf4d44e 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; @@ -198,7 +201,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..cafb2321eb0 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelViewOverrideContext.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +type ChannelViewOverride = { + headerNavigation: React.ReactNode; + hideMainColumnBody?: boolean; + isChannelViewActive: boolean; + mainColumnHeader?: React.ReactNode; + mainColumnHeaderPlacement?: "top" | "right"; + 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/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index eaff2546c23..cb92d6cb3a4 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -365,19 +365,27 @@ export function resolveAvatarDataUrl( } /** - * Fetches an avatar from `avatarProxyUrl` and converts it to a base64 data URL. + * Upper bound on a fetched avatar's encoded bytes. Keeps localStorage usage + * bounded across communities and accounts, and bounds the work any single + * decode can be asked to do. + */ +const MAX_AVATAR_BLOB_BYTES = 256 * 1024; + +/** + * Fetches an avatar from `avatarProxyUrl` as an image blob. * * The caller should pass the `rewriteRelayUrl()`-proxied URL and invoke this * only immediately after a successful profile fetch — at that moment the relay * is known to be reachable, so the fetch has the best chance of succeeding. * - * The data URL is capped at 256 KB to keep localStorage usage bounded across - * communities and accounts. Returns null on ANY failure: network error, non-OK - * response, wrong content-type, blob too large, or FileReader error. + * Returns null on ANY failure: network error, non-OK response, wrong + * content-type, or blob too large. Callers that need the raw bytes (to + * re-encode at a smaller size, say) use this; callers that want the image + * verbatim use {@link fetchAvatarDataUrl}. */ -export async function fetchAvatarDataUrl( +export async function fetchAvatarBlob( avatarProxyUrl: string, -): Promise { +): Promise { try { const response = await fetch(avatarProxyUrl); if (!response.ok) return null; @@ -386,18 +394,34 @@ export async function fetchAvatarDataUrl( if (!contentType.startsWith("image/")) return null; const blob = await response.blob(); - if (blob.size > 256 * 1024) return null; + if (blob.size > MAX_AVATAR_BLOB_BYTES) return null; - return await new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result; - resolve(typeof result === "string" ? result : null); - }; - reader.onerror = () => resolve(null); - reader.readAsDataURL(blob); - }); + return blob; } catch { return null; } } + +/** + * Fetches an avatar from `avatarProxyUrl` and converts it to a base64 data URL. + * + * Shares {@link fetchAvatarBlob}'s fetch policy, so the same 256 KB ceiling and + * failure modes apply. Returns null on any failure, including a FileReader + * error. + */ +export async function fetchAvatarDataUrl( + avatarProxyUrl: string, +): Promise { + const blob = await fetchAvatarBlob(avatarProxyUrl); + if (!blob) return null; + + return await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + resolve(typeof result === "string" ? result : null); + }; + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); +} 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/issueMutations.ts b/desktop/src/features/projects/issueMutations.ts index 0e6aa513e18..a73bef05e67 100644 --- a/desktop/src/features/projects/issueMutations.ts +++ b/desktop/src/features/projects/issueMutations.ts @@ -4,7 +4,13 @@ import { relayClient } from "@/shared/api/relayClient"; import { signRelayEvent } from "@/shared/api/tauri"; import { KIND_GIT_ISSUE } from "@/shared/constants/kinds"; import type { Repository as Project } from "./hooks"; -import { buildGitIssueTags } from "./projectIssues.mjs"; +import { useProjectIssueWriteInvalidation } from "./issueAssignments"; +import { + buildGitIssueTags, + buildProjectIssueStatusEventTemplate, + type ProjectIssue, + type ProjectIssueLifecycleStatus, +} from "./projectIssues.mjs"; import type { ProjectTaskCategory } from "./projectTaskCategories"; type CreateProjectIssueInput = { @@ -59,3 +65,46 @@ export function useCreateProjectIssueMutation( }, }); } + +// Same trust rule as PR status changes (allowedActorsForRoot): only the issue +// author or repo owner are honored by the status reduction, so the event is +// published as the signed-in identity and simply won't take effect for others. +export async function updateProjectIssueStatus( + project: Project, + issue: ProjectIssue, + status: ProjectIssueLifecycleStatus, +): Promise { + const event = await signRelayEvent( + buildProjectIssueStatusEventTemplate({ + issue, + now: Math.floor(Date.now() / 1_000), + repoAddress: project.repoAddress, + repoOwner: project.owner, + status, + }), + ); + await relayClient.publishEvent( + event, + "Timed out updating task status.", + "Failed to update task status.", + ); +} + +export function useUpdateProjectIssueStatusMutation( + project: Project | null | undefined, +) { + const invalidate = useProjectIssueWriteInvalidation(project); + return useMutation({ + mutationFn: ({ + issue, + status, + }: { + issue: ProjectIssue; + status: ProjectIssueLifecycleStatus; + }) => { + if (!project) throw new Error("No project selected."); + return updateProjectIssueStatus(project, issue, status); + }, + onSuccess: invalidate, + }); +} diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index f5a7349fe7b..d9f39f5b359 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -80,3 +80,23 @@ export function buildGitStatusTags(input: { repoAddress?: string | null; repoOwner?: string | null; }): string[][]; + +export type ProjectIssueLifecycleStatus = "open" | "done" | "closed" | "draft"; + +export const ISSUE_STATUS_KIND_BY_LIFECYCLE: Record< + ProjectIssueLifecycleStatus, + number +>; + +export function buildProjectIssueStatusEventTemplate(input: { + issue: ProjectIssue; + now: number; + repoAddress?: string | null; + repoOwner?: string | null; + status: ProjectIssueLifecycleStatus; +}): { + kind: number; + content: string; + createdAt: number; + tags: string[][]; +}; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 67fd3ca5af4..1a59f378ffa 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -277,6 +277,39 @@ export function buildGitIssueTags({ return tags; } +// NIP-34 status kinds. 1633 ("Draft") is surfaced as Triage for issues; see +// the status reduction above. +export const ISSUE_STATUS_KIND_BY_LIFECYCLE = { + closed: 1632, + done: 1631, + draft: 1633, + open: 1630, +}; + +/** + * Unsigned template for an issue lifecycle status event. `createdAt` is + * bumped past the issue's latest observed activity so the newest-wins status + * reduction cannot resurrect a stale state. + */ +export function buildProjectIssueStatusEventTemplate({ + issue, + now, + repoAddress, + repoOwner, + status, +}) { + const kind = ISSUE_STATUS_KIND_BY_LIFECYCLE[status]; + if (!kind) { + throw new Error(`Unsupported task status: ${status}`); + } + return { + kind, + content: "", + createdAt: Math.max(now, issue.updatedAt + 1), + tags: buildGitStatusTags({ issueId: issue.id, repoAddress, repoOwner }), + }; +} + export function buildGitStatusTags({ issueId, repoAddress, repoOwner }) { if (!/^[a-fA-F0-9]{64}$/.test(issueId)) { throw new Error("Task ID must be 64 hex characters."); diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 936f6d60134..fd2ebcdc734 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -3,10 +3,12 @@ import test from "node:test"; import { buildGitIssueTags, + buildProjectIssueStatusEventTemplate, eventToProjectIssue, getAllTags, getTag, ISSUE_ASSIGNMENT_LABEL, + ISSUE_STATUS_KIND_BY_LIFECYCLE, ISSUE_UNASSIGNMENT_LABEL, nextProjectIssueCommentCreatedAt, PROJECT_ISSUE_STATUS, @@ -469,3 +471,49 @@ test("orders consecutive issue comments across whole-second timestamps", () => { assert.equal(nextProjectIssueCommentCreatedAt(issue, 200, AUTHOR), 202); assert.equal(nextProjectIssueCommentCreatedAt(issue, 300, AUTHOR), 300); }); + +test("status templates map lifecycle states to NIP-34 kinds and outrun stale activity", () => { + const issue = { id: "e".repeat(64), updatedAt: 500 }; + const template = buildProjectIssueStatusEventTemplate({ + issue, + now: 100, + repoAddress: REPO_ADDRESS, + repoOwner: OWNER, + status: "done", + }); + assert.equal(template.kind, 1631); + assert.equal(template.content, ""); + // Newest-wins reduction: the status event must postdate observed activity. + assert.equal(template.createdAt, 501); + assert.deepEqual(template.tags, [ + ["e", issue.id, "", "root"], + ["a", REPO_ADDRESS], + ["p", OWNER], + ]); + + const fresh = buildProjectIssueStatusEventTemplate({ + issue, + now: 900, + repoAddress: REPO_ADDRESS, + repoOwner: OWNER, + status: "open", + }); + assert.equal(fresh.kind, 1630); + assert.equal(fresh.createdAt, 900); + + assert.deepEqual(ISSUE_STATUS_KIND_BY_LIFECYCLE, { + closed: 1632, + done: 1631, + draft: 1633, + open: 1630, + }); + assert.throws(() => + buildProjectIssueStatusEventTemplate({ + issue, + now: 900, + repoAddress: REPO_ADDRESS, + repoOwner: OWNER, + status: "merged", + }), + ); +}); 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..06a7da44b65 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -1,35 +1,52 @@ +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 { + 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 { useLiveProjectWorkItems } from "@/features/projects/useLiveProjectWorkItems"; 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 { 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 { + fetchCanvasAvatarDataUrl, + selectAvatarsWithinBudget, + toCanvasAvatarUploads, +} from "./project-canvas/canvasAvatars"; +import { ProjectCanvasSurface } from "./project-canvas/ProjectCanvasSurface"; +import type { ProjectCanvasOpenTarget } from "./project-canvas/projectCanvasBroker"; +import { + publishProjectCanvasAvatars, + type ProjectCanvasPackageRequest, +} from "./project-canvas/projectCanvasCommands"; +import type { ProjectCanvasSnapshots } from "./project-canvas/projectCanvasProtocol"; +import { useProjectCanvasBroker } from "./project-canvas/useProjectCanvasBroker"; +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 +55,24 @@ 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; +/** Avatars inlined into the channel snapshot, bounded by the RPC ceiling. */ +const MAX_CANVAS_AVATARS = 8; +/** + * Avatars fetched and published for frames to load by pubkey. Published + * pictures cost nothing in the snapshot, so this is bounded by fetch volume + * rather than by message size, and it stays inside the backend's + * per-project store. + */ +const MAX_CANVAS_PUBLISHED_AVATARS = 32; + +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 +82,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 +93,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, goProfile, goProject } = useAppNavigation(); + const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const channelsQuery = useChannelsQuery(); @@ -108,10 +108,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 +118,373 @@ 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 [canvasWorkspaceSelection, setCanvasWorkspaceSelection] = + React.useState<{ + issueId?: string; + pullRequestId?: string; + seq: number; + } | null>(null); + 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 canvasRequest = React.useMemo( + () => + activeCommunity?.id && project.projectAddress + ? { communityId: activeCommunity.id, projectId: project.projectAddress } + : null, + [activeCommunity?.id, project.projectAddress], + ); + 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_PUBLISHED_AVATARS), + [canvasProfilePubkeys, canvasProfilesQuery.data], + ); + const canvasAvatarQueries = useQueries({ + queries: canvasAvatarCandidates.map(({ pubkey, snapshotUrl }) => ({ + enabled: canvasRequest !== null, + gcTime: 10 * 60_000, + queryFn: async () => { + const dataUrl = await fetchCanvasAvatarDataUrl(snapshotUrl); + // Publish before resolving. Resolving is what updates the snapshot, + // and the snapshot update is what re-renders the widget — so the bytes + // are registered before any frame can request them, whichever order + // the frame and the fetch happened to complete in. + if (dataUrl && canvasRequest) { + await publishProjectCanvasAvatars( + canvasRequest, + toCanvasAvatarUploads([{ dataUrl, pubkey }]), + ); + } + return dataUrl; + }, + // Scoped to the project: a cached hit from another project would report + // an avatar as published that was never published for this one. + queryKey: [ + "project-canvas-avatar", + canvasRequest?.communityId ?? null, + canvasRequest?.projectId ?? null, + pubkey, + snapshotUrl, + ], + staleTime: 10 * 60_000, + })), + }); + const canvasAvatarDataByPubkey = React.useMemo(() => { + // The channel snapshot ships these to the frame in one RPC message, so the + // combined ceiling applies here exactly as it does to a people lookup. + const budgeted = selectAvatarsWithinBudget( + canvasAvatarCandidates.map((_candidate, index) => { + const dataUrl = canvasAvatarQueries[index]?.data; + return dataUrl?.startsWith("data:image/") ? dataUrl : null; + }), + ); + const avatars = new Map(); + canvasAvatarCandidates.forEach((candidate, index) => { + const dataUrl = budgeted[index]; + if (dataUrl) 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)); + setActiveView(view); }, - [], + [workspaceRepository], ); - 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 }); + React.useEffect(() => { + if (!projectChannelViewEnabled(activeView, channelFeatures.enabled)) { + selectView("chat"); + } + }, [activeView, channelFeatures.enabled, selectView]); + + useLiveProjectWorkItems(project); + const handleCanvasOpenTarget = React.useCallback( + (target: ProjectCanvasOpenTarget) => { + if (target.type === "channel") { + void goChannel(target.id); + return; + } + if (target.type === "user") { + void goProfile(target.pubkey); return; } - openWorkspaceSheet(tab, repositoryId); + setCanvasWorkspaceSelection((current) => ({ + ...(target.type === "task" + ? { issueId: target.id } + : { pullRequestId: target.id }), + seq: (current?.seq ?? 0) + 1, + })); + selectView(target.type === "task" ? "issues" : "reviews"); }, - [goProject, openWorkspaceSheet, project.id], + [goChannel, goProfile, selectView], ); + const canvasBroker = useProjectCanvasBroker({ + canvasRequest, + identityPubkey: identityQuery.data?.pubkey, + issues: { + data: channelFeatures.issuesQuery.data, + isError: channelFeatures.issuesQuery.isError, + isPending: channelFeatures.issuesQuery.isPending, + }, + onOpenTarget: handleCanvasOpenTarget, + primaryRepository: channelFeatures.primaryRepository, + relayUrl: activeCommunity?.relayUrl, + reviews: { + data: canvasReviewsQuery.data, + isError: canvasReviewsQuery.isError, + isPending: canvasReviewsQuery.isPending, + }, + snapshots: canvasSnapshots, + }); + 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 +510,231 @@ 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, + mainColumnHeaderPlacement: + activeView === "chat" ? "right" : "top", + mainContent, + onSelectChannelView: () => selectView("chat"), + }} + > + + + +
+
) : (

@@ -402,41 +752,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 080ec5aa32a..3dbba585b4e 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/ProjectHomeWorkspaceSheet.tsx b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx index eca3cb6a614..870f0fd542c 100644 --- a/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx +++ b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx @@ -46,6 +46,8 @@ export type ProjectHomeWorkspaceDetail = { export function ProjectHomeWorkspaceSheet({ identityPubkey, + initialIssueId = null, + initialPullRequestId = null, onCreateActionChange, onDetailChange, onOpenCommit, @@ -57,6 +59,9 @@ export function ProjectHomeWorkspaceSheet({ tab, }: { identityPubkey?: string; + /** Preselected work item (e.g. canvas navigation). Remount to change. */ + initialIssueId?: string | null; + initialPullRequestId?: string | null; onCreateActionChange?: ( action: ProjectHomeWorkspaceCreateAction | null, ) => void; @@ -72,11 +77,11 @@ export function ProjectHomeWorkspaceSheet({ const { goProject } = useAppNavigation(); const { activeCommunity } = useCommunities(); const [selectedIssueId, setSelectedIssueId] = React.useState( - null, + initialIssueId, ); const [selectedPullRequestId, setSelectedPullRequestId] = React.useState< string | null - >(null); + >(initialPullRequestId); const [selectedCommitHash, setSelectedCommitHash] = React.useState< string | null >(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 ? (