Compare commits
8
Commits
v2.0.0-alpha2
..
web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29aa7f05aa | ||
|
|
4b4a99c464 | ||
|
|
72794dcbdc | ||
|
|
d7ae86e955 | ||
|
|
a27ecd767e | ||
|
|
f9b40d9d49 | ||
|
|
f829a2774d | ||
|
|
8929e3137b |
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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" ] }
|
||||||
|
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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:?}"))
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>) {
|
||||||
|
|||||||
@@ -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(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
-396
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+1075
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user