diff --git a/Cargo.lock b/Cargo.lock index b5b74db5..36bbc772 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]] @@ -1450,6 +1454,7 @@ dependencies = [ "ui", "universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)", "wasm-bindgen", + "wasm-bindgen-futures", "workspace", ] 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/web/Cargo.toml b/web/Cargo.toml index 69597762..94237a8d 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -30,6 +30,7 @@ console_error_panic_hook = "0.1" tracing-wasm = "0.2" console_log = "1.0" wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" universal-time = { git = "https://github.com/shadowylab/universal-time" } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/web/src/lib.rs b/web/src/lib.rs index df97a5d4..8ffd4955 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::*; @@ -19,8 +23,44 @@ impl MonotonicClock for CustomTimeProvider { define_time_provider!(CustomTimeProvider); +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 run() -> Result<(), JsValue> { +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> { console_error_panic_hook::set_once(); // Initialize logging to browser console @@ -37,16 +77,61 @@ pub fn run() -> Result<(), JsValue> { #[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; + + 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 +163,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..3384959d 100644 --- a/web/www/vite.config.js +++ b/web/www/vite.config.js @@ -13,13 +13,17 @@ export default defineConfig({ src: path.resolve(__dirname, "../../../assets/icons"), dest: "assets", }, + { + src: path.resolve(__dirname, "../../../assets/brand"), + dest: "assets", + }, ], }), { name: "serve-assets", configureServer(server) { server.middlewares.use( - "/coop/assets", + "/assets", (req, res, next) => { const assetsPath = path.resolve(__dirname, "../../../assets"); const filePath = path.join(