diff --git a/Cargo.lock b/Cargo.lock index bc84014d..459f07dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,9 +276,13 @@ name = "assets" version = "1.0.0" dependencies = [ "anyhow", + "futures", "gpui", "log", + "reqwest", "rust-embed", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -1247,7 +1251,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "gpui_util", "indexmap", @@ -1441,6 +1445,7 @@ dependencies = [ "gpui_platform", "gpui_web", "instant", + "js-sys", "log", "person", "settings", @@ -1450,6 +1455,8 @@ dependencies = [ "ui", "universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)", "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "workspace", ] @@ -1761,7 +1768,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "proc-macro2", "quote", @@ -2658,7 +2665,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "accesskit", "anyhow", @@ -2743,7 +2750,7 @@ dependencies = [ [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "block", @@ -2766,7 +2773,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "accesskit", "accesskit_unix", @@ -2812,7 +2819,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "accesskit", "accesskit_macos", @@ -2858,7 +2865,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2869,7 +2876,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "console_error_panic_hook", "gpui", @@ -2882,7 +2889,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "schemars", "serde", @@ -2892,7 +2899,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "gpui", @@ -2903,7 +2910,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "log", @@ -2913,7 +2920,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "console_error_panic_hook", @@ -2937,7 +2944,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "bytemuck", @@ -2963,7 +2970,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "accesskit", "accesskit_windows", @@ -3141,9 +3148,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -3238,7 +3245,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "async-compression", @@ -3258,7 +3265,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "log", "rustls", @@ -3942,9 +3949,9 @@ dependencies = [ [[package]] name = "lyon_algorithms" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" dependencies = [ "lyon_path", "num-traits", @@ -3973,9 +3980,9 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +checksum = "dabea159dc6eea9171a541b458134b70ca95e3162d068132b2b3b9bc01aad06e" dependencies = [ "float_next_after", "lyon_path", @@ -4037,7 +4044,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "bindgen", @@ -4593,6 +4600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4963,7 +4971,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "collections", "serde", @@ -4977,6 +4985,7 @@ dependencies = [ "anyhow", "common", "flume 0.11.1", + "futures", "gpui", "instant", "log", @@ -5734,7 +5743,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "derive_refineable", ] @@ -5816,7 +5825,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "bytes", @@ -6117,7 +6126,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "async-task", "backtrace", @@ -6711,7 +6720,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "heapless 0.9.3", "log", @@ -7750,7 +7759,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "perf", "quote", @@ -7789,9 +7798,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.14.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d4788a546b899f8656382fe210e0c478fe3d2dcd6263d50cb6cc268240de6c" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" dependencies = [ "value-bag-serde1", "value-bag-sval2", @@ -8929,7 +8938,6 @@ version = "1.0.0" dependencies = [ "anyhow", "auto_update", - "browser-signer-proxy", "chat", "chat_ui", "common", @@ -9426,7 +9434,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "anyhow", "chrono", @@ -9443,7 +9451,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" dependencies = [ "tracing", "tracing-subscriber", @@ -9454,7 +9462,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#bce0c5785bfd9172c939aca4083fd70bc4930927" +source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 32c9b9ae..252e54a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,6 @@ smallvec = "1.14.0" smol = "2" webbrowser = "1.0.4" tracing-subscriber = { version = "0.3.18", features = ["fmt"] } -errno = { version = "0.3.14", default-features = false } instant = "0.1" [patch.crates-io] diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 3e3d11fd..6ba48188 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -8,4 +8,12 @@ publish.workspace = true gpui.workspace = true anyhow.workspace = true log.workspace = true + +[target.'cfg(not(target_family = "wasm"))'.dependencies] rust-embed.workspace = true + +[target.'cfg(target_family = "wasm")'.dependencies] +futures.workspace = true +reqwest = { version = "0.12", default-features = false } +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Location"] } diff --git a/crates/assets/build.rs b/crates/assets/build.rs new file mode 100644 index 00000000..18546b2e --- /dev/null +++ b/crates/assets/build.rs @@ -0,0 +1,58 @@ +//! Generates a compile-time manifest of the asset files served on wasm, so +//! the web entrypoint can preload them before the first frame. +//! +//! `WASM_ASSETS` is emitted into `OUT_DIR` and included by +//! `src/wasm_assets.rs` on wasm targets. Native builds keep using +//! `rust-embed` and ignore it. + +use std::path::Path; +use std::{env, fs}; + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by cargo"); + let assets_dir = Path::new(&manifest_dir).join("../../assets"); + + let mut paths = Vec::new(); + for dir in ["icons", "brand"] { + let dir_path = assets_dir.join(dir); + let entries = fs::read_dir(&dir_path).unwrap_or_else(|error| { + panic!( + "expected asset directory {} to exist: {error}", + dir_path.display() + ) + }); + + for entry in entries { + let entry = entry.expect("failed to read asset directory entry"); + if entry.file_type().is_ok_and(|t| t.is_file()) { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with('.') { + paths.push(format!("{dir}/{name}")); + } + } + } + } + paths.sort(); + + let manifest = format!( + "/// Asset files served by the wasm asset loader. Generated by build.rs.\npub const WASM_ASSETS: &[&str] = &[\n{}\n];\n", + paths + .iter() + .map(|path| format!(" \"{path}\",")) + .collect::>() + .join("\n") + ); + + let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set by cargo"); + fs::write(Path::new(&out_dir).join("wasm_assets.rs"), manifest) + .expect("failed to write wasm asset manifest"); + + // Rerun when the asset files change (adding/removing files updates the + // directory mtime). + for dir in ["icons", "brand"] { + if let Ok(canonical) = assets_dir.join(dir).canonicalize() { + println!("cargo:rerun-if-changed={}", canonical.display()); + } + } + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 8229e2af..ce870a57 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -1,51 +1,19 @@ -use anyhow::Context; -use gpui::{App, AssetSource, Result, SharedString}; -use rust_embed::RustEmbed; +//! Application assets for Coop. +//! +//! ## Platform differences +//! +//! - **Native (desktop)**: assets are embedded into the binary at compile time +//! with `rust-embed`. +//! - **WASM (web)**: assets are downloaded on demand from `{endpoint}/assets/{path}` +//! and cached in memory. This keeps the WASM bundle size small. -#[derive(RustEmbed)] -#[folder = "../../assets"] -#[include = "fonts/**/*"] -#[include = "brand/**/*"] -#[include = "icons/**/*"] -#[include = "themes/**/*"] -#[exclude = "*.DS_Store"] -pub struct Assets; +#[cfg(not(target_family = "wasm"))] +mod native_assets; -impl AssetSource for Assets { - fn load(&self, path: &str) -> Result>> { - Self::get(path) - .map(|f| Some(f.data)) - .with_context(|| format!("loading asset at path {path:?}")) - } +#[cfg(target_family = "wasm")] +mod wasm_assets; - fn list(&self, path: &str) -> Result> { - Ok(Self::iter() - .filter_map(|p| { - if p.starts_with(path) { - Some(p.into()) - } else { - None - } - }) - .collect()) - } -} - -impl Assets { - /// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory. - pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> { - let font_paths = self.list("fonts")?; - let mut embedded_fonts = Vec::new(); - for font_path in font_paths { - if font_path.ends_with(".ttf") { - let font_bytes = cx - .asset_source() - .load(&font_path)? - .expect("Assets should never return None"); - embedded_fonts.push(font_bytes); - } - } - - cx.text_system().add_fonts(embedded_fonts) - } -} +#[cfg(not(target_family = "wasm"))] +pub use native_assets::Assets; +#[cfg(target_family = "wasm")] +pub use wasm_assets::Assets; diff --git a/crates/assets/src/native_assets.rs b/crates/assets/src/native_assets.rs new file mode 100644 index 00000000..2f20deac --- /dev/null +++ b/crates/assets/src/native_assets.rs @@ -0,0 +1,63 @@ +use std::borrow::Cow; + +use anyhow::Context; +use gpui::{App, AssetSource, Result, SharedString}; +use rust_embed::RustEmbed; + +/// Native implementation using `rust-embed`: assets are embedded into the +/// binary at compile time. +#[derive(RustEmbed)] +#[folder = "../../assets"] +#[include = "fonts/**/*"] +#[include = "brand/**/*"] +#[include = "icons/**/*"] +#[include = "themes/**/*"] +#[exclude = "*.DS_Store"] +pub struct Assets; + +impl Assets { + /// Create a new Assets instance. The endpoint parameter is ignored for + /// native builds. + pub fn new(_endpoint: impl Into) -> Self { + Self + } +} + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result>> { + Self::get(path) + .map(|f| Some(f.data)) + .with_context(|| format!("loading asset at path {path:?}")) + } + + fn list(&self, path: &str) -> Result> { + Ok(Self::iter() + .filter_map(|p| { + if p.starts_with(path) { + Some(p.into()) + } else { + None + } + }) + .collect()) + } +} + +impl Assets { + /// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory. + pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> { + let font_paths = self.list("fonts")?; + let mut embedded_fonts = Vec::new(); + for font_path in font_paths { + if font_path.ends_with(".ttf") { + let font_bytes = cx + .asset_source() + .load(&font_path)? + .expect("Assets should never return None"); + embedded_fonts.push(font_bytes); + } + } + + cx.text_system().add_fonts(embedded_fonts) + } +} diff --git a/crates/assets/src/wasm_assets.rs b/crates/assets/src/wasm_assets.rs new file mode 100644 index 00000000..f7cb58df --- /dev/null +++ b/crates/assets/src/wasm_assets.rs @@ -0,0 +1,176 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use gpui::{AssetSource, Result, SharedString}; +use wasm_bindgen_futures::spawn_local; + +// Compile-time manifest of every asset file served on wasm (see build.rs). +include!(concat!(env!("OUT_DIR"), "/wasm_assets.rs")); + +/// Path prefixes that the wasm loader serves. Fonts and themes are not +/// downloaded on web: the web platform bundles its own fonts, and the theme +/// registry falls back to the built-in default theme. +const SERVED_PREFIXES: [&str; 2] = ["icons/", "brand/"]; + +/// WASM implementation - download assets on demand. +/// +/// Assets are fetched from `{endpoint}/assets/{path}` and cached in memory +/// after the first successful download. This keeps the WASM bundle small +/// while still providing the full asset set at runtime. +pub struct Assets { + endpoint: SharedString, + cache: Arc>>>, + pending: Arc>>, +} + +impl Assets { + /// Create a new Assets instance backed by the given endpoint. + /// + /// Assets are resolved as `{endpoint}/assets/{path}`. An empty endpoint + /// resolves against the current page origin (e.g. `/assets/icons/foo.svg`). + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + cache: Arc::new(RwLock::new(HashMap::new())), + pending: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Absolute URL of the given asset path. + /// + /// `reqwest` requires absolute URLs, so a relative endpoint is resolved + /// against the current page origin. + fn asset_url(&self, path: &str) -> String { + let endpoint = if self.endpoint.is_empty() { + web_sys::window() + .and_then(|window| window.location().origin().ok()) + .unwrap_or_default() + } else { + self.endpoint.to_string() + }; + format!("{endpoint}/assets/{path}") + } + + /// Download every asset in [`WASM_ASSETS`] into the cache, in parallel, + /// before the app starts. + /// + /// Preloading is required for two reasons: + /// - Assets loaded through GPUI's [`gpui::Asset`] machinery (e.g. `img()`) + /// cache failed loads and never retry them. + /// - SVG painting only re-attempts an empty load on the next repaint, so + /// an icon would stay invisible until the window happens to redraw. + pub async fn preload(&self) { + let downloads = WASM_ASSETS.iter().map(|path| async move { + let result = reqwest::get(self.asset_url(path)).await; + match result { + Ok(response) if response.status().is_success() => match response.bytes().await { + Ok(bytes) => { + if let Ok(mut cache) = self.cache.write() { + cache.insert(path.to_string(), bytes.to_vec()); + } + } + Err(e) => { + log::warn!("Failed to read asset {}: {}", path, e); + } + }, + Ok(response) => { + log::warn!( + "Failed to download asset {}: HTTP {}", + path, + response.status() + ); + } + Err(e) => { + log::warn!("Failed to fetch asset {}: {}", path, e); + } + } + }); + futures::future::join_all(downloads).await; + } +} + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result>> { + if path.is_empty() { + return Ok(None); + } + + // Only serve paths the web build actually ships. + if !SERVED_PREFIXES + .iter() + .any(|prefix| path.starts_with(prefix)) + { + return Ok(None); + } + + // Serve from the in-memory cache when available. + if let Ok(cache) = self.cache.read() { + if let Some(data) = cache.get(path) { + return Ok(Some(Cow::Owned(data.clone()))); + } + } + + // Kick off a single download per path; concurrent requests for the + // same path share it. + let is_pending = self + .pending + .read() + .map(|pending| pending.contains_key(path)) + .unwrap_or(false); + + if !is_pending { + if let Ok(mut pending) = self.pending.write() { + pending.insert(path.to_string(), true); + } + + let url = self.asset_url(path); + let path_clone = path.to_string(); + let cache = self.cache.clone(); + let pending = self.pending.clone(); + + spawn_local(async move { + match reqwest::get(&url).await { + Ok(response) if response.status().is_success() => { + match response.bytes().await { + Ok(bytes) => { + if let Ok(mut cache) = cache.write() { + cache.insert(path_clone.clone(), bytes.to_vec()); + } + } + Err(e) => { + log::warn!("Failed to read asset {}: {}", path_clone, e); + } + } + } + Ok(response) => { + log::warn!( + "Failed to download asset {}: HTTP {}", + path_clone, + response.status() + ); + } + Err(e) => { + log::warn!("Failed to fetch asset {}: {}", path_clone, e); + } + } + + // Allow retrying failed downloads on subsequent requests. + if let Ok(mut pending) = pending.write() { + pending.remove(&path_clone); + } + }); + } + + // The asset is not available yet. GPUI's SVG atlas does not cache + // empty loads, so the next repaint will call `load` again and find + // the asset in the cache once the download completes. + Ok(None) + } + + fn list(&self, _path: &str) -> Result> { + // The asset manifest is not available at runtime on web; embedded + // directories are not listed. + Ok(Vec::new()) + } +} diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index ae5134b2..2600026f 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -233,7 +233,7 @@ impl ChatRegistry { if event.kind == Kind::InboxRelays { let current_user = signer.get_public_key_async().await?; if event.pubkey == current_user { - tx.send_async(Signal::InboxReady).await?; + tx.send_async(Signal::InboxReady).await.ok(); } } @@ -262,24 +262,24 @@ impl ChatRegistry { if rumor.tags.is_empty() { let signal = Signal::error(&event, "Recipient is missing"); - tx.send_async(signal).await?; + tx.send_async(signal).await.ok(); } // Emit message for both new and backlog events let signal = Signal::message(event.id, rumor); - tx.send_async(signal).await?; + tx.send_async(signal).await.ok(); } Err(e) => { let reason = format!("Failed to extract rumor: {e}"); let signal = Signal::error(event.as_ref(), reason); - tx.send_async(signal).await?; + tx.send_async(signal).await.ok(); } } } RelayMessage::EndOfStoredEvents(id) if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) => { - tx.send_async(Signal::Eose).await?; + tx.send_async(Signal::Eose).await.ok(); } _ => {} } @@ -290,33 +290,41 @@ impl ChatRegistry { self.signal_consumer = Some(cx.spawn(async move |this, cx| { while let Ok(message) = rx.recv_async().await { - match message { - Signal::Message(message) => { - this.update(cx, |this, cx| { - this.new_message(message, cx); - })?; + // `update_in` (rather than `update`) routes through a + // try-borrow: on wasm a task poll that lands while the app + // context is borrowed can't panic and kill this consumer + // (which would stall all message delivery). + this.update_in(cx, |this, _window, cx| { + // Drain the whole queue in a single update so a burst of + // events (e.g. history sync after login) collapses into + // one repaint instead of one per message (important on + // wasm, where everything runs on the main thread). + let mut batch = vec![message]; + while let Ok(extra) = rx.try_recv() { + batch.push(extra); } - Signal::InboxReady => { - this.update(cx, |this, cx| { - this.get_messages(cx); - })?; - } - Signal::Eose => { - this.update(cx, |this, _cx| { - this.tracking.store(false, Ordering::Release); - })?; - this.update(cx, |this, cx| { - this.get_rooms(cx); - })?; + for message in batch { + match message { + Signal::Message(message) => { + this.new_message(message, cx); + } + Signal::InboxReady => { + this.get_messages(cx); + } + Signal::Eose => { + this.tracking.store(false, Ordering::Release); + this.get_rooms(cx); + } + Signal::Error(failed) => { + let _ = trash.update(cx, |this, cx| { + this.insert(failed); + cx.notify(); + }); + } + }; } - Signal::Error(failed) => { - trash.update(cx, |this, cx| { - this.insert(failed); - cx.notify(); - })?; - } - }; + })?; } Ok(()) @@ -370,7 +378,7 @@ impl ChatRegistry { .is_some(); if !found { - this.update(cx, |_this, cx| { + this.update_in(cx, |_this, _window, cx| { cx.emit(ChatEvent::InboxRelayNotFound); })?; } @@ -421,7 +429,7 @@ impl ChatRegistry { }); if let Err(e) = task.await { - this.update(cx, |_this, cx| { + this.update_in(cx, |_this, _window, cx| { cx.emit(ChatEvent::Error(e.to_string())); })?; } @@ -430,6 +438,7 @@ impl ChatRegistry { })); } + /// Get all messages for the provided signer /// Reload the chat registry, fetching messages and contact list from relays. pub fn reload(&mut self, cx: &mut Context) { self.reset(cx); @@ -634,13 +643,13 @@ impl ChatRegistry { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(rooms) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.extend_rooms(rooms, cx); this.sort(cx); })?; } Err(e) => { - this.update(cx, |_, cx| { + this.update_in(cx, |_, _window, cx| { cx.emit(ChatEvent::Error(e.to_string())); })?; } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 16c81352..f3c3b047 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -242,33 +242,44 @@ impl ChatPanel { while let Ok(status) = rx.recv_async().await { { let mut map = reports.write().unwrap(); - let status_id = match &*status { - SendStatus::Ok { id, .. } => *id, - SendStatus::Failed { id, .. } => *id, - }; - // Find the matching report and update it (exit early on first match) - 'outer: for reports_list in map.values_mut() { - for report in reports_list.iter_mut() { - let Some(output) = report.output.as_mut() else { - continue; - }; - if *output.id() != status_id { - continue; - } - match &*status { - SendStatus::Ok { relay, .. } => { - output.success.insert(relay.clone(), EventSendStatus::Sent); + // Drain the whole queue in a single update so bursts of + // send statuses collapse into one repaint (important on + // wasm, where everything runs on the main thread). + let mut statuses = vec![status]; + while let Ok(extra) = rx.try_recv() { + statuses.push(extra); + } + + for status in statuses { + let status_id = match &*status { + SendStatus::Ok { id, .. } => *id, + SendStatus::Failed { id, .. } => *id, + }; + + // Find the matching report and update it (exit early on first match) + 'outer: for reports_list in map.values_mut() { + for report in reports_list.iter_mut() { + let Some(output) = report.output.as_mut() else { + continue; + }; + if *output.id() != status_id { + continue; } - SendStatus::Failed { relay, message, .. } => { - output.failed.insert(relay.clone(), message.clone()); + match &*status { + SendStatus::Ok { relay, .. } => { + output.success.insert(relay.clone(), EventSendStatus::Sent); + } + SendStatus::Failed { relay, message, .. } => { + output.failed.insert(relay.clone(), message.clone()); + } } + break 'outer; } - break 'outer; } } } - this.update(cx, |_, cx| cx.notify()).ok(); + this.update_in(cx, |_, _window, cx| cx.notify()).ok(); } Ok(()) })); @@ -325,8 +336,10 @@ impl ChatPanel { self.tasks.push(cx.spawn(async move |this, cx| { let events = get_messages.await?; - // Update message list - this.update(cx, |this, cx| { + // Update message list. `update_in` (rather than `update`) routes + // through a try-borrow: on wasm a task poll that lands while the + // app context is borrowed can't panic and kill this task. + this.update_in(cx, |this, _window, cx| { this.insert_messages(&events, cx); })?; @@ -468,7 +481,10 @@ impl ChatPanel { let mut sent_ids = sent_ids.lock().await; sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id)); - this.update(cx, |this, cx| { + // `update_in` (rather than `update`) routes through a try-borrow: + // on wasm a poll that lands while the app context is borrowed + // can't panic and kill this task. + this.update_in(cx, |this, _window, cx| { this.insert_reports(id, outputs, cx); })?; @@ -639,7 +655,7 @@ impl ChatPanel { }); self.tasks.push(cx.spawn_in(window, async move |this, cx| { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_uploading(true, cx); })?; diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 84f9b079..f8e61ad3 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -184,7 +184,7 @@ impl DeviceRegistry { } // New response event from the master device Kind::Custom(4455) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.extract_encryption(event, cx); })?; } @@ -272,7 +272,7 @@ impl DeviceRegistry { return Ok(()); } - this.update(cx, |_this, cx| { + this.update_in(cx, |_this, _window, cx| { cx.emit(DeviceEvent::NotSet); })?; @@ -287,13 +287,13 @@ impl DeviceRegistry { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(keys) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_signer(keys, cx); this.wait_for_request(cx); })?; } Err(e) => { - this.update(cx, |_this, cx| { + this.update_in(cx, |_this, _window, cx| { cx.emit(DeviceEvent::error(e.to_string())); })?; } @@ -358,12 +358,12 @@ impl DeviceRegistry { self.tasks.push(cx.spawn(async move |this, cx| { if let Ok(keys) = task.await { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_signer(keys, cx); this.wait_for_request(cx); })?; } else { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.request(cx); })?; } @@ -439,17 +439,17 @@ impl DeviceRegistry { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(Some(event)) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.extract_encryption(event, cx); })?; } Ok(None) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.wait_for_approval(cx); })?; } Err(e) => { - this.update(cx, |_this, cx| { + this.update_in(cx, |_this, _window, cx| { cx.emit(DeviceEvent::error(e.to_string())); })?; } @@ -508,12 +508,12 @@ impl DeviceRegistry { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(keys) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_signer(keys, cx); })?; } Err(e) => { - this.update(cx, |_this, cx| { + this.update_in(cx, |_this, _window, cx| { cx.emit(DeviceEvent::error(e.to_string())); })?; } diff --git a/crates/person/Cargo.toml b/crates/person/Cargo.toml index c1385580..3be9ed32 100644 --- a/crates/person/Cargo.toml +++ b/crates/person/Cargo.toml @@ -15,3 +15,4 @@ anyhow.workspace = true smallvec.workspace = true flume.workspace = true log.workspace = true +futures.workspace = true diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 28e41e4a..1757bfc1 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -3,7 +3,8 @@ use std::sync::RwLock; use anyhow::{Error, anyhow}; use common::EventExt; -use gpui::{App, AppContext, Context, Entity, Global, Task, Window}; +use futures::FutureExt; +use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, Global, Task, Window}; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -72,24 +73,40 @@ impl PersonRegistry { })); let client3 = client.clone(); + let executor = cx.background_executor().clone(); tasks.push(cx.background_spawn(async move { - Self::handle_requests(&client3, &metadata_rx).await; + Self::handle_requests(&client3, &metadata_rx, &executor).await; })); tasks.push(cx.spawn(async move |this, cx| { while let Ok(event) = rx.recv_async().await { - this.update(cx, |this, cx| { - match event { - Dispatch::Person(person) => { - this.insert(person, cx); - } - Dispatch::Announcement(event) => { - this.set_announcement(&event, cx); - } - Dispatch::Relays(event) => { - this.set_messaging_relays(&event, cx); - } - }; + // `update_in` (rather than `update`) routes through a + // try-borrow: on wasm a task poll that lands while the app + // context is borrowed can't panic and kill this consumer + // (which would stall the whole metadata pipeline). + this.update_in(cx, |this, _window, cx| { + // Drain the whole queue in a single update so a burst of + // events collapses into one repaint instead of one per + // event (important on wasm, where everything runs on the + // main thread). + let mut dispatch = vec![event]; + while let Ok(extra) = rx.try_recv() { + dispatch.push(extra); + } + + for event in dispatch { + match event { + Dispatch::Person(person) => { + this.insert(person, cx); + } + Dispatch::Announcement(event) => { + this.set_announcement(&event, cx); + } + Dispatch::Relays(event) => { + this.set_messaging_relays(&event, cx); + } + }; + } }) .ok(); } @@ -156,30 +173,43 @@ impl PersonRegistry { } /// Handle request for metadata - async fn handle_requests(client: &Client, rx: &flume::Receiver) { + /// + /// Requests are collected into batches and flushed when the batch is + /// full or the timeout expires. + /// + /// Note: `flume::Selector::wait_timeout` is intentionally not used here: + /// it relies on `std::time::Instant` and `thread::park_timeout`, which are + /// unavailable on `wasm32-unknown-unknown` (the former panics, the latter + /// is a no-op that would turn the wait into a busy loop on the main + /// thread). + async fn handle_requests( + client: &Client, + rx: &flume::Receiver, + executor: &BackgroundExecutor, + ) { let mut batch: HashSet = HashSet::new(); loop { - match flume::Selector::new() - .recv(rx, |result| result.ok()) - .wait_timeout(Duration::from_secs(TIMEOUT)) + // Wait for the next request, or the batch timeout. + futures::select! { + result = rx.recv_async() => match result { + Ok(public_key) => { + batch.insert(public_key); + // Keep collecting until the batch is full + if batch.len() < 20 { + continue; + } + } + Err(_) => return, + }, + _ = executor.timer(Duration::from_secs(TIMEOUT)).fuse() => {} + } + + // Flush the batch + if !batch.is_empty() + && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await { - Ok(Some(public_key)) => { - batch.insert(public_key); - // Process the batch if it's full - if batch.len() >= 20 - && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await - { - log::warn!("Failed to get metadata batch: {e}"); - } - } - _ => { - if !batch.is_empty() - && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await - { - log::warn!("Failed to get metadata batch: {e}"); - } - } + log::warn!("Failed to get metadata batch: {e}"); } } } @@ -205,7 +235,7 @@ impl PersonRegistry { self.tasks.push(cx.spawn(async move |this, cx| { if let Ok(persons) = task.await { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.bulk_insert(persons, cx); }) .ok(); diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index f597a197..cb93d18b 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -105,7 +105,7 @@ impl Person { /// Get profile avatar pub fn avatar(&self) -> SharedString { - self.metadata() + self.metadata .picture .as_ref() .filter(|picture| !picture.is_empty()) @@ -115,13 +115,13 @@ impl Person { /// Get profile name pub fn name(&self) -> SharedString { - if let Some(display_name) = self.metadata().display_name.as_ref() + if let Some(display_name) = self.metadata.display_name.as_ref() && !display_name.is_empty() { return SharedString::from(display_name.trim()); } - if let Some(name) = self.metadata().name.as_ref() + if let Some(name) = self.metadata.name.as_ref() && !name.is_empty() { return SharedString::from(name.trim()); diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 23fa04ae..d9afb86a 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -3,8 +3,10 @@ use std::collections::HashMap; use anyhow::{Error, anyhow}; #[cfg(not(target_arch = "wasm32"))] use browser_signer_proxy::prelude::*; +#[cfg(not(target_arch = "wasm32"))] use common::config_dir; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window}; +#[cfg(not(target_arch = "wasm32"))] use gpui_tokio::Tokio; use instant::Duration; use nostr_connect::prelude::*; @@ -179,7 +181,7 @@ impl NostrRegistry { let task = cx.spawn(async move |this, cx| { match new_signer.get_public_key_async().await { Ok(public_key) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.signer.swap_inner(new_signer); this.current_user = Some(public_key); cx.emit(StateEvent::SignerChanged); @@ -187,7 +189,7 @@ impl NostrRegistry { })?; } Err(e) => { - this.update(cx, |_this, cx| { + this.update_in(cx, |_this, _window, cx| { cx.emit(StateEvent::error(e.to_string())); })?; } @@ -224,7 +226,7 @@ impl NostrRegistry { self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = task.await { - this.update(cx, |_this, cx| { + this.update_in(cx, |_this, _window, cx| { cx.emit(StateEvent::error(e.to_string())); })?; } @@ -246,7 +248,7 @@ impl NostrRegistry { let secret_key = SecretKey::parse(&content)?; let keys = Keys::new(secret_key); - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_signer(keys, cx); cx.notify(); })?; @@ -261,19 +263,19 @@ impl NostrRegistry { // Handle auth url with the default browser signer.auth_url_handler(CoopAuthUrlHandler); - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_signer(signer, cx); cx.notify(); })?; } else if content == "proxy" { #[cfg(not(target_arch = "wasm32"))] - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.connect_proxy(cx); })?; } } _ => { - this.update(cx, |_, cx| { + this.update_in(cx, |_, _window, cx| { cx.emit(StateEvent::NoSigner); })?; } @@ -350,7 +352,7 @@ impl NostrRegistry { let proxy = proxy.clone(); async move |this, cx| { while let Ok(url) = rx.recv_async().await { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy"); cx.background_spawn(async move { save.await.ok() }).detach(); cx.open_url(&url); @@ -372,7 +374,7 @@ impl NostrRegistry { loop { executor.timer(Duration::from_secs(5)).await; if !proxy.is_session_active() { - _ = this.update(cx, |this, cx| { + _ = this.update_in(cx, |this, _window, cx| { // Only notify if this proxy is still the active signer if this.current_user.is_some() { this.signer.swap_inner(Keys::generate()); diff --git a/crates/ui/src/checkbox.rs b/crates/ui/src/checkbox.rs index 1ce8e19e..a1645074 100644 --- a/crates/ui/src/checkbox.rs +++ b/crates/ui/src/checkbox.rs @@ -1,16 +1,16 @@ use std::rc::Rc; -use instant::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId, - InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, - StatefulInteractiveElement, StyleRefinement, Styled, Window, + Animation, AnimationExt, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, + Window, div, px, relative, rems, svg, }; +use instant::Duration; use theme::ActiveTheme; use crate::icon::IconNamed; -use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _}; +use crate::{Disableable, IconName, Selectable, Sizable, Size, StyledExt as _, v_flex}; /// A Checkbox element. #[allow(clippy::type_complexity)] @@ -172,10 +172,16 @@ pub(crate) fn checkbox_check_icon( if !disabled && checked != *toggle_state.read(cx) { let duration = Duration::from_secs_f64(0.25); cx.spawn({ - let toggle_state = toggle_state.clone(); + let toggle_state = toggle_state.downgrade(); async move |cx| { cx.background_executor().timer(duration).await; - toggle_state.update(cx, |this, _| *this = checked); + // `update_in` (rather than `update`) routes through a + // try-borrow: on wasm a task poll that lands while + // the app context is borrowed can't panic and kill + // this task. + toggle_state + .update_in(cx, |this, _window, _| *this = checked) + .ok(); } }) .detach(); diff --git a/crates/ui/src/input/blink_cursor.rs b/crates/ui/src/input/blink_cursor.rs index a0c2c979..da9b03f5 100644 --- a/crates/ui/src/input/blink_cursor.rs +++ b/crates/ui/src/input/blink_cursor.rs @@ -1,6 +1,5 @@ -use instant::Duration; - use gpui::{Context, Pixels, Task, px}; +use instant::Duration; static INTERVAL: Duration = Duration::from_millis(500); static PAUSE_DELAY: Duration = Duration::from_millis(300); @@ -63,9 +62,11 @@ impl BlinkCursor { let epoch = self.next_epoch(); self._task = cx.spawn(async move |this, cx| { cx.background_executor().timer(INTERVAL).await; - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| this.blink(epoch, cx)); - } + // `update_in` (rather than `update`) routes through a try-borrow: + // on wasm a task poll that lands while the app context is + // borrowed can't panic and kill this recurring task. + this.update_in(cx, |this, _window, cx| this.blink(epoch, cx)) + .ok(); }); } @@ -85,12 +86,11 @@ impl BlinkCursor { self._task = cx.spawn(async move |this, cx| { cx.background_executor().timer(PAUSE_DELAY).await; - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| { - this.paused = false; - this.blink(epoch, cx); - }); - } + this.update_in(cx, |this, _window, cx| { + this.paused = false; + this.blink(epoch, cx); + }) + .ok(); }); } } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 6a75fb25..d6e779fe 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -14,15 +14,16 @@ chat = { path = "../chat" } chat_ui = { path = "../chat_ui" } settings = { path = "../settings" } person = { path = "../person" } -auto_update = { path = "../auto_update" } gpui.workspace = true nostr-sdk.workspace = true instant.workspace = true nostr-connect.workspace = true -browser-signer-proxy = { path = "../browser-signer-proxy" } anyhow.workspace = true serde.workspace = true log.workspace = true smallvec.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +auto_update = { path = "../auto_update" } diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index cb41d765..f2ba0e04 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -129,7 +129,7 @@ impl ImportIdentity { })?; } Err(e) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_error(e.to_string(), cx); })?; } @@ -172,6 +172,11 @@ impl ImportIdentity { }); } + // The "Connect via Web Extension" button is hidden on wasm (`is_wasm`), + // so this stub is never invoked in the browser. + #[cfg(target_arch = "wasm32")] + fn proxy(&mut self, _cx: &mut Context) {} + fn set_loading(&mut self, status: bool, cx: &mut Context) { self.loading = status; cx.notify(); @@ -193,7 +198,7 @@ impl ImportIdentity { self.tasks.push(cx.spawn(async move |this, cx| { cx.background_executor().timer(Duration::from_secs(3)).await; - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.error.update(cx, |this, cx| { *this = None; cx.notify(); diff --git a/crates/workspace/src/dialogs/restore.rs b/crates/workspace/src/dialogs/restore.rs index 958d387b..45babfbd 100644 --- a/crates/workspace/src/dialogs/restore.rs +++ b/crates/workspace/src/dialogs/restore.rs @@ -83,7 +83,7 @@ impl RestoreEncryption { self.tasks.push(cx.spawn(async move |this, cx| { cx.background_executor().timer(Duration::from_secs(3)).await; - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.error.update(cx, |this, cx| { *this = None; cx.notify(); diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 79738788..6a26049a 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -105,7 +105,7 @@ impl Screening { self.tasks.push(cx.spawn(async move |this, cx| { let result = task.await.unwrap_or(false); - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.followed = result; cx.notify(); }) @@ -139,7 +139,7 @@ impl Screening { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(contacts) => { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.mutual_contacts = contacts; cx.notify(); }) @@ -185,7 +185,7 @@ impl Screening { self.tasks.push(cx.spawn(async move |this, cx| { let result = task.await; - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.last_active = result; cx.notify(); }) @@ -208,7 +208,7 @@ impl Screening { self.tasks.push(cx.spawn(async move |this, cx| { let result = task.await.unwrap_or(false); - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.verified = result; cx.notify(); }) diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 668426e1..451663c4 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use ::settings::AppSettings; use anyhow::Error; +#[cfg(not(target_arch = "wasm32"))] use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry}; use common::{CoopImageCache, download_dir}; @@ -379,6 +380,7 @@ impl Workspace { Command::ImportEncryption => { self.import_encryption(window, cx); } + #[cfg(not(target_arch = "wasm32"))] Command::Update => { let auto_updater = AutoUpdater::global(cx); auto_updater.update(cx, |this, cx| { @@ -387,6 +389,9 @@ impl Workspace { }); }); } + // Auto-update is a desktop-only feature; no-op in the browser. + #[cfg(target_arch = "wasm32")] + Command::Update => {} } } @@ -563,7 +568,8 @@ impl Workspace { let avatar = avatar.clone(); let name = name.clone(); - this.min_w(px(256.)) + let menu = this + .min_w(px(256.)) .item(PopupMenuItem::element(move |_window, cx| { h_flex() .gap_1p5() @@ -593,24 +599,27 @@ impl Workspace { IconName::Sun, Box::new(Command::ToggleTheme), ) - .separator() - .menu_with_icon( - "Check for Updates", - IconName::Device, - Box::new(Command::Update), - ) - .menu_with_icon( - "Settings", - IconName::Settings, - Box::new(Command::ShowSettings), - ) + .separator(); + + // Auto-update is a desktop-only feature; there is no updater in the browser. + #[cfg(not(target_arch = "wasm32"))] + let menu = menu.menu_with_icon( + "Check for Updates", + IconName::Device, + Box::new(Command::Update), + ); + + menu.menu_with_icon( + "Settings", + IconName::Settings, + Box::new(Command::ShowSettings), + ) }), ) }) } fn titlebar_right(&mut self, cx: &mut Context) -> impl IntoElement { - let updater = AutoUpdater::global(cx); let chat = ChatRegistry::global(cx); let nip4e_enabled = AppSettings::get_nip4e(cx); let nostr = NostrRegistry::global(cx); @@ -622,15 +631,23 @@ impl Workspace { let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(&public_key, cx); let announcement = profile.announcement(); - let updater_idle = updater.read(cx).idle(cx); - h_flex() + let titlebar = h_flex() .when(!cx.theme().platform.is_mac(), |this| this.pr_2()) - .gap_2() - .when(!updater_idle, |this| { + .gap_2(); + + // Auto-update is a desktop-only feature; there is no updater in the browser. + #[cfg(not(target_arch = "wasm32"))] + let titlebar = { + let updater = AutoUpdater::global(cx); + let updater_idle = updater.read(cx).idle(cx); + titlebar.when(!updater_idle, |this| { let status = updater.read(cx).status(cx); this.child(div().text_xs().italic().child(status)) }) + }; + + titlebar .when(nip4e_enabled, |this| { this.child( Button::new("key") diff --git a/crates/workspace/src/panels/backup.rs b/crates/workspace/src/panels/backup.rs index 51dd1cae..36d33559 100644 --- a/crates/workspace/src/panels/backup.rs +++ b/crates/workspace/src/panels/backup.rs @@ -97,7 +97,7 @@ impl BackupPanel { cx.background_executor().timer(Duration::from_secs(2)).await; // Clear the error message after a delay - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_copied(false, cx); })?; diff --git a/crates/workspace/src/panels/contact_list.rs b/crates/workspace/src/panels/contact_list.rs index b6afb557..f046a1b1 100644 --- a/crates/workspace/src/panels/contact_list.rs +++ b/crates/workspace/src/panels/contact_list.rs @@ -108,8 +108,10 @@ impl ContactListPanel { self.tasks.push(cx.spawn_in(window, async move |this, cx| { let public_keys = task.await?; - // Update state - this.update(cx, |this, cx| { + // Update state. `update_in` (rather than `update`) routes through + // a try-borrow, so on wasm a poll that happens to land while the + // app context is borrowed can't panic and kill this task. + this.update_in(cx, |this, _window, cx| { this.contacts.extend(public_keys); cx.notify(); })?; @@ -148,8 +150,11 @@ impl ContactListPanel { self.tasks.push(cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(Duration::from_secs(2)).await; - // Clear the error message after a delay - this.update(cx, |this, cx| { + // Clear the error message after a delay. `update_in` (rather than + // `update`) routes through a try-borrow, so on wasm a poll that + // happens to land while the app context is borrowed can't panic + // and kill this task. + this.update_in(cx, |this, _window, cx| { this.error = None; cx.notify(); })?; diff --git a/crates/workspace/src/panels/messaging_relays.rs b/crates/workspace/src/panels/messaging_relays.rs index 93cde120..b933a4ab 100644 --- a/crates/workspace/src/panels/messaging_relays.rs +++ b/crates/workspace/src/panels/messaging_relays.rs @@ -103,8 +103,10 @@ impl MessagingRelayPanel { self.tasks.push(cx.spawn_in(window, async move |this, cx| { let relays = task.await?; - // Update state - this.update(cx, |this, cx| { + // Update state. `update_in` (rather than `update`) routes through + // a try-borrow: on wasm a poll that lands while the app context + // is borrowed can't panic and kill this task. + this.update_in(cx, |this, _window, cx| { this.relays.extend(relays); cx.notify(); })?; @@ -148,8 +150,11 @@ impl MessagingRelayPanel { self.tasks.push(cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(Duration::from_secs(2)).await; - // Clear the error message after a delay - this.update(cx, |this, cx| { + // Clear the error message after a delay. `update_in` (rather than + // `update`) routes through a try-borrow: on wasm a poll that + // lands while the app context is borrowed can't panic and kill + // this task. + this.update_in(cx, |this, _window, cx| { this.error = None; cx.notify(); })?; diff --git a/crates/workspace/src/panels/profile.rs b/crates/workspace/src/panels/profile.rs index a944c9cf..581374e0 100644 --- a/crates/workspace/src/panels/profile.rs +++ b/crates/workspace/src/panels/profile.rs @@ -167,7 +167,7 @@ impl ProfilePanel { }); self.tasks.push(cx.spawn_in(window, async move |this, cx| { - this.update(cx, |this, cx| { + this.update_in(cx, |this, _window, cx| { this.set_uploading(true, cx); })?; diff --git a/crates/workspace/src/panels/relay_list.rs b/crates/workspace/src/panels/relay_list.rs index 4e76bd4f..45fefee8 100644 --- a/crates/workspace/src/panels/relay_list.rs +++ b/crates/workspace/src/panels/relay_list.rs @@ -121,8 +121,10 @@ impl RelayListPanel { self.tasks.push(cx.spawn_in(window, async move |this, cx| { let relays = task.await?; - // Update state - this.update(cx, |this, cx| { + // Update state. `update_in` (rather than `update`) routes through + // a try-borrow: on wasm a poll that lands while the app context + // is borrowed can't panic and kill this task. + this.update_in(cx, |this, _window, cx| { this.relays.extend(relays); cx.notify(); })?; @@ -167,8 +169,11 @@ impl RelayListPanel { self.tasks.push(cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(Duration::from_secs(2)).await; - // Clear the error message after a delay - this.update(cx, |this, cx| { + // Clear the error message after a delay. `update_in` (rather than + // `update`) routes through a try-borrow: on wasm a poll that + // lands while the app context is borrowed can't panic and kill + // this task. + this.update_in(cx, |this, _window, cx| { this.error = None; cx.notify(); })?; diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index efb74af9..327ebb24 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -178,7 +178,10 @@ impl Sidebar { self.tasks.push(cx.spawn_in(window, async move |this, cx| { match task.await { Ok(contacts) => { - this.update(cx, |this, cx| { + // `update_in` (rather than `update`) routes through a + // try-borrow: on wasm a poll that lands while the app + // context is borrowed can't panic and kill this task. + this.update_in(cx, |this, _window, cx| { this.set_contact_list(contacts, cx); })?; } diff --git a/web/Cargo.toml b/web/Cargo.toml index 69597762..4495ef60 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -30,6 +30,9 @@ console_error_panic_hook = "0.1" tracing-wasm = "0.2" console_log = "1.0" wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Performance", "console", "DomException"] } universal-time = { git = "https://github.com/shadowylab/universal-time" } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/web/script/build-wasm.sh b/web/script/build-wasm.sh index 220b8bf8..a9b34152 100755 --- a/web/script/build-wasm.sh +++ b/web/script/build-wasm.sh @@ -36,6 +36,11 @@ if [[ "$(uname)" == "Darwin" ]]; then fi # Step 1: Build WASM +# +# Single-threaded build: `+bulk-memory` only. The multithreaded web backend +# is disabled in `web/src/lib.rs` (gpui's wasm workers freeze their JS event +# loop in `Atomics.wait`, which breaks nostr-sdk's spawn_local-driven client +# and the WebSocket transport), so no atomics/shared-memory flags here. echo -e "${GREEN}Step 1: Building WASM...${NC}" cd "$PROJECT_ROOT" export CARGO_TARGET_DIR="$PROJECT_ROOT/target" diff --git a/web/src/lib.rs b/web/src/lib.rs index df97a5d4..d4c0c56b 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,4 +1,8 @@ +use std::borrow::Cow; +use std::cell::RefCell; + use gpui::*; +use theme::{Theme, ThemeMode}; use ui::Root; use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider}; use wasm_bindgen::prelude::*; @@ -7,22 +11,64 @@ struct CustomTimeProvider; impl WallClock for CustomTimeProvider { fn system_time(&self) -> SystemTime { - SystemTime::from_unix_duration(instant::Duration::from_secs(0)) + // Browser wall clock: milliseconds since the Unix epoch. + let millis = js_sys::Date::now(); + SystemTime::from_unix_duration(instant::Duration::from_millis(millis as u64)) } } impl MonotonicClock for CustomTimeProvider { fn instant(&self) -> Instant { - Instant::from_ticks(instant::Duration::from_secs(0)) + // `performance.now()` is monotonic; fall back to the wall clock if + // it's unavailable. + let millis = web_sys::window() + .and_then(|window| window.performance()) + .map(|performance| performance.now()) + .unwrap_or_else(js_sys::Date::now); + Instant::from_ticks(instant::Duration::from_millis(millis as u64)) } } define_time_provider!(CustomTimeProvider); -#[wasm_bindgen] -pub fn run() -> Result<(), JsValue> { - console_error_panic_hook::set_once(); +thread_local! { + static APPLICATION: RefCell> = const { RefCell::new(None) }; +} +/// Applies a theme mode and restores the bundled web fonts. +/// +/// `Theme::change` reapplies the theme config, which can carry its own font +/// family; host system fonts are unavailable in wasm, so the bundled Inter +/// fonts are put back afterwards. +fn apply_theme(mode: ThemeMode, cx: &mut App) { + Theme::change(mode, None, cx); + Theme::global_mut(cx).font_family = "Inter".into(); +} + +/// Switches the app between light and dark after it is running. +/// +/// The embedding page calls this to keep the app in sync with its own +/// appearance. +#[cfg(target_family = "wasm")] +#[wasm_bindgen] +pub fn set_theme(dark: bool) { + let mode = if dark { + ThemeMode::Dark + } else { + ThemeMode::Light + }; + APPLICATION.with(|application| { + if let Some(handle) = application.borrow().as_ref() { + handle.update(|cx| { + apply_theme(mode, cx); + cx.refresh_windows(); + }); + } + }); +} + +#[wasm_bindgen] +pub async fn run() -> Result<(), JsValue> { // Initialize logging to browser console console_log::init_with_level(log::Level::Info).expect("Failed to initialize logger"); @@ -32,21 +78,92 @@ pub fn run() -> Result<(), JsValue> { #[cfg(target_family = "wasm")] gpui_platform::web_init(); + // Install the panic hook AFTER `web_init` (which sets the default + // `console_error_panic_hook`), so ours wins. It prints the entire + // JS/wasm stack as a single string: `console_error_panic_hook`'s default + // output is an `Error` object whose stack is collapsed in the console, + // and on wasm the frames below the panic machinery name the task that + // panicked (debug builds keep symbol names) — essential for diagnosing + // `RefCell already borrowed`. + #[cfg(target_family = "wasm")] + std::panic::set_hook(Box::new(|info| { + // Capture the JS stack (which includes the wasm frames with symbol + // names in debug builds) without constructing DOM objects. + let stack = js_sys::Reflect::get(&js_sys::Error::new(""), &"stack".into()) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(); + web_sys::console::error_1( + &format!("{info}\n\n==== full stack ====\n{stack}\n=====================").into(), + ); + })); + #[cfg(not(target_family = "wasm"))] let app = gpui_platform::application(); #[cfg(target_family = "wasm")] let app = { - let app = gpui_platform::single_threaded_web(); + // Assets are not embedded in the WASM bundle; they are served from + // the `/assets/...` URL prefix (see `web/www/vite.config.js`) and + // downloaded by the `assets` crate. + let assets = assets::Assets::new(""); - // Temporary fix: intentionally leak the `Rc` to keep the application alive - struct WasmApplication(std::rc::Rc); - let wasm_app = unsafe { std::mem::transmute::(app) }; - std::mem::forget(wasm_app.0.clone()); - unsafe { std::mem::transmute::(wasm_app) } + // Download every icon and brand asset before the first frame: brand + // images are loaded through GPUI's image cache, which does not retry + // failed loads, and pre-caching the icons lets them render + // immediately instead of waiting for a repaint. + assets.preload().await; + + // NOTE: the multithreaded web backend (application_with_web_backend) + // cannot host this app's backend. gpui's wasm background workers + // block on `Atomics.wait` while idle, freezing their JS event loop, + // so `spawn_local`-driven tasks (nostr-sdk's client actor, the + // WebSocket transport) and fetch promises never make progress on a + // worker thread. Everything must run on the main thread. + gpui_platform::single_threaded_web().with_assets(assets) }; - app.run(|cx| { + let launch = move |cx: &mut App| { + // Load the embedded Inter font stack for WASM, where host system + // fonts are unavailable. Inter is the app's UI font on Linux; the + // wasm build reuses it so the web app matches the desktop look. + let inter_regular = + Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-Regular.ttf").as_slice()); + let inter_italic = + Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-Italic.ttf").as_slice()); + let inter_medium = + Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-Medium.ttf").as_slice()); + let inter_medium_italic = Cow::Borrowed( + include_bytes!("../../assets/fonts/Inter/Inter-MediumItalic.ttf").as_slice(), + ); + let inter_semibold = + Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-SemiBold.ttf").as_slice()); + let inter_semibold_italic = Cow::Borrowed( + include_bytes!("../../assets/fonts/Inter/Inter-SemiBoldItalic.ttf").as_slice(), + ); + let inter_bold = + Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-Bold.ttf").as_slice()); + let inter_bold_italic = Cow::Borrowed( + include_bytes!("../../assets/fonts/Inter/Inter-BoldItalic.ttf").as_slice(), + ); + + cx.text_system() + .add_fonts(vec![ + inter_regular, + inter_italic, + inter_medium, + inter_medium_italic, + inter_semibold, + inter_semibold_italic, + inter_bold, + inter_bold_italic, + ]) + .expect("Failed to load fonts"); + + // Apply the system appearance before the first frame, so the app + // never flashes the default light theme. + apply_theme(cx.window_appearance().into(), cx); + // Open the root window cx.open_window(WindowOptions::default(), |window, cx| { // Initialize components @@ -78,7 +195,15 @@ pub fn run() -> Result<(), JsValue> { .expect("Failed to open window. Please restart the application."); cx.activate(true); + }; + + #[cfg(target_family = "wasm")] + APPLICATION.with(|application| { + *application.borrow_mut() = Some(app.run_embedded(launch)); }); + #[cfg(not(target_family = "wasm"))] + app.run(launch); + Ok(()) } diff --git a/web/www/vite.config.js b/web/www/vite.config.js index b5b0203a..3a0b38f6 100644 --- a/web/www/vite.config.js +++ b/web/www/vite.config.js @@ -10,7 +10,11 @@ export default defineConfig({ viteStaticCopy({ targets: [ { - src: path.resolve(__dirname, "../../../assets/icons"), + src: path.resolve(__dirname, "../../assets/icons"), + dest: "assets", + }, + { + src: path.resolve(__dirname, "../../assets/brand"), dest: "assets", }, ], @@ -19,9 +23,9 @@ export default defineConfig({ name: "serve-assets", configureServer(server) { server.middlewares.use( - "/coop/assets", + "/assets", (req, res, next) => { - const assetsPath = path.resolve(__dirname, "../../../assets"); + const assetsPath = path.resolve(__dirname, "../../assets"); const filePath = path.join( assetsPath, req.url.replace("/assets", ""),