mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): proxy media through Rust to bypass WKWebView WARP gap (#147)
This commit is contained in:
@@ -11,7 +11,7 @@ use managed_agents::{
|
||||
find_managed_agent_mut, load_managed_agents, save_managed_agents, start_managed_agent_process,
|
||||
stop_managed_agent_process, sync_managed_agent_processes, BackendKind,
|
||||
};
|
||||
use tauri::{Manager, RunEvent};
|
||||
use tauri::{http, Manager, RunEvent};
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
|
||||
fn restore_managed_agents_on_launch(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
@@ -86,6 +86,75 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Proxy media requests through the Rust backend so they traverse the WARP tunnel.
|
||||
///
|
||||
/// WKWebView's networking stack bypasses WARP, causing 403s from Cloudflare Access.
|
||||
/// This handler routes `sprout-media://localhost/{path}` through reqwest, which
|
||||
/// runs in the Tauri process and goes through WARP.
|
||||
async fn handle_sprout_media(
|
||||
app: &tauri::AppHandle,
|
||||
request: &http::Request<Vec<u8>>,
|
||||
) -> http::Response<Vec<u8>> {
|
||||
let state = app.state::<AppState>();
|
||||
let base = relay::relay_api_base_url();
|
||||
|
||||
// Preserve path + query (thumbnails may have query params).
|
||||
// Only proxy /media/ paths — reject anything else.
|
||||
let path_and_query = request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or("/");
|
||||
|
||||
if !path_and_query.starts_with("/media/") {
|
||||
return error_response(404, "not found");
|
||||
}
|
||||
|
||||
let upstream_url = format!("{base}{path_and_query}");
|
||||
|
||||
let result = state
|
||||
.http_client
|
||||
.get(&upstream_url)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
match resp.bytes().await {
|
||||
Ok(bytes) => http::Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", &content_type)
|
||||
.body(bytes.to_vec())
|
||||
.unwrap_or_else(|_| error_response(500, "response build failed")),
|
||||
Err(_) => error_response(502, "failed to read upstream body"),
|
||||
}
|
||||
}
|
||||
Err(_) => error_response(502, "upstream request failed"),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(status: u16, msg: &str) -> http::Response<Vec<u8>> {
|
||||
http::Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "text/plain")
|
||||
.body(msg.as_bytes().to_vec())
|
||||
.unwrap_or_else(|_| {
|
||||
http::Response::builder()
|
||||
.status(500)
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let app = tauri::Builder::default()
|
||||
@@ -98,6 +167,13 @@ pub fn run() {
|
||||
)
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.register_asynchronous_uri_scheme_protocol("sprout-media", |ctx, request, responder| {
|
||||
let app = ctx.app_handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let response = handle_sprout_media(&app, &request).await;
|
||||
responder.respond(response);
|
||||
});
|
||||
})
|
||||
.manage(build_app_state())
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
@@ -172,7 +173,7 @@ export const MessageRow = React.memo(
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
src={rewriteRelayUrl(message.avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -198,7 +199,7 @@ export const MessageRow = React.memo(
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
src={rewriteRelayUrl(message.avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UserRound } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
|
||||
type ProfileAvatarProps = {
|
||||
avatarUrl: string | null;
|
||||
@@ -44,7 +45,7 @@ export function ProfileAvatar({
|
||||
setFailedAvatarUrl(avatarUrl);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={avatarUrl}
|
||||
src={rewriteRelayUrl(avatarUrl)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
@@ -42,7 +43,7 @@ export function UserProfilePopover({
|
||||
alt={profile.displayName ?? "User avatar"}
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-cover shadow-sm"
|
||||
referrerPolicy="no-referrer"
|
||||
src={profile.avatarUrl}
|
||||
src={rewriteRelayUrl(profile.avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-secondary text-xs font-semibold text-secondary-foreground shadow-sm">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Rewrite relay media URLs to use the sprout-media:// custom protocol.
|
||||
*
|
||||
* WKWebView's networking stack bypasses WARP, so direct <img src> requests
|
||||
* to the relay get 403'd by Cloudflare Access. The sprout-media:// scheme
|
||||
* routes fetches through the Rust backend, which goes through WARP.
|
||||
*
|
||||
* Detection is path-based: /media/{64-hex-chars}.{ext} is a Blossom BUD-01
|
||||
* content-addressed URL. The 64-char lowercase hex SHA-256 hash makes this
|
||||
* pattern unique to Blossom relays — false positives from other origins are
|
||||
* practically impossible. This avoids needing async relay-URL initialization,
|
||||
* eliminating race conditions with first render.
|
||||
*/
|
||||
|
||||
// Matches: https://anything.com/media/{64-hex}.{ext}
|
||||
// Also matches thumbnails: /media/{64-hex}.thumb.jpg
|
||||
const RELAY_MEDIA_RE =
|
||||
/^(?:https?:\/\/[^/]+)\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp)(?:\?.*)?)$/;
|
||||
|
||||
/**
|
||||
* If `url` looks like a Blossom relay media URL, rewrite it to go through
|
||||
* the sprout-media:// custom protocol. Otherwise return it unchanged.
|
||||
*/
|
||||
export function rewriteRelayUrl(url: string): string {
|
||||
const m = RELAY_MEDIA_RE.exec(url);
|
||||
if (!m) return url;
|
||||
return `sprout-media://localhost/media/${m[1]}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
|
||||
import remarkMentions from "@/shared/lib/remarkMentions";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
@@ -106,7 +107,7 @@ function createMarkdownComponents(
|
||||
<img
|
||||
alt={alt}
|
||||
className="max-h-96 rounded-2xl border border-border/70 object-cover"
|
||||
src={src}
|
||||
src={src ? rewriteRelayUrl(src) : src}
|
||||
/>
|
||||
),
|
||||
li: ({ children }) => <li className={listItemClassName}>{children}</li>,
|
||||
|
||||
Reference in New Issue
Block a user