Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ nostr = { version = "0.44", features = ["nip44", "nip49"] }
getrandom = "0.2"
zeroize = "1"
percent-encoding = "2"
reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] }
reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking", "gzip"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
url = "2"
buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
Expand Down
18 changes: 2 additions & 16 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,6 @@ fn identity_from_env() -> Option<Keys> {
/// Returned as a `Result` so the fail-closed invariant is testable — callers
/// must never substitute a redirect-following client on build failure. Shares
/// the localhost `resolve`/pool config with the app-wide `http_client`.
pub fn build_media_fetch_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(1)
.redirect(reqwest::redirect::Policy::none())
.build()
}

pub fn build_app_state() -> AppState {
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
// in setup() will replace the ephemeral placeholder with a persisted key.
Expand All @@ -192,13 +183,8 @@ pub fn build_app_state() -> AppState {
AppState {
keys: Mutex::new(keys),
identity_storage: AtomicU8::new(identity_storage as u8),
http_client: reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(1)
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
media_fetch_client: build_media_fetch_client().expect(
http_client: crate::http_client::build().unwrap_or_else(|_| reqwest::Client::new()),
media_fetch_client: crate::http_client::build_media().expect(
"media_fetch_client must build with redirect::Policy::none(); a \
redirect-following fallback would forward the minted media auth \
header across origins (redirect-hop SSRF)",
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -937,7 +937,7 @@ mod tests {
});

// Drive the exact client the command path uses, not an ad-hoc one.
let client = crate::app_state::build_media_fetch_client()
let client = crate::http_client::build_media()
.expect("media fetch client must build with no-redirect policy");
let resp = client
.get(format!("http://{addr}/media/clip.mp4"))
Expand Down Expand Up @@ -975,7 +975,7 @@ mod tests {
// panics loudly (see `build_app_state`) rather than substituting an
// insecure client.
assert!(
crate::app_state::build_media_fetch_client().is_ok(),
crate::http_client::build_media().is_ok(),
"media fetch client must build; a redirect-following fallback is forbidden",
);
}
Expand Down
87 changes: 87 additions & 0 deletions desktop/src-tauri/src/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//! HTTP clients with separate decompression contracts for relay JSON and media.

pub fn build() -> reqwest::Result<reqwest::Client> {
base().build()
}

/// Build the media client without redirects or transparent decoding.
///
/// Media callers forward upstream Content-Length/Content-Range. Decoding would
/// change the body while stripping or invalidating those video-seeking headers.
pub fn build_media() -> reqwest::Result<reqwest::Client> {
base()
.no_gzip()
.redirect(reqwest::redirect::Policy::none())
.build()
}

fn base() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(1)
}

#[cfg(test)]
mod tests {
use axum::{http::HeaderMap, response::Response, routing::get, Router};

const GZIP_BODY: &[u8] = &[
31, 139, 8, 0, 0, 0, 0, 0, 2, 255, 43, 72, 76, 207, 204, 75, 44, 201, 204, 207, 83, 40, 72,
172, 204, 201, 79, 76, 1, 0, 132, 249, 73, 160, 18, 0, 0, 0,
];

async fn serve(app: Router) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(address, server)
}

#[tokio::test]
async fn app_client_transparently_decodes_gzip() {
let app = Router::new().route(
"/",
get(|headers: HeaderMap| async move {
assert!(headers
.get("accept-encoding")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.split(',').any(|coding| coding.trim() == "gzip")));
Response::builder()
.header("content-encoding", "gzip")
.header("content-length", GZIP_BODY.len())
.body(axum::body::Body::from(GZIP_BODY))
.unwrap()
}),
);
let (address, server) = serve(app).await;
let response = super::build()
.unwrap()
.get(format!("http://{address}/"))
.send()
.await
.unwrap();
assert_eq!(response.text().await.unwrap(), "pagination payload");
server.abort();
}

#[tokio::test]
async fn media_client_does_not_advertise_gzip() {
let app = Router::new().route(
"/",
get(|headers: HeaderMap| async move {
assert!(headers.get("accept-encoding").is_none());
"range bytes"
}),
);
let (address, server) = serve(app).await;
let response = super::build_media()
.unwrap()
.get(format!("http://{address}/"))
.send()
.await
.unwrap();
assert_eq!(response.text().await.unwrap(), "range bytes");
server.abort();
}
}
8 changes: 4 additions & 4 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod deep_link;
mod egress_guard;
mod event_sync;
mod events;
mod http_client;
mod huddle;
mod identity_storage;
mod initial_window;
Expand Down Expand Up @@ -354,10 +355,9 @@ pub fn run() {
tauri::async_runtime::spawn(crate::mesh_llm::start_coordinator(app_handle.clone()));
}

// Start the localhost media streaming proxy. Uses the shared HTTP
// client so VPN tunnelling applies. The port is stored in AppState
// and exposed to the frontend via the `get_media_proxy_port` command.
let proxy_client = state.http_client.clone();
// Use the no-redirect, no-transparent-decompression media client:
// the proxy forwards range metadata verbatim for video seeking.
let proxy_client = state.media_fetch_client.clone();
let proxy_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
let port = media_proxy::spawn_media_proxy(proxy_client, proxy_handle.clone()).await;
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/media_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ pub async fn handle_buzz_media(

// Forward Range header if present — enables video seeking through the proxy.
let mut upstream = state
.http_client
.media_fetch_client
.get(&upstream_url)
.timeout(std::time::Duration::from_secs(60));

Expand Down
41 changes: 38 additions & 3 deletions desktop/src/features/messages/lib/channelWindowStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
mergeLiveChannelWindowEvent,
mergeLiveThreadSummary,
replaceNewestChannelWindow,
stageOlderChannelWindow,
} from "./channelWindowStore.ts";

function event(id, createdAt, kind = 9) {
Expand Down Expand Up @@ -113,23 +114,57 @@ test("rejects inconsistent exhaustion and cursor facts", () => {
);
});

test("newest refresh drops a stale tail when its boundary moves", () => {
test("newest refresh drops a stale tail and its staged successor", () => {
const first = page(null, [event("a", 100)]);
const loaded = appendOlderChannelWindow(
const staged = page(first.nextCursor, [event("z", 90)], { hasMore: false });
const loaded = stageOlderChannelWindow(
replaceNewestChannelWindow(emptyChannelWindowStore(), first),
page(first.nextCursor, [event("z", 90)], { hasMore: false }),
staged,
);
const refreshed = replaceNewestChannelWindow(
loaded,
page(null, [event("n", 110), event("a", 100)]),
);
assert.equal(refreshed.pages.length, 1);
assert.equal(refreshed.stagedPage, null);
assert.deepEqual(
flattenChannelWindowEvents(refreshed).map((item) => item.content),
["a", "n"],
);
});

test("staging replaces at most one matching successor without rendering it", () => {
const first = page(null, [event("a", 100)]);
const store = replaceNewestChannelWindow(emptyChannelWindowStore(), first);
const staged = page(first.nextCursor, [event("b", 90)]);
const replacement = page(first.nextCursor, [event("c", 80)], {
hasMore: false,
});

const once = stageOlderChannelWindow(store, staged);
const twice = stageOlderChannelWindow(once, replacement);

assert.equal(twice.stagedPage, replacement);
assert.equal(twice.pages.length, 1);
assert.deepEqual(
flattenChannelWindowEvents(twice).map((item) => item.content),
["a"],
);
});

test("staging discards a page outside the retained tail cursor", () => {
const first = page(null, [event("a", 100)]);
const store = replaceNewestChannelWindow(emptyChannelWindowStore(), first);

assert.equal(
stageOlderChannelWindow(
store,
page(cursor(event("wrong", 70)), [event("older", 60)]),
),
store,
);
});

test("live rows arriving before page zero enter the overlay", () => {
const live = event("n", 110);
const store = mergeLiveChannelWindowEvent(emptyChannelWindowStore(), live);
Expand Down
19 changes: 18 additions & 1 deletion desktop/src/features/messages/lib/channelWindowStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type ChannelWindowPage = {
};
export type ChannelWindowStore = {
pages: ChannelWindowPage[];
/** One fetched successor held outside the rendered page chain. */
stagedPage: ChannelWindowPage | null;
/** Top-level live events not represented in an authoritative relay page. */
liveOverlay: RelayEvent[];
/** Live structural events retained independently from frozen page closure. */
Expand All @@ -38,12 +40,13 @@ export type ChannelWindowStore = {

export const emptyChannelWindowStore = (): ChannelWindowStore => ({
pages: [],
stagedPage: null,
liveOverlay: [],
liveAux: [],
liveSummaries: {},
});

function cursorsEqual(
export function cursorsEqual(
left: ChannelWindowCursor | null,
right: ChannelWindowCursor | null,
) {
Expand Down Expand Up @@ -112,6 +115,7 @@ export function replaceNewestChannelWindow(
const auxIds = new Set(page.aux.map((event) => event.id));
return {
pages: [page],
stagedPage: null,
liveOverlay: current.liveOverlay.filter((event) => !ids.has(event.id)),
liveAux: current.liveAux.filter((event) => !auxIds.has(event.id)),
// A head refetch is the authoritative resync moment (subscribe/reconnect
Expand Down Expand Up @@ -151,10 +155,23 @@ export function appendOlderChannelWindow(
return {
...current,
pages: [...current.pages, page],
stagedPage: null,
liveOverlay: current.liveOverlay.filter((event) => !pageIds.has(event.id)),
};
}

/** Hold one cursor-matched successor outside the rendered page chain. */
export function stageOlderChannelWindow(
current: ChannelWindowStore,
page: ChannelWindowPage,
): ChannelWindowStore {
assertValidPage(page);
const tail = current.pages[current.pages.length - 1];
if (!tail?.hasMore || !tail.nextCursor) return current;
if (!cursorsEqual(page.startCursor, tail.nextCursor)) return current;
return { ...current, stagedPage: page };
}

/**
* Record a relay-pushed live `39005` summary. Newest `created_at` wins per
* root: the relay pushes a full recount on every thread mutation, so the
Expand Down
Loading
Loading