From 8929e3137b836c4ed817af0dc69f00d0e294f147 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 24 Oct 2026 18:19:03 +0700 Subject: [PATCH 1/9] wip --- Cargo.lock | 36 +++++++++++----------- crates/auto_update/Cargo.toml | 1 + crates/state/src/lib.rs | 1 + crates/workspace/src/dialogs/import.rs | 41 +++++++++++++++----------- crates/workspace/src/lib.rs | 5 +++- crates/workspace/src/sidebar/header.rs | 22 +++++++++----- 6 files changed, 61 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee01791d..db46edf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "assets" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "gpui-kit", @@ -542,7 +542,7 @@ dependencies = [ [[package]] name = "auto_update" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "futures", "gpui-pre", @@ -1041,7 +1041,7 @@ dependencies = [ [[package]] name = "chat" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "common", @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "chat_ui" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "chat", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "common" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "chrono", "dirs 5.0.1", @@ -1335,7 +1335,7 @@ dependencies = [ [[package]] name = "coop" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "assets", "auto_update", @@ -1364,7 +1364,7 @@ dependencies = [ [[package]] name = "coop_web" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "chat", "console_error_panic_hook", @@ -4765,7 +4765,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nip29" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "chat", @@ -4783,7 +4783,7 @@ dependencies = [ [[package]] name = "nip29_ui" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "chat", @@ -4804,7 +4804,7 @@ dependencies = [ [[package]] name = "nip4e" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "flume 0.11.1", @@ -5697,7 +5697,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "person" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "common", @@ -6576,9 +6576,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" dependencies = [ "memchr", ] @@ -7161,7 +7161,7 @@ dependencies = [ [[package]] name = "settings" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "common", @@ -7417,7 +7417,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "state" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "aes-gcm", "anyhow", @@ -8236,7 +8236,7 @@ dependencies = [ [[package]] name = "ui" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "common", "gpui-base", @@ -8444,7 +8444,7 @@ dependencies = [ "kurbo 0.13.1", "log", "pico-args", - "roxmltree 0.21.1", + "roxmltree 0.21.2", "rustybuzz", "simplecss", "siphasher", @@ -9674,7 +9674,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "workspace" -version = "2.0.0-alpha" +version = "2.0.0-alpha2" dependencies = [ "anyhow", "auto_update", diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 90d9004c..0a08d154 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -11,6 +11,7 @@ serde.workspace = true serde_json.workspace = true futures.workspace = true +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] gpui-updater-pre = "0.2" [dev-dependencies] diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 5a53fcf6..b157bf9b 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -6,6 +6,7 @@ use anyhow::{Error, anyhow}; use browser_signer_proxy::prelude::*; use common::config_dir; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt}; +#[cfg(not(target_arch = "wasm32"))] use gpui_tokio::Tokio; use instant::Duration; use nostr_connect::prelude::*; diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index f9a7f45b..e492595b 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -226,7 +226,6 @@ impl Render for ImportIdentity { const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages."; const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time."; - let is_wasm = cfg!(target_arch = "wasm32"); let is_unlock = self.mode == Mode::Unlock; let value = self.key_input.read(cx).value().trim().to_string(); @@ -305,26 +304,32 @@ impl Render for ImportIdentity { ) }), ) - .child( - Button::new("login") - .label(if is_unlock { "Unlock" } else { "Continue" }) - .primary() - .loading(self.loading) - .disabled(self.loading) - .on_click(cx.listener(move |this, _ev, _window, cx| { - this.login(cx); - })), - ) - .when(!is_wasm && !is_unlock, |this| { - this.child( - Button::new("proxy") - .label("Connect via Web Extension (Experimental)") - .ghost() + .child({ + let content = v_flex().child( + Button::new("login") + .label(if is_unlock { "Unlock" } else { "Continue" }) + .primary() + .loading(self.loading) .disabled(self.loading) .on_click(cx.listener(move |this, _ev, _window, cx| { - this.proxy(cx); + this.login(cx); })), - ) + ); + + #[cfg(not(target_arch = "wasm32"))] + let content = content.when(!is_unlock, |this| { + this.child( + Button::new("proxy") + .label("Connect via Web Extension (Experimental)") + .ghost() + .disabled(self.loading) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.proxy(cx); + })), + ) + }); + + content }) .when_some(self.error.read(cx).as_ref(), |this, error| { this.child( diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index a0482b24..9ae51eff 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use ::settings::{AppSettings, AppearanceMode}; use anyhow::Error; +#[cfg(not(target_arch = "wasm32"))] use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry}; use common::download_dir; @@ -372,7 +373,9 @@ impl Workspace { Command::ImportEncryption => { self.import_encryption(window, cx); } - Command::Update => { + Command::Update => + { + #[cfg(not(target_arch = "wasm32"))] if let Some(auto_updater) = AutoUpdater::try_global(cx) { auto_updater.update(cx, |this, cx| this.check(cx)); } diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs index fc0ca359..3f19b598 100644 --- a/crates/workspace/src/sidebar/header.rs +++ b/crates/workspace/src/sidebar/header.rs @@ -1,3 +1,4 @@ +#[cfg(not(target_arch = "wasm32"))] use auto_update::AutoUpdater; use gpui::prelude::FluentBuilder; use gpui::{ @@ -25,6 +26,7 @@ impl Sidebar { current_user: &PublicKey, cx: &mut Context, ) -> Stateful
{ + #[cfg(not(target_arch = "wasm32"))] let updater = AutoUpdater::try_global(cx); let profile = self.directory.read(cx).person(current_user, cx); let avatar = profile.avatar(); @@ -62,12 +64,15 @@ impl Sidebar { ) }), ) - .child( - h_flex() - .gap_1() - .when_some(updater, |this, updater| { - this.child(self.render_updater(updater, cx)) - }) + .child({ + let actions = h_flex().gap_1(); + + #[cfg(not(target_arch = "wasm32"))] + let actions = actions.when_some(updater, |this, updater| { + this.child(self.render_updater(updater, cx)) + }); + + actions .child( Button::new("sidebar-search") .icon(IconName::Search) @@ -104,8 +109,8 @@ impl Sidebar { this.hide_group(cx); })), ) - }), - ) + }) + }) } /// The bottom navigation, pinned below the list like signed's sidebar. @@ -128,6 +133,7 @@ impl Sidebar { ) } + #[cfg(not(target_arch = "wasm32"))] fn render_updater(&self, updater: Entity, cx: &mut App) -> AnyElement { let status = updater.read(cx).status(); let up_to_date = updater.read(cx).up_to_date(); -- 2.54.0 From f829a2774d44f2a13e54cffec9ba094431ff7be9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 24 Oct 2026 19:37:04 +0700 Subject: [PATCH 2/9] 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), + } +} -- 2.54.0 From f9b40d9d49ec9f91aa7b68e51263463dcc05e19c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 24 Oct 2026 20:29:09 +0700 Subject: [PATCH 3/9] add NIP-07 browser extension signer support for wasm --- Cargo.lock | 38 +++++++++---- Cargo.toml | 1 + crates/common/Cargo.toml | 4 ++ crates/common/src/lib.rs | 17 +++--- crates/common/src/web_store.rs | 32 +++++++++++ crates/settings/src/lib.rs | 50 ++++++++++------- crates/state/Cargo.toml | 1 + crates/state/src/credentials.rs | 51 ++++++++++++++++++ crates/state/src/lib.rs | 74 +++++++++++++++++++++----- crates/workspace/src/dialogs/import.rs | 34 +++++++++++- 10 files changed, 251 insertions(+), 51 deletions(-) create mode 100644 crates/common/src/web_store.rs create mode 100644 crates/state/src/credentials.rs diff --git a/Cargo.lock b/Cargo.lock index 04fbcdc5..b3567e7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1223,6 +1223,7 @@ dependencies = [ name = "common" version = "2.0.0-alpha2" dependencies = [ + "anyhow", "chrono", "dirs 5.0.1", "futures", @@ -1232,6 +1233,7 @@ dependencies = [ "nostr", "nostr-sdk", "regex", + "web-sys", ] [[package]] @@ -1824,7 +1826,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2052,7 +2054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4900,6 +4902,19 @@ dependencies = [ "serde", ] +[[package]] +name = "nostr-browser-signer" +version = "0.45.1" +source = "git+https://github.com/rust-nostr/nostr#f7ebc0a88ea27ac4e3d74b941e3a44ddb10b306c" +dependencies = [ + "js-sys", + "nostr", + "opaquerr 0.2.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "nostr-connect" version = "0.45.2" @@ -5047,7 +5062,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6134,7 +6149,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6707,7 +6722,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6775,7 +6790,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7381,7 +7396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7434,6 +7449,7 @@ dependencies = [ "mime_guess", "nostr", "nostr-blossom", + "nostr-browser-signer", "nostr-connect", "nostr-gossip-memory", "nostr-lmdb", @@ -7702,7 +7718,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8233,7 +8249,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9031,7 +9047,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -9656,7 +9672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index da069921..2573dced 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } +nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index af857673..aefb5ab9 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -16,3 +16,7 @@ futures.workspace = true dirs = "5.0" regex = "1.10" +anyhow.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", features = ["Window", "Storage"] } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 265f3e21..12b4f52a 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,13 +1,16 @@ -pub use debounced_delay::*; -pub use display::*; -pub use event::*; -pub use media_extractor::*; -pub use parser::*; -pub use paths::*; - mod debounced_delay; mod display; mod event; mod media_extractor; mod parser; mod paths; + +#[cfg(target_arch = "wasm32")] +pub mod web_store; + +pub use debounced_delay::*; +pub use display::*; +pub use event::*; +pub use media_extractor::*; +pub use parser::*; +pub use paths::*; diff --git a/crates/common/src/web_store.rs b/crates/common/src/web_store.rs new file mode 100644 index 00000000..5e21a0eb --- /dev/null +++ b/crates/common/src/web_store.rs @@ -0,0 +1,32 @@ +use anyhow::{Error, anyhow}; + +const LOCAL_STORAGE: &str = "localStorage"; + +fn storage() -> Result { + web_sys::window() + .ok_or_else(|| anyhow!("no browser window"))? + .local_storage() + .map_err(|e| anyhow!("{LOCAL_STORAGE} unavailable: {e:?}"))? + .ok_or_else(|| anyhow!("{LOCAL_STORAGE} unavailable")) +} + +/// Read a value previously written with [`set`] +pub fn get(key: &str) -> Result, Error> { + storage()? + .get_item(key) + .map_err(|e| anyhow!("{LOCAL_STORAGE} read failed: {e:?}")) +} + +/// Write a value, replacing any previous one +pub fn set(key: &str, value: &str) -> Result<(), Error> { + storage()? + .set_item(key, value) + .map_err(|e| anyhow!("{LOCAL_STORAGE} write failed: {e:?}")) +} + +/// Remove a value if it exists +pub fn remove(key: &str) -> Result<(), Error> { + storage()? + .remove_item(key) + .map_err(|e| anyhow!("{LOCAL_STORAGE} delete failed: {e:?}")) +} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 51f56b37..fb7b1150 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -1,12 +1,17 @@ use std::path::PathBuf; use anyhow::{Error, anyhow}; +#[cfg(not(target_arch = "wasm32"))] use common::config_dir; use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use smallvec::{SmallVec, smallvec}; +/// Browser storage key for the settings JSON +#[cfg(target_arch = "wasm32")] +const SETTINGS_KEY: &str = "coop.settings"; + /// The user's appearance preference, persisted with the rest of the settings. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum AppearanceMode { @@ -63,21 +68,21 @@ pub fn init(cx: &mut App) { } /// Read the persisted appearance mode synchronously. -/// -/// The async load fills the global settings later; the native theme must be -/// applied before the first window opens, so boot reads the mode straight -/// from the settings file instead. fn persisted_settings() -> Option { - #[cfg(not(target_arch = "wasm32"))] - { - let path = config_dir().join(".settings"); - if let Ok(content) = std::fs::read_to_string(path) - && let Ok(settings) = serde_json::from_str::(&content) - { - return Some(settings); - } - } - None + let content = persisted_content(); + content.and_then(|content| serde_json::from_str(&content).ok()) +} + +/// The persisted settings JSON, from the config file or browser storage +#[cfg(not(target_arch = "wasm32"))] +fn persisted_content() -> Option { + let path = config_dir().join(".settings"); + std::fs::read_to_string(path).ok() +} + +#[cfg(target_arch = "wasm32")] +fn persisted_content() -> Option { + common::web_store::get(SETTINGS_KEY).ok().flatten() } pub fn persisted_appearance_mode() -> AppearanceMode { @@ -87,10 +92,6 @@ pub fn persisted_appearance_mode() -> AppearanceMode { } /// Read the persisted theme tuning synchronously. -/// -/// Like [`persisted_appearance_mode`], the native theme is applied before the -/// async settings load completes, so boot reads the tuning straight from the -/// settings file instead. pub fn persisted_theme() -> ThemeSettings { persisted_settings() .map(|settings| settings.theme) @@ -357,6 +358,14 @@ impl AppSettings { return Ok(serde_json::from_str(&content)?); } } + + #[cfg(target_arch = "wasm32")] + { + if let Some(content) = common::web_store::get(SETTINGS_KEY)? { + return Ok(serde_json::from_str(&content)?); + } + } + Err(anyhow!("Not found")) }); @@ -389,6 +398,11 @@ impl AppSettings { smol::fs::write(&path, content).await.ok(); }) .detach(); + + #[cfg(target_arch = "wasm32")] + if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) { + log::warn!("failed to persist settings: {e}"); + } } } diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index 18c2196f..d65f4cc4 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -28,6 +28,7 @@ sha2.workspace = true data-encoding.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] +nostr-browser-signer.workspace = true nostr-memory.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/state/src/credentials.rs b/crates/state/src/credentials.rs new file mode 100644 index 00000000..f7b93a2e --- /dev/null +++ b/crates/state/src/credentials.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +#[cfg(target_arch = "wasm32")] +use common::web_store; +use gpui::{App, Task}; + +use crate::USER_KEYRING; + +/// Read the stored credential as `(username, secret)` +#[allow(clippy::type_complexity)] +pub(crate) fn read(cx: &App) -> Task)>>> { + #[cfg(not(target_arch = "wasm32"))] + return cx.read_credentials(USER_KEYRING); + + #[cfg(target_arch = "wasm32")] + { + let _ = cx; + Task::ready( + web_store::get(USER_KEYRING) + .map(|stored| stored.map(|content| (String::new(), content.into_bytes()))), + ) + } +} + +/// Write the stored credential +pub(crate) fn write(cx: &App, username: &str, secret: &[u8]) -> Task> { + #[cfg(not(target_arch = "wasm32"))] + return cx.write_credentials(USER_KEYRING, username, secret); + + #[cfg(target_arch = "wasm32")] + { + let _ = (cx, username); + let content = match std::str::from_utf8(secret) { + Ok(content) => content, + Err(e) => return Task::ready(Err(anyhow::anyhow!("credential is not UTF-8: {e}"))), + }; + + Task::ready(web_store::set(USER_KEYRING, content)) + } +} + +/// Delete the stored credential +pub(crate) fn delete(cx: &App) -> Task> { + #[cfg(not(target_arch = "wasm32"))] + return cx.delete_credentials(USER_KEYRING); + + #[cfg(target_arch = "wasm32")] + { + let _ = cx; + Task::ready(web_store::remove(USER_KEYRING)) + } +} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index b157bf9b..7c285789 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -9,6 +9,8 @@ use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt #[cfg(not(target_arch = "wasm32"))] use gpui_tokio::Tokio; use instant::Duration; +#[cfg(target_arch = "wasm32")] +use nostr_browser_signer::BrowserSigner; use nostr_connect::prelude::*; use nostr_gossip_memory::prelude::*; #[cfg(not(target_arch = "wasm32"))] @@ -19,6 +21,7 @@ use nostr_sdk::prelude::*; mod blossom; mod constants; +mod credentials; mod file; mod nip05; mod nip4e; @@ -138,9 +141,7 @@ impl NostrRegistry { let _ = entity.update(cx, |this, cx| { this.connect_bootstrap_relays(cx); - if cfg!(target_arch = "wasm32") { - cx.emit(StateEvent::NoSigner); - } else if let Some(secret) = cli_key { + if let Some(secret) = cli_key { // Use CLI-provided key -- same path as restore_session let keys = Keys::new(secret); this.set_signer(keys, cx); @@ -242,7 +243,7 @@ impl NostrRegistry { /// Restore the signer from the stored credential fn restore_session(&mut self, cx: &mut Context) { - let user_keyring = cx.read_credentials(USER_KEYRING); + let user_keyring = credentials::read(cx); self.tasks.push(cx.spawn(async move |this, cx| { let result = async { @@ -279,6 +280,11 @@ impl NostrRegistry { this.update(cx, |this, cx| { this.connect_proxy(cx); })?; + } else if content == "extension" { + #[cfg(target_arch = "wasm32")] + this.update(cx, |this, cx| { + this.connect_extension(cx); + })?; } else { this.update(cx, |_, cx| { cx.emit(StateEvent::NoSigner); @@ -314,7 +320,7 @@ impl NostrRegistry { cx: &mut Context, ) -> Task> { let password = password.to_owned(); - let user_keyring = cx.read_credentials(USER_KEYRING); + let user_keyring = credentials::read(cx); cx.spawn(async move |this, cx| { let content = user_keyring @@ -373,9 +379,8 @@ impl NostrRegistry { let (keys, ncryptsec) = job.await?; let public_key = keys.public_key(); - let write = cx.update(|cx| { - cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) - }); + let write = + cx.update(|cx| credentials::write(cx, &public_key.to_hex(), ncryptsec.as_bytes())); write.await?; this.update(cx, |this, cx| { @@ -412,9 +417,8 @@ impl NostrRegistry { let keys = decrypt_task.await?; let public_key = keys.public_key(); - let write = cx.update(|cx| { - cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes()) - }); + let write = + cx.update(|cx| credentials::write(cx, &public_key.to_hex(), stored.as_bytes())); write.await?; this.update(cx, |this, cx| { @@ -440,7 +444,7 @@ impl NostrRegistry { let keys = Keys::generate(); let credential = with_master_key(&uri_string, &keys); - let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); + let write = credentials::write(cx, "bunker", credential.as_bytes()); cx.spawn(async move |this, cx| { let signer = NostrConnect::builder(connect_uri, keys) @@ -464,12 +468,56 @@ impl NostrRegistry { self.current_user = None; self.passphrase_required = false; - cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx); + credentials::delete(cx).detach_and_log_err(cx); cx.emit(StateEvent::SignerChanged); cx.emit(StateEvent::NoSigner); cx.notify(); } + /// Connect the browser extension signer (NIP-07), remembering the choice so + /// reloads reconnect automatically + #[cfg(target_arch = "wasm32")] + pub fn connect_extension(&mut self, cx: &mut Context) { + let signer = match BrowserSigner::new() { + Ok(signer) => signer, + Err(e) => { + cx.emit(StateEvent::error(format!( + "No Nostr extension found: {e}. Install a NIP-07 extension like Alby or nos2x." + ))); + cx.emit(StateEvent::NoSigner); + return; + } + }; + + self.tasks.push(cx.spawn(async move |this, cx| { + match signer.get_public_key_async().await { + Ok(public_key) => { + // Remember the choice so reloads reconnect automatically + this.update(cx, |this, cx| { + credentials::write(cx, "extension", b"extension").detach_and_log_err(cx); + this.signer.swap_inner(signer); + this.current_user = Some(public_key); + this.passphrase_required = false; + + cx.emit(StateEvent::SignerChanged); + cx.notify(); + })?; + } + Err(e) => { + // Drop the remembered choice so the next visit does not + // retry a signer that is no longer answering + this.update(cx, |_, cx| { + credentials::delete(cx).detach_and_log_err(cx); + cx.emit(StateEvent::error(e.to_string())); + cx.emit(StateEvent::NoSigner); + })?; + } + }; + + Ok(()) + })); + } + /// Start the browser proxy #[cfg(not(target_arch = "wasm32"))] pub fn connect_proxy(&mut self, cx: &mut Context) { diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index e492595b..3e81a434 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -9,7 +9,7 @@ use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex}; use instant::Duration; use nostr_connect::prelude::*; -use state::NostrRegistry; +use state::{NostrRegistry, StateEvent}; pub fn open(window: &mut Window, cx: &mut App) { let import = cx.new(|cx| ImportIdentity::new(window, cx)); @@ -73,12 +73,13 @@ impl ImportIdentity { } fn build(mode: Mode, window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://")); let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); let error = cx.new(|_| None); - let subscriptions = vec![ + let mut subscriptions = vec![ cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| { if let InputEvent::PressEnter { .. } = event { this.login(cx); @@ -96,6 +97,14 @@ impl ImportIdentity { }), ]; + subscriptions.push( + cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| { + if let StateEvent::Error(error) = event { + this.set_error(error.clone(), cx); + } + }), + ); + Self { mode, key_input, @@ -188,6 +197,14 @@ impl ImportIdentity { }); } + #[cfg(target_arch = "wasm32")] + fn extension(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + nostr.update(cx, |this, cx| { + this.connect_extension(cx); + }); + } + fn set_loading(&mut self, status: bool, cx: &mut Context) { self.loading = status; cx.notify(); @@ -329,6 +346,19 @@ impl Render for ImportIdentity { ) }); + #[cfg(target_arch = "wasm32")] + let content = content.when(!is_unlock, |this| { + this.child( + Button::new("extension") + .label("Continue with Browser Extension (NIP-07)") + .ghost() + .disabled(self.loading) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.extension(cx); + })), + ) + }); + content }) .when_some(self.error.read(cx).as_ref(), |this, error| { -- 2.54.0 From a27ecd767e80a559809e17db75f21d2921fe8b47 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 24 Oct 2026 21:30:45 +0700 Subject: [PATCH 4/9] support file picking and uploads on web --- Cargo.lock | 4 + crates/chat_ui/src/file.rs | 58 ++++- crates/chat_ui/src/lib.rs | 102 ++++----- crates/nip29_ui/src/lib.rs | 18 +- crates/nip4e/src/lib.rs | 29 ++- crates/state/Cargo.toml | 13 ++ crates/state/src/blossom.rs | 39 ++-- crates/state/src/file.rs | 266 +++++++++++++++++----- crates/state/src/lib.rs | 5 +- crates/state/src/signer.rs | 6 + crates/state/src/web_file.rs | 198 ++++++++++++++++ crates/workspace/src/dialogs/new_group.rs | 17 +- crates/workspace/src/dialogs/profile.rs | 19 +- crates/workspace/src/dialogs/settings.rs | 64 ++++-- crates/workspace/src/lib.rs | 74 ++++-- crates/workspace/src/sidebar/header.rs | 11 +- 16 files changed, 677 insertions(+), 246 deletions(-) create mode 100644 crates/state/src/web_file.rs diff --git a/Cargo.lock b/Cargo.lock index b3567e7f..2cceff40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7446,6 +7446,7 @@ dependencies = [ "gpui-pre", "gpui_tokio", "instant", + "js-sys", "mime_guess", "nostr", "nostr-blossom", @@ -7459,6 +7460,9 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smol", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "webbrowser", ] diff --git a/crates/chat_ui/src/file.rs b/crates/chat_ui/src/file.rs index 1d8cc168..c4076d96 100644 --- a/crates/chat_ui/src/file.rs +++ b/crates/chat_ui/src/file.rs @@ -1,32 +1,70 @@ -use std::path::{Path, PathBuf}; - -use chat::FileAttachment; +use anyhow::Error; use gpui::SharedString; use nostr_sdk::prelude::*; +use state::{FileAttachment, LocalFile, PickedFile}; /// A file attachment that has been uploaded, but not sent yet. /// -/// The local `path` is kept around so the composer can preview +/// The `local` handle is kept around so the composer can preview /// the file without downloading and decrypting it again. pub(crate) struct PendingFile { pub file: FileAttachment, - pub path: PathBuf, + pub local: LocalFile, } /// State of the encrypted file attachment of a message pub(crate) enum DecryptedFile { Loading, - Ready(PathBuf), + Ready(LocalFile), Failed(SharedString), } /// Result of an upload, either plain or encrypted pub(crate) enum Uploaded { Url(Url), - File(FileAttachment, PathBuf), + File(Box), } -/// A `file://` url for a decrypted file, so it can be opened by the OS -pub(crate) fn file_url(path: &Path) -> String { - format!("file://{}", path.display()) +/// An encrypted upload and the handle to preview it locally +pub(crate) struct UploadedFile { + pub file: FileAttachment, + pub local: LocalFile, +} + +impl Uploaded { + /// Build the upload result for a picked file, encrypted when the + /// attachment is the whole message + pub(crate) async fn from_pick( + encrypted: bool, + server: Url, + picked: PickedFile, + cx: &gpui::AsyncApp, + ) -> Result { + if encrypted { + let UploadedFileParts { file, local } = upload_encrypted(server, picked, cx).await?; + + Ok(Self::File(Box::new(UploadedFile { file, local }))) + } else { + state::upload_file(server, picked, cx).await.map(Self::Url) + } + } +} + +/// The pieces of an encrypted upload, split so the local handle survives the +/// picked file being consumed by the upload +struct UploadedFileParts { + file: FileAttachment, + local: LocalFile, +} + +async fn upload_encrypted( + server: Url, + picked: PickedFile, + cx: &gpui::AsyncApp, +) -> Result { + let local = picked.local.clone(); + + state::upload_encrypted_file(server, picked, cx) + .await + .map(|file| UploadedFileParts { file, local }) } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 2015d461..28e0f739 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::PathBuf; use std::sync::{Arc, LazyLock, RwLock}; pub use actions::*; @@ -10,10 +9,9 @@ use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, - ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri, - StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification, - SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, - retain_all, svg, white, + ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled, + StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity, + Window, div, img, list, px, red, relative, retain_all, svg, white, }; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; @@ -31,7 +29,7 @@ use regex::Regex; use settings::{AppSettings, SignerKind}; use smallvec::{SmallVec, smallvec}; use state::{ - FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted, + FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file, }; use ui::{ Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow, @@ -642,22 +640,17 @@ impl ChatPanel { // Encrypt attachments which are not part of a message being written let encrypted = self.input.read(cx).value().trim().is_empty(); - // Ask user for file upload - let path = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + // Ask the user for a file to upload + let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + let Some(picked) = picker.await? else { return Ok(()); }; this.update_in(cx, |this, window, cx| { - this.upload_file(server, path, encrypted, window, cx); + this.upload_file(server, picked, encrypted, window, cx); })?; Ok(()) @@ -668,7 +661,7 @@ impl ChatPanel { fn upload_file( &mut self, server: Url, - path: PathBuf, + picked: PickedFile, encrypted: bool, window: &mut Window, cx: &mut Context, @@ -676,25 +669,18 @@ impl ChatPanel { self.set_uploading(true, cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = if encrypted { - upload_encrypted(server.clone(), path.clone(), cx) - .await - .map(|file| Uploaded::File(file, path.clone())) - } else { - upload(server.clone(), path.clone(), cx) - .await - .map(Uploaded::Url) - }; + let result = Uploaded::from_pick(encrypted, server.clone(), picked, cx).await; this.update_in(cx, |this, window, cx| { this.set_uploading(false, cx); match result { Ok(Uploaded::Url(url)) => this.add_attachment(url, cx), - Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx), - Err(e) if encrypted => { - this.report_encrypted_upload_error(server, path, e, window, cx) + Ok(Uploaded::File(uploaded)) => { + let UploadedFile { file, local } = *uploaded; + this.add_pending_file(file, local, cx); } + Err(e) if encrypted => this.report_encrypted_upload_error(e, window, cx), Err(e) => { window.push_notification( Notification::error(e.to_string()).autohide(false), @@ -711,8 +697,6 @@ impl ChatPanel { /// Report a failed encrypted upload, offering to retry it without encryption fn report_encrypted_upload_error( &mut self, - server: Url, - path: PathBuf, error: Error, window: &mut Window, cx: &mut Context, @@ -724,14 +708,12 @@ impl ChatPanel { .title("Encrypted upload failed") .action(move |_this, _window, _cx| { let view = view.clone(); - let server = server.clone(); - let path = path.clone(); Button::new("retry-without-encryption") - .label("Upload without encryption") + .label("Pick the file again without encryption") .on_click(move |_ev, window, cx| { view.update(cx, |this, cx| { - this.upload_file(server.clone(), path.clone(), false, window, cx); + this.upload(window, cx); }) .ok(); }) @@ -761,9 +743,9 @@ impl ChatPanel { }); } - fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context) { + fn add_pending_file(&mut self, file: FileAttachment, local: LocalFile, cx: &mut Context) { self.encrypted_attachments.update(cx, |this, cx| { - this.push(PendingFile { file, path }); + this.push(PendingFile { file, local }); cx.notify(); }); } @@ -782,12 +764,13 @@ impl ChatPanel { self.decrypted_files.insert(id, DecryptedFile::Loading); self.tasks.push(cx.spawn(async move |this, cx| { - let result = download_and_decrypt_to_file(&file, cx).await; + let result = download_and_decrypt_file(&file, cx).await; this.update(cx, |this, cx| { match result { - Ok(path) => { - this.decrypted_files.insert(id, DecryptedFile::Ready(path)); + Ok(downloaded) => { + this.decrypted_files + .insert(id, DecryptedFile::Ready(downloaded.local)); } Err(e) => { this.decrypted_files @@ -802,7 +785,7 @@ impl ChatPanel { })); } - /// Decrypt the attachment of a file message and open it with the OS + /// Decrypt the attachment of a file message and open it with the platform handler fn open_file( &mut self, id: EventId, @@ -810,25 +793,30 @@ impl ChatPanel { window: &mut Window, cx: &mut Context, ) { - match self.decrypted_files.get(&id) { - Some(DecryptedFile::Ready(path)) => { - cx.open_url(&file_url(path)); - return; - } - Some(DecryptedFile::Loading) => return, - _ => {} - }; + if let Some(DecryptedFile::Ready(local)) = self.decrypted_files.get(&id) { + let _ = local.open(cx).map_err(|e| { + window.push_notification(Notification::error(e.to_string()).autohide(false), cx); + }); + return; + } + + if let Some(DecryptedFile::Loading) = self.decrypted_files.get(&id) { + return; + } self.decrypted_files.insert(id, DecryptedFile::Loading); self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = download_and_decrypt_to_file(&file, cx).await; + let result = download_and_decrypt_file(&file, cx).await; - this.update_in(cx, |this, _window, cx| { + this.update_in(cx, |this, window, cx| { match result { - Ok(path) => { - cx.open_url(&file_url(&path)); - this.decrypted_files.insert(id, DecryptedFile::Ready(path)); + Ok(downloaded) => { + if let Err(e) = downloaded.open(cx) { + window.push_notification(Notification::error(e.to_string()), cx); + } + this.decrypted_files + .insert(id, DecryptedFile::Ready(downloaded.local)); } Err(e) => { this.decrypted_files @@ -1475,16 +1463,16 @@ impl ChatPanel { ) -> AnyElement { let state = self.decrypted_files.get(id); - if let Some(path) = state + if let Some(local) = state .and_then(|state| match state { - DecryptedFile::Ready(path) => Some(path), + DecryptedFile::Ready(local) => Some(local), _ => None, }) .filter(|_| file.is_image()) { return div() .child( - img(path.clone()) + img(local.image_source()) .border_1() .border_color(cx.theme().border) .h(px(250.)) @@ -1560,7 +1548,7 @@ impl ChatPanel { .map(|this| { if file.is_image() { this.child( - img(pending.path.clone()) + img(pending.local.image_source()) .size_16() .when(cx.theme().shadow, |this| this.shadow_sm()) .rounded(cx.theme().radius) diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 60c580a5..2d2b187a 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -4,9 +4,8 @@ use anyhow::Result; use gpui::prelude::FluentBuilder; use gpui::{ Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, - PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, - list, px, + FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, + SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; @@ -23,7 +22,7 @@ use person::Directory; use serde::Deserialize; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; -use state::{NostrRegistry, upload}; +use state::{NostrRegistry, pick_file, upload_file}; use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text}; use util::{attachment_name, display_name, opens_run, pin_label, report}; @@ -553,22 +552,17 @@ impl GroupPanel { fn upload(&mut self, window: &mut Window, cx: &mut Context) { let server = AppSettings::get_file_server(cx); - let path = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + let Some(picked) = picker.await? else { return Ok(()); }; this.update(cx, |this, cx| this.set_uploading(true, cx))?; - match upload(server, path, cx).await { + match upload_file(server, picked, cx).await { Ok(url) => { this.update_in(cx, |this, _window, cx| { this.attachments.push(url); diff --git a/crates/nip4e/src/lib.rs b/crates/nip4e/src/lib.rs index 3bcfe081..bb1dbbea 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -221,25 +221,38 @@ impl DeviceRegistry { cx.emit(DeviceEvent::Set); } - /// Backup the encryption's secret key to a file - pub fn backup(&self, path: PathBuf, cx: &App) -> Task> { + /// Backup the encryption's secret key, returning the `nsec1...` content + pub fn backup_bytes(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); cx.background_spawn(async move { let keys = get_keys(&client, &signer).await?; - let content = keys.secret_key().to_bech32()?; + Ok(keys.secret_key().to_bech32()?) + }) + } - if cfg!(target_arch = "wasm32") { - return Err(anyhow!("Not supported")); - } + /// Backup the encryption's secret key to a file + pub fn backup(&self, path: PathBuf, cx: &App) -> Task> { + let backup = self.backup_bytes(cx); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(target_arch = "wasm32"))] + return cx.background_spawn(async move { + let content = backup.await?; smol::fs::write(path, &content).await?; Ok(()) - }) + }); + + #[cfg(target_arch = "wasm32")] + { + let _ = path; + cx.background_spawn(async move { + backup.await?; + Err(anyhow!("File backups are not supported on web; copy the key from the backup panel instead.")) + }) + } } /// Get device announcement for current user diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index d65f4cc4..1485dffd 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -28,8 +28,21 @@ sha2.workspace = true data-encoding.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" nostr-browser-signer.workspace = true nostr-memory.workspace = true +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = [ + "Blob", + "BlobPropertyBag", + "File", + "FileList", + "HtmlAnchorElement", + "HtmlInputElement", + "Url", + "Window", +] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] browser-signer-proxy = { path = "../browser-signer-proxy" } diff --git a/crates/state/src/blossom.rs b/crates/state/src/blossom.rs index eed8c6b6..437eb5b1 100644 --- a/crates/state/src/blossom.rs +++ b/crates/state/src/blossom.rs @@ -1,17 +1,10 @@ -use std::path::PathBuf; - use anyhow::{Error, anyhow}; use gpui::AsyncApp; #[cfg(not(target_arch = "wasm32"))] use gpui_tokio::Tokio; -#[cfg(not(target_arch = "wasm32"))] -use mime_guess::from_path; use nostr_blossom::prelude::*; use nostr_sdk::prelude::*; -#[cfg(not(target_arch = "wasm32"))] -use crate::file::sha256_hex; - /// Upload a blob to a blossom server and return its URL #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn upload_blob( @@ -41,16 +34,26 @@ pub(crate) async fn upload_blob( .map_err(|e| anyhow!("Upload error: {e}"))? } -#[cfg(not(target_arch = "wasm32"))] -pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result { - let content_type = from_path(&path).first_or_octet_stream().to_string(); - let data = smol::fs::read(&path).await?; - let sha256 = sha256_hex(&data); - - upload_blob(&server, data, &content_type, &sha256, cx).await -} - +/// Upload a blob to a blossom server and return its URL. #[cfg(target_arch = "wasm32")] -pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result { - Err(anyhow!("File upload not supported on web")) +pub(crate) async fn upload_blob( + server: &Url, + data: Vec, + content_type: &str, + sha256: &str, + _cx: &AsyncApp, +) -> Result { + let client = BlossomClient::new(server.clone()); + let keys = Keys::generate(); + let base = server.clone(); + let hash = sha256.to_string(); + + match client + .upload_blob(data, Some(content_type.to_string()), None, Some(&keys)) + .await + { + Ok(blob) => Ok(blob.url), + Err(e) if e.to_string().contains("201 Created") => Ok(base.join(&hash)?), + Err(e) => Err(anyhow!(e.to_string())), + } } diff --git a/crates/state/src/file.rs b/crates/state/src/file.rs index 2dc380a3..3f7f6e87 100644 --- a/crates/state/src/file.rs +++ b/crates/state/src/file.rs @@ -7,8 +7,10 @@ use aes_gcm::{Aes256Gcm, AesGcm, Nonce}; use anyhow::{Error, anyhow, bail}; use data_encoding::HEXLOWER; use futures::AsyncReadExt; +#[cfg(not(target_arch = "wasm32"))] +use gpui::PathPromptOptions; use gpui::http_client::AsyncBody; -use gpui::{AsyncApp, SharedString}; +use gpui::{AppContext as _, AsyncApp, SharedString, SharedUri, Task}; #[cfg(not(target_arch = "wasm32"))] use mime_guess::from_path; use nostr::nips::nip94::Sha256Hash; @@ -115,6 +117,56 @@ impl FileAttachment { } } +/// A file the user picked, read into memory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickedFile { + pub name: String, + pub mime: String, + pub data: Vec, + pub local: LocalFile, +} + +/// A handle to a file stored on the user's device +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalFile { + /// A path on disk (desktop) + Path(PathBuf), + /// An in-memory blob URL (web) + Url(String), +} + +impl LocalFile { + /// The source an `img` element can render + pub fn image_source(&self) -> gpui::ImageSource { + match self { + LocalFile::Path(path) => path.clone().into(), + LocalFile::Url(url) => SharedUri::from(url.clone()).into(), + } + } + + /// The URL the platform opener understands + pub fn open_url(&self) -> String { + match self { + LocalFile::Path(path) => format!("file://{}", path.display()), + LocalFile::Url(url) => url.clone(), + } + } + + /// Open the file with the platform handler + pub fn open(&self, cx: &gpui::App) -> Result<(), Error> { + match self { + LocalFile::Path(_) => { + cx.open_url(&self.open_url()); + Ok(()) + } + #[cfg(target_arch = "wasm32")] + LocalFile::Url(url) => crate::web_file::open_url(url), + #[cfg(not(target_arch = "wasm32"))] + LocalFile::Url(_) => Ok(()), + } + } +} + pub(crate) fn encrypt(data: &[u8]) -> Result { let key = Aes256Gcm::generate_key(OsRng); let nonce = AesGcm::::generate_nonce(OsRng); @@ -165,21 +217,63 @@ pub(crate) fn sha256_hex(data: &[u8]) -> String { Sha256Hash::from_byte_array(hash).to_hex() } -#[cfg(not(target_arch = "wasm32"))] -pub async fn upload_encrypted( +/// Ask the user for a single file and read it into memory +pub fn pick_file(cx: &gpui::App) -> Task, Error>> { + #[cfg(not(target_arch = "wasm32"))] + { + let dialog = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }); + + cx.background_spawn(async move { + let Some(path) = dialog.await?? else { + return Ok(None); + }; + let path = path.into_iter().next().ok_or_else(|| anyhow!("No file"))?; + + let mime = from_path(&path).first_or_octet_stream().to_string(); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let data = smol::fs::read(&path).await?; + + Ok(Some(PickedFile { + name, + mime, + data, + local: LocalFile::Path(path), + })) + }) + } + + #[cfg(target_arch = "wasm32")] + { + let _ = cx; + cx.background_spawn(async move { crate::web_file::pick_file().await }) + } +} + +/// Upload a file unencrypted and return its URL +pub async fn upload_file(server: Url, file: PickedFile, cx: &AsyncApp) -> Result { + let PickedFile { mime, data, .. } = file; + let sha256 = sha256_hex(&data); + + crate::blossom::upload_blob(&server, data, &mime, &sha256, cx).await +} + +/// Upload a file encrypted as an opaque blob, returning the attachment metadata +pub async fn upload_encrypted_file( server: Url, - path: PathBuf, + file: PickedFile, cx: &AsyncApp, ) -> Result { - let mime = from_path(&path).first_or_octet_stream().to_string(); - let name = path - .file_name() - .map(|name| name.to_string_lossy().into_owned()); - let data = smol::fs::read(&path).await?; - - let encrypted = encrypt(&data)?; + let encrypted = encrypt(&file.data)?; let sha256 = sha256_hex(&encrypted.data); - let original_sha256 = sha256_hex(&data); + let original_sha256 = sha256_hex(&file.data); let size = encrypted.data.len() as u64; let base_url = server.to_string(); @@ -207,26 +301,17 @@ pub async fn upload_encrypted( Ok(FileAttachment { url, - mime, + mime: file.mime, key: encrypted.key, nonce: encrypted.nonce, sha256: Some(sha256), original_sha256: Some(original_sha256), size: Some(size), dim: None, - name, + name: Some(file.name), }) } -#[cfg(target_arch = "wasm32")] -pub async fn upload_encrypted( - _server: Url, - _path: PathBuf, - _cx: &AsyncApp, -) -> Result { - Err(anyhow!("File upload not supported on web")) -} - pub(crate) async fn download_and_decrypt( url: &Url, key: &str, @@ -261,33 +346,49 @@ pub(crate) async fn download_and_decrypt( decrypt(&data, key, nonce) } -/// Download and decrypt a file attachment into a temporary file. -/// -/// The same attachment always maps to the same path, so callers can render the -/// result directly (e.g. with `img`) without downloading it more than once. -#[cfg(not(target_arch = "wasm32"))] -pub async fn download_and_decrypt_to_file( - file: &FileAttachment, - cx: &AsyncApp, -) -> Result { - let name = file - .sha256 - .clone() - .unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes())); +/// A downloaded and decrypted attachment on the user's device +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DownloadedFile { + pub local: LocalFile, + pub mime: String, + pub name: String, + /// The decrypted bytes, kept so the web can offer them as a download + #[cfg(target_arch = "wasm32")] + pub(crate) data: Vec, +} - let extension = mime_guess::get_mime_extensions_str(&file.mime) - .and_then(|extensions| extensions.first()) - .copied() - .unwrap_or("bin"); - - let path = std::env::temp_dir() - .join("coop-files") - .join(format!("{name}.{extension}")); - - if smol::fs::metadata(&path).await.is_ok() { - return Ok(path); +impl DownloadedFile { + pub fn open(&self, cx: &gpui::App) -> Result<(), Error> { + match &self.local { + LocalFile::Path(path) => { + cx.open_with_system(path); + Ok(()) + } + #[cfg(target_arch = "wasm32")] + LocalFile::Url(url) => { + if self.mime.starts_with("image/") { + crate::web_file::open_url(url) + } else { + crate::web_file::download(&self.name, &self.mime, &self.data) + } + } + #[cfg(not(target_arch = "wasm32"))] + LocalFile::Url(_) => Ok(()), + } } + /// Save the file to the user's downloads (web) + #[cfg(target_arch = "wasm32")] + pub fn save(&self) -> Result<(), Error> { + crate::web_file::download(&self.name, &self.mime, &self.data) + } +} + +/// Download and decrypt a file attachment to the user's device +pub async fn download_and_decrypt_file( + file: &FileAttachment, + cx: &AsyncApp, +) -> Result { let data = download_and_decrypt( &file.url, &file.key, @@ -297,25 +398,66 @@ pub async fn download_and_decrypt_to_file( ) .await?; - let Some(parent) = path.parent() else { - bail!("Invalid file path"); - }; - smol::fs::create_dir_all(parent).await?; + let name = file + .name + .clone() + .unwrap_or_else(|| fallback_name(&file.url)); - // Write under a temporary name first, so an interrupted download is never reused - let partial = path.with_extension("download"); - smol::fs::write(&partial, data).await?; - smol::fs::rename(&partial, &path).await?; + let local = store_file(&name, &file.mime, &data).await?; - Ok(path) + Ok(DownloadedFile { + local, + mime: file.mime.clone(), + name, + #[cfg(target_arch = "wasm32")] + data, + }) } -#[cfg(target_arch = "wasm32")] -pub async fn download_and_decrypt_to_file( - _file: &FileAttachment, - _cx: &AsyncApp, -) -> Result { - Err(anyhow!("File download not supported on web")) +/// Persist decrypted bytes somewhere the platform can render or open them +async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let digest = sha256_hex(name.as_bytes()); + let extension = mime_guess::get_mime_extensions_str(mime) + .and_then(|extensions| extensions.first()) + .copied() + .unwrap_or("bin"); + + let path = std::env::temp_dir() + .join("coop-files") + .join(format!("{digest}.{extension}")); + + if smol::fs::metadata(&path).await.is_err() { + let Some(parent) = path.parent() else { + bail!("Invalid file path"); + }; + smol::fs::create_dir_all(parent).await?; + + // Write under a temporary name first, so an interrupted download is never reused + let partial = path.with_extension("download"); + smol::fs::write(&partial, data).await?; + smol::fs::rename(&partial, &path).await?; + } + + Ok(LocalFile::Path(path)) + } + + #[cfg(target_arch = "wasm32")] + { + let _ = name; + let url = crate::web_file::blob_url(mime, data)?; + Ok(LocalFile::Url(url)) + } +} + +/// A file name derived from the attachment URL's last path segment +fn fallback_name(url: &Url) -> String { + url.path() + .rsplit('/') + .find(|segment| !segment.is_empty()) + .unwrap_or("attachment") + .to_string() } fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 7c285789..512110b4 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::Arc; 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, TaskExt}; #[cfg(not(target_arch = "wasm32"))] @@ -27,7 +28,9 @@ mod nip05; mod nip4e; mod signer; -pub use blossom::*; +#[cfg(target_arch = "wasm32")] +pub mod web_file; + pub use constants::*; pub use file::*; pub use nip4e::*; diff --git a/crates/state/src/signer.rs b/crates/state/src/signer.rs index c710f0ca..724e4c50 100644 --- a/crates/state/src/signer.rs +++ b/crates/state/src/signer.rs @@ -188,7 +188,13 @@ impl AuthUrlHandler for CoopAuthUrlHandler { auth_url: Url, ) -> Pin> + Send + '_>> { Box::pin(async move { + #[cfg(not(target_arch = "wasm32"))] webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?; + + #[cfg(target_arch = "wasm32")] + crate::web_file::open_url(auth_url.as_str()) + .map_err(nostr_connect::error::Error::other)?; + Ok(()) }) } diff --git a/crates/state/src/web_file.rs b/crates/state/src/web_file.rs new file mode 100644 index 00000000..81f0d4d6 --- /dev/null +++ b/crates/state/src/web_file.rs @@ -0,0 +1,198 @@ +//! Browser file interoperability: a persistent hidden `` +//! picker, blob URLs for rendering, and anchor-based downloads. + +use std::cell::RefCell; + +use anyhow::{Error, anyhow}; +use flume::Sender; +use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; +use web_sys::{File, HtmlAnchorElement, HtmlInputElement, Url as DomUrl, window as js_window}; + +use crate::file::PickedFile; + +type PickResult = Result, Error>; + +thread_local! { + /// The picker input lives for the whole page so its change closure does + /// not have to be leaked or re-registered on every pick + static PICKER: RefCell> = const { RefCell::new(None) }; + /// The sender the active pick delivers through + static PENDING: RefCell>> = const { RefCell::new(None) }; +} + +struct Picker { + input: HtmlInputElement, + /// Keep the change handler alive for the life of the page + _on_change: Closure, +} + +/// Ask the user for a single file and read its bytes +pub async fn pick_file() -> PickResult { + let (sender, receiver) = flume::bounded(1); + + PICKER.with(|picker| -> Result<(), Error> { + let mut picker = picker.borrow_mut(); + if picker.is_none() { + *picker = Some(init_picker()?); + } + let picker = picker.as_ref().expect("picker just initialized"); + + PENDING.with(|pending| *pending.borrow_mut() = Some(sender)); + picker.input.click(); + + Ok(()) + })?; + + receiver + .recv_async() + .await + .map_err(|_| anyhow!("File picker closed"))? +} + +fn init_picker() -> Result { + let document = js_window() + .ok_or_else(|| anyhow!("no browser window"))? + .document() + .ok_or_else(|| anyhow!("no browser document"))?; + + let input = document + .create_element("input") + .map_err(|e| anyhow!("failed to create file input: {e:?}"))? + .dyn_into::() + .map_err(|_| anyhow!("created element is not an input"))?; + + input.set_type("file"); + input.style().set_property("display", "none").ok(); + + let on_change = Closure::wrap(Box::new(move || { + let Some(sender) = PENDING.with(|pending| pending.borrow_mut().take()) else { + return; + }; + + wasm_bindgen_futures::spawn_local(async move { + sender.send_async(read_selected_file().await).await.ok(); + }); + }) as Box); + + input.set_onchange(Some(on_change.as_ref().unchecked_ref())); + + let body = document.body().ok_or_else(|| anyhow!("no browser body"))?; + body.append_child(&input) + .map_err(|e| anyhow!("failed to mount file input: {e:?}"))?; + + Ok(Picker { + input, + _on_change: on_change, + }) +} + +/// Read the first file of the picker input, if one was chosen +async fn read_selected_file() -> PickResult { + let picker = PICKER + .with(|picker| picker.borrow().as_ref().map(|picker| picker.input.clone())) + .ok_or_else(|| anyhow!("file picker is not initialized"))?; + + let Some(file) = picker.files().and_then(|files| files.get(0)) else { + // The dialog was dismissed without choosing a file + return Ok(None); + }; + + let mime = non_empty(file.type_()); + let data = read_file_bytes(&file).await?; + let local = crate::file::LocalFile::Url(blob_url(&mime, &data)?); + + Ok(Some(PickedFile { + name: file.name(), + mime, + data, + local, + })) +} + +/// Read a browser `File` into memory +async fn read_file_bytes(file: &File) -> Result, Error> { + let promise = file.array_buffer(); + let buffer = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|e| anyhow!("failed to read file: {e:?}"))?; + + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) +} + +/// Create an object URL for bytes, so images and downloads render from memory +pub fn blob_url(mime: &str, data: &[u8]) -> Result { + let array = js_sys::Uint8Array::from(data); + let parts = js_sys::Array::new(); + parts.push(&array); + + let bag = web_sys::BlobPropertyBag::new(); + bag.set_type(mime); + + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts.into(), &bag) + .map_err(|e| anyhow!("failed to create blob: {e:?}"))?; + + DomUrl::create_object_url_with_blob(&blob).map_err(|e| anyhow!("failed to create url: {e:?}")) +} + +/// Offer bytes to the user as a browser download +pub fn download(name: &str, mime: &str, data: &[u8]) -> Result<(), Error> { + let window = js_window().ok_or_else(|| anyhow!("no browser window"))?; + let document = window + .document() + .ok_or_else(|| anyhow!("no browser document"))?; + + let url = blob_url(mime, data)?; + + let anchor: HtmlAnchorElement = document + .create_element("a") + .map_err(|e| anyhow!("failed to create download link: {e:?}"))? + .dyn_into() + .map_err(|_| anyhow!("created element is not an anchor"))?; + anchor.set_href(&url); + anchor.set_download(name); + anchor.style().set_property("display", "none").ok(); + + let body = document.body().ok_or_else(|| anyhow!("no browser body"))?; + body.append_child(&anchor) + .map_err(|e| anyhow!("failed to mount download link: {e:?}"))?; + anchor.click(); + body.remove_child(&anchor) + .map_err(|e| anyhow!("failed to remove download link: {e:?}"))?; + + // Keep the blob alive briefly so the download is not cancelled + let callback = Closure::wrap(Box::new(move || { + DomUrl::revoke_object_url(&url).ok(); + }) as Box); + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + 10_000, + ) + .map_err(|e| anyhow!("failed to schedule cleanup: {e:?}"))?; + // The closure only runs once, after which it can be released + callback.forget(); + + Ok(()) +} + +/// Open a URL in a new browser tab +pub fn open_url(url: &str) -> Result<(), Error> { + js_window() + .ok_or_else(|| anyhow!("no browser window"))? + .open_with_url_and_target(url, "_blank") + .map_err(|e| anyhow!("failed to open url: {e:?}"))? + .ok_or_else(|| anyhow!("the browser blocked opening the url"))?; + + Ok(()) +} + +fn non_empty(value: impl Into) -> String { + let value: String = value.into(); + + if value.is_empty() { + "application/octet-stream".to_string() + } else { + value + } +} diff --git a/crates/workspace/src/dialogs/new_group.rs b/crates/workspace/src/dialogs/new_group.rs index 06fb8a93..f41562f9 100644 --- a/crates/workspace/src/dialogs/new_group.rs +++ b/crates/workspace/src/dialogs/new_group.rs @@ -2,8 +2,8 @@ use anyhow::Error; use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement, - ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _, - StyleRefinement, Styled, Subscription, Task, WeakEntity, Window, div, px, rems, + ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, + Subscription, Task, WeakEntity, Window, div, px, rems, }; use gpui_base::{Popup, Select}; use gpui_kit::assets::IconName; @@ -18,7 +18,7 @@ use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_ use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use settings::AppSettings; -use state::upload; +use state::{pick_file, upload_file}; use ui::{Avatar, settings_row}; const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"]; @@ -297,16 +297,11 @@ impl NewGroup { fn upload(&mut self, window: &mut Window, cx: &mut Context) { let server = AppSettings::get_file_server(cx); - let path = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + let Some(picked) = picker.await? else { return Ok(()); }; @@ -314,7 +309,7 @@ impl NewGroup { this.set_uploading(true, cx); })?; - match upload(server, path, cx).await { + match upload_file(server, picked, cx).await { Ok(url) => { this.update(cx, |this, cx| { this.picture = Some(url.to_string().into()); diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index 00eb388a..82a67fd8 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -4,8 +4,8 @@ use anyhow::Error; use chat::{ChatRegistry, Room, RoomKind}; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, - PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all, + App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, Render, + SharedString, Styled, Task, Window, div, retain_all, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; @@ -18,7 +18,7 @@ use instant::Duration; use nostr_sdk::prelude::*; use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use settings::AppSettings; -use state::{NostrRegistry, upload}; +use state::{NostrRegistry, pick_file, upload_file}; use ui::Avatar; use crate::Workspace; @@ -179,17 +179,12 @@ impl ProfileDialog { // Get the user's configured blossom server let server = AppSettings::get_file_server(cx); - // Ask user for file upload - let path = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + // Ask the user for an image to upload + let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + let Some(picked) = picker.await? else { return Ok(()); }; @@ -198,7 +193,7 @@ impl ProfileDialog { })?; // Upload via blossom client - match upload(server, path, cx).await { + match upload_file(server, picked, cx).await { Ok(url) => { this.update_in(cx, |this, window, cx| { this.avatar_input.update(cx, |this, cx| { diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index a9f47ecf..eae40c36 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -1,7 +1,9 @@ +#[cfg(not(target_arch = "wasm32"))] +use gpui::PathPromptOptions; use gpui::http_client::Url; use gpui::{ - App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render, - Styled, Subscription, Window, div, + App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, + Window, div, }; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants}; @@ -150,29 +152,41 @@ impl Preferences { } /// Ask the user for an image to show behind chat and group panels - fn choose_background(cx: &mut App) { - let task = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + fn choose_background( + #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window, + cx: &mut App, + ) { + #[cfg(target_arch = "wasm32")] + { + let note = Notification::warning("Background images are not supported on web yet."); + window.push_notification(note, cx); + } - cx.spawn(async move |cx| { - let Ok(Ok(Some(mut paths))) = task.await else { - return; - }; - - let Some(path) = paths.pop() else { - return; - }; - - cx.update(|cx| { - AppSettings::update_panel_background_path(Some(path), cx); - cx.refresh_windows(); + #[cfg(not(target_arch = "wasm32"))] + { + let task = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, }); - }) - .detach(); + + cx.spawn(async move |cx| { + let Ok(Ok(Some(mut paths))) = task.await else { + return; + }; + + let Some(path) = paths.pop() else { + return; + }; + + cx.update(|cx| { + AppSettings::update_panel_background_path(Some(path), cx); + cx.refresh_windows(); + }); + }) + .detach(); + } } } @@ -330,8 +344,8 @@ impl Render for Preferences { .ghost() .small() .disabled(!panel_background) - .on_click(|_, _window, cx| { - Self::choose_background(cx); + .on_click(|_, window, cx| { + Self::choose_background(window, cx); }), ) .child( diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 9ae51eff..6f99f2b0 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -5,6 +5,7 @@ use anyhow::Error; #[cfg(not(target_arch = "wasm32"))] use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry}; +#[cfg(not(target_arch = "wasm32"))] use common::download_dir; use gpui::{ Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, @@ -338,34 +339,57 @@ impl Workspace { } Command::BackupEncryption => { let device = DeviceRegistry::global(cx).downgrade(); - let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt")); + #[cfg(not(target_arch = "wasm32"))] + { + let save_dialog = + cx.prompt_for_new_path(download_dir(), Some("encryption.txt")); + + self.tasks.push(cx.spawn_in(window, async move |_this, cx| { + // Get the output path from the save dialog + let output_path = match save_dialog.await { + Ok(Ok(Some(path))) => path, + Ok(Ok(None)) | Err(_) => return Ok(()), + Ok(Err(error)) => { + cx.update(|window, cx| { + let message = + format!("Failed to pick save location: {error:#}"); + let note = Notification::error(message).autohide(false); + window.push_notification(note, cx); + })?; + return Ok(()); + } + }; + + // Get the backup task + let backup = device + .read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?; + + // Run the backup task + backup.await?; + + // Open the backup file with the system's default application + cx.update(|_window, cx| { + cx.open_with_system(output_path.as_path()); + })?; + + Ok(()) + })); + } + + #[cfg(target_arch = "wasm32")] self.tasks.push(cx.spawn_in(window, async move |_this, cx| { - // Get the output path from the save dialog - let output_path = match save_dialog.await { - Ok(Ok(Some(path))) => path, - Ok(Ok(None)) | Err(_) => return Ok(()), - Ok(Err(error)) => { - cx.update(|window, cx| { - let message = format!("Failed to pick save location: {error:#}"); - let note = Notification::error(message).autohide(false); - window.push_notification(note, cx); - })?; - return Ok(()); - } - }; + let backup = device.read_with(cx, |this, cx| this.backup_bytes(cx))?; + let content = backup.await?; - // Get the backup task - let backup = - device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?; - - // Run the backup task - backup.await?; - - // Open the backup file with the system's default application - cx.update(|_window, cx| { - cx.open_with_system(output_path.as_path()); - })?; + cx.update(|_window, _cx| { + state::web_file::download( + "encryption.txt", + "text/plain", + content.as_bytes(), + ) + }) + .map_err(|e| anyhow::anyhow!(e))??; Ok(()) })); diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs index 3f19b598..70152a0f 100644 --- a/crates/workspace/src/sidebar/header.rs +++ b/crates/workspace/src/sidebar/header.rs @@ -1,16 +1,17 @@ #[cfg(not(target_arch = "wasm32"))] use auto_update::AutoUpdater; use gpui::prelude::FluentBuilder; +#[cfg(not(target_arch = "wasm32"))] +use gpui::{AnyElement, Entity, IntoElement}; use gpui::{ - AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, - Stateful, Styled, Window, div, px, + App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px, }; +#[cfg(not(target_arch = "wasm32"))] +use gpui_kit::component::Disableable; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; -use gpui_kit::component::{ - ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex, -}; +use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex}; use nostr_sdk::prelude::*; use state::NostrRegistry; use ui::{Avatar, IconName}; -- 2.54.0 From d7ae86e955a91429ce133abb8bde81de6699b277 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 25 Oct 2026 08:50:56 +0700 Subject: [PATCH 5/9] Add CI check for wasm target and switch web build to npm --- .github/workflows/rust.yml | 22 + web/Makefile | 18 +- web/script/build-wasm.sh | 4 +- web/www/deno.lock | 396 ------------- web/www/package-lock.json | 1075 ++++++++++++++++++++++++++++++++++++ web/www/package.json | 2 +- web/www/vite.config.js | 8 +- 7 files changed, 1110 insertions(+), 415 deletions(-) delete mode 100644 web/www/deno.lock create mode 100644 web/www/package-lock.json diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8c89a97c..a3973a22 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -30,3 +30,25 @@ jobs: - name: Run tests run: cargo test --verbose + + check-wasm: + name: Check web target + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + targets: wasm32-unknown-unknown + components: llvm-tools-preview + + - name: Install wasm C toolchain + run: sudo apt-get update && sudo apt-get install -y clang llvm + + - name: Check web target + env: + CC_wasm32_unknown_unknown: clang + AR_wasm32_unknown_unknown: llvm-ar + run: cargo +nightly check -p coop_web --target wasm32-unknown-unknown diff --git a/web/Makefile b/web/Makefile index df98c609..6475e06c 100644 --- a/web/Makefile +++ b/web/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev build-wasm build-web build clean install +.PHONY: help dev build-wasm build-wasm-dev build-web build-web-prod build build-prod preview clean install watch-wasm help: ## Show help information @echo "Coop - Available commands:" @@ -7,11 +7,9 @@ help: ## Show help information install: ## Install all dependencies @echo "Checking Rust WASM target..." - @rustup target add wasm32-unknown-unknown || true - @echo "Checking wasm-bindgen-cli..." - @cargo install wasm-bindgen-cli || true + @rustup target add wasm32-unknown-unknown --toolchain nightly || true @echo "Installing frontend dependencies..." - @cd www && deno install + @cd www && npm install build-wasm: ## Build WASM (release mode) @./script/build-wasm.sh --release @@ -20,20 +18,20 @@ build-wasm-dev: ## Build WASM (debug mode) @./script/build-wasm.sh build-web: ## Build frontend - @cd www && deno task build + @cd www && npm run build build-web-prod: ## Build frontend for production (GitHub Pages) - @cd www && deno install && NODE_ENV=production deno task build + @cd www && npm install && NODE_ENV=production npm run build build: build-wasm build-web ## Build complete project (WASM + frontend) build-prod: build-wasm build-web-prod ## Build complete project for production dev: build-wasm-dev ## Start development server - @cd www && deno install && deno task dev + @cd www && npm install && npm run dev preview: ## Preview production build - @cd www && deno task preview + @cd www && npm run preview clean: ## Clean build artifacts @echo "Cleaning build artifacts..." @@ -43,4 +41,4 @@ clean: ## Clean build artifacts watch-wasm: ## Watch Rust code changes and auto-rebuild WASM @echo "Watching WASM changes..." - @cargo watch -x 'build --target wasm32-unknown-unknown' -s './script/build-wasm.sh' + @cargo watch -w ../crates -w ../Cargo.toml -w ../Cargo.lock -s './script/build-wasm.sh' diff --git a/web/script/build-wasm.sh b/web/script/build-wasm.sh index ed36e272..09e34a00 100755 --- a/web/script/build-wasm.sh +++ b/web/script/build-wasm.sh @@ -89,5 +89,5 @@ wasm-bindgen "$WASM_PATH" \ echo -e "${GREEN}✓ Build completed successfully!${NC}" echo -e "${YELLOW}Next steps:${NC}" echo -e " cd web/www" -echo -e " deno install" -echo -e " deno task dev" +echo -e " npm install" +echo -e " npm run dev" diff --git a/web/www/deno.lock b/web/www/deno.lock deleted file mode 100644 index 8378c05e..00000000 --- a/web/www/deno.lock +++ /dev/null @@ -1,396 +0,0 @@ -{ - "version": "5", - "specifiers": { - "npm:vite-plugin-static-copy@^3.2.0": "3.4.0_vite@8.1.5", - "npm:vite-plugin-wasm@^3.3.0": "3.6.0_vite@8.1.5", - "npm:vite@8": "8.1.5" - }, - "npm": { - "@emnapi/core@1.11.1": { - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dependencies": [ - "@emnapi/wasi-threads", - "tslib" - ] - }, - "@emnapi/runtime@1.11.1": { - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dependencies": [ - "tslib" - ] - }, - "@emnapi/wasi-threads@1.2.2": { - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dependencies": [ - "tslib" - ] - }, - "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": { - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dependencies": [ - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util" - ] - }, - "@oxc-project/types@0.139.0": { - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==" - }, - "@rolldown/binding-android-arm64@1.1.5": { - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@rolldown/binding-darwin-arm64@1.1.5": { - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@rolldown/binding-darwin-x64@1.1.5": { - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@rolldown/binding-freebsd-x64@1.1.5": { - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@rolldown/binding-linux-arm-gnueabihf@1.1.5": { - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@rolldown/binding-linux-arm64-gnu@1.1.5": { - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@rolldown/binding-linux-arm64-musl@1.1.5": { - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@rolldown/binding-linux-ppc64-gnu@1.1.5": { - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@rolldown/binding-linux-s390x-gnu@1.1.5": { - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@rolldown/binding-linux-x64-gnu@1.1.5": { - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@rolldown/binding-linux-x64-musl@1.1.5": { - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@rolldown/binding-openharmony-arm64@1.1.5": { - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "os": ["openharmony"], - "cpu": ["arm64"] - }, - "@rolldown/binding-wasm32-wasi@1.1.5": { - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "dependencies": [ - "@emnapi/core", - "@emnapi/runtime", - "@napi-rs/wasm-runtime" - ], - "cpu": ["wasm32"] - }, - "@rolldown/binding-win32-arm64-msvc@1.1.5": { - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@rolldown/binding-win32-x64-msvc@1.1.5": { - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@rolldown/pluginutils@1.0.1": { - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" - }, - "@tybys/wasm-util@0.10.3": { - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dependencies": [ - "tslib" - ] - }, - "anymatch@3.1.3": { - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dependencies": [ - "normalize-path", - "picomatch@2.3.2" - ] - }, - "binary-extensions@2.3.0": { - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==" - }, - "braces@3.0.3": { - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dependencies": [ - "fill-range" - ] - }, - "chokidar@3.6.0": { - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dependencies": [ - "anymatch", - "braces", - "glob-parent", - "is-binary-path", - "is-glob", - "normalize-path", - "readdirp" - ], - "optionalDependencies": [ - "fsevents" - ] - }, - "detect-libc@2.1.2": { - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" - }, - "fdir@6.5.0_picomatch@4.0.5": { - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dependencies": [ - "picomatch@4.0.5" - ], - "optionalPeers": [ - "picomatch@4.0.5" - ] - }, - "fill-range@7.1.1": { - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dependencies": [ - "to-regex-range" - ] - }, - "fsevents@2.3.3": { - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "os": ["darwin"], - "scripts": true - }, - "glob-parent@5.1.2": { - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": [ - "is-glob" - ] - }, - "is-binary-path@2.1.0": { - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": [ - "binary-extensions" - ] - }, - "is-extglob@2.1.1": { - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" - }, - "is-glob@4.0.3": { - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": [ - "is-extglob" - ] - }, - "is-number@7.0.0": { - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "lightningcss-android-arm64@1.33.0": { - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "os": ["android"], - "cpu": ["arm64"] - }, - "lightningcss-darwin-arm64@1.33.0": { - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "lightningcss-darwin-x64@1.33.0": { - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "lightningcss-freebsd-x64@1.33.0": { - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "lightningcss-linux-arm-gnueabihf@1.33.0": { - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "os": ["linux"], - "cpu": ["arm"] - }, - "lightningcss-linux-arm64-gnu@1.33.0": { - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "lightningcss-linux-arm64-musl@1.33.0": { - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "lightningcss-linux-x64-gnu@1.33.0": { - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "os": ["linux"], - "cpu": ["x64"] - }, - "lightningcss-linux-x64-musl@1.33.0": { - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "os": ["linux"], - "cpu": ["x64"] - }, - "lightningcss-win32-arm64-msvc@1.33.0": { - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "lightningcss-win32-x64-msvc@1.33.0": { - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "os": ["win32"], - "cpu": ["x64"] - }, - "lightningcss@1.33.0": { - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dependencies": [ - "detect-libc" - ], - "optionalDependencies": [ - "lightningcss-android-arm64", - "lightningcss-darwin-arm64", - "lightningcss-darwin-x64", - "lightningcss-freebsd-x64", - "lightningcss-linux-arm-gnueabihf", - "lightningcss-linux-arm64-gnu", - "lightningcss-linux-arm64-musl", - "lightningcss-linux-x64-gnu", - "lightningcss-linux-x64-musl", - "lightningcss-win32-arm64-msvc", - "lightningcss-win32-x64-msvc" - ] - }, - "nanoid@3.3.16": { - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "bin": true - }, - "normalize-path@3.0.0": { - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" - }, - "p-map@7.0.6": { - "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==" - }, - "picocolors@1.1.1": { - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "picomatch@2.3.2": { - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" - }, - "picomatch@4.0.5": { - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==" - }, - "postcss@8.5.23": { - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", - "dependencies": [ - "nanoid", - "picocolors", - "source-map-js" - ] - }, - "readdirp@3.6.0": { - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": [ - "picomatch@2.3.2" - ] - }, - "rolldown@1.1.5": { - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dependencies": [ - "@oxc-project/types", - "@rolldown/pluginutils" - ], - "optionalDependencies": [ - "@rolldown/binding-android-arm64", - "@rolldown/binding-darwin-arm64", - "@rolldown/binding-darwin-x64", - "@rolldown/binding-freebsd-x64", - "@rolldown/binding-linux-arm-gnueabihf", - "@rolldown/binding-linux-arm64-gnu", - "@rolldown/binding-linux-arm64-musl", - "@rolldown/binding-linux-ppc64-gnu", - "@rolldown/binding-linux-s390x-gnu", - "@rolldown/binding-linux-x64-gnu", - "@rolldown/binding-linux-x64-musl", - "@rolldown/binding-openharmony-arm64", - "@rolldown/binding-wasm32-wasi", - "@rolldown/binding-win32-arm64-msvc", - "@rolldown/binding-win32-x64-msvc" - ], - "bin": true - }, - "source-map-js@1.2.1": { - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" - }, - "tinyglobby@0.2.17": { - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dependencies": [ - "fdir", - "picomatch@4.0.5" - ] - }, - "to-regex-range@5.0.1": { - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": [ - "is-number" - ] - }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "vite-plugin-static-copy@3.4.0_vite@8.1.5": { - "integrity": "sha512-ekryzCw0ouAOE8tw4RvVL/dfqguXzumsV3FBKoKso4MQ1MUUrUXtl5RI4KpJQUNGqFEsg9kxl4EvDl02YtA9VQ==", - "dependencies": [ - "chokidar", - "p-map", - "picocolors", - "tinyglobby", - "vite" - ] - }, - "vite-plugin-wasm@3.6.0_vite@8.1.5": { - "integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==", - "dependencies": [ - "vite" - ] - }, - "vite@8.1.5": { - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", - "dependencies": [ - "lightningcss", - "picomatch@4.0.5", - "postcss", - "rolldown", - "tinyglobby" - ], - "optionalDependencies": [ - "fsevents" - ], - "bin": true - } - }, - "workspace": { - "packageJson": { - "dependencies": [ - "npm:vite-plugin-static-copy@^3.2.0", - "npm:vite-plugin-wasm@^3.3.0", - "npm:vite@8" - ] - } - } -} diff --git a/web/www/package-lock.json b/web/www/package-lock.json new file mode 100644 index 00000000..3243615f --- /dev/null +++ b/web/www/package-lock.json @@ -0,0 +1,1075 @@ +{ + "name": "coop-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "coop-web", + "version": "0.1.0", + "devDependencies": { + "vite": "^8", + "vite-plugin-static-copy": "^3.2.0", + "vite-plugin-wasm": "^3.3.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-static-copy": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.4.0.tgz", + "integrity": "sha512-ekryzCw0ouAOE8tw4RvVL/dfqguXzumsV3FBKoKso4MQ1MUUrUXtl5RI4KpJQUNGqFEsg9kxl4EvDl02YtA9VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.4", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/sapphi-red" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz", + "integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + } + } +} diff --git a/web/www/package.json b/web/www/package.json index 197cb941..27d97ac7 100644 --- a/web/www/package.json +++ b/web/www/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "wasm": "cargo build --target wasm32-unknown-unknown --release && wasm-bindgen ../../../target/wasm32-unknown-unknown/release/gpui_component_story_web.wasm --out-dir ./src/wasm --target web --no-typescript" + "wasm": "../script/build-wasm.sh --release" }, "devDependencies": { "vite": "^8", diff --git a/web/www/vite.config.js b/web/www/vite.config.js index b5b0203a..51373b37 100644 --- a/web/www/vite.config.js +++ b/web/www/vite.config.js @@ -10,7 +10,7 @@ export default defineConfig({ viteStaticCopy({ targets: [ { - src: path.resolve(__dirname, "../../../assets/icons"), + src: path.resolve(__dirname, "../../assets/icons"), dest: "assets", }, ], @@ -21,7 +21,7 @@ export default defineConfig({ server.middlewares.use( "/coop/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", ""), @@ -61,10 +61,6 @@ export default defineConfig({ strict: false, allow: [".."], }, - headers: { - "Cross-Origin-Embedder-Policy": "require-corp", - "Cross-Origin-Opener-Policy": "same-origin", - }, }, optimizeDeps: { exclude: ["./src/wasm"], -- 2.54.0 From 72794dcbdc8ece0e8ccb0f98a2b18d558920b65b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 25 Oct 2026 10:31:53 +0700 Subject: [PATCH 6/9] Replace flume Selector with futures select for wasm compatibility --- Cargo.lock | 1 + crates/assets/Cargo.toml | 5 +++ crates/person/Cargo.toml | 1 + crates/person/src/lib.rs | 51 +++++++++++++++------- crates/workspace/src/sidebar/onboarding.rs | 9 +--- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2cceff40..8a8cfa52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5719,6 +5719,7 @@ dependencies = [ "anyhow", "common", "flume 0.11.1", + "futures", "gpui-pre", "instant", "log", diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 21b3f8ad..97af1380 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -9,3 +9,8 @@ gpui.workspace = true anyhow.workspace = true rust-embed.workspace = true gpui-kit.workspace = true + +# On wasm there is no filesystem to read from, so rust-embed's debug-mode +# disk loading returns nothing; embed the assets even in debug builds. +[target.'cfg(target_arch = "wasm32")'.dependencies] +rust-embed = { workspace = true, features = ["debug-embed"] } diff --git a/crates/person/Cargo.toml b/crates/person/Cargo.toml index c1385580..aa4221b2 100644 --- a/crates/person/Cargo.toml +++ b/crates/person/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true common = { path = "../common" } state = { path = "../state" } +futures.workspace = true gpui.workspace = true nostr-sdk.workspace = true instant.workspace = true diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index d885e079..39a04cc5 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -3,7 +3,10 @@ use std::sync::RwLock; use anyhow::{Error, anyhow}; use common::emojis_from_tags; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; +use futures::{FutureExt, pin_mut}; +use gpui::{ + App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Subscription, Task, +}; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -98,8 +101,9 @@ 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| { @@ -193,30 +197,45 @@ impl PersonRegistry { } /// Handle request for metadata - async fn handle_requests(client: &Client, rx: &flume::Receiver) { + 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)) - { - 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}"); + let timer = executor.timer(Duration::from_secs(TIMEOUT)).fuse(); + pin_mut!(timer); + + let request = futures::select! { + result = rx.recv_async().fuse() => match result { + Ok(public_key) => public_key, + Err(_) => { + if !batch.is_empty() + && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await + { + log::warn!("Failed to get metadata batch: {e}"); + } + return; } - } - _ => { + }, + () = timer => { if !batch.is_empty() && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await { log::warn!("Failed to get metadata batch: {e}"); } + continue; } + }; + + batch.insert(request); + // 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}"); } } } diff --git a/crates/workspace/src/sidebar/onboarding.rs b/crates/workspace/src/sidebar/onboarding.rs index 15fc4b54..8a178a3e 100644 --- a/crates/workspace/src/sidebar/onboarding.rs +++ b/crates/workspace/src/sidebar/onboarding.rs @@ -1,8 +1,7 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img}; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::v_flex; +use instant::Instant; use crate::dialogs::import; @@ -11,12 +10,8 @@ const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"]; /// Pick one of the signed-out backgrounds at random. pub(super) fn pick_banner() -> SharedString { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|elapsed| elapsed.subsec_nanos()) - .unwrap_or_default(); + let nanos = Instant::now().elapsed().subsec_nanos(); let index = nanos as usize % SIGNED_OUT_BANNERS.len(); - SIGNED_OUT_BANNERS[index].into() } -- 2.54.0 From 4b4a99c4645d3b2f15ff854de258866733708b50 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 25 Oct 2026 12:11:37 +0700 Subject: [PATCH 7/9] update --- crates/nip4e/src/lib.rs | 11 +++-- crates/state/src/credentials.rs | 56 +++++++++++++++++++++++++- crates/state/src/lib.rs | 2 +- crates/workspace/src/dock/tab_panel.rs | 1 + 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/crates/nip4e/src/lib.rs b/crates/nip4e/src/lib.rs index bb1dbbea..2aa90c2b 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -18,7 +18,7 @@ use nostr_sdk::prelude::*; use person::Directory; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; -use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner}; +use state::{Announcement, NostrRegistry, UniversalSigner, credentials}; use ui::Avatar; const IDENTIFIER: &str = "coop:device"; @@ -840,10 +840,10 @@ fn authorization_code(public_key: &PublicKey) -> SharedString { /// Get or create new app keys (async, returns a task) fn get_or_init_app_keys(cx: &App) -> Task> { - let read = cx.read_credentials(CLIENT_NAME); + let read = credentials::read_app_keys(cx); cx.spawn(async move |cx| { - if let Ok(Some((_, secret))) = read.await + if let Ok(Some(secret)) = read.await && let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new) { return Ok(keys); @@ -851,14 +851,13 @@ fn get_or_init_app_keys(cx: &App) -> Task> { // No stored keys found or invalid — generate new ones let keys = Keys::generate(); - let user = keys.public_key().to_hex(); let secret = keys.secret_key().to_secret_bytes(); cx.update(|cx| { - let write = cx.write_credentials(CLIENT_NAME, &user, &secret); + let write = credentials::write_app_keys(cx, &secret); cx.background_spawn(async move { if let Err(e) = write.await { - log::error!("Keyring not available or panic: {e}") + log::error!("Failed to persist app keys: {e}") } }) .detach(); diff --git a/crates/state/src/credentials.rs b/crates/state/src/credentials.rs index f7b93a2e..d9fade1f 100644 --- a/crates/state/src/credentials.rs +++ b/crates/state/src/credentials.rs @@ -1,10 +1,13 @@ use anyhow::Result; #[cfg(target_arch = "wasm32")] use common::web_store; -use gpui::{App, Task}; +use gpui::{App, AppContext as _, Task}; use crate::USER_KEYRING; +/// The storage key for the NIP-4e app keys +const APP_KEYS_KEY: &str = "coop/nip4e/app-keys"; + /// Read the stored credential as `(username, secret)` #[allow(clippy::type_complexity)] pub(crate) fn read(cx: &App) -> Task)>>> { @@ -49,3 +52,54 @@ pub(crate) fn delete(cx: &App) -> Task> { Task::ready(web_store::remove(USER_KEYRING)) } } + +/// Read named bytes from the platform keyring, +/// falling back to browser storage on web where no keyring exists +fn read_named(cx: &App, key: &str) -> Task>>> { + #[cfg(not(target_arch = "wasm32"))] + { + let key = key.to_owned(); + let read = cx.read_credentials(&key); + cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) }) + } + + #[cfg(target_arch = "wasm32")] + { + let _ = cx; + let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes())); + Task::ready(stored) + } +} + +/// Write named bytes to the platform keyring, +/// falling back to browser storage on web where no keyring exists +fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task> { + #[cfg(not(target_arch = "wasm32"))] + { + let key = key.to_owned(); + let secret = secret.to_vec(); + let write = cx.write_credentials(&key, "", &secret); + cx.background_spawn(write) + } + + #[cfg(target_arch = "wasm32")] + { + let _ = cx; + let content = match std::str::from_utf8(secret) { + Ok(content) => content, + Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))), + }; + + Task::ready(web_store::set(key, content)) + } +} + +/// Load the NIP-4e app keys from storage, as raw secret bytes +pub fn read_app_keys(cx: &App) -> Task>>> { + read_named(cx, APP_KEYS_KEY) +} + +/// Persist the NIP-4e app keys, as raw secret bytes +pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task> { + write_named(cx, APP_KEYS_KEY, secret) +} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 512110b4..e79f058f 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -22,7 +22,7 @@ use nostr_sdk::prelude::*; mod blossom; mod constants; -mod credentials; +pub mod credentials; mod file; mod nip05; mod nip4e; diff --git a/crates/workspace/src/dock/tab_panel.rs b/crates/workspace/src/dock/tab_panel.rs index 678b8d63..c92101b4 100644 --- a/crates/workspace/src/dock/tab_panel.rs +++ b/crates/workspace/src/dock/tab_panel.rs @@ -54,6 +54,7 @@ impl Render for DragPanelPreview { .cursor_grab() .p_1() .whitespace_nowrap() + .font_family(cx.theme().font_family.clone()) .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg) -- 2.54.0 From 29aa7f05aa60a745e2c1d5ed1970ffcecc4581b1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 25 Oct 2026 18:23:18 +0700 Subject: [PATCH 8/9] update --- crates/chat/src/lib.rs | 277 ++++++++++++++++++++++++++-------------- crates/state/src/lib.rs | 10 +- web/src/lib.rs | 26 ++-- 3 files changed, 194 insertions(+), 119 deletions(-) diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index f306fd9b..ce98b396 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -11,12 +11,12 @@ use gpui::{ App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task, WeakEntity, }; -use instant::Duration; +use instant::{Duration, Instant}; use nip4e::{DeviceEvent, DeviceRegistry}; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; -use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP}; +use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner}; mod message; mod room; @@ -29,7 +29,10 @@ pub use state::FileAttachment; use crate::search::MessageSearchIndex; -/// A static keypair used only for signing locally-cached rumor events. +/// Upper bound on signals applied to the UI in a single update +const MAX_SIGNAL_BATCH: usize = 256; + +/// Keypair used only for identifying locally-cached rumor events. static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); pub fn init(cx: &mut App) { @@ -74,6 +77,13 @@ impl Signal { } } +/// Which signer to attempt when unwrapping a gift wrap +enum UnwrapRoute { + User, + Device, + Both, +} + /// Chat Registry #[derive(Debug)] pub struct ChatRegistry { @@ -216,32 +226,34 @@ impl ChatRegistry { let tx = self.signal_tx.clone(); let rx = self.signal_rx.clone(); + let executor = cx.background_executor().clone(); + self.notification_listener = Some(cx.background_spawn(async move { - let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); const MAX_PROCESSED: usize = 10_000; + let mut notifications = client.notifications(); + let mut processed_events = HashSet::new(); + + let mut user_key = signer.get_public_key_async().await.ok(); + let device_key = match device_signer.as_ref() { + Some(device) => device.get_public_key_async().await.ok(), + None => None, + }; + + let mut last_yield = Instant::now(); + while let Some(notification) = notifications.next().await { + if last_yield.elapsed() >= Duration::from_millis(8) { + executor.timer(Duration::from_millis(0)).await; + last_yield = Instant::now(); + } + let ClientNotification::Message { message, relay_url } = notification else { continue; }; match *message { - RelayMessage::Event { - subscription_id, - event, - .. - } => { - let chat_sub = subscription_id.as_str() != sub_id1.as_str(); - let device_sub = subscription_id.as_str() != sub_id2.as_str(); - - // Concord wraps are also kind 1059. - // - // Only the two gift wrap subscriptions carry NIP-59 wraps for this account. - if event.kind == Kind::GiftWrap && chat_sub && device_sub { - continue; - } - + RelayMessage::Event { event, .. } => { // Prune the dedup set before it grows unbounded if processed_events.len() >= MAX_PROCESSED { processed_events.clear(); @@ -253,8 +265,10 @@ impl ChatRegistry { // Handle msg relays event to determine when the app is ready to subscribe if event.kind == Kind::InboxRelays { - let current_user = signer.get_public_key_async().await?; - if event.pubkey == current_user { + if user_key.is_none() { + user_key = signer.get_public_key_async().await.ok(); + } + if Some(event.pubkey) == user_key { tx.send_async(Signal::InboxReady).await?; } } @@ -270,15 +284,39 @@ impl ChatRegistry { seen.entry(event.id).or_default().insert(relay_url); } + // Route to the signer matching the recipient tag + // so a wrap is never decrypted twice. + let recipient = event.tags.public_keys().next(); + let route = match recipient { + Some(key) if Some(key) == device_key => UnwrapRoute::Device, + Some(key) if Some(key) == user_key => UnwrapRoute::User, + Some(_) if user_key.is_some() && device_key.is_some() => continue, + _ => UnwrapRoute::Both, + }; + // Extract the rumor from the gift wrap event - match extract_rumor( - &client, - &signer, - device_signer.as_ref(), - event.as_ref(), - ) - .await - { + let result = match route { + UnwrapRoute::User => { + extract_rumor(&client, &signer, None, event.as_ref()).await + } + UnwrapRoute::Device => match device_signer.as_ref() { + Some(device) => { + extract_rumor(&client, device, None, event.as_ref()).await + } + None => continue, + }, + UnwrapRoute::Both => { + extract_rumor( + &client, + &signer, + device_signer.as_ref(), + event.as_ref(), + ) + .await + } + }; + + match result { Ok(rumor) => { let Some(rumor_id) = rumor.id else { log::error!("Rumor missing id after ensure_id"); @@ -317,35 +355,56 @@ impl ChatRegistry { Ok(()) })); - 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); - })?; - } - Signal::InboxReady => { - this.update(cx, |this, cx| { - this.get_messages(cx); - })?; - } - Signal::Eose => { - this.update(cx, |this, _cx| { - this.tracking.store(false, Ordering::Release); - })?; + let tracking = self.tracking.clone(); - this.update(cx, |this, cx| { - this.get_rooms(cx); - })?; + self.signal_consumer = Some(cx.spawn(async move |this, cx| { + while let Ok(first) = rx.recv_async().await { + // Batch whatever has already arrived so the UI updates once per burst + let mut messages = Vec::new(); + let mut errors = Vec::new(); + let mut inbox_ready = false; + let mut eose = false; + + let mut pending = Some(first); + while let Some(signal) = pending.take() { + match signal { + Signal::Message(message) => messages.push(message), + Signal::InboxReady => inbox_ready = true, + Signal::Eose => eose = true, + Signal::Error(failed) => errors.push(failed), } - Signal::Error(failed) => { - trash.update(cx, |this, cx| { - this.insert(failed); - cx.notify(); - })?; + + if messages.len() + errors.len() >= MAX_SIGNAL_BATCH { + break; } - }; + + pending = rx.try_recv().ok(); + } + + if inbox_ready { + this.update(cx, |this, cx| { + this.get_messages(cx); + })?; + } + + if !messages.is_empty() { + this.update(cx, |this, cx| { + this.new_messages(messages, cx); + })?; + } + + if !errors.is_empty() { + trash.update(cx, |trash, cx| { + trash.extend(errors); + cx.notify(); + })?; + } + + if eose && tracking.swap(false, Ordering::AcqRel) { + this.update(cx, |this, cx| { + this.get_rooms(cx); + })?; + } } Ok(()) @@ -669,6 +728,7 @@ impl ChatRegistry { self.rooms.clear(); self.room_index.clear(); self.search.write().unwrap().clear(); + self.tracking.store(true, Ordering::Release); self.trash.update(cx, |this, cx| { this.clear(); cx.notify(); @@ -748,6 +808,7 @@ impl ChatRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); let search = self.search.clone(); + let executor = cx.background_executor().clone(); cx.background_spawn(async move { let public_key = signer.get_public_key_async().await?; @@ -775,7 +836,15 @@ impl ChatRegistry { let events = client.database().query(filter).await?; let mut grouped: HashMap> = HashMap::new(); + // Keep the single-threaded web event loop responsive during large rescans + let mut last_yield = Instant::now(); + for raw in events.into_iter() { + if last_yield.elapsed() >= Duration::from_millis(8) { + executor.timer(Duration::from_millis(0)).await; + last_yield = Instant::now(); + } + if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) && rumor.tags.public_keys().next().is_some() { @@ -811,12 +880,17 @@ impl ChatRegistry { }) } - /// Parse a nostr event into a message and push it to the belonging room + /// Parse nostr events into messages and push them to the belonging rooms /// - /// - If the room doesn't exist, it will be created. - /// - Updates room ordering based on the most recent messages. - pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context) { - self.search.write().unwrap().insert(&message.rumor); + /// - If a room doesn't exist, it will be created. + /// - Room ordering is updated once after the whole batch. + pub(crate) fn new_messages(&mut self, messages: Vec, cx: &mut Context) { + { + let mut search = self.search.write().unwrap(); + for message in &messages { + search.insert(&message.rumor); + } + } let nostr = NostrRegistry::global(cx); @@ -824,37 +898,42 @@ impl ChatRegistry { return; }; - match self.room_index.get(&message.room).cloned() { - Some(room) => { - room.update(cx, |this, cx| { - if this.kind == RoomKind::Request - && (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx)) - { - this.set_ongoing(cx); - } - this.push_message(message, cx); - }); - self.sort(cx); - } - None => { - // Push the new room to the front of the list - self.add_room(message.rumor, cx); + for message in messages { + match self.room_index.get(&message.room).cloned() { + Some(room) => { + room.update(cx, |this, cx| { + if this.kind == RoomKind::Request + && (message.rumor.pubkey == public_key + || this.auto_trusted(public_key, cx)) + { + this.set_ongoing(cx); + } + this.push_message(message, cx); + }); + } + None => { + // Push the new room to the front of the list + self.add_room(message.rumor, cx); + } } } + + self.sort(cx); + } + + /// Parse a nostr event into a message and push it to the belonging room + pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context) { + self.new_messages(vec![message], cx); } } /// Unwraps a gift-wrapped event and processes its contents. -async fn extract_rumor( +async fn extract_rumor( client: &Client, - signer: &S, - device_signer: Option<&D>, + signer: &UniversalSigner, + device_signer: Option<&UniversalSigner>, gift_wrap: &Event, -) -> Result -where - S: AsyncNip44 + ?Sized, - D: AsyncNip44 + ?Sized, -{ +) -> Result { // Try to get cached rumor first if let Ok(rumor) = get_rumor(client, gift_wrap.id).await { return Ok(rumor); @@ -888,10 +967,10 @@ where } /// Attempts to unwrap a gift wrap event with a given signer. -async fn try_unwrap_with(signer: &S, gift_wrap: &Event) -> Result -where - S: AsyncNip44 + ?Sized, -{ +async fn try_unwrap_with( + signer: &UniversalSigner, + gift_wrap: &Event, +) -> Result { // Get the sealed event let seal = signer .nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content) @@ -926,17 +1005,27 @@ where async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { let room_id = rumor.uniq_id().to_string(); - let tags = vec![ + let tags = Tags::from_list(vec![ Tag::identifier(id), Tag::public_key(rumor.pubkey), Tag::custom("r", [room_id]), Tag::custom("k", [rumor.kind.to_string()]), - ]; + ]); - let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json()) - .tags(tags) - .finalize_async(&*LOCAL_KEYS) - .await?; + let public_key = LOCAL_KEYS.public_key(); + let created_at = Timestamp::now(); + let kind = Kind::ApplicationSpecificData; + let content = rumor.as_json(); + + let event = Event::new( + EventId::compute(&public_key, &created_at, &kind, &tags, &content), + public_key, + created_at, + kind, + tags, + content, + Signature::from_byte_array([0u8; 64]), + ); client.database().save_event(&event).await?; diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index e79f058f..e5e7700b 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -477,16 +477,13 @@ impl NostrRegistry { cx.notify(); } - /// Connect the browser extension signer (NIP-07), remembering the choice so - /// reloads reconnect automatically + /// Connect the browser extension signer (NIP-07). #[cfg(target_arch = "wasm32")] pub fn connect_extension(&mut self, cx: &mut Context) { let signer = match BrowserSigner::new() { Ok(signer) => signer, Err(e) => { - cx.emit(StateEvent::error(format!( - "No Nostr extension found: {e}. Install a NIP-07 extension like Alby or nos2x." - ))); + cx.emit(StateEvent::error(format!("No Nostr extension found: {e}."))); cx.emit(StateEvent::NoSigner); return; } @@ -495,7 +492,6 @@ impl NostrRegistry { self.tasks.push(cx.spawn(async move |this, cx| { match signer.get_public_key_async().await { Ok(public_key) => { - // Remember the choice so reloads reconnect automatically this.update(cx, |this, cx| { credentials::write(cx, "extension", b"extension").detach_and_log_err(cx); this.signer.swap_inner(signer); @@ -507,8 +503,6 @@ impl NostrRegistry { })?; } Err(e) => { - // Drop the remembered choice so the next visit does not - // retry a signer that is no longer answering this.update(cx, |_, cx| { credentials::delete(cx).detach_and_log_err(cx); cx.emit(StateEvent::error(e.to_string())); diff --git a/web/src/lib.rs b/web/src/lib.rs index 54422234..d1046d5f 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,5 +1,8 @@ +#![cfg(target_arch = "wasm32")] + use std::cell::RefCell; use std::rc::Rc; +use std::sync::Arc; use gpui::*; use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider}; @@ -42,36 +45,29 @@ pub fn run() -> Result<(), JsValue> { // Sets the panic hook and GPUI's console logger gpui_platform::web_init(); - // 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 http_client = Arc::new(platform.fetch_http_client()); + let app = Application::with_platform(platform) .with_http_client(http_client) .with_assets(assets::CombinedAssets); APPLICATION.with(|application| { *application.borrow_mut() = Some(app.run_embedded(|cx| { - // Initialize the framework before anything renders - gpui_kit::init(cx); - - // 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); + // Initialize the framework before anything renders + gpui_kit::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); @@ -121,10 +117,6 @@ fn load_embedded_fonts(cx: &mut App) { .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; -- 2.54.0 From ded0e27a56b711ad439f67eddb412ceb14306753 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 25 Oct 2026 20:09:46 +0700 Subject: [PATCH 9/9] use sqlite for wasm --- Cargo.lock | 329 +++++++++++++++++++++++++++++-- Cargo.toml | 1 + crates/state/Cargo.toml | 4 + crates/state/src/lib.rs | 7 +- crates/state/src/web_database.rs | 62 ++++++ web/Cargo.toml | 1 + web/src/lib.rs | 38 +++- web/www/main.js | 3 + 8 files changed, 420 insertions(+), 25 deletions(-) create mode 100644 crates/state/src/web_database.rs diff --git a/Cargo.lock b/Cargo.lock index 8a8cfa52..987496e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,18 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "accessory" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc86a613208ca7d144ca24f6ec49c5cdfa5c05a46f2f7ab5880ff99371f165de" +dependencies = [ + "macroific 3.1.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -1391,6 +1403,7 @@ dependencies = [ "ui", "universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)", "wasm-bindgen", + "wasm-bindgen-futures", "workspace", ] @@ -1733,6 +1746,20 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "delegate-display" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249" +dependencies = [ + "impartial-ord", + "itoa", + "macroific 3.1.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "deranged" version = "0.5.9" @@ -1762,6 +1789,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -1826,7 +1854,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2054,7 +2082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2113,6 +2141,30 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy_constructor" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a" +dependencies = [ + "macroific 3.1.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "faster-hex" version = "0.10.1" @@ -3912,6 +3964,51 @@ version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" +[[package]] +name = "impartial-ord" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "indexed_db_futures" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ff41758cbd104e91033bb53bc449bec7eea65652960c81eddf3fc146ecea19" +dependencies = [ + "accessory", + "cfg-if", + "delegate-display", + "derive_more", + "fancy_constructor", + "indexed_db_futures_macros_internal", + "js-sys", + "sealed 0.6.0", + "smallvec", + "thiserror 2.0.21", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "indexed_db_futures_macros_internal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caeba94923b68f254abef921cea7e7698bf4675fdd89d7c58bf1ed885b49a27d" +dependencies = [ + "macroific 2.0.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -4310,6 +4407,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -4487,6 +4595,102 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +[[package]] +name = "macroific" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285" +dependencies = [ + "macroific_attr_parse 2.0.0", + "macroific_core 2.0.0", + "macroific_macro 2.0.0", +] + +[[package]] +name = "macroific" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc8ab95c98294764c0f1eacebdb5b027849201b71fbc383871fe991a1de7eb83" +dependencies = [ + "macroific_attr_parse 3.1.0", + "macroific_core 3.1.0", + "macroific_macro 3.1.0", +] + +[[package]] +name = "macroific_attr_parse" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9" +dependencies = [ + "proc-macro2", + "quote", + "sealed 0.6.0", + "syn 2.0.119", +] + +[[package]] +name = "macroific_attr_parse" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf9b7bdb92825e6a507392b12f0570ade17480be20f4ca951b9071b40deba1d1" +dependencies = [ + "proc-macro2", + "quote", + "sealed 0.7.0", + "syn 3.0.9", +] + +[[package]] +name = "macroific_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3" +dependencies = [ + "proc-macro2", + "quote", + "sealed 0.6.0", + "syn 2.0.119", +] + +[[package]] +name = "macroific_core" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc880da2bc00d6d87f748805e242d670e248d1b9b6fcef3190503210b6cf056" +dependencies = [ + "proc-macro2", + "quote", + "sealed 0.7.0", + "syn 3.0.9", +] + +[[package]] +name = "macroific_macro" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b" +dependencies = [ + "macroific_attr_parse 2.0.0", + "macroific_core 2.0.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "macroific_macro" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201faf668c264432727fb64e44407bcfcf1f5f8153bf4ec248eb6bbe4a39b144" +dependencies = [ + "macroific_attr_parse 3.1.0", + "macroific_core 3.1.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -5006,6 +5210,19 @@ dependencies = [ "universal-time 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "nostr-sqlite" +version = "0.45.2" +source = "git+https://github.com/rust-nostr/nostr#f7ebc0a88ea27ac4e3d74b941e3a44ddb10b306c" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "rusqlite", + "serde_json", + "tokio", +] + [[package]] name = "notify" version = "8.2.0" @@ -5062,7 +5279,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6150,7 +6367,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6601,6 +6818,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rust-embed" version = "8.13.0" @@ -6723,7 +6964,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6791,7 +7032,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6961,6 +7202,28 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sealed" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "secp256k1" version = "0.30.0" @@ -7397,7 +7660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7427,6 +7690,34 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "sqlite-wasm-vfs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7a5c9ac229421d577bb5a9bb59048838509958b218dd4e0b3c1214a87c361e" +dependencies = [ + "indexed_db_futures", + "js-sys", + "rsqlite-vfs", + "thiserror 2.0.21", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7448,6 +7739,7 @@ dependencies = [ "gpui_tokio", "instant", "js-sys", + "log", "mime_guess", "nostr", "nostr-blossom", @@ -7457,10 +7749,13 @@ dependencies = [ "nostr-lmdb", "nostr-memory", "nostr-sdk", + "nostr-sqlite", "rustls", "serde_json", "sha2 0.10.9", "smol", + "sqlite-wasm-rs", + "sqlite-wasm-vfs", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -7723,7 +8018,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8254,7 +8549,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8354,6 +8649,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -8528,6 +8829,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -9052,7 +9359,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -9677,7 +9984,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2573dced..e188678d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } +nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] } # Crypto (NIP-17 encrypted file messages) diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index 1485dffd..116a721d 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -20,6 +20,7 @@ futures.workspace = true anyhow.workspace = true webbrowser.workspace = true serde_json.workspace = true +log.workspace = true mime_guess = "2.0.4" @@ -31,6 +32,7 @@ data-encoding.workspace = true js-sys = "0.3" nostr-browser-signer.workspace = true nostr-memory.workspace = true +nostr-sqlite.workspace = true wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = [ @@ -43,6 +45,8 @@ web-sys = { version = "0.3", features = [ "Url", "Window", ] } +sqlite-wasm-rs = "0.5" +sqlite-wasm-vfs = "0.2" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] browser-signer-proxy = { path = "../browser-signer-proxy" } diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index e5e7700b..6ea25b37 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -16,8 +16,6 @@ use nostr_connect::prelude::*; use nostr_gossip_memory::prelude::*; #[cfg(not(target_arch = "wasm32"))] use nostr_lmdb::prelude::*; -#[cfg(target_arch = "wasm32")] -use nostr_memory::prelude::*; use nostr_sdk::prelude::*; mod blossom; @@ -28,6 +26,8 @@ mod nip05; mod nip4e; mod signer; +#[cfg(target_arch = "wasm32")] +pub mod web_database; #[cfg(target_arch = "wasm32")] pub mod web_file; @@ -114,7 +114,6 @@ impl NostrRegistry { let signer = UniversalSigner::new(Keys::generate()); let authenticator = SignerAuthenticator::new(signer.clone()); - // Construct the nostr lmdb instance #[cfg(not(target_arch = "wasm32"))] let database = cx.foreground_executor().block_on(async move { NostrLmdb::open(config_dir().join("nostr")) @@ -123,7 +122,7 @@ impl NostrRegistry { }); #[cfg(target_arch = "wasm32")] - let database = MemoryDatabase::unbounded(); + let database = web_database::take(); // Construct the nostr client let client = ClientBuilder::default() diff --git a/crates/state/src/web_database.rs b/crates/state/src/web_database.rs new file mode 100644 index 00000000..0bbc8de7 --- /dev/null +++ b/crates/state/src/web_database.rs @@ -0,0 +1,62 @@ +use std::cell::RefCell; +use std::sync::Arc; + +use nostr_memory::prelude::*; +use nostr_sqlite::prelude::NostrSqlite; +use sqlite_wasm_rs::WasmOsCallback; +use sqlite_wasm_vfs::relaxed_idb; + +const DATABASE_PATH: &str = "coop.sqlite3"; +const VFS_NAME: &str = "coop-relaxed-idb"; + +thread_local! { + static DATABASE: RefCell> = const { RefCell::new(None) }; +} + +/// Install the IndexedDB storage backend and open the database. +pub async fn open() { + match open_persistent().await { + Some(database) => DATABASE.with(|slot| *slot.borrow_mut() = Some(database)), + None => log::error!("Falling back to the in-memory nostr database"), + } +} + +/// Hand the opened database to the client constructor +pub(crate) fn take() -> Arc { + let database = DATABASE.with(|slot| slot.borrow_mut().take()); + + match database { + Some(database) => Arc::new(database), + None => { + log::error!("Persistent web database was not opened, falling back to memory"); + Arc::new(MemoryDatabase::unbounded()) + } + } +} + +/// Open the database on IndexedDB storage, or `None` when unavailable +async fn open_persistent() -> Option { + let options = relaxed_idb::RelaxedIdbCfgBuilder::new() + .vfs_name(VFS_NAME) + .build(); + + if let Err(e) = relaxed_idb::install::(&options, true).await { + log::warn!("IndexedDB storage unavailable: {e:?}"); + return None; + } + + match NostrSqlite::builder() + .persistent_with_vfs(DATABASE_PATH, VFS_NAME) + .build() + .await + { + Ok(database) => { + log::info!("Nostr database opened on IndexedDB storage"); + Some(database) + } + Err(e) => { + log::error!("Failed to open the nostr database on IndexedDB: {e:?}"); + None + } + } +} diff --git a/web/Cargo.toml b/web/Cargo.toml index b4afec19..8658a426 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" js-sys = "0.3" universal-time = { git = "https://github.com/shadowylab/universal-time" } diff --git a/web/src/lib.rs b/web/src/lib.rs index d1046d5f..5afcbbf4 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -32,15 +32,34 @@ impl MonotonicClock for BrowserTimeProvider { define_time_provider!(BrowserTimeProvider); +fn init_logging() { + // Called from both entry points; the first one wins + static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + + ONCE.get_or_init(|| { + console_error_panic_hook::set_once(); + + // Initialize logging to browser console + console_log::init_with_level(log::Level::Info).ok(); + + // Also initialize tracing for WASM + tracing_wasm::set_as_global_default(); + }); +} + +/// Open the persistent nostr database before the app starts. +#[wasm_bindgen] +pub async fn open_database() -> Result<(), JsValue> { + init_logging(); + + state::web_database::open().await; + + Ok(()) +} + #[wasm_bindgen] pub fn run() -> Result<(), JsValue> { - console_error_panic_hook::set_once(); - - // Initialize logging to browser console - console_log::init_with_level(log::Level::Info).expect("Failed to initialize logger"); - - // Also initialize tracing for WASM - tracing_wasm::set_as_global_default(); + init_logging(); // Sets the panic hook and GPUI's console logger gpui_platform::web_init(); @@ -59,12 +78,11 @@ pub fn run() -> Result<(), JsValue> { APPLICATION.with(|application| { *application.borrow_mut() = Some(app.run_embedded(|cx| { + gpui_kit::init(cx); + load_embedded_fonts(cx); apply_web_fonts(cx); - // Initialize the framework before anything renders - gpui_kit::init(cx); - // Initialize settings settings::init(cx); diff --git a/web/www/main.js b/web/www/main.js index 48d644c3..88df4453 100644 --- a/web/www/main.js +++ b/web/www/main.js @@ -7,6 +7,9 @@ async function init() { const wasm = await import('./wasm/coop_web.js'); await wasm.default(); + // Open the persistent nostr database before the app starts + await wasm.open_database(); + // Initialize the story gallery await wasm.run(); -- 2.54.0