feat(desktop): proxy media through Rust to bypass WKWebView WARP gap (#147)

This commit is contained in:
tlongwell-block
2026-03-20 20:59:38 -04:00
committed by GitHub
parent 168c1ca8de
commit 897ec51683
6 changed files with 114 additions and 6 deletions
+77 -1
View File
@@ -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">
+28
View File
@@ -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]}`;
}
+2 -1
View File
@@ -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>,