mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-17 00:52:09 +02:00
Adapt the bridge to id-keyed response bodies
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e294e6bcef
commit
4122b9d72a
@@ -247,12 +247,12 @@ async fn response_body(
|
||||
Query(_q): Query<BodyQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let response = match app.state.db().get_http_response(&id) {
|
||||
Ok(response) => response,
|
||||
let location = match app.state.locate_response_body(&id) {
|
||||
Ok(location) => location,
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "No such response").into_response(),
|
||||
};
|
||||
|
||||
let Some(body_path) = response.body_path else {
|
||||
let Some(body_path) = location.path else {
|
||||
return (StatusCode::NOT_FOUND, "Response has no body").into_response();
|
||||
};
|
||||
|
||||
@@ -269,12 +269,11 @@ async fn response_body(
|
||||
}
|
||||
};
|
||||
|
||||
let content_type = response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let content_type = if location.content_type.is_empty() {
|
||||
"application/octet-stream".to_string()
|
||||
} else {
|
||||
location.content_type
|
||||
};
|
||||
|
||||
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()).and_then(parse_range);
|
||||
|
||||
|
||||
@@ -490,27 +490,22 @@ async fn cmd_send_ephemeral_request(
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CmdHttpResponseBodyReq {
|
||||
pub response: HttpResponse,
|
||||
pub response_id: String,
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
/// The frontend hands back an id and never a path, so the only bodies reachable
|
||||
/// here are ones the engine wrote and the database still knows about.
|
||||
async fn cmd_http_response_body(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdHttpResponseBodyReq,
|
||||
) -> Result<FilterResponse> {
|
||||
let Some(body_path) = req.response.body_path else {
|
||||
let location = ctx.state.locate_response_body(&req.response_id).map_err(err)?;
|
||||
let Some(body_path) = location.path else {
|
||||
return Ok(FilterResponse { content: String::new(), error: None });
|
||||
};
|
||||
|
||||
let content_type = req
|
||||
.response
|
||||
.headers
|
||||
.iter()
|
||||
.find_map(|h| {
|
||||
if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None }
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let content_type = location.content_type.as_str();
|
||||
let body = read_response_body(&body_path, content_type)
|
||||
.await
|
||||
.ok_or_else(|| RpcError { message: "Failed to find response body".to_string() })?;
|
||||
@@ -525,6 +520,24 @@ async fn cmd_http_response_body(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CmdHttpResponseBodyPathReq {
|
||||
pub response_id: String,
|
||||
}
|
||||
|
||||
/// The desktop host uses this to open the file itself. A tab cannot open a
|
||||
/// path, so the bridge's browser host never calls it — it fetches
|
||||
/// `/responses/:id/body` instead — but the command answers honestly for any
|
||||
/// client that does, with the path on the bridge's machine.
|
||||
async fn cmd_http_response_body_path(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdHttpResponseBodyPathReq,
|
||||
) -> Result<Option<String>> {
|
||||
let location = ctx.state.locate_response_body(&req.response_id).map_err(err)?;
|
||||
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// Decode a response body from disk using the charset its Content-Type
|
||||
/// declares. Ported from crates-tauri/yaak-app-client/src/encoding.rs.
|
||||
async fn read_response_body(body_path: impl AsRef<Path>, content_type: &str) -> Option<String> {
|
||||
@@ -576,16 +589,21 @@ async fn cmd_get_http_response_events(
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CmdGetSseEventsReq {
|
||||
pub file_path: String,
|
||||
pub response_id: String,
|
||||
}
|
||||
|
||||
async fn cmd_get_sse_events(
|
||||
_ctx: BridgeCtx,
|
||||
ctx: BridgeCtx,
|
||||
req: CmdGetSseEventsReq,
|
||||
) -> Result<Vec<ServerSentEvent>> {
|
||||
use eventsource_client::{EventParser, SSE};
|
||||
|
||||
let body = std::fs::read(&req.file_path).map_err(err)?;
|
||||
let Some(body_path) = ctx.state.locate_response_body(&req.response_id).map_err(err)?.path
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let body = std::fs::read(&body_path).map_err(err)?;
|
||||
let mut event_parser = EventParser::new();
|
||||
event_parser.process_bytes(body).map_err(err)?;
|
||||
|
||||
@@ -1107,6 +1125,7 @@ rpc_commands! {
|
||||
cmd_send_ephemeral_request,
|
||||
|
||||
cmd_http_response_body,
|
||||
cmd_http_response_body_path,
|
||||
cmd_http_request_body,
|
||||
cmd_get_http_response_events,
|
||||
cmd_get_sse_events,
|
||||
|
||||
@@ -84,6 +84,14 @@ impl BridgeCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a response's body is, and what it is meant to be read as.
|
||||
pub struct ResponseBodyLocation {
|
||||
/// None when the response has no stored body.
|
||||
pub path: Option<PathBuf>,
|
||||
/// The response's declared `Content-Type`, empty when it has none.
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
pub struct BridgeState {
|
||||
data_dir: PathBuf,
|
||||
query_manager: QueryManager,
|
||||
@@ -199,6 +207,28 @@ impl BridgeState {
|
||||
self.data_dir.join("responses")
|
||||
}
|
||||
|
||||
/// Find a response's body from its id alone.
|
||||
///
|
||||
/// The tab hands back an id and never a path, so the only bodies reachable
|
||||
/// through the bridge are ones the engine wrote and the database still
|
||||
/// knows about. Every route and command that reads a body goes through
|
||||
/// here for that reason.
|
||||
pub fn locate_response_body(
|
||||
&self,
|
||||
response_id: &str,
|
||||
) -> yaak_models::error::Result<ResponseBodyLocation> {
|
||||
let response = self.db().get_http_response(response_id)?;
|
||||
Ok(ResponseBodyLocation {
|
||||
path: response.body_path.map(PathBuf::from),
|
||||
content_type: response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn db(&self) -> ClientDb<'_> {
|
||||
self.query_manager.connect()
|
||||
}
|
||||
|
||||
@@ -62,17 +62,6 @@ function detectOsType(): OsType {
|
||||
return "linux";
|
||||
}
|
||||
|
||||
/**
|
||||
* A response body path is an opaque handle the backend minted, and the bridge
|
||||
* writes them as `<data dir>/responses/<response id>`. Taking the last segment
|
||||
* turns it back into the id the `/responses/{id}/body` route wants, which keeps
|
||||
* the server from ever being asked for a path chosen by the page.
|
||||
*/
|
||||
function responseIdFromBodyPath(path: string): string {
|
||||
const segments = path.split(/[/\\]/);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the Tauri host-plugin commands, which ride outside the RPC envelope.
|
||||
*
|
||||
@@ -221,25 +210,34 @@ export function createBridgePlatform(baseUrl: string, token: string | null): Pla
|
||||
},
|
||||
|
||||
files: {
|
||||
async readFile(path) {
|
||||
const res = await connection.fetch(`/responses/${responseIdFromBodyPath(path)}/body`);
|
||||
readDir: async () => {
|
||||
throw unsupported("Browsing the filesystem");
|
||||
},
|
||||
// No filesystem here, so a path is just a string this host echoes back.
|
||||
url: (path) => path,
|
||||
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
||||
resolveResource: async (path) => path,
|
||||
},
|
||||
|
||||
// Bodies live on the bridge's disk and are addressed by response id. The
|
||||
// server resolves the id through its database, so a page can only ever
|
||||
// reach a body the engine actually wrote.
|
||||
blobs: {
|
||||
async read(id) {
|
||||
const res = await connection.fetch(`/responses/${encodeURIComponent(id)}/body`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to read response body (${res.status})`);
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
},
|
||||
|
||||
readDir: async () => {
|
||||
throw unsupported("Browsing the filesystem");
|
||||
},
|
||||
|
||||
// The `<img src>`/`<video src>` equivalent of Tauri's `convertFileSrc`.
|
||||
// The token rides in the query because the browser makes these requests
|
||||
// itself and the page cannot add a header to them.
|
||||
url: (path) => connection.url(`/responses/${responseIdFromBodyPath(path)}/body`),
|
||||
|
||||
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
||||
resolveResource: async (path) => path,
|
||||
async url(id) {
|
||||
return connection.url(`/responses/${encodeURIComponent(id)}/body`);
|
||||
},
|
||||
},
|
||||
|
||||
rpc: <T,>(cmd: string, payload?: RpcPayload): Promise<T> => {
|
||||
|
||||
Reference in New Issue
Block a user