refactor: clean up the tool::preview crate (#1582)

This commit is contained in:
Myriad-Dreamin 2025-03-25 17:21:57 +08:00 committed by GitHub
parent 10ec787cc9
commit 9790153381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 826 additions and 868 deletions

View file

@ -248,19 +248,24 @@ impl HoverWorker<'_> {
// Preview results
let provider = self.ctx.analysis.periscope.clone()?;
let doc = self.doc.as_ref()?;
let position = jump_from_cursor(doc, &self.source, self.cursor);
let jump = |cursor| {
jump_from_cursor(doc, &self.source, cursor)
.into_iter()
.next()
};
let position = jump(self.cursor);
let position = position.or_else(|| {
for idx in 1..100 {
let next_cursor = self.cursor + idx;
if next_cursor < self.source.text().len() {
let position = jump_from_cursor(doc, &self.source, next_cursor);
let position = jump(next_cursor);
if position.is_some() {
return position;
}
}
let prev_cursor = self.cursor.checked_sub(idx);
if let Some(prev_cursor) = prev_cursor {
let position = jump_from_cursor(doc, &self.source, prev_cursor);
let position = jump(prev_cursor);
if position.is_some() {
return position;
}

View file

@ -2,58 +2,132 @@
use std::num::NonZeroUsize;
use tinymist_std::typst::TypstDocument;
use tinymist_project::LspWorld;
use tinymist_std::{debug_loc::SourceSpanOffset, typst::TypstDocument};
use typst::{
layout::{Frame, FrameItem, Point, Position},
layout::{Frame, FrameItem, Point, Position, Size},
syntax::{LinkedNode, Source, Span, SyntaxKind},
visualize::Geometry,
World,
};
use typst_shim::syntax::LinkedNodeExt;
/// Find the output location in the document for a cursor position.
pub fn jump_from_cursor(
/// Finds a span range from a clicked physical position in a rendered paged
/// document.
pub fn jump_from_click(
world: &LspWorld,
frame: &Frame,
click: Point,
) -> Option<(SourceSpanOffset, SourceSpanOffset)> {
// Try to find a link first.
for (pos, item) in frame.items() {
if let FrameItem::Link(_dest, size) = item {
if is_in_rect(*pos, *size, click) {
// todo: url reaction
return None;
}
}
}
// If there's no link, search for a jump target.
for (mut pos, item) in frame.items().rev() {
match item {
FrameItem::Group(group) => {
// TODO: Handle transformation.
if let Some(span) = jump_from_click(world, &group.frame, click - pos) {
return Some(span);
}
}
FrameItem::Text(text) => {
for glyph in &text.glyphs {
let width = glyph.x_advance.at(text.size);
if is_in_rect(
Point::new(pos.x, pos.y - text.size),
Size::new(width, text.size),
click,
) {
let (span, span_offset) = glyph.span;
let mut span_offset = span_offset as usize;
let Some(id) = span.id() else { continue };
let source = world.source(id).ok()?;
let node = source.find(span)?;
if matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText)
&& (click.x - pos.x) > width / 2.0
{
span_offset += glyph.range().len();
}
let span_offset = SourceSpanOffset {
span,
offset: span_offset,
};
return Some((span_offset, span_offset));
}
pos.x += width;
}
}
FrameItem::Shape(shape, span) => {
let Geometry::Rect(size) = shape.geometry else {
continue;
};
if is_in_rect(pos, size, click) {
let span = (*span).into();
return Some((span, span));
}
}
FrameItem::Image(_, size, span) if is_in_rect(pos, *size, click) => {
let span = (*span).into();
return Some((span, span));
}
_ => {}
}
}
None
}
/// Finds the output location in the document for a cursor position.
pub fn jump_from_cursor(document: &TypstDocument, source: &Source, cursor: usize) -> Vec<Position> {
jump_from_cursor_(document, source, cursor).unwrap_or_default()
}
/// Finds the output location in the document for a cursor position.
fn jump_from_cursor_(
document: &TypstDocument,
source: &Source,
cursor: usize,
) -> Option<Position> {
) -> Option<Vec<Position>> {
match document {
TypstDocument::Paged(paged_doc) => {
let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
if matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText) {
return None;
}
let mut min_dis = u64::MAX;
let mut point = Point::default();
let mut ppage = 0usize;
let node = LinkedNode::new(source.root())
.leaf_at_compat(cursor)
.filter(|node| !matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText))?;
let span = node.span();
let mut positions = vec![];
for (idx, page) in paged_doc.pages.iter().enumerate() {
let t_dis = min_dis;
let mut min_dis = u64::MAX;
let mut point = Point::default();
if let Some(point) = find_in_frame(&page.frame, span, &mut min_dis, &mut point) {
return Some(Position {
page: NonZeroUsize::new(idx + 1)?,
point,
});
}
if t_dis != min_dis {
ppage = idx;
if let Some(page) = NonZeroUsize::new(idx + 1) {
positions.push(Position { page, point });
}
}
}
if min_dis == u64::MAX {
return None;
}
Some(Position {
page: NonZeroUsize::new(ppage + 1)?,
point,
})
Some(positions)
}
_ => None,
}
}
/// Find the position of a span in a frame.
/// Finds the position of a span in a frame.
fn find_in_frame(frame: &Frame, span: Span, min_dis: &mut u64, res: &mut Point) -> Option<Point> {
for (mut pos, item) in frame.items() {
if let FrameItem::Group(group) = item {
@ -87,3 +161,9 @@ fn find_in_frame(frame: &Frame, span: Span, min_dis: &mut u64, res: &mut Point)
None
}
/// Whether a rectangle with the given size at the given position contains the
/// click position.
fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
pos.x <= click.x && pos.x + size.x >= click.x && pos.y <= click.y && pos.y + size.y >= click.y
}

View file

@ -9,7 +9,7 @@ use tokio::sync::{mpsc, oneshot};
use typst_preview::{ControlPlaneMessage, Previewer};
use crate::project::ProjectPreviewState;
use crate::tool::preview::{HttpServer, PreviewProjectHandler};
use crate::tool::preview::{HttpServer, ProjectPreviewHandler};
pub struct PreviewTab {
/// Task ID
@ -21,7 +21,7 @@ pub struct PreviewTab {
/// Control plane message sender
pub ctl_tx: mpsc::UnboundedSender<ControlPlaneMessage>,
/// Compile handler
pub compile_handler: Arc<PreviewProjectHandler>,
pub compile_handler: Arc<ProjectPreviewHandler>,
/// Whether this tab is primary
pub is_primary: bool,
/// Whether this tab is background

View file

@ -279,12 +279,12 @@ impl ServerState {
/// Starts a preview instance.
#[cfg(feature = "preview")]
pub fn start_preview(
pub fn do_start_preview(
&mut self,
mut args: Vec<JsonValue>,
) -> SchedulableResponse<crate::tool::preview::StartPreviewResponse> {
let cli_args = get_arg_or_default!(args[0] as Vec<String>);
self.start_preview_inner(cli_args, crate::tool::preview::PreviewKind::Regular)
self.start_preview(cli_args, crate::tool::preview::PreviewKind::Regular)
}
/// Starts a preview instance for browsing.
@ -294,7 +294,7 @@ impl ServerState {
mut args: Vec<JsonValue>,
) -> SchedulableResponse<crate::tool::preview::StartPreviewResponse> {
let cli_args = get_arg_or_default!(args[0] as Vec<String>);
self.start_preview_inner(cli_args, crate::tool::preview::PreviewKind::Browsing)
self.start_preview(cli_args, crate::tool::preview::PreviewKind::Browsing)
}
/// Starts a preview instance but without arguments. This is used for the
@ -322,7 +322,7 @@ impl ServerState {
];
default_args.map(ToString::to_string).to_vec()
});
self.start_preview_inner(cli_args, crate::tool::preview::PreviewKind::Browsing)
self.start_preview(cli_args, crate::tool::preview::PreviewKind::Browsing)
}
/// Kill a preview instance.

View file

@ -595,42 +595,42 @@ impl CompileHandler<LspCompilerFeat, ProjectInsStateExt> for CompileHandlerImpl
self.push_diagnostics(dv, None);
}
fn notify_compile(&self, snap: &LspCompiledArtifact) {
fn notify_compile(&self, art: &LspCompiledArtifact) {
{
let mut n_revs = self.notified_revision.lock();
let n_rev = n_revs.entry(snap.id().clone()).or_default();
if *n_rev >= snap.world().revision().get() {
let n_rev = n_revs.entry(art.id().clone()).or_default();
if *n_rev >= art.world().revision().get() {
log::info!(
"Project: already notified for revision {} <= {n_rev}",
snap.world().revision(),
art.world().revision(),
);
return;
}
*n_rev = snap.world().revision().get();
*n_rev = art.world().revision().get();
}
// Prints the diagnostics when we are running the compilation in standalone
// CLI.
if self.is_standalone {
print_diagnostics(
snap.world(),
snap.diagnostics(),
art.world(),
art.diagnostics(),
reflexo_typst::DiagnosticFormat::Human,
)
.log_error("failed to print diagnostics");
}
self.notify_diagnostics(snap);
self.notify_diagnostics(art);
self.client.interrupt(LspInterrupt::Compiled(snap.clone()));
self.export.signal(snap);
self.client.interrupt(LspInterrupt::Compiled(art.clone()));
self.export.signal(art);
#[cfg(feature = "preview")]
if let Some(inner) = self.preview.get(snap.id()) {
let snap = snap.clone();
inner.notify_compile(Arc::new(crate::tool::preview::PreviewCompileView { snap }));
if let Some(inner) = self.preview.get(art.id()) {
let art = art.clone();
inner.notify_compile(Arc::new(crate::tool::preview::PreviewCompileView { art }));
} else {
log::info!("Project: no preview for {:?}", snap.id());
log::info!("Project: no preview for {:?}", art.id());
}
}
}

View file

@ -204,7 +204,7 @@ impl ServerState {
.with_command("tinymist.startDefaultPreview", State::default_preview)
.with_command("tinymist.scrollPreview", State::scroll_preview)
// Internal commands
.with_command("tinymist.doStartPreview", State::start_preview)
.with_command("tinymist.doStartPreview", State::do_start_preview)
.with_command("tinymist.doStartBrowsingPreview", State::browse_preview)
.with_command("tinymist.doKillPreview", State::kill_preview);

View file

@ -1,59 +1,125 @@
//! Document preview tool for Typst
#![allow(missing_docs)]
pub use compile::{PreviewCompileView, ProjectPreviewHandler};
pub use http::{make_http_server, HttpServer};
use std::num::NonZeroUsize;
use std::sync::LazyLock;
use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};
mod compile;
mod http;
use std::{collections::HashMap, path::Path, sync::Arc};
use clap::Parser;
use futures::{SinkExt, StreamExt, TryStreamExt};
use hyper::header::HeaderValue;
use hyper::service::service_fn;
use hyper_tungstenite::{tungstenite::Message, HyperWebsocket, HyperWebsocketStream};
use hyper_util::rt::TokioIo;
use hyper_util::server::graceful::GracefulShutdown;
use lsp_types::notification::Notification;
use lsp_types::Url;
use reflexo_typst::debug_loc::SourceSpanOffset;
use reflexo_typst::Bytes;
use reflexo_typst::{error::prelude::*, Error};
use reflexo_typst::error::prelude::*;
use serde::Serialize;
use serde_json::Value as JsonValue;
use sync_ls::just_ok;
use tinymist_assets::TYPST_PREVIEW_HTML;
use tinymist_query::{LspPosition, LspRange};
use tinymist_std::error::IgnoreLogging;
use tinymist_std::typst::TypstDocument;
use tokio::sync::{mpsc, oneshot};
use typst::layout::{Abs, Frame, FrameItem, Point, Position, Size};
use typst::syntax::{LinkedNode, Source, Span, SyntaxKind};
use typst::visualize::Geometry;
use typst::World;
use typst_preview::{
frontend_html, ControlPlaneMessage, ControlPlaneResponse, ControlPlaneRx, ControlPlaneTx,
DocToSrcJumpInfo, EditorServer, Location, MemoryFiles, MemoryFilesShort, PreviewArgs,
PreviewBuilder, PreviewMode, Previewer, WsMessage,
frontend_html, ControlPlaneMessage, ControlPlaneRx, ControlPlaneTx, DocToSrcJumpInfo,
PreviewArgs, PreviewBuilder, PreviewMode, Previewer, WsMessage,
};
use typst_shim::syntax::LinkedNodeExt;
use crate::project::{
LspCompiledArtifact, LspInterrupt, LspWorld, ProjectClient, ProjectInsId, WorldProvider,
};
use crate::actor::preview::{PreviewActor, PreviewRequest, PreviewTab};
use crate::project::{ProjectInsId, ProjectPreviewState, WorldProvider};
use crate::tool::project::{start_project, ProjectOpts, StartProjectResult};
use crate::*;
use actor::preview::{PreviewActor, PreviewRequest, PreviewTab};
use project::world::vfs::{notify::MemoryEvent, FileChangeSet};
use project::ProjectPreviewState;
pub use typst_preview::CompileStatus;
/// The kind of the preview.
pub enum PreviewKind {
/// Previews a specific file.
Regular,
/// Walks through the project and previews the main file related to the
/// current focused file.
Browsing,
/// Runs a browsing preview in background.
Background,
}
/// CLI Arguments for the preview tool.
#[derive(Debug, Clone, clap::Parser)]
pub struct PreviewCliArgs {
/// Preview arguments
#[clap(flatten)]
pub preview: PreviewArgs,
/// Compile arguments
#[clap(flatten)]
pub compile: CompileOnceArgs,
/// Preview mode
#[clap(long = "preview-mode", default_value = "document", value_name = "MODE")]
pub preview_mode: PreviewMode,
/// Data plane server will bind to this address. Note: if it equals to
/// `static_file_host`, same address will be used.
#[clap(
long = "data-plane-host",
default_value = "127.0.0.1:23625",
value_name = "HOST",
hide(true)
)]
pub data_plane_host: String,
/// Control plane server will bind to this address
#[clap(
long = "control-plane-host",
default_value = "127.0.0.1:23626",
value_name = "HOST",
hide(true)
)]
pub control_plane_host: String,
/// (Deprecated) (File) Host for the preview server. Note: if it equals to
/// `data_plane_host`, same address will be used.
#[clap(
long = "host",
value_name = "HOST",
default_value = "",
alias = "static-file-host"
)]
pub static_file_host: String,
/// Let it not be the primary instance.
#[clap(long = "not-primary", hide(true))]
pub not_as_primary: bool,
/// Open the preview in the browser after compilation. If `--no-open` is
/// set, this flag will be ignored.
#[clap(long = "open")]
pub open: bool,
/// Don't open the preview in the browser after compilation. If `--open` is
/// set as well, this flag will win.
#[clap(long = "no-open")]
pub no_open: bool,
}
impl PreviewCliArgs {
/// Whether to open the preview in the browser after compilation.
pub fn open_in_browser(&self, default: bool) -> bool {
!self.no_open && (self.open || default)
}
}
/// Response for starting a preview.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartPreviewResponse {
static_server_port: Option<u16>,
static_server_addr: Option<String>,
data_plane_port: Option<u16>,
is_primary: bool,
}
impl ServerState {
/// Starts a background preview instance.
pub fn background_preview(&mut self) {
if !self.config.preview.background.enabled {
return;
@ -67,7 +133,7 @@ impl ServerState {
]
});
let res = self.start_preview_inner(args, PreviewKind::Background);
let res = self.start_preview(args, PreviewKind::Background);
// todo: looks ugly
self.client.handle.spawn(async move {
@ -87,17 +153,12 @@ impl ServerState {
});
}
/// Start a preview instance.
pub fn start_preview_inner(
/// Starts a preview instance.
pub fn start_preview(
&mut self,
cli_args: Vec<String>,
kind: PreviewKind,
) -> SchedulableResponse<crate::tool::preview::StartPreviewResponse> {
use std::path::Path;
use crate::tool::preview::PreviewCliArgs;
use clap::Parser;
) -> SchedulableResponse<StartPreviewResponse> {
// clap parse
let cli_args = ["preview"]
.into_iter()
@ -175,182 +236,6 @@ impl ServerState {
}
}
}
/// The preview's view of the compiled artifact.
pub struct PreviewCompileView {
/// The artifact and snap.
pub snap: LspCompiledArtifact,
}
impl typst_preview::CompileView for PreviewCompileView {
fn doc(&self) -> Option<TypstDocument> {
self.snap.doc.clone()
}
fn status(&self) -> CompileStatus {
match self.snap.doc {
Some(_) => CompileStatus::CompileSuccess,
None => CompileStatus::CompileError,
}
}
fn is_on_saved(&self) -> bool {
self.snap.snap.signal.by_fs_events
}
fn is_by_entry_update(&self) -> bool {
self.snap.snap.signal.by_entry_update
}
fn resolve_source_span(&self, loc: Location) -> Option<SourceSpanOffset> {
let world = self.snap.world();
let Location::Src(loc) = loc;
let source_id = world.id_for_path(Path::new(&loc.filepath))?;
let source = world.source(source_id).ok()?;
let cursor =
source.line_column_to_byte(loc.pos.line as usize, loc.pos.character as usize)?;
let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
if !matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText) {
return None;
}
let span = node.span();
// todo: unicode char
let offset = cursor.saturating_sub(node.offset());
Some(SourceSpanOffset { span, offset })
}
// todo: use vec2bbox to handle bbox correctly
fn resolve_frame_loc(
&self,
pos: &reflexo::debug_loc::DocumentPosition,
) -> Option<(SourceSpanOffset, SourceSpanOffset)> {
let TypstDocument::Paged(doc) = self.doc()? else {
return None;
};
let world = self.snap.world();
let page = pos.page_no.checked_sub(1)?;
let page = doc.pages.get(page)?;
let click = Point::new(Abs::pt(pos.x as f64), Abs::pt(pos.y as f64));
jump_from_click(world, &page.frame, click)
}
fn resolve_document_position(&self, loc: Location) -> Vec<Position> {
let world = self.snap.world();
let Location::Src(src_loc) = loc;
let line = src_loc.pos.line as usize;
let column = src_loc.pos.character as usize;
let doc = self.snap.success_doc();
let Some(doc) = doc.as_ref() else {
return vec![];
};
let Some(source_id) = world.id_for_path(Path::new(&src_loc.filepath)) else {
return vec![];
};
let Some(source) = world.source(source_id).ok() else {
return vec![];
};
let Some(cursor) = source.line_column_to_byte(line, column) else {
return vec![];
};
jump_from_cursor(doc, &source, cursor)
}
fn resolve_span(&self, span: Span, offset: Option<usize>) -> Option<DocToSrcJumpInfo> {
let world = self.snap.world();
let resolve_off =
|src: &Source, off: usize| src.byte_to_line(off).zip(src.byte_to_column(off));
let source = world.source(span.id()?).ok()?;
let mut range = source.find(span)?.range();
if let Some(off) = offset {
if off < range.len() {
range.start += off;
}
}
// todo: resolve untitled uri.
let filepath = world.path_for_id(span.id()?).ok()?.to_err().ok()?;
Some(DocToSrcJumpInfo {
filepath: filepath.to_string_lossy().to_string(),
start: resolve_off(&source, range.start),
end: resolve_off(&source, range.end),
})
}
}
/// CLI Arguments for the preview tool.
#[derive(Debug, Clone, clap::Parser)]
pub struct PreviewCliArgs {
/// Preview arguments
#[clap(flatten)]
pub preview: PreviewArgs,
/// Compile arguments
#[clap(flatten)]
pub compile: CompileOnceArgs,
/// Preview mode
#[clap(long = "preview-mode", default_value = "document", value_name = "MODE")]
pub preview_mode: PreviewMode,
/// Data plane server will bind to this address. Note: if it equals to
/// `static_file_host`, same address will be used.
#[clap(
long = "data-plane-host",
default_value = "127.0.0.1:23625",
value_name = "HOST",
hide(true)
)]
pub data_plane_host: String,
/// Control plane server will bind to this address
#[clap(
long = "control-plane-host",
default_value = "127.0.0.1:23626",
value_name = "HOST",
hide(true)
)]
pub control_plane_host: String,
/// (Deprecated) (File) Host for the preview server. Note: if it equals to
/// `data_plane_host`, same address will be used.
#[clap(
long = "host",
value_name = "HOST",
default_value = "",
alias = "static-file-host"
)]
pub static_file_host: String,
/// Let it not be the primary instance.
#[clap(long = "not-primary", hide(true))]
pub not_as_primary: bool,
/// Open the preview in the browser after compilation. If `--no-open` is
/// set, this flag will be ignored.
#[clap(long = "open")]
pub open: bool,
/// Don't open the preview in the browser after compilation. If `--open` is
/// set as well, this flag will win.
#[clap(long = "no-open")]
pub no_open: bool,
}
impl PreviewCliArgs {
pub fn open_in_browser(&self, default: bool) -> bool {
!self.no_open && (self.open || default)
}
}
/// The global state of the preview tool.
pub struct PreviewState {
@ -407,77 +292,6 @@ impl PreviewState {
}
}
/// Response for starting a preview.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartPreviewResponse {
static_server_port: Option<u16>,
static_server_addr: Option<String>,
data_plane_port: Option<u16>,
is_primary: bool,
}
pub struct PreviewProjectHandler {
pub project_id: ProjectInsId,
client: Box<dyn ProjectClient>,
}
impl PreviewProjectHandler {
pub fn flush_compile(&self) {
let _ = self.project_id;
self.client
.interrupt(LspInterrupt::Compile(self.project_id.clone()));
}
pub fn settle(&self) -> Result<(), Error> {
self.client
.interrupt(LspInterrupt::Settle(self.project_id.clone()));
Ok(())
}
pub fn unpin_primary(&self) {
self.client.server_event(ServerEvent::UnpinPrimaryByPreview);
}
}
impl EditorServer for PreviewProjectHandler {
async fn update_memory_files(
&self,
files: MemoryFiles,
reset_shadow: bool,
) -> Result<(), Error> {
// todo: is it safe to believe that the path is normalized?
let files = FileChangeSet::new_inserts(
files
.files
.into_iter()
.map(|(path, content)| {
// todo: cloning PathBuf -> Arc<Path>
(path.into(), Ok(Bytes::from_string(content)).into())
})
.collect(),
);
let intr = LspInterrupt::Memory(if reset_shadow {
MemoryEvent::Sync(files)
} else {
MemoryEvent::Update(files)
});
self.client.interrupt(intr);
Ok(())
}
async fn remove_shadow_files(&self, files: MemoryFilesShort) -> Result<(), Error> {
// todo: is it safe to believe that the path is normalized?
let files = FileChangeSet::new_removes(files.files.into_iter().map(From::from).collect());
self.client
.interrupt(LspInterrupt::Memory(MemoryEvent::Update(files)));
Ok(())
}
}
impl PreviewState {
/// Start a preview on a given compiler.
pub fn start(
@ -489,7 +303,7 @@ impl PreviewState {
is_primary: bool,
is_background: bool,
) -> SchedulableResponse<StartPreviewResponse> {
let compile_handler = Arc::new(PreviewProjectHandler {
let compile_handler = Arc::new(ProjectPreviewHandler {
project_id,
client: Box::new(self.client.clone().to_untyped()),
});
@ -520,7 +334,7 @@ impl PreviewState {
self.client.handle.spawn(async move {
let mut resp_rx = resp_rx;
while let Some(resp) = resp_rx.recv().await {
use ControlPlaneResponse::*;
use typst_preview::ControlPlaneResponse::*;
match resp {
// ignoring compile status per task.
@ -620,226 +434,6 @@ impl PreviewState {
}
}
/// created by `make_http_server`
pub struct HttpServer {
/// The address the server is listening on.
pub addr: SocketAddr,
/// The sender to shutdown the server.
pub shutdown_tx: oneshot::Sender<()>,
/// The join handle of the server.
pub join: tokio::task::JoinHandle<()>,
}
/// Create a http server for the previewer.
pub async fn make_http_server(
frontend_html: String,
static_file_addr: String,
websocket_tx: mpsc::UnboundedSender<HyperWebsocket>,
) -> HttpServer {
use http_body_util::Full;
use hyper::body::{Bytes, Incoming};
type Server = hyper_util::server::conn::auto::Builder<hyper_util::rt::TokioExecutor>;
let listener = tokio::net::TcpListener::bind(&static_file_addr)
.await
.unwrap();
let addr = listener.local_addr().unwrap();
log::info!("preview server listening on http://{addr}");
let frontend_html = hyper::body::Bytes::from(frontend_html);
let make_service = move || {
let frontend_html = frontend_html.clone();
let websocket_tx = websocket_tx.clone();
let static_file_addr = static_file_addr.clone();
service_fn(move |mut req: hyper::Request<Incoming>| {
let frontend_html = frontend_html.clone();
let websocket_tx = websocket_tx.clone();
let static_file_addr = static_file_addr.clone();
async move {
// When a user visits a website in a browser, that website can try to connect to
// our http / websocket server on `127.0.0.1` which may leak sensitive
// information. We could use CORS headers to explicitly disallow
// this. However, for Websockets, this does not work. Thus, we
// manually check the `Origin` header. Browsers always send this
// header for cross-origin requests.
//
// Important: This does _not_ protect against malicious users that share the
// same computer as us (i.e. multi- user systems where the users
// don't trust each other). In this case, malicious attackers can _still_
// connect to our http / websocket servers (using a browser and
// otherwise). And additionally they can impersonate a tinymist
// http / websocket server towards a legitimate frontend/html client.
// This requires additional protection that may be added in the future.
let origin_header = req.headers().get("Origin");
if origin_header
.is_some_and(|h| !is_valid_origin(h, &static_file_addr, addr.port()))
{
anyhow::bail!(
"Connection with unexpected `Origin` header. Closing connection."
);
}
// Check if the request is a websocket upgrade request.
if hyper_tungstenite::is_upgrade_request(&req) {
if origin_header.is_none() {
log::error!("websocket connection is not set `Origin` header, which will be a hard error in the future.");
}
let Some((response, websocket)) = hyper_tungstenite::upgrade(&mut req, None)
.log_error("Error in websocket upgrade")
else {
anyhow::bail!("cannot upgrade as websocket connection");
};
let _ = websocket_tx.send(websocket);
// Return the response so the spawned future can continue.
Ok(response)
} else if req.uri().path() == "/" {
// log::debug!("Serve frontend: {mode:?}");
let res = hyper::Response::builder()
.header(hyper::header::CONTENT_TYPE, "text/html")
.body(Full::<Bytes>::from(frontend_html))
.unwrap();
Ok(res)
} else {
// jump to /
let res = hyper::Response::builder()
.status(hyper::StatusCode::FOUND)
.header(hyper::header::LOCATION, "/")
.body(Full::<Bytes>::default())
.unwrap();
Ok(res)
}
}
})
};
let (shutdown_tx, rx) = tokio::sync::oneshot::channel();
let (final_tx, final_rx) = tokio::sync::oneshot::channel();
// the graceful watcher
let graceful = hyper_util::server::graceful::GracefulShutdown::new();
let serve_conn = move |server: &Server, graceful: &GracefulShutdown, conn| {
let (stream, _peer_addr) = match conn {
Ok(conn) => conn,
Err(e) => {
log::error!("accept error: {e}");
return;
}
};
let conn = server.serve_connection_with_upgrades(TokioIo::new(stream), make_service());
let conn = graceful.watch(conn.into_owned());
tokio::spawn(async move {
conn.await.log_error("cannot serve http");
});
};
let join = tokio::spawn(async move {
// when this signal completes, start shutdown
let mut signal = std::pin::pin!(final_rx);
let mut server = Server::new(hyper_util::rt::TokioExecutor::new());
server.http1().keep_alive(true);
loop {
tokio::select! {
conn = listener.accept() => serve_conn(&server, &graceful, conn),
Ok(_) = &mut signal => {
log::info!("graceful shutdown signal received");
break;
}
}
}
tokio::select! {
_ = graceful.shutdown() => {
log::info!("Gracefully shutdown!");
},
_ = tokio::time::sleep(reflexo::time::Duration::from_secs(10)) => {
log::info!("Waited 10 seconds for graceful shutdown, aborting...");
}
}
});
tokio::spawn(async move {
let _ = rx.await;
final_tx.send(()).ok();
log::info!("Preview server joined");
});
HttpServer {
addr,
shutdown_tx,
join,
}
}
fn is_valid_origin(h: &HeaderValue, static_file_addr: &str, expected_port: u16) -> bool {
static GITPOD_ID_AND_HOST: LazyLock<Option<(String, String)>> = LazyLock::new(|| {
let workspace_id = std::env::var("GITPOD_WORKSPACE_ID").ok();
let cluster_host = std::env::var("GITPOD_WORKSPACE_CLUSTER_HOST").ok();
workspace_id.zip(cluster_host)
});
is_valid_origin_impl(h, static_file_addr, expected_port, &GITPOD_ID_AND_HOST)
}
// Separate function so we can do gitpod-related tests without relying on env
// vars.
fn is_valid_origin_impl(
origin_header: &HeaderValue,
static_file_addr: &str,
expected_port: u16,
gitpod_id_and_host: &Option<(String, String)>,
) -> bool {
let Ok(Ok(origin_url)) = origin_header.to_str().map(Url::parse) else {
return false;
};
// Path is not allowed in Origin headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
if origin_url.path() != "/" && origin_url.path() != "" {
return false;
};
let expected_origin = {
let expected_host = Url::parse(&format!("http://{static_file_addr}")).unwrap();
let expected_host = expected_host.host_str().unwrap();
// Don't take the port from `static_file_addr` (it may have a dummy port e.g.
// `127.0.0.1:0`)
format!("http://{expected_host}:{expected_port}")
};
let gitpod_expected_origin = gitpod_id_and_host
.as_ref()
.map(|(workspace_id, cluster_host)| {
format!("https://{expected_port}-{workspace_id}.{cluster_host}")
});
*origin_header == expected_origin
// tmistele (PR #1382): The VSCode webview panel needs an exception: It doesn't send `http://{static_file_addr}`
// as `Origin`. Instead it sends `vscode-webview://<random>`. Thus, we allow any
// `Origin` starting with `vscode-webview://` as well. I think that's okay from a security
// point of view, because I think malicious websites can't trick browsers into sending
// `vscode-webview://...` as `Origin`.
|| origin_url.scheme() == "vscode-webview"
// `code-server` also needs an exception: It opens `http://localhost:8080/proxy/<port>` in
// the browser and proxies requests through to tinymist (which runs at `127.0.0.1:<port>`).
// Thus, the `Origin` header will be `http://localhost:8080` which doesn't match what
// we expect. Thus, just always allow anything from localhost/127.0.0.1
// https://github.com/Myriad-Dreamin/tinymist/issues/1350
|| (
matches!(origin_url.host_str(), Some("localhost") | Some("127.0.0.1"))
&& origin_url.scheme() == "http"
)
// `gitpod` also needs an exception. It loads `https://<port>-<workspace>.<host>` in the browser
// and proxies requests through to tinymist (which runs as `127.0.0.1:<port>`).
// We can detect this by looking at the env variables (see `GITPOD_ID_AND_HOST` in `is_valid_origin(..)`)
|| gitpod_expected_origin.is_some_and(|o| o == *origin_header)
}
/// Entry point of the preview tool.
pub async fn preview_main(args: PreviewCliArgs) -> Result<()> {
log::info!("Arguments: {args:#?}");
@ -883,7 +477,7 @@ pub async fn preview_main(args: PreviewCliArgs) -> Result<()> {
tinymist_std::bail!("failed to register preview");
}
let handle: Arc<PreviewProjectHandler> = Arc::new(PreviewProjectHandler {
let handle: Arc<ProjectPreviewHandler> = Arc::new(ProjectPreviewHandler {
project_id: id,
client: Box::new(intr_tx),
});
@ -1043,158 +637,6 @@ fn send_show_document(client: &TypedLspClient<PreviewState>, s: &DocToSrcJumpInf
);
}
/// Determine where to jump to based on a click in a frame.
pub fn jump_from_click(
world: &LspWorld,
frame: &Frame,
click: Point,
) -> Option<(SourceSpanOffset, SourceSpanOffset)> {
// Try to find a link first.
for (pos, item) in frame.items() {
if let FrameItem::Link(_dest, size) = item {
if is_in_rect(*pos, *size, click) {
// todo: url reaction
return None;
}
}
}
// If there's no link, search for a jump target.
for (mut pos, item) in frame.items().rev() {
match item {
FrameItem::Group(group) => {
// TODO: Handle transformation.
if let Some(span) = jump_from_click(world, &group.frame, click - pos) {
return Some(span);
}
}
FrameItem::Text(text) => {
for glyph in &text.glyphs {
let width = glyph.x_advance.at(text.size);
if is_in_rect(
Point::new(pos.x, pos.y - text.size),
Size::new(width, text.size),
click,
) {
let (span, span_offset) = glyph.span;
let mut span_offset = span_offset as usize;
let Some(id) = span.id() else { continue };
let source = world.source(id).ok()?;
let node = source.find(span)?;
if matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText)
&& (click.x - pos.x) > width / 2.0
{
span_offset += glyph.range().len();
}
let span_offset = SourceSpanOffset {
span,
offset: span_offset,
};
return Some((span_offset, span_offset));
}
pos.x += width;
}
}
FrameItem::Shape(shape, span) => {
let Geometry::Rect(size) = shape.geometry else {
continue;
};
if is_in_rect(pos, size, click) {
let span = (*span).into();
return Some((span, span));
}
}
FrameItem::Image(_, size, span) if is_in_rect(pos, *size, click) => {
let span = (*span).into();
return Some((span, span));
}
_ => {}
}
}
None
}
/// Find the output location in the document for a cursor position.
fn jump_from_cursor(document: &TypstDocument, source: &Source, cursor: usize) -> Vec<Position> {
let Some(node) = LinkedNode::new(source.root())
.leaf_at_compat(cursor)
.filter(|node| node.kind() == SyntaxKind::Text)
else {
return vec![];
};
let mut p = Point::default();
let span = node.span();
match document {
TypstDocument::Paged(paged_doc) => {
let mut positions: Vec<Position> = vec![];
for (i, page) in paged_doc.pages.iter().enumerate() {
let mut min_dis = u64::MAX;
if let Some(pos) = find_in_frame(&page.frame, span, &mut min_dis, &mut p) {
if let Some(page) = NonZeroUsize::new(i + 1) {
positions.push(Position { page, point: pos });
}
}
}
log::info!("jump_from_cursor: {positions:#?}");
positions
}
_ => vec![],
}
}
/// Find the position of a span in a frame.
fn find_in_frame(frame: &Frame, span: Span, min_dis: &mut u64, p: &mut Point) -> Option<Point> {
for (mut pos, item) in frame.items() {
if let FrameItem::Group(group) = item {
// TODO: Handle transformation.
if let Some(point) = find_in_frame(&group.frame, span, min_dis, p) {
return Some(point + pos);
}
}
if let FrameItem::Text(text) = item {
for glyph in &text.glyphs {
if glyph.span.0 == span {
return Some(pos);
}
if glyph.span.0.id() == span.id() {
let dis = glyph
.span
.0
.into_raw()
.get()
.abs_diff(span.into_raw().get());
if dis < *min_dis {
*min_dis = dis;
*p = pos;
}
}
pos.x += glyph.x_advance.at(text.size);
}
}
}
None
}
/// Whether a rectangle with the given size at the given position contains the
/// click position.
fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
pos.x <= click.x && pos.x + size.x >= click.x && pos.y <= click.y && pos.y + size.y >= click.y
}
fn bind_streams(previewer: &mut Previewer, websocket_rx: mpsc::UnboundedReceiver<HyperWebsocket>) {
previewer.start_data_plane(
websocket_rx,
@ -1221,147 +663,3 @@ fn bind_streams(previewer: &mut Previewer, websocket_rx: mpsc::UnboundedReceiver
},
);
}
#[cfg(test)]
mod tests {
use super::*;
fn check_origin(origin: &'static str, static_file_addr: &str, port: u16) -> bool {
is_valid_origin(&HeaderValue::from_static(origin), static_file_addr, port)
}
#[test]
fn test_valid_origin_localhost() {
assert!(check_origin("http://127.0.0.1:42", "127.0.0.1:42", 42));
assert!(check_origin("http://127.0.0.1:42", "127.0.0.1:42", 42));
assert!(check_origin("http://127.0.0.1:42", "127.0.0.1:0", 42));
assert!(check_origin("http://localhost:42", "127.0.0.1:42", 42));
assert!(check_origin("http://localhost:42", "127.0.0.1:0", 42));
assert!(check_origin("http://localhost", "127.0.0.1:0", 42));
assert!(check_origin("http://127.0.0.1:42", "localhost:42", 42));
assert!(check_origin("http://127.0.0.1:42", "localhost:42", 42));
assert!(check_origin("http://127.0.0.1:42", "localhost:0", 42));
assert!(check_origin("http://localhost:42", "localhost:42", 42));
assert!(check_origin("http://localhost:42", "localhost:0", 42));
assert!(check_origin("http://localhost", "localhost:0", 42));
}
#[test]
fn test_invalid_origin_localhost() {
assert!(!check_origin("https://huh.io:8080", "127.0.0.1:42", 42));
assert!(!check_origin("http://huh.io:8080", "127.0.0.1:42", 42));
assert!(!check_origin("https://huh.io:443", "127.0.0.1:42", 42));
assert!(!check_origin("http://huh.io:42", "127.0.0.1:0", 42));
assert!(!check_origin("http://huh.io", "127.0.0.1:42", 42));
assert!(!check_origin("https://huh.io", "127.0.0.1:42", 42));
assert!(!check_origin("https://huh.io:8080", "localhost:42", 42));
assert!(!check_origin("http://huh.io:8080", "localhost:42", 42));
assert!(!check_origin("https://huh.io:443", "localhost:42", 42));
assert!(!check_origin("http://huh.io:42", "localhost:0", 42));
assert!(!check_origin("http://huh.io", "localhost:42", 42));
assert!(!check_origin("https://huh.io", "localhost:42", 42));
}
#[test]
fn test_invalid_origin_scheme() {
assert!(!check_origin("ftp://127.0.0.1:42", "127.0.0.1:42", 42));
assert!(!check_origin("ftp://localhost:42", "127.0.0.1:42", 42));
assert!(!check_origin("ftp://127.0.0.1:42", "127.0.0.1:0", 42));
assert!(!check_origin("ftp://localhost:42", "127.0.0.1:0", 42));
// The scheme must be specified.
assert!(!check_origin("127.0.0.1:42", "127.0.0.1:0", 42));
assert!(!check_origin("localhost:42", "127.0.0.1:0", 42));
assert!(!check_origin("localhost:42", "127.0.0.1:42", 42));
assert!(!check_origin("127.0.0.1:42", "127.0.0.1:42", 42));
}
#[test]
fn test_valid_origin_vscode() {
assert!(check_origin("vscode-webview://it", "127.0.0.1:42", 42));
assert!(check_origin("vscode-webview://it", "127.0.0.1:0", 42));
}
#[test]
fn test_origin_manually_binding() {
assert!(!check_origin("https://huh.io:8080", "huh.io:42", 42));
assert!(!check_origin("http://huh.io:8080", "huh.io:42", 42));
assert!(!check_origin("https://huh.io:443", "huh.io:42", 42));
assert!(check_origin("http://huh.io:42", "huh.io:0", 42));
assert!(!check_origin("http://huh.io", "huh.io:42", 42));
assert!(!check_origin("https://huh.io", "huh.io:42", 42));
assert!(check_origin("http://127.0.0.1:42", "huh.io:42", 42));
assert!(check_origin("http://127.0.0.1:42", "huh.io:42", 42));
assert!(check_origin("http://127.0.0.1:42", "huh.io:0", 42));
assert!(check_origin("http://localhost:42", "huh.io:42", 42));
assert!(check_origin("http://localhost:42", "huh.io:0", 42));
assert!(!check_origin("https://huh2.io:8080", "huh.io:42", 42));
assert!(!check_origin("http://huh2.io:8080", "huh.io:42", 42));
assert!(!check_origin("https://huh2.io:443", "huh.io:42", 42));
assert!(!check_origin("http://huh2.io:42", "huh.io:0", 42));
assert!(!check_origin("http://huh2.io", "huh.io:42", 42));
assert!(!check_origin("https://huh2.io", "huh.io:42", 42));
}
// https://github.com/Myriad-Dreamin/tinymist/issues/1350
// the origin of code-server's proxy
#[test]
fn test_valid_origin_code_server_proxy() {
assert!(check_origin(
// The URL has path /proxy/45411 but that is not sent in the Origin header
"http://localhost:8080",
"127.0.0.1:42",
42
));
assert!(check_origin("http://localhost", "127.0.0.1:42", 42));
}
// the origin of gitpod
#[test]
fn test_valid_origin_gitpod_proxy() {
fn check_gitpod_origin(
origin: &'static str,
static_file_addr: &str,
port: u16,
workspace: &str,
cluster_host: &str,
) -> bool {
is_valid_origin_impl(
&HeaderValue::from_static(origin),
static_file_addr,
port,
&Some((workspace.to_owned(), cluster_host.to_owned())),
)
}
let check_gitpod_origin1 = |origin: &'static str| {
let explicit =
check_gitpod_origin(origin, "127.0.0.1:42", 42, "workspace_id", "gitpod.typ");
let implicit =
check_gitpod_origin(origin, "127.0.0.1:0", 42, "workspace_id", "gitpod.typ");
assert_eq!(explicit, implicit, "failed port binding");
explicit
};
assert!(check_gitpod_origin1("http://127.0.0.1:42"));
assert!(check_gitpod_origin1("http://127.0.0.1:42"));
assert!(check_gitpod_origin1("https://42-workspace_id.gitpod.typ"));
assert!(!check_gitpod_origin1(
// A path is not allowed in Origin header
"https://42-workspace_id.gitpod.typ/path"
));
assert!(!check_gitpod_origin1(
// Gitpod always runs on default port
"https://42-workspace_id.gitpod.typ:42"
));
assert!(!check_gitpod_origin1("https://42-workspace_id2.gitpod.typ"));
assert!(!check_gitpod_origin1("http://huh.io"));
assert!(!check_gitpod_origin1("https://huh.io"));
}
}

View file

@ -0,0 +1,198 @@
//! Document preview tool for Typst
use std::path::Path;
use reflexo::debug_loc::SourceSpanOffset;
use reflexo_typst::{error::prelude::*, Bytes, Error, TypstDocument};
use tinymist_project::LspCompiledArtifact;
use tinymist_query::{jump_from_click, jump_from_cursor};
use typst::layout::{Abs, Point, Position};
use typst::syntax::{LinkedNode, Source, Span, SyntaxKind};
use typst::World;
use typst_preview::{
CompileStatus, DocToSrcJumpInfo, EditorServer, Location, MemoryFiles, MemoryFilesShort,
};
use typst_shim::syntax::LinkedNodeExt;
use crate::project::{LspInterrupt, ProjectClient, ProjectInsId};
use crate::world::vfs::{notify::MemoryEvent, FileChangeSet};
use crate::*;
/// The compiler's view of the a preview task (server).
pub struct ProjectPreviewHandler {
/// The project id.
pub project_id: ProjectInsId,
/// The connection to the compiler compiling projects (language server).
pub(crate) client: Box<dyn ProjectClient>,
}
impl ProjectPreviewHandler {
/// Requests the compiler to compile the project.
pub fn flush_compile(&self) {
let _ = self.project_id;
self.client
.interrupt(LspInterrupt::Compile(self.project_id.clone()));
}
/// Requests the compiler to settle the project.
pub fn settle(&self) -> Result<(), Error> {
self.client
.interrupt(LspInterrupt::Settle(self.project_id.clone()));
Ok(())
}
/// Requests the compiler to unpin the primary project.
pub fn unpin_primary(&self) {
self.client.server_event(ServerEvent::UnpinPrimaryByPreview);
}
}
impl EditorServer for ProjectPreviewHandler {
async fn update_memory_files(
&self,
files: MemoryFiles,
reset_shadow: bool,
) -> Result<(), Error> {
// todo: is it safe to believe that the path is normalized?
let files = FileChangeSet::new_inserts(
files
.files
.into_iter()
.map(|(path, content)| {
// todo: cloning PathBuf -> Arc<Path>
(path.into(), Ok(Bytes::from_string(content)).into())
})
.collect(),
);
let intr = LspInterrupt::Memory(if reset_shadow {
MemoryEvent::Sync(files)
} else {
MemoryEvent::Update(files)
});
self.client.interrupt(intr);
Ok(())
}
async fn remove_memory_files(&self, files: MemoryFilesShort) -> Result<(), Error> {
// todo: is it safe to believe that the path is normalized?
let files = FileChangeSet::new_removes(files.files.into_iter().map(From::from).collect());
self.client
.interrupt(LspInterrupt::Memory(MemoryEvent::Update(files)));
Ok(())
}
}
/// The preview's view of the compiled artifact.
pub struct PreviewCompileView {
/// The compiled artifact.
pub art: LspCompiledArtifact,
}
impl typst_preview::CompileView for PreviewCompileView {
fn doc(&self) -> Option<TypstDocument> {
self.art.doc.clone()
}
fn status(&self) -> CompileStatus {
match self.art.doc {
Some(_) => CompileStatus::CompileSuccess,
None => CompileStatus::CompileError,
}
}
fn is_on_saved(&self) -> bool {
self.art.snap.signal.by_fs_events
}
fn is_by_entry_update(&self) -> bool {
self.art.snap.signal.by_entry_update
}
fn resolve_source_span(&self, loc: Location) -> Option<SourceSpanOffset> {
let world = self.art.world();
let Location::Src(loc) = loc;
let source_id = world.id_for_path(Path::new(&loc.filepath))?;
let source = world.source(source_id).ok()?;
let cursor =
source.line_column_to_byte(loc.pos.line as usize, loc.pos.character as usize)?;
let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
if !matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText) {
return None;
}
let span = node.span();
// todo: unicode char
let offset = cursor.saturating_sub(node.offset());
Some(SourceSpanOffset { span, offset })
}
// todo: use vec2bbox to handle bbox correctly
fn resolve_frame_loc(
&self,
pos: &reflexo::debug_loc::DocumentPosition,
) -> Option<(SourceSpanOffset, SourceSpanOffset)> {
let TypstDocument::Paged(doc) = self.doc()? else {
return None;
};
let world = self.art.world();
let page = pos.page_no.checked_sub(1)?;
let page = doc.pages.get(page)?;
let click = Point::new(Abs::pt(pos.x as f64), Abs::pt(pos.y as f64));
jump_from_click(world, &page.frame, click)
}
fn resolve_document_position(&self, loc: Location) -> Vec<Position> {
let world = self.art.world();
let Location::Src(src_loc) = loc;
let line = src_loc.pos.line as usize;
let column = src_loc.pos.character as usize;
let doc = self.art.success_doc();
let Some(doc) = doc.as_ref() else {
return vec![];
};
let Some(source_id) = world.id_for_path(Path::new(&src_loc.filepath)) else {
return vec![];
};
let Some(source) = world.source(source_id).ok() else {
return vec![];
};
let Some(cursor) = source.line_column_to_byte(line, column) else {
return vec![];
};
jump_from_cursor(doc, &source, cursor)
}
fn resolve_span(&self, span: Span, offset: Option<usize>) -> Option<DocToSrcJumpInfo> {
let world = self.art.world();
let resolve_off =
|src: &Source, off: usize| src.byte_to_line(off).zip(src.byte_to_column(off));
let source = world.source(span.id()?).ok()?;
let mut range = source.find(span)?.range();
if let Some(off) = offset {
if off < range.len() {
range.start += off;
}
}
// todo: resolve untitled uri.
let filepath = world.path_for_id(span.id()?).ok()?.to_err().ok()?;
Some(DocToSrcJumpInfo {
filepath: filepath.to_string_lossy().to_string(),
start: resolve_off(&source, range.start),
end: resolve_off(&source, range.end),
})
}
}

View file

@ -0,0 +1,377 @@
//! Document preview tool for Typst
use std::net::SocketAddr;
use std::sync::LazyLock;
use hyper::header::HeaderValue;
use hyper::service::service_fn;
use hyper_tungstenite::HyperWebsocket;
use hyper_util::rt::TokioIo;
use hyper_util::server::graceful::GracefulShutdown;
use lsp_types::Url;
use tinymist_std::error::IgnoreLogging;
use tokio::sync::{mpsc, oneshot};
/// created by `make_http_server`
pub struct HttpServer {
/// The address the server is listening on.
pub addr: SocketAddr,
/// The sender to shutdown the server.
pub shutdown_tx: oneshot::Sender<()>,
/// The join handle of the server.
pub join: tokio::task::JoinHandle<()>,
}
/// Create a http server for the previewer.
pub async fn make_http_server(
frontend_html: String,
static_file_addr: String,
websocket_tx: mpsc::UnboundedSender<HyperWebsocket>,
) -> HttpServer {
use http_body_util::Full;
use hyper::body::{Bytes, Incoming};
type Server = hyper_util::server::conn::auto::Builder<hyper_util::rt::TokioExecutor>;
let listener = tokio::net::TcpListener::bind(&static_file_addr)
.await
.unwrap();
let addr = listener.local_addr().unwrap();
log::info!("preview server listening on http://{addr}");
let frontend_html = hyper::body::Bytes::from(frontend_html);
let make_service = move || {
let frontend_html = frontend_html.clone();
let websocket_tx = websocket_tx.clone();
let static_file_addr = static_file_addr.clone();
service_fn(move |mut req: hyper::Request<Incoming>| {
let frontend_html = frontend_html.clone();
let websocket_tx = websocket_tx.clone();
let static_file_addr = static_file_addr.clone();
async move {
// When a user visits a website in a browser, that website can try to connect to
// our http / websocket server on `127.0.0.1` which may leak sensitive
// information. We could use CORS headers to explicitly disallow
// this. However, for Websockets, this does not work. Thus, we
// manually check the `Origin` header. Browsers always send this
// header for cross-origin requests.
//
// Important: This does _not_ protect against malicious users that share the
// same computer as us (i.e. multi- user systems where the users
// don't trust each other). In this case, malicious attackers can _still_
// connect to our http / websocket servers (using a browser and
// otherwise). And additionally they can impersonate a tinymist
// http / websocket server towards a legitimate frontend/html client.
// This requires additional protection that may be added in the future.
let origin_header = req.headers().get("Origin");
if origin_header
.is_some_and(|h| !is_valid_origin(h, &static_file_addr, addr.port()))
{
anyhow::bail!(
"Connection with unexpected `Origin` header. Closing connection."
);
}
// Check if the request is a websocket upgrade request.
if hyper_tungstenite::is_upgrade_request(&req) {
if origin_header.is_none() {
log::error!("websocket connection is not set `Origin` header, which will be a hard error in the future.");
}
let Some((response, websocket)) = hyper_tungstenite::upgrade(&mut req, None)
.log_error("Error in websocket upgrade")
else {
anyhow::bail!("cannot upgrade as websocket connection");
};
let _ = websocket_tx.send(websocket);
// Return the response so the spawned future can continue.
Ok(response)
} else if req.uri().path() == "/" {
// log::debug!("Serve frontend: {mode:?}");
let res = hyper::Response::builder()
.header(hyper::header::CONTENT_TYPE, "text/html")
.body(Full::<Bytes>::from(frontend_html))
.unwrap();
Ok(res)
} else {
// jump to /
let res = hyper::Response::builder()
.status(hyper::StatusCode::FOUND)
.header(hyper::header::LOCATION, "/")
.body(Full::<Bytes>::default())
.unwrap();
Ok(res)
}
}
})
};
let (shutdown_tx, rx) = tokio::sync::oneshot::channel();
let (final_tx, final_rx) = tokio::sync::oneshot::channel();
// the graceful watcher
let graceful = hyper_util::server::graceful::GracefulShutdown::new();
let serve_conn = move |server: &Server, graceful: &GracefulShutdown, conn| {
let (stream, _peer_addr) = match conn {
Ok(conn) => conn,
Err(e) => {
log::error!("accept error: {e}");
return;
}
};
let conn = server.serve_connection_with_upgrades(TokioIo::new(stream), make_service());
let conn = graceful.watch(conn.into_owned());
tokio::spawn(async move {
conn.await.log_error("cannot serve http");
});
};
let join = tokio::spawn(async move {
// when this signal completes, start shutdown
let mut signal = std::pin::pin!(final_rx);
let mut server = Server::new(hyper_util::rt::TokioExecutor::new());
server.http1().keep_alive(true);
loop {
tokio::select! {
conn = listener.accept() => serve_conn(&server, &graceful, conn),
Ok(_) = &mut signal => {
log::info!("graceful shutdown signal received");
break;
}
}
}
tokio::select! {
_ = graceful.shutdown() => {
log::info!("Gracefully shutdown!");
},
_ = tokio::time::sleep(reflexo::time::Duration::from_secs(10)) => {
log::info!("Waited 10 seconds for graceful shutdown, aborting...");
}
}
});
tokio::spawn(async move {
let _ = rx.await;
final_tx.send(()).ok();
log::info!("Preview server joined");
});
HttpServer {
addr,
shutdown_tx,
join,
}
}
fn is_valid_origin(h: &HeaderValue, static_file_addr: &str, expected_port: u16) -> bool {
static GITPOD_ID_AND_HOST: LazyLock<Option<(String, String)>> = LazyLock::new(|| {
let workspace_id = std::env::var("GITPOD_WORKSPACE_ID").ok();
let cluster_host = std::env::var("GITPOD_WORKSPACE_CLUSTER_HOST").ok();
workspace_id.zip(cluster_host)
});
is_valid_origin_impl(h, static_file_addr, expected_port, &GITPOD_ID_AND_HOST)
}
// Separate function so we can do gitpod-related tests without relying on env
// vars.
fn is_valid_origin_impl(
origin_header: &HeaderValue,
static_file_addr: &str,
expected_port: u16,
gitpod_id_and_host: &Option<(String, String)>,
) -> bool {
let Ok(Ok(origin_url)) = origin_header.to_str().map(Url::parse) else {
return false;
};
// Path is not allowed in Origin headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
if origin_url.path() != "/" && origin_url.path() != "" {
return false;
};
let expected_origin = {
let expected_host = Url::parse(&format!("http://{static_file_addr}")).unwrap();
let expected_host = expected_host.host_str().unwrap();
// Don't take the port from `static_file_addr` (it may have a dummy port e.g.
// `127.0.0.1:0`)
format!("http://{expected_host}:{expected_port}")
};
let gitpod_expected_origin = gitpod_id_and_host
.as_ref()
.map(|(workspace_id, cluster_host)| {
format!("https://{expected_port}-{workspace_id}.{cluster_host}")
});
*origin_header == expected_origin
// tmistele (PR #1382): The VSCode webview panel needs an exception: It doesn't send `http://{static_file_addr}`
// as `Origin`. Instead it sends `vscode-webview://<random>`. Thus, we allow any
// `Origin` starting with `vscode-webview://` as well. I think that's okay from a security
// point of view, because I think malicious websites can't trick browsers into sending
// `vscode-webview://...` as `Origin`.
|| origin_url.scheme() == "vscode-webview"
// `code-server` also needs an exception: It opens `http://localhost:8080/proxy/<port>` in
// the browser and proxies requests through to tinymist (which runs at `127.0.0.1:<port>`).
// Thus, the `Origin` header will be `http://localhost:8080` which doesn't match what
// we expect. Thus, just always allow anything from localhost/127.0.0.1
// https://github.com/Myriad-Dreamin/tinymist/issues/1350
|| (
matches!(origin_url.host_str(), Some("localhost") | Some("127.0.0.1"))
&& origin_url.scheme() == "http"
)
// `gitpod` also needs an exception. It loads `https://<port>-<workspace>.<host>` in the browser
// and proxies requests through to tinymist (which runs as `127.0.0.1:<port>`).
// We can detect this by looking at the env variables (see `GITPOD_ID_AND_HOST` in `is_valid_origin(..)`)
|| gitpod_expected_origin.is_some_and(|o| o == *origin_header)
}
#[cfg(test)]
mod tests {
use super::*;
fn check_origin(origin: &'static str, static_file_addr: &str, port: u16) -> bool {
is_valid_origin(&HeaderValue::from_static(origin), static_file_addr, port)
}
#[test]
fn test_valid_origin_localhost() {
assert!(check_origin("http://127.0.0.1:42", "127.0.0.1:42", 42));
assert!(check_origin("http://127.0.0.1:42", "127.0.0.1:42", 42));
assert!(check_origin("http://127.0.0.1:42", "127.0.0.1:0", 42));
assert!(check_origin("http://localhost:42", "127.0.0.1:42", 42));
assert!(check_origin("http://localhost:42", "127.0.0.1:0", 42));
assert!(check_origin("http://localhost", "127.0.0.1:0", 42));
assert!(check_origin("http://127.0.0.1:42", "localhost:42", 42));
assert!(check_origin("http://127.0.0.1:42", "localhost:42", 42));
assert!(check_origin("http://127.0.0.1:42", "localhost:0", 42));
assert!(check_origin("http://localhost:42", "localhost:42", 42));
assert!(check_origin("http://localhost:42", "localhost:0", 42));
assert!(check_origin("http://localhost", "localhost:0", 42));
}
#[test]
fn test_invalid_origin_localhost() {
assert!(!check_origin("https://huh.io:8080", "127.0.0.1:42", 42));
assert!(!check_origin("http://huh.io:8080", "127.0.0.1:42", 42));
assert!(!check_origin("https://huh.io:443", "127.0.0.1:42", 42));
assert!(!check_origin("http://huh.io:42", "127.0.0.1:0", 42));
assert!(!check_origin("http://huh.io", "127.0.0.1:42", 42));
assert!(!check_origin("https://huh.io", "127.0.0.1:42", 42));
assert!(!check_origin("https://huh.io:8080", "localhost:42", 42));
assert!(!check_origin("http://huh.io:8080", "localhost:42", 42));
assert!(!check_origin("https://huh.io:443", "localhost:42", 42));
assert!(!check_origin("http://huh.io:42", "localhost:0", 42));
assert!(!check_origin("http://huh.io", "localhost:42", 42));
assert!(!check_origin("https://huh.io", "localhost:42", 42));
}
#[test]
fn test_invalid_origin_scheme() {
assert!(!check_origin("ftp://127.0.0.1:42", "127.0.0.1:42", 42));
assert!(!check_origin("ftp://localhost:42", "127.0.0.1:42", 42));
assert!(!check_origin("ftp://127.0.0.1:42", "127.0.0.1:0", 42));
assert!(!check_origin("ftp://localhost:42", "127.0.0.1:0", 42));
// The scheme must be specified.
assert!(!check_origin("127.0.0.1:42", "127.0.0.1:0", 42));
assert!(!check_origin("localhost:42", "127.0.0.1:0", 42));
assert!(!check_origin("localhost:42", "127.0.0.1:42", 42));
assert!(!check_origin("127.0.0.1:42", "127.0.0.1:42", 42));
}
#[test]
fn test_valid_origin_vscode() {
assert!(check_origin("vscode-webview://it", "127.0.0.1:42", 42));
assert!(check_origin("vscode-webview://it", "127.0.0.1:0", 42));
}
#[test]
fn test_origin_manually_binding() {
assert!(!check_origin("https://huh.io:8080", "huh.io:42", 42));
assert!(!check_origin("http://huh.io:8080", "huh.io:42", 42));
assert!(!check_origin("https://huh.io:443", "huh.io:42", 42));
assert!(check_origin("http://huh.io:42", "huh.io:0", 42));
assert!(!check_origin("http://huh.io", "huh.io:42", 42));
assert!(!check_origin("https://huh.io", "huh.io:42", 42));
assert!(check_origin("http://127.0.0.1:42", "huh.io:42", 42));
assert!(check_origin("http://127.0.0.1:42", "huh.io:42", 42));
assert!(check_origin("http://127.0.0.1:42", "huh.io:0", 42));
assert!(check_origin("http://localhost:42", "huh.io:42", 42));
assert!(check_origin("http://localhost:42", "huh.io:0", 42));
assert!(!check_origin("https://huh2.io:8080", "huh.io:42", 42));
assert!(!check_origin("http://huh2.io:8080", "huh.io:42", 42));
assert!(!check_origin("https://huh2.io:443", "huh.io:42", 42));
assert!(!check_origin("http://huh2.io:42", "huh.io:0", 42));
assert!(!check_origin("http://huh2.io", "huh.io:42", 42));
assert!(!check_origin("https://huh2.io", "huh.io:42", 42));
}
// https://github.com/Myriad-Dreamin/tinymist/issues/1350
// the origin of code-server's proxy
#[test]
fn test_valid_origin_code_server_proxy() {
assert!(check_origin(
// The URL has path /proxy/45411 but that is not sent in the Origin header
"http://localhost:8080",
"127.0.0.1:42",
42
));
assert!(check_origin("http://localhost", "127.0.0.1:42", 42));
}
// the origin of gitpod
#[test]
fn test_valid_origin_gitpod_proxy() {
fn check_gitpod_origin(
origin: &'static str,
static_file_addr: &str,
port: u16,
workspace: &str,
cluster_host: &str,
) -> bool {
is_valid_origin_impl(
&HeaderValue::from_static(origin),
static_file_addr,
port,
&Some((workspace.to_owned(), cluster_host.to_owned())),
)
}
let check_gitpod_origin1 = |origin: &'static str| {
let explicit =
check_gitpod_origin(origin, "127.0.0.1:42", 42, "workspace_id", "gitpod.typ");
let implicit =
check_gitpod_origin(origin, "127.0.0.1:0", 42, "workspace_id", "gitpod.typ");
assert_eq!(explicit, implicit, "failed port binding");
explicit
};
assert!(check_gitpod_origin1("http://127.0.0.1:42"));
assert!(check_gitpod_origin1("http://127.0.0.1:42"));
assert!(check_gitpod_origin1("https://42-workspace_id.gitpod.typ"));
assert!(!check_gitpod_origin1(
// A path is not allowed in Origin header
"https://42-workspace_id.gitpod.typ/path"
));
assert!(!check_gitpod_origin1(
// Gitpod always runs on default port
"https://42-workspace_id.gitpod.typ:42"
));
assert!(!check_gitpod_origin1("https://42-workspace_id2.gitpod.typ"));
assert!(!check_gitpod_origin1("http://huh.io"));
assert!(!check_gitpod_origin1("https://huh.io"));
}
}

View file

@ -106,7 +106,7 @@ impl ControlPlaneTx {
}
pub struct EditorActor<T> {
client: Arc<T>,
server: Arc<T>,
mailbox: mpsc::UnboundedReceiver<EditorActorRequest>,
editor_conn: ControlPlaneTx,
@ -150,7 +150,7 @@ pub enum ControlPlaneResponse {
impl<T: EditorServer> EditorActor<T> {
pub fn new(
client: Arc<T>,
server: Arc<T>,
mailbox: mpsc::UnboundedReceiver<EditorActorRequest>,
editor_websocket_conn: ControlPlaneTx,
renderer_sender: broadcast::Sender<RenderActorRequest>,
@ -158,7 +158,7 @@ impl<T: EditorServer> EditorActor<T> {
span_interner: SpanInterner,
) -> Self {
Self {
client,
server,
mailbox,
editor_conn: editor_websocket_conn,
renderer_sender,
@ -230,7 +230,7 @@ impl<T: EditorServer> EditorActor<T> {
);
handle_error(
"SyncMemoryFiles",
self.client.update_memory_files(req, true).await,
self.server.update_memory_files(req, true).await,
);
}
ControlPlaneMessage::UpdateMemoryFiles(req) => {
@ -240,14 +240,14 @@ impl<T: EditorServer> EditorActor<T> {
);
handle_error(
"UpdateMemoryFiles",
self.client.update_memory_files(req, false).await,
self.server.update_memory_files(req, false).await,
);
}
ControlPlaneMessage::RemoveMemoryFiles(req) => {
log::debug!("EditorActor: processing REMOVE memory files: {:?}", req.files);
handle_error(
"RemoveMemoryFiles",
self.client.remove_shadow_files(req).await,
self.server.remove_memory_files(req).await,
);
}
};

View file

@ -49,9 +49,9 @@ pub fn frontend_html(html: &str, mode: PreviewMode, to: &str) -> String {
pub async fn preview(
arguments: PreviewArgs,
conn: ControlPlaneTx,
client: Arc<impl EditorServer>,
server: Arc<impl EditorServer>,
) -> Previewer {
PreviewBuilder::new(arguments).build(conn, client).await
PreviewBuilder::new(arguments).build(conn, server).await
}
pub struct Previewer {
@ -230,7 +230,7 @@ impl PreviewBuilder {
})
}
pub async fn build<T: EditorServer>(self, conn: ControlPlaneTx, client: Arc<T>) -> Previewer {
pub async fn build<T: EditorServer>(self, conn: ControlPlaneTx, server: Arc<T>) -> Previewer {
let PreviewBuilder {
arguments,
shutdown_tx,
@ -247,7 +247,7 @@ impl PreviewBuilder {
// Spawns the editor actor
let editor_actor = EditorActor::new(
client,
server,
editor_rx,
conn,
renderer_mailbox.0.clone(),
@ -305,7 +305,7 @@ pub trait EditorServer: Send + Sync + 'static {
async { Ok(()) }
}
fn remove_shadow_files(
fn remove_memory_files(
&self,
_files: MemoryFilesShort,
) -> impl Future<Output = Result<(), Error>> + Send {