8 Commits
Author SHA1 Message Date
reya 29aa7f05aa update 2026-10-25 18:23:18 +07:00
reya 4b4a99c464 update 2026-10-25 12:11:37 +07:00
reya 72794dcbdc Replace flume Selector with futures select for wasm compatibility 2026-10-25 10:31:53 +07:00
reya d7ae86e955 Add CI check for wasm target and switch web build to npm 2026-10-25 08:50:56 +07:00
reya a27ecd767e support file picking and uploads on web 2026-10-24 21:30:45 +07:00
reya f9b40d9d49 add NIP-07 browser extension signer support for wasm 2026-10-24 20:29:09 +07:00
reya f829a2774d fix web build 2026-10-24 19:37:04 +07:00
reya 8929e3137b wip 2026-10-24 18:19:03 +07:00
40 changed files with 2553 additions and 936 deletions
+22
View File
@@ -30,3 +30,25 @@ jobs:
- name: Run tests - name: Run tests
run: cargo test --verbose 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
Generated
+52 -29
View File
@@ -299,7 +299,7 @@ dependencies = [
[[package]] [[package]]
name = "assets" name = "assets"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui-kit", "gpui-kit",
@@ -542,7 +542,7 @@ dependencies = [
[[package]] [[package]]
name = "auto_update" name = "auto_update"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"futures", "futures",
"gpui-pre", "gpui-pre",
@@ -1041,7 +1041,7 @@ dependencies = [
[[package]] [[package]]
name = "chat" name = "chat"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]] [[package]]
name = "chat_ui" name = "chat_ui"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chat", "chat",
@@ -1221,8 +1221,9 @@ dependencies = [
[[package]] [[package]]
name = "common" name = "common"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"futures", "futures",
@@ -1232,6 +1233,7 @@ dependencies = [
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"regex", "regex",
"web-sys",
] ]
[[package]] [[package]]
@@ -1335,7 +1337,7 @@ dependencies = [
[[package]] [[package]]
name = "coop" name = "coop"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"assets", "assets",
"auto_update", "auto_update",
@@ -1364,8 +1366,9 @@ dependencies = [
[[package]] [[package]]
name = "coop_web" name = "coop_web"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"assets",
"chat", "chat",
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
@@ -1377,6 +1380,7 @@ dependencies = [
"gpui-pre-platform", "gpui-pre-platform",
"gpui-pre-web", "gpui-pre-web",
"instant", "instant",
"js-sys",
"log", "log",
"nip29", "nip29",
"nip4e", "nip4e",
@@ -1822,7 +1826,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.3", "redox_users 0.5.3",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -2050,7 +2054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4765,7 +4769,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "nip29" name = "nip29"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chat", "chat",
@@ -4783,7 +4787,7 @@ dependencies = [
[[package]] [[package]]
name = "nip29_ui" name = "nip29_ui"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chat", "chat",
@@ -4804,7 +4808,7 @@ dependencies = [
[[package]] [[package]]
name = "nip4e" name = "nip4e"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"flume 0.11.1", "flume 0.11.1",
@@ -4898,6 +4902,19 @@ dependencies = [
"serde", "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]] [[package]]
name = "nostr-connect" name = "nostr-connect"
version = "0.45.2" version = "0.45.2"
@@ -5045,7 +5062,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -5697,11 +5714,12 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "person" name = "person"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
"flume 0.11.1", "flume 0.11.1",
"futures",
"gpui-pre", "gpui-pre",
"instant", "instant",
"log", "log",
@@ -6132,7 +6150,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -6576,9 +6594,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.21.1" version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -6705,7 +6723,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -6773,7 +6791,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -7161,7 +7179,7 @@ dependencies = [
[[package]] [[package]]
name = "settings" name = "settings"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
@@ -7379,7 +7397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -7417,7 +7435,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "state" name = "state"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -7429,9 +7447,11 @@ dependencies = [
"gpui-pre", "gpui-pre",
"gpui_tokio", "gpui_tokio",
"instant", "instant",
"js-sys",
"mime_guess", "mime_guess",
"nostr", "nostr",
"nostr-blossom", "nostr-blossom",
"nostr-browser-signer",
"nostr-connect", "nostr-connect",
"nostr-gossip-memory", "nostr-gossip-memory",
"nostr-lmdb", "nostr-lmdb",
@@ -7441,6 +7461,9 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"smol", "smol",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webbrowser", "webbrowser",
] ]
@@ -7700,7 +7723,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -8231,12 +8254,12 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [ dependencies = [
"memoffset", "memoffset",
"tempfile", "tempfile",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
name = "ui" name = "ui"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"common", "common",
"gpui-base", "gpui-base",
@@ -8444,7 +8467,7 @@ dependencies = [
"kurbo 0.13.1", "kurbo 0.13.1",
"log", "log",
"pico-args", "pico-args",
"roxmltree 0.21.1", "roxmltree 0.21.2",
"rustybuzz", "rustybuzz",
"simplecss", "simplecss",
"siphasher", "siphasher",
@@ -9029,7 +9052,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -9654,7 +9677,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -9674,7 +9697,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "workspace" name = "workspace"
version = "2.0.0-alpha" version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"auto_update", "auto_update",
+1
View File
@@ -32,6 +32,7 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { 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-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { 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-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
+5
View File
@@ -9,3 +9,8 @@ gpui.workspace = true
anyhow.workspace = true anyhow.workspace = true
rust-embed.workspace = true rust-embed.workspace = true
gpui-kit.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"] }
+27
View File
@@ -20,12 +20,39 @@ impl AssetSource for CombinedAssets {
if let Ok(Some(data)) = Assets.load(path) { if let Ok(Some(data)) = Assets.load(path) {
return Ok(Some(data)); 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<gpui_kit::assets::Assets> = 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) gpui_kit::assets::Assets.load(path)
} }
fn list(&self, path: &str) -> Result<Vec<SharedString>> { fn list(&self, path: &str) -> Result<Vec<SharedString>> {
let mut paths = Assets.list(path).unwrap_or_default(); let mut paths = Assets.list(path).unwrap_or_default();
#[cfg(target_arch = "wasm32")]
{
use std::sync::OnceLock;
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = 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)?); paths.extend(gpui_kit::assets::Assets.list(path)?);
Ok(paths) Ok(paths)
} }
} }
+1
View File
@@ -11,6 +11,7 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
futures.workspace = true futures.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
gpui-updater-pre = "0.2" gpui-updater-pre = "0.2"
[dev-dependencies] [dev-dependencies]
+183 -94
View File
@@ -11,12 +11,12 @@ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task, App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
WeakEntity, WeakEntity,
}; };
use instant::Duration; use instant::{Duration, Instant};
use nip4e::{DeviceEvent, DeviceRegistry}; use nip4e::{DeviceEvent, DeviceRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP}; use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
mod message; mod message;
mod room; mod room;
@@ -29,7 +29,10 @@ pub use state::FileAttachment;
use crate::search::MessageSearchIndex; 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<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
pub fn init(cx: &mut App) { 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 /// Chat Registry
#[derive(Debug)] #[derive(Debug)]
pub struct ChatRegistry { pub struct ChatRegistry {
@@ -216,32 +226,34 @@ impl ChatRegistry {
let tx = self.signal_tx.clone(); let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone(); let rx = self.signal_rx.clone();
let executor = cx.background_executor().clone();
self.notification_listener = Some(cx.background_spawn(async move { 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; 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 { 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 { let ClientNotification::Message { message, relay_url } = notification else {
continue; continue;
}; };
match *message { match *message {
RelayMessage::Event { RelayMessage::Event { 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;
}
// Prune the dedup set before it grows unbounded // Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED { if processed_events.len() >= MAX_PROCESSED {
processed_events.clear(); processed_events.clear();
@@ -253,8 +265,10 @@ impl ChatRegistry {
// Handle msg relays event to determine when the app is ready to subscribe // Handle msg relays event to determine when the app is ready to subscribe
if event.kind == Kind::InboxRelays { if event.kind == Kind::InboxRelays {
let current_user = signer.get_public_key_async().await?; if user_key.is_none() {
if event.pubkey == current_user { user_key = signer.get_public_key_async().await.ok();
}
if Some(event.pubkey) == user_key {
tx.send_async(Signal::InboxReady).await?; tx.send_async(Signal::InboxReady).await?;
} }
} }
@@ -270,15 +284,39 @@ impl ChatRegistry {
seen.entry(event.id).or_default().insert(relay_url); 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 // Extract the rumor from the gift wrap event
match extract_rumor( let result = match route {
&client, UnwrapRoute::User => {
&signer, extract_rumor(&client, &signer, None, event.as_ref()).await
device_signer.as_ref(), }
event.as_ref(), UnwrapRoute::Device => match device_signer.as_ref() {
) Some(device) => {
.await 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) => { Ok(rumor) => {
let Some(rumor_id) = rumor.id else { let Some(rumor_id) = rumor.id else {
log::error!("Rumor missing id after ensure_id"); log::error!("Rumor missing id after ensure_id");
@@ -317,35 +355,56 @@ impl ChatRegistry {
Ok(()) Ok(())
})); }));
self.signal_consumer = Some(cx.spawn(async move |this, cx| { let tracking = self.tracking.clone();
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);
})?;
this.update(cx, |this, cx| { self.signal_consumer = Some(cx.spawn(async move |this, cx| {
this.get_rooms(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| { if messages.len() + errors.len() >= MAX_SIGNAL_BATCH {
this.insert(failed); break;
cx.notify();
})?;
} }
};
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(()) Ok(())
@@ -669,6 +728,7 @@ impl ChatRegistry {
self.rooms.clear(); self.rooms.clear();
self.room_index.clear(); self.room_index.clear();
self.search.write().unwrap().clear(); self.search.write().unwrap().clear();
self.tracking.store(true, Ordering::Release);
self.trash.update(cx, |this, cx| { self.trash.update(cx, |this, cx| {
this.clear(); this.clear();
cx.notify(); cx.notify();
@@ -748,6 +808,7 @@ impl ChatRegistry {
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let search = self.search.clone(); let search = self.search.clone();
let executor = cx.background_executor().clone();
cx.background_spawn(async move { cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?; let public_key = signer.get_public_key_async().await?;
@@ -775,7 +836,15 @@ impl ChatRegistry {
let events = client.database().query(filter).await?; let events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new(); let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = 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() { 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) if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().next().is_some() && 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. /// - If a room doesn't exist, it will be created.
/// - Updates room ordering based on the most recent messages. /// - Room ordering is updated once after the whole batch.
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) { pub(crate) fn new_messages(&mut self, messages: Vec<NewMessage>, cx: &mut Context<Self>) {
self.search.write().unwrap().insert(&message.rumor); {
let mut search = self.search.write().unwrap();
for message in &messages {
search.insert(&message.rumor);
}
}
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -824,37 +898,42 @@ impl ChatRegistry {
return; return;
}; };
match self.room_index.get(&message.room).cloned() { for message in messages {
Some(room) => { match self.room_index.get(&message.room).cloned() {
room.update(cx, |this, cx| { Some(room) => {
if this.kind == RoomKind::Request room.update(cx, |this, cx| {
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx)) if this.kind == RoomKind::Request
{ && (message.rumor.pubkey == public_key
this.set_ongoing(cx); || this.auto_trusted(public_key, cx))
} {
this.push_message(message, cx); this.set_ongoing(cx);
}); }
self.sort(cx); this.push_message(message, cx);
} });
None => { }
// Push the new room to the front of the list None => {
self.add_room(message.rumor, cx); // 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>) {
self.new_messages(vec![message], cx);
} }
} }
/// Unwraps a gift-wrapped event and processes its contents. /// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor<S, D>( async fn extract_rumor(
client: &Client, client: &Client,
signer: &S, signer: &UniversalSigner,
device_signer: Option<&D>, device_signer: Option<&UniversalSigner>,
gift_wrap: &Event, gift_wrap: &Event,
) -> Result<UnsignedEvent, Error> ) -> Result<UnsignedEvent, Error> {
where
S: AsyncNip44 + ?Sized,
D: AsyncNip44 + ?Sized,
{
// Try to get cached rumor first // Try to get cached rumor first
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await { if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
return Ok(rumor); return Ok(rumor);
@@ -888,10 +967,10 @@ where
} }
/// Attempts to unwrap a gift wrap event with a given signer. /// Attempts to unwrap a gift wrap event with a given signer.
async fn try_unwrap_with<S>(signer: &S, gift_wrap: &Event) -> Result<UnwrappedGift, Error> async fn try_unwrap_with(
where signer: &UniversalSigner,
S: AsyncNip44 + ?Sized, gift_wrap: &Event,
{ ) -> Result<UnwrappedGift, Error> {
// Get the sealed event // Get the sealed event
let seal = signer let seal = signer
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content) .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> { async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string(); let room_id = rumor.uniq_id().to_string();
let tags = vec![ let tags = Tags::from_list(vec![
Tag::identifier(id), Tag::identifier(id),
Tag::public_key(rumor.pubkey), Tag::public_key(rumor.pubkey),
Tag::custom("r", [room_id]), Tag::custom("r", [room_id]),
Tag::custom("k", [rumor.kind.to_string()]), Tag::custom("k", [rumor.kind.to_string()]),
]; ]);
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json()) let public_key = LOCAL_KEYS.public_key();
.tags(tags) let created_at = Timestamp::now();
.finalize_async(&*LOCAL_KEYS) let kind = Kind::ApplicationSpecificData;
.await?; 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?; client.database().save_event(&event).await?;
+48 -10
View File
@@ -1,32 +1,70 @@
use std::path::{Path, PathBuf}; use anyhow::Error;
use chat::FileAttachment;
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::{FileAttachment, LocalFile, PickedFile};
/// A file attachment that has been uploaded, but not sent yet. /// 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. /// the file without downloading and decrypting it again.
pub(crate) struct PendingFile { pub(crate) struct PendingFile {
pub file: FileAttachment, pub file: FileAttachment,
pub path: PathBuf, pub local: LocalFile,
} }
/// State of the encrypted file attachment of a message /// State of the encrypted file attachment of a message
pub(crate) enum DecryptedFile { pub(crate) enum DecryptedFile {
Loading, Loading,
Ready(PathBuf), Ready(LocalFile),
Failed(SharedString), Failed(SharedString),
} }
/// Result of an upload, either plain or encrypted /// Result of an upload, either plain or encrypted
pub(crate) enum Uploaded { pub(crate) enum Uploaded {
Url(Url), Url(Url),
File(FileAttachment, PathBuf), File(Box<UploadedFile>),
} }
/// A `file://` url for a decrypted file, so it can be opened by the OS /// An encrypted upload and the handle to preview it locally
pub(crate) fn file_url(path: &Path) -> String { pub(crate) struct UploadedFile {
format!("file://{}", path.display()) 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<Self, Error> {
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<UploadedFileParts, Error> {
let local = picked.local.clone();
state::upload_encrypted_file(server, picked, cx)
.await
.map(|file| UploadedFileParts { file, local })
} }
+45 -57
View File
@@ -1,5 +1,4 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*; pub use actions::*;
@@ -10,10 +9,9 @@ use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri, ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled,
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity,
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, Window, div, img, list, px, red, relative, retain_all, svg, white,
retain_all, svg, white,
}; };
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
@@ -31,7 +29,7 @@ use regex::Regex;
use settings::{AppSettings, SignerKind}; use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{ use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted, FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
}; };
use ui::{ use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow, 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 // Encrypt attachments which are not part of a message being written
let encrypted = self.input.read(cx).value().trim().is_empty(); let encrypted = self.input.read(cx).value().trim().is_empty();
// Ask user for file upload // Ask the user for a file to upload
let path = cx.prompt_for_paths(PathPromptOptions { let picker = pick_file(cx);
files: true,
directories: false,
multiple: false,
prompt: None,
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled // 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(()); return Ok(());
}; };
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.upload_file(server, path, encrypted, window, cx); this.upload_file(server, picked, encrypted, window, cx);
})?; })?;
Ok(()) Ok(())
@@ -668,7 +661,7 @@ impl ChatPanel {
fn upload_file( fn upload_file(
&mut self, &mut self,
server: Url, server: Url,
path: PathBuf, picked: PickedFile,
encrypted: bool, encrypted: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@@ -676,25 +669,18 @@ impl ChatPanel {
self.set_uploading(true, cx); self.set_uploading(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = if encrypted { let result = Uploaded::from_pick(encrypted, server.clone(), picked, cx).await;
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)
};
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx); this.set_uploading(false, cx);
match result { match result {
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx), Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx), Ok(Uploaded::File(uploaded)) => {
Err(e) if encrypted => { let UploadedFile { file, local } = *uploaded;
this.report_encrypted_upload_error(server, path, e, window, cx) this.add_pending_file(file, local, cx);
} }
Err(e) if encrypted => this.report_encrypted_upload_error(e, window, cx),
Err(e) => { Err(e) => {
window.push_notification( window.push_notification(
Notification::error(e.to_string()).autohide(false), Notification::error(e.to_string()).autohide(false),
@@ -711,8 +697,6 @@ impl ChatPanel {
/// Report a failed encrypted upload, offering to retry it without encryption /// Report a failed encrypted upload, offering to retry it without encryption
fn report_encrypted_upload_error( fn report_encrypted_upload_error(
&mut self, &mut self,
server: Url,
path: PathBuf,
error: Error, error: Error,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@@ -724,14 +708,12 @@ impl ChatPanel {
.title("Encrypted upload failed") .title("Encrypted upload failed")
.action(move |_this, _window, _cx| { .action(move |_this, _window, _cx| {
let view = view.clone(); let view = view.clone();
let server = server.clone();
let path = path.clone();
Button::new("retry-without-encryption") Button::new("retry-without-encryption")
.label("Upload without encryption") .label("Pick the file again without encryption")
.on_click(move |_ev, window, cx| { .on_click(move |_ev, window, cx| {
view.update(cx, |this, cx| { view.update(cx, |this, cx| {
this.upload_file(server.clone(), path.clone(), false, window, cx); this.upload(window, cx);
}) })
.ok(); .ok();
}) })
@@ -761,9 +743,9 @@ impl ChatPanel {
}); });
} }
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) { fn add_pending_file(&mut self, file: FileAttachment, local: LocalFile, cx: &mut Context<Self>) {
self.encrypted_attachments.update(cx, |this, cx| { self.encrypted_attachments.update(cx, |this, cx| {
this.push(PendingFile { file, path }); this.push(PendingFile { file, local });
cx.notify(); cx.notify();
}); });
} }
@@ -782,12 +764,13 @@ impl ChatPanel {
self.decrypted_files.insert(id, DecryptedFile::Loading); self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn(async move |this, cx| { 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| { this.update(cx, |this, cx| {
match result { match result {
Ok(path) => { Ok(downloaded) => {
this.decrypted_files.insert(id, DecryptedFile::Ready(path)); this.decrypted_files
.insert(id, DecryptedFile::Ready(downloaded.local));
} }
Err(e) => { Err(e) => {
this.decrypted_files 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( fn open_file(
&mut self, &mut self,
id: EventId, id: EventId,
@@ -810,25 +793,30 @@ impl ChatPanel {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
match self.decrypted_files.get(&id) { if let Some(DecryptedFile::Ready(local)) = self.decrypted_files.get(&id) {
Some(DecryptedFile::Ready(path)) => { let _ = local.open(cx).map_err(|e| {
cx.open_url(&file_url(path)); window.push_notification(Notification::error(e.to_string()).autohide(false), cx);
return; });
} return;
Some(DecryptedFile::Loading) => return, }
_ => {}
}; if let Some(DecryptedFile::Loading) = self.decrypted_files.get(&id) {
return;
}
self.decrypted_files.insert(id, DecryptedFile::Loading); self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn_in(window, async move |this, cx| { 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 { match result {
Ok(path) => { Ok(downloaded) => {
cx.open_url(&file_url(&path)); if let Err(e) = downloaded.open(cx) {
this.decrypted_files.insert(id, DecryptedFile::Ready(path)); window.push_notification(Notification::error(e.to_string()), cx);
}
this.decrypted_files
.insert(id, DecryptedFile::Ready(downloaded.local));
} }
Err(e) => { Err(e) => {
this.decrypted_files this.decrypted_files
@@ -1475,16 +1463,16 @@ impl ChatPanel {
) -> AnyElement { ) -> AnyElement {
let state = self.decrypted_files.get(id); let state = self.decrypted_files.get(id);
if let Some(path) = state if let Some(local) = state
.and_then(|state| match state { .and_then(|state| match state {
DecryptedFile::Ready(path) => Some(path), DecryptedFile::Ready(local) => Some(local),
_ => None, _ => None,
}) })
.filter(|_| file.is_image()) .filter(|_| file.is_image())
{ {
return div() return div()
.child( .child(
img(path.clone()) img(local.image_source())
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.h(px(250.)) .h(px(250.))
@@ -1560,7 +1548,7 @@ impl ChatPanel {
.map(|this| { .map(|this| {
if file.is_image() { if file.is_image() {
this.child( this.child(
img(pending.path.clone()) img(pending.local.image_source())
.size_16() .size_16()
.when(cx.theme().shadow, |this| this.shadow_sm()) .when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
+4
View File
@@ -16,3 +16,7 @@ futures.workspace = true
dirs = "5.0" dirs = "5.0"
regex = "1.10" regex = "1.10"
anyhow.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Window", "Storage"] }
+10 -7
View File
@@ -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 debounced_delay;
mod display; mod display;
mod event; mod event;
mod media_extractor; mod media_extractor;
mod parser; mod parser;
mod paths; 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::*;
+32
View File
@@ -0,0 +1,32 @@
use anyhow::{Error, anyhow};
const LOCAL_STORAGE: &str = "localStorage";
fn storage() -> Result<web_sys::Storage, Error> {
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<Option<String>, 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:?}"))
}
+6 -12
View File
@@ -4,9 +4,8 @@ use anyhow::Result;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render,
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
list, px,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
@@ -23,7 +22,7 @@ use person::Directory;
use serde::Deserialize; use serde::Deserialize;
use settings::AppSettings; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload}; use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text}; use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report}; 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<Self>) { fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let server = AppSettings::get_file_server(cx); let server = AppSettings::get_file_server(cx);
let path = cx.prompt_for_paths(PathPromptOptions { let picker = pick_file(cx);
files: true,
directories: false,
multiple: false,
prompt: None,
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled // 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(()); return Ok(());
}; };
this.update(cx, |this, cx| this.set_uploading(true, cx))?; 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) => { Ok(url) => {
this.update_in(cx, |this, _window, cx| { this.update_in(cx, |this, _window, cx| {
this.attachments.push(url); this.attachments.push(url);
+26 -14
View File
@@ -18,7 +18,7 @@ use nostr_sdk::prelude::*;
use person::Directory; use person::Directory;
use settings::AppSettings; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner}; use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
use ui::Avatar; use ui::Avatar;
const IDENTIFIER: &str = "coop:device"; const IDENTIFIER: &str = "coop:device";
@@ -221,25 +221,38 @@ impl DeviceRegistry {
cx.emit(DeviceEvent::Set); cx.emit(DeviceEvent::Set);
} }
/// Backup the encryption's secret key to a file /// Backup the encryption's secret key, returning the `nsec1...` content
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> { pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
cx.background_spawn(async move { cx.background_spawn(async move {
let keys = get_keys(&client, &signer).await?; 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") { /// Backup the encryption's secret key to a file
return Err(anyhow!("Not supported")); pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
} 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?; smol::fs::write(path, &content).await?;
Ok(()) 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 /// Get device announcement for current user
@@ -827,10 +840,10 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
/// Get or create new app keys (async, returns a task) /// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> { fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = cx.read_credentials(CLIENT_NAME); let read = credentials::read_app_keys(cx);
cx.spawn(async move |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) && let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
{ {
return Ok(keys); return Ok(keys);
@@ -838,14 +851,13 @@ fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
// No stored keys found or invalid — generate new ones // No stored keys found or invalid — generate new ones
let keys = Keys::generate(); let keys = Keys::generate();
let user = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_bytes(); let secret = keys.secret_key().to_secret_bytes();
cx.update(|cx| { 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 { cx.background_spawn(async move {
if let Err(e) = write.await { if let Err(e) = write.await {
log::error!("Keyring not available or panic: {e}") log::error!("Failed to persist app keys: {e}")
} }
}) })
.detach(); .detach();
+1
View File
@@ -8,6 +8,7 @@ publish.workspace = true
common = { path = "../common" } common = { path = "../common" }
state = { path = "../state" } state = { path = "../state" }
futures.workspace = true
gpui.workspace = true gpui.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
instant.workspace = true instant.workspace = true
+35 -16
View File
@@ -3,7 +3,10 @@ use std::sync::RwLock;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::emojis_from_tags; 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 instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
@@ -98,8 +101,9 @@ impl PersonRegistry {
})); }));
let client3 = client.clone(); let client3 = client.clone();
let executor = cx.background_executor().clone();
tasks.push(cx.background_spawn(async move { 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| { tasks.push(cx.spawn(async move |this, cx| {
@@ -193,30 +197,45 @@ impl PersonRegistry {
} }
/// Handle request for metadata /// Handle request for metadata
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) { async fn handle_requests(
client: &Client,
rx: &flume::Receiver<PublicKey>,
executor: BackgroundExecutor,
) {
let mut batch: HashSet<PublicKey> = HashSet::new(); let mut batch: HashSet<PublicKey> = HashSet::new();
loop { loop {
match flume::Selector::new() let timer = executor.timer(Duration::from_secs(TIMEOUT)).fuse();
.recv(rx, |result| result.ok()) pin_mut!(timer);
.wait_timeout(Duration::from_secs(TIMEOUT))
{ let request = futures::select! {
Ok(Some(public_key)) => { result = rx.recv_async().fuse() => match result {
batch.insert(public_key); Ok(public_key) => public_key,
// Process the batch if it's full Err(_) => {
if batch.len() >= 20 if !batch.is_empty()
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{ {
log::warn!("Failed to get metadata batch: {e}"); log::warn!("Failed to get metadata batch: {e}");
}
return;
} }
} },
_ => { () = timer => {
if !batch.is_empty() if !batch.is_empty()
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{ {
log::warn!("Failed to get metadata batch: {e}"); log::warn!("Failed to get metadata batch: {e}");
} }
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}");
} }
} }
} }
+32 -18
View File
@@ -1,12 +1,17 @@
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use common::config_dir; use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px}; use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec}; 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. /// The user's appearance preference, persisted with the rest of the settings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AppearanceMode { pub enum AppearanceMode {
@@ -63,21 +68,21 @@ pub fn init(cx: &mut App) {
} }
/// Read the persisted appearance mode synchronously. /// 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<Settings> { fn persisted_settings() -> Option<Settings> {
#[cfg(not(target_arch = "wasm32"))] let content = persisted_content();
{ content.and_then(|content| serde_json::from_str(&content).ok())
let path = config_dir().join(".settings"); }
if let Ok(content) = std::fs::read_to_string(path)
&& let Ok(settings) = serde_json::from_str::<Settings>(&content) /// The persisted settings JSON, from the config file or browser storage
{ #[cfg(not(target_arch = "wasm32"))]
return Some(settings); fn persisted_content() -> Option<String> {
} let path = config_dir().join(".settings");
} std::fs::read_to_string(path).ok()
None }
#[cfg(target_arch = "wasm32")]
fn persisted_content() -> Option<String> {
common::web_store::get(SETTINGS_KEY).ok().flatten()
} }
pub fn persisted_appearance_mode() -> AppearanceMode { pub fn persisted_appearance_mode() -> AppearanceMode {
@@ -87,10 +92,6 @@ pub fn persisted_appearance_mode() -> AppearanceMode {
} }
/// Read the persisted theme tuning synchronously. /// 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 { pub fn persisted_theme() -> ThemeSettings {
persisted_settings() persisted_settings()
.map(|settings| settings.theme) .map(|settings| settings.theme)
@@ -357,6 +358,14 @@ impl AppSettings {
return Ok(serde_json::from_str(&content)?); 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")) Err(anyhow!("Not found"))
}); });
@@ -389,6 +398,11 @@ impl AppSettings {
smol::fs::write(&path, content).await.ok(); smol::fs::write(&path, content).await.ok();
}) })
.detach(); .detach();
#[cfg(target_arch = "wasm32")]
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
log::warn!("failed to persist settings: {e}");
}
} }
} }
+14
View File
@@ -28,7 +28,21 @@ sha2.workspace = true
data-encoding.workspace = true data-encoding.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
nostr-browser-signer.workspace = true
nostr-memory.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] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" } browser-signer-proxy = { path = "../browser-signer-proxy" }
+21 -18
View File
@@ -1,17 +1,10 @@
use std::path::PathBuf;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use gpui::AsyncApp; use gpui::AsyncApp;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio; use gpui_tokio::Tokio;
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path;
use nostr_blossom::prelude::*; use nostr_blossom::prelude::*;
use nostr_sdk::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 /// Upload a blob to a blossom server and return its URL
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn upload_blob( pub(crate) async fn upload_blob(
@@ -41,16 +34,26 @@ pub(crate) async fn upload_blob(
.map_err(|e| anyhow!("Upload error: {e}"))? .map_err(|e| anyhow!("Upload error: {e}"))?
} }
#[cfg(not(target_arch = "wasm32"))] /// Upload a blob to a blossom server and return its URL.
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
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
}
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> { pub(crate) async fn upload_blob(
Err(anyhow!("File upload not supported on web")) server: &Url,
data: Vec<u8>,
content_type: &str,
sha256: &str,
_cx: &AsyncApp,
) -> Result<Url, Error> {
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())),
}
} }
+105
View File
@@ -0,0 +1,105 @@
use anyhow::Result;
#[cfg(target_arch = "wasm32")]
use common::web_store;
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<Result<Option<(String, Vec<u8>)>>> {
#[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<Result<()>> {
#[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<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
return cx.delete_credentials(USER_KEYRING);
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
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<Result<Option<Vec<u8>>>> {
#[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<Result<()>> {
#[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<Result<Option<Vec<u8>>>> {
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<Result<()>> {
write_named(cx, APP_KEYS_KEY, secret)
}
+204 -62
View File
@@ -7,8 +7,10 @@ use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
use anyhow::{Error, anyhow, bail}; use anyhow::{Error, anyhow, bail};
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use futures::AsyncReadExt; use futures::AsyncReadExt;
#[cfg(not(target_arch = "wasm32"))]
use gpui::PathPromptOptions;
use gpui::http_client::AsyncBody; use gpui::http_client::AsyncBody;
use gpui::{AsyncApp, SharedString}; use gpui::{AppContext as _, AsyncApp, SharedString, SharedUri, Task};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path; use mime_guess::from_path;
use nostr::nips::nip94::Sha256Hash; 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<u8>,
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<EncryptedFile, Error> { pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
let key = Aes256Gcm::generate_key(OsRng); let key = Aes256Gcm::generate_key(OsRng);
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng); let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
@@ -165,21 +217,63 @@ pub(crate) fn sha256_hex(data: &[u8]) -> String {
Sha256Hash::from_byte_array(hash).to_hex() Sha256Hash::from_byte_array(hash).to_hex()
} }
#[cfg(not(target_arch = "wasm32"))] /// Ask the user for a single file and read it into memory
pub async fn upload_encrypted( pub fn pick_file(cx: &gpui::App) -> Task<Result<Option<PickedFile>, 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<Url, Error> {
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, server: Url,
path: PathBuf, file: PickedFile,
cx: &AsyncApp, cx: &AsyncApp,
) -> Result<FileAttachment, Error> { ) -> Result<FileAttachment, Error> {
let mime = from_path(&path).first_or_octet_stream().to_string(); let encrypted = encrypt(&file.data)?;
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 sha256 = sha256_hex(&encrypted.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 size = encrypted.data.len() as u64;
let base_url = server.to_string(); let base_url = server.to_string();
@@ -207,26 +301,17 @@ pub async fn upload_encrypted(
Ok(FileAttachment { Ok(FileAttachment {
url, url,
mime, mime: file.mime,
key: encrypted.key, key: encrypted.key,
nonce: encrypted.nonce, nonce: encrypted.nonce,
sha256: Some(sha256), sha256: Some(sha256),
original_sha256: Some(original_sha256), original_sha256: Some(original_sha256),
size: Some(size), size: Some(size),
dim: None, dim: None,
name, name: Some(file.name),
}) })
} }
#[cfg(target_arch = "wasm32")]
pub async fn upload_encrypted(
_server: Url,
_path: PathBuf,
_cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
Err(anyhow!("File upload not supported on web"))
}
pub(crate) async fn download_and_decrypt( pub(crate) async fn download_and_decrypt(
url: &Url, url: &Url,
key: &str, key: &str,
@@ -261,33 +346,49 @@ pub(crate) async fn download_and_decrypt(
decrypt(&data, key, nonce) decrypt(&data, key, nonce)
} }
/// Download and decrypt a file attachment into a temporary file. /// A downloaded and decrypted attachment on the user's device
/// #[derive(Debug, Clone, PartialEq, Eq)]
/// The same attachment always maps to the same path, so callers can render the pub struct DownloadedFile {
/// result directly (e.g. with `img`) without downloading it more than once. pub local: LocalFile,
#[cfg(not(target_arch = "wasm32"))] pub mime: String,
pub async fn download_and_decrypt_to_file( pub name: String,
file: &FileAttachment, /// The decrypted bytes, kept so the web can offer them as a download
cx: &AsyncApp, #[cfg(target_arch = "wasm32")]
) -> Result<PathBuf, Error> { pub(crate) data: Vec<u8>,
let name = file }
.sha256
.clone()
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
let extension = mime_guess::get_mime_extensions_str(&file.mime) impl DownloadedFile {
.and_then(|extensions| extensions.first()) pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
.copied() match &self.local {
.unwrap_or("bin"); LocalFile::Path(path) => {
cx.open_with_system(path);
let path = std::env::temp_dir() Ok(())
.join("coop-files") }
.join(format!("{name}.{extension}")); #[cfg(target_arch = "wasm32")]
LocalFile::Url(url) => {
if smol::fs::metadata(&path).await.is_ok() { if self.mime.starts_with("image/") {
return Ok(path); 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<DownloadedFile, Error> {
let data = download_and_decrypt( let data = download_and_decrypt(
&file.url, &file.url,
&file.key, &file.key,
@@ -297,25 +398,66 @@ pub async fn download_and_decrypt_to_file(
) )
.await?; .await?;
let Some(parent) = path.parent() else { let name = file
bail!("Invalid file path"); .name
}; .clone()
smol::fs::create_dir_all(parent).await?; .unwrap_or_else(|| fallback_name(&file.url));
// Write under a temporary name first, so an interrupted download is never reused let local = store_file(&name, &file.mime, &data).await?;
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
Ok(path) Ok(DownloadedFile {
local,
mime: file.mime.clone(),
name,
#[cfg(target_arch = "wasm32")]
data,
})
} }
#[cfg(target_arch = "wasm32")] /// Persist decrypted bytes somewhere the platform can render or open them
pub async fn download_and_decrypt_to_file( async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
_file: &FileAttachment, #[cfg(not(target_arch = "wasm32"))]
_cx: &AsyncApp, {
) -> Result<PathBuf, Error> { let digest = sha256_hex(name.as_bytes());
Err(anyhow!("File download not supported on web")) 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> { fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
+60 -14
View File
@@ -4,10 +4,14 @@ use std::sync::Arc;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*; use browser_signer_proxy::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use common::config_dir; use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
#[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio; use gpui_tokio::Tokio;
use instant::Duration; use instant::Duration;
#[cfg(target_arch = "wasm32")]
use nostr_browser_signer::BrowserSigner;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*; use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -18,12 +22,15 @@ use nostr_sdk::prelude::*;
mod blossom; mod blossom;
mod constants; mod constants;
pub mod credentials;
mod file; mod file;
mod nip05; mod nip05;
mod nip4e; mod nip4e;
mod signer; mod signer;
pub use blossom::*; #[cfg(target_arch = "wasm32")]
pub mod web_file;
pub use constants::*; pub use constants::*;
pub use file::*; pub use file::*;
pub use nip4e::*; pub use nip4e::*;
@@ -137,9 +144,7 @@ impl NostrRegistry {
let _ = entity.update(cx, |this, cx| { let _ = entity.update(cx, |this, cx| {
this.connect_bootstrap_relays(cx); this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") { if let Some(secret) = cli_key {
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as restore_session // Use CLI-provided key -- same path as restore_session
let keys = Keys::new(secret); let keys = Keys::new(secret);
this.set_signer(keys, cx); this.set_signer(keys, cx);
@@ -241,7 +246,7 @@ impl NostrRegistry {
/// Restore the signer from the stored credential /// Restore the signer from the stored credential
fn restore_session(&mut self, cx: &mut Context<Self>) { fn restore_session(&mut self, cx: &mut Context<Self>) {
let user_keyring = cx.read_credentials(USER_KEYRING); let user_keyring = credentials::read(cx);
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result = async { let result = async {
@@ -278,6 +283,11 @@ impl NostrRegistry {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.connect_proxy(cx); this.connect_proxy(cx);
})?; })?;
} else if content == "extension" {
#[cfg(target_arch = "wasm32")]
this.update(cx, |this, cx| {
this.connect_extension(cx);
})?;
} else { } else {
this.update(cx, |_, cx| { this.update(cx, |_, cx| {
cx.emit(StateEvent::NoSigner); cx.emit(StateEvent::NoSigner);
@@ -313,7 +323,7 @@ impl NostrRegistry {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> { ) -> Task<Result<PublicKey, Error>> {
let password = password.to_owned(); 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| { cx.spawn(async move |this, cx| {
let content = user_keyring let content = user_keyring
@@ -372,9 +382,8 @@ impl NostrRegistry {
let (keys, ncryptsec) = job.await?; let (keys, ncryptsec) = job.await?;
let public_key = keys.public_key(); let public_key = keys.public_key();
let write = cx.update(|cx| { let write =
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) cx.update(|cx| credentials::write(cx, &public_key.to_hex(), ncryptsec.as_bytes()));
});
write.await?; write.await?;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -411,9 +420,8 @@ impl NostrRegistry {
let keys = decrypt_task.await?; let keys = decrypt_task.await?;
let public_key = keys.public_key(); let public_key = keys.public_key();
let write = cx.update(|cx| { let write =
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes()) cx.update(|cx| credentials::write(cx, &public_key.to_hex(), stored.as_bytes()));
});
write.await?; write.await?;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -439,7 +447,7 @@ impl NostrRegistry {
let keys = Keys::generate(); let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys); 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| { cx.spawn(async move |this, cx| {
let signer = NostrConnect::builder(connect_uri, keys) let signer = NostrConnect::builder(connect_uri, keys)
@@ -463,12 +471,50 @@ impl NostrRegistry {
self.current_user = None; self.current_user = None;
self.passphrase_required = false; 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::SignerChanged);
cx.emit(StateEvent::NoSigner); cx.emit(StateEvent::NoSigner);
cx.notify(); cx.notify();
} }
/// Connect the browser extension signer (NIP-07).
#[cfg(target_arch = "wasm32")]
pub fn connect_extension(&mut self, cx: &mut Context<Self>) {
let signer = match BrowserSigner::new() {
Ok(signer) => signer,
Err(e) => {
cx.emit(StateEvent::error(format!("No Nostr extension found: {e}.")));
cx.emit(StateEvent::NoSigner);
return;
}
};
self.tasks.push(cx.spawn(async move |this, cx| {
match signer.get_public_key_async().await {
Ok(public_key) => {
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) => {
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 /// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) { pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
+6
View File
@@ -188,7 +188,13 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
auth_url: Url, auth_url: Url,
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> { ) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
Box::pin(async move { Box::pin(async move {
#[cfg(not(target_arch = "wasm32"))]
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?; 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(()) Ok(())
}) })
} }
+198
View File
@@ -0,0 +1,198 @@
//! Browser file interoperability: a persistent hidden `<input type="file">`
//! 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<Option<PickedFile>, 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<Option<Picker>> = const { RefCell::new(None) };
/// The sender the active pick delivers through
static PENDING: RefCell<Option<Sender<PickResult>>> = const { RefCell::new(None) };
}
struct Picker {
input: HtmlInputElement,
/// Keep the change handler alive for the life of the page
_on_change: Closure<dyn FnMut()>,
}
/// 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<Picker, Error> {
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::<HtmlInputElement>()
.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<dyn FnMut()>);
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<Vec<u8>, 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<String, Error> {
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<dyn FnMut()>);
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>) -> String {
let value: String = value.into();
if value.is_empty() {
"application/octet-stream".to_string()
} else {
value
}
}
+55 -20
View File
@@ -9,7 +9,7 @@ use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
use instant::Duration; use instant::Duration;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use state::NostrRegistry; use state::{NostrRegistry, StateEvent};
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx)); 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>) -> Self { fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://")); 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 pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let repass_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 error = cx.new(|_| None);
let subscriptions = vec![ let mut subscriptions = vec![
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| { cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
if let InputEvent::PressEnter { .. } = event { if let InputEvent::PressEnter { .. } = event {
this.login(cx); 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 { Self {
mode, mode,
key_input, key_input,
@@ -188,6 +197,14 @@ impl ImportIdentity {
}); });
} }
#[cfg(target_arch = "wasm32")]
fn extension(&mut self, cx: &mut Context<Self>) {
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>) { fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status; self.loading = status;
cx.notify(); cx.notify();
@@ -226,7 +243,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 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."; 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 is_unlock = self.mode == Mode::Unlock;
let value = self.key_input.read(cx).value().trim().to_string(); let value = self.key_input.read(cx).value().trim().to_string();
@@ -305,26 +321,45 @@ impl Render for ImportIdentity {
) )
}), }),
) )
.child( .child({
Button::new("login") let content = v_flex().child(
.label(if is_unlock { "Unlock" } else { "Continue" }) Button::new("login")
.primary() .label(if is_unlock { "Unlock" } else { "Continue" })
.loading(self.loading) .primary()
.disabled(self.loading) .loading(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()
.disabled(self.loading) .disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| { .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);
})),
)
});
#[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| { .when_some(self.error.read(cx).as_ref(), |this, error| {
this.child( this.child(
+6 -11
View File
@@ -2,8 +2,8 @@ use anyhow::Error;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement, App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _, ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
StyleRefinement, Styled, Subscription, Task, WeakEntity, Window, div, px, rems, Subscription, Task, WeakEntity, Window, div, px, rems,
}; };
use gpui_base::{Popup, Select}; use gpui_base::{Popup, Select};
use gpui_kit::assets::IconName; 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 nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
use state::upload; use state::{pick_file, upload_file};
use ui::{Avatar, settings_row}; use ui::{Avatar, settings_row};
const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"]; 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<Self>) { fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let server = AppSettings::get_file_server(cx); let server = AppSettings::get_file_server(cx);
let path = cx.prompt_for_paths(PathPromptOptions { let picker = pick_file(cx);
files: true,
directories: false,
multiple: false,
prompt: None,
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled // 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(()); return Ok(());
}; };
@@ -314,7 +309,7 @@ impl NewGroup {
this.set_uploading(true, cx); this.set_uploading(true, cx);
})?; })?;
match upload(server, path, cx).await { match upload_file(server, picked, cx).await {
Ok(url) => { Ok(url) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.picture = Some(url.to_string().into()); this.picture = Some(url.to_string().into());
+7 -12
View File
@@ -4,8 +4,8 @@ use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind}; use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, Render,
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all, SharedString, Styled, Task, Window, div, retain_all,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
@@ -18,7 +18,7 @@ use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings; use settings::AppSettings;
use state::{NostrRegistry, upload}; use state::{NostrRegistry, pick_file, upload_file};
use ui::Avatar; use ui::Avatar;
use crate::Workspace; use crate::Workspace;
@@ -179,17 +179,12 @@ impl ProfileDialog {
// Get the user's configured blossom server // Get the user's configured blossom server
let server = AppSettings::get_file_server(cx); let server = AppSettings::get_file_server(cx);
// Ask user for file upload // Ask the user for an image to upload
let path = cx.prompt_for_paths(PathPromptOptions { let picker = pick_file(cx);
files: true,
directories: false,
multiple: false,
prompt: None,
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled // 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(()); return Ok(());
}; };
@@ -198,7 +193,7 @@ impl ProfileDialog {
})?; })?;
// Upload via blossom client // Upload via blossom client
match upload(server, path, cx).await { match upload_file(server, picked, cx).await {
Ok(url) => { Ok(url) => {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.avatar_input.update(cx, |this, cx| { this.avatar_input.update(cx, |this, cx| {
+39 -25
View File
@@ -1,7 +1,9 @@
#[cfg(not(target_arch = "wasm32"))]
use gpui::PathPromptOptions;
use gpui::http_client::Url; use gpui::http_client::Url;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription,
Styled, Subscription, Window, div, Window, div,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants}; 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 /// Ask the user for an image to show behind chat and group panels
fn choose_background(cx: &mut App) { fn choose_background(
let task = cx.prompt_for_paths(PathPromptOptions { #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window,
files: true, cx: &mut App,
directories: false, ) {
multiple: false, #[cfg(target_arch = "wasm32")]
prompt: None, {
}); let note = Notification::warning("Background images are not supported on web yet.");
window.push_notification(note, cx);
}
cx.spawn(async move |cx| { #[cfg(not(target_arch = "wasm32"))]
let Ok(Ok(Some(mut paths))) = task.await else { {
return; let task = cx.prompt_for_paths(PathPromptOptions {
}; files: true,
directories: false,
let Some(path) = paths.pop() else { multiple: false,
return; prompt: None,
};
cx.update(|cx| {
AppSettings::update_panel_background_path(Some(path), cx);
cx.refresh_windows();
}); });
})
.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() .ghost()
.small() .small()
.disabled(!panel_background) .disabled(!panel_background)
.on_click(|_, _window, cx| { .on_click(|_, window, cx| {
Self::choose_background(cx); Self::choose_background(window, cx);
}), }),
) )
.child( .child(
+1
View File
@@ -54,6 +54,7 @@ impl Render for DragPanelPreview {
.cursor_grab() .cursor_grab()
.p_1() .p_1()
.whitespace_nowrap() .whitespace_nowrap()
.font_family(cx.theme().font_family.clone())
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(cx.theme().radius_lg) .rounded(cx.theme().radius_lg)
+53 -26
View File
@@ -2,8 +2,10 @@ use std::sync::Arc;
use ::settings::{AppSettings, AppearanceMode}; use ::settings::{AppSettings, AppearanceMode};
use anyhow::Error; use anyhow::Error;
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry}; use chat::{ChatEvent, ChatRegistry};
#[cfg(not(target_arch = "wasm32"))]
use common::download_dir; use common::download_dir;
use gpui::{ use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
@@ -337,34 +339,57 @@ impl Workspace {
} }
Command::BackupEncryption => { Command::BackupEncryption => {
let device = DeviceRegistry::global(cx).downgrade(); 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| { self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
// Get the output path from the save dialog let backup = device.read_with(cx, |this, cx| this.backup_bytes(cx))?;
let output_path = match save_dialog.await { let content = backup.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 cx.update(|_window, _cx| {
let backup = state::web_file::download(
device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?; "encryption.txt",
"text/plain",
// Run the backup task content.as_bytes(),
backup.await?; )
})
// Open the backup file with the system's default application .map_err(|e| anyhow::anyhow!(e))??;
cx.update(|_window, cx| {
cx.open_with_system(output_path.as_path());
})?;
Ok(()) Ok(())
})); }));
@@ -372,7 +397,9 @@ impl Workspace {
Command::ImportEncryption => { Command::ImportEncryption => {
self.import_encryption(window, cx); self.import_encryption(window, cx);
} }
Command::Update => { Command::Update =>
{
#[cfg(not(target_arch = "wasm32"))]
if let Some(auto_updater) = AutoUpdater::try_global(cx) { if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| this.check(cx)); auto_updater.update(cx, |this, cx| this.check(cx));
} }
+20 -13
View File
@@ -1,15 +1,17 @@
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity, IntoElement};
use gpui::{ use gpui::{
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
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::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::{ use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex,
};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::NostrRegistry; use state::NostrRegistry;
use ui::{Avatar, IconName}; use ui::{Avatar, IconName};
@@ -25,6 +27,7 @@ impl Sidebar {
current_user: &PublicKey, current_user: &PublicKey,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Stateful<Div> { ) -> Stateful<Div> {
#[cfg(not(target_arch = "wasm32"))]
let updater = AutoUpdater::try_global(cx); let updater = AutoUpdater::try_global(cx);
let profile = self.directory.read(cx).person(current_user, cx); let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar(); let avatar = profile.avatar();
@@ -62,12 +65,15 @@ impl Sidebar {
) )
}), }),
) )
.child( .child({
h_flex() let actions = h_flex().gap_1();
.gap_1()
.when_some(updater, |this, updater| { #[cfg(not(target_arch = "wasm32"))]
this.child(self.render_updater(updater, cx)) let actions = actions.when_some(updater, |this, updater| {
}) this.child(self.render_updater(updater, cx))
});
actions
.child( .child(
Button::new("sidebar-search") Button::new("sidebar-search")
.icon(IconName::Search) .icon(IconName::Search)
@@ -104,8 +110,8 @@ impl Sidebar {
this.hide_group(cx); this.hide_group(cx);
})), })),
) )
}), })
) })
} }
/// The bottom navigation, pinned below the list like signed's sidebar. /// The bottom navigation, pinned below the list like signed's sidebar.
@@ -128,6 +134,7 @@ impl Sidebar {
) )
} }
#[cfg(not(target_arch = "wasm32"))]
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement { fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
let status = updater.read(cx).status(); let status = updater.read(cx).status();
let up_to_date = updater.read(cx).up_to_date(); let up_to_date = updater.read(cx).up_to_date();
+2 -7
View File
@@ -1,8 +1,7 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img}; use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img};
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::v_flex; use gpui_kit::component::v_flex;
use instant::Instant;
use crate::dialogs::import; 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. /// Pick one of the signed-out backgrounds at random.
pub(super) fn pick_banner() -> SharedString { pub(super) fn pick_banner() -> SharedString {
let nanos = SystemTime::now() let nanos = Instant::now().elapsed().subsec_nanos();
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or_default();
let index = nanos as usize % SIGNED_OUT_BANNERS.len(); let index = nanos as usize % SIGNED_OUT_BANNERS.len();
SIGNED_OUT_BANNERS[index].into() SIGNED_OUT_BANNERS[index].into()
} }
+2
View File
@@ -8,6 +8,7 @@ publish.workspace = true
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" } workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" } ui = { path = "../crates/ui" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
@@ -29,6 +30,7 @@ console_error_panic_hook = "0.1"
tracing-wasm = "0.2" tracing-wasm = "0.2"
console_log = "1.0" console_log = "1.0"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
js-sys = "0.3"
universal-time = { git = "https://github.com/shadowylab/universal-time" } universal-time = { git = "https://github.com/shadowylab/universal-time" }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
+8 -10
View File
@@ -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 help: ## Show help information
@echo "Coop - Available commands:" @echo "Coop - Available commands:"
@@ -7,11 +7,9 @@ help: ## Show help information
install: ## Install all dependencies install: ## Install all dependencies
@echo "Checking Rust WASM target..." @echo "Checking Rust WASM target..."
@rustup target add wasm32-unknown-unknown || true @rustup target add wasm32-unknown-unknown --toolchain nightly || true
@echo "Checking wasm-bindgen-cli..."
@cargo install wasm-bindgen-cli || true
@echo "Installing frontend dependencies..." @echo "Installing frontend dependencies..."
@cd www && deno install @cd www && npm install
build-wasm: ## Build WASM (release mode) build-wasm: ## Build WASM (release mode)
@./script/build-wasm.sh --release @./script/build-wasm.sh --release
@@ -20,20 +18,20 @@ build-wasm-dev: ## Build WASM (debug mode)
@./script/build-wasm.sh @./script/build-wasm.sh
build-web: ## Build frontend build-web: ## Build frontend
@cd www && deno task build @cd www && npm run build
build-web-prod: ## Build frontend for production (GitHub Pages) 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: build-wasm build-web ## Build complete project (WASM + frontend)
build-prod: build-wasm build-web-prod ## Build complete project for production build-prod: build-wasm build-web-prod ## Build complete project for production
dev: build-wasm-dev ## Start development server 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 preview: ## Preview production build
@cd www && deno task preview @cd www && npm run preview
clean: ## Clean build artifacts clean: ## Clean build artifacts
@echo "Cleaning build artifacts..." @echo "Cleaning build artifacts..."
@@ -43,4 +41,4 @@ clean: ## Clean build artifacts
watch-wasm: ## Watch Rust code changes and auto-rebuild WASM watch-wasm: ## Watch Rust code changes and auto-rebuild WASM
@echo "Watching WASM changes..." @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'
+39 -18
View File
@@ -11,7 +11,8 @@ echo -e "${GREEN}Building Coop Web...${NC}"
# Get the script directory # Get the script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$SCRIPT_DIR/.." PROJECT_ROOT="$SCRIPT_DIR/../.."
WEB_ROOT="$SCRIPT_DIR/.."
# Parse arguments # Parse arguments
RELEASE_FLAG="" RELEASE_FLAG=""
@@ -20,27 +21,47 @@ if [[ "$1" == "--release" ]]; then
echo -e "${YELLOW}Building in release mode${NC}" echo -e "${YELLOW}Building in release mode${NC}"
fi 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 if [[ "$(uname)" == "Darwin" ]]; then
echo -e "${GREEN}Detected macOS, setting up LLVM...${NC}" # secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and
if brew list llvm &>/dev/null; then # llvm-ar for archives comes from the rustup llvm-tools component
echo -e "${GREEN}LLVM already installed, skipping install${NC}" if ! xcrun --find clang &>/dev/null; then
else echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}"
echo -e "${YELLOW}LLVM not found, installing via Homebrew...${NC}" exit 1
brew install llvm
fi fi
LLVM_PATH=$(brew --prefix llvm) rustup component add llvm-tools-preview --toolchain nightly
export AR="${LLVM_PATH}/bin/llvm-ar"
export CC="${LLVM_PATH}/bin/clang" RUSTUP_HOME=$(rustup show home)
echo -e "${GREEN}LLVM path: ${LLVM_PATH}${NC}" 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 fi
# Step 1: Build WASM # Step 1: Build WASM
echo -e "${GREEN}Step 1: Building WASM...${NC}" echo -e "${GREEN}Step 1: Building WASM...${NC}"
cd "$PROJECT_ROOT"
export CARGO_TARGET_DIR="$PROJECT_ROOT/target" export CARGO_TARGET_DIR="$PROJECT_ROOT/target"
RUSTFLAGS='-C target-feature=+bulk-memory -C link-arg=--max-memory=4294967296' \ 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 # Determine the build directory
if [[ "$RELEASE_FLAG" == "--release" ]]; then if [[ "$RELEASE_FLAG" == "--release" ]]; then
@@ -61,12 +82,12 @@ fi
# Step 2: Generate JavaScript bindings # Step 2: Generate JavaScript bindings
echo -e "${GREEN}Step 2: Generating JavaScript bindings...${NC}" echo -e "${GREEN}Step 2: Generating JavaScript bindings...${NC}"
wasm-bindgen "$WASM_PATH" \ wasm-bindgen "$WASM_PATH" \
--out-dir "$PROJECT_ROOT/www/wasm" \ --out-dir "$WEB_ROOT/www/wasm" \
--target web \ --target web \
--no-typescript --no-typescript
echo -e "${GREEN}✓ Build completed successfully!${NC}" echo -e "${GREEN}✓ Build completed successfully!${NC}"
echo -e "${YELLOW}Next steps:${NC}" echo -e "${YELLOW}Next steps:${NC}"
echo -e " cd www" echo -e " cd web/www"
echo -e " deno install" echo -e " npm install"
echo -e " deno run dev" echo -e " npm run dev"
+105 -40
View File
@@ -1,22 +1,36 @@
#![cfg(target_arch = "wasm32")]
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use gpui::*; use gpui::*;
use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider}; use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider};
use wasm_bindgen::prelude::*; 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<Option<ApplicationHandle>> = 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 { 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 { 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] #[wasm_bindgen]
pub fn run() -> Result<(), JsValue> { pub fn run() -> Result<(), JsValue> {
@@ -28,54 +42,105 @@ pub fn run() -> Result<(), JsValue> {
// Also initialize tracing for WASM // Also initialize tracing for WASM
tracing_wasm::set_as_global_default(); tracing_wasm::set_as_global_default();
#[cfg(target_family = "wasm")] // Sets the panic hook and GPUI's console logger
gpui_platform::web_init(); gpui_platform::web_init();
#[cfg(not(target_family = "wasm"))] let platform = std::rc::Rc::new(gpui_web::WebPlatform::new_with_backend_and_font_fallback(
let app = gpui_platform::application(); false,
gpui_web::WebBackendPreference::Auto,
gpui_web::CanvasFontFallback::EmojiAndCjk,
));
#[cfg(target_family = "wasm")] let http_client = Arc::new(platform.fetch_http_client());
let app = {
let app = gpui_platform::single_threaded_web();
// Temporary fix: intentionally leak the `Rc<AppCell>` to keep the application alive let app = Application::with_platform(platform)
struct WasmApplication(std::rc::Rc<AppCell>); .with_http_client(http_client)
let wasm_app = unsafe { std::mem::transmute::<Application, WasmApplication>(app) }; .with_assets(assets::CombinedAssets);
std::mem::forget(wasm_app.0.clone());
unsafe { std::mem::transmute::<WasmApplication, Application>(wasm_app) }
};
app.run(|cx| { APPLICATION.with(|application| {
// Initialize the framework before anything renders *application.borrow_mut() = Some(app.run_embedded(|cx| {
gpui_kit::init(cx); load_embedded_fonts(cx);
apply_web_fonts(cx);
// Initialize settings // Initialize the framework before anything renders
settings::init(cx); gpui_kit::init(cx);
// Initialize the nostr client // Initialize settings
state::init(cx, None); settings::init(cx);
// Initialize person registry apply_appearance(settings::persisted_appearance_mode(), cx);
person::init(cx); settings::AppSettings::apply_theme_style(cx);
// Initialize device signer // Initialize the nostr client
// state::init(cx, None);
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
nip4e::init(cx);
// Initialize app registry // Initialize person registry
chat::init(cx); person::init(cx);
// Initialize groups registry // Initialize device signer
nip29::init(cx); //
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
nip4e::init(cx);
gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { // Initialize app registry
workspace::init(window, cx) chat::init(cx);
})
.expect("Failed to open window. Please restart the application.");
cx.activate(true); // 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.");
cx.activate(true);
}));
}); });
Ok(()) 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::<Vec<_>>();
let fonts = font_paths
.iter()
.filter_map(|path| assets::Assets::get(path.as_ref()))
.map(|font| font.data)
.collect::<Vec<_>>();
cx.text_system()
.add_fonts(fonts)
.expect("Failed to load bundled fonts");
}
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),
}
}
-396
View File
@@ -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"
]
}
}
}
+1075
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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": { "devDependencies": {
"vite": "^8", "vite": "^8",
+2 -6
View File
@@ -10,7 +10,7 @@ export default defineConfig({
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [
{ {
src: path.resolve(__dirname, "../../../assets/icons"), src: path.resolve(__dirname, "../../assets/icons"),
dest: "assets", dest: "assets",
}, },
], ],
@@ -21,7 +21,7 @@ export default defineConfig({
server.middlewares.use( server.middlewares.use(
"/coop/assets", "/coop/assets",
(req, res, next) => { (req, res, next) => {
const assetsPath = path.resolve(__dirname, "../../../assets"); const assetsPath = path.resolve(__dirname, "../../assets");
const filePath = path.join( const filePath = path.join(
assetsPath, assetsPath,
req.url.replace("/assets", ""), req.url.replace("/assets", ""),
@@ -61,10 +61,6 @@ export default defineConfig({
strict: false, strict: false,
allow: [".."], allow: [".."],
}, },
headers: {
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
},
}, },
optimizeDeps: { optimizeDeps: {
exclude: ["./src/wasm"], exclude: ["./src/wasm"],