harden proxy auth and event ordering
This commit is contained in:
@@ -5,7 +5,7 @@ async function pollForRequests() {
|
|||||||
isPolling = true;
|
isPolling = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/pending');
|
const response = await fetch('/api/pending?token=' + encodeURIComponent(window.proxySessionToken));
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log('Polled for requests, got:', data);
|
console.log('Polled for requests, got:', data);
|
||||||
@@ -94,7 +94,7 @@ async function handleNip07Request(request) {
|
|||||||
|
|
||||||
console.log('Sending response:', responsePayload);
|
console.log('Sending response:', responsePayload);
|
||||||
|
|
||||||
await fetch('/api/response', {
|
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -117,7 +117,7 @@ async function handleNip07Request(request) {
|
|||||||
|
|
||||||
console.log('Sending error response:', errorPayload);
|
console.log('Sending error response:', errorPayload);
|
||||||
|
|
||||||
await fetch('/api/response', {
|
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -265,6 +265,10 @@ impl BrowserSignerProxy {
|
|||||||
let state: Arc<ProxyState> = self.inner.state.clone();
|
let state: Arc<ProxyState> = self.inner.state.clone();
|
||||||
let custom_html: &'static str = self.inner.options.custom_html;
|
let custom_html: &'static str = self.inner.options.custom_html;
|
||||||
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
||||||
|
// Regenerated on every startup, never part of the opened URL
|
||||||
|
let session_token: String = Uuid::new_v4().simple().to_string();
|
||||||
|
// Only the exact host:port the server binds may appear in the Host header
|
||||||
|
let expected_host: String = addr.to_string();
|
||||||
|
|
||||||
smol::spawn(async move {
|
smol::spawn(async move {
|
||||||
tracing::info!("Starting proxy server on {addr}");
|
tracing::info!("Starting proxy server on {addr}");
|
||||||
@@ -291,13 +295,22 @@ impl BrowserSignerProxy {
|
|||||||
AcceptEvent::Connection(Ok((stream, _))) => {
|
AcceptEvent::Connection(Ok((stream, _))) => {
|
||||||
let state: Arc<ProxyState> = state.clone();
|
let state: Arc<ProxyState> = state.clone();
|
||||||
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
||||||
|
let expected_host: String = expected_host.clone();
|
||||||
|
let session_token: String = session_token.clone();
|
||||||
|
|
||||||
smol::spawn(async move {
|
smol::spawn(async move {
|
||||||
let shutdown_listener = shutdown.listen();
|
let shutdown_listener = shutdown.listen();
|
||||||
|
|
||||||
smol::future::or(
|
smol::future::or(
|
||||||
async {
|
async {
|
||||||
handle_connection(stream, state, custom_html).await;
|
handle_connection(
|
||||||
|
stream,
|
||||||
|
state,
|
||||||
|
custom_html,
|
||||||
|
expected_host,
|
||||||
|
session_token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
},
|
},
|
||||||
async {
|
async {
|
||||||
shutdown_listener.await;
|
shutdown_listener.await;
|
||||||
@@ -336,6 +349,18 @@ impl BrowserSignerProxy {
|
|||||||
outgoing_requests.push(request);
|
outgoing_requests.push(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a request from both queues so timed-out request data is not retained.
|
||||||
|
#[inline]
|
||||||
|
async fn cancel_request(&self, id: Uuid) {
|
||||||
|
self.inner.state.pending_responses.lock().await.remove(&id);
|
||||||
|
self.inner
|
||||||
|
.state
|
||||||
|
.outgoing_requests
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.retain(|request| request.id != id);
|
||||||
|
}
|
||||||
|
|
||||||
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
||||||
where
|
where
|
||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
@@ -345,6 +370,7 @@ impl BrowserSignerProxy {
|
|||||||
|
|
||||||
// Construct the request
|
// Construct the request
|
||||||
let request: RequestData = RequestData::new(method, params);
|
let request: RequestData = RequestData::new(method, params);
|
||||||
|
let request_id = request.id;
|
||||||
|
|
||||||
// Create a bounded channel of size 1 as a oneshot replacement
|
// Create a bounded channel of size 1 as a oneshot replacement
|
||||||
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
||||||
@@ -361,7 +387,10 @@ impl BrowserSignerProxy {
|
|||||||
match response {
|
match response {
|
||||||
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
||||||
Ok(Err(error)) => Err(Error::generic(error)),
|
Ok(Err(error)) => Err(Error::generic(error)),
|
||||||
Err(TimeoutError) => Err(Error::timeout()),
|
Err(TimeoutError) => {
|
||||||
|
self.cancel_request(request_id).await;
|
||||||
|
Err(Error::timeout())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,10 +466,14 @@ impl AsyncNip44 for BrowserSignerProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Minimal HTTP server ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Handle a single HTTP connection.
|
/// Handle a single HTTP connection.
|
||||||
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
|
async fn handle_connection(
|
||||||
|
stream: TcpStream,
|
||||||
|
state: Arc<ProxyState>,
|
||||||
|
custom_html: &'static str,
|
||||||
|
expected_host: String,
|
||||||
|
session_token: String,
|
||||||
|
) {
|
||||||
let mut reader = BufReader::new(stream);
|
let mut reader = BufReader::new(stream);
|
||||||
|
|
||||||
// Read the request line
|
// Read the request line
|
||||||
@@ -462,24 +495,49 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
|||||||
// Read headers until empty line
|
// Read headers until empty line
|
||||||
let mut headers = Vec::new();
|
let mut headers = Vec::new();
|
||||||
let mut content_length: usize = 0;
|
let mut content_length: usize = 0;
|
||||||
|
let mut host: Option<String> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
if reader.read_line(&mut line).await.is_err() {
|
if reader.read_line(&mut line).await.is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let line = line.trim_end().to_string();
|
let line = line.trim_end().to_string();
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(value) = line.strip_prefix("content-length:") {
|
if let Some(value) = line.strip_prefix("content-length:") {
|
||||||
content_length = value.trim().parse().unwrap_or(0);
|
content_length = value.trim().parse().unwrap_or(0);
|
||||||
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
||||||
content_length = value.trim().parse().unwrap_or(0);
|
content_length = value.trim().parse().unwrap_or(0);
|
||||||
|
} else if let Some(value) = line.strip_prefix("host:") {
|
||||||
|
host = Some(value.trim().to_string());
|
||||||
|
} else if let Some(value) = line.strip_prefix("Host:") {
|
||||||
|
host = Some(value.trim().to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.push(line);
|
headers.push(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
match (method.as_str(), path.as_str()) {
|
// DNS rebinding defense: only the exact bound host:port is accepted
|
||||||
|
if host.as_deref() != Some(expected_host.as_str()) {
|
||||||
|
send_response(&mut reader, 403, "Forbidden", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (route, query) = match path.split_once('?') {
|
||||||
|
Some((route, query)) => (route, Some(query)),
|
||||||
|
None => (path.as_str(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if route.starts_with("/api/") && query_param(query, "token") != Some(session_token.as_str()) {
|
||||||
|
send_response(&mut reader, 403, "Forbidden", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (method.as_str(), route) {
|
||||||
// Serve the HTML proxy page
|
// Serve the HTML proxy page
|
||||||
("GET", "/") => {
|
("GET", "/") => {
|
||||||
let html = if custom_html.is_empty() {
|
let html = if custom_html.is_empty() {
|
||||||
@@ -487,7 +545,8 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
|||||||
} else {
|
} else {
|
||||||
custom_html
|
custom_html
|
||||||
};
|
};
|
||||||
send_response(&mut reader, 200, "OK", "text/html", html).await;
|
let html = inject_session_token(html, &session_token);
|
||||||
|
send_response(&mut reader, 200, "OK", "text/html", &html).await;
|
||||||
}
|
}
|
||||||
// Serve the JS proxy script
|
// Serve the JS proxy script
|
||||||
("GET", "/proxy.js") => {
|
("GET", "/proxy.js") => {
|
||||||
@@ -516,7 +575,7 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
|||||||
// Clear the outgoing requests after sending them
|
// Clear the outgoing requests after sending them
|
||||||
outgoing.clear();
|
outgoing.clear();
|
||||||
|
|
||||||
send_response_cors_json(&mut reader, 200, "OK", &json).await;
|
send_response(&mut reader, 200, "OK", "application/json", &json).await;
|
||||||
}
|
}
|
||||||
// Receive response from browser extension
|
// Receive response from browser extension
|
||||||
("POST", "/api/response") => {
|
("POST", "/api/response") => {
|
||||||
@@ -541,27 +600,16 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
|||||||
let mut pending = state.pending_responses.lock().await;
|
let mut pending = state.pending_responses.lock().await;
|
||||||
|
|
||||||
match pending.remove(&id) {
|
match pending.remove(&id) {
|
||||||
Some(sender) => {
|
Some(sender) => match sender.try_send(message.into_result()) {
|
||||||
// Use try_send since we already hold the lock
|
Ok(()) => tracing::info!("Forwarded response for request {id}"),
|
||||||
let _ = sender.try_send(message.into_result());
|
Err(e) => {
|
||||||
tracing::info!("Forwarded response for request {id}");
|
tracing::warn!("Failed to forward response for request {id}: {e}")
|
||||||
}
|
}
|
||||||
|
},
|
||||||
None => tracing::warn!("No pending request found for {id}"),
|
None => tracing::warn!("No pending request found for {id}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
|
send_response(&mut reader, 200, "OK", "text/plain", "OK").await;
|
||||||
}
|
|
||||||
// CORS preflight
|
|
||||||
("OPTIONS", _) => {
|
|
||||||
let response = "HTTP/1.1 200 OK\r\n\
|
|
||||||
Access-Control-Allow-Origin: *\r\n\
|
|
||||||
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
|
||||||
Access-Control-Allow-Headers: Content-Type\r\n\
|
|
||||||
Content-Length: 0\r\n\
|
|
||||||
Connection: close\r\n\
|
|
||||||
\r\n";
|
|
||||||
let _ = reader.get_mut().write_all(response.as_bytes()).await;
|
|
||||||
let _ = reader.get_mut().flush().await;
|
|
||||||
}
|
}
|
||||||
// 404 - not found
|
// 404 - not found
|
||||||
_ => {
|
_ => {
|
||||||
@@ -585,68 +633,44 @@ async fn send_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
response.push_str("Connection: close\r\n");
|
||||||
response.push_str("\r\n");
|
response.push_str("\r\n");
|
||||||
response.push_str(body);
|
response.push_str(body);
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
write_response(stream, &response).await;
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a response with CORS headers and JSON content type.
|
async fn write_response(stream: &mut (impl AsyncWriteExt + Unpin), response: &str) {
|
||||||
async fn send_response_cors_json(
|
if let Err(e) = stream.write_all(response.as_bytes()).await {
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
tracing::debug!("Failed to write response to peer: {e}");
|
||||||
status: u16,
|
}
|
||||||
status_text: &str,
|
if let Err(e) = stream.flush().await {
|
||||||
body: &str,
|
tracing::debug!("Failed to flush response to peer: {e}");
|
||||||
) {
|
|
||||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
|
||||||
response.push_str("Content-Type: application/json\r\n");
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
|
||||||
response.push_str("\r\n");
|
|
||||||
response.push_str(body);
|
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a response with CORS headers.
|
|
||||||
async fn send_response_cors(
|
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
|
||||||
status: u16,
|
|
||||||
status_text: &str,
|
|
||||||
content_type: &str,
|
|
||||||
body: &str,
|
|
||||||
) {
|
|
||||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
|
||||||
|
|
||||||
if !content_type.is_empty() {
|
|
||||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
|
||||||
response.push_str("\r\n");
|
|
||||||
response.push_str(body);
|
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Timeout helper ───────────────────────────────────────────────────────
|
/// Get a parameter value from an optional query string.
|
||||||
|
fn query_param<'a>(query: Option<&'a str>, name: &str) -> Option<&'a str> {
|
||||||
|
let query = query?;
|
||||||
|
query.split('&').find_map(|pair| {
|
||||||
|
let (key, value) = pair.split_once('=')?;
|
||||||
|
(key == name).then_some(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_session_token(html: &str, token: &str) -> String {
|
||||||
|
let script = format!("<script>window.proxySessionToken=\"{token}\";</script>");
|
||||||
|
match html.rfind("</body>") {
|
||||||
|
Some(index) => format!("{}{script}{}", &html[..index], &html[index..]),
|
||||||
|
None => format!("{html}{script}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An error indicating that an operation timed out.
|
/// An error indicating that an operation timed out.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TimeoutError;
|
struct TimeoutError;
|
||||||
|
|
||||||
/// Races a channel receive against a duration.
|
/// Races a channel receive against a duration.
|
||||||
///
|
|
||||||
/// Returns the channel value on success, or [`TimeoutError`] if the duration
|
|
||||||
/// elapses first or the channel is closed.
|
|
||||||
async fn race_timeout<T>(
|
async fn race_timeout<T>(
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
||||||
@@ -677,10 +701,6 @@ async fn race_timeout<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Utility ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
|
||||||
/// time is before the epoch, returns 0.
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn current_time() -> u64 {
|
fn current_time() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
|
|||||||
@@ -598,16 +598,23 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = SharedString::from(&event.content);
|
||||||
let emojis = emojis_from_tags(&event.tags);
|
let emojis = emojis_from_tags(&event.tags);
|
||||||
|
|
||||||
let image = Message::reaction_image(&event.content, &emojis)
|
let image = Message::reaction_image(&event.content, &emojis)
|
||||||
.map(|url| SharedUri::from(url.to_string()));
|
.map(|url| SharedUri::from(url.to_string()));
|
||||||
|
|
||||||
for id in event.tags.event_ids() {
|
for id in event.tags.event_ids() {
|
||||||
self.reactions.entry(id).or_default().push((
|
let entries = self.reactions.entry(id).or_default();
|
||||||
SharedString::from(&event.content),
|
// NIP-25: a newer reaction by the same author replaces their previous one
|
||||||
image.clone(),
|
if let Some(existing) = entries
|
||||||
event.pubkey,
|
.iter_mut()
|
||||||
));
|
.find(|(_, _, author)| *author == event.pubkey)
|
||||||
|
{
|
||||||
|
*existing = (content.clone(), image.clone(), event.pubkey);
|
||||||
|
} else {
|
||||||
|
entries.push((content.clone(), image.clone(), event.pubkey));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|||||||
@@ -1,65 +1,161 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use gpui::SharedUri;
|
use gpui::SharedUri;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
/// Extracts media URLs from a string and returns both
|
/// Matches image URLs with common image extensions
|
||||||
/// the extracted URLs and the string with media URLs removed
|
static IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
struct MediaExtractor {
|
Regex::new(
|
||||||
image_regex: Regex,
|
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
||||||
}
|
)
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Extracts media URLs from a string and returns the URLs and the cleaned text
|
||||||
|
struct MediaExtractor;
|
||||||
|
|
||||||
impl MediaExtractor {
|
impl MediaExtractor {
|
||||||
/// Creates a new MediaExtractor with compiled regex patterns
|
|
||||||
fn new() -> Self {
|
|
||||||
MediaExtractor {
|
|
||||||
// Match common image extensions
|
|
||||||
image_regex: Regex::new(
|
|
||||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
|
||||||
).unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts all media URLs from a string
|
/// Extracts all media URLs from a string
|
||||||
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
||||||
let mut urls = Vec::new();
|
IMAGE_RE
|
||||||
|
.find_iter(text)
|
||||||
// Extract image URLs
|
.map(|capture| capture.as_str().to_string().into())
|
||||||
for capture in self.image_regex.find_iter(text) {
|
.collect()
|
||||||
urls.push(capture.as_str().to_string().into());
|
|
||||||
}
|
|
||||||
|
|
||||||
urls
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes all media URLs from a string and returns the cleaned text
|
/// Removes all media URLs from a string and returns the cleaned text
|
||||||
fn remove_media_urls(&self, text: &str) -> String {
|
fn remove_media_urls(text: &str) -> String {
|
||||||
let mut result = text.to_string();
|
let mut cleaned = String::with_capacity(text.len());
|
||||||
|
let mut last_end = 0;
|
||||||
|
// Whitespace bordered a removed URL, so one space stands in for it
|
||||||
|
let mut spaced = false;
|
||||||
|
|
||||||
// Remove image URLs
|
for url in IMAGE_RE.find_iter(text) {
|
||||||
result = self.image_regex.replace_all(&result, "").to_string();
|
let raw_gap = &text[last_end..url.start()];
|
||||||
|
let surrounded = raw_gap.ends_with(char::is_whitespace)
|
||||||
|
|| text[url.end()..].starts_with(char::is_whitespace);
|
||||||
|
|
||||||
// Clean up extra whitespace that might result from removal
|
let gap = if last_end == 0 {
|
||||||
self.cleanup_text(&result)
|
raw_gap.trim_end()
|
||||||
|
} else {
|
||||||
|
raw_gap.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !gap.is_empty() {
|
||||||
|
if spaced && !cleaned.is_empty() {
|
||||||
|
cleaned.push(' ');
|
||||||
|
}
|
||||||
|
cleaned.push_str(gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
spaced |= surrounded;
|
||||||
|
last_end = url.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tail = text[last_end..].trim_start();
|
||||||
|
if spaced && !cleaned.is_empty() && !tail.is_empty() {
|
||||||
|
cleaned.push(' ');
|
||||||
|
}
|
||||||
|
cleaned.push_str(tail);
|
||||||
|
|
||||||
|
cleaned.trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts media URLs and removes them from the string, returning both
|
/// Extracts media URLs and removes them from the string, returning both
|
||||||
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
fn extract_and_remove(text: &str) -> (Vec<SharedUri>, String) {
|
||||||
let urls = self.extract_media_urls(text);
|
(
|
||||||
let cleaned_text = self.remove_media_urls(text);
|
Self::extract_media_urls(text),
|
||||||
(urls, cleaned_text)
|
Self::remove_media_urls(text),
|
||||||
}
|
)
|
||||||
|
|
||||||
/// Helper function to clean up text after URL removal
|
|
||||||
fn cleanup_text(&self, text: &str) -> String {
|
|
||||||
let text = text.trim();
|
|
||||||
|
|
||||||
// Remove multiple consecutive spaces
|
|
||||||
let re = Regex::new(r"\s+").unwrap();
|
|
||||||
re.replace_all(text, " ").trim().to_string()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience function for one-time extraction and removal
|
/// Convenience function for one-time extraction and removal
|
||||||
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
||||||
let extractor = MediaExtractor::new();
|
MediaExtractor::extract_and_remove(text)
|
||||||
extractor.extract_and_remove(text)
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_line_text_without_urls_keeps_line_breaks() {
|
||||||
|
let text = "first line\n\nsecond line";
|
||||||
|
let (urls, cleaned) = extract_and_remove_media_urls(text);
|
||||||
|
|
||||||
|
assert!(urls.is_empty());
|
||||||
|
assert_eq!(cleaned, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removes_url_and_tidies_around_it() {
|
||||||
|
let (urls, cleaned) =
|
||||||
|
extract_and_remove_media_urls("check https://example.com/pic.png here");
|
||||||
|
|
||||||
|
assert_eq!(urls, vec![SharedUri::from("https://example.com/pic.png")]);
|
||||||
|
assert_eq!(cleaned, "check here");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_url_keeps_other_line_breaks() {
|
||||||
|
let (urls, cleaned) = extract_and_remove_media_urls(
|
||||||
|
"first\nsecond https://example.com/pic.png third\nfourth",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(urls.len(), 1);
|
||||||
|
assert_eq!(cleaned, "first\nsecond third\nfourth");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_on_its_own_line_joins_neighbors_with_one_space() {
|
||||||
|
let (urls, cleaned) =
|
||||||
|
extract_and_remove_media_urls("first\n\nhttps://example.com/pic.png\n\nlast");
|
||||||
|
|
||||||
|
assert_eq!(urls.len(), 1);
|
||||||
|
assert_eq!(cleaned, "first last");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_urls_collapse_to_single_spaces() {
|
||||||
|
let (urls, cleaned) = extract_and_remove_media_urls(
|
||||||
|
"a https://example.com/a.png b https://example.com/b.png c",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(urls.len(), 2);
|
||||||
|
assert_eq!(cleaned, "a b c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_with_only_a_url_becomes_empty() {
|
||||||
|
let (urls, cleaned) = extract_and_remove_media_urls("https://example.com/pic.png");
|
||||||
|
|
||||||
|
assert_eq!(urls.len(), 1);
|
||||||
|
assert_eq!(cleaned, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digits_glued_to_url_scheme_prevent_match() {
|
||||||
|
let text = "v1.2https://example.com/pic.png";
|
||||||
|
let (urls, cleaned) = extract_and_remove_media_urls(text);
|
||||||
|
|
||||||
|
assert!(urls.is_empty());
|
||||||
|
assert_eq!(cleaned, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digits_around_removed_url_survive() {
|
||||||
|
let (urls, cleaned) = extract_and_remove_media_urls("123 https://example.com/pic.png 456");
|
||||||
|
|
||||||
|
assert_eq!(urls.len(), 1);
|
||||||
|
assert_eq!(cleaned, "123 456");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emoji_around_removed_url_survives() {
|
||||||
|
let (urls, cleaned) = extract_and_remove_media_urls("👍 https://example.com/pic.png 👍");
|
||||||
|
|
||||||
|
assert_eq!(urls.len(), 1);
|
||||||
|
assert_eq!(cleaned, "👍 👍");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::LOCAL_KEYS;
|
|||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||||
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
||||||
TimelineWindow, tag_value,
|
TimelineWindow, is_newer_event, tag_value,
|
||||||
};
|
};
|
||||||
|
|
||||||
const REPLAY_LIMIT: usize = 100;
|
const REPLAY_LIMIT: usize = 100;
|
||||||
@@ -91,7 +91,8 @@ pub struct Group {
|
|||||||
membership: Membership,
|
membership: Membership,
|
||||||
membership_events: Vec<Event>,
|
membership_events: Vec<Event>,
|
||||||
elsewhere: Option<GroupKey>,
|
elsewhere: Option<GroupKey>,
|
||||||
state_ids: HashMap<Kind, EventId>,
|
/// The last accepted (created_at, id) per state kind.
|
||||||
|
state_ids: HashMap<Kind, (Timestamp, EventId)>,
|
||||||
rows: Vec<Row>,
|
rows: Vec<Row>,
|
||||||
index: HashSet<EventId>,
|
index: HashSet<EventId>,
|
||||||
reactions: HashMap<EventId, Vec<Reaction>>,
|
reactions: HashMap<EventId, Vec<Reaction>>,
|
||||||
@@ -694,6 +695,15 @@ impl Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
|
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||||
|
// Relays deliver same-kind state events in any order.
|
||||||
|
if !is_newer_event(
|
||||||
|
self.state_ids.get(&event.kind).copied(),
|
||||||
|
event.created_at,
|
||||||
|
event.id,
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||||
Ok(metadata) => self.metadata = Some(metadata),
|
Ok(metadata) => self.metadata = Some(metadata),
|
||||||
@@ -724,7 +734,10 @@ impl Group {
|
|||||||
_ => return false,
|
_ => return false,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
self.state_ids
|
||||||
|
.insert(event.kind, (event.created_at, event.id));
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
|
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
|
||||||
|
|||||||
+37
-11
@@ -76,6 +76,8 @@ pub struct GroupsRegistry {
|
|||||||
groups: Vec<Entity<Group>>,
|
groups: Vec<Entity<Group>>,
|
||||||
previews: HashMap<GroupKey, Entity<Group>>,
|
previews: HashMap<GroupKey, Entity<Group>>,
|
||||||
list: GroupList,
|
list: GroupList,
|
||||||
|
/// The (created_at, id) of the last accepted group list event.
|
||||||
|
list_stamp: Option<(Timestamp, EventId)>,
|
||||||
browse: HashMap<SubscriptionId, RelayUrl>,
|
browse: HashMap<SubscriptionId, RelayUrl>,
|
||||||
routes: HashMap<SubscriptionId, GroupKey>,
|
routes: HashMap<SubscriptionId, GroupKey>,
|
||||||
observers: HashMap<GroupKey, Subscription>,
|
observers: HashMap<GroupKey, Subscription>,
|
||||||
@@ -132,6 +134,7 @@ impl GroupsRegistry {
|
|||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
previews: HashMap::new(),
|
previews: HashMap::new(),
|
||||||
list: GroupList::default(),
|
list: GroupList::default(),
|
||||||
|
list_stamp: None,
|
||||||
browse: HashMap::new(),
|
browse: HashMap::new(),
|
||||||
routes: HashMap::new(),
|
routes: HashMap::new(),
|
||||||
observers: HashMap::new(),
|
observers: HashMap::new(),
|
||||||
@@ -455,6 +458,8 @@ impl GroupsRegistry {
|
|||||||
self.fork_authors.clear();
|
self.fork_authors.clear();
|
||||||
self.syncing.clear();
|
self.syncing.clear();
|
||||||
self.list = GroupList::default();
|
self.list = GroupList::default();
|
||||||
|
// A new signer's list events must not be judged against the old stamp.
|
||||||
|
self.list_stamp = None;
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
@@ -488,24 +493,26 @@ impl GroupsRegistry {
|
|||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
// Show the cached groups before the relays answer.
|
// Show the cached groups before the relays answer.
|
||||||
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(GroupList, Option<Event>), Error>> =
|
||||||
|
cx.background_spawn(async move {
|
||||||
let me = signer.get_public_key_async().await?;
|
let me = signer.get_public_key_async().await?;
|
||||||
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
||||||
|
|
||||||
match client.database().query(filter).await?.into_iter().next() {
|
match client.database().query(filter).await?.into_iter().next() {
|
||||||
Some(event) => {
|
Some(event) => {
|
||||||
let list = GroupList::parse(&event)?;
|
let list =
|
||||||
Ok(Self::hydrate_list(list, &signer, &me).await)
|
Self::hydrate_list(GroupList::parse(&event)?, &signer, &me).await;
|
||||||
|
Ok((list, Some(event)))
|
||||||
}
|
}
|
||||||
None => Ok(GroupList::default()),
|
None => Ok((GroupList::default(), None)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks
|
self.tasks
|
||||||
.push(cx.spawn(async move |this, cx| match task.await {
|
.push(cx.spawn(async move |this, cx| match task.await {
|
||||||
Ok(list) => {
|
Ok((list, event)) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.merge(list, cx);
|
this.merge(list, event.as_ref(), cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -538,7 +545,16 @@ impl GroupsRegistry {
|
|||||||
self.watch_forks(cx);
|
self.watch_forks(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
|
fn merge(&mut self, list: GroupList, event: Option<&Event>, cx: &mut Context<Self>) {
|
||||||
|
if let Some(event) = event {
|
||||||
|
// Relays replay list events out of order; only strictly newer applies.
|
||||||
|
if !is_newer_event(self.list_stamp, event.created_at, event.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.list_stamp = Some((event.created_at, event.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Carry the event's private section so a later publish can preserve it.
|
// Carry the event's private section so a later publish can preserve it.
|
||||||
self.list
|
self.list
|
||||||
.set_content(list.content().map(str::to_owned), list.content_readable());
|
.set_content(list.content().map(str::to_owned), list.content_readable());
|
||||||
@@ -589,20 +605,21 @@ impl GroupsRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if list.content().is_none() {
|
if list.content().is_none() {
|
||||||
self.merge(list, cx);
|
self.merge(list, Some(&event), cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let Some(me) = nostr.read(cx).current_user() else {
|
let Some(me) = nostr.read(cx).current_user() else {
|
||||||
self.merge(list, cx);
|
self.merge(list, Some(&event), cx);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let list = Self::hydrate_list(list, &signer, &me).await;
|
let list = Self::hydrate_list(list, &signer, &me).await;
|
||||||
this.update(cx, |this, cx| this.merge(list, cx)).ok();
|
this.update(cx, |this, cx| this.merge(list, Some(&event), cx))
|
||||||
|
.ok();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,7 +881,7 @@ impl GroupsRegistry {
|
|||||||
let me = nostr.read(cx).current_user();
|
let me = nostr.read(cx).current_user();
|
||||||
let list = self.list.clone();
|
let list = self.list.clone();
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
// Re-encrypt the private section when it carries local entries; a
|
// Re-encrypt the private section when it carries local entries; a
|
||||||
// section this session could not read is carried verbatim.
|
// section this session could not read is carried verbatim.
|
||||||
let content = match (list.has_private(), list.content_readable()) {
|
let content = match (list.has_private(), list.content_readable()) {
|
||||||
@@ -893,6 +910,15 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Our own publish is the newest list until a newer event arrives.
|
||||||
|
if let Err(error) = this.update(cx, |this, _cx| {
|
||||||
|
if is_newer_event(this.list_stamp, event.created_at, event.id) {
|
||||||
|
this.list_stamp = Some((event.created_at, event.id));
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
log::warn!("recording the published list failed: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
match client.send_event(&event).to_nip65().await {
|
match client.send_event(&event).to_nip65().await {
|
||||||
Ok(output) if output.failed.is_empty() => {}
|
Ok(output) if output.failed.is_empty() => {}
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
|||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `(created_at, id)` is strictly newer than the last accepted stamp.
|
||||||
|
pub(crate) fn is_newer_event(
|
||||||
|
accepted: Option<(Timestamp, EventId)>,
|
||||||
|
created_at: Timestamp,
|
||||||
|
id: EventId,
|
||||||
|
) -> bool {
|
||||||
|
accepted.is_none_or(|last| (created_at, id) > last)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct GroupId(String);
|
pub struct GroupId(String);
|
||||||
|
|
||||||
@@ -1152,4 +1161,19 @@ mod tests {
|
|||||||
let malformed = Tag::custom("previous", ["nothex"]);
|
let malformed = Tag::custom("previous", ["nothex"]);
|
||||||
assert_eq!(window.references_seen(Some(&malformed)), Some(false));
|
assert_eq!(window.references_seen(Some(&malformed)), Some(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_newer_event_accepts_only_strictly_newer_events() {
|
||||||
|
let lower = EventId::from_byte_array([0x00; 32]);
|
||||||
|
let higher = EventId::from_byte_array([0xff; 32]);
|
||||||
|
let earlier = Timestamp::from(100);
|
||||||
|
let later = Timestamp::from(200);
|
||||||
|
|
||||||
|
assert!(is_newer_event(None, earlier, lower));
|
||||||
|
assert!(!is_newer_event(Some((earlier, lower)), earlier, lower));
|
||||||
|
assert!(is_newer_event(Some((earlier, lower)), later, lower));
|
||||||
|
assert!(!is_newer_event(Some((later, lower)), earlier, higher));
|
||||||
|
assert!(is_newer_event(Some((earlier, lower)), earlier, higher));
|
||||||
|
assert!(!is_newer_event(Some((earlier, higher)), earlier, lower));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-7
@@ -55,6 +55,9 @@ pub struct HistoryEntry {
|
|||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many times a failed nutzap redemption is retried before giving up
|
||||||
|
const MAX_REDEEM_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
pub struct WalletRegistry {
|
pub struct WalletRegistry {
|
||||||
wallet: Option<keys::WalletData>,
|
wallet: Option<keys::WalletData>,
|
||||||
tokens: HashMap<EventId, TokenEvent>,
|
tokens: HashMap<EventId, TokenEvent>,
|
||||||
@@ -69,6 +72,10 @@ pub struct WalletRegistry {
|
|||||||
pending_nutzaps: Vec<Event>,
|
pending_nutzaps: Vec<Event>,
|
||||||
/// Nutzaps with a redemption job in flight
|
/// Nutzaps with a redemption job in flight
|
||||||
redeeming: HashSet<EventId>,
|
redeeming: HashSet<EventId>,
|
||||||
|
/// Redemption attempts per nutzap, reset on success
|
||||||
|
redeem_attempts: HashMap<EventId, u32>,
|
||||||
|
/// A job errored after possibly mutating the store
|
||||||
|
publish_failed: bool,
|
||||||
store: Option<Arc<WalletRedbDatabase>>,
|
store: Option<Arc<WalletRedbDatabase>>,
|
||||||
wallets: HashMap<MintUrl, Wallet>,
|
wallets: HashMap<MintUrl, Wallet>,
|
||||||
loading: bool,
|
loading: bool,
|
||||||
@@ -119,6 +126,8 @@ impl WalletRegistry {
|
|||||||
redeemed: HashSet::new(),
|
redeemed: HashSet::new(),
|
||||||
pending_nutzaps: Vec::new(),
|
pending_nutzaps: Vec::new(),
|
||||||
redeeming: HashSet::new(),
|
redeeming: HashSet::new(),
|
||||||
|
redeem_attempts: HashMap::new(),
|
||||||
|
publish_failed: false,
|
||||||
store: None,
|
store: None,
|
||||||
wallets: HashMap::new(),
|
wallets: HashMap::new(),
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -378,6 +387,8 @@ impl WalletRegistry {
|
|||||||
self.redeemed.clear();
|
self.redeemed.clear();
|
||||||
self.pending_nutzaps.clear();
|
self.pending_nutzaps.clear();
|
||||||
self.redeeming.clear();
|
self.redeeming.clear();
|
||||||
|
self.redeem_attempts.clear();
|
||||||
|
self.publish_failed = false;
|
||||||
self.store = None;
|
self.store = None;
|
||||||
self.wallets.clear();
|
self.wallets.clear();
|
||||||
self.loading = false;
|
self.loading = false;
|
||||||
@@ -397,6 +408,7 @@ impl WalletRegistry {
|
|||||||
let signer = nostr.read(cx).signer().clone();
|
let signer = nostr.read(cx).signer().clone();
|
||||||
|
|
||||||
let (tx, rx) = flume::bounded::<Event>(100);
|
let (tx, rx) = flume::bounded::<Event>(100);
|
||||||
|
let consumer_signer = signer.clone();
|
||||||
|
|
||||||
self.notification_listener = Some(cx.background_spawn(async move {
|
self.notification_listener = Some(cx.background_spawn(async move {
|
||||||
const MAX_PROCESSED: usize = 1000;
|
const MAX_PROCESSED: usize = 1000;
|
||||||
@@ -417,7 +429,7 @@ impl WalletRegistry {
|
|||||||
| Kind::CashuWalletSpendingHistory
|
| Kind::CashuWalletSpendingHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
let wallet_event = ours && is_cashu || is_token_deletion(&event);
|
let wallet_event = ours && (is_cashu || is_token_deletion(&event));
|
||||||
|
|
||||||
let nutzap = event.kind == Kind::Custom(9321)
|
let nutzap = event.kind == Kind::Custom(9321)
|
||||||
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
|
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
|
||||||
@@ -443,21 +455,22 @@ impl WalletRegistry {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let me = consumer_signer.get_public_key_async().await.ok();
|
||||||
while let Ok(event) = rx.recv_async().await {
|
while let Ok(event) = rx.recv_async().await {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.absorb(event, cx);
|
this.absorb(event, me, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn absorb(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn absorb(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::CashuWallet => self.absorb_wallet(event, cx),
|
Kind::CashuWallet => self.absorb_wallet(event, cx),
|
||||||
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
|
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
|
||||||
Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx),
|
Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx),
|
||||||
Kind::EventDeletion => self.absorb_deletion(event, cx),
|
Kind::EventDeletion => self.absorb_deletion(event, me, cx),
|
||||||
Kind::Custom(9321) => self.absorb_nutzap(event, cx),
|
Kind::Custom(9321) => self.absorb_nutzap(event, cx),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -549,7 +562,15 @@ impl WalletRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn absorb_deletion(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn absorb_deletion(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||||
|
if me != Some(event.pubkey) {
|
||||||
|
log::warn!(
|
||||||
|
"ignoring kind 5 deletion {} from a foreign author",
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let mut removed_any = false;
|
let mut removed_any = false;
|
||||||
for tag in event.tags.iter() {
|
for tag in event.tags.iter() {
|
||||||
if tag.kind() != "e" {
|
if tag.kind() != "e" {
|
||||||
@@ -807,6 +828,9 @@ impl WalletRegistry {
|
|||||||
|
|
||||||
self.job_running = true;
|
self.job_running = true;
|
||||||
|
|
||||||
|
let job_handle = job.clone();
|
||||||
|
let publish_failed = self.publish_failed;
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = Tokio::spawn_result(cx, async move {
|
let result = Tokio::spawn_result(cx, async move {
|
||||||
let ctx = ops::JobCtx {
|
let ctx = ops::JobCtx {
|
||||||
@@ -815,6 +839,7 @@ impl WalletRegistry {
|
|||||||
signer,
|
signer,
|
||||||
client,
|
client,
|
||||||
tokens,
|
tokens,
|
||||||
|
publish_failed,
|
||||||
};
|
};
|
||||||
|
|
||||||
job.run(ctx).await
|
job.run(ctx).await
|
||||||
@@ -823,7 +848,7 @@ impl WalletRegistry {
|
|||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.job_running = false;
|
this.job_running = false;
|
||||||
this.apply_outcome(result, cx);
|
this.apply_outcome(job_handle, result, cx);
|
||||||
|
|
||||||
for job in std::mem::take(&mut this.job_queued) {
|
for job in std::mem::take(&mut this.job_queued) {
|
||||||
this.schedule_job(job, cx);
|
this.schedule_job(job, cx);
|
||||||
@@ -833,10 +858,38 @@ impl WalletRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_outcome(&mut self, result: Result<ops::Outcome, Error>, cx: &mut Context<Self>) {
|
fn apply_outcome(
|
||||||
|
&mut self,
|
||||||
|
job: ops::Job,
|
||||||
|
result: Result<ops::Outcome, Error>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let outcome = match result {
|
let outcome = match result {
|
||||||
Ok(outcome) => outcome,
|
Ok(outcome) => outcome,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
if !matches!(job, ops::Job::CreateQuote { .. }) {
|
||||||
|
self.publish_failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(job, ops::Job::CreateQuote { .. } | ops::Job::Reconcile) {
|
||||||
|
self.schedule_job(ops::Job::Reconcile, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ops::Job::RedeemNutzap { nutzap_id, .. } = &job {
|
||||||
|
self.redeeming.remove(nutzap_id);
|
||||||
|
|
||||||
|
let attempts = self.redeem_attempts.entry(*nutzap_id).or_default();
|
||||||
|
*attempts += 1;
|
||||||
|
|
||||||
|
if *attempts <= MAX_REDEEM_ATTEMPTS {
|
||||||
|
self.job_queued.push(job.clone());
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"giving up redeeming nutzap {nutzap_id} after {MAX_REDEEM_ATTEMPTS} attempts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.emit_error(format!("{error:#}"), cx);
|
self.emit_error(format!("{error:#}"), cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -845,6 +898,7 @@ impl WalletRegistry {
|
|||||||
match outcome {
|
match outcome {
|
||||||
ops::Outcome::Idle => {}
|
ops::Outcome::Idle => {}
|
||||||
ops::Outcome::Reconciled { updates } => {
|
ops::Outcome::Reconciled { updates } => {
|
||||||
|
self.publish_failed = false;
|
||||||
let changed = !updates.is_empty();
|
let changed = !updates.is_empty();
|
||||||
|
|
||||||
for update in updates {
|
for update in updates {
|
||||||
@@ -905,6 +959,7 @@ impl WalletRegistry {
|
|||||||
amount,
|
amount,
|
||||||
} => {
|
} => {
|
||||||
self.redeeming.remove(&nutzap_id);
|
self.redeeming.remove(&nutzap_id);
|
||||||
|
self.redeem_attempts.remove(&nutzap_id);
|
||||||
self.redeemed.insert(nutzap_id);
|
self.redeemed.insert(nutzap_id);
|
||||||
self.apply_token_update(update);
|
self.apply_token_update(update);
|
||||||
self.emit_updated(cx);
|
self.emit_updated(cx);
|
||||||
|
|||||||
+117
-27
@@ -361,6 +361,8 @@ pub(crate) struct JobCtx<S> {
|
|||||||
pub(crate) client: Client,
|
pub(crate) client: Client,
|
||||||
/// Relay token events at schedule time.
|
/// Relay token events at schedule time.
|
||||||
pub(crate) tokens: HashMap<EventId, TokenEvent>,
|
pub(crate) tokens: HashMap<EventId, TokenEvent>,
|
||||||
|
/// A previous job errored after possibly mutating the store
|
||||||
|
pub(crate) publish_failed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S> JobCtx<S>
|
impl<S> JobCtx<S>
|
||||||
@@ -426,11 +428,71 @@ where
|
|||||||
log::warn!("saga recovery failed for {mint_url}: {error:#}");
|
log::warn!("saga recovery failed for {mint_url}: {error:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let minted = wallet.mint_unissued_quotes().await?;
|
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
|
||||||
if minted == Amount::ZERO {
|
let states = vec![State::Pending, State::Reserved, State::PendingSpent];
|
||||||
|
|
||||||
|
let in_flight = wallet.get_proofs_by_states(states).await?;
|
||||||
|
let in_flight_ys = proof_ys(in_flight.iter())?;
|
||||||
|
|
||||||
|
let mut relay_ys = HashSet::new();
|
||||||
|
let mut unseen = 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) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if local_ys.contains(&info.y) || in_flight_ys.contains(&info.y) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unseen.push(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unseen.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unseen_proofs: Proofs = unseen.iter().map(|info| info.proof.clone()).collect();
|
||||||
|
|
||||||
|
let spent = match wallet.check_proofs_spent(unseen_proofs).await {
|
||||||
|
Ok(states) => states
|
||||||
|
.into_iter()
|
||||||
|
.filter(|state| state.state == State::Spent)
|
||||||
|
.map(|state| state.y)
|
||||||
|
.collect::<HashSet<_>>(),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("mint state check failed, accepting unseen proofs: {error:#}");
|
||||||
|
HashSet::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let added: Vec<ProofInfo> = unseen
|
||||||
|
.into_iter()
|
||||||
|
.filter(|info| !spent.contains(&info.y))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !added.is_empty() {
|
||||||
|
wallet.localstore.update_proofs(added, Vec::new()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (mint_url, wallet) in &self.wallets {
|
||||||
|
let minted = wallet.mint_unissued_quotes().await?;
|
||||||
|
if minted != Amount::ZERO {
|
||||||
let update = self
|
let update = self
|
||||||
.sync_token_events(
|
.sync_token_events(
|
||||||
wallet,
|
wallet,
|
||||||
@@ -452,39 +514,39 @@ where
|
|||||||
updates.push(update);
|
updates.push(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (mint_url, wallet) in &self.wallets {
|
let relay_ys = relay_proof_ys(&tokens, mint_url);
|
||||||
let mut relay_ys = HashSet::new();
|
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
|
||||||
let mut added = Vec::new();
|
|
||||||
|
|
||||||
for token in tokens
|
if local_ys != relay_ys {
|
||||||
.values()
|
if self.publish_failed {
|
||||||
.filter(|token| token.mint.as_str() == mint_url.to_string())
|
let update = self
|
||||||
{
|
.sync_token_events(
|
||||||
for proof in &token.proofs {
|
wallet,
|
||||||
let info = match proof_info(proof, mint_url) {
|
nostr_mint(mint_url)?,
|
||||||
Ok(info) => info,
|
&previous_ids(&tokens, mint_url),
|
||||||
Err(error) => {
|
None,
|
||||||
log::warn!("skipping proof: {error:#}");
|
)
|
||||||
continue;
|
.await?;
|
||||||
}
|
|
||||||
};
|
|
||||||
if relay_ys.insert(info.y) {
|
|
||||||
added.push(info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let unspent = wallet.get_unspent_proofs().await?;
|
for id in &update.destroyed {
|
||||||
let removed = unspent
|
tokens.remove(id);
|
||||||
.iter()
|
}
|
||||||
|
tokens.insert(update.event_id, update.token.clone());
|
||||||
|
updates.push(update);
|
||||||
|
} else {
|
||||||
|
let removed = wallet
|
||||||
|
.get_unspent_proofs()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
.filter(|proof| match proof.y() {
|
.filter(|proof| match proof.y() {
|
||||||
Ok(y) => !relay_ys.contains(&y),
|
Ok(y) => !relay_ys.contains(&y),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
})
|
})
|
||||||
.map(|proof| proof.y())
|
.map(|proof| proof.y())
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
wallet.localstore.update_proofs(Vec::new(), removed).await?;
|
||||||
wallet.localstore.update_proofs(added, removed).await?;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Outcome::Reconciled { updates })
|
Ok(Outcome::Reconciled { updates })
|
||||||
@@ -499,6 +561,34 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn proof_ys<'a>(
|
||||||
|
proofs: impl Iterator<Item = &'a Proof>,
|
||||||
|
) -> Result<HashSet<cdk::nuts::PublicKey>, Error> {
|
||||||
|
proofs.map(|proof| proof.y().map_err(Error::from)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proof ys referenced by the relay token events of one mint
|
||||||
|
fn relay_proof_ys(
|
||||||
|
tokens: &HashMap<EventId, TokenEvent>,
|
||||||
|
mint: &MintUrl,
|
||||||
|
) -> HashSet<cdk::nuts::PublicKey> {
|
||||||
|
let mut ys = HashSet::new();
|
||||||
|
for token in tokens
|
||||||
|
.values()
|
||||||
|
.filter(|token| token.mint.as_str() == mint.to_string())
|
||||||
|
{
|
||||||
|
for proof in &token.proofs {
|
||||||
|
match proof_info(proof, mint) {
|
||||||
|
Ok(info) => {
|
||||||
|
ys.insert(info.y);
|
||||||
|
}
|
||||||
|
Err(error) => log::warn!("skipping proof: {error:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ys
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
|
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
|
||||||
async fn recipient_wallet_info<S>(
|
async fn recipient_wallet_info<S>(
|
||||||
ctx: &JobCtx<S>,
|
ctx: &JobCtx<S>,
|
||||||
|
|||||||
+79
-18
@@ -223,6 +223,11 @@ impl Global for GlobalAppSettings {}
|
|||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
/// Settings
|
/// Settings
|
||||||
inner: Entity<Settings>,
|
inner: Entity<Settings>,
|
||||||
|
/// The pending save task, so writes to the settings file complete in order
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
save_task: Option<Task<()>>,
|
||||||
|
/// Set after a failed load so its own save cannot overwrite the file with defaults
|
||||||
|
suppress_next_save: bool,
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
@@ -315,11 +320,16 @@ impl AppSettings {
|
|||||||
|
|
||||||
// Run at the end of current cycle
|
// Run at the end of current cycle
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
if let Err(error) = entity.update(cx, |this, cx| this.load(cx)) {
|
||||||
|
log::error!("failed to load settings: {error:#}");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
inner,
|
inner,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
save_task: None,
|
||||||
|
suppress_next_save: false,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,27 +391,46 @@ impl AppSettings {
|
|||||||
|
|
||||||
/// Load settings
|
/// Load settings
|
||||||
fn load(&mut self, cx: &mut Context<Self>) {
|
fn load(&mut self, cx: &mut Context<Self>) {
|
||||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Option<Settings>, Error>> = cx.background_spawn(async move {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
let path = config_dir().join(".settings");
|
let path = config_dir().join(".settings");
|
||||||
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
match smol::fs::read_to_string(&path).await {
|
||||||
return Ok(serde_json::from_str(&content)?);
|
Ok(content) => {
|
||||||
|
let settings: Settings =
|
||||||
|
serde_json::from_str(&content).map_err(|error| {
|
||||||
|
anyhow!("failed to parse {}: {error}", path.display())
|
||||||
|
})?;
|
||||||
|
return Ok(Some(settings));
|
||||||
|
}
|
||||||
|
// No settings file yet on a fresh install, so keep the defaults
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(anyhow!("failed to read {}: {error}", path.display()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
|
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
|
||||||
return Ok(serde_json::from_str(&content)?);
|
let settings: Settings = serde_json::from_str(&content)
|
||||||
|
.map_err(|error| anyhow!("failed to parse saved settings: {error}"))?;
|
||||||
|
return Ok(Some(settings));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!("Not found"))
|
Ok(None)
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let mut settings = task.await.unwrap_or(Settings::default());
|
let (mut settings, load_failed) = match task.await {
|
||||||
|
Ok(settings) => (settings.unwrap_or_default(), false),
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("failed to load settings, keeping defaults: {error:#}");
|
||||||
|
(Settings::default(), true)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Move settings still pointed at the old default file server over to the new one
|
// Move settings still pointed at the old default file server over to the new one
|
||||||
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
|
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
|
||||||
@@ -409,31 +438,63 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update settings
|
// Update settings
|
||||||
this.update(cx, |this, cx| {
|
if let Err(error) = this.update(cx, |this, cx| {
|
||||||
|
// A failed load must not trigger the save that would overwrite the file
|
||||||
|
this.suppress_next_save = load_failed;
|
||||||
this.set_settings(settings, cx);
|
this.set_settings(settings, cx);
|
||||||
this.apply_theme(cx);
|
this.apply_theme(cx);
|
||||||
cx.refresh_windows();
|
cx.refresh_windows();
|
||||||
})
|
}) {
|
||||||
.ok();
|
log::error!("failed to apply loaded settings: {error:#}");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save settings
|
/// Save settings
|
||||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||||
|
// Consume the failed-load suppression, so the next user change saves again
|
||||||
|
if self.suppress_next_save {
|
||||||
|
self.suppress_next_save = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let settings = self.inner.read(cx);
|
let settings = self.inner.read(cx);
|
||||||
if let Ok(content) = serde_json::to_string(&settings) {
|
let content = match serde_json::to_string(settings) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("failed to serialize settings: {error:#}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
cx.background_spawn(async move {
|
{
|
||||||
|
let previous_task = self.save_task.take();
|
||||||
|
self.save_task = Some(cx.background_spawn(async move {
|
||||||
|
// Await the previous write, so saves to the file complete in order
|
||||||
|
if let Some(previous_task) = previous_task {
|
||||||
|
previous_task.await;
|
||||||
|
}
|
||||||
|
|
||||||
let path = config_dir().join(".settings");
|
let path = config_dir().join(".settings");
|
||||||
smol::fs::write(&path, content).await.ok();
|
let temporary_path = config_dir().join(".settings.tmp");
|
||||||
})
|
|
||||||
.detach();
|
let result: std::io::Result<()> = async {
|
||||||
|
smol::fs::write(&temporary_path, &content).await?;
|
||||||
|
smol::fs::rename(&temporary_path, &path).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
log::error!("failed to save settings to {}: {error}", path.display());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
|
if let Err(error) = common::web_store::set(SETTINGS_KEY, &content) {
|
||||||
log::warn!("failed to persist settings: {e}");
|
log::error!("failed to persist settings: {error:#}");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, anyhow};
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
@@ -30,7 +30,7 @@ impl SpeechRecognizer for ParakeetRecognizer {
|
|||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||||
if super::model::installed() {
|
if super::model::installed() {
|
||||||
worker().send_or_log(Command::Warm(super::model::dir()));
|
send(Command::Warm(super::model::dir())).map_err(SpeechError::recognizer)?;
|
||||||
} else if super::model::status().is_downloading() {
|
} else if super::model::status().is_downloading() {
|
||||||
return Err(SpeechError::recognizer(anyhow!(
|
return Err(SpeechError::recognizer(anyhow!(
|
||||||
"The speech model is still downloading. Try again when it finishes."
|
"The speech model is still downloading. Try again when it finishes."
|
||||||
@@ -79,7 +79,11 @@ impl RecognitionSession for ParakeetSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (sender, receiver) = flume::unbounded();
|
let (sender, receiver) = flume::unbounded();
|
||||||
worker().send_or_log(Command::Transcribe { samples, sender });
|
|
||||||
|
if let Err(error) = send(Command::Transcribe { samples, sender }) {
|
||||||
|
sink.error(SpeechError::recognizer(error), cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self._result = Some(cx.spawn(async move |cx| {
|
self._result = Some(cx.spawn(async move |cx| {
|
||||||
let result = receiver.recv_async().await;
|
let result = receiver.recv_async().await;
|
||||||
@@ -109,23 +113,50 @@ enum Command {
|
|||||||
Unload,
|
Unload,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A handle to the single inference worker.
|
fn worker_slot() -> &'static Mutex<Option<flume::Sender<Command>>> {
|
||||||
fn worker() -> &'static flume::Sender<Command> {
|
static WORKER: OnceLock<Mutex<Option<flume::Sender<Command>>>> = OnceLock::new();
|
||||||
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
|
WORKER.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
WORKER.get_or_init(|| {
|
/// A handle to the live worker, spawning a replacement after an idle exit.
|
||||||
let (sender, receiver) = flume::unbounded::<Command>();
|
fn worker() -> Result<flume::Sender<Command>> {
|
||||||
|
let mut slot = worker_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
let spawned = std::thread::Builder::new()
|
if slot.as_ref().is_some_and(|sender| sender.is_disconnected()) {
|
||||||
.name("speech-parakeet".into())
|
*slot = None;
|
||||||
.spawn(move || run_worker(receiver));
|
|
||||||
|
|
||||||
if let Err(error) = spawned {
|
|
||||||
log::error!("failed to spawn the speech worker: {error}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sender
|
if let Some(sender) = slot.as_ref() {
|
||||||
})
|
return Ok(sender.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded::<Command>();
|
||||||
|
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("speech-parakeet".into())
|
||||||
|
.spawn(move || run_worker(receiver))
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
*slot = Some(sender.clone());
|
||||||
|
Ok(sender)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("failed to spawn the speech worker: {error}");
|
||||||
|
Err(anyhow!("Could not start the speech worker: {error}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliver a command, retrying once on a fresh worker if the previous one exited.
|
||||||
|
fn send(command: Command) -> Result<()> {
|
||||||
|
let command = match worker()?.send(command) {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(error) => error.into_inner(),
|
||||||
|
};
|
||||||
|
// The worker can exit between the liveness check and the send, so retry once.
|
||||||
|
worker()?.send(command).context("the speech worker exited")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_worker(receiver: flume::Receiver<Command>) {
|
fn run_worker(receiver: flume::Receiver<Command>) {
|
||||||
@@ -192,21 +223,11 @@ fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) ->
|
|||||||
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
|
.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.
|
/// Retire cached weights, e.g. after the model was deleted.
|
||||||
pub(crate) fn unload() {
|
pub(crate) fn unload() {
|
||||||
worker().send_or_log(Command::Unload);
|
if let Err(error) = send(Command::Unload) {
|
||||||
|
log::warn!("failed to unload the speech model: {error}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
|
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ sqlite-wasm-vfs = "0.2"
|
|||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||||
nostr-lmdb.workspace = true
|
nostr-lmdb.workspace = true
|
||||||
|
nostr-memory.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
gpui_tokio.workspace = true
|
gpui_tokio.workspace = true
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ pub async fn download_and_decrypt_file(
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| fallback_name(&file.url));
|
.unwrap_or_else(|| fallback_name(&file.url));
|
||||||
|
|
||||||
let local = store_file(&name, &file.mime, &data).await?;
|
let local = store_file(&file.mime, &data).await?;
|
||||||
|
|
||||||
Ok(DownloadedFile {
|
Ok(DownloadedFile {
|
||||||
local,
|
local,
|
||||||
@@ -415,10 +415,11 @@ pub async fn download_and_decrypt_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Persist decrypted bytes somewhere the platform can render or open them
|
/// Persist decrypted bytes somewhere the platform can render or open them
|
||||||
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
|
async fn store_file(mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
let digest = sha256_hex(name.as_bytes());
|
// Key the cache entry on the content hash, so same-named attachments can't collide
|
||||||
|
let digest = sha256_hex(data);
|
||||||
let extension = mime_guess::get_mime_extensions_str(mime)
|
let extension = mime_guess::get_mime_extensions_str(mime)
|
||||||
.and_then(|extensions| extensions.first())
|
.and_then(|extensions| extensions.first())
|
||||||
.copied()
|
.copied()
|
||||||
@@ -428,7 +429,6 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
|
|||||||
.join("coop-files")
|
.join("coop-files")
|
||||||
.join(format!("{digest}.{extension}"));
|
.join(format!("{digest}.{extension}"));
|
||||||
|
|
||||||
if smol::fs::metadata(&path).await.is_err() {
|
|
||||||
let Some(parent) = path.parent() else {
|
let Some(parent) = path.parent() else {
|
||||||
bail!("Invalid file path");
|
bail!("Invalid file path");
|
||||||
};
|
};
|
||||||
@@ -438,14 +438,12 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
|
|||||||
let partial = path.with_extension("download");
|
let partial = path.with_extension("download");
|
||||||
smol::fs::write(&partial, data).await?;
|
smol::fs::write(&partial, data).await?;
|
||||||
smol::fs::rename(&partial, &path).await?;
|
smol::fs::rename(&partial, &path).await?;
|
||||||
}
|
|
||||||
|
|
||||||
Ok(LocalFile::Path(path))
|
Ok(LocalFile::Path(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
let _ = name;
|
|
||||||
let url = crate::web_file::blob_url(mime, data)?;
|
let url = crate::web_file::blob_url(mime, data)?;
|
||||||
Ok(LocalFile::Url(url))
|
Ok(LocalFile::Url(url))
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-8
@@ -16,6 +16,8 @@ use nostr_connect::prelude::*;
|
|||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use nostr_lmdb::prelude::*;
|
use nostr_lmdb::prelude::*;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use nostr_memory::prelude::MemoryDatabase;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
mod blossom;
|
mod blossom;
|
||||||
@@ -114,15 +116,27 @@ impl NostrRegistry {
|
|||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||||
|
|
||||||
|
// NostrLmdb::open schedules nothing back onto the GPUI executor, so blocking here can't deadlock
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let database = cx.foreground_executor().block_on(async move {
|
let (database, database_error) = match cx
|
||||||
NostrLmdb::open(config_dir().join("nostr"))
|
.foreground_executor()
|
||||||
.await
|
.block_on(NostrLmdb::open(config_dir().join("nostr")))
|
||||||
.expect("Failed to initialize database")
|
{
|
||||||
});
|
Ok(database) => (Arc::new(database) as Arc<dyn NostrDatabase>, None),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to open the local database, falling back to memory: {e}");
|
||||||
|
|
||||||
|
(
|
||||||
|
Arc::new(MemoryDatabase::unbounded()) as Arc<dyn NostrDatabase>,
|
||||||
|
Some(format!(
|
||||||
|
"Local database unavailable, using an in-memory database instead: {e}"
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let database = web_database::take();
|
let (database, database_error) = (web_database::take(), None::<String>);
|
||||||
|
|
||||||
// Construct the nostr client
|
// Construct the nostr client
|
||||||
let client = ClientBuilder::default()
|
let client = ClientBuilder::default()
|
||||||
@@ -141,6 +155,10 @@ impl NostrRegistry {
|
|||||||
// Connect to bootstrap relays once the registry has been returned to the app
|
// Connect to bootstrap relays once the registry has been returned to the app
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
let _ = entity.update(cx, |this, cx| {
|
let _ = entity.update(cx, |this, cx| {
|
||||||
|
if let Some(error) = database_error {
|
||||||
|
cx.emit(StateEvent::error(error));
|
||||||
|
}
|
||||||
|
|
||||||
this.connect_bootstrap_relays(cx);
|
this.connect_bootstrap_relays(cx);
|
||||||
|
|
||||||
if let Some(secret) = cli_key {
|
if let Some(secret) = cli_key {
|
||||||
@@ -445,8 +463,14 @@ impl NostrRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let credential = with_master_key(&uri_string, &keys);
|
|
||||||
let write = credentials::write(cx, "bunker", credential.as_bytes());
|
// The URI embeds the client key, so never persist it where web stores it unencrypted
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let write =
|
||||||
|
credentials::write(cx, "bunker", with_master_key(&uri_string, &keys).as_bytes());
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let write = Task::ready(Ok::<(), Error>(()));
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let signer = NostrConnect::builder(connect_uri, keys)
|
let signer = NostrConnect::builder(connect_uri, keys)
|
||||||
@@ -709,6 +733,7 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Embed the master key in a bunker URI so reconnects reuse the same local key
|
// Embed the master key in a bunker URI so reconnects reuse the same local key
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||||
let separator = if uri.contains('?') { '&' } else { '?' };
|
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||||
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
||||||
|
|||||||
@@ -29,26 +29,17 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
|
|||||||
pub struct ContactListPanel {
|
pub struct ContactListPanel {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
/// Npub input
|
/// Npub input
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
|
|
||||||
/// Whether the panel is updating
|
/// Whether the panel is updating
|
||||||
updating: bool,
|
updating: bool,
|
||||||
|
|
||||||
/// Error message
|
/// Error message
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
|
|
||||||
/// All contacts
|
/// All contacts
|
||||||
contacts: HashSet<PublicKey>,
|
contacts: HashSet<Contact>,
|
||||||
|
|
||||||
/// Person directory for the contacts
|
/// Person directory for the contacts
|
||||||
directory: Entity<Directory>,
|
directory: Entity<Directory>,
|
||||||
|
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
/// Background tasks
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,33 +83,50 @@ impl ContactListPanel {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<HashSet<Contact>, Error>> = cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
.kind(Kind::ContactList)
|
.kind(Kind::ContactList)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let contact_list: HashSet<PublicKey> = client
|
let contact_list: HashSet<Contact> = client
|
||||||
.database()
|
.database()
|
||||||
.query(filter)
|
.query(filter)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.map(|event| event.tags.public_keys().collect())
|
.map(|event| {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tag| match Nip02Tag::try_from(tag) {
|
||||||
|
Ok(Nip02Tag::PublicKey {
|
||||||
|
public_key,
|
||||||
|
relay_hint,
|
||||||
|
alias,
|
||||||
|
}) => Some(Contact {
|
||||||
|
public_key,
|
||||||
|
relay_url: relay_hint,
|
||||||
|
alias,
|
||||||
|
}),
|
||||||
|
Err(_) => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(contact_list)
|
Ok(contact_list)
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let public_keys = task.await?;
|
let contacts = task.await?;
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.directory.update(cx, |directory, cx| {
|
this.directory.update(cx, |directory, cx| {
|
||||||
directory.track(public_keys.iter().copied(), cx)
|
directory.track(contacts.iter().map(|contact| contact.public_key), cx)
|
||||||
});
|
});
|
||||||
this.contacts.extend(public_keys);
|
this.contacts.extend(contacts);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -130,7 +138,7 @@ impl ContactListPanel {
|
|||||||
let value = self.input.read(cx).value().to_string();
|
let value = self.input.read(cx).value().to_string();
|
||||||
|
|
||||||
if let Ok(public_key) = PublicKey::parse(&value) {
|
if let Ok(public_key) = PublicKey::parse(&value) {
|
||||||
if self.contacts.insert(public_key) {
|
if self.contacts.insert(Contact::new(public_key)) {
|
||||||
self.directory
|
self.directory
|
||||||
.update(cx, |directory, cx| directory.track([public_key], cx));
|
.update(cx, |directory, cx| directory.track([public_key], cx));
|
||||||
self.input.update(cx, |this, cx| {
|
self.input.update(cx, |this, cx| {
|
||||||
@@ -144,7 +152,8 @@ impl ContactListPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||||
self.contacts.remove(public_key);
|
self.contacts
|
||||||
|
.retain(|contact| contact.public_key != *public_key);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,11 +193,7 @@ impl ContactListPanel {
|
|||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
// Get contacts
|
// Get contacts
|
||||||
let contacts: Vec<Contact> = self
|
let contacts: Vec<Contact> = self.contacts.iter().cloned().collect();
|
||||||
.contacts
|
|
||||||
.iter()
|
|
||||||
.map(|public_key| Contact::new(*public_key))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Set updating state
|
// Set updating state
|
||||||
self.set_updating(true, cx);
|
self.set_updating(true, cx);
|
||||||
@@ -230,8 +235,9 @@ impl ContactListPanel {
|
|||||||
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|
||||||
for (ix, public_key) in self.contacts.iter().enumerate() {
|
for (ix, contact) in self.contacts.iter().enumerate() {
|
||||||
let profile = self.directory.read(cx).person(public_key, cx);
|
let public_key = contact.public_key;
|
||||||
|
let profile = self.directory.read(cx).person(&public_key, cx);
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -262,12 +268,9 @@ impl ContactListPanel {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.invisible()
|
.invisible()
|
||||||
.group_hover("", |this| this.visible())
|
.group_hover("", |this| this.visible())
|
||||||
.on_click({
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
let public_key = public_key.to_owned();
|
|
||||||
cx.listener(move |this, _ev, _window, cx| {
|
|
||||||
this.remove(&public_key, cx);
|
this.remove(&public_key, cx);
|
||||||
})
|
})),
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire workspace (~35k lines, 18 crates + `desktop` + `web`)
|
Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire workspace (~35k lines, 18 crates + `desktop` + `web`)
|
||||||
|
|
||||||
|
> **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been
|
||||||
|
> implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing).
|
||||||
|
> See §9 for the per-fix log.
|
||||||
|
|
||||||
## 1. Scope and Method
|
## 1. Scope and Method
|
||||||
|
|
||||||
Every crate was audited file-by-file against the project conventions (`AGENTS.md`), Rust best
|
Every crate was audited file-by-file against the project conventions (`AGENTS.md`), Rust best
|
||||||
@@ -823,3 +827,81 @@ claims were verified against the vendored `gpui-pre 0.3.12`, `gpui-base 0.9.1`,
|
|||||||
`gpui-component 0.9.1`, and rust-nostr sources; the Critical and top-High findings were
|
`gpui-component 0.9.1`, and rust-nostr sources; the Critical and top-High findings were
|
||||||
independently re-verified by hand against the working tree at `8fa79aef`. No files were modified
|
independently re-verified by hand against the working tree at `8fa79aef`. No files were modified
|
||||||
during the audit.*
|
during the audit.*
|
||||||
|
|
||||||
|
## 9. P0 Remediation Log (2026-11-12)
|
||||||
|
|
||||||
|
All ten P0 items fixed. Validation after the full batch: `cargo clippy --workspace --all-targets`
|
||||||
|
zero warnings, `cargo fmt --all --check` clean, `cargo test --workspace` 35 passed / 0 failed.
|
||||||
|
Diff: 15 files, +816/−273.
|
||||||
|
|
||||||
|
1. **nip60 deletion spoofing** — `nip60/src/lib.rs`: precedence fixed to
|
||||||
|
`ours && (is_cashu || is_token_deletion(..))`; `absorb`/`absorb_deletion` now take `me` and
|
||||||
|
reject foreign-authored kind 5 events (defense in depth; the live notification gate was the
|
||||||
|
only protection before, and `absorb_deletion` had no author check). The DB load path was
|
||||||
|
already author-filtered and is unchanged.
|
||||||
|
2. **browser-signer-proxy hardening** — per-session UUID token generated at startup, embedded in
|
||||||
|
the served page (`inject_session_token`), required on `/api/*` (403 otherwise); exact `Host`
|
||||||
|
match (DNS-rebinding defense); all CORS headers and the OPTIONS preflight removed;
|
||||||
|
`cancel_request` removes timed-out requests from both maps so plaintext is dropped;
|
||||||
|
centralized best-effort `write_response`. `proxy.js` authenticates all three fetches. Token
|
||||||
|
never appears in the URL the app opens.
|
||||||
|
3. **state startup crash loop** — `NostrLmdb::open` failure now logs and falls back to
|
||||||
|
`MemoryDatabase::unbounded()` (nostr-memory added to the desktop deps), emitting
|
||||||
|
`StateEvent::error` via the existing deferred path; the `block_on` stays (safe: the LMDB open
|
||||||
|
schedules nothing back onto the GPUI executor — now commented).
|
||||||
|
4. **state web localStorage key** — `import_bunker` on wasm32 no longer persists the bunker
|
||||||
|
credential (session is memory-only; desktop keyring unchanged); `restore_session` degrades to
|
||||||
|
`NoSigner` when nothing is stored (verified). **Known behavior change: web users re-enter their
|
||||||
|
bunker URI after each reload.**
|
||||||
|
5. **state file-cache collision** — cache filename now derived from the decrypted content's
|
||||||
|
sha256 instead of the filename; the metadata guard is gone and every store goes through
|
||||||
|
temp-write + atomic rename.
|
||||||
|
6. **nip60 reconcile money-safety** — `ops.rs::reconcile` rewritten: relay tokens are converged
|
||||||
|
into the store BEFORE any publish (closes the `del`-before-converge race); relay proofs already
|
||||||
|
tracked locally (unspent or in-flight Pending/Reserved/PendingSpent) are never re-added, and
|
||||||
|
unseen proofs are checked against the mint (NUT-08 `check_proofs_spent`, best-effort) before
|
||||||
|
being accepted as Unspent; when a prior job errored after mutating the store
|
||||||
|
(`publish_failed` flag), reconcile republishes the store's unspent set instead of trusting
|
||||||
|
relay state — healing stranded sends/receives; without the flag the relay-trust removal path
|
||||||
|
(cross-device spend convergence) is preserved. A failing Reconcile does not reschedule itself
|
||||||
|
(no infinite loop).
|
||||||
|
7. **nip60 stuck nutzap redemptions** — `apply_outcome` now receives the failed job; on
|
||||||
|
`RedeemNutzap` error the id is removed from `redeeming` and requeued up to
|
||||||
|
`MAX_REDEEM_ATTEMPTS = 3` (cleared on success, reset on signer change), so transient mint
|
||||||
|
failures no longer wedge redemptions until restart.
|
||||||
|
8. **Reactions double-count** — `chat_ui::insert_reaction` replaces per (author, target) per
|
||||||
|
NIP-25, making the backup gift-wrap echo idempotent (verified: optimistic rumor and the
|
||||||
|
re-delivered seal carry the same event id) and history reloads non-duplicating.
|
||||||
|
9. **Whitespace collapse** — `common/media_extractor.rs` rewritten: whitespace is tidied only at
|
||||||
|
URL-removal boundaries; line breaks in untouched regions survive; regex hoisted to
|
||||||
|
`LazyLock`; 9 unit tests added (multi-line preservation, URL-only messages, digit/emoji edges).
|
||||||
|
10. **Contact-list data loss** — `contact_list.rs` now parses the stored event into full
|
||||||
|
`nostr::nips::nip02::Contact` values (relay hint + alias preserved through load → add/remove
|
||||||
|
→ republish); new contacts still get `Contact::new`.
|
||||||
|
11. **Speech worker lifetime** — `parakeet.rs`: the `OnceLock<Sender>` became a resettable
|
||||||
|
`Mutex<Option<Sender>>`; a dead worker (30 s idle exit) is detected via `is_disconnected`, the
|
||||||
|
slot cleared, and a replacement thread spawned lazily on the next command; spawn failure no
|
||||||
|
longer caches a dead sender; failures surface through `sink.error` / returned `Err` instead
|
||||||
|
of a debug-level swallow (one retry covers the liveness-check/send race).
|
||||||
|
12. **Settings durability** — a failed load logs and keeps defaults in memory without
|
||||||
|
persisting (`suppress_next_save`, consumed by the next real change); fresh-install NotFound is
|
||||||
|
no longer conflated with read/parse failures; saves are serialized through a single
|
||||||
|
`save_task` slot (chained, FIFO order) with temp-file + atomic rename; serialization and
|
||||||
|
write failures are logged.
|
||||||
|
13. **nip29 ordering guards** — shared `is_newer_event` (strict `(created_at, id)` total order):
|
||||||
|
`Group::absorb_state` keeps a per-kind stamp (written only after a successful apply, so a
|
||||||
|
malformed newer event can't poison the guard) and rejects stale replays while ascending cache
|
||||||
|
hydration still applies; the registry's `list_stamp` seeds from the cached list event at
|
||||||
|
startup, is checked in `merge` before applying, updated by `publish_list` (closing the
|
||||||
|
leave→resurrect window), and reset on signer change. 3 new tests across protocol/group.
|
||||||
|
14. **nip60_ui invoice flow** — *not* in this batch: it is P1 (the audit lists it as High but the
|
||||||
|
P0 list ranks it 16th via `nip60_ui/wallet.rs`); `mint_quotes()`/`claim_mint_quote()` remain
|
||||||
|
dead until the P1 UI work lands. No other P0 code paths depend on it.
|
||||||
|
|
||||||
|
Behavior changes to be aware of when testing:
|
||||||
|
- Web: bunker credentials are no longer persisted (re-connect after each reload).
|
||||||
|
- Desktop: a corrupted/unreadable local database now starts with an empty in-memory history and
|
||||||
|
shows an error event instead of crash-looping; relays re-sync as before.
|
||||||
|
- nip60: reconcile now performs one NUT-08 mint round-trip when unseen relay proofs appear.
|
||||||
|
- Reactions render per NIP-25 replace semantics (one reaction per author per target).
|
||||||
|
- Messages keep their line breaks; only URL-adjacent whitespace is tidied.
|
||||||
|
|||||||
Reference in New Issue
Block a user