2 Commits
Author SHA1 Message Date
reya 9fa4f4f2ff add speech component 2026-10-27 09:52:28 +07:00
reya 6fdbedac14 clean up 2026-10-27 09:17:11 +07:00
48 changed files with 645 additions and 6674 deletions
+3
View File
@@ -20,6 +20,9 @@ jobs:
- platform: windows-arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-15-intel
target: x86_64-apple-darwin
- platform: macos-arm64
os: macos-latest
target: aarch64-apple-darwin
+2 -3
View File
@@ -39,9 +39,8 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@master
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-10-31
targets: wasm32-unknown-unknown
components: llvm-tools-preview
@@ -52,4 +51,4 @@ jobs:
env:
CC_wasm32_unknown_unknown: clang
AR_wasm32_unknown_unknown: llvm-ar
run: cargo +nightly-2026-10-31 check -p coop_web --target wasm32-unknown-unknown
run: cargo +nightly check -p coop_web --target wasm32-unknown-unknown
Generated
+232 -1811
View File
File diff suppressed because it is too large Load Diff
+16 -29
View File
@@ -4,26 +4,27 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "2.0.0-alpha3"
version = "2.0.0-alpha2"
edition = "2024"
publish = false
[workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.10" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
gpui = { package = "gpui-pre", version = "0.3.8" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
gpui_tokio = { path = "crates/gpui_tokio" }
gpui-base = "0.7.1"
gpui-kit = "0.7.3"
gpui-base = "0.8.1"
gpui-kit = "0.8.1"
native-theme = { version = "0.7", features = ["native", "watch"] }
native-theme-gpui = { version = "0.7", default-features = false, features = ["system-fonts"] }
# Native OS theme reading; the connector is written against gpui-component 0.7.x
# and gpui-pre 0.3.8, both satisfied by the versions above (0.7.3 / 0.3.9).
native-theme = { version = "0.6.1", features = ["watch"] }
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
@@ -34,7 +35,7 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
@@ -48,16 +49,8 @@ hmac = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
# Cashu (NIP-60). From git: the published 0.18.x line targets nostr 0.44 and
# cannot unify with the git rust-nostr used here, while main targets 0.45.x
# (redirected to this repo by the [patch.crates-io] section below). redb
# storage: pure Rust, avoids the libsqlite3-sys links clash with nostr-sqlite.
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
# Others
anyhow = "1.0.44"
tts = { git = "https://github.com/ndarilek/tts-rs" }
chrono = { version = "0.4.38", features = ["wasmbind"] }
futures = "0.3"
itertools = "0.13.0"
@@ -73,12 +66,6 @@ webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
instant = "0.1"
# cdk's registry nostr/nostr-sdk requirements are redirected onto the same
# git repo coop already uses, keeping a single copy of each in the graph.
[patch.crates-io]
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
[profile.release]
strip = true
opt-level = "z"
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M10.75 14.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.25 7.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.75 10.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M21.25 12.75V15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 5.25V3.75H7M11.25 5.25V3.75H7M7 3.75V12.25M7 12.25H5.25M7 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 726 B

+3 -3
View File
@@ -37,8 +37,9 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
};
let arch = match (os, arch) {
// cargo-packager names the disk images `aarch64`.
// 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",
@@ -242,7 +243,7 @@ mod tests {
#[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"), None);
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
assert_eq!(
selected("linux", "aarch64"),
Some("coop-linux-aarch64.tar.gz")
@@ -257,7 +258,6 @@ mod tests {
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", "x86_64"), None);
assert_eq!(asset_filter_for("macos", "riscv64"), None);
}
+1 -24
View File
@@ -947,11 +947,7 @@ async fn extract_rumor(
let Some(device_signer) = device_signer else {
return Err(error);
};
match try_unwrap_with(device_signer, gift_wrap).await {
Ok(unwrapped) => unwrapped,
// The wrap may be sealed to a key retired by a rotation
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
}
try_unwrap_with(device_signer, gift_wrap).await?
}
};
let mut rumor = unwrapped.rumor;
@@ -1007,25 +1003,6 @@ async fn try_unwrap_with(
})
}
/// Unwrap with an encryption key retired by a rotation; peers may still seal
/// to the previous key until their announcement cache refreshes
async fn try_unwrap_archived(
client: &Client,
signer: &UniversalSigner,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
for keys in DeviceRegistry::archived_keys(client, signer).await? {
let archived = UniversalSigner::new(keys);
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
return Ok(unwrapped);
}
}
Err(anyhow!(
"No archived encryption key could unwrap the gift wrap"
))
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string();
-24
View File
@@ -16,7 +16,6 @@ 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 yet. Set one up in the settings to message them.";
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
/// The outcome of delivering an encrypted rumor to a single receiver.
#[derive(Debug, Clone)]
@@ -554,7 +553,6 @@ impl Room {
let config = self.config.clone();
let device = DeviceRegistry::global(cx);
let resyncing = device.read(cx).is_resyncing();
let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx);
@@ -576,28 +574,6 @@ impl Room {
Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind();
// Block sends while the local encryption key is stale against the
// current announcement; the replacement is being synced from
// another device
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
let mut reports: Vec<SendReport> = receivers
.into_iter()
.map(|receiver| SendReport {
receiver: receiver.public_key(),
result: Err(KEY_STALE.into()),
})
.collect();
if config.backup() {
reports.push(SendReport {
receiver: sender.public_key(),
result: Err(KEY_STALE.into()),
});
}
return reports;
}
// Deliver to every receiver concurrently
let sends = receivers.into_iter().map(|receiver| {
deliver(
-2
View File
@@ -6,7 +6,6 @@ publish.workspace = true
[dependencies]
state = { path = "../state" }
speech = { path = "../speech" }
ui = { path = "../ui" }
gpui-kit = { workspace = true, features = ["speech"] }
common = { path = "../common" }
@@ -15,7 +14,6 @@ chat = { path = "../chat" }
settings = { path = "../settings" }
gpui.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
+1 -4
View File
@@ -1,4 +1,4 @@
use gpui::{Action, actions};
use gpui::Action;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::SignerKind;
@@ -15,6 +15,3 @@ pub enum Command {
Njump(PublicKey),
Trace(EventId),
}
// Start or stop dictation in the focused composer
actions!(chat, [ToggleDictation]);
+52 -159
View File
@@ -1,6 +1,5 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock};
use std::time::Duration;
pub use actions::*;
use anyhow::Error;
@@ -30,14 +29,13 @@ use person::{Directory, Person};
use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use speech::speaker::{END_GRACE, Speaker};
use state::{
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
};
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage,
group_reactions, opens_run, rendered_text,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
opens_run, rendered_text,
};
use crate::file::*;
@@ -46,7 +44,8 @@ use crate::file::*;
const PRIVATE_NOTICE: &str =
"This conversation is private. Only members can see each other's messages.";
/// Regex matching strings that consist entirely of emoji characters.
/// Regex matching strings that consist entirely of emoji characters,
/// zero-width joiners, variation selectors, and keycap combiners.
static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
@@ -65,49 +64,62 @@ pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity
pub struct ChatPanel {
id: SharedString,
focus_handle: FocusHandle,
/// Chat Room
room: WeakEntity<Room>,
/// Person directory shared with the room
directory: Entity<Directory>,
/// Message list state
list_state: ListState,
/// All messages (sorted by created_at)
messages: Vec<Message>,
/// O(1) message lookup by EventId
message_index: HashMap<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
/// Mapping message ids to their rendered texts
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
/// Mapping message (rumor event) ids to their reports
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
/// Chat input state
input: Entity<TextareaState>,
/// Dictation session for the chat input
speech: Entity<SpeechState>,
/// When the running dictation started, for the elapsed counter
speech_started: Option<instant::Instant>,
/// Reads messages aloud
speaker: Speaker,
/// The message being revealed in step with speech, with its estimated duration
speech_reveal: Option<(EventId, Duration)>,
/// Fallback reveal end for backends that cannot report utterance end
reveal_timeout: Option<Task<Result<(), Error>>>,
/// Subject input state
subject_input: Entity<InputState>,
/// Subject bar visibility
subject_bar: Entity<bool>,
/// Replies to
replies_to: Entity<HashSet<EventId>>,
/// Media Attachment
attachments: Entity<Vec<Url>>,
/// Uploaded, encrypted file attachments which are not sent yet
encrypted_attachments: Entity<Vec<PendingFile>>,
/// Decrypted attachments of file messages, by message id
decrypted_files: HashMap<EventId, DecryptedFile>,
/// Upload state
uploading: bool,
/// Async operations
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
subscriptions: SmallVec<[Subscription; 4]>,
}
@@ -153,17 +165,7 @@ impl ChatPanel {
});
// Define dictation state
let speech = cx.new(|cx| {
let state = SpeechState::new(cx)
.recognizer(speech::recognizer())
// Batch transcription of a long recording can take a while.
.stop_timeout(Duration::from_secs(180));
#[cfg(target_arch = "wasm32")]
let state = state.input(speech::input());
state
});
let speech = cx.new(SpeechState::new);
// Define subject input state
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
@@ -194,25 +196,14 @@ impl ChatPanel {
// Insert the dictated text into the chat input
cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| {
match event {
SpeechEvent::Started => {
this.speech_started = Some(instant::Instant::now());
cx.notify();
}
SpeechEvent::Final(text) => {
this.speech_started = None;
if !text.is_empty() {
this.input
.update(cx, |input, cx| input.insert(text.clone(), window, cx));
}
SpeechEvent::Final(text) if !text.is_empty() => {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
SpeechEvent::Error(error) => {
this.speech_started = None;
window.push_notification(Notification::error(error.to_string()), cx);
}
SpeechEvent::Cancelled => {
this.speech_started = None;
}
_ => {}
};
}),
@@ -225,20 +216,6 @@ impl ChatPanel {
this.get_messages(window, cx);
});
// End the speech reveal when the utterance finishes ahead of its estimate
let speaker = Speaker::new();
let end_events = speaker.end_events();
let reveal_end_task = cx.spawn(async move |this, cx| {
while end_events.recv_async().await.is_ok() {
this.update(cx, |this, cx| {
this.speech_reveal = None;
cx.notify();
})?;
}
Ok(())
});
Self {
focus_handle: cx.focus_handle(),
id,
@@ -250,10 +227,6 @@ impl ChatPanel {
list_state,
input,
speech,
speech_started: None,
speaker,
speech_reveal: None,
reveal_timeout: None,
subject_input,
subject_bar,
replies_to,
@@ -264,7 +237,7 @@ impl ChatPanel {
reports_by_id,
uploading: false,
subscriptions,
tasks: vec![reveal_end_task],
tasks: vec![],
}
}
@@ -883,17 +856,6 @@ impl ChatPanel {
self.directory.read(cx).person(public_key, cx)
}
fn toggle_dictation(
&mut self,
_: &ToggleDictation,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
@@ -1193,15 +1155,6 @@ impl ChatPanel {
nostr.read(cx).current_user(),
));
let body = match self
.speech_reveal
.as_ref()
.filter(|(reveal_id, _)| *reveal_id == id)
{
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
None => rendered_text,
};
MessageRow::new(ix)
.show_author(show_author)
.hide_avatar(hide_avatar)
@@ -1226,7 +1179,7 @@ impl ChatPanel {
.when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx))
})
.when(message.file.is_none(), |this| this.child(body))
.when(message.file.is_none(), |this| this.child(rendered_text))
.child(MessageMedia::new(&message.media))
.when_some(message.file.as_ref(), |this, file| {
this.child(self.render_message_file(&id, file, cx))
@@ -1474,43 +1427,6 @@ impl ChatPanel {
actions
};
let speaking = self.speech_reveal.is_some();
let speech_id = *id;
let actions = actions.speech(
speaking,
cx.listener(move |this, _event, window, cx| {
let text = this.message(&speech_id).map(|message| message.preview());
if let Some(text) = text {
match this.speaker.speak(&text) {
Ok(duration) => {
this.speech_reveal = Some((speech_id, duration));
// Re-enable speaking when no end event ever arrives
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
this.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}
}),
);
actions.dropdown(move |menu, _window, _cx| {
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
@@ -1779,12 +1695,7 @@ impl ChatPanel {
items
}
fn render_config_menu(
&self,
disabled: bool,
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let (backup, signer_kind) = self
.room
.read_with(cx, |this, _cx| {
@@ -1797,7 +1708,6 @@ impl ChatPanel {
.icon(IconName::Settings2)
.tooltip("Configuration")
.ghost()
.disabled(disabled)
.dropdown_menu(move |this, _window, _cx| {
let auto = matches!(signer_kind, SignerKind::Auto);
let encryption = matches!(signer_kind, SignerKind::Encryption);
@@ -1828,12 +1738,7 @@ impl ChatPanel {
})
}
fn render_emoji_menu(
&self,
disabled: bool,
_window: &Window,
cx: &Context<Self>,
) -> impl IntoElement {
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
@@ -1850,7 +1755,6 @@ impl ChatPanel {
Button::new("emoji")
.icon(IconName::Emoji)
.ghost()
.disabled(disabled)
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
@@ -1924,12 +1828,11 @@ impl Render for ChatPanel {
let is_typing = !self.input.read(cx).value().trim().is_empty();
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
let dictating = self.speech.read(cx).status().is_active();
let capturing = self.speech.read(cx).status().is_capturing();
v_flex()
.image_cache(retain_all(self.id.clone()))
.on_action(cx.listener(Self::on_command))
.on_action(cx.listener(Self::toggle_dictation))
.size_full()
.when(*self.subject_bar.read(cx), |this| {
this.child(
@@ -2006,52 +1909,42 @@ impl Render for ChatPanel {
.icon(IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(dictating || self.uploading)
.disabled(self.uploading)
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.upload(window, cx);
})),
)
.when(dictating, |this| {
this.child(
h_flex()
.h_8()
.flex_1()
.gap_2()
.pl_1()
.child(SpeechWaveform::new(&self.speech).small().flex_1())
.when_some(self.speech_started, |this, started| {
this.child(speech::elapsed(started, cx))
}),
)
})
.when(!dictating, |this| {
this.child(
Textarea::new(&self.input)
.appearance(false)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
})
.child(
Textarea::new(&self.input)
.appearance(false)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
.child(
h_flex()
.items_end()
.gap_1()
.pl_1()
.when(capturing, |this| {
this.child(
SpeechWaveform::new(&self.speech).xsmall().w_12(),
)
})
.child(SpeechButton::new(&self.speech)),
)
.child(
h_flex()
.pl_1()
.gap_1()
.child(self.render_emoji_menu(dictating, window, cx))
.child(self.render_config_menu(dictating, window, cx))
.child(self.render_emoji_menu(window, cx))
.child(self.render_config_menu(window, cx))
.child(
Button::new("send")
.icon(IconName::PaperPlaneFill)
.disabled(dictating || self.uploading)
.disabled(self.uploading)
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.send_text_message(window, cx);
-2
View File
@@ -11,13 +11,11 @@ common = { path = "../common" }
nip29 = { path = "../nip29" }
person = { path = "../person" }
settings = { path = "../settings" }
speech = { path = "../speech" }
state = { path = "../state" }
gpui-kit = { workspace = true, features = ["speech"] }
ui = { path = "../ui" }
gpui.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
log.workspace = true
+46 -127
View File
@@ -1,14 +1,11 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::Result;
use chat_ui::ToggleDictation;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState,
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list,
px,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render,
SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
@@ -26,9 +23,8 @@ use person::Directory;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use speech::speaker::Speaker;
use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, RenderedText, RevealText, WelcomeMessage, rendered_text};
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
mod details;
@@ -319,14 +315,6 @@ pub struct GroupPanel {
input: Entity<TextareaState>,
/// Dictation session for the composer input
speech: Entity<SpeechState>,
/// When the running dictation started, for the elapsed counter
speech_started: Option<instant::Instant>,
/// Reads messages aloud
speaker: Speaker,
/// The message being revealed in step with speech, with its estimated duration
speech_reveal: Option<(EventId, Duration)>,
/// Fallback reveal end for backends that cannot report utterance end
reveal_timeout: Option<Task<Result<()>>>,
/// Uploaded, non-encrypted attachments waiting to be sent.
attachments: Vec<Url>,
/// Whether an upload is in flight.
@@ -353,17 +341,7 @@ impl GroupPanel {
.clean_on_escape()
});
let speech = cx.new(|cx| {
let state = SpeechState::new(cx)
.recognizer(speech::recognizer())
// Batch transcription of a long recording can take a while.
.stop_timeout(Duration::from_secs(180));
#[cfg(target_arch = "wasm32")]
let state = state.input(speech::input());
state
});
let speech = cx.new(SpeechState::new);
let mut subscriptions = smallvec![];
@@ -378,26 +356,14 @@ impl GroupPanel {
subscriptions.push(
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
match event {
SpeechEvent::Started => {
this.speech_started = Some(instant::Instant::now());
cx.notify();
}
SpeechEvent::Final(text) => {
this.speech_started = None;
if !text.is_empty() {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
SpeechEvent::Final(text) if !text.is_empty() => {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
SpeechEvent::Error(error) => {
this.speech_started = None;
window.push_notification(Notification::error(error.to_string()), cx);
}
SpeechEvent::Cancelled => {
this.speech_started = None;
}
_ => {}
};
}),
@@ -426,24 +392,9 @@ impl GroupPanel {
}
},
));
this.track_participants(cx);
});
// End the speech reveal when the utterance finishes ahead of its estimate
let speaker = Speaker::new();
let end_events = speaker.end_events();
let reveal_end_task = cx.spawn(async move |this, cx| {
while end_events.recv_async().await.is_ok() {
this.update(cx, |this, cx| {
this.speech_reveal = None;
cx.notify();
})?;
}
Ok(())
});
Self {
let mut this = Self {
focus_handle: cx.focus_handle(),
group: group.downgrade(),
directory: Directory::new(cx),
@@ -454,18 +405,19 @@ impl GroupPanel {
rendered_texts_by_id: BTreeMap::new(),
input,
speech,
speech_started: None,
speaker,
speech_reveal: None,
reveal_timeout: None,
attachments: Vec::new(),
uploading: false,
tasks: smallvec![reveal_end_task],
tasks: smallvec![],
_subscriptions: subscriptions,
}
};
this.track_participants(cx);
this
}
/// Track the keys rendered by this panel.
/// Track the keys rendered by this panel: members, message authors, and
/// activity subjects
fn track_participants(&mut self, cx: &mut Context<Self>) {
let Some(group) = self.group.upgrade() else {
return;
@@ -1043,29 +995,22 @@ impl GroupPanel {
let nostr = NostrRegistry::global(cx);
let directory = self.directory.clone();
let content = match self
.speech_reveal
.as_ref()
.filter(|(reveal_id, _)| *reveal_id == message.id)
{
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
None => self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx),
};
let content = self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx);
let admin = nostr
.read(cx)
@@ -1086,24 +1031,12 @@ impl GroupPanel {
panel: cx.weak_entity(),
admin,
pinned,
speaking: self.speech_reveal.is_some(),
persons: self.directory.clone(),
},
cx,
)
}
fn toggle_dictation(
&mut self,
_ev: &ToggleDictation,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
let notice = self.group.upgrade().and_then(|group| {
let group = group.read(cx);
@@ -1129,8 +1062,6 @@ impl GroupPanel {
return self.render_notice(&notice, cx);
}
let dictating = self.speech.read(cx).status().is_active();
v_flex()
.flex_shrink_0()
.w_full()
@@ -1149,34 +1080,23 @@ impl GroupPanel {
.icon(ui::IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(dictating || self.uploading)
.disabled(self.uploading)
.ghost()
.on_click(cx.listener(|this, _event, window, cx| {
this.upload(window, cx);
})),
)
.when(dictating, |this| {
this.child(
h_flex()
.h_8()
.flex_1()
.gap_2()
.child(SpeechWaveform::new(&self.speech).small().flex_1())
.when_some(self.speech_started, |this, started| {
this.child(speech::elapsed(started, cx))
}),
)
})
.when(!dictating, |this| {
this.child(
Textarea::new(&self.input)
.appearance(false)
.bg(cx.theme().input)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
.child(
Textarea::new(&self.input)
.appearance(false)
.bg(cx.theme().input)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
.when(self.speech.read(cx).status().is_capturing(), |this| {
this.child(SpeechWaveform::new(&self.speech).xsmall().w_12())
})
.child(SpeechButton::new(&self.speech))
.child(
@@ -1184,7 +1104,7 @@ impl GroupPanel {
.icon(ui::IconName::PaperPlaneFill)
.tooltip("Send")
.ghost()
.disabled(dictating || self.uploading)
.disabled(self.uploading)
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
@@ -1382,7 +1302,6 @@ impl Render for GroupPanel {
v_flex()
.size_full()
.min_w_0()
.on_action(cx.listener(Self::toggle_dictation))
.child(self.render_encryption_notice(cx))
.child(self.render_banner(cx))
.child(self.render_pins(cx))
+6 -44
View File
@@ -8,17 +8,15 @@ use gpui::{
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind};
use person::Directory;
use settings::AppSettings;
use speech::speaker::END_GRACE;
use state::NostrRegistry;
use ui::{
Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview,
group_reactions, react_menu,
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
react_menu,
};
use crate::GroupPanel;
@@ -28,8 +26,6 @@ pub(crate) struct Actions {
pub panel: WeakEntity<GroupPanel>,
pub admin: bool,
pub pinned: bool,
/// Whether a message is currently being spoken, disabling the speech buttons
pub speaking: bool,
pub persons: Entity<Directory>,
}
@@ -91,44 +87,10 @@ pub(crate) fn render(
let admin = actions.admin;
let pinned = actions.pinned;
let group = actions.group;
let speech_panel = actions.panel;
let panel = speech_panel.clone();
let speech_text = message.preview();
let speech_id = message.id;
let speaking = actions.speaking;
let row = row.overlay(
MessageActions::new().speech(speaking, move |_event, _window, cx| {
_ = speech_panel.update_in(cx, |panel, window, cx| {
match panel.speaker.speak(&speech_text) {
Ok(duration) => {
panel.speech_reveal = Some((speech_id, duration));
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
panel.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
});
}),
);
let panel = actions.panel;
// Reading the panel here would re-enter its ongoing update, so resolve
// the current user's custom emojis through the shared person directory.
let custom = nostr
.read(cx)
.current_user()
+165 -488
View File
@@ -1,10 +1,9 @@
use std::cell::Cell;
use std::collections::{HashSet, VecDeque};
use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
@@ -17,14 +16,12 @@ use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
use ui::Avatar;
mod storage;
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
const IDENTIFIER: &str = "coop:device";
pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
@@ -62,18 +59,10 @@ impl DeviceEvent {
#[derive(Debug)]
pub struct DeviceRegistry {
signer: Entity<Option<UniversalSigner>>,
/// Ephemeral client key for the in-flight key request; never persisted
app_keys: Option<Keys>,
/// The key request currently awaiting user approval on this device
pending_request: Option<PendingRequest>,
/// Handled key-sync request ids, bounded and persisted
processed_requests: VecDeque<EventId>,
/// Whether there is a pending request for encryption key approval
pub(crate) pending_request: bool,
/// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>,
/// Whether the local key is stale against the current announcement.
resync: Arc<AtomicBool>,
/// Whether a key rotation is in flight, to coalesce overlapping requests
rotating: bool,
/// Person directory for requesters shown in notifications
directory: Entity<Directory>,
/// Relay notification listener
@@ -119,21 +108,6 @@ impl DeviceRegistry {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
// The old account's state must not leak into the new one
this.announcement_existed.store(false, Ordering::Relaxed);
this.resync.store(false, Ordering::Relaxed);
// The in-flight request and client key belong to the old account
this.app_keys = None;
if this.pending_request.take().is_some() {
// The old approval must not target the new account's signer
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.handle_notifications(cx);
this.get_announcement(cx);
}
@@ -141,19 +115,15 @@ impl DeviceRegistry {
);
cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| {
this.handle_notifications(cx);
});
entity
.update(cx, |this, cx| this.handle_notifications(cx))
.ok();
});
Self {
signer,
app_keys: None,
pending_request: None,
processed_requests: VecDeque::new(),
pending_request: false,
announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)),
rotating: false,
directory: Directory::new(cx),
notification_listener: None,
tasks: vec![],
@@ -171,8 +141,6 @@ impl DeviceRegistry {
let announcement_existed = self.announcement_existed.clone();
let (tx, rx) = flume::bounded::<Event>(100);
let read = credentials::read_processed_requests(cx);
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
@@ -207,21 +175,6 @@ impl DeviceRegistry {
}));
self.tasks.push(cx.spawn(async move |this, cx| {
// Hydrate the processed-request log before routing any events
match read.await {
Ok(bytes) => {
let processed = bytes
.map(|bytes| parse_processed_requests(&bytes))
.unwrap_or_default();
this.update(cx, |this, _cx| {
this.processed_requests = processed;
})
.ok();
}
Err(e) => log::error!("Failed to read processed key-sync requests: {e}"),
}
while let Ok(event) = rx.recv_async().await {
match event.kind {
Kind::Custom(10044) => {
@@ -238,7 +191,7 @@ impl DeviceRegistry {
// New response event from the master device
Kind::Custom(4455) => {
this.update(cx, |this, cx| {
this.handle_transfer(event, cx);
this.extract_encryption(event, cx);
})?;
}
_ => {}
@@ -248,62 +201,12 @@ impl DeviceRegistry {
}));
}
/// Record a handled key-sync request id, persisting a bounded log
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) {
if self.processed_requests.contains(&event_id) {
return;
}
self.processed_requests.push_back(event_id);
while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT {
self.processed_requests.pop_front();
}
let bytes: Vec<u8> = self
.processed_requests
.iter()
.flat_map(|id| id.to_hex().into_bytes())
.collect();
let write = credentials::write_processed_requests(cx, &bytes);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist processed key-sync requests: {e}");
}
})
.detach();
}
/// Withdraw the pending approval.
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
let Some(pending) = self.pending_request.take() else {
return;
};
self.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
/// Set whether there is a pending request for encryption key approval
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
self.pending_request = pending;
cx.notify();
}
/// Route a key transfer.
fn handle_transfer(&mut self, event: Event, cx: &mut Context<Self>) {
let resolved = self
.pending_request
.as_ref()
.is_some_and(|pending| transfer_addresses(&event, pending.client_key));
if resolved {
self.resolve_pending_request(cx);
return;
}
self.extract_encryption(event, cx);
}
/// Get the signer
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
self.signer.read(cx).clone()
@@ -315,16 +218,9 @@ impl DeviceRegistry {
*this = Some(UniversalSigner::new(new_signer));
cx.notify();
});
// The local key is now consistent with the announcement
self.resync.store(false, Ordering::Release);
cx.emit(DeviceEvent::Set);
}
/// Whether the local encryption key is stale against the current announcement.
pub fn is_resyncing(&self) -> bool {
self.resync.load(Ordering::Acquire)
}
/// Backup the encryption's secret key, returning the `nsec1...` content
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
let nostr = NostrRegistry::global(cx);
@@ -427,88 +323,6 @@ impl DeviceRegistry {
}));
}
/// Replace the encryption key with a new one, archiving the old key.
pub fn rotate(&mut self, cx: &mut Context<Self>) {
if self.rotating {
return;
}
self.rotating = true;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let result: Result<Keys, Error> = async {
// Archive the current key before replacing it
if let Ok(old) = get_keys(&client, &signer).await {
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
}
let created = this
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
.await?;
this.update(cx, |this, cx| {
this.set_signer(created.clone(), cx);
this.wait_for_request(cx);
})?;
Ok(created)
}
.await;
this.update(cx, |this, cx| {
this.rotating = false;
if let Err(e) = result {
cx.emit(DeviceEvent::error(e.to_string()));
}
})
.ok();
Ok(())
}));
}
/// Rotate the encryption key when the configured interval has elapsed.
///
/// Runs after the announcement reconciled, outside the blocking boot path.
pub fn rotate_if_due(&mut self, cx: &mut Context<Self>) {
let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else {
return;
};
let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let Ok(keys) = load_device_keys(&client, &signer).await else {
return Ok(());
};
let elapsed = Timestamp::now()
.as_secs()
.saturating_sub(keys.created_at.as_secs());
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
return Ok(());
}
this.update(cx, |this, cx| this.rotate(cx)).ok();
Ok(())
}));
}
/// Load encryption keys retired by a rotation from the local database.
pub async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
storage::archived_keys(client, signer).await
}
/// Create new encryption key and announce it to user's relay list
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx);
@@ -517,7 +331,6 @@ impl DeviceRegistry {
let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key();
let created_at = Timestamp::now();
cx.background_spawn(async move {
// Construct an announcement event
@@ -527,7 +340,7 @@ impl DeviceRegistry {
.await?;
// Persist the key locally before announcing it
set_keys(&client, &signer, &secret, created_at).await?;
set_keys(&client, &signer, &secret).await?;
// Publish announcement
client
@@ -570,12 +383,10 @@ impl DeviceRegistry {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.wait_for_request(cx);
this.rotate_if_due(cx);
})?;
} else {
this.update(cx, |this, cx| {
this.resync.store(true, Ordering::Release);
this.request(Some(device_pubkey), cx);
this.request(cx);
})?;
}
Ok(())
@@ -603,17 +414,15 @@ impl DeviceRegistry {
}
/// Request encryption keys from other device
pub(crate) fn request(
&mut self,
expected_encryption: Option<PublicKey>,
cx: &mut Context<Self>,
) {
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys = self.app_keys.get_or_insert_with(Keys::generate).clone();
let app_keys_task = get_or_init_app_keys(cx);
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let app_pubkey = app_keys.public_key();
let public_key = signer.get_public_key_async().await?;
@@ -625,7 +434,8 @@ impl DeviceRegistry {
.limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
// Only reuse the stored approval if its key is still valid
// Only reuse the stored approval if its key is still valid,
// otherwise it's stale and a fresh request is needed
if decrypt_approval(&client, &app_keys, public_key, &event)
.await
.is_ok()
@@ -634,26 +444,9 @@ impl DeviceRegistry {
}
}
// No usable approval event found, construct a request event.
//
// The `pubkey` tag is a compatibility alias for the draft's `P`,
// `relay` hints tell other devices where the response is accepted,
// `n` names the exact key this device expects to be transferred
let mut tags = vec![
Tag::custom("P", vec![app_pubkey]),
Tag::custom("pubkey", vec![app_pubkey]),
];
for url in write_relay_hints(&client, public_key).await? {
tags.push(Tag::custom("relay", vec![url.to_string()]));
}
if let Some(expected) = expected_encryption {
tags.push(Tag::custom("n", vec![expected]));
}
// No usable approval event found, construct a request event
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(tags)
.tags(vec![Tag::custom("P", vec![app_pubkey])])
.finalize_async(&signer)
.await?;
@@ -690,13 +483,19 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Cannot wait for approval without a client key");
return;
};
let app_keys_task = get_or_init_app_keys(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let app_keys = match app_keys_task.await {
Ok(app_keys) => app_keys,
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
return Ok(());
}
};
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::Requesting(authorization_code(
&app_keys.public_key(),
@@ -706,7 +505,7 @@ impl DeviceRegistry {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
// Only approvals addressed to this device's client key matter
// Only approvals addressed to this device's app key matter
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
@@ -730,18 +529,15 @@ impl DeviceRegistry {
/// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let app_keys_task = get_or_init_app_keys(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Ignoring key transfer addressed to another device");
return;
};
let approval_id = event.id;
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let identity = signer.get_public_key_async().await?;
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
@@ -753,14 +549,14 @@ impl DeviceRegistry {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
// The client key was consumed; start fresh on the next request
this.app_keys = None;
// Clean up our request and the approval event from relays
this.cleanup_sync_events(approval_id, cx);
})?;
}
Err(e) => {
log::warn!("Ignoring key transfer: {e}");
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
}
}
Ok(())
@@ -814,58 +610,40 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get user's write relays
let event = event.clone();
let request_id = event.id;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys
let keys = get_keys(&client, &signer).await?;
let secret = keys.secret_key().to_secret_hex();
let identity = signer.get_public_key_async().await?;
// Extract the target public key from the event tags
let target =
client_pubkey_from_event(&event).context("Target is not a valid public key")?;
let target = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Target is not a valid public key")?;
// The requester may pin the exact key it expects
if let Some(requested) = requested_encryption_key(&event)
&& requested != keys.public_key()
{
return Err(anyhow!(
"The requesting device expects an encryption key this device no longer holds"
));
}
// Encrypt to a fresh one-time keypair so the transfer event
// can never be decrypted with the long-lived encryption key.
let sender = Keys::generate();
let payload = sender.nip44_encrypt_async(&target, &secret).await?;
// Encrypt the device keys with the user's signer
let payload = keys.nip44_encrypt_async(&target, &secret).await?;
// Construct the response event
//
// P tag: the one-time sender key used for the encryption
// p tag: the account identity, for inbox routing
// p tag: the requester's client key
let response = EventBuilder::new(Kind::Custom(4455), payload)
// P tag: the current device's public key
// p tag: the requester's public key
let event = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![
Tag::custom("P", vec![sender.public_key().to_hex()]),
Tag::public_key(identity),
Tag::custom("P", vec![keys.public_key().to_hex()]),
Tag::public_key(target),
])
.finalize_async(&signer)
.await?;
let relays = merge_relays(
write_relay_hints(&client, identity).await?,
request_relay_hints(&event),
);
let send = client.send_event(&response);
if relays.is_empty() {
send.to_nip65().await?;
} else {
send.to(relays).await?;
}
// Send the response event to the user's relay list
client.send_event(&event).to_nip65().await?;
Ok(())
});
@@ -874,11 +652,8 @@ impl DeviceRegistry {
match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
// Record the request as handled so it cannot resurface
this.mark_request_processed(request_id, cx);
// Allow future requests to surface again
this.pending_request = None;
cx.notify();
this.set_pending_request(false, cx);
})?;
cx.update(|window, cx| {
@@ -903,70 +678,21 @@ impl DeviceRegistry {
/// Handle encryption request
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
let Some(client_key) = client_pubkey_from_event(&event) else {
log::warn!("Ignoring invalid encryption key request: no valid requester key");
// Ignore if there is already a pending request
if self.pending_request {
return;
};
}
self.set_pending_request(true, cx);
let read = credentials::read_processed_requests(cx);
cx.spawn(async move |this, cx| {
let processed = match read.await {
Ok(bytes) => bytes
.map(|bytes| parse_processed_requests(&bytes).contains(&event.id))
.unwrap_or(false),
Err(e) => {
log::error!("Failed to read processed key-sync requests: {e}");
false
}
};
this.update(cx, |this, cx| {
if processed || this.processed_requests.contains(&event.id) {
return;
}
match this.pending_request.take() {
// A retry signs a new request id but keeps the same client key
Some(mut pending) if pending.client_key == client_key => {
this.mark_request_processed(event.id, cx);
pending.event = event;
this.pending_request = Some(pending);
}
// A different client key is a remounted requester
other => {
if let Some(pending) = other {
this.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.show_approval(event, client_key, cx);
}
}
})
.ok();
})
.detach();
}
/// Build and surface the approval notification for a key request
fn show_approval(&mut self, event: Event, client_key: PublicKey, cx: &mut Context<Self>) {
let notification = match self.notification(event.clone(), cx) {
let notification = match self.notification(event, cx) {
Ok(notification) => notification,
Err(e) => {
log::warn!("Ignoring invalid encryption key request: {e}");
self.set_pending_request(false, cx);
return;
}
};
self.pending_request = Some(PendingRequest { event, client_key });
cx.notify();
if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx);
@@ -989,16 +715,18 @@ impl DeviceRegistry {
compare the code with the one shown on the requesting \
device.";
let requester =
client_pubkey_from_event(&event).context("Request has no valid requester key")?;
let requester = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Request has no valid requester key")?;
let code = authorization_code(&requester);
let code_label = code.clone();
let directory = self.directory.clone();
directory.update(cx, |directory, cx| {
directory.track([requester], cx);
});
directory.update(cx, |directory, cx| directory.track([requester], cx));
let code_label = code.clone();
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
@@ -1103,111 +831,6 @@ impl DeviceRegistry {
struct DeviceNotification;
/// A key request awaiting user approval on this device
#[derive(Debug)]
struct PendingRequest {
event: Event,
client_key: PublicKey,
}
/// Keep the durable processed-request log bounded
const PROCESSED_REQUESTS_LIMIT: usize = 256;
/// Parse concatenated hex event ids
fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
let Ok(hex) = std::str::from_utf8(bytes) else {
return VecDeque::new();
};
hex.as_bytes()
.as_chunks::<64>()
.0
.iter()
.filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok())
.collect()
}
/// Extract the client public key from an event.
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
let key = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok());
key.or_else(|| {
event
.tags
.iter()
.find(|tag| tag.kind() == "pubkey")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
})
}
/// Whether the transfer event is p-tagged to the given client key
fn transfer_addresses(event: &Event, client_key: PublicKey) -> bool {
event.tags.iter().any(|tag| {
tag.kind() == "p"
&& tag
.content()
.and_then(|content| PublicKey::parse(content).ok())
.is_some_and(|key| key == client_key)
})
}
/// Parse the `relay` hints attached to a key request.
fn request_relay_hints(event: &Event) -> Vec<RelayUrl> {
event
.tags
.iter()
.filter(|tag| tag.kind() == "relay")
.filter_map(|tag| {
tag.content()
.and_then(|content| RelayUrl::parse(content).ok())
})
.collect()
}
/// Parse the optional `n` tag of a key request.
fn requested_encryption_key(event: &Event) -> Option<PublicKey> {
event
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
}
/// Collect the user's NIP-65 write relays.
async fn write_relay_hints(client: &Client, identity: PublicKey) -> Result<Vec<RelayUrl>, Error> {
let filter = Filter::new()
.kind(Kind::RelayList)
.author(identity)
.limit(1);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
// Unmarked relays serve both reads and writes
Ok(nip65::extract_relay_list(&event)
.filter(|(_, metadata)| *metadata != Some(RelayMetadata::Read))
.map(|(url, _)| url)
.collect())
}
/// Merge relay lists, dropping duplicates
fn merge_relays(first: Vec<RelayUrl>, second: Vec<RelayUrl>) -> Vec<RelayUrl> {
first
.into_iter()
.chain(second)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
/// The first eight hexadecimal characters of the client public key.
fn authorization_code(public_key: &PublicKey) -> SharedString {
let hex = public_key.to_hex();
@@ -1215,6 +838,35 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
}
/// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = credentials::read_app_keys(cx);
cx.spawn(async move |cx| {
if let Ok(Some(secret)) = read.await
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
{
return Ok(keys);
}
// No stored keys found or invalid — generate new ones
let keys = Keys::generate();
let secret = keys.secret_key().to_secret_bytes();
cx.update(|cx| {
let write = credentials::write_app_keys(cx, &secret);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist app keys: {e}")
}
})
.detach();
});
Ok(keys)
})
}
/// Decrypt and validate an approval event against the current announcement.
async fn decrypt_approval(
client: &Client,
@@ -1222,7 +874,13 @@ async fn decrypt_approval(
identity: PublicKey,
event: &Event,
) -> Result<Keys, Error> {
let master = client_pubkey_from_event(event).context("Invalid event's tags")?;
let master = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let decrypted = app_keys
.nip44_decrypt_async(&master, event.content.as_str())
@@ -1231,52 +889,71 @@ async fn decrypt_approval(
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
// Reject approvals carrying a key that no longer matches the published announcement
// Reject approvals carrying a key that no longer matches the published announcement.
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(identity)
.limit(1);
let announcement = client
.database()
.query(filter.clone())
.await?
.into_iter()
.next();
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
let announcement = match announcement {
Some(announcement) => Some(announcement),
None => {
let mut stream = client
.stream_events(filter)
.timeout(Duration::from_secs(3))
.await?;
stream
.next()
.await
.map(|(_url, result)| result)
.transpose()?
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
}
};
let Some(announcement) = announcement else {
return Err(anyhow!(
"No encryption announcement available to validate the approval"
));
};
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
}
Ok(keys)
}
/// Encrypt and store device keys in the local database.
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
if let Some(event) = client.database().query(filter).await?.first() {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
-174
View File
@@ -1,174 +0,0 @@
use anyhow::{Error, anyhow};
use nostr_sdk::prelude::*;
/// The storage identifier for the device encryption key
const IDENTIFIER: &str = "coop:device";
/// The storage identifier for encryption keys retired by a rotation
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
/// Keep the archived key list bounded
const ARCHIVE_LIMIT: usize = 10;
/// Seconds in a day, for rotation intervals
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
/// Device encryption key material with its creation time
#[derive(Debug, Clone)]
pub(crate) struct DeviceKeys {
pub(crate) keys: Keys,
pub(crate) created_at: Timestamp,
}
/// Encode the stored key content: secret hex and creation time
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
format!("{secret}\n{}", created_at.as_secs())
}
/// Parse stored key content.
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
match content.split_once('\n') {
Some((secret, created_at)) => {
let created_at = created_at
.trim()
.parse::<u64>()
.unwrap_or_else(|_| Timestamp::now().as_secs());
Ok((
SecretKey::from_hex(secret)?,
Timestamp::from_secs(created_at),
))
}
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
}
}
/// Parse archived secrets from stored content, one hex secret per line
fn parse_archived_keys(content: &str) -> Vec<Keys> {
content
.lines()
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
.map(Keys::new)
.collect()
}
/// Load the device keys with their creation time from the local database.
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Err(anyhow!("Key not found"));
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let (secret, created_at) = parse_keys_content(&content)?;
Ok(DeviceKeys {
keys: Keys::new(secret),
created_at,
})
}
/// Encrypt and store device keys in the local database.
pub(crate) async fn set_keys<S>(
client: &Client,
signer: &S,
secret: &str,
created_at: Timestamp,
) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
.await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Append an encryption secret to the bounded archive of retired keys.
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
{
Some(event) => {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
content.lines().map(str::to_string).collect()
}
None => vec![],
};
archived.insert(0, secret.to_string());
archived.truncate(ARCHIVE_LIMIT);
let content = signer
.nip44_encrypt_async(&public_key, &archived.join("\n"))
.await?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
.finalize_async(signer)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
Ok(load_device_keys(client, signer).await?.keys)
}
/// Load encryption keys retired by a rotation from the local database.
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
Ok(parse_archived_keys(&content))
}
-25
View File
@@ -1,25 +0,0 @@
[package]
name = "nip60"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
state = { path = "../state" }
common = { path = "../common" }
gpui.workspace = true
gpui_tokio.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
log.workspace = true
flume.workspace = true
serde_json.workspace = true
hkdf.workspace = true
sha2.workspace = true
tokio = { version = "1", default-features = false, features = [ "rt" ] }
cdk.workspace = true
cdk-redb.workspace = true
-292
View File
@@ -1,292 +0,0 @@
use anyhow::{Error, anyhow};
use hkdf::Hkdf;
use nostr::nips::nip60::TokenEvent;
use nostr_sdk::prelude::*;
use sha2::Sha256;
const SEED_SALT: &[u8] = b"coop nip60 seed salt";
const SEED_INFO: &[u8] = b"coop cashu seed";
pub(crate) struct WalletData {
pub(crate) event_id: EventId,
pub(crate) created_at: Timestamp,
pub(crate) privkey: SecretKey,
pub(crate) mints: Vec<Url>,
}
impl Clone for WalletData {
fn clone(&self) -> Self {
Self {
event_id: self.event_id,
created_at: self.created_at,
privkey: self.privkey.clone(),
mints: self.mints.clone(),
}
}
}
pub(crate) fn generate_privkey() -> SecretKey {
SecretKey::generate()
}
/// Derive the cdk seed from the wallet key.
pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
let ikm = privkey.to_secret_bytes();
let hk = Hkdf::<Sha256>::new(Some(SEED_SALT), &ikm);
let mut seed = [0u8; 64];
hk.expand(SEED_INFO, &mut seed)
.map_err(|error| anyhow!("seed derivation failed: {error}"))?;
Ok(seed)
}
pub(crate) fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>), Error> {
let rows: Vec<Vec<String>> =
serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?;
let mut privkey = None;
let mut mints = Vec::new();
for row in rows {
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
continue;
};
match key.as_str() {
"privkey" if privkey.is_none() => privkey = Some(SecretKey::from_hex(value)?),
"mint" => {
if let Ok(mint) = Url::parse(value) {
mints.push(mint);
}
}
_ => {}
}
}
let Some(privkey) = privkey else {
return Err(anyhow!("wallet event is missing the private key"));
};
if mints.is_empty() {
return Err(anyhow!("wallet event is missing mints"));
}
Ok((privkey, mints))
}
pub(crate) async fn decrypt_wallet_event<S>(
signer: &S,
me: &PublicKey,
event: &Event,
) -> Result<WalletData, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("wallet event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let (privkey, mints) = parse_wallet_content(&content)?;
Ok(WalletData {
event_id: event.id,
created_at: event.created_at,
privkey,
mints,
})
}
pub(crate) async fn decrypt_token_event<S>(
signer: &S,
me: &PublicKey,
event: &Event,
) -> Result<TokenEvent, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("token event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let token =
serde_json::from_str(&content).map_err(|error| anyhow!("invalid token event: {error}"))?;
Ok(token)
}
pub(crate) async fn build_wallet_event<S>(
signer: &S,
me: &PublicKey,
privkey: &SecretKey,
mints: &[Url],
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut rows = vec![vec!["privkey".to_string(), privkey.to_secret_hex()]];
for mint in mints {
rows.push(vec!["mint".to_string(), mint.to_string()]);
}
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWallet, content)
.finalize_async(signer)
.await?)
}
pub(crate) async fn build_token_event<S>(
signer: &S,
me: &PublicKey,
token: &TokenEvent,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(token)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content)
.finalize_async(signer)
.await?)
}
/// NIP-09 deletion carrying the `k: 7375` tag required by NIP-60.
pub(crate) async fn build_token_deletion<S>(signer: &S, ids: &[EventId]) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut tags: Vec<Tag> = ids.iter().map(|id| Tag::event(*id)).collect();
tags.push(Tag::custom("k", ["7375".to_string()]));
Ok(EventBuilder::new(Kind::EventDeletion, "")
.tags(tags)
.finalize_async(signer)
.await?)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn build_history_event<S>(
signer: &S,
me: &PublicKey,
direction: TransactionDirection,
amount: u64,
created: EventId,
destroyed: &[EventId],
redeemed: &[EventId],
sender: Option<PublicKey>,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut rows = vec![
vec!["direction".to_string(), direction.to_string()],
vec!["amount".to_string(), amount.to_string()],
];
for id in destroyed {
rows.push(vec![
"e".to_string(),
id.to_hex(),
String::new(),
"destroyed".to_string(),
]);
}
rows.push(vec![
"e".to_string(),
created.to_hex(),
String::new(),
"created".to_string(),
]);
let mut tags: Vec<Tag> = redeemed
.iter()
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
.collect();
if let Some(sender) = sender {
tags.push(Tag::custom("p", [sender.to_hex()]));
}
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
.tags(tags)
.finalize_async(signer)
.await?)
}
pub(crate) async fn build_nutzap_info_event<S>(
signer: &S,
mints: &[Url],
relays: &[&str],
wallet_pubkey: PublicKey,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let mut tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::custom("relay", [relay.to_string()]))
.collect();
for mint in mints {
tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
}
tags.push(Tag::custom(
"pubkey",
[format!("02{}", wallet_pubkey.to_hex())],
));
Ok(EventBuilder::new(Kind::Custom(10019), "")
.tags(tags)
.finalize_async(signer)
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_wallet_content() {
let privkey = SecretKey::generate();
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
let content = serde_json::to_string(&vec![
vec!["privkey".to_string(), privkey.to_secret_hex()],
vec!["mint".to_string(), mint.to_string()],
])
.expect("serialization cannot fail");
let (parsed_key, mints) = parse_wallet_content(&content).expect("valid content");
assert_eq!(parsed_key.to_secret_hex(), privkey.to_secret_hex());
assert_eq!(mints, vec![mint]);
}
#[test]
fn wallet_content_rejects_missing_privkey() {
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
let content = serde_json::to_string(&vec![vec!["mint".to_string(), mint.to_string()]])
.expect("serialization cannot fail");
assert!(parse_wallet_content(&content).is_err());
}
#[test]
fn seed_is_deterministic_per_key() {
let key = SecretKey::generate();
let first = derive_seed(&key).expect("seed derivation cannot fail");
let second = derive_seed(&key).expect("seed derivation cannot fail");
assert_eq!(first, second);
let other = derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
assert_ne!(first, other);
}
}
File diff suppressed because it is too large Load Diff
-440
View File
@@ -1,440 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use anyhow::{Error, anyhow};
use cdk::Amount;
use cdk::amount::SplitTarget;
use cdk::mint_url::MintUrl;
use cdk::nuts::nut00::KnownMethod;
use cdk::nuts::{
CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey,
State,
};
use cdk::secret::Secret;
use cdk::wallet::types::ProofInfo;
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
use nostr_sdk::prelude::*;
use crate::keys;
/// A job mutating the wallet store or reconciling it with relays.
#[derive(Debug, Clone)]
pub(crate) enum Job {
Reconcile,
ClaimQuote {
mint: MintUrl,
quote_id: String,
},
Melt {
mint: MintUrl,
invoice: String,
},
SendToken {
mint: MintUrl,
amount: u64,
},
Receive {
mint: MintUrl,
token: String,
},
RedeemNutzap {
mint: MintUrl,
proofs: Proofs,
nutzap_id: EventId,
sender: PublicKey,
},
}
/// History event payload for a token state change
pub(crate) struct History {
pub(crate) direction: TransactionDirection,
pub(crate) amount: u64,
/// NIP-61 nutzap events redeemed by this state change
pub(crate) redeemed: Vec<EventId>,
/// Author of the redeemed nutzap, for the unencrypted `p` tag
pub(crate) sender: Option<PublicKey>,
}
pub(crate) struct TokenUpdate {
pub(crate) event_id: EventId,
pub(crate) token: TokenEvent,
pub(crate) destroyed: Vec<EventId>,
}
pub(crate) enum Outcome {
/// Nothing changed (e.g. an unpaid quote)
Idle,
/// Token events published by a reconcile pass
Reconciled {
updates: Vec<TokenUpdate>,
},
QuoteMinted {
quote_id: String,
update: TokenUpdate,
amount: u64,
},
Melted {
update: TokenUpdate,
amount: u64,
},
Sent {
update: TokenUpdate,
token: String,
},
Received {
update: TokenUpdate,
amount: u64,
},
NutzapRedeemed {
update: TokenUpdate,
nutzap_id: EventId,
amount: u64,
},
}
pub(crate) struct JobCtx<S> {
pub(crate) wallets: HashMap<MintUrl, Wallet>,
/// Wallet signing key, unlocking P2PK-locked nutzap proofs
pub(crate) wallet_key: SecretKey,
pub(crate) signer: S,
pub(crate) client: Client,
/// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>,
}
pub(crate) async fn run_job<S>(job: Job, ctx: JobCtx<S>) -> Result<Outcome, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
{
match job {
Job::Reconcile => reconcile(ctx).await,
Job::ClaimQuote { mint, quote_id } => {
let wallet = wallet_for(&ctx, &mint)?;
let quote = wallet.check_mint_quote(&quote_id).await?;
if quote.state != MintQuoteState::Paid {
return Ok(Outcome::Idle);
}
let proofs = wallet.mint(&quote_id, SplitTarget::default(), None).await?;
let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
let update = sync_token_events(
&ctx.signer,
&ctx.client,
wallet,
nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint),
Some(History {
direction: TransactionDirection::In,
amount: u64::from(quote.amount.unwrap_or_default()),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::QuoteMinted {
quote_id,
update,
amount,
})
}
Job::Melt { mint, invoice } => {
let wallet = wallet_for(&ctx, &mint)?;
let quote = wallet
.melt_quote(
PaymentMethod::Known(KnownMethod::Bolt11),
invoice,
None,
None,
)
.await?;
let finalized = wallet
.prepare_melt(&quote.id, HashMap::new())
.await?
.confirm()
.await?;
if finalized.state() != MeltQuoteState::Paid {
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
}
let amount = u64::from(finalized.total_amount());
let update = sync_token_events(
&ctx.signer,
&ctx.client,
wallet,
nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint),
Some(History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Melted { update, amount })
}
Job::SendToken { mint, amount } => {
let wallet = wallet_for(&ctx, &mint)?;
let prepared = wallet
.prepare_send(Amount::from(amount), SendOptions::default())
.await?;
let token = prepared.confirm(None).await?.to_string();
let update = sync_token_events(
&ctx.signer,
&ctx.client,
wallet,
nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint),
Some(History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Sent { update, token })
}
Job::Receive { mint, token } => {
let wallet = wallet_for(&ctx, &mint)?;
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
let update = sync_token_events(
&ctx.signer,
&ctx.client,
wallet,
nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint),
Some(History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Received {
update,
amount: u64::from(received),
})
}
Job::RedeemNutzap {
mint,
proofs,
nutzap_id,
sender,
} => {
let wallet = wallet_for(&ctx, &mint)?;
let options = ReceiveOptions {
p2pk_signing_keys: vec![ctx.wallet_key.clone()],
..Default::default()
};
let received = wallet.receive_proofs(proofs, options, None, None).await?;
let update = sync_token_events(
&ctx.signer,
&ctx.client,
wallet,
nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint),
Some(History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: vec![nutzap_id],
sender: Some(sender),
}),
)
.await?;
Ok(Outcome::NutzapRedeemed {
update,
nutzap_id,
amount: u64::from(received),
})
}
}
}
/// Claim paid quotes, then converge each mint.
async fn reconcile<S>(ctx: JobCtx<S>) -> Result<Outcome, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
{
let mut tokens = ctx.tokens.clone();
let mut updates = Vec::new();
for (mint_url, wallet) in &ctx.wallets {
let minted = wallet.mint_unissued_quotes().await?;
if minted == Amount::ZERO {
continue;
}
let update = sync_token_events(
&ctx.signer,
&ctx.client,
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
Some(History {
direction: TransactionDirection::In,
amount: u64::from(minted),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
apply_update(&mut tokens, &update);
updates.push(update);
}
for (mint_url, wallet) in &ctx.wallets {
let mut relay_ys = HashSet::new();
let mut added = Vec::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint_url.to_string())
{
for proof in &token.proofs {
let info = match proof_info(proof, mint_url) {
Ok(info) => info,
Err(error) => {
log::warn!("skipping proof: {error:#}");
continue;
}
};
if relay_ys.insert(info.y) {
added.push(info);
}
}
}
let unspent = wallet.get_unspent_proofs().await?;
let removed = unspent
.iter()
.filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y),
Err(_) => false,
})
.map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?;
wallet.localstore.update_proofs(added, removed).await?;
}
Ok(Outcome::Reconciled { updates })
}
/// Publish the store's current unspent set as a fresh token event.
async fn sync_token_events<S>(
signer: &S,
client: &Client,
wallet: &Wallet,
mint: Url,
previous: &[EventId],
history: Option<History>,
) -> Result<TokenUpdate, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized,
{
let me = signer.get_public_key_async().await?;
let unspent = wallet.get_unspent_proofs().await?;
let mut proofs = Vec::new();
for proof in &unspent {
proofs.push(cashu_proof(proof)?);
}
let token = TokenEvent::new(mint, proofs);
let event = keys::build_token_event(signer, &me, &token).await?;
client.send_event(&event).await?;
if !previous.is_empty() {
let deletion = keys::build_token_deletion(signer, previous).await?;
client.send_event(&deletion).await?;
}
if let Some(history) = history {
let history_event = keys::build_history_event(
signer,
&me,
history.direction,
history.amount,
event.id,
previous,
&history.redeemed,
history.sender,
)
.await?;
client.send_event(&history_event).await?;
}
Ok(TokenUpdate {
event_id: event.id,
token,
destroyed: previous.to_vec(),
})
}
fn apply_update(tokens: &mut HashMap<EventId, TokenEvent>, update: &TokenUpdate) {
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
}
fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<EventId> {
tokens
.iter()
.filter(|(_, token)| token.mint.as_str() == mint.to_string())
.map(|(id, _)| *id)
.collect()
}
fn wallet_for<'a, S>(ctx: &'a JobCtx<S>, mint: &MintUrl) -> Result<&'a Wallet, Error> {
ctx.wallets
.get(mint)
.ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
}
fn nostr_mint(mint: &MintUrl) -> Result<Url, Error> {
Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
}
fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
Ok(CashuProof {
id: proof.keyset_id.to_string(),
amount: u64::from(proof.amount),
secret: proof.secret.to_string(),
c: proof.c.to_string(),
})
}
fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result<ProofInfo, Error> {
let keyset_id = Id::from_str(&proof.id)?;
let c = cdk::nuts::PublicKey::from_str(&proof.c)?;
let info = ProofInfo::new(
Proof::new(
Amount::from(proof.amount),
keyset_id,
Secret::new(proof.secret.clone()),
c,
),
mint.clone(),
State::Unspent,
CurrencyUnit::Sat,
)?;
Ok(info)
}
+3 -7
View File
@@ -54,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Box<Person>),
Person(Person),
Announcement(Event),
Relays(Event),
Emojis(Event),
@@ -111,7 +111,7 @@ impl PersonRegistry {
this.update(cx, |this, cx| {
match event {
Dispatch::Person(person) => {
this.insert(*person, cx);
this.insert(person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
@@ -175,11 +175,7 @@ impl PersonRegistry {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
if tx
.send_async(Dispatch::Person(Box::new(person)))
.await
.is_err()
{
if tx.send_async(Dispatch::Person(person)).await.is_err() {
log::warn!("Channel closed, dropping metadata event");
}
}
-7
View File
@@ -144,13 +144,6 @@ impl Person {
/// Set profile encryption keys announcement
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
// A delayed or repeated announcement must not roll the key back
if let Some(current) = &self.announcement
&& !announcement.is_newer_than(current)
{
return;
}
self.announcement = Some(announcement);
}
+14 -43
View File
@@ -100,12 +100,7 @@ pub fn persisted_theme() -> ThemeSettings {
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Minimum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
/// Maximum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
/// Days between automatic encryption key rotations when enabled
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
/// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
@@ -152,7 +147,6 @@ setting_accessors! {
pub screening: bool,
pub wot_auto_trust: bool,
pub nip4e: bool,
pub nip4e_rotation_days: Option<u32>,
pub hide_group_notice: bool,
pub file_server: Url,
pub panel_background: bool,
@@ -228,9 +222,6 @@ pub struct Settings {
pub wot_auto_trust: bool,
/// Enable decoupling encryption key
pub nip4e: bool,
/// Days between automatic encryption key rotations; None disables rotation
#[serde(default)]
pub nip4e_rotation_days: Option<u32>,
/// Hide the notice that messages in public groups are not encrypted
#[serde(default)]
pub hide_group_notice: bool,
@@ -263,7 +254,6 @@ impl Default for Settings {
screening: true,
wot_auto_trust: true,
nip4e: false,
nip4e_rotation_days: None,
hide_group_notice: false,
theme: ThemeSettings::default(),
panel_background: true,
@@ -340,40 +330,22 @@ impl AppSettings {
});
}
/// Apply the persisted theme tuning on top of the active theme
pub fn apply_theme_style(cx: &mut App) {
Self::apply_theme_tuning(Self::get_theme(cx), cx);
}
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
use gpui_kit::component::Theme;
{
let styled = Theme::global_mut(cx);
styled.font_size = px(tuning.font_size);
styled.focus_ring = tuning.focus_ring;
if let Some(radius) = tuning.radius {
styled.radius = px(radius);
}
if let Some(radius_lg) = tuning.radius_lg {
styled.radius_lg = px(radius_lg);
}
let theme = Self::get_theme(cx);
let styled = Theme::global_mut(cx);
styled.font_size = px(theme.font_size);
styled.focus_ring = theme.focus_ring;
if let Some(radius) = theme.radius {
styled.radius = px(radius);
}
Theme::update(cx, |styled| {
let background = styled.background;
let foreground = styled.foreground;
let (hover_alpha, active_alpha) = if styled.is_dark() {
(0.08, 0.12)
} else {
(0.04, 0.06)
};
if styled.list_hover == background {
styled.list_hover = foreground.alpha(hover_alpha);
}
if styled.tab_active == background {
styled.tab_active = foreground.alpha(active_alpha);
}
});
if let Some(radius_lg) = theme.radius_lg {
styled.radius_lg = px(radius_lg);
}
Theme::sync_base(cx);
cx.refresh_windows();
}
/// Load settings
@@ -444,8 +416,7 @@ impl AppSettings {
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
}
let tuning = self.inner.read(cx).theme;
Self::apply_theme_tuning(tuning, cx);
cx.refresh_windows();
}
/// Check if decoupling encryption key is enabled
-31
View File
@@ -1,31 +0,0 @@
[package]
name = "speech"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
log.workspace = true
gpui.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
flume.workspace = true
instant.workspace = true
tts.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
common = { path = "../common" }
parakeet-rs = "=0.3.8"
ort = "=2.0.0-rc.13"
reqwest = { version = "0.12", default-features = false, features = [
"blocking",
"rustls-tls",
] }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["Window"] }
-54
View File
@@ -1,54 +0,0 @@
#[cfg(not(target_arch = "wasm32"))]
pub mod model;
#[cfg(not(target_arch = "wasm32"))]
mod parakeet;
pub mod speaker;
#[cfg(target_arch = "wasm32")]
mod web;
use std::time::Duration;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, IntoElement as _, ParentElement as _,
Styled as _, div,
};
use gpui_kit::component::ActiveTheme as _;
use gpui_kit::component::speech::SpeechRecognizer;
/// A ticking `M:SS` counter for the dictation in progress.
pub fn elapsed(started: instant::Instant, cx: &App) -> AnyElement {
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.with_animation(
"dictation-elapsed",
Animation::new(Duration::from_secs(1))
.repeat()
.with_max_fps(1.),
move |counter, _| counter.child(format_elapsed(started)),
)
.into_any_element()
}
fn format_elapsed(started: instant::Instant) -> String {
let seconds = started.elapsed().as_secs();
format!("{}:{:02}", seconds / 60, seconds % 60)
}
/// The recognizer backing the composer's microphone button.
#[cfg(not(target_arch = "wasm32"))]
pub fn recognizer() -> impl SpeechRecognizer {
parakeet::ParakeetRecognizer
}
/// The recognizer backing the composer's microphone button.
#[cfg(target_arch = "wasm32")]
pub fn recognizer() -> impl SpeechRecognizer {
web::WebSpeechRecognizer
}
/// The audio source backing the composer's microphone button on the web.
#[cfg(target_arch = "wasm32")]
pub fn input() -> impl gpui_kit::component::speech::AudioInput {
web::SelfCapturedInput
}
-23
View File
@@ -1,23 +0,0 @@
{
"repository": "istupakov/parakeet-tdt-0.6b-v3-onnx",
"revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce",
"source": "nvidia/parakeet-tdt-0.6b-v3",
"license": "CC-BY-4.0",
"files": [
{
"name": "decoder_joint-model.int8.onnx",
"size": 18202004,
"sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70"
},
{
"name": "encoder-model.int8.onnx",
"size": 652183999,
"sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09"
},
{
"name": "vocab.txt",
"size": 93939,
"sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d"
}
]
}
-295
View File
@@ -1,295 +0,0 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::{fs, io};
use anyhow::{Result, bail};
use serde::Deserialize;
/// How the model is currently available.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
NotInstalled,
Downloading { downloaded: u64, total: u64 },
Installed,
}
impl Status {
pub fn is_downloading(self) -> bool {
matches!(self, Status::Downloading { .. })
}
}
#[derive(Debug, Deserialize)]
struct Manifest {
repository: String,
revision: String,
files: Vec<Artifact>,
}
#[derive(Debug, Deserialize)]
struct Artifact {
name: String,
size: u64,
sha256: String,
}
fn manifest() -> &'static Manifest {
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
MANIFEST.get_or_init(|| {
serde_json::from_str(include_str!("model.json")).expect("pinned model manifest")
})
}
/// The directory the model artifacts are downloaded into.
pub fn dir() -> PathBuf {
common::config_dir()
.join("speech")
.join("parakeet-tdt-0.6b-v3")
}
/// Total download size in bytes.
pub fn total_size() -> u64 {
manifest().files.iter().map(|file| file.size).sum()
}
/// The revision of the installed model, if its artifacts are complete.
fn installed_revision() -> Option<String> {
let manifest = manifest();
let dir = dir();
let complete = manifest.files.iter().all(|file| {
fs::metadata(dir.join(&file.name)).is_ok_and(|metadata| metadata.len() == file.size)
});
if complete
&& fs::read_to_string(dir.join("verified"))
.is_ok_and(|revision| revision == manifest.revision)
{
Some(manifest.revision.clone())
} else {
None
}
}
/// Whether the model is ready to load.
pub fn installed() -> bool {
status() == Status::Installed
}
/// The current status, reconciling the download state with the disk.
pub fn status() -> Status {
let state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running {
downloaded, total, ..
} = state.download
{
return Status::Downloading { downloaded, total };
}
if installed_revision().is_some() {
Status::Installed
} else {
Status::NotInstalled
}
}
/// Take the last download failure, if it has not been reported yet.
pub fn take_error() -> Option<String> {
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
state.error.take()
}
/// Start downloading the model, unless a download is already running.
pub fn start_download() {
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
if matches!(state.download, Download::Running { .. }) {
return;
}
let cancel = Arc::new(AtomicBool::new(false));
state.error = None;
state.download = Download::Running {
downloaded: 0,
total: total_size(),
cancel: cancel.clone(),
};
if let Err(error) = std::thread::Builder::new()
.name("speech-model-download".into())
.spawn(move || download(cancel))
{
state.download = Download::Idle;
state.error = Some(error.to_string());
}
}
/// Request cancellation of the running download.
pub fn cancel_download() {
let state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running { cancel, .. } = &state.download {
cancel.store(true, Ordering::Release);
}
}
/// Delete the downloaded model and any partial download.
pub fn delete() {
super::parakeet::unload();
cancel_download();
if let Err(error) = fs::remove_dir_all(dir())
&& error.kind() != io::ErrorKind::NotFound
{
log::warn!("failed to remove the speech model: {error}");
}
}
fn download(cancel: Arc<AtomicBool>) {
let result = download_inner(&cancel);
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
state.download = Download::Idle;
// A cancelled download must not report a spurious failure.
if !cancelled(&cancel)
&& let Err(error) = result
{
state.error = Some(format!("{error:#}"));
}
}
fn download_inner(cancel: &AtomicBool) -> Result<()> {
let manifest = manifest();
let dir = dir();
let client = reqwest::blocking::Client::builder()
.connect_timeout(std::time::Duration::from_secs(20))
.build()?;
fs::create_dir_all(&dir)?;
for file in &manifest.files {
if cancelled(cancel) {
bail!("Cancelled");
}
let staging = dir.join(format!("{}.part", file.name));
let result = download_file(&client, manifest, file, &staging, cancel);
if result.is_err() || cancelled(cancel) {
let _unused = fs::remove_file(&staging);
result?;
bail!("Cancelled");
}
}
fs::write(dir.join("verified"), &manifest.revision)?;
Ok(())
}
fn download_file(
client: &reqwest::blocking::Client,
manifest: &Manifest,
file: &Artifact,
staging: &Path,
cancel: &AtomicBool,
) -> Result<()> {
use std::io::{Read as _, Write as _};
use sha2::Digest as _;
let url = format!(
"https://huggingface.co/{}/resolve/{}/{}",
manifest.repository, manifest.revision, file.name
);
let mut response = client.get(url).send()?.error_for_status()?;
let mut out = fs::File::create(staging)?;
let mut hash = sha2::Sha256::new();
let mut size = 0u64;
loop {
if cancelled(cancel) {
bail!("Cancelled");
}
let mut chunk = [0u8; 64 * 1024];
let read = response.read(&mut chunk)?;
if read == 0 {
break;
}
size += read as u64;
if size > file.size {
bail!("Unexpected model size");
}
hash.update(&chunk[..read]);
out.write_all(&chunk[..read])?;
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running { downloaded, .. } = &mut state.download {
*downloaded += read as u64;
}
}
if size != file.size || format!("{:x}", hash.finalize()) != file.sha256 {
bail!("Model checksum verification failed");
}
out.sync_all()?;
fs::rename(staging, dir().join(&file.name))?;
Ok(())
}
fn cancelled(cancel: &AtomicBool) -> bool {
cancel.load(Ordering::Acquire)
}
enum Download {
Idle,
Running {
downloaded: u64,
total: u64,
cancel: Arc<AtomicBool>,
},
}
struct State {
download: Download,
error: Option<String>,
}
fn state() -> &'static Mutex<State> {
static STATE: Mutex<State> = Mutex::new(State {
download: Download::Idle,
error: None,
});
&STATE
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_pins_v3_artifacts_with_exact_sizes() {
let manifest = manifest();
assert_eq!(manifest.repository, "istupakov/parakeet-tdt-0.6b-v3-onnx");
assert_eq!(manifest.revision.len(), 40);
assert_eq!(manifest.files.len(), 3);
assert_eq!(total_size(), 670_479_942);
assert!(
manifest
.files
.iter()
.all(|file| file.sha256.len() == 64 && !file.name.contains('/'))
);
}
}
-214
View File
@@ -1,214 +0,0 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow};
use gpui::{App, Task};
use gpui_kit::component::speech::{
AudioFormat, RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
};
use parakeet_rs::{ParakeetTDT, Transcriber as _};
const MODEL_RATE: u32 = 16_000;
const MAX_SECONDS: usize = 120;
/// The on-device recognizer.
pub struct ParakeetRecognizer;
impl SpeechRecognizer for ParakeetRecognizer {
fn audio_format(&self) -> AudioFormat {
AudioFormat::new(MODEL_RATE, 1)
}
fn is_available(&self, _cx: &App) -> bool {
true
}
fn start(
&self,
sink: SpeechSink,
cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
if super::model::installed() {
worker().send_or_log(Command::Warm(super::model::dir()));
} else if super::model::status().is_downloading() {
return Err(SpeechError::recognizer(anyhow!(
"The speech model is still downloading. Try again when it finishes."
)));
} else {
return Err(SpeechError::recognizer(anyhow!(
"No speech model is installed. Download it from Settings."
)));
}
// The session buffers everything it is pushed,
// so audio is accepted right away.
sink.ready(cx);
Ok(Box::new(ParakeetSession {
sink,
samples: Vec::new(),
_result: None,
}))
}
}
struct ParakeetSession {
sink: SpeechSink,
samples: Vec<f32>,
_result: Option<Task<()>>,
}
impl RecognitionSession for ParakeetSession {
fn push_audio(&mut self, samples: &[i16], _cx: &mut App) {
if self.samples.len() < MAX_SECONDS * MODEL_RATE as usize {
self.samples
.extend(samples.iter().map(|sample| f32::from(*sample) / 32_768.0));
}
}
fn finish(&mut self, cx: &mut App) {
let samples = std::mem::take(&mut self.samples);
let sink = self.sink.clone();
// Speech too short to transcribe ends the session at once.
let silent = samples.iter().all(|sample| sample.abs() < 0.0001);
if samples.len() < MODEL_RATE as usize / 5 || silent {
sink.finish(cx);
return;
}
let (sender, receiver) = flume::unbounded();
worker().send_or_log(Command::Transcribe { samples, sender });
self._result = Some(cx.spawn(async move |cx| {
let result = receiver.recv_async().await;
cx.update(|cx| match result {
Ok(Ok(text)) if !text.is_empty() => {
sink.phrase(text, cx);
sink.finish(cx);
}
Ok(Ok(_)) => sink.finish(cx),
Ok(Err(error)) => sink.error(SpeechError::recognizer(error), cx),
Err(_) => sink.error(
SpeechError::recognizer(anyhow!("The speech worker is unavailable")),
cx,
),
});
}));
}
}
enum Command {
Warm(PathBuf),
Transcribe {
samples: Vec<f32>,
sender: flume::Sender<Result<String, anyhow::Error>>,
},
Unload,
}
/// A handle to the single inference worker.
fn worker() -> &'static flume::Sender<Command> {
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
WORKER.get_or_init(|| {
let (sender, receiver) = flume::unbounded::<Command>();
let spawned = std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver));
if let Err(error) = spawned {
log::error!("failed to spawn the speech worker: {error}");
}
sender
})
}
fn run_worker(receiver: flume::Receiver<Command>) {
let mut cached: Option<(PathBuf, ParakeetTDT)> = None;
while let Ok(command) = receiver.recv_timeout(Duration::from_secs(30)) {
match command {
Command::Unload => {
cached = None;
}
Command::Warm(dir) => {
let loaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
load(&mut cached, &dir).map(|_| ())
}));
match flatten_panic(loaded) {
Ok(Ok(())) => {}
Ok(Err(error)) | Err(error) => {
log::warn!("failed to load the speech model: {error:#}")
}
}
}
Command::Transcribe { samples, sender } => {
let transcribed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
transcribe(&mut cached, samples)
}));
let result = match flatten_panic(transcribed) {
Ok(result) => result,
Err(_) => {
cached = None;
Err(anyhow!("Dictation stopped unexpectedly. Try again."))
}
};
if sender.send(result).is_err() {
log::debug!("speech session was cancelled before its result arrived");
}
}
}
}
}
fn load<'a>(
cached: &'a mut Option<(PathBuf, ParakeetTDT)>,
dir: &PathBuf,
) -> Result<&'a mut ParakeetTDT> {
if cached.as_ref().is_none_or(|(path, _)| path != dir) {
let model = ParakeetTDT::from_pretrained(dir, None)
.map_err(|error| anyhow!("Could not load the speech model: {error}"))?;
*cached = Some((dir.clone(), model));
}
let (_, model) = cached.as_mut().context("speech model was unloaded")?;
Ok(model)
}
fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) -> Result<String> {
let model = load(cached, &super::model::dir())?;
model
.transcribe_samples(samples, MODEL_RATE, 1, None)
.map(|result| result.text)
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
}
trait SendOrLog {
fn send_or_log(&self, command: Command);
}
impl SendOrLog for flume::Sender<Command> {
fn send_or_log(&self, command: Command) {
if self.send(command).is_err() {
log::debug!("speech worker is gone; command dropped");
}
}
}
/// Retire cached weights, e.g. after the model was deleted.
pub(crate) fn unload() {
worker().send_or_log(Command::Unload);
}
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
result.map_err(|_| anyhow!("The speech worker panicked"))
}
-88
View File
@@ -1,88 +0,0 @@
use std::time::Duration;
use tts::Tts;
/// Words per minute of an engine at its normal rate, for estimating speech duration.
const NORMAL_WPM: f32 = 160.0;
/// Extra time past the estimate before speech is considered finished, for backends that
/// cannot report utterance end.
pub const END_GRACE: Duration = Duration::from_secs(5);
/// Reads text aloud through the platform's speech backend, created on first use.
pub struct Speaker {
tts: Option<Tts>,
end_tx: flume::Sender<()>,
end_rx: flume::Receiver<()>,
}
impl Speaker {
pub fn new() -> Self {
let (end_tx, end_rx) = flume::unbounded();
Self {
tts: None,
end_tx,
end_rx,
}
}
/// Cloneable stream of utterance-finish events.
pub fn end_events(&self) -> flume::Receiver<()> {
self.end_rx.clone()
}
/// Speak the text, interrupting what this speaker is already saying.
pub fn speak(&mut self, text: &str) -> Result<Duration, tts::Error> {
let end_tx = self.end_tx.clone();
let tts = match self.tts.as_ref() {
Some(tts) => tts,
None => self.tts.insert({
let tts = Tts::default()?;
if let Err(error) = tts.on_utterance_end(move |_| {
// A send error only means the panel dropped its event loop.
let _ = end_tx.send(());
}) {
log::warn!("utterance end events unavailable ({error})");
}
tts
}),
};
tts.speak(text, true)?;
Ok(Self::estimate(tts, text))
}
/// Stop speaking, a speaker that never spoke is already silent.
pub fn stop(&mut self) -> Result<(), tts::Error> {
match self.tts.as_ref() {
Some(tts) => tts.stop()?,
None => return Ok(()),
}
Ok(())
}
/// Best-effort speaking state, a speaker that never spoke is silent.
pub fn speaking(&self) -> bool {
self.tts
.as_ref()
.and_then(|tts| tts.is_speaking().ok())
.unwrap_or(false)
}
/// How long speaking the text is expected to take at the engine's current speed.
fn estimate(tts: &Tts, text: &str) -> Duration {
let words = text.split_whitespace().count().max(1) as f32;
let speed = match (tts.get_rate(), tts.normal_rate()) {
(Ok(rate), Ok(normal)) if normal > 0. => (rate / normal).clamp(0.2, 4.),
_ => 1.,
};
Duration::from_secs_f32(words / NORMAL_WPM * 60. / speed)
}
}
impl Default for Speaker {
fn default() -> Self {
Self::new()
}
}
-256
View File
@@ -1,256 +0,0 @@
use std::cell::RefCell;
use anyhow::anyhow;
use gpui::App;
use gpui_kit::Subscription;
use gpui_kit::component::speech::{
AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechRecognizer,
SpeechSink,
};
use js_sys::{Array, Function, JsString, Reflect};
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast as _, JsValue};
use web_sys::window;
/// The recognizer backed by `SpeechRecognition` (or its `webkit` prefix).
pub struct WebSpeechRecognizer;
impl SpeechRecognizer for WebSpeechRecognizer {
fn audio_format(&self) -> AudioFormat {
AudioFormat::default()
}
fn is_available(&self, _cx: &App) -> bool {
true
}
fn start(
&self,
sink: SpeechSink,
cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
let recognition = Recognition::open(sink, cx).map_err(SpeechError::recognizer)?;
Ok(Box::new(recognition))
}
}
pub struct SelfCapturedInput;
impl AudioInput for SelfCapturedInput {
fn start(
&self,
_format: AudioFormat,
_sink: AudioSink,
_cx: &mut App,
) -> Result<Subscription, SpeechError> {
Ok(Subscription::new(|| {}))
}
}
enum Event {
Hypothesis(String),
Phrase(String),
Error(String),
End,
}
struct Recognition {
recognition: JsValue,
on_result: Option<Closure<dyn FnMut(JsValue)>>,
on_error: Option<Closure<dyn FnMut(JsValue)>>,
on_end: Option<Closure<dyn FnMut(JsValue)>>,
_task: gpui::Task<()>,
}
impl Recognition {
fn open(sink: SpeechSink, cx: &mut App) -> anyhow::Result<Self> {
let window = window().ok_or_else(|| anyhow!("No browser window"))?;
let constructor = Reflect::get(&window, &JsString::from("SpeechRecognition"))
.ok()
.filter(|value| value.is_function())
.or_else(|| {
Reflect::get(&window, &JsString::from("webkitSpeechRecognition"))
.ok()
.filter(|value| value.is_function())
})
.ok_or_else(|| anyhow!("This browser does not support speech recognition"))?;
let recognition =
Reflect::construct(constructor.unchecked_ref::<Function>(), &Array::new())
.map_err(|error| js_error("constructing the recognizer", error))?;
let (sender, receiver) = flume::unbounded::<Event>();
let error_sender = sender.clone();
let end_sender = sender.clone();
let finalized = RefCell::new(0usize);
let on_result = Closure::wrap(Box::new(move |event: JsValue| {
let read = |value: &JsValue, property: &str| -> Option<JsValue> {
Reflect::get(value, &JsString::from(property)).ok()
};
let Some(results) = read(&event, "results") else {
return;
};
let length = read(&results, "length")
.and_then(|length| length.as_f64())
.unwrap_or(0.0) as usize;
let mut finalized = finalized.borrow_mut();
let mut interim = String::new();
for index in *finalized..length {
let Some(result) = read(&results, &index.to_string()) else {
continue;
};
let Some(alternative) = read(&result, "0") else {
continue;
};
let Some(text) = read(&alternative, "transcript").and_then(|text| text.as_string())
else {
continue;
};
let is_final = read(&result, "isFinal")
.and_then(|final_value| final_value.as_bool())
.unwrap_or(false);
if is_final {
*finalized += 1;
if sender.send(Event::Phrase(text)).is_err() {
log::debug!("speech session was cancelled mid-utterance");
}
} else {
interim.push_str(&text);
}
}
if !interim.is_empty() && sender.send(Event::Hypothesis(interim)).is_err() {
log::debug!("speech session was cancelled mid-utterance");
}
}) as Box<dyn FnMut(JsValue)>);
let on_error = Closure::wrap(Box::new(move |event: JsValue| {
let message = Reflect::get(&event, &JsString::from("error"))
.ok()
.and_then(|error| error.as_string())
.unwrap_or_else(|| "unknown speech error".into());
if error_sender.send(Event::Error(message)).is_err() {
log::debug!("speech session was cancelled before its error arrived");
}
}) as Box<dyn FnMut(JsValue)>);
let on_end = Closure::wrap(Box::new(move |_event: JsValue| {
if end_sender.send(Event::End).is_err() {
log::debug!("speech session was cancelled before its end arrived");
}
}) as Box<dyn FnMut(JsValue)>);
set_callback(&recognition, "onresult", on_result.as_ref().unchecked_ref())?;
set_callback(&recognition, "onerror", on_error.as_ref().unchecked_ref())?;
set_callback(&recognition, "onend", on_end.as_ref().unchecked_ref())?;
set_bool(&recognition, "continuous", true)?;
set_bool(&recognition, "interimResults", true)?;
call_method(&recognition, "start")?;
// The browser consumes audio as soon as it has permission
sink.ready(cx);
let task = cx.spawn(async move |cx| {
while let Ok(event) = receiver.recv_async().await {
let done = matches!(event, Event::End);
// The speech state ignores sink calls once its session ended,
// so a late event after a cancel is harmless.
cx.update(|cx| match event {
Event::Hypothesis(text) => sink.hypothesis(text, cx),
Event::Phrase(text) => sink.phrase(text, cx),
Event::Error(message) => {
sink.error(SpeechError::recognizer(anyhow!("{message}")), cx)
}
Event::End => sink.finish(cx),
});
if done {
break;
}
}
});
Ok(Self {
recognition,
on_result: Some(on_result),
on_error: Some(on_error),
on_end: Some(on_end),
_task: task,
})
}
fn stop(&mut self) {
self.on_result = None;
self.on_error = None;
self.on_end = None;
let _ignored = call_method(&self.recognition, "abort");
}
}
impl Drop for Recognition {
fn drop(&mut self) {
self.stop();
}
}
impl RecognitionSession for Recognition {
fn push_audio(&mut self, _samples: &[i16], _cx: &mut App) {
// The browser recognizer captures the microphone itself.
// the state still pushes audio through its input, which is a no-op here.
}
fn finish(&mut self, _cx: &mut App) {
// The end handler reports completion, so no result is fabricated here.
let _ignored = call_method(&self.recognition, "stop");
}
}
fn set_callback(target: &JsValue, property: &str, callback: &Function) -> anyhow::Result<()> {
Reflect::set(target, &JsString::from(property), callback)
.map_err(|error| js_error(property, error))?;
Ok(())
}
fn set_bool(target: &JsValue, property: &str, value: bool) -> anyhow::Result<()> {
Reflect::set(target, &JsString::from(property), &JsValue::from(value))
.map_err(|error| js_error(property, error))?;
Ok(())
}
fn call_method(target: &JsValue, method: &str) -> anyhow::Result<()> {
let function = Reflect::get(target, &JsString::from(method))
.map_err(|error| js_error(method, error))?
.unchecked_into::<Function>();
function
.call0(target)
.map(|_| ())
.map_err(|error| js_error(method, error))
}
fn js_error(what: &str, error: JsValue) -> anyhow::Error {
let description = error
.as_string()
.or_else(|| {
Reflect::get(&error, &JsString::from("message"))
.ok()
.and_then(|message| message.as_string())
})
.unwrap_or_else(|| "unknown error".into());
anyhow!("{what} failed: {description}")
}
+8 -8
View File
@@ -5,8 +5,8 @@ use gpui::{App, AppContext as _, Task};
use crate::USER_KEYRING;
/// The storage key for the log of handled NIP-4e key-sync request ids
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
/// The storage key for the NIP-4e app keys
const APP_KEYS_KEY: &str = "coop/nip4e/app-keys";
/// Read the stored credential as `(username, secret)`
#[allow(clippy::type_complexity)]
@@ -94,12 +94,12 @@ fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
}
}
/// Read the log of handled key-sync request ids, as concatenated hex ids
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, PROCESSED_REQUESTS_KEY)
/// Load the NIP-4e app keys from storage, as raw secret bytes
pub fn read_app_keys(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, APP_KEYS_KEY)
}
/// Persist the log of handled key-sync request ids, as concatenated hex ids
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
/// Persist the NIP-4e app keys, as raw secret bytes
pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task<Result<()>> {
write_named(cx, APP_KEYS_KEY, secret)
}
+1 -14
View File
@@ -5,10 +5,6 @@ use nostr_sdk::prelude::*;
pub struct Announcement {
/// The public key of the device that created this announcement.
public_key: PublicKey,
/// The id of the announcement event, for ordering
event_id: EventId,
/// The creation time of the announcement event
created_at: Timestamp,
}
impl Announcement {
@@ -20,20 +16,11 @@ impl Announcement {
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())?;
Some(Self {
public_key,
event_id: event.id,
created_at: event.created_at,
})
Some(Self { public_key })
}
/// Returns the public key of the device that created this announcement.
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn is_newer_than(&self, other: &Self) -> bool {
self.created_at > other.created_at
|| (self.created_at == other.created_at && self.event_id < other.event_id)
}
}
-4
View File
@@ -26,11 +26,9 @@ pub enum IconName {
PlusChat,
Profile,
Relay,
Speech,
Search,
Settings,
Settings2,
Unlock,
Upload,
Warning,
}
@@ -60,11 +58,9 @@ impl IconNamed for IconName {
Self::PlusChat => "icons/plus-chat.svg",
Self::Profile => "icons/profile.svg",
Self::Relay => "icons/relay.svg",
Self::Speech => "icons/speech.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Unlock => "icons/unlock.svg",
Self::Upload => "icons/upload.svg",
Self::Warning => "icons/warning.svg",
}
+7 -76
View File
@@ -1,21 +1,19 @@
use std::collections::BTreeMap;
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId,
InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit,
ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _,
StyleRefinement, Styled, StyledImage, Window, div, img, px,
AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement,
MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString,
SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div,
img, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::{
ActiveTheme, Disableable, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex,
v_flex,
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
};
use smallvec::SmallVec;
@@ -599,14 +597,13 @@ pub fn react_menu(
})
}
/// A hovered message's action bar: quick reactions, reply, copy, read aloud, and an overflow menu.
/// A hovered message's action bar: quick reactions, reply, copy, and an overflow menu.
#[derive(IntoElement)]
pub struct MessageActions {
style: StyleRefinement,
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
reply: Option<SharedClickListener>,
copy: Option<(ElementId, SharedString)>,
speech: Option<(bool, SharedClickListener)>,
dropdown: Option<DropdownBuilder>,
}
@@ -618,7 +615,6 @@ impl MessageActions {
reactions: SmallVec::new(),
reply: None,
copy: None,
speech: None,
dropdown: None,
}
}
@@ -646,16 +642,6 @@ impl MessageActions {
self
}
/// Add the read-aloud button, disabled while a message is already being spoken.
pub fn speech(
mut self,
disabled: bool,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.speech = Some((disabled, Rc::new(handler)));
self
}
/// Fill the bar's overflow menu.
pub fn dropdown(
mut self,
@@ -685,13 +671,10 @@ impl RenderOnce for MessageActions {
reactions,
reply,
copy,
speech,
dropdown,
} = self;
// The leading separator only separates reactions from the center buttons
let has_center =
!reactions.is_empty() && (reply.is_some() || copy.is_some() || speech.is_some());
let has_center = reply.is_some() || copy.is_some();
h_flex()
.p_0p5()
@@ -729,17 +712,6 @@ impl RenderOnce for MessageActions {
.when_some(copy, |this, (id, value)| {
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
})
.when_some(speech, |this, (disabled, speech)| {
this.child(
Button::new("speech")
.icon(crate::IconName::Speech)
.tooltip("Read aloud")
.small()
.ghost()
.disabled(disabled)
.on_click(move |event, window, cx| speech(event, window, cx)),
)
})
.when_some(dropdown, |this, dropdown| {
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
@@ -754,44 +726,3 @@ impl RenderOnce for MessageActions {
.refine_style(&style)
}
}
/// A message's text revealed word by word, paced by the estimated speech duration.
#[derive(IntoElement)]
pub struct RevealText {
text: SharedString,
duration: Duration,
}
impl RevealText {
pub fn new(text: impl Into<SharedString>, duration: Duration) -> Self {
Self {
text: text.into(),
duration,
}
}
}
impl RenderOnce for RevealText {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let words: Vec<SharedString> = self
.text
.split_whitespace()
.map(SharedString::from)
.collect();
let total = words.len().max(1) as f32;
div().flex().flex_wrap().gap_x_1().with_animation(
"speech-reveal",
Animation::new(self.duration).with_max_fps(30.),
move |this, progress| {
let visible = ((progress * total).ceil() as usize).min(words.len());
this.children(words.iter().enumerate().map(|(ix, word)| {
div()
.when(ix >= visible, |this| this.invisible())
.child(word.clone())
}))
},
)
}
}
-1
View File
@@ -15,7 +15,6 @@ chat_ui = { path = "../chat_ui" }
nip29 = { path = "../nip29" }
nip29_ui = { path = "../nip29_ui" }
settings = { path = "../settings" }
speech = { path = "../speech" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
+44 -88
View File
@@ -1,18 +1,15 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, Window, div, px, relative,
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div, px, relative,
};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_kit::component::form::{field, v_form};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
use instant::Duration;
use nostr_connect::prelude::*;
use state::{NostrRegistry, StateEvent};
use ui::IconName;
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
@@ -35,6 +32,7 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
this.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Unlock your identity")
.child(import.clone())
});
}
@@ -76,15 +74,7 @@ impl ImportIdentity {
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
let pass_input = cx.new(|cx| {
let input = InputState::new(window, cx).masked(true);
if mode == Mode::Unlock {
input.placeholder("Passphrase to unlock your identity")
} else {
input
}
});
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
@@ -245,59 +235,15 @@ impl ImportIdentity {
Ok(())
}));
}
fn render_unlock(&self, cx: &mut Context<Self>) -> Div {
v_flex()
.gap_4()
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Unlock your identity"))
.child(
DialogDescription::new()
.child("Enter the passphrase used to encrypt this identity."),
),
)
.child(
v_form().child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&self.pass_input)),
),
)
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_sm()
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
.child(
DialogFooter::new().child(
Button::new("unlock")
.primary()
.icon(IconName::Unlock)
.label("Unlock")
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.login(cx);
})),
),
)
}
}
impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.mode == Mode::Unlock {
return self.render_unlock(cx);
}
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
let is_unlock = self.mode == Mode::Unlock;
let value = self.key_input.read(cx).value().trim().to_string();
let require_password = value.starts_with("ncryptsec1");
let require_encrypt = value.starts_with("nsec1");
@@ -311,19 +257,25 @@ impl Render for ImportIdentity {
.child(
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
.when(!is_unlock, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child("Decrypt Password:")
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
})
.when(is_unlock || require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child(if is_unlock {
"Passphrase to unlock your identity:"
} else {
"Decrypt Password:"
})
.child(Input::new(&self.pass_input)),
)
})
@@ -371,7 +323,7 @@ impl Render for ImportIdentity {
.child({
let content = v_flex().gap_1().child(
Button::new("login")
.label("Continue")
.label(if is_unlock { "Unlock" } else { "Continue" })
.primary()
.loading(self.loading)
.disabled(self.loading)
@@ -381,26 +333,30 @@ impl Render for ImportIdentity {
);
#[cfg(not(target_arch = "wasm32"))]
let content = content.child(
Button::new("proxy")
.label("Connect via Web Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
);
let content = content.when(!is_unlock, |this| {
this.child(
Button::new("proxy")
.label("Connect via Web Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
)
});
#[cfg(target_arch = "wasm32")]
let content = content.child(
Button::new("extension")
.label("Continue with Browser Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.extension(cx);
})),
);
let content = content.when(!is_unlock, |this| {
this.child(
Button::new("extension")
.label("Continue with Browser Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.extension(cx);
})),
)
});
content
})
+5 -160
View File
@@ -1,30 +1,19 @@
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
use chat_ui::ToggleDictation;
#[cfg(not(target_arch = "wasm32"))]
use gpui::PathPromptOptions;
#[cfg(not(target_arch = "wasm32"))]
use gpui::Task;
use gpui::http_client::Url;
use gpui::{
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
Subscription, Window, div,
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription,
Window, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::kbd::Kbd;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::notification::Notification;
#[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::progress::Progress;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
#[cfg(not(target_arch = "wasm32"))]
use speech::model::{self as speech_model};
use settings::{AppSettings, AppearanceMode};
use ui::{settings_block, settings_row};
/// Bounds for the theme number inputs
@@ -40,8 +29,6 @@ pub struct Preferences {
font_size_input: Entity<InputState>,
radius_input: Entity<InputState>,
radius_lg_input: Entity<InputState>,
#[cfg(not(target_arch = "wasm32"))]
_speech_poll: Task<()>,
/// Keeps the control subscriptions alive while the dialog is open
_subscriptions: Vec<Subscription>,
}
@@ -107,40 +94,11 @@ impl Preferences {
}),
];
#[cfg(not(target_arch = "wasm32"))]
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
let timer = Duration::from_millis(500);
let mut last = speech_model::status();
loop {
cx.background_executor().timer(timer).await;
let status = speech_model::status();
let error = speech_model::take_error();
if status == last {
continue;
}
last = status;
this.update_in(cx, |_, window, cx| {
if let Some(error) = error {
window.push_notification(Notification::error(error).autohide(false), cx);
}
cx.notify();
})
.ok();
}
});
Self {
file_input,
font_size_input,
radius_input,
radius_lg_input,
#[cfg(not(target_arch = "wasm32"))]
_speech_poll,
_subscriptions: subscriptions,
}
}
@@ -230,102 +188,10 @@ impl Preferences {
.detach();
}
}
fn render_speech(
&mut self,
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
) -> AnyElement {
#[cfg(target_arch = "wasm32")]
{
return div().into_any_element();
}
#[cfg(not(target_arch = "wasm32"))]
{
let size = speech_model::total_size() / 1_000_000;
let status = speech_model::status();
let (description, control) = match &status {
speech_model::Status::NotInstalled => (
format!("{size} MB download"),
Button::new("download-model")
.label("Download")
.secondary()
.small()
.on_click(|_, _window, cx| {
speech_model::start_download();
cx.refresh_windows();
})
.into_any_element(),
),
speech_model::Status::Downloading { downloaded, total } => (
format!(
"Downloading · {} of {} MB",
downloaded / 1_000_000,
total / 1_000_000
),
Button::new("cancel-download")
.label("Cancel")
.ghost()
.small()
.on_click(|_, _window, cx| {
speech_model::cancel_download();
cx.refresh_windows();
})
.into_any_element(),
),
speech_model::Status::Installed => (
format!("Parakeet v3 · {size} MB"),
Button::new("remove-model")
.label("Remove")
.secondary()
.small()
.on_click(|_, _window, cx| {
speech_model::delete();
cx.refresh_windows();
})
.into_any_element(),
),
};
let mut block = v_flex().w_full().gap_3().child(settings_row(
"Speech model",
description,
control,
cx,
));
if let speech_model::Status::Downloading { downloaded, total } = &status {
let (downloaded, total) = (*downloaded, *total);
block = block.child(
Progress::new("speech-model-download")
.value(downloaded as f32 / total.max(1) as f32 * 100.)
.small(),
);
}
block.into_any_element()
}
}
/// Shows the keystroke actually bound to the dictation action at startup
fn render_dictation_shortcut(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let control = match Kbd::global_binding_for_action(&ToggleDictation, window) {
Some(kbd) => kbd.into_any_element(),
None => div().into_any_element(),
};
settings_row("Shortcut", "Press to start or stop dictation", control, cx)
}
}
impl Render for Preferences {
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 {
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
@@ -337,7 +203,6 @@ impl Render for Preferences {
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
const BACKGROUND: &str = "Show an image behind chat and group panels.";
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
@@ -345,7 +210,6 @@ impl Render for Preferences {
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx);
let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx);
let theme = AppSettings::get_theme(cx);
let panel_background = AppSettings::get_panel_background(cx);
@@ -357,7 +221,7 @@ impl Render for Preferences {
};
v_flex()
.my_2()
.mt_2()
.gap_4()
.w_full()
.child(
@@ -509,25 +373,6 @@ impl Render for Preferences {
}),
cx,
))
.child(settings_row(
"Rotate Encryption Key",
NIP4E_ROTATION,
Switch::new("nip4e_rotation")
.checked(nip4e_rotation_days.is_some())
.on_click(move |_, _window, cx| {
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
let days = if enabled {
None
} else {
Some(NIP4E_ROTATION_DEFAULT_DAYS)
};
AppSettings::update_nip4e_rotation_days(days, cx);
}),
cx,
))
.child(Separator::horizontal())
.child(self.render_speech(cx))
.child(self.render_dictation_shortcut(window, cx))
.child(Separator::horizontal())
.child(settings_block(
"Media Upload Service",
+2 -1
View File
@@ -1,4 +1,5 @@
use std::cell::Cell;
use std::ops::Deref as _;
use std::rc::Rc;
use std::sync::Arc;
@@ -165,7 +166,7 @@ impl CoopDockSkin {
.on_drag(ResizePanel, move |info, _, _, cx| {
cx.stop_propagation();
shared.resizing_dock().set(Some(placement));
cx.new(|_| info.clone())
cx.new(|_| info.deref().clone())
})
}
}
+4 -5
View File
@@ -91,12 +91,11 @@ impl Workspace {
let workspace = cx.entity().downgrade();
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx);
AppSettings::apply_theme_style(cx);
}
}));
}));
}
subscriptions.push(
// Subscribe to the nostr events
+4 -103
View File
@@ -1,25 +1,18 @@
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
Stateful, Styled, Window, div, px,
};
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity};
use gpui_kit::base::StyledExt as _;
use gpui::{AnyElement, Entity, IntoElement};
use gpui::{
App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
};
#[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::Disableable;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::popover::Popover;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::shorten_pubkey;
use settings::AppSettings;
use state::{CLIENT_NAME, NostrRegistry};
use ui::{Avatar, IconName};
@@ -38,10 +31,6 @@ impl Sidebar {
let updater = AutoUpdater::try_global(cx);
let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar();
let encryption_key = profile
.announcement()
.map(|announcement| announcement.public_key());
let encryption_enabled = AppSettings::get_nip4e(cx);
h_flex()
.id("sidebar-user")
@@ -82,11 +71,6 @@ impl Sidebar {
});
actions
.when(encryption_enabled, |actions| {
actions.when_some(encryption_key, |actions, encryption_key| {
actions.child(self.render_encryption(encryption_key))
})
})
.child(
Button::new("sidebar-search")
.icon(IconName::Search)
@@ -127,89 +111,6 @@ impl Sidebar {
})
}
/// The encryption key popover: public key with copy, backup and reset
fn render_encryption(&self, encryption_key: PublicKey) -> impl IntoElement {
Popover::new("sidebar-encryption")
.anchor(Anchor::TopRight)
.trigger(
Button::new("encryption-key")
.icon(IconName::Lock)
.tooltip("Encryption Key")
.ghost()
.small(),
)
.content(move |_, _, cx| {
let popover = cx.entity();
let Ok(npub) = encryption_key.to_bech32();
v_flex()
.w_64()
.gap_2()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Public key"),
)
.child(
h_flex()
.items_center()
.gap_1()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(shorten_pubkey(encryption_key, 12))),
)
.child(
Clipboard::new("encryption-key-copy")
.tooltip("Copy public key")
.value(npub),
),
)
.child(Separator::horizontal())
.child(
h_flex()
.justify_end()
.gap_2()
.child(
Button::new("encryption-backup")
.label("Backup")
.outline()
.small()
.on_click({
let popover = popover.clone();
move |_, window, cx| {
popover
.update(cx, |state, cx| state.dismiss(window, cx));
window.dispatch_action(
Box::new(Command::BackupEncryption),
cx,
);
}
}),
)
.child(
Button::new("encryption-reset")
.label("Reset")
.ghost()
.small()
.on_click(move |_, window, cx| {
popover.update(cx, |state, cx| state.dismiss(window, cx));
window.dispatch_action(
Box::new(Command::ResetEncryption),
cx,
);
}),
),
)
})
}
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
v_flex()
.p_2()
+1 -9
View File
@@ -21,7 +21,6 @@ use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry};
pub(crate) use rows::open_room;
use search::SearchResult;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use ui::title_bar_drag_handlers;
@@ -105,8 +104,6 @@ impl Sidebar {
let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
let directory = Directory::new(cx);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -209,11 +206,6 @@ impl Sidebar {
},
));
// The user header reads settings and the tracked profiles on render, so
// the encryption button reacts to toggles and announcement arrivals
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
tasks.push(cx.spawn(async move |this, cx| {
let timer = Duration::from_millis(PROGRESS_TICK_MS);
loop {
@@ -237,7 +229,7 @@ impl Sidebar {
chats_open: None,
contacts_open: false,
contacts: None,
directory,
directory: Directory::new(cx),
selected_contacts: HashSet::new(),
requests_open: false,
new_requests: false,
+1 -3
View File
@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "2.0.0-alpha3"
version = "2.0.0-alpha2"
out-dir = "../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
@@ -37,9 +37,7 @@ workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" }
nip60 = { path = "../crates/nip60" }
chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" }
+18 -23
View File
@@ -1,6 +1,5 @@
use std::sync::{Arc, Mutex};
use chat_ui::ToggleDictation;
use gpui::{
App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
@@ -46,19 +45,13 @@ fn main() {
// Register the `quit` function
cx.on_action(quit);
// Register the `quit` function with CMD+Q (macOS) and dictation with CMD+D
// Register the `quit` function with CMD+Q (macOS)
#[cfg(target_os = "macos")]
cx.bind_keys([
KeyBinding::new("cmd-q", Quit, None),
KeyBinding::new("cmd-d", ToggleDictation, None),
]);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Register the `quit` function with Super+Q (others) and dictation with Ctrl+D
// Register the `quit` function with Super+Q (others)
#[cfg(not(target_os = "macos"))]
cx.bind_keys([
KeyBinding::new("super-q", Quit, None),
KeyBinding::new("ctrl-d", ToggleDictation, None),
]);
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
// Set menu items
cx.set_menus(vec![Menu {
@@ -72,13 +65,14 @@ fn main() {
let appearance = settings::persisted_appearance_mode();
let font_size = settings::persisted_theme().font_size;
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
log::warn!("native theme unavailable ({error})");
apply_appearance(appearance, cx);
AppSettings::apply_theme_style(cx);
match apply_native_theme(appearance, font_size, cx) {
Ok(()) => start_theme_watcher(appearance, cx),
Err(error) => {
log::warn!("native theme unavailable ({error})");
apply_appearance(appearance, cx);
AppSettings::apply_theme_style(cx);
}
}
start_theme_watcher(cx);
// Initialize the nostr client
state::init(cx, cli_key);
@@ -91,11 +85,6 @@ fn main() {
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
nip4e::init(cx);
// Initialize cashu wallet registry
//
// NIP-60: https://github.com/nostr-protocol/nips/blob/master/60.md
nip60::init(cx);
// Initialize app registry
chat::init(cx);
@@ -217,12 +206,14 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
/// Holds the watcher subscription so it lives as long as the app.
struct ThemeWatcher {
_subscription: ThemeSubscription,
/// The appearance mode the observer in `workspace` was registered with.
appearance: std::cell::Cell<AppearanceMode>,
}
impl Global for ThemeWatcher {}
/// Start a background task to watch for OS theme changes and re-apply the native theme.
fn start_theme_watcher(cx: &mut App) {
fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
let (sender, receiver) = flume::unbounded();
match native_theme::watch::on_theme_change(move |event| {
if let Err(error) = sender.send(event) {
@@ -232,6 +223,7 @@ fn start_theme_watcher(cx: &mut App) {
Ok(subscription) => {
cx.set_global(ThemeWatcher {
_subscription: subscription,
appearance: std::cell::Cell::new(initial_appearance),
});
cx.spawn(async move |cx| {
while receiver.recv_async().await.is_ok() {
@@ -239,6 +231,9 @@ fn start_theme_watcher(cx: &mut App) {
cx.update(|cx| {
let appearance = AppSettings::get_appearance_mode(cx);
let font_size = AppSettings::get_theme(cx).font_size;
if let Some(watcher) = cx.try_global::<ThemeWatcher>() {
watcher.appearance.set(appearance);
}
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
log::warn!("failed to apply updated OS theme ({error})");
}
-318
View File
@@ -1,318 +0,0 @@
# NIP-60: cashu wallet — implementation plan
Spec: https://github.com/nostr-protocol/nips/blob/master/60.md
CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, from git main)
NIP-61: https://github.com/nostr-protocol/nips/blob/master/61.md
NIP-60 stores a user's cashu wallet state on relays: the wallet config
(kind `17375`), its unspent proofs (kind `7375`), and an optional spending
history (kind `7376`). All content is NIP-44 encrypted to self. This plan
implements it inside coop using the CDK for all cashu/mint operations and
the app's existing nostr client/signer for events.
## Analysis
| Kind | Replaceable | Content (NIP-44 to self) | Purpose |
| ------ | ----------- | ----------------------------------------------- | -------------------------------- |
| 17375 | yes `[[privkey, hex], [mint, url], ...]` | wallet config: P2PK key + mints |
| 7375 | no `{mint, unit, proofs[], del[]}` | unspent proofs (per mint) |
| 7376 | no `[[direction], [amount], [unit], [e, ...]]` | spend/receive history (optional) |
| 7374 | no `quote-id` + `mint`/`expiration` tags | pending mint quote (optional) |
| 10019 | yes empty, `relay` tags | wallet relay list |
Key mechanics:
- Wallet state transitions are rollover: spending part of a token event
publishes a new `7375` with the unspent proofs + `del: [old-event-id]`,
then NIP-09 deletes the old event (the delete MUST carry `["k", "7375"]`).
- The `privkey` in `17375` is a wallet-only key (never the nostr identity
key). It exists to unlock P2PK-locked ecash, i.e. redeeming NIP-61
nutzaps.
- NIP-60 itself does not move money to/from others. Receiving ecash from
peers is NIP-61 (nutzaps): public `kind:9321` events published by the
sender, p-tagging the recipient's nostr identity pubkey, carrying
P2PK-locked proofs (`proof` tags) on a trusted mint (`u` tag). Redemption
is `receive_proofs` with the wallet signing key + a `7376` whose
unencrypted tags mark the nutzap `redeemed` and carry the sender pubkey.
- NIP-61 discovery runs through a `kind:10019` informational event
(relays, mints, wallet pubkey) published alongside the wallet event.
- History (7376) and quote events (7374) are optional; the spec recommends
local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in
its local DB).
## How it fits coop
Follows the existing crate patterns exactly; no new architecture.
- One protocol crate: `crates/nip60`, mirroring `nip4e`/`nip29` — a
registry entity set as GPUI global by `init(cx)`, `EventEmitter` for
UI updates, `SmallVec<Task>` + `SmallVec<Subscription>` fields, flume
signal channel for background results, reset/reload on
`NostrRegistry::signer_changed` (see `crates/nip29/src/lib.rs`).
- Desktop-only: crate-level `#![cfg(not(target_arch = "wasm32"))]` like
`crates/auto_update/src/lib.rs`. Wallet UI is only wired on desktop.
- Tokio bridge: all cdk calls run through `gpui_tokio::Tokio::spawn(cx, ...)`
(runtime is already initialized by `state::init`). The `cdk-nostr`
inbox listener also runs on that runtime.
- Signer-agnostic encryption: the signer may be a bunker/extension, so we
never use `nostr::nips::nip60` prepare helpers (they need an owned
`SecretKey`).
We build payloads as plain JSON, encrypt with
`signer.nip44_encrypt(self_pubkey, json)` and sign with
`EventBuilder::new(kind, content).finalize_async(signer)` — the same
pattern as `crates/nip4e/src/storage.rs`. Parsing is decrypt-then-parse:
`TokenEvent` derives `Deserialize`; `17375` content is a trivial
`Vec<Vec<String>>` parse.
- Existing facts we build on:
- rust-nostr (git, already a dependency) ships `nostr::nip60` with the
event data types (`WalletEvent`, `TokenEvent`, `SpendingHistory`,
`QuoteEvent`, `CashuProof`) and kinds
(`Kind::CashuWallet` = 17375, `CashuWalletUnspentProof` = 7375,
`CashuWalletSpendingHistory` = 7376, `CashuWalletQuote` = 7374).
It is behind the `nip60` feature, which coop does not enable yet.
- `state::NostrRegistry` gives the `Client` (LMDB cache + bootstrap
relays + gossip) and the `UniversalSigner`.
- Local file storage precedent: `NostrLmdb::open(config_dir().join("nostr"))`
in `crates/state/src/lib.rs`; cdk's sqlite store goes to
`config_dir().join("cashu")`.
## Dependencies
Workspace `Cargo.toml` (final state after the phase 0 spike):
```toml
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] }
# Cashu (NIP-60). From git: the published 0.18.x line targets nostr 0.44 and
# cannot unify with the git rust-nostr used here, while main targets 0.45.x
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false }
[patch.crates-io]
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
```
Spike findings (all resolved and verified with `cargo check`):
- The cdk family hard-pins `cdk-common` with `=` per release, so all crates
must come from one release train. crates.io 0.18.3 pins nostr 0.44 and
cannot coexist with coop's git nostr 0.45.x; the git main line pins
nostr ^0.45.3, which the `[patch.crates-io]` block redirects onto the
same git rust-nostr checkout. The lockfile ends with exactly one `nostr`
and one `nostr-sdk`, proven by compile-time type-identity checks between
`nostr` and `cdk_nostr::nostr`.
- Storage is `cdk-redb` (`WalletRedbDatabase::new(&Path)`, pure Rust), not
`cdk-sqlite`: published cdk-sqlite pins rusqlite ^0.31, whose
`libsqlite3-sys` clashes at the `links` level with the `nostr-sqlite`
wasm dependency of the `state` crate.
- rust-nostr 0.45.x has no root re-exports: the module is
`nostr::nips::nip60`, and cdk-nostr re-exports the SDK as
`cdk_nostr::nostr` / `cdk_nostr::nostr_sdk`.
- The `nip60` crate exists (`crates/nip60`) with a compile-only spike in
`lib.rs` and a no-op `init(cx)` wired into `desktop/src/main.rs`; both
are replaced in phase 1.
## Design
### Key material
- On first `setup(mint_url)` (explicit user action, no auto-creation):
generate `SecretKey::generate()` as the wallet key, publish `17375`
containing it + the mint list (encrypted to self, signed by the user).
- The cdk seed is derived deterministically from the wallet key
(`HKDF-SHA256(wallet_privkey, info = "coop cashu seed")` -> `[u8; 64]`).
No extra local storage, and a fresh device recovers the full wallet from
the relay state alone. Tradeoff: the derivation is coop-specific (NIP-60
does not standardize seed derivation), so it does not restore into other
apps — acceptable, see open questions.
- Proof-level secrets/counters live in cdk's sqlite store; NUT-13 restore
from mints works because the seed is deterministic.
### Local state
- cdk redb store at `config_dir().join("cashu")` (one `WalletDatabase`
shared by all mint wallets).
- `WalletRegistry` holds: parsed `17375` (`WalletEvent` + event id), the
active `7375` events per mint (event id + `TokenEvent`), cdk `Wallet`
per mint (`CurrencyUnit::Sat` only in v1), and the subscription handle.
### Relay sync
- One subscription: `authors: [me], kinds: [17375, 7375, 7376]` plus
kind `5` with `k: 7375`, on the existing client (bootstrap relays).
Notifications flow through the nip29-style flume channel.
- Published relays: the same client/relays coop already uses. Publishing a
`10019` wallet relay list with those relays is a one-liner for interop;
reading `10019` from other clients is skipped in v1 (coop always uses
its own relay set).
### Operations
Each op runs on tokio and ends with the same `sync` tail:
read unspent proofs from cdk -> publish one `7375` per changed mint
(rolling up previous event ids into `del`) -> NIP-09 delete old events with
a `k:7375` tag -> publish `7376` history -> update registry state and emit.
- `setup(mint_url)`: create wallet key + seed, publish `17375`, build cdk
`Wallet`, open subscription.
- `create_mint_quote(amount)`: `wallet.mint_quote` -> return invoice to UI;
poll / subscribe quote state, then `wallet.mint(quote_id)` -> sync.
On load, `mint_unissued_quotes` claims quotes paid elsewhere.
- `melt(invoice)`: `wallet.melt` -> sync (change proofs roll over).
- `send_token(amount)`: `wallet.send` -> return token string for the user
to share -> sync. (No in-chat token delivery in v1.)
- `receive_token(token)`: `wallet.receive` -> sync.
- Redeem nutzap (NIP-61): verify the gift-wrapped proofs are P2PK-locked to
the wallet pubkey, then `wallet.receive_proofs(proofs, ReceiveOptions {
p2pk_signing_keys: [wallet_key] })` -> `7376` with unencrypted
`e <nutzap-id> "" redeemed` tag -> sync.
- Remote `7375` from another device: import its unspent proofs into the
cdk store as the new local truth (cdk proof insertion + state check via
`check_proofs_spent`); superseded `7375`s reconcile through `del`/NIP-09.
- On every load: `recover_incomplete_sagas` and `mint_unissued_quotes`
(required by cdk to clear interrupted operations), then re-derive the
unspent set from `7375` minus deletes.
### Crate layout (3 files, kept small on purpose)
- `lib.rs` — `init`, `WalletRegistry`, `WalletEvent` (`Updated`, `Error`),
subscription/notification handling, `reset` on signer change.
- `keys.rs` — wallet key/seed derivation, `17375` build/parse via signer.
- `ops.rs` — cdk operations + the shared `sync` tail (rollover, deletes,
history), NIP-61 redemption.
Wiring: `nip60::init(cx)` in `desktop/src/main.rs` after `nip4e::init(cx)`;
`nip60 = { path = "../crates/nip60" }` added to `desktop/Cargo.toml` (and
the UI crate when Phase 3 lands).
## Phases
### Phase 0 — dependency spike (done)
Outcome recorded in the Dependencies section: cdk family on git main,
`[patch.crates-io]` unification, cdk-redb storage, `nip60` crate skeleton
wired into desktop. `cargo check -p nip60` and the full desktop check pass.
The remaining spike code in `crates/nip60/src/lib.rs` is deleted in
phase 1.
### Phase 1 — bootstrap + load + balance (done)
Implemented in `crates/nip60` (`lib.rs`, `keys.rs`):
- `WalletRegistry` follows the nip29/nip4e pattern: global entity, signer
reset, flume notification listener, task + subscription SmallVecs.
- `keys.rs`: wallet key generation, HKDF seed derivation, `17375`
encrypt/parse through the signer (works with bunkers), unit tests.
- Load: subscribe + query cache for 17375/7375/7376 and kind 5 `k:7375`,
decrypt via signer, prune deleted (`k:7375` deletes) and superseded
(`del` chains) token events, expose `has_wallet()`/`balance()`.
- cdk: redb store per wallet pubkey under `config_dir()/cashu`, one `Wallet`
per mint (`sat`), unspent proofs imported/diffed against the store on
every sync, `recover_incomplete_sagas` + `mint_unissued_quotes` fire on
first store open.
- `setup(mint)` creates the wallet (first mint) or adds a mint, publishing
`17375` and saving it to the local cache.
Deferred to later phases: token rollover publishing (phase 2), remote
proof validation at the mint (phase 2 reconciliation), replacing proofs
reserved by local in-flight operations (phase 2 guard).
### Phase 2 — moving money (done)
Implemented in `crates/nip60/src/ops.rs` plus registry wiring:
- All store mutations go through a serialized job queue
(`schedule_job`): one job at a time on the background executor,
snapshots taken at schedule time, one queued job slot. This keeps ops
and relay-driven imports from interleaving on the store, which a loose
sync tail would not guarantee.
- Jobs: `Reconcile` (claim paid quotes via `mint_unissued_quotes`, then
converge each mint — publish the store's unspent set when it diverges
from the relay view, and import relay-only proofs into the store),
`ClaimQuote`, `Melt`, `SendToken`, `Receive`.
- The sync tail (`sync_token_events`) publishes a fresh `7375` with the
store's unspent proofs, NIP-09-deletes the previous token events with
the required `k: 7375` tag, and publishes a `7376` history event with
`created`/`destroyed` markers.
- Registry API: `create_mint_quote(amount)` (invoice stored in
`mint_quotes()`, UI polls `claim_mint_quote(id)`), `melt(invoice)`,
`send_token(amount)` (token delivered via `WalletEvent::TokenCreated`),
`receive_token(token)` (mint resolved from the token).
- Claim-on-load moved from a fire-and-forget task into `Reconcile`:
the fire-and-forget variant raced the import pass, which could wipe
freshly claimed proofs from the store before they were published.
- History amounts: mint/receive record what was received; melt records
invoice amount + fee (`FinalizedMelt::total_amount`); send records the
sent amount.
Deferred: NUT-20/quote events (`7374`, per spec local state is fine),
sending tokens in-chat (v1 copies the token string).
### Phase 3 — NIP-61 redemption (done)
Spec note: NIP-61 changed since this plan was first written — nutzaps are
now public `kind:9321` events p-tagging the recipient's nostr identity
pubkey (the old draft's `kind:938` + gift-wrap shape is gone). So
`cdk_nostr::NostrInbox` (a NIP-17 gift-wrap listener) is not the right
tool; the dependency was dropped and reception rides the app's existing
nostr client.
Implemented:
- `setup` also publishes a `kind:10019` nutzap info event (bootstrap
relays, wallet mints, wallet pubkey) so other clients can nutzap us.
- A second subscription (`kinds: [9321], #p: me`) feeds nutzaps through
the same notification listener; the cache load scans stored `9321`s
too, so unredeemed nutzaps survive restarts.
- `absorb_nutzap` parses `proof`/`u` tags, resolves the mint against the
open wallets, verifies every proof is P2PK-locked to the wallet pubkey
(NUT-10 spending conditions), and enqueues `Job::RedeemNutzap` through
the serialized job queue.
- The job redeems via `wallet.receive_proofs` with the wallet key in
`p2pk_signing_keys`, then syncs with a `7376` history event whose
unencrypted tags mark the nutzap `redeemed` and tag the sender.
- Already-redeemed nutzaps (from cached `7376` tags) and ones with a job
in flight are skipped; failed redemptions are retried on relay echo.
### Phase 4 — UI
- Settings entry for the mint URL (`settings` crate pattern).
- Minimal wallet surface in the workspace (balance, receive invoice,
paste-token receive, send-token, history list) subscribing to
`WalletEvent`. Follows `nip29_ui`'s split if it grows beyond a panel.
## Tests
- `keys.rs` round-trips: encrypt/decrypt `17375` payloads against the
rust-nostr reference types (mirrors `nostr::nip60` unit tests).
- Rollover logic: given prior token events + a new unspent set, assert the
produced `7375`/delete/`7376` trio (pure functions, no network).
- Integration (optional, behind `#[ignore]`): testnut mint mint -> melt
cycle against cdk's memory store.
## Open questions / tradeoffs
- Seed derivation is coop-specific; cross-app portability of proof
restores is unsolved by NIP-60 generally. Revisit if a derivation
convention lands in the NIP or cdk.
- v1 is single-unit (`sat`) and effectively single-mint in the UI, even
though the event format and registry support many mints.
- Concurrent spends from two devices double-spend; recovery is the spec's
Appendix-1 validation (check proofs at the mint, roll over survivors) —
implemented as reconciliation on sync, not real-time locking.
- Import replaces the unspent proof set wholesale; the serialized job
queue closes the in-flight-operation window, and mint-side double-spend
rejection remains the backstop.
- Tracking cdk via git main is a moving target; the lockfile pins a rev
and must be bumped deliberately (same policy as the rust-nostr git dep).
Watch for the next cdk release line built against nostr 0.45.x — moving
back to crates.io then removes the moving-target concern.
- Web target gets no wallet in v1: cdk store backends (redb, sqlite) need
a real filesystem and do not build on wasm32; the crate compiles to
nothing there (auto_update pattern). A wasm store would need a cdk
`WalletDatabase` over IndexedDB — explicitly out of scope.
- `7374` quote events are skipped (local cdk state per the spec); pending
quotes are not visible to other devices until claimed.
+1
View File
@@ -3,6 +3,7 @@ channel = "1.98"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = [
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
-1
View File
@@ -14,7 +14,6 @@ ui = { path = "../crates/ui" }
state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
person = { path = "../crates/person" }
+5 -5
View File
@@ -21,12 +21,12 @@ if [[ "$1" == "--release" ]]; then
echo -e "${YELLOW}Building in release mode${NC}"
fi
NIGHTLY="nightly-2026-10-31"
CARGO="cargo +$NIGHTLY"
# wasm_thread requires a nightly compiler for its atomics features
CARGO="cargo +nightly"
# Step 0: Toolchain prerequisites
echo -e "${GREEN}Step 0: Checking toolchain...${NC}"
rustup target add wasm32-unknown-unknown --toolchain "$NIGHTLY"
rustup target add wasm32-unknown-unknown --toolchain nightly
if [[ "$(uname)" == "Darwin" ]]; then
# secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and
@@ -35,7 +35,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}"
exit 1
fi
rustup component add llvm-tools-preview --toolchain "$NIGHTLY"
rustup component add llvm-tools-preview --toolchain nightly
RUSTUP_HOME=$(rustup show home)
if [[ "$(uname -m)" == "arm64" ]]; then
@@ -43,7 +43,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
else
HOST_ARCH="x86_64-apple-darwin"
fi
LLVM_BIN="$RUSTUP_HOME/toolchains/$NIGHTLY-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin"
LLVM_BIN="$RUSTUP_HOME/toolchains/nightly-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin"
export CC_wasm32_unknown_unknown=clang
export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar"
-5
View File
@@ -78,11 +78,6 @@ pub fn run() -> Result<(), JsValue> {
APPLICATION.with(|application| {
*application.borrow_mut() = Some(app.run_embedded(|cx| {
cx.bind_keys([
KeyBinding::new("cmd-d", chat_ui::ToggleDictation, None),
KeyBinding::new("ctrl-d", chat_ui::ToggleDictation, None),
]);
gpui_kit::init(cx);
load_embedded_fonts(cx);