Compare commits
9
Commits
v1.0.0
...
2d7881e574
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7881e574 | ||
|
|
37d3380c55 | ||
|
|
9fcfb9f0bf | ||
|
|
1877e0082c | ||
|
|
68d3d77ae3 | ||
|
|
584ab34df6 | ||
|
|
0514c1d982 | ||
|
|
2f834a0bcc | ||
|
|
49cd5cb9a0 |
Generated
+530
-649
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0-beta5"
|
||||
version = "1.0.1"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
@@ -27,6 +27,11 @@ nostr-connect = { 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" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0-beta5"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "1"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
@@ -0,0 +1,452 @@
|
||||
# NIP-17 Encrypted File Messages (kind 15) — Backend Plan
|
||||
|
||||
Implementation plan for sending and receiving NIP-17 **file messages** in coop.
|
||||
|
||||
**Scope: backend only.** This document covers crypto, blob upload/download, rumor
|
||||
construction, rumor parsing and local caching. No UI work (composer button, file
|
||||
rendering, image cache, decryption-on-render state) — that is a separate follow-up
|
||||
once these APIs exist.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the protocol requires
|
||||
|
||||
NIP-17 file messages are **not** a new transport. They reuse everything coop already
|
||||
has (NIP-44, NIP-59 seal + gift wrap, kind 10050 inbox relays) and only add:
|
||||
|
||||
1. A new inner **rumor kind: `15`** (`Kind::Custom(15)`), whose `.content` is the URL
|
||||
of an **encrypted** blob and whose tags carry the MIME type and the decryption
|
||||
material.
|
||||
2. **AES-256-GCM** encryption of the file bytes before upload.
|
||||
|
||||
Kind 15 tags (per NIP-17):
|
||||
|
||||
| Tag | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `p` | yes | receivers (as for kind 14) |
|
||||
| `e` | if reply | parent message id |
|
||||
| `subject` | optional | conversation title |
|
||||
| `file-type` | yes | MIME type of the **plaintext** file |
|
||||
| `encryption-algorithm` | yes | `aes-gcm` (only supported value) |
|
||||
| `decryption-key` | yes | key for the recipient |
|
||||
| `decryption-nonce` | yes | nonce for the recipient |
|
||||
| `x` | yes | SHA-256 hex of the **encrypted** file |
|
||||
| `ox` | expected | SHA-256 hex of the file **before** encryption |
|
||||
| `size` | optional | size of the **encrypted** file in bytes |
|
||||
| `dim` | optional | `<width>x<height>` in pixels |
|
||||
| `thumbhash` / `blurhash` | optional | placeholder previews |
|
||||
| `thumb` | optional | thumbnail URL (same key/nonce) |
|
||||
| `fallback` | optional | extra file sources (same key/nonce) |
|
||||
|
||||
Key material travels **inside** the gift wrap, so the public blob URL is useless
|
||||
without it. That property falls out of the existing seal/gift-wrap code for free.
|
||||
|
||||
`thumbhash`, `blurhash`, `thumb`, `fallback` are out of scope for v1 (all optional).
|
||||
|
||||
---
|
||||
|
||||
## 2. What already exists in coop (verified against the pinned deps)
|
||||
|
||||
| Requirement | Location | Status |
|
||||
|---|---|---|
|
||||
| NIP-59 seal + gift wrap | `crates/chat/src/room.rs::send_gift_wrap` (`nip59::GiftWrapBuilder`) | exists |
|
||||
| Per-recipient publish + self backup | `crates/chat/src/room.rs::send` | exists |
|
||||
| Inbox relays (kind 10050) | `crates/chat/src/lib.rs::handle_notifications` | exists |
|
||||
| Rumor unwrap + local cache | `crates/chat/src/lib.rs::{extract_rumor,try_unwrap_with,set_rumor,get_rumor}` | exists |
|
||||
| Kind 14 rumor construction | `crates/chat/src/room.rs::rumor` | exists |
|
||||
| Blossom upload | `crates/state/src/blossom.rs::upload` (plaintext, random signing key) | partial |
|
||||
| **Kind 15 rumor + parse** | — | **to add** |
|
||||
| **AES-256-GCM encrypt/decrypt** | — | **to add** |
|
||||
| **Encrypted blob upload/download** | — | **to add** |
|
||||
|
||||
Verified facts about the pinned SDK (`rust-nostr@b230cec`, `nostr 0.45.4`) and
|
||||
`gpui@69af529`:
|
||||
|
||||
- rust-nostr has **no** kind-15 helper and **no** AES-GCM/AES-GCM-tag support anywhere
|
||||
(`FileMetadata` in NIP-94 is kind 1063, unrelated). `nip17.rs` only covers kind 14
|
||||
and 10050.
|
||||
- `nip59::GiftWrapBuilder::new(receiver, rumor: UnsignedEvent)` accepts **any**
|
||||
`UnsignedEvent`, so kind 15 flows through the existing wrap/send path unchanged.
|
||||
- `Kind` has no named variant for 15; `Kind::Custom(15)` is required, and
|
||||
`Kind: Display` prints `as_u16()`, so `rumor.kind.to_string()` yields `"15"`.
|
||||
- `nostr::nips::nip94::Sha256Hash` is public (`from_byte_array`, `to_hex`, `Display`,
|
||||
`from_hex`) even though it lives in the nip94 module — usable for `x`/`ox` hex
|
||||
formatting without a new hashing crate.
|
||||
- `gpui::App::http_client()` returns `Arc<dyn HttpClient>` and
|
||||
`gpui_web/src/http_client.rs` implements it, so HTTP download is cross-platform.
|
||||
`AsyncApp` exposes `update(|app| ...)`, which is how backend async code reaches it.
|
||||
- `nostr-blossom` exposes `upload_blob`, `get_blob`, `has_blob`, `list_blobs`,
|
||||
`delete_blob`.
|
||||
- The `k` tag is currently hardcoded to `"14"` in two places
|
||||
(`room.rs::send_gift_wrap`, `lib.rs::set_rumor`) and used as a room-list filter
|
||||
(`lib.rs::get_rooms_task`, `custom_tag(LOWERCASE_K, "14")`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependencies
|
||||
|
||||
Add to `[workspace.dependencies]` in `Cargo.toml`, then reference from the crates below.
|
||||
|
||||
```toml
|
||||
aes-gcm = "0.10" # NEW - RustCrypto: Aes256Gcm, aead::{Aead, KeyInit, OsRng}
|
||||
sha2 = "0.10" # NEW (already in Cargo.lock, cached) - SHA-256 for x / ox
|
||||
base64 = "0.22" # NEW as a direct dep (already in the tree transitively)
|
||||
```
|
||||
|
||||
- No new RNG dependency: `aes_gcm::aead::OsRng` (the wasm getrandom backends are
|
||||
already configured in `web/Cargo.toml`).
|
||||
- No new hashing/hex dependency: `sha2` output → `nostr::nips::nip94::Sha256Hash::from_byte_array(...).to_hex()`.
|
||||
- `aes-gcm` is the only crate that needs a crates.io fetch (`aes 0.8` / `aead 0.5` are
|
||||
already in the lock file), so it is a small addition to the build graph.
|
||||
- Hand-rolling AES-GCM is explicitly **not** an option.
|
||||
|
||||
---
|
||||
|
||||
## 4. Layering and type ownership
|
||||
|
||||
`chat` depends on `state` (see `crates/chat/Cargo.toml`), never the reverse, so the
|
||||
shared types and tag names must live in `state`.
|
||||
|
||||
- `crates/state/src/file.rs` (new) owns:
|
||||
- `EncryptedFile`, `FileAttachment`, the tag-name constants, `ALGORITHM = "aes-gcm"`
|
||||
- `encrypt` / `decrypt` / `sha256_hex`
|
||||
- `FileAttachment::from_tags` / `FileAttachment::tags` (single source of truth for
|
||||
tag names, so build and parse can't drift)
|
||||
- `upload_encrypted`, `download_and_decrypt`
|
||||
- re-exported from `crates/state/src/lib.rs`: `mod file; pub use file::*;`
|
||||
- `crates/chat` consumes it: `message.rs` (parse into `Message`), `room.rs`
|
||||
(build kind-15 rumor), `lib.rs` (cache tag + room list query).
|
||||
- `crates/chat` should re-export the type for the future UI layer:
|
||||
`pub use state::FileAttachment;` in `crates/chat/src/lib.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow (backend)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[caller: path + blossom server] --> B[read bytes + guess mime]
|
||||
B --> C[encrypt: random 32B key, 12B nonce, AES-256-GCM]
|
||||
C --> D[sha256 ciphertext = x, sha256 plaintext = ox]
|
||||
D --> E[Blossom upload ciphertext]
|
||||
E --> F[FileAttachment]
|
||||
F --> G[Room::file_rumor -> kind 15 rumor]
|
||||
G --> H[Room::send -> existing NIP-59 wrap per member + self backup]
|
||||
H --> I[existing extract_rumor on receiver]
|
||||
I --> J[Message.file]
|
||||
J --> K[download_and_decrypt: GET url, verify x, decrypt]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation steps
|
||||
|
||||
### Step 1 — Dependencies
|
||||
|
||||
Add the three lines from section 3 and wire them into `crates/state/Cargo.toml`
|
||||
(`aes-gcm`, `sha2`, `base64`). Run `cargo check -p state` to confirm the fetch works.
|
||||
|
||||
### Step 2 — `crates/state/src/file.rs` (new, ~180 LOC)
|
||||
|
||||
```rust
|
||||
use aes_gcm::aead::{Aead, KeyInit, OsRng};
|
||||
use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
|
||||
use nostr::nips::nip94::Sha256Hash;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const ALGORITHM: &str = "aes-gcm";
|
||||
/// Ciphertext hash tag (NIP-17).
|
||||
const TAG_SHA256: &str = "x";
|
||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||
const TAG_FILE_TYPE: &str = "file-type";
|
||||
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
||||
const TAG_KEY: &str = "decryption-key";
|
||||
const TAG_NONCE: &str = "decryption-nonce";
|
||||
const TAG_SIZE: &str = "size";
|
||||
const TAG_DIM: &str = "dim";
|
||||
const TAG_ALT: &str = "alt";
|
||||
|
||||
/// Maximum blob size accepted when downloading (bytes). See edge cases.
|
||||
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
|
||||
/// Result of encrypting a file: ciphertext to upload plus NIP-17 key material.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncryptedFile {
|
||||
pub data: Vec<u8>,
|
||||
/// base64-encoded 32-byte key
|
||||
pub key: String,
|
||||
/// base64-encoded 12-byte nonce
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
/// NIP-17 kind 15 attachment metadata (tags + `.content` URL).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FileAttachment {
|
||||
pub url: Url,
|
||||
pub mime: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub sha256: Option<String>,
|
||||
pub original_sha256: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub dim: Option<(u32, u32)>,
|
||||
/// Non-standard display name (see "Open decisions" #2).
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl FileAttachment {
|
||||
/// Build the NIP-17 tags for a kind 15 rumor.
|
||||
pub fn tags(&self) -> Vec<Tag>;
|
||||
/// Parse a kind 15 rumor's tags. Returns `None` if key material is missing
|
||||
/// or `encryption-algorithm` is not `aes-gcm`.
|
||||
pub fn from_tags(tags: &Tags) -> Option<Self>;
|
||||
pub fn is_image(&self) -> bool;
|
||||
pub fn display_name(&self) -> SharedString; // falls back to mime/size
|
||||
}
|
||||
|
||||
/// AES-256-GCM encrypt with a fresh random key and nonce.
|
||||
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile>;
|
||||
/// AES-256-GCM decrypt using the values from a kind 15 rumor.
|
||||
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>>;
|
||||
|
||||
/// Lowercase hex SHA-256, matching the NIP-94 `x`/`ox` convention.
|
||||
pub fn sha256_hex(data: &[u8]) -> String;
|
||||
|
||||
/// Read a file, encrypt it, upload the ciphertext to Blossom, return the attachment.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload_encrypted(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<FileAttachment>;
|
||||
|
||||
/// Fetch the blob, verify its SHA-256 against `expected_sha256`, then decrypt.
|
||||
pub async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
expected_sha256: Option<&str>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Vec<u8>>;
|
||||
```
|
||||
|
||||
Implementation notes, in priority order:
|
||||
|
||||
1. **Compute `x` and `ox` locally with `sha256_hex`.** Do **not** derive `x` from
|
||||
`BlobDescriptor::sha256` — that field is a `bitcoin_hashes::sha256::Hash` whose
|
||||
`Display` byte order could not be confirmed from the vendored sources, and a
|
||||
reversed digest would silently break integrity checks in other clients. Use the
|
||||
Blossom response only for `blob.url`. (`sha2` → `Sha256Hash::from_byte_array(...).to_hex()`
|
||||
gives the conventional digest order and is unambiguous.)
|
||||
2. **Key/nonce decoding must be tolerant.** NIP-17 does not specify an encoding:
|
||||
accept base64 standard (padded and unpadded), base64url, and hex on read; emit
|
||||
base64 standard on write. See "Open decisions" #1.
|
||||
3. Validate sizes after decoding: key must be exactly 32 bytes, nonce exactly 12.
|
||||
Return a descriptive error otherwise.
|
||||
4. `decrypt` must fail closed on a bad tag (GCM authentication failure) — never
|
||||
return partial plaintext.
|
||||
5. `upload_encrypted` mirrors the existing `state::blossom::upload` shape:
|
||||
`smol::fs::read` + `mime_guess::from_path`, `BlossomClient::new(server)`,
|
||||
`upload_blob(ciphertext, Some("application/octet-stream"), None, Some(&keys))`
|
||||
wrapped in `Tokio::spawn(cx, ...)`. Note the content type describes the
|
||||
**ciphertext**; the plaintext MIME goes in the `file-type` tag.
|
||||
On `wasm32` return `Err(anyhow!("File upload not supported on web"))`, matching
|
||||
the existing stub in `blossom.rs`.
|
||||
6. `download_and_decrypt` uses gpui's HTTP client
|
||||
(`cx.update(|app| app.http_client())`, then
|
||||
`client.get(url.as_str(), AsyncBody::default(), true)`), reads the body with
|
||||
`futures::AsyncReadExt`, caps the read at `MAX_FILE_SIZE + 1` and rejects anything
|
||||
larger, verifies `sha256_hex(&ciphertext)` against `expected_sha256`
|
||||
(case-insensitive) when present, then decrypts. This path works on desktop and web.
|
||||
7. `size` in `FileAttachment` is the **ciphertext** size (per the NIP wording), i.e.
|
||||
`encrypted.data.len()`. `dim` is optional; if wanted, decode dimensions with the
|
||||
`image` crate (`image::ImageReader`) — mark as a nice-to-have, not a blocker.
|
||||
8. `encrypt` should take `&[u8]` (not a path) so it stays pure and testable without I/O.
|
||||
|
||||
### Step 3 — `crates/chat/src/message.rs` (~80 LOC)
|
||||
|
||||
- Add the kind constant next to `Message`:
|
||||
|
||||
```rust
|
||||
/// NIP-17 file message (rust-nostr has no named `Kind` variant for 15).
|
||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
```
|
||||
|
||||
- Add the field:
|
||||
|
||||
```rust
|
||||
pub struct Message {
|
||||
// ... existing fields ...
|
||||
/// Encrypted file attachment (NIP-17 kind 15)
|
||||
pub file: Option<FileAttachment>,
|
||||
}
|
||||
```
|
||||
|
||||
- In all three `From` impls (`&Event`, `&UnsignedEvent`, `&NewMessage`), parse from
|
||||
tags when `kind == KIND_FILE_MESSAGE`, and when a file is present **do not** run
|
||||
`extract_and_remove_media_urls` or `extract_mentions` on `.content` — for kind 15
|
||||
`.content` is a ciphertext URL that must never be rendered as text or as media:
|
||||
|
||||
```rust
|
||||
let file = (val.kind == KIND_FILE_MESSAGE)
|
||||
.then(|| FileAttachment::from_tags(&val.tags))
|
||||
.flatten();
|
||||
|
||||
let (media, content) = if file.is_some() {
|
||||
(vec![], String::new())
|
||||
} else {
|
||||
extract_and_remove_media_urls(&val.content)
|
||||
};
|
||||
```
|
||||
|
||||
- Add a helper for non-UI consumers that display a message one-line
|
||||
(reply previews, notifications, copy):
|
||||
|
||||
```rust
|
||||
/// Single-line representation: text for kind 14, `[File] <name>` for kind 15.
|
||||
pub fn preview(&self) -> SharedString;
|
||||
```
|
||||
|
||||
- `Ord`/`Eq`/`Hash` are keyed on `id`/`created_at`, so adding the field does not
|
||||
change `Message` ordering semantics.
|
||||
|
||||
### Step 4 — `crates/chat/src/room.rs` (~70 LOC)
|
||||
|
||||
- Extract the shared tag logic from `rumor()` into a private helper so kind 14 and
|
||||
kind 15 cannot drift:
|
||||
|
||||
```rust
|
||||
/// `subject` + reply `e` tags + receiver `p` tags (excluding `sender`).
|
||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag>;
|
||||
```
|
||||
|
||||
`rumor()` then becomes `EventBuilder::new(kind, content).tags(self.conversation_tags(...))`,
|
||||
and gains no behavior change.
|
||||
|
||||
- Add:
|
||||
|
||||
```rust
|
||||
/// Build a NIP-17 kind 15 (file message) rumor.
|
||||
///
|
||||
/// WARNING: never sign and send this event to relays; it is only ever wrapped.
|
||||
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
where
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
// tags = conversation_tags(...) ++ file.tags()
|
||||
// content = file.url.to_string()
|
||||
// EventBuilder::new(KIND_FILE_MESSAGE, content).tags(tags).finalize_unsigned(sender)
|
||||
// then event.ensure_id()
|
||||
}
|
||||
```
|
||||
|
||||
- Fix the gift wrap's `k` tag so relays see the real inner kind
|
||||
(`send_gift_wrap`, currently hardcoded):
|
||||
|
||||
```rust
|
||||
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
||||
```
|
||||
|
||||
- `Room::send` needs **no change**: it already accepts any `UnsignedEvent`, and the
|
||||
self-backup branch publishes the same rumor wrapped for the sender, so the sender's
|
||||
other devices receive the file message with the same key material.
|
||||
|
||||
### Step 5 — `crates/chat/src/lib.rs` (~5 LOC)
|
||||
|
||||
- `set_rumor`: store the real kind in the local cache tag:
|
||||
|
||||
```rust
|
||||
Tag::custom("k", [rumor.kind.to_string()]),
|
||||
```
|
||||
|
||||
- `get_rooms_task` currently filters `custom_tag(SingleLetterTag::LOWERCASE_K, "14")`,
|
||||
which would hide rooms whose only messages are file messages. Simplest fix: drop
|
||||
the `k` filter and keep `.kind(Kind::ApplicationSpecificData)`; the existing
|
||||
in-loop JSON parse plus `tags.public_keys().next().is_some()` check already
|
||||
discards junk, and kind 30078 rows in the local DB exist only for this rumor cache.
|
||||
Fallback if the extra rows are a concern: run two queries (`k = "14"` and
|
||||
`k = "15"`) and merge the results.
|
||||
|
||||
- No changes to `handle_notifications`, `extract_rumor`, `try_unwrap_with`, `new_message`,
|
||||
`Room::get_messages` — all are already kind-agnostic. (`Room::get_messages` filters
|
||||
only on the `r` room tag, so file messages load with the rest of the history.)
|
||||
|
||||
### Step 6 — (out of scope) UI wiring
|
||||
|
||||
Composer entry point, decryption cache, image/file rendering live in
|
||||
`crates/chat_ui`. Not part of this plan; the APIs above are what that layer needs:
|
||||
`state::upload_encrypted`, `FileAttachment::tags/from_tags`, `Room::file_rumor`,
|
||||
`Room::send`, `state::download_and_decrypt`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open decisions
|
||||
|
||||
1. **Key/nonce encoding (interop risk — decide/verify first).** NIP-17 does not specify
|
||||
how `decryption-key` / `decryption-nonce` are encoded, and no reference
|
||||
implementation could be reached from this environment to confirm. Plan: emit
|
||||
**base64 standard**, accept base64 std/unpadded/url-safe/hex on read. Before
|
||||
release, verify against another NIP-17 client (0xchat, Amethyst) and tighten if
|
||||
needed. Everything else in the plan is encoding-agnostic.
|
||||
2. **Display name.** The NIP defines no filename tag and Blossom URLs end in a content
|
||||
hash, so a name has to come from a non-standard tag. Proposal: emit
|
||||
`["alt", <file name>]` (NIP-94 tag; NIP-17 allows "rest of tags", and `alt` is a
|
||||
benign a11y field), tolerate its absence, fall back to mime + size. Alternative:
|
||||
ship strictly spec-shaped and show mime + size only.
|
||||
3. **`thumbhash`/`blurhash`/`thumb`/`fallback`/`dim`**: all optional; recommend skipping
|
||||
in v1 (and letting the UI show a placeholder until the blob is decrypted).
|
||||
4. **Web (wasm)**: encrypted upload stays unsupported on web, matching today's
|
||||
`state::blossom::upload` stub. Download/decrypt is cross-platform via gpui's client.
|
||||
5. **Ciphertext caching**: recommend *not* persisting decrypted plaintext in the local
|
||||
database in v1; decryption happens on demand from the blob URL.
|
||||
|
||||
---
|
||||
|
||||
## 8. Edge cases and failure handling
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| `encryption-algorithm` != `aes-gcm` | do not decrypt; report "unsupported encryption" (forward compatibility) |
|
||||
| missing `decryption-key` / `decryption-nonce` | `FileAttachment::from_tags` returns `None` → surfaced as a failed message, not a panic |
|
||||
| key != 32 bytes or nonce != 12 bytes | descriptive error |
|
||||
| GCM authentication failure | error; never emit partial plaintext |
|
||||
| `sha256_hex(ciphertext) != x` | error (tampered or corrupted blob), checked **before** decrypting |
|
||||
| blob larger than `MAX_FILE_SIZE` | rejected while reading the body |
|
||||
| HTTP error / non-200 | error message including status |
|
||||
| `content` not a valid URL | error; message still cached so it renders as a failed attachment |
|
||||
| sender's own copy | works automatically via the existing self-backup gift wrap |
|
||||
| multiple receivers | one blob, one key, key re-sent inside each recipient's gift wrap |
|
||||
|
||||
---
|
||||
|
||||
## 9. Verification
|
||||
|
||||
The feature cannot be exercised end-to-end without the UI step (out of scope), so
|
||||
backend verification is:
|
||||
|
||||
1. `cargo check -p state -p chat` after each step.
|
||||
2. `cargo check -p coop_web --target wasm32-unknown-unknown` to confirm the
|
||||
`cfg(target_arch = "wasm32")` stubs and the new module compile for web.
|
||||
3. Cheap unit checks (no network, ~15 LOC in `state/src/file.rs`), despite "no tests
|
||||
required" for the feature, because these are pure functions and catch the two
|
||||
riskiest bugs — encoding and tag ordering:
|
||||
- `encrypt` → `decrypt` round trip returns the original bytes.
|
||||
- flipping one ciphertext bit makes `decrypt` fail.
|
||||
- `sha256_hex(b"abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"`
|
||||
(confirms the digest order used for `x`/`ox`).
|
||||
- `FileAttachment` tags → `from_tags` round trip preserves key/nonce/mime/url.
|
||||
4. End-to-end (requires the UI step or a temporary call site): send to self with
|
||||
`RoomConfig::backup` enabled and confirm the rumor unwraps, downloads, verifies and
|
||||
decrypts; then a second account; then a cross-client check for decision #1.
|
||||
|
||||
---
|
||||
|
||||
## 10. Estimated size
|
||||
|
||||
| File | Change | ~LOC |
|
||||
|---|---|---|
|
||||
| `Cargo.toml`, `crates/state/Cargo.toml` | deps | 6 |
|
||||
| `crates/state/src/file.rs` | new | 180 |
|
||||
| `crates/state/src/lib.rs` | module + re-export | 2 |
|
||||
| `crates/chat/src/message.rs` | parse + preview | 80 |
|
||||
| `crates/chat/src/room.rs` | `file_rumor` + tag refactor + `k` tag | 70 |
|
||||
| `crates/chat/src/lib.rs` | cache tag + room list query | 5 |
|
||||
|
||||
Order of work: 1 → 2 → 3 → 4 → 5, each step compiling on its own. Steps 2–4 are
|
||||
independent of any UI change, so the branch stays green without touching `chat_ui`.
|
||||
@@ -5,6 +5,7 @@ use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
||||
use instant::{Duration, Instant};
|
||||
|
||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
||||
|
||||
fn get_github_repo_owner() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||
@@ -14,18 +15,27 @@ fn get_github_repo_name() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||
}
|
||||
|
||||
fn is_flatpak_installation() -> bool {
|
||||
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
/// Whether updates are managed by an external distribution channel
|
||||
/// (Flatpak/Snap), in which case the in-app updater must not run.
|
||||
fn uses_managed_updates() -> bool {
|
||||
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
||||
std::env::var("FLATPAK_ID").is_ok()
|
||||
// Allow opting out of in-app updates via an explicit environment variable.
|
||||
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
|
||||
}
|
||||
|
||||
/// Initialize the auto-update system.
|
||||
///
|
||||
/// Skips initialization when running as a Flatpak (updates are handled by the
|
||||
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
|
||||
/// Skips initialization when updates are handled by an external distribution
|
||||
/// channel (Flatpak/Snap). Otherwise creates the global [`AutoUpdater`]
|
||||
/// entity and schedules a check for updates after a 2-minute delay.
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
if is_flatpak_installation() {
|
||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||
if uses_managed_updates() {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: App is installed via a managed distribution channel (Flatpak/Snap)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,7 +67,27 @@ pub struct AutoUpdater {
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Whether auto-update is available for this installation.
|
||||
///
|
||||
/// Returns `false` on managed distribution channels (Flatpak/Snap), where
|
||||
/// updates are handled by the channel and no global updater is created.
|
||||
pub fn is_available(cx: &App) -> bool {
|
||||
cx.try_global::<GlobalAutoUpdater>().is_some()
|
||||
}
|
||||
|
||||
/// Retrieve the global auto updater instance, if one was initialized.
|
||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalAutoUpdater>()
|
||||
.map(|global| global.0.clone())
|
||||
}
|
||||
|
||||
/// Retrieve the global auto updater instance.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics when auto-update is not available for this installation. Prefer
|
||||
/// [`AutoUpdater::try_global`] when the installation type is not known at
|
||||
/// compile time (e.g. Flatpak/Snap).
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ publish = false
|
||||
atomic-destructor = "0.2"
|
||||
event-listener = "5"
|
||||
nostr.workspace = true
|
||||
opaquerr = "0.1"
|
||||
opaquerr = { version = "0.1", features = ["alloc"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
|
||||
+26
-12
@@ -21,6 +21,7 @@ mod room;
|
||||
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
pub use state::FileAttachment;
|
||||
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
@@ -365,7 +366,8 @@ impl ChatRegistry {
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.first_owned()
|
||||
.into_iter()
|
||||
.next()
|
||||
.is_some();
|
||||
|
||||
if !found {
|
||||
@@ -397,7 +399,8 @@ impl ChatRegistry {
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.first_owned()
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
||||
|
||||
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
||||
@@ -627,7 +630,7 @@ impl ChatRegistry {
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.get_rooms_task(cx);
|
||||
let task = self.query_chat_rooms(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
@@ -648,23 +651,34 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a task to load rooms from the database
|
||||
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
/// Query the chat rooms from the database
|
||||
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let contacts = client
|
||||
|
||||
// Query the latest contact list (previously `NostrDatabaseExt::contacts_public_keys`)
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contacts: HashSet<PublicKey> = client
|
||||
.database()
|
||||
.contacts_public_keys(public_key)
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), "14");
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
@@ -706,8 +720,8 @@ impl ChatRegistry {
|
||||
|
||||
/// Parse a nostr event into a message and push it to the belonging room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
/// - If the room doesn't exist, it will be created.
|
||||
/// - Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
@@ -810,7 +824,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
Tag::identifier(id),
|
||||
Tag::public_key(rumor.pubkey),
|
||||
Tag::custom("r", [room_id]),
|
||||
Tag::custom("k", ["14"]),
|
||||
Tag::custom("k", [rumor.kind.to_string()]),
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||
@@ -827,7 +841,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
||||
let filter = Filter::new().identifier(gift_wrap).limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
||||
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
||||
} else {
|
||||
Err(anyhow!("Event is not cached yet."))
|
||||
|
||||
+79
-36
@@ -4,6 +4,9 @@ use std::ops::Range;
|
||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||
use gpui::{SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::FileAttachment;
|
||||
|
||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -21,61 +24,90 @@ pub struct Message {
|
||||
pub mentions: Vec<Mention>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
/// Encrypted file attachment
|
||||
pub file: Option<FileAttachment>,
|
||||
}
|
||||
|
||||
impl From<&Event> for Message {
|
||||
fn from(val: &Event) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
id: val.id,
|
||||
author: val.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
from_parts(
|
||||
val.id,
|
||||
val.pubkey,
|
||||
val.created_at,
|
||||
val.kind,
|
||||
&val.content,
|
||||
&val.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for Message {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
from_parts(
|
||||
// Event ID must be known
|
||||
id: val.id.unwrap(),
|
||||
author: val.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
val.id.unwrap(),
|
||||
val.pubkey,
|
||||
val.created_at,
|
||||
val.kind,
|
||||
&val.content,
|
||||
&val.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&NewMessage> for Message {
|
||||
fn from(val: &NewMessage) -> Self {
|
||||
let mentions = extract_mentions(&val.rumor.content);
|
||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
|
||||
|
||||
Self {
|
||||
from_parts(
|
||||
// Event ID must be known
|
||||
id: val.rumor.id.unwrap(),
|
||||
author: val.rumor.pubkey,
|
||||
content: string,
|
||||
val.rumor.id.unwrap(),
|
||||
val.rumor.pubkey,
|
||||
val.rumor.created_at,
|
||||
val.rumor.kind,
|
||||
&val.rumor.content,
|
||||
&val.rumor.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
id: EventId,
|
||||
author: PublicKey,
|
||||
created_at: Timestamp,
|
||||
kind: Kind,
|
||||
content: &str,
|
||||
tags: &Tags,
|
||||
) -> Message {
|
||||
let file = if kind == KIND_FILE_MESSAGE {
|
||||
FileAttachment::from_tags(content, tags)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_file = file.is_some();
|
||||
|
||||
let replies_to = extract_reply_ids(tags);
|
||||
|
||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||
let mentions = if has_file {
|
||||
Vec::new()
|
||||
} else {
|
||||
extract_mentions(content)
|
||||
};
|
||||
|
||||
let (media, content) = if has_file {
|
||||
(Vec::new(), String::new())
|
||||
} else {
|
||||
extract_and_remove_media_urls(content)
|
||||
};
|
||||
|
||||
Message {
|
||||
id,
|
||||
author,
|
||||
content,
|
||||
media,
|
||||
created_at: val.rumor.created_at,
|
||||
created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +137,17 @@ impl Hash for Message {
|
||||
}
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Single-line representation for reply previews, notifications and copy.
|
||||
pub fn preview(&self) -> SharedString {
|
||||
if let Some(file) = &self.file {
|
||||
return format!("[File] {}", file.display_name()).into();
|
||||
}
|
||||
|
||||
self.content.clone().into()
|
||||
}
|
||||
}
|
||||
|
||||
/// New message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
|
||||
+49
-21
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
use crate::NewMessage;
|
||||
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||
|
||||
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
||||
@@ -403,7 +403,7 @@ impl Room {
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
|
||||
|
||||
let messages = client
|
||||
.database()
|
||||
@@ -439,12 +439,52 @@ impl Room {
|
||||
let content: String = content.into();
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(self.conversation_tags(&replies, sender, cx))
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
|
||||
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
where
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||
tags.extend(file.tags());
|
||||
|
||||
// Construct a file message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
|
||||
@@ -454,32 +494,20 @@ impl Room {
|
||||
}
|
||||
|
||||
// Add all reply tags
|
||||
for id in replies.into_iter() {
|
||||
tags.push(Tag::event(id))
|
||||
for id in replies {
|
||||
tags.push(Tag::event(*id))
|
||||
}
|
||||
|
||||
// Add all receiver tags (no intermediate allocation)
|
||||
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
||||
let member = persons.read(cx).get(public_key, cx);
|
||||
tags.push(
|
||||
Nip01Tag::PublicKey {
|
||||
tags.push(Tag::from(Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_hint: member.messaging_relay_hint(),
|
||||
}
|
||||
.to_tag(),
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
tags
|
||||
}
|
||||
|
||||
/// Select the appropriate signer based on signer kind and available keys.
|
||||
@@ -612,7 +640,7 @@ async fn send_gift_wrap(
|
||||
rumor: &UnsignedEvent,
|
||||
config: &SignerKind,
|
||||
) -> Result<SendReport, Error> {
|
||||
let k_tag = Tag::custom("k", vec!["14"]);
|
||||
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
||||
let mut extra_tags = vec![k_tag];
|
||||
|
||||
// Determine the receiver public key based on the config
|
||||
|
||||
@@ -4,13 +4,13 @@ use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use instant::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
@@ -414,7 +414,7 @@ impl DeviceRegistry {
|
||||
.pubkey(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
match client.database().query(filter).await?.first_owned() {
|
||||
match client.database().query(filter).await?.into_iter().next() {
|
||||
// Found an approval event
|
||||
Some(event) => Ok(Some(event)),
|
||||
// No approval event found, construct a request event
|
||||
|
||||
@@ -24,6 +24,10 @@ serde_json.workspace = true
|
||||
|
||||
mime_guess = "2.0.4"
|
||||
|
||||
aes-gcm.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aes_gcm::aead::consts::U12;
|
||||
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::http_client::AsyncBody;
|
||||
use gpui::{AsyncApp, SharedString};
|
||||
use nostr::nips::nip94::Sha256Hash;
|
||||
use nostr_sdk::prelude::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use nostr_blossom::prelude::*;
|
||||
|
||||
pub const ALGORITHM: &str = "aes-gcm";
|
||||
|
||||
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
|
||||
const TAG_SHA256: &str = "x";
|
||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||
const TAG_FILE_TYPE: &str = "file-type";
|
||||
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
||||
const TAG_KEY: &str = "decryption-key";
|
||||
const TAG_NONCE: &str = "decryption-nonce";
|
||||
const TAG_SIZE: &str = "size";
|
||||
const TAG_DIM: &str = "dim";
|
||||
const TAG_ALT: &str = "alt";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncryptedFile {
|
||||
pub data: Vec<u8>,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileAttachment {
|
||||
pub url: Url,
|
||||
pub mime: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub sha256: Option<String>,
|
||||
pub original_sha256: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub dim: Option<(u32, u32)>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl FileAttachment {
|
||||
pub fn tags(&self) -> Vec<Tag> {
|
||||
let mut tags = vec![
|
||||
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
|
||||
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
|
||||
Tag::custom(TAG_KEY, [self.key.clone()]),
|
||||
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
|
||||
];
|
||||
|
||||
if let Some(sha256) = &self.sha256 {
|
||||
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
|
||||
}
|
||||
|
||||
if let Some(original_sha256) = &self.original_sha256 {
|
||||
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
|
||||
}
|
||||
|
||||
if let Some(size) = self.size {
|
||||
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
|
||||
}
|
||||
|
||||
if let Some((width, height)) = self.dim {
|
||||
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
|
||||
}
|
||||
|
||||
if let Some(name) = &self.name {
|
||||
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
|
||||
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
url: Url::parse(content).ok()?,
|
||||
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
|
||||
key: tag_value(tags, TAG_KEY)?.to_string(),
|
||||
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
|
||||
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
|
||||
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
|
||||
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
|
||||
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
|
||||
name: tag_value(tags, TAG_ALT).map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_image(&self) -> bool {
|
||||
self.mime.starts_with("image/")
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
if let Some(name) = &self.name {
|
||||
return name.clone().into();
|
||||
}
|
||||
|
||||
match self.size {
|
||||
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
|
||||
None => self.mime.clone().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
let key = Aes256Gcm::generate_key(OsRng);
|
||||
let nonce = Aes256Gcm::generate_nonce(OsRng);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
|
||||
let data = cipher
|
||||
.encrypt(&nonce, data)
|
||||
.map_err(|_| anyhow!("Failed to encrypt file"))?;
|
||||
|
||||
Ok(EncryptedFile {
|
||||
data,
|
||||
key: STANDARD.encode(key.as_slice()),
|
||||
nonce: STANDARD.encode(nonce.as_slice()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
|
||||
let key = decode(key, 32, "decryption key")?;
|
||||
let nonce = decode(nonce, 12, "decryption nonce")?;
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| anyhow!("Invalid decryption key"))?;
|
||||
|
||||
cipher
|
||||
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
|
||||
.map_err(|_| anyhow!("Failed to decrypt file"))
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let hash: [u8; 32] = Sha256::digest(data).into();
|
||||
|
||||
Sha256Hash::from_byte_array(hash).to_hex()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload_encrypted(
|
||||
server: Url,
|
||||
path: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
let data = smol::fs::read(&path).await?;
|
||||
|
||||
let encrypted = encrypt(&data)?;
|
||||
let sha256 = Some(sha256_hex(&encrypted.data));
|
||||
let original_sha256 = Some(sha256_hex(&data));
|
||||
let size = Some(encrypted.data.len() as u64);
|
||||
let keys = Keys::generate();
|
||||
let client = BlossomClient::new(server);
|
||||
|
||||
let url = Tokio::spawn(cx, async move {
|
||||
let blob = client
|
||||
.upload_blob(
|
||||
encrypted.data,
|
||||
Some("application/octet-stream".to_string()),
|
||||
None,
|
||||
Some(&keys),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<Url, Error>(blob.url)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Upload error: {e}"))??;
|
||||
|
||||
Ok(FileAttachment {
|
||||
url,
|
||||
mime,
|
||||
key: encrypted.key,
|
||||
nonce: encrypted.nonce,
|
||||
sha256,
|
||||
original_sha256,
|
||||
size,
|
||||
dim: None,
|
||||
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 async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
expected_sha256: Option<&str>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let client = cx.update(|app| app.http_client());
|
||||
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!("Failed to download file: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
response
|
||||
.into_body()
|
||||
.take(MAX_FILE_SIZE as u64 + 1)
|
||||
.read_to_end(&mut data)
|
||||
.await?;
|
||||
|
||||
if data.len() > MAX_FILE_SIZE {
|
||||
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
|
||||
}
|
||||
|
||||
if let Some(expected) = expected_sha256
|
||||
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
|
||||
{
|
||||
bail!("File hash mismatch");
|
||||
}
|
||||
|
||||
decrypt(&data, key, nonce)
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
fn parse_dim(value: &str) -> Option<(u32, u32)> {
|
||||
let (width, height) = value.split_once('x')?;
|
||||
|
||||
Some((width.parse().ok()?, height.parse().ok()?))
|
||||
}
|
||||
|
||||
fn decode(value: &str, expected_len: usize, label: &str) -> Result<Vec<u8>, Error> {
|
||||
let decoded = STANDARD
|
||||
.decode(value)
|
||||
.or_else(|_| STANDARD_NO_PAD.decode(value))
|
||||
.or_else(|_| URL_SAFE.decode(value))
|
||||
.or_else(|_| URL_SAFE_NO_PAD.decode(value))
|
||||
.map_err(|_| anyhow!("Invalid {label} encoding"))?;
|
||||
|
||||
if decoded.len() != expected_len {
|
||||
bail!(
|
||||
"Invalid {label} length: expected {expected_len} bytes, got {}",
|
||||
decoded.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
@@ -17,12 +17,14 @@ use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
mod constants;
|
||||
mod file;
|
||||
mod nip05;
|
||||
mod nip4e;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
pub use constants::*;
|
||||
pub use file::*;
|
||||
pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||
|
||||
@@ -84,8 +84,20 @@ impl Screening {
|
||||
|
||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
// Check if user is in contact list
|
||||
let contacts = client.database().contacts_public_keys(current_user).await;
|
||||
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||
let filter = Filter::new()
|
||||
.author(current_user)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let followed = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().any(|k| k == public_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(followed)
|
||||
});
|
||||
@@ -228,11 +240,10 @@ impl Screening {
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let tag = Nip56Tag::PublicKey {
|
||||
let tag = Tag::from(Nip56Tag::PublicKey {
|
||||
public_key,
|
||||
report: Report::Impersonation,
|
||||
}
|
||||
.to_tag();
|
||||
});
|
||||
|
||||
let event = EventBuilder::new(Kind::Reporting, "")
|
||||
.tag(tag)
|
||||
|
||||
@@ -380,7 +380,9 @@ impl Workspace {
|
||||
self.import_encryption(window, cx);
|
||||
}
|
||||
Command::Update => {
|
||||
let auto_updater = AutoUpdater::global(cx);
|
||||
// No-op on managed distribution channels (Flatpak/Snap) where
|
||||
// the in-app updater is never initialized.
|
||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||
auto_updater.update(cx, |this, cx| {
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.check(cx);
|
||||
@@ -389,6 +391,7 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
||||
@@ -559,7 +562,7 @@ impl Workspace {
|
||||
.caret()
|
||||
.compact()
|
||||
.transparent()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let avatar = avatar.clone();
|
||||
let name = name.clone();
|
||||
|
||||
@@ -593,12 +596,15 @@ impl Workspace {
|
||||
IconName::Sun,
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
// Only offer in-app updates when auto-update is
|
||||
// enabled (managed channels update themselves).
|
||||
.when(AutoUpdater::is_available(cx), |this| {
|
||||
this.separator().menu_with_icon(
|
||||
"Check for Updates",
|
||||
IconName::Device,
|
||||
Box::new(Command::Update),
|
||||
)
|
||||
})
|
||||
.menu_with_icon(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
@@ -610,7 +616,6 @@ impl Workspace {
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let updater = AutoUpdater::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -622,13 +627,19 @@ impl Workspace {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
let announcement = profile.announcement();
|
||||
let updater_idle = updater.read(cx).idle(cx);
|
||||
|
||||
// Update status is only shown when auto-update is available. On
|
||||
// managed distribution channels (Flatpak/Snap) no updater exists, so
|
||||
// nothing is rendered.
|
||||
let updater_status = AutoUpdater::try_global(cx).and_then(|updater| {
|
||||
let updater = updater.read(cx);
|
||||
(!updater.idle(cx)).then(|| updater.status(cx))
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||
.gap_2()
|
||||
.when(!updater_idle, |this| {
|
||||
let status = updater.read(cx).status(cx);
|
||||
.when_some(updater_status, |this, status| {
|
||||
this.child(div().text_xs().italic().child(status))
|
||||
})
|
||||
.when(nip4e_enabled, |this| {
|
||||
|
||||
@@ -88,7 +88,20 @@ impl ContactListPanel {
|
||||
};
|
||||
|
||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contact_list: HashSet<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(contact_list)
|
||||
});
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ impl MessagingRelayPanel {
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
||||
Ok(nip17::extract_relay_list(&event).collect())
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
@@ -177,7 +177,7 @@ impl MessagingRelayPanel {
|
||||
let tags: Vec<Tag> = self
|
||||
.relays
|
||||
.iter()
|
||||
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
|
||||
.map(|relay| Tag::from(Nip17Tag::Relay(relay.to_owned())))
|
||||
.collect();
|
||||
|
||||
// Set updating state
|
||||
|
||||
@@ -111,7 +111,7 @@ impl RelayListPanel {
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
||||
Ok(nip65::extract_relay_list(&event).collect())
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
|
||||
@@ -158,7 +158,20 @@ impl Sidebar {
|
||||
};
|
||||
|
||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contacts: HashSet<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "1.0.0-beta5"
|
||||
version = "1.0.1"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
[package]
|
||||
name = "coop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.packager]
|
||||
name = "Coop"
|
||||
product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "1.0.0-beta5"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
icons = [
|
||||
"resources/32x32.png",
|
||||
"resources/128x128.png",
|
||||
"resources/128x128@2x.png",
|
||||
"resources/icon.icns",
|
||||
"resources/icon.ico",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../crates/assets" }
|
||||
workspace = { path = "../crates/workspace" }
|
||||
ui = { path = "../crates/ui" }
|
||||
theme = { path = "../crates/theme" }
|
||||
common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
settings = { path = "../crates/settings" }
|
||||
auto_update = { path = "../crates/auto_update" }
|
||||
person = { path = "../crates/person" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_linux.workspace = true
|
||||
gpui_windows.workspace = true
|
||||
gpui_macos.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
log.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
@@ -1,3 +1,7 @@
|
||||
# Snaps built by snapcraft without Snap Store credentials are unsigned and
|
||||
# cannot be installed without bypassing signature checks. Use:
|
||||
# sudo snap install --dangerous ./coop_<version>_<arch>.snap
|
||||
# For signed installs (`snap install coop`), publish via the Snap Store.
|
||||
name: coop
|
||||
title: Coop
|
||||
base: core24
|
||||
|
||||
+4
-4
@@ -55,8 +55,8 @@ flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
|
||||
|
||||
Ensure you have:
|
||||
- [ ] Committed all changes
|
||||
- [ ] Tagged the release: `git tag -a v1.0.0-beta2 -m "Release v1.0.0-beta2"`
|
||||
- [ ] Pushed the tag: `git push origin v1.0.0-beta2`
|
||||
- [ ] Tagged the release: `git tag -a v1.0.0 -m "Release v1.0.0"`
|
||||
- [ ] Pushed the tag: `git push origin v1.0.0`
|
||||
- [ ] Run `./script/prepare-flathub.sh` to regenerate files
|
||||
|
||||
### 2. Fork and Submit
|
||||
@@ -101,8 +101,8 @@ git push origin su.reya.coop
|
||||
To release a new version:
|
||||
|
||||
1. Update version in workspace `Cargo.toml`
|
||||
2. Tag the new release: `git tag -a v1.0.0-beta3 -m "Release v1.0.0-beta3"`
|
||||
3. Push the tag: `git push origin v1.0.0-beta3`
|
||||
2. Tag the new release: `git tag -a v1.0.0 -m "Release v1.0.0"`
|
||||
3. Push the tag: `git push origin v1.0.0`
|
||||
4. Run `./script/prepare-flathub.sh` to regenerate
|
||||
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.coop.git`
|
||||
6. Update the manifest with new commit/tag and hashes
|
||||
|
||||
@@ -35,3 +35,7 @@ SNAP_NAME="coop_${1}_${ARCH_SUFFIX}.snap"
|
||||
snapcraft --destructive-mode --output "$SNAP_NAME"
|
||||
|
||||
echo "Created snap package: $SNAP_NAME"
|
||||
echo ""
|
||||
echo "This snap is unsigned (built without Snap Store credentials)."
|
||||
echo "To install it locally, use:"
|
||||
echo " sudo snap install --dangerous ./$SNAP_NAME"
|
||||
|
||||
+16
-10
@@ -29,23 +29,29 @@ fi
|
||||
# Function to update version in a Cargo.toml file
|
||||
update_version() {
|
||||
local file="$1"
|
||||
local backup="${file}.bak"
|
||||
local tmp="${file}.tmp"
|
||||
|
||||
# Backup the original file
|
||||
cp "$file" "$backup"
|
||||
|
||||
# More flexible regex that handles various version formats and whitespace
|
||||
if sed -i -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then
|
||||
# Portable in-place edit. `sed -i` behaves differently on GNU sed (Linux)
|
||||
# and BSD sed (macOS): on macOS `sed -i -E` treats `-E` as the backup
|
||||
# suffix instead of the extended-regex flag, leaving a stray
|
||||
# `Cargo.toml-E` behind and never updating the file.
|
||||
# Writing to a temp file and moving it over works on both implementations.
|
||||
if sed -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file" > "$tmp" \
|
||||
&& mv "$tmp" "$file"; then
|
||||
echo "✓ Updated version to $NEW_VERSION in $file"
|
||||
else
|
||||
echo "Error: Failed to update version in $file"
|
||||
# Restore original backup
|
||||
mv "$backup" "$file"
|
||||
# Remove any partial temp file; the original file is untouched
|
||||
rm -f "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove the backup file
|
||||
rm -f "$backup"
|
||||
# The substitution can silently match nothing (e.g. a `version.workspace` key),
|
||||
# so verify the new version actually landed before moving on.
|
||||
if ! grep -q "^[[:space:]]*version[[:space:]]*=[[:space:]]*\"$NEW_VERSION\"" "$file"; then
|
||||
echo "Error: Version line not found/updated in $file"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Update both Cargo.toml files
|
||||
|
||||
+16
-1
@@ -15,11 +15,26 @@ if [ "$#" -ne 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get system architecture (same mapping as script/bundle-snap)
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH_SUFFIX="x86_64" ;;
|
||||
aarch64) ARCH_SUFFIX="aarch64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
snap_file="coop_${1}_${ARCH_SUFFIX}.snap"
|
||||
if [ ! -f "$snap_file" ]; then
|
||||
echo "Snap file not found: $snap_file"
|
||||
echo "Build it first with: script/bundle-snap $1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Rerun as root
|
||||
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
||||
|
||||
snap remove coop || true
|
||||
mkdir -p snap
|
||||
rm -rf snap/unpacked
|
||||
unsquashfs -dest snap/unpacked "coop_$1_amd64.snap"
|
||||
unsquashfs -dest snap/unpacked "$snap_file"
|
||||
snap try --classic snap/unpacked
|
||||
|
||||
Reference in New Issue
Block a user