Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ jobs:
run: |
cargo test --locked -p openbitfun-services-core --no-default-features --features workspace-runtime --lib workspace::tests::
cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::manager::tests::workspace_
cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib remote_connect::html_preview
cargo test --locked -p tool-runtime --no-default-features --lib fs::read_file::tests::
cargo test --locked -p tool-runtime --no-default-features --test tool_io_contracts

Expand Down
9 changes: 9 additions & 0 deletions src/apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,15 @@ capability negotiation and encrypted loopback Relay transfer, run:
cargo test --locked -p openbitfun-desktop --lib html_preview
```

For encrypted peer resource-load timings, run the same command with
`--lib html_preview_encrypted_peer_loading_benchmark -- --ignored --nocapture`.
The isolated Relay fixture adds 40 ms per resource RPC and compares the previous
serial reader with cold and warm HTTP gateway loads of the same HTML/assets.
It reports three samples and verifies payload bytes; it does not time native
rendering or use a physical remote host. Cache and prefetch limits belong to
`services-integrations::remote_connect::html_preview::transfer`; the Desktop
adapter owns HTTP authentication, explicit-refresh headers and peer RPC wiring.

Use `node tests/e2e/scripts/run-html-preview.mjs` after building Desktop and Web UI
to verify CSS, JavaScript modules, images and concurrent preview cookies in native
browser child views. The fixture uses isolated storage; see `tests/e2e/AGENTS.md`.
183 changes: 54 additions & 129 deletions src/apps/desktop/src/api/html_preview_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ use axum::{
routing::any,
Router,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use futures::TryStreamExt;
use openbitfun_core::service::filesystem::html_preview::resolve_html_preview_source;
use openbitfun_product_domains::{
html_preview::{
HtmlPreviewError, HtmlPreviewErrorCode as Code, HtmlPreviewResource,
HtmlPreviewResourceReply, HtmlPreviewResourceRequest, MAX_PREVIEW_BYTES,
PREVIEW_CHUNK_BYTES,
HtmlPreviewResourceReply, HtmlPreviewResourceRequest,
},
remote_surface::PeerHostCapability,
};
use openbitfun_services_integrations::remote_connect::{
html_preview::HtmlPreviewSource, RemoteCommand, RemoteResponse,
html_preview::transfer::{HtmlPreviewReader as PreviewReader, HtmlPreviewTransfer},
RemoteCommand, RemoteResponse,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
Expand Down Expand Up @@ -60,27 +60,6 @@ pub struct HtmlPreviewReleaseRequest {
pub session_id: String,
}

#[async_trait::async_trait]
trait PreviewReader: Send + Sync {
fn is_current(&self) -> bool {
true
}
async fn read(
&self,
request: &HtmlPreviewResourceRequest,
) -> Result<HtmlPreviewResource, HtmlPreviewError>;
}

#[async_trait::async_trait]
impl PreviewReader for HtmlPreviewSource {
async fn read(
&self,
request: &HtmlPreviewResourceRequest,
) -> Result<HtmlPreviewResource, HtmlPreviewError> {
HtmlPreviewSource::read(self, request).await
}
}

struct PeerPreviewReader {
device_id: String,
account_generation: u64,
Expand Down Expand Up @@ -177,7 +156,7 @@ struct PreviewState {
cookie_name: String,
entry_path: String,
request: HtmlPreviewResourceRequest,
reader: Arc<dyn PreviewReader>,
transfer: Arc<HtmlPreviewTransfer>,
closed: watch::Receiver<bool>,
}

Expand Down Expand Up @@ -266,18 +245,22 @@ async fn start_preview(
reader: Arc<dyn PreviewReader>,
mut request: HtmlPreviewResourceRequest,
) -> Result<HtmlPreviewCreateResponse, String> {
let entry = reader.read(&request).await.map_err(|error| error.message)?;
validate_chunk(&entry, &request).map_err(|error| error.message)?;
let (shutdown, closed) = watch::channel(false);
let transfer = Arc::new(HtmlPreviewTransfer::new(reader, closed.clone()));
let entry = transfer
.open(request.clone(), false, None)
.await
.map_err(|error| error.message)?
.metadata;
request.expected_source = Some(entry.source);
let token = Uuid::new_v4().simple().to_string();
let session_id = Uuid::new_v4().simple().to_string();
let (shutdown, closed) = watch::channel(false);
let state = PreviewState {
token: token.clone(),
cookie_name: format!("openbitfun_html_preview_{session_id}"),
entry_path: entry.entry_path,
request,
reader,
transfer,
closed: closed.clone(),
};
let listener = TcpListener::bind(("127.0.0.1", 0))
Expand Down Expand Up @@ -320,68 +303,6 @@ pub async fn html_preview_release(request: HtmlPreviewReleaseRequest) -> Result<
Ok(())
}

impl PreviewState {
async fn read(
&self,
request: &HtmlPreviewResourceRequest,
) -> Result<HtmlPreviewResource, HtmlPreviewError> {
let mut closed = self.closed.clone();
if *closed.borrow() || !self.reader.is_current() {
return Err(HtmlPreviewError::unavailable(
"HTML preview is no longer available",
));
}
tokio::select! {
_ = closed.wait_for(|closed| *closed) => Err(HtmlPreviewError::unavailable("HTML preview was closed")),
result = self.reader.read(request) => {
let resource = result?;
if *self.closed.borrow() || !self.reader.is_current() { return Err(HtmlPreviewError::unavailable("HTML preview is no longer available")); }
validate_chunk(&resource, request)?;
Ok(resource)
}
}
}
}

fn validate_chunk(
file: &HtmlPreviewResource,
request: &HtmlPreviewResourceRequest,
) -> Result<(), HtmlPreviewError> {
if file.size > MAX_PREVIEW_BYTES
|| file.offset != request.offset
|| file.offset > file.size
|| file.source.is_empty()
|| file.revision.is_empty()
|| request
.expected_source
.as_ref()
.is_some_and(|source| source != &file.source)
|| request
.expected_revision
.as_ref()
.is_some_and(|revision| revision != &file.revision)
|| file.content_base64.len() > PREVIEW_CHUNK_BYTES.div_ceil(3) * 4
{
return Err(HtmlPreviewError::unavailable(
"Invalid HTML preview resource chunk",
));
}
let bytes = BASE64
.decode(&file.content_base64)
.map_err(|_| HtmlPreviewError::unavailable("Invalid HTML preview resource encoding"))?;
let expected = if request.metadata_only {
0
} else {
(file.size - file.offset).min(PREVIEW_CHUNK_BYTES as u64) as usize
};
if bytes.len() != expected {
return Err(HtmlPreviewError::unavailable(
"Incomplete HTML preview resource chunk",
));
}
Ok(())
}

async fn preview_handler(
State(state): State<PreviewState>,
method: Method,
Expand All @@ -404,7 +325,7 @@ async fn preview_handler(
.any(|(key, value)| key == state.cookie_name && value == state.token)
});
if *state.closed.borrow()
|| !state.reader.is_current()
|| !state.transfer.is_current()
|| (method != Method::GET && method != Method::HEAD)
{
return response(StatusCode::NOT_FOUND, "Not found");
Expand Down Expand Up @@ -441,44 +362,27 @@ async fn preview_handler(
let mut request = state.request.clone();
request.resource_path = Some(relative);
request.metadata_only = method == Method::HEAD;
let first = match state.read(&request).await {
Ok(file) => file,
let bypass_cache = force_resource_reload(&headers);
let resource = match state
.transfer
.open(
request,
bypass_cache,
uri.path_and_query().map(|path| path.as_str().to_string()),
)
.await
{
Ok(resource) => resource,
Err(error) => return preview_error_response(error),
};
let size = first.size;
let content_type = first.mime_type.clone();
request.expected_revision = Some(first.revision.clone());
let body = if method == Method::HEAD {
Body::empty()
} else {
let stream = futures::stream::try_unfold(
(state, request, Some(first)),
move |(state, mut request, first)| async move {
let file = match first {
Some(file) => file,
None => state
.read(&request)
.await
.map_err(|error| std::io::Error::other(error.message))?,
};
if file.offset == file.size {
return Ok::<_, std::io::Error>(None);
}
let bytes = BASE64
.decode(file.content_base64)
.map_err(std::io::Error::other)?;
request.offset += bytes.len() as u64;
// An empty sentinel ends the stream without another RPC at EOF.
let next = (request.offset == size).then(|| HtmlPreviewResource {
offset: size,
content_base64: String::new(),
..file
});
Ok(Some((bytes, (state, request, next))))
},
);
Body::from_stream(stream)
};
let size = resource.metadata.size;
let content_type = resource.metadata.mime_type;
let body = Body::from_stream(
resource
.body
.map_ok(|bytes| bytes.to_vec())
.map_err(|error| std::io::Error::other(error.message)),
);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
Expand All @@ -495,6 +399,27 @@ async fn preview_handler(
})
}

fn force_resource_reload(headers: &HeaderMap) -> bool {
[header::CACHE_CONTROL, header::PRAGMA].iter().any(|name| {
headers
.get_all(name)
.iter()
.filter_map(|value| value.to_str().ok())
.any(|value| {
value.split(',').any(|directive| {
let (name, value) = directive
.trim()
.split_once('=')
.unwrap_or((directive.trim(), ""));
name.trim().eq_ignore_ascii_case("no-cache")
|| name.trim().eq_ignore_ascii_case("no-store")
|| (name.trim().eq_ignore_ascii_case("max-age")
&& value.trim().trim_matches('"') == "0")
})
})
})
}

fn preview_error_response(error: HtmlPreviewError) -> Response {
let status = match error.code {
Code::InvalidPath => StatusCode::BAD_REQUEST,
Expand Down
Loading
Loading