From f829a2774d44f2a13e54cffec9ba094431ff7be9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 24 Oct 2026 19:37:04 +0700 Subject: [PATCH] fix web build --- Cargo.lock | 2 + crates/assets/src/lib.rs | 27 +++++++ web/Cargo.toml | 2 + web/script/build-wasm.sh | 55 +++++++++----- web/src/lib.rs | 153 +++++++++++++++++++++++++++++---------- 5 files changed, 182 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db46edf2..04fbcdc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1366,6 +1366,7 @@ dependencies = [ name = "coop_web" version = "2.0.0-alpha2" dependencies = [ + "assets", "chat", "console_error_panic_hook", "console_log", @@ -1377,6 +1378,7 @@ dependencies = [ "gpui-pre-platform", "gpui-pre-web", "instant", + "js-sys", "log", "nip29", "nip4e", diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 87548ef0..8cc8cc90 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -20,12 +20,39 @@ impl AssetSource for CombinedAssets { if let Ok(Some(data)) = Assets.load(path) { return Ok(Some(data)); } + + #[cfg(target_arch = "wasm32")] + { + // The kit bundle fetches icons over HTTP on the web; a single + // instance keeps its download cache alive across loads. + use std::sync::OnceLock; + static KIT_ASSETS: OnceLock = OnceLock::new(); + KIT_ASSETS + .get_or_init(gpui_kit::assets::Assets::default) + .load(path) + } + + #[cfg(not(target_arch = "wasm32"))] gpui_kit::assets::Assets.load(path) } fn list(&self, path: &str) -> Result> { let mut paths = Assets.list(path).unwrap_or_default(); + + #[cfg(target_arch = "wasm32")] + { + use std::sync::OnceLock; + static KIT_ASSETS: OnceLock = OnceLock::new(); + paths.extend( + KIT_ASSETS + .get_or_init(gpui_kit::assets::Assets::default) + .list(path)?, + ); + } + + #[cfg(not(target_arch = "wasm32"))] paths.extend(gpui_kit::assets::Assets.list(path)?); + Ok(paths) } } diff --git a/web/Cargo.toml b/web/Cargo.toml index 09df22c2..b4afec19 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true crate-type = ["cdylib", "rlib"] [dependencies] +assets = { path = "../crates/assets" } workspace = { path = "../crates/workspace" } ui = { path = "../crates/ui" } state = { path = "../crates/state" } @@ -29,6 +30,7 @@ console_error_panic_hook = "0.1" tracing-wasm = "0.2" console_log = "1.0" wasm-bindgen = "0.2" +js-sys = "0.3" 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..ed36e272 100755 --- a/web/script/build-wasm.sh +++ b/web/script/build-wasm.sh @@ -11,7 +11,8 @@ echo -e "${GREEN}Building Coop Web...${NC}" # Get the script directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -PROJECT_ROOT="$SCRIPT_DIR/.." +PROJECT_ROOT="$SCRIPT_DIR/../.." +WEB_ROOT="$SCRIPT_DIR/.." # Parse arguments RELEASE_FLAG="" @@ -20,27 +21,47 @@ if [[ "$1" == "--release" ]]; then echo -e "${YELLOW}Building in release mode${NC}" fi -# macOS: ensure LLVM from Homebrew is available +# wasm_thread requires a nightly compiler for its atomics features +CARGO="cargo +nightly" + +# Step 0: Toolchain prerequisites +echo -e "${GREEN}Step 0: Checking toolchain...${NC}" +rustup target add wasm32-unknown-unknown --toolchain nightly + if [[ "$(uname)" == "Darwin" ]]; then - echo -e "${GREEN}Detected macOS, setting up LLVM...${NC}" - if brew list llvm &>/dev/null; then - echo -e "${GREEN}LLVM already installed, skipping install${NC}" - else - echo -e "${YELLOW}LLVM not found, installing via Homebrew...${NC}" - brew install llvm + # secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and + # llvm-ar for archives comes from the rustup llvm-tools component + if ! xcrun --find clang &>/dev/null; then + echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}" + exit 1 fi - LLVM_PATH=$(brew --prefix llvm) - export AR="${LLVM_PATH}/bin/llvm-ar" - export CC="${LLVM_PATH}/bin/clang" - echo -e "${GREEN}LLVM path: ${LLVM_PATH}${NC}" + rustup component add llvm-tools-preview --toolchain nightly + + RUSTUP_HOME=$(rustup show home) + if [[ "$(uname -m)" == "arm64" ]]; then + HOST_ARCH="aarch64-apple-darwin" + else + HOST_ARCH="x86_64-apple-darwin" + fi + LLVM_BIN="$RUSTUP_HOME/toolchains/nightly-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin" + + export CC_wasm32_unknown_unknown=clang + export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar" + echo -e "${GREEN}Using Apple clang + rustup llvm-ar for wasm32 C code${NC}" +fi + +# Match wasm-bindgen-cli to the version in Cargo.lock +LOCKED_BINDGEN=$(grep -A1 '^name = "wasm-bindgen"$' "$PROJECT_ROOT/Cargo.lock" | grep version | cut -d'"' -f2) +if [[ "$(wasm-bindgen --version 2>/dev/null | awk '{print $2}')" != "$LOCKED_BINDGEN" ]]; then + echo -e "${YELLOW}Installing wasm-bindgen-cli $LOCKED_BINDGEN to match Cargo.lock...${NC}" + cargo install -f wasm-bindgen-cli --version "$LOCKED_BINDGEN" fi # Step 1: Build WASM echo -e "${GREEN}Step 1: Building WASM...${NC}" -cd "$PROJECT_ROOT" export CARGO_TARGET_DIR="$PROJECT_ROOT/target" RUSTFLAGS='-C target-feature=+bulk-memory -C link-arg=--max-memory=4294967296' \ -cargo build --target wasm32-unknown-unknown $RELEASE_FLAG + $CARGO build -p coop_web --target wasm32-unknown-unknown $RELEASE_FLAG # Determine the build directory if [[ "$RELEASE_FLAG" == "--release" ]]; then @@ -61,12 +82,12 @@ fi # Step 2: Generate JavaScript bindings echo -e "${GREEN}Step 2: Generating JavaScript bindings...${NC}" wasm-bindgen "$WASM_PATH" \ - --out-dir "$PROJECT_ROOT/www/wasm" \ + --out-dir "$WEB_ROOT/www/wasm" \ --target web \ --no-typescript echo -e "${GREEN}✓ Build completed successfully!${NC}" echo -e "${YELLOW}Next steps:${NC}" -echo -e " cd www" +echo -e " cd web/www" echo -e " deno install" -echo -e " deno run dev" +echo -e " deno task dev" diff --git a/web/src/lib.rs b/web/src/lib.rs index 168cece6..54422234 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,22 +1,33 @@ +use std::cell::RefCell; +use std::rc::Rc; + use gpui::*; use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider}; use wasm_bindgen::prelude::*; -struct CustomTimeProvider; +thread_local! { + /// Dropping the handle releases the app, so the page's lifetime must own it. + static APPLICATION: RefCell> = const { RefCell::new(None) }; +} -impl WallClock for CustomTimeProvider { +/// Serves `Date.now()` as the wall clock and `performance.now()` as the monotonic clock. +struct BrowserTimeProvider; + +impl WallClock for BrowserTimeProvider { fn system_time(&self) -> SystemTime { - SystemTime::from_unix_duration(instant::Duration::from_secs(0)) + SystemTime::from_unix_duration(core::time::Duration::from_secs_f64( + js_sys::Date::now() / 1000.0, + )) } } -impl MonotonicClock for CustomTimeProvider { +impl MonotonicClock for BrowserTimeProvider { fn instant(&self) -> Instant { - Instant::from_ticks(instant::Duration::from_secs(0)) + Instant::from_ticks(core::time::Duration::from_secs_f64(instant::now() / 1000.0)) } } -define_time_provider!(CustomTimeProvider); +define_time_provider!(BrowserTimeProvider); #[wasm_bindgen] pub fn run() -> Result<(), JsValue> { @@ -28,54 +39,116 @@ pub fn run() -> Result<(), JsValue> { // Also initialize tracing for WASM tracing_wasm::set_as_global_default(); - #[cfg(target_family = "wasm")] + // Sets the panic hook and GPUI's console logger gpui_platform::web_init(); - #[cfg(not(target_family = "wasm"))] - let app = gpui_platform::application(); + // A single-threaded platform that draws emoji and CJK graphemes missing + // from the bundled fonts with the browser's own fonts, plus a fetch-based + // HTTP client so remote images and content work like on desktop + let platform = std::rc::Rc::new(gpui_web::WebPlatform::new_with_backend_and_font_fallback( + false, + gpui_web::WebBackendPreference::Auto, + gpui_web::CanvasFontFallback::EmojiAndCjk, + )); + let http_client = std::sync::Arc::new(platform.fetch_http_client()); + let app = Application::with_platform(platform) + .with_http_client(http_client) + .with_assets(assets::CombinedAssets); - #[cfg(target_family = "wasm")] - let app = { - let app = gpui_platform::single_threaded_web(); + APPLICATION.with(|application| { + *application.borrow_mut() = Some(app.run_embedded(|cx| { + // Initialize the framework before anything renders + gpui_kit::init(cx); - // 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) } - }; + // The web text system starts with no fonts, and GPUI panics when + // it lays out text in a family that does not exist. Inter is both + // bundled and named on the theme (including both mode configs, so + // theme reloads keep it) before any text is laid out + load_embedded_fonts(cx); + apply_web_fonts(cx); - app.run(|cx| { - // Initialize the framework before anything renders - gpui_kit::init(cx); + // Initialize settings + settings::init(cx); - // Initialize settings - settings::init(cx); + // There is no native theme on the web; apply the persisted + // appearance directly, then the user's theme tuning + apply_appearance(settings::persisted_appearance_mode(), cx); + settings::AppSettings::apply_theme_style(cx); - // Initialize the nostr client - state::init(cx, None); + // Initialize the nostr client + state::init(cx, None); - // Initialize person registry - person::init(cx); + // Initialize person registry + person::init(cx); - // Initialize device signer - // - // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - nip4e::init(cx); + // Initialize device signer + // + // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + nip4e::init(cx); - // Initialize app registry - chat::init(cx); + // Initialize app registry + chat::init(cx); - // Initialize groups registry - nip29::init(cx); + // Initialize groups registry + nip29::init(cx); - gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { - workspace::init(window, cx) - }) - .expect("Failed to open window. Please restart the application."); + gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { + workspace::init(window, cx) + }) + .expect("Failed to open window. Please restart the application."); - cx.activate(true); + cx.activate(true); + })); }); Ok(()) } + +/// Register every bundled font with the text system before any text is laid out +fn load_embedded_fonts(cx: &mut App) { + let font_paths = assets::Assets::iter() + .filter(|path| path.starts_with("fonts/") && path.ends_with(".ttf")) + .collect::>(); + + let fonts = font_paths + .iter() + .filter_map(|path| assets::Assets::get(path.as_ref())) + .map(|font| font.data) + .collect::>(); + + cx.text_system() + .add_fonts(fonts) + .expect("Failed to load bundled fonts"); +} + +/// Name Inter on the theme and both mode configs so every text run renders in +/// it and theme reloads keep it. Without an explicit family the theme falls +/// back to `.SystemUIFont`, which the web platform resolves to a family that +/// is not bundled +fn apply_web_fonts(cx: &mut App) { + use gpui_kit::component::Theme; + + const UI_FONT: &str = "Inter"; + + Theme::update(cx, |theme| { + theme.font_family = UI_FONT.into(); + theme.mono_font_family = UI_FONT.into(); + + for config in [&mut theme.light_theme, &mut theme.dark_theme] { + let config = Rc::make_mut(config); + config.font_family = Some(UI_FONT.into()); + config.mono_font_family = Some(UI_FONT.into()); + } + }); +} + +/// Honor the appearance mode against the default palettes +fn apply_appearance(appearance: settings::AppearanceMode, cx: &mut App) { + use gpui_kit::component::{Theme, ThemeMode}; + + match appearance { + settings::AppearanceMode::System => Theme::sync_system_appearance(None, cx), + settings::AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx), + settings::AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx), + } +}