Compare commits
3
Commits
413ce04476
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a34d66c7c | ||
|
|
57c85f5b99 | ||
|
|
bf7080654d |
@@ -152,11 +152,23 @@ jobs:
|
|||||||
echo "Artifacts structure:"
|
echo "Artifacts structure:"
|
||||||
find artifacts -type f -exec ls -la {} \;
|
find artifacts -type f -exec ls -la {} \;
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
run: |
|
||||||
|
# One `<sha256> <path>` line per artifact. The in-app updater reads
|
||||||
|
# this to verify a download before installing it, so it must be
|
||||||
|
# published alongside every release. Written outside the directory
|
||||||
|
# being hashed so the checksums file never includes itself.
|
||||||
|
find artifacts -type f ! -name SHA256SUMS -print0 \
|
||||||
|
| sort -z \
|
||||||
|
| xargs -0 sha256sum > SHA256SUMS.raw
|
||||||
|
mv SHA256SUMS.raw artifacts/SHA256SUMS
|
||||||
|
cat artifacts/SHA256SUMS
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
server_url: "https://git.reya.su/"
|
server_url: "https://git.reya.info/"
|
||||||
repository: "reya/coop"
|
repository: "reya/coop"
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
Generated
+356
-301
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ webbrowser = "1.0.4"
|
|||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
errno = { version = "0.3.14", default-features = false }
|
errno = { version = "0.3.14", default-features = false }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
|
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
# Use stacker's psm version which may have better WASM support
|
# Use stacker's psm version which may have better WASM support
|
||||||
|
|||||||
@@ -1,452 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -8,5 +8,8 @@ publish.workspace = true
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
ureq.workspace = true
|
||||||
|
|
||||||
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
||||||
|
|||||||
+239
-123
@@ -1,22 +1,30 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
use std::sync::Arc;
|
||||||
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use instant::{Duration, Instant};
|
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
||||||
|
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
||||||
|
use instant::Duration;
|
||||||
|
|
||||||
|
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
||||||
|
|
||||||
|
mod source;
|
||||||
|
|
||||||
|
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
|
||||||
|
|
||||||
|
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
|
||||||
|
const GITEA_REPO_OWNER: &str = "reya";
|
||||||
|
const GITEA_REPO_NAME: &str = "coop";
|
||||||
|
|
||||||
|
/// Delay before the automatic check that runs on startup.
|
||||||
|
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
|
||||||
|
/// How long a failure stays visible before the status reverts to "Up to date".
|
||||||
|
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_github_repo_name() -> String {
|
|
||||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
fn uses_managed_updates() -> bool {
|
||||||
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
||||||
std::env::var("FLATPAK_ID").is_ok()
|
std::env::var("FLATPAK_ID").is_ok()
|
||||||
@@ -27,50 +35,55 @@ fn uses_managed_updates() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the auto-update system.
|
/// Initialize the auto-update system.
|
||||||
///
|
|
||||||
/// 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) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
if uses_managed_updates() {
|
if uses_managed_updates() {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skipping auto-update initialization: App is installed via a managed distribution channel (Flatpak/Snap)"
|
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
|
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
|
||||||
|
|
||||||
|
let Some(filter) = asset_filter_for(os, arch) else {
|
||||||
|
log::info!(
|
||||||
|
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
|
||||||
|
log::error!(
|
||||||
|
"Skipping auto-update initialization: crate version {:?} is not valid semver",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
AutoUpdater::set_global(
|
||||||
|
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
|
|
||||||
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
|
|
||||||
|
|
||||||
/// The global auto-updater entity.
|
|
||||||
///
|
|
||||||
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
|
|
||||||
/// (GitHub repo, Flatpak detection, delayed auto-check).
|
|
||||||
///
|
|
||||||
/// Retrieve the global instance via [`AutoUpdater::global`].
|
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
/// The underlying gpui-updater entity that does the heavy lifting.
|
/// The blocking engine, driven on the background executor.
|
||||||
pub updater: Entity<Updater>,
|
engine: Arc<UpdateEngine<GiteaSource>>,
|
||||||
|
status: UpdateStatus,
|
||||||
|
/// The newer release found by the last successful check, if any.
|
||||||
|
available: Option<Release>,
|
||||||
/// Currently running app version.
|
/// Currently running app version.
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
/// Keeps the observer subscription alive.
|
/// The in-flight check or download, if any.
|
||||||
_subscription: Subscription,
|
task: Option<Task<()>>,
|
||||||
/// When the last error was recorded, so we can reset to idle after 5s.
|
|
||||||
error_time: Option<Instant>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
/// Whether auto-update is available for this installation.
|
/// 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 {
|
pub fn is_available(cx: &App) -> bool {
|
||||||
cx.try_global::<GlobalAutoUpdater>().is_some()
|
cx.try_global::<GlobalAutoUpdater>().is_some()
|
||||||
}
|
}
|
||||||
@@ -82,12 +95,6 @@ impl AutoUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the global auto updater instance.
|
/// 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> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||||
}
|
}
|
||||||
@@ -96,92 +103,49 @@ impl AutoUpdater {
|
|||||||
cx.set_global(GlobalAutoUpdater(state));
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(
|
||||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
window: &mut Window,
|
||||||
|
version: Version,
|
||||||
|
filter: AssetFilter,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
||||||
|
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
||||||
|
let engine = Arc::new(UpdateEngine::new(source, config));
|
||||||
|
|
||||||
let repo_owner = get_github_repo_owner();
|
// Schedule an auto-check after a 2-minute delay
|
||||||
let repo_name = get_github_repo_name();
|
|
||||||
|
|
||||||
let source =
|
|
||||||
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
|
|
||||||
"macos" => "macos",
|
|
||||||
"linux" => "linux",
|
|
||||||
_ => "",
|
|
||||||
});
|
|
||||||
|
|
||||||
let updater: Entity<Updater> =
|
|
||||||
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
|
|
||||||
|
|
||||||
// When an update becomes available, automatically download and install it.
|
|
||||||
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
|
|
||||||
let status = this.updater.read(cx).status().clone();
|
|
||||||
|
|
||||||
if matches!(status, UpdateStatus::Available(_)) {
|
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.download_and_install(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(status, UpdateStatus::Errored(_)) {
|
|
||||||
this.error_time = Some(Instant::now());
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
|
||||||
this.update(cx, |_this, cx| cx.notify()).ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
} else {
|
|
||||||
this.error_time = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
|
||||||
// end of the current frame so the window is fully set up).
|
|
||||||
cx.defer_in(window, |_this, _window, cx| {
|
cx.defer_in(window, |_this, _window, cx| {
|
||||||
let duration = Duration::from_secs(120);
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(duration).await;
|
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| this.check(cx)).ok();
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
updater,
|
engine,
|
||||||
|
status: UpdateStatus::Idle,
|
||||||
|
available: None,
|
||||||
version,
|
version,
|
||||||
_subscription: subscription,
|
task: None,
|
||||||
error_time: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn idle(&self, cx: &App) -> bool {
|
/// Whether nothing is happening, so the UI can hide the status line.
|
||||||
let status = self.updater.read(cx).status();
|
pub fn idle(&self) -> bool {
|
||||||
if status == &UpdateStatus::Idle {
|
matches!(self.status, UpdateStatus::Idle)
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if matches!(status, UpdateStatus::Errored(_))
|
|
||||||
&& self
|
|
||||||
.error_time
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self, cx: &App) -> SharedString {
|
/// Whether a verified update is installed and waiting for a restart.
|
||||||
let status = self.updater.read(cx).status();
|
pub fn staged(&self) -> bool {
|
||||||
|
matches!(self.status, UpdateStatus::Staged(_))
|
||||||
|
}
|
||||||
|
|
||||||
match status {
|
/// A short, human-readable description of the current status.
|
||||||
UpdateStatus::Idle => "Up to date".into(),
|
pub fn status(&self) -> SharedString {
|
||||||
|
match &self.status {
|
||||||
|
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
|
||||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||||
UpdateStatus::UpToDate => "Up to date".into(),
|
|
||||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||||
UpdateStatus::Downloading { downloaded, total } => {
|
UpdateStatus::Downloading { downloaded, total } => {
|
||||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||||
@@ -195,16 +159,168 @@ impl AutoUpdater {
|
|||||||
UpdateStatus::Staged(version) => {
|
UpdateStatus::Staged(version) => {
|
||||||
format!("Version {version} ready — restart to apply").into()
|
format!("Version {version} ready — restart to apply").into()
|
||||||
}
|
}
|
||||||
UpdateStatus::Errored(msg) => {
|
UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
|
||||||
if self
|
|
||||||
.error_time
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
|
||||||
{
|
|
||||||
"Up to date".into()
|
|
||||||
} else {
|
|
||||||
format!("Update failed: {msg}").into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check the release host for a newer version, then download and install it.
|
||||||
|
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.status.is_busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.set_status(UpdateStatus::Checking, cx);
|
||||||
|
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
|
||||||
|
self.task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_executor()
|
||||||
|
.spawn(async move { engine.check() })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
match result {
|
||||||
|
Ok(Some(release)) => {
|
||||||
|
log::info!("Update {} is available", release.version);
|
||||||
|
let version = release.version.clone();
|
||||||
|
this.available = Some(release);
|
||||||
|
this.set_status(UpdateStatus::Available(version), cx);
|
||||||
|
this.download_and_install(cx);
|
||||||
|
}
|
||||||
|
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update check failed: {error}");
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the available update, verify it, and swap it into place.
|
||||||
|
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.status.is_busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(release) = self.available.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
self.set_status(
|
||||||
|
UpdateStatus::Downloading {
|
||||||
|
downloaded: 0,
|
||||||
|
total: None,
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
self.task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
let downloaded = Arc::new(AtomicU64::new(0));
|
||||||
|
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let download_task = {
|
||||||
|
let (engine, release) = (engine.clone(), release.clone());
|
||||||
|
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
||||||
|
cx.background_executor().spawn(async move {
|
||||||
|
let result = engine.download(&release, |got, expected| {
|
||||||
|
downloaded.store(got, Ordering::Relaxed);
|
||||||
|
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
result
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let got = downloaded.load(Ordering::Relaxed);
|
||||||
|
let total = total.load(Ordering::Relaxed);
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(
|
||||||
|
UpdateStatus::Downloading {
|
||||||
|
downloaded: got,
|
||||||
|
total: (total != 0).then_some(total),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
if done.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_millis(120))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifact = match download_task.await {
|
||||||
|
Ok(artifact) => artifact,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update download failed: {error}");
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
|
||||||
|
|
||||||
|
let installed = {
|
||||||
|
let engine = engine.clone();
|
||||||
|
cx.background_executor()
|
||||||
|
.spawn(async move { engine.install(&artifact) })
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
match installed {
|
||||||
|
Ok(installed) => {
|
||||||
|
if let Some(path) = installed.restart_path {
|
||||||
|
cx.set_restart_path(path);
|
||||||
|
}
|
||||||
|
let version = release.version.clone();
|
||||||
|
this.set_status(UpdateStatus::Staged(version), cx);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relaunch into the staged update.
|
||||||
|
pub fn restart(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.staged() {
|
||||||
|
log::warn!("Ignoring restart request: no update is staged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cx.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
||||||
|
let errored = matches!(status, UpdateStatus::Errored(_));
|
||||||
|
self.status = status;
|
||||||
|
|
||||||
|
if errored {
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(UpdateStatus::Idle, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||||
|
const RELEASE_PAGE_SIZE: usize = 20;
|
||||||
|
|
||||||
|
/// Which published artifact belongs to a target platform.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AssetFilter {
|
||||||
|
extension: &'static str,
|
||||||
|
arch: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetFilter {
|
||||||
|
/// Whether `name` is the installable artifact for this target.
|
||||||
|
fn matches(&self, name: &str) -> bool {
|
||||||
|
let name = name.to_ascii_lowercase();
|
||||||
|
name.ends_with(self.extension) && name.contains(self.arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
||||||
|
let extension = match os {
|
||||||
|
"macos" => ".dmg",
|
||||||
|
"linux" => ".tar.gz",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let arch = match (os, arch) {
|
||||||
|
// cargo-packager names the disk images `aarch64`/`x64`.
|
||||||
|
("macos", "aarch64") => "aarch64",
|
||||||
|
("macos", "x86_64") => "x64",
|
||||||
|
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||||
|
("linux", "aarch64") => "aarch64",
|
||||||
|
("linux", "x86_64") => "x86_64",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(AssetFilter { extension, arch })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads releases from a Gitea repository's Releases.
|
||||||
|
pub struct GiteaSource {
|
||||||
|
api_base: String,
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
filter: AssetFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GiteaSource {
|
||||||
|
/// Build a source for `owner/repo` on the Gitea instance at `api_base`
|
||||||
|
/// (e.g. `https://git.reya.info/api/v1`).
|
||||||
|
pub fn new(
|
||||||
|
api_base: impl Into<String>,
|
||||||
|
owner: impl Into<String>,
|
||||||
|
repo: impl Into<String>,
|
||||||
|
filter: AssetFilter,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||||
|
owner: owner.into(),
|
||||||
|
repo: repo.into(),
|
||||||
|
filter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn releases_url(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
|
||||||
|
self.api_base, self.owner, self.repo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateSource for GiteaSource {
|
||||||
|
fn fetch_latest(&self) -> Result<Release> {
|
||||||
|
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
|
||||||
|
let release = newest_published(&releases)
|
||||||
|
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
||||||
|
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| self.filter.matches(&asset.name))
|
||||||
|
.ok_or(Error::NoMatchingAsset {
|
||||||
|
target_os: std::env::consts::OS,
|
||||||
|
target_arch: std::env::consts::ARCH,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Resolve the published checksum so the engine can reject a truncated or substituted download.
|
||||||
|
let sha256 = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
||||||
|
.map(|sums| http::get_string(&sums.browser_download_url))
|
||||||
|
.transpose()?
|
||||||
|
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||||
|
|
||||||
|
Ok(Release {
|
||||||
|
version: parse_tag(&release.tag_name)?,
|
||||||
|
notes: release
|
||||||
|
.body
|
||||||
|
.clone()
|
||||||
|
.filter(|body| !body.trim().is_empty())
|
||||||
|
.or_else(|| release.name.clone()),
|
||||||
|
asset: Asset {
|
||||||
|
name: asset.name.clone(),
|
||||||
|
url: asset.browser_download_url.clone(),
|
||||||
|
size: asset.size,
|
||||||
|
},
|
||||||
|
signature: None,
|
||||||
|
signature_url: None,
|
||||||
|
sha256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> {
|
||||||
|
releases
|
||||||
|
.iter()
|
||||||
|
.filter(|release| !release.draft && !release.prerelease)
|
||||||
|
.filter_map(|release| {
|
||||||
|
parse_tag(&release.tag_name)
|
||||||
|
.ok()
|
||||||
|
.map(|version| (version, release))
|
||||||
|
})
|
||||||
|
.max_by(|(left, _), (right, _)| left.cmp(right))
|
||||||
|
.map(|(_, release)| release)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file.
|
||||||
|
fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
|
||||||
|
sums.lines().find_map(|line| {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let (hash, path) = (parts.next()?, parts.next()?);
|
||||||
|
let path = path.strip_prefix('*').unwrap_or(path);
|
||||||
|
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||||
|
(base == asset_name).then(|| hash.to_ascii_lowercase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaRelease {
|
||||||
|
tag_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
prerelease: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
assets: Vec<GiteaAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release asset as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaAsset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking HTTP helpers for release metadata.
|
||||||
|
mod http {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui_updater_core::{Error, Result};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use ureq::Agent;
|
||||||
|
use ureq::tls::{RootCerts, TlsConfig};
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
fn agent() -> Agent {
|
||||||
|
Agent::config_builder()
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.tls_config(
|
||||||
|
TlsConfig::builder()
|
||||||
|
.root_certs(RootCerts::PlatformVerifier)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.timeout_resolve(Some(CONNECT_TIMEOUT))
|
||||||
|
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||||
|
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut response = agent().get(url).call().map_err(|error| match error {
|
||||||
|
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
|
||||||
|
other => Error::Http(other.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_vec()
|
||||||
|
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
|
||||||
|
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_string(url: &str) -> Result<String> {
|
||||||
|
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use gpui_updater_core::Version;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const PUBLISHED: &[&str] = &[
|
||||||
|
"Coop_1.0.1_aarch64.dmg",
|
||||||
|
"Coop_1.0.1_x64.dmg",
|
||||||
|
"coop-linux-aarch64.tar.gz",
|
||||||
|
"coop-linux-x86_64.tar.gz",
|
||||||
|
"coop_1.0.1_aarch64.snap",
|
||||||
|
"coop_1.0.1_arm64-setup.exe",
|
||||||
|
"coop_1.0.1_x64-setup.exe",
|
||||||
|
"coop_1.0.1_x86_64.snap",
|
||||||
|
"su.reya.coop_aarch64.flatpak",
|
||||||
|
"su.reya.coop_x86_64.flatpak",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn selected(os: &str, arch: &str) -> Option<&'static str> {
|
||||||
|
let filter = asset_filter_for(os, arch)?;
|
||||||
|
PUBLISHED.iter().copied().find(|name| filter.matches(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_the_artifact_matching_os_and_architecture() {
|
||||||
|
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
||||||
|
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
||||||
|
assert_eq!(
|
||||||
|
selected("linux", "aarch64"),
|
||||||
|
Some("coop-linux-aarch64.tar.gz")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selected("linux", "x86_64"),
|
||||||
|
Some("coop-linux-x86_64.tar.gz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||||
|
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() {
|
||||||
|
let macos = asset_filter_for("macos", "aarch64").unwrap();
|
||||||
|
assert!(!macos.matches("coop_1.0.1_aarch64.snap"));
|
||||||
|
assert!(!macos.matches("su.reya.coop_aarch64.flatpak"));
|
||||||
|
assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig"));
|
||||||
|
|
||||||
|
let linux = asset_filter_for("linux", "x86_64").unwrap();
|
||||||
|
assert!(!linux.matches("coop_1.0.1_x64-setup.exe"));
|
||||||
|
assert!(!linux.matches("coop_1.0.1_x86_64.snap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_checksums_by_basename_ignoring_directory_prefix() {
|
||||||
|
let sums = "\
|
||||||
|
abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
||||||
|
123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz
|
||||||
|
789abc SHA256SUMS
|
||||||
|
";
|
||||||
|
assert_eq!(
|
||||||
|
sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(),
|
||||||
|
Some("abcdef")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(),
|
||||||
|
Some("123456")
|
||||||
|
);
|
||||||
|
assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newest_published_skips_drafts_prereleases_and_bad_tags() {
|
||||||
|
let releases: Vec<GiteaRelease> = serde_json::from_str(
|
||||||
|
r#"[
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.2",
|
||||||
|
"draft": true,
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v2.0.0-rc.1",
|
||||||
|
"prerelease": true,
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "nightly",
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "coop-linux-x86_64.tar.gz",
|
||||||
|
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz",
|
||||||
|
"size": 26160329
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.1",
|
||||||
|
"name": "v1.0.1",
|
||||||
|
"body": "Fixed app panic on flatpak installations",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "coop-linux-x86_64.tar.gz",
|
||||||
|
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz",
|
||||||
|
"size": 26160329
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let newest = newest_published(&releases).unwrap();
|
||||||
|
assert_eq!(newest.tag_name, "v1.0.1");
|
||||||
|
assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1");
|
||||||
|
assert_eq!(newest.assets.len(), 1);
|
||||||
|
assert_eq!(newest.assets[0].size, 26160329);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires network access to the release host"]
|
||||||
|
fn live_release_source_resolves_the_running_platform() {
|
||||||
|
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
||||||
|
.expect("this platform should be supported");
|
||||||
|
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
|
||||||
|
|
||||||
|
let release = source
|
||||||
|
.fetch_latest()
|
||||||
|
.expect("release lookup should succeed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
release.version >= Version::new(1, 0, 0),
|
||||||
|
"unexpected version {}",
|
||||||
|
release.version
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source.filter.matches(&release.asset.name),
|
||||||
|
"unexpected artifact {}",
|
||||||
|
release.asset.name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
release.asset.url.starts_with("https://"),
|
||||||
|
"{} ",
|
||||||
|
release.asset.url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use std::sync::{Arc, LazyLock, RwLock};
|
|||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||||
use common::{TimestampExt, coop_cache};
|
use common::TimestampExt;
|
||||||
use futures::lock::Mutex;
|
use futures::lock::Mutex;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -13,8 +13,8 @@ use gpui::{
|
|||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
|
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
||||||
white,
|
retain_all, svg, white,
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -2024,7 +2024,7 @@ impl Render for ChatPanel {
|
|||||||
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache(self.id.clone(), 100))
|
.image_cache(retain_all(self.id.clone()))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::mem::take;
|
|
||||||
|
|
||||||
use futures::FutureExt;
|
|
||||||
use gpui::{
|
|
||||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
|
||||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
|
||||||
CoopImageCacheProvider {
|
|
||||||
id: id.into(),
|
|
||||||
max_items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCacheProvider {
|
|
||||||
id: ElementId,
|
|
||||||
max_items: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCacheProvider for CoopImageCacheProvider {
|
|
||||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
|
||||||
window
|
|
||||||
.with_global_id(self.id.clone(), |id, window| {
|
|
||||||
window.with_element_state(id, |cache, _| {
|
|
||||||
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
|
||||||
(cache.clone(), cache)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCache {
|
|
||||||
max_items: usize,
|
|
||||||
usage_list: VecDeque<u64>,
|
|
||||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CoopImageCache {
|
|
||||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
|
||||||
cx.new(|cx| {
|
|
||||||
log::info!("Creating CoopImageCache");
|
|
||||||
cx.on_release(|this: &mut Self, cx| {
|
|
||||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
|
||||||
if let Some(Ok(image)) = image.get() {
|
|
||||||
log::info!("Dropping image {ix}");
|
|
||||||
cx.drop_image(image, None);
|
|
||||||
}
|
|
||||||
ImageSource::Resource(resource).remove_asset(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
CoopImageCache {
|
|
||||||
max_items,
|
|
||||||
usage_list: VecDeque::with_capacity(max_items),
|
|
||||||
cache: HashMap::with_capacity(max_items),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCache for CoopImageCache {
|
|
||||||
fn load(
|
|
||||||
&mut self,
|
|
||||||
resource: &Resource,
|
|
||||||
window: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::App,
|
|
||||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
|
||||||
let hash = hash(resource);
|
|
||||||
|
|
||||||
if let Some(item) = self.cache.get_mut(&hash) {
|
|
||||||
let current_idx = self
|
|
||||||
.usage_list
|
|
||||||
.iter()
|
|
||||||
.position(|item| *item == hash)
|
|
||||||
.expect("cache has an item usage_list doesn't");
|
|
||||||
|
|
||||||
self.usage_list.remove(current_idx);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
return item.0.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
|
||||||
let task = cx.background_executor().spawn(load_future).shared();
|
|
||||||
|
|
||||||
if self.usage_list.len() >= self.max_items {
|
|
||||||
log::info!("Image cache is full, evicting oldest item");
|
|
||||||
|
|
||||||
if let Some(oldest) = self.usage_list.pop_back() {
|
|
||||||
let mut image = self
|
|
||||||
.cache
|
|
||||||
.remove(&oldest)
|
|
||||||
.expect("usage_list has an item cache doesn't");
|
|
||||||
|
|
||||||
if let Some(Ok(image)) = image.0.get() {
|
|
||||||
log::info!("requesting image to be dropped");
|
|
||||||
cx.drop_image(image, Some(window));
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageSource::Resource(image.1).remove_asset(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cache.insert(
|
|
||||||
hash,
|
|
||||||
(
|
|
||||||
gpui::ImageCacheItem::Loading(task.clone()),
|
|
||||||
resource.clone(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
let entity = window.current_view();
|
|
||||||
|
|
||||||
window
|
|
||||||
.spawn(cx, async move |cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
if let Err(err) = result {
|
|
||||||
log::error!("error loading image into cache: {:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.on_next_frame(move |_, cx| {
|
|
||||||
cx.notify(entity);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
pub use caching::*;
|
|
||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
@@ -7,7 +6,6 @@ pub use parser::*;
|
|||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
pub use range::*;
|
pub use range::*;
|
||||||
|
|
||||||
mod caching;
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ pub const USER_KEYRING: &str = "Coop User Credential";
|
|||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
/// Default image cache size
|
|
||||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
|
|||||||
+40
-40
@@ -4,18 +4,18 @@ use ::settings::AppSettings;
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::{CoopImageCache, download_dir};
|
use common::download_dir;
|
||||||
use device::{DeviceEvent, DeviceRegistry};
|
use device::{DeviceEvent, DeviceRegistry};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, Window, div, image_cache, px,
|
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{PersonRegistry, shorten_pubkey};
|
use person::{PersonRegistry, shorten_pubkey};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -64,9 +64,6 @@ pub struct Workspace {
|
|||||||
/// App's Dock Area
|
/// App's Dock Area
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
|
||||||
/// App's Image Cache
|
|
||||||
image_cache: Entity<CoopImageCache>,
|
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
@@ -82,7 +79,6 @@ impl Workspace {
|
|||||||
|
|
||||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
||||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||||
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -233,7 +229,6 @@ impl Workspace {
|
|||||||
Self {
|
Self {
|
||||||
sidebar,
|
sidebar,
|
||||||
dock,
|
dock,
|
||||||
image_cache,
|
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -380,14 +375,8 @@ impl Workspace {
|
|||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
}
|
}
|
||||||
Command::Update => {
|
Command::Update => {
|
||||||
// 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) {
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
auto_updater.update(cx, |this, cx| {
|
auto_updater.update(cx, |this, cx| this.check(cx));
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,6 +605,7 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let auto_updater = AutoUpdater::try_global(cx);
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -628,20 +618,35 @@ impl Workspace {
|
|||||||
let profile = persons.read(cx).get(&public_key, cx);
|
let profile = persons.read(cx).get(&public_key, cx);
|
||||||
let announcement = profile.announcement();
|
let announcement = profile.announcement();
|
||||||
|
|
||||||
// Update status is only shown when auto-update is available. On
|
let updater_status = auto_updater.as_ref().and_then(|updater| {
|
||||||
// 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);
|
let updater = updater.read(cx);
|
||||||
(!updater.idle(cx)).then(|| updater.status(cx))
|
(!updater.idle()).then(|| updater.status())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let staged_update = auto_updater
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|updater| updater.read(cx).staged());
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when_some(updater_status, |this, status| {
|
.when_some(updater_status, |this, status| {
|
||||||
this.child(div().text_xs().italic().child(status))
|
this.child(div().text_xs().italic().child(status))
|
||||||
})
|
})
|
||||||
|
.when(staged_update, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("restart-to-update")
|
||||||
|
.label("Restart to Update")
|
||||||
|
.tooltip("Quit and relaunch into the installed update")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click(cx.listener(|_this, _event, _window, cx| {
|
||||||
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
|
auto_updater.update(cx, |this, cx| this.restart(cx));
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(nip4e_enabled, |this| {
|
.when(nip4e_enabled, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("key")
|
Button::new("key")
|
||||||
@@ -778,31 +783,26 @@ impl Render for Workspace {
|
|||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
image_cache(self.image_cache.clone())
|
v_flex()
|
||||||
.relative()
|
|
||||||
.size_full()
|
.size_full()
|
||||||
|
// Title Bar
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
TitleBar::new()
|
||||||
|
.child(self.titlebar_left(cx))
|
||||||
|
.child(self.titlebar_right(cx)),
|
||||||
|
)
|
||||||
|
// Main
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
// Title Bar
|
|
||||||
.child(
|
.child(
|
||||||
TitleBar::new()
|
div()
|
||||||
.child(self.titlebar_left(cx))
|
.flex_shrink_0()
|
||||||
.child(self.titlebar_right(cx)),
|
.h_full()
|
||||||
|
.w(SIDEBAR_WIDTH)
|
||||||
|
.child(self.sidebar.clone()),
|
||||||
)
|
)
|
||||||
// Main
|
.child(self.dock.clone()),
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.h_full()
|
|
||||||
.w(SIDEBAR_WIDTH)
|
|
||||||
.child(self.sidebar.clone()),
|
|
||||||
)
|
|
||||||
.child(self.dock.clone()),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Notifications
|
// Notifications
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Task, TextAlign, Window, div, rems,
|
Task, TextAlign, Window, div, rems, retain_all,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -297,6 +297,7 @@ impl Focusable for ContactListPanel {
|
|||||||
impl Render for ContactListPanel {
|
impl Render for ContactListPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.image_cache(retain_all("contact-list-panel"))
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use anyhow::Error;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||||
Window, div,
|
Window, div, retain_all,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -321,6 +321,7 @@ impl Render for ProfilePanel {
|
|||||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.image_cache(retain_all("profile-panel"))
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::{DebouncedDelay, TimestampExt, coop_cache};
|
use common::{DebouncedDelay, TimestampExt};
|
||||||
use entry::RoomEntry;
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
||||||
Window, div, uniform_list,
|
Window, div, retain_all, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
|
use state::{FIND_DELAY, NostrRegistry};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
@@ -521,7 +521,7 @@ impl Render for Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE))
|
.image_cache(retain_all("sidebar"))
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
|
|||||||
Reference in New Issue
Block a user