diff --git a/Cargo.lock b/Cargo.lock index c1dfb8c5e..0082ebe36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3837,6 +3837,7 @@ dependencies = [ "open", "parking_lot", "paste", + "rand", "rayon", "reflexo", "reflexo-typst", @@ -3844,9 +3845,10 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "strum", "sync-lsp", - "tinymist-assets 0.12.16-rc1 (registry+https://github.com/rust-lang/crates.io-index)", + "tinymist-assets", "tinymist-query", "tinymist-render", "tinymist-world", @@ -3892,12 +3894,6 @@ dependencies = [ name = "tinymist-assets" version = "0.12.16-rc1" -[[package]] -name = "tinymist-assets" -version = "0.12.16-rc1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81d5678f6753b3adda671a428842f1ada25ded7ce78dcfe83bb152120eb7b3fc" - [[package]] name = "tinymist-derive" version = "0.12.16-rc1" @@ -3989,7 +3985,7 @@ dependencies = [ "serde", "serde_json", "tar", - "tinymist-assets 0.12.16-rc1 (registry+https://github.com/rust-lang/crates.io-index)", + "tinymist-assets", "typst", "typst-assets", ] @@ -4376,7 +4372,7 @@ dependencies = [ "reflexo-vec2svg", "serde", "serde_json", - "tinymist-assets 0.12.16-rc1 (registry+https://github.com/rust-lang/crates.io-index)", + "tinymist-assets", "tokio", "typst", "typst-assets", diff --git a/Cargo.toml b/Cargo.toml index 130b97b9c..6e617da78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ strum = { version = "0.26.2", features = ["derive"] } quote = "1" syn = "2" triomphe = { version = "0.1.10", default-features = false, features = ["std"] } +rand = "0.8.5" +sha2 = "0.10.8" # Asynchoronous and Multi-threading async-trait = "0.1.77" @@ -146,7 +148,9 @@ insta = { version = "1.39", features = ["glob"] } # Our Own Crates typst-preview = { path = "./crates/typst-preview" } -tinymist-assets = { version = "0.12.16-rc1" } +# TODO: temporarily for dev +# tinymist-assets = { version = "0.12.16-rc1" } +tinymist-assets = { path = "./crates/tinymist-assets" } tinymist = { path = "./crates/tinymist/" } tinymist-derive = { path = "./crates/tinymist-derive/" } tinymist-analysis = { path = "./crates/tinymist-analysis/" } diff --git a/crates/tinymist/Cargo.toml b/crates/tinymist/Cargo.toml index 2cb6a54dc..a79493503 100644 --- a/crates/tinymist/Cargo.toml +++ b/crates/tinymist/Cargo.toml @@ -84,6 +84,8 @@ open = { workspace = true, optional = true } dirs.workspace = true base64.workspace = true rayon.workspace = true +rand.workspace = true +sha2.workspace = true [features] default = ["cli", "embed-fonts", "no-content-hint", "preview"] diff --git a/crates/tinymist/src/tool/preview.rs b/crates/tinymist/src/tool/preview.rs index 5d1f4b94e..64e95441e 100644 --- a/crates/tinymist/src/tool/preview.rs +++ b/crates/tinymist/src/tool/preview.rs @@ -1,11 +1,13 @@ //! Document preview tool for Typst +mod auth; + use std::num::NonZeroUsize; use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc}; use futures::{SinkExt, StreamExt, TryStreamExt}; use hyper::service::service_fn; -use hyper_tungstenite::{tungstenite::Message, HyperWebsocket, HyperWebsocketStream}; +use hyper_tungstenite::{tungstenite::Message, HyperWebsocketStream}; use hyper_util::rt::TokioIo; use hyper_util::server::graceful::GracefulShutdown; use lsp_types::notification::Notification; @@ -22,9 +24,9 @@ use typst::syntax::{LinkedNode, Source, Span, SyntaxKind, VirtualPath}; use typst::World; pub use typst_preview::CompileStatus; use typst_preview::{ - frontend_html, CompileHost, ControlPlaneMessage, ControlPlaneResponse, ControlPlaneRx, - ControlPlaneTx, DocToSrcJumpInfo, EditorServer, Location, MemoryFiles, MemoryFilesShort, - PreviewArgs, PreviewBuilder, PreviewMode, Previewer, SourceFileServer, WsMessage, + CompileHost, ControlPlaneMessage, ControlPlaneResponse, ControlPlaneRx, ControlPlaneTx, + DocToSrcJumpInfo, EditorServer, Location, MemoryFiles, MemoryFilesShort, PreviewArgs, + PreviewBuilder, PreviewMode, Previewer, SourceFileServer, WsMessage, }; use typst_shim::syntax::LinkedNodeExt; @@ -228,6 +230,12 @@ pub struct PreviewCliArgs { /// Don't open the preview in the browser after compilation. #[clap(long = "no-open")] pub dont_open_in_browser: bool, + + /// Use this to disable websocket authentication for the control plane server. Careful: Among other things, this allows any website you visit to use the control plane server. + /// + /// This option is only meant to ease the transition to authentication for downstream packages. It will be removed in a future version of tinymist. + #[clap(long, default_value = "false")] + pub disable_control_plane_auth: bool, } /// The global state of the preview tool. @@ -263,6 +271,7 @@ pub struct StartPreviewResponse { static_server_port: Option, static_server_addr: Option, data_plane_port: Option, + secret: String, is_primary: bool, } @@ -340,10 +349,15 @@ impl PreviewState { // The fence must be put after the previewer is initialized. compile_handler.flush_compile(); - // Replace the data plane port in the html to self - let frontend_html = frontend_html(TYPST_PREVIEW_HTML, args.preview_mode, "/"); + let secret = auth::generate_token(); - let srv = make_http_server(frontend_html, args.data_plane_host, websocket_tx).await; + let srv = make_http_server( + true, + args.data_plane_host, + Some(secret.clone()), + websocket_tx, + ) + .await; let addr = srv.addr; log::info!("PreviewTask({task_id}): preview server listening on: {addr}"); @@ -351,6 +365,7 @@ impl PreviewState { static_server_port: Some(addr.port()), static_server_addr: Some(addr.to_string()), data_plane_port: Some(addr.port()), + secret, is_primary, }; @@ -399,21 +414,21 @@ pub struct HttpServer { /// Create a http server for the previewer. pub async fn make_http_server( - frontend_html: String, + serve_frontend_html: bool, static_file_addr: String, - websocket_tx: mpsc::UnboundedSender, + secret: Option, + websocket_tx: mpsc::UnboundedSender, ) -> HttpServer { use http_body_util::Full; use hyper::body::{Bytes, Incoming}; type Server = hyper_util::server::conn::auto::Builder; - 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 secret = secret.clone(); service_fn(move |mut req: hyper::Request| { - let frontend_html = frontend_html.clone(); let websocket_tx = websocket_tx.clone(); + let secret = secret.clone(); async move { // Check if the request is a websocket upgrade request. if hyper_tungstenite::is_upgrade_request(&req) { @@ -424,7 +439,34 @@ pub async fn make_http_server( }) .unwrap(); - let _ = websocket_tx.send(websocket); + tokio::spawn(async move { + let websocket = websocket.await.unwrap(); + + // Authenticate the client before we talk to it. + // Important even if we run on localhost because + // 1) browsers allow any website to connect to http servers/websockets on localhost + // 2) on multi-user systems another (potentially untrusted) user can connect to localhost. + // + // Note: We use authentication only for the websocket. The static HTML file server (see below) + // only serves a not secret static template, so we don't bother with authentication there. + match secret { + Some(secret) => { + if let Ok(websocket) = + auth::try_auth_websocket_client(websocket, &secret).await + { + let _ = websocket_tx.send(websocket); + } else { + log::error!("Websocket client authentication failed"); + } + } + None => { + // We optionally allow to skip authentication upon explicit request to ease the transition to + // authentication for downstream packages. + // FIXME: Remove this is in a future version. + let _ = websocket_tx.send(websocket); + } + } + }); // Return the response so the spawned future can continue. Ok(response) @@ -432,9 +474,17 @@ pub async fn make_http_server( // log::debug!("Serve frontend: {mode:?}"); let res = hyper::Response::builder() .header(hyper::header::CONTENT_TYPE, "text/html") - .body(Full::::from(frontend_html)) + // It's important that we serve a static template that only contains information that is public anyway. + // Otherwise, we need authentication here (see comment for websocket case above). + // In particular, the websocket port, the secret etc. must not be in the HTML we serve. These information + // are in the # part of the URL. + .body(Full::::from(if serve_frontend_html { + TYPST_PREVIEW_HTML + } else { + "" + })) .unwrap(); - Ok::<_, std::convert::Infallible>(res) + Ok::<_, anyhow::Error>(res) } else { // jump to / let res = hyper::Response::builder() @@ -570,17 +620,33 @@ pub async fn preview_main(args: PreviewCliArgs) -> anyhow::Result<()> { (service, handle) }; + let secret = auth::generate_token(); + log::info!("Secret for websocket authentication: {secret}"); + let (lsp_tx, mut lsp_rx) = ControlPlaneTx::new(true); + let secret_for_control_plane = if args.disable_control_plane_auth { + log::warn!( + "Disabling authentication for the control plane server. This is not recommended." + ); + None + } else { + Some(secret.clone()) + }; let control_plane_server_handle = tokio::spawn(async move { let (control_sock_tx, mut control_sock_rx) = mpsc::unbounded_channel(); - let srv = - make_http_server(String::default(), args.control_plane_host, control_sock_tx).await; + let srv = make_http_server( + false, + args.control_plane_host, + secret_for_control_plane, + control_sock_tx, + ) + .await; log::info!("Control panel server listening on: {}", srv.addr); let control_websocket = control_sock_rx.recv().await.unwrap(); - let ws = control_websocket.await.unwrap(); + let ws = control_websocket; tokio::pin!(ws); @@ -641,24 +707,42 @@ pub async fn preview_main(args: PreviewCliArgs) -> anyhow::Result<()> { bind_streams(&mut previewer, websocket_rx); - let frontend_html = frontend_html(TYPST_PREVIEW_HTML, args.preview_mode, "/"); - let static_server = if let Some(static_file_host) = static_file_host { log::warn!("--static-file-host is deprecated, which will be removed in the future. Use --data-plane-host instead."); - let html = frontend_html.clone(); - Some(make_http_server(html, static_file_host, websocket_tx.clone()).await) + Some( + make_http_server( + true, + static_file_host, + Some(secret.clone()), + websocket_tx.clone(), + ) + .await, + ) } else { None }; - let srv = make_http_server(frontend_html, args.data_plane_host, websocket_tx).await; + let srv = make_http_server( + true, + args.data_plane_host, + Some(secret.clone()), + websocket_tx, + ) + .await; log::info!("Data plane server listening on: {}", srv.addr); let static_server_addr = static_server.as_ref().map(|s| s.addr).unwrap_or(srv.addr); - log::info!("Static file server listening on: {static_server_addr}"); + let preview_url = format!( + "http://{static_server_addr}/#secret={secret}&previewMode={}", + match args.preview_mode { + PreviewMode::Document => "Doc", + PreviewMode::Slide => "Slide", + } + ); + log::info!("Static file server listening on: {preview_url}"); if !args.dont_open_in_browser { - if let Err(e) = open::that_detached(format!("http://{static_server_addr}")) { + if let Err(e) = open::that_detached(preview_url) { log::error!("failed to open browser: {e}"); }; } @@ -741,29 +825,26 @@ fn find_in_frame(frame: &Frame, span: Span, min_dis: &mut u64, p: &mut Point) -> None } -fn bind_streams(previewer: &mut Previewer, websocket_rx: mpsc::UnboundedReceiver) { - previewer.start_data_plane( - websocket_rx, - |conn: Result| { - let conn = conn.map_err(error_once_map_string!("cannot receive websocket"))?; - - Ok(conn - .sink_map_err(|e| error_once!("cannot serve_with websocket", err: e.to_string())) - .map_err(|e| error_once!("cannot serve_with websocket", err: e.to_string())) - .with(|msg| { - Box::pin(async move { - let msg = match msg { - WsMessage::Text(msg) => Message::Text(msg), - WsMessage::Binary(msg) => Message::Binary(msg), - }; - Ok(msg) - }) +fn bind_streams( + previewer: &mut Previewer, + websocket_rx: mpsc::UnboundedReceiver, +) { + previewer.start_data_plane(websocket_rx, |conn: HyperWebsocketStream| { + conn.sink_map_err(|e| error_once!("cannot serve_with websocket", err: e.to_string())) + .map_err(|e| error_once!("cannot serve_with websocket", err: e.to_string())) + .with(|msg| { + Box::pin(async move { + let msg = match msg { + WsMessage::Text(msg) => Message::Text(msg), + WsMessage::Binary(msg) => Message::Binary(msg), + }; + Ok(msg) }) - .map_ok(|msg| match msg { - Message::Text(msg) => WsMessage::Text(msg), - Message::Binary(msg) => WsMessage::Binary(msg), - _ => WsMessage::Text("unsupported message".to_owned()), - })) - }, - ); + }) + .map_ok(|msg| match msg { + Message::Text(msg) => WsMessage::Text(msg), + Message::Binary(msg) => WsMessage::Binary(msg), + _ => WsMessage::Text("unsupported message".to_owned()), + }) + }); } diff --git a/crates/tinymist/src/tool/preview/auth.rs b/crates/tinymist/src/tool/preview/auth.rs new file mode 100644 index 000000000..a1e9b0f3a --- /dev/null +++ b/crates/tinymist/src/tool/preview/auth.rs @@ -0,0 +1,103 @@ +use anyhow::{Context, Result}; +use futures::SinkExt; +use futures::StreamExt; +use hyper_tungstenite::{tungstenite::Message, HyperWebsocketStream}; +use rand::distributions::{Alphanumeric, DistString}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha512}; + +const TOKEN_LENGTH: usize = 50; + +pub fn generate_token() -> String { + Alphanumeric.sample_string(&mut rand::thread_rng(), TOKEN_LENGTH) +} + +fn sha512hex(text: &str) -> String { + let hash = Sha512::digest(text); + format!("{:x}", hash) +} + +#[derive(Serialize, Debug)] +struct AuthMsgChallenge<'a> { + challenge: &'a str, +} + +#[derive(Deserialize, Debug)] +struct AuthMsgResponseClient<'a> { + hash: &'a str, + challenge: &'a str, + client_nonce: &'a str, +} + +#[derive(Serialize, Debug)] +struct AuthMsgResponseServer<'a> { + hash: &'a str, + server_nonce: &'a str, +} + +#[derive(Serialize, Debug)] +struct AuthFailResponseServer { + auth: bool, +} + +#[derive(Debug, Clone)] +struct AuthError; + +impl std::fmt::Display for AuthError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "Authentication failed") + } +} + +impl std::error::Error for AuthError {} + +/// Websocket Authentication +/// +/// This a) authenticates the client to us and b) we (the server) authenticate ourselves to the client. +/// +/// a) is important so that we don't send any sensitive information to clients that are not supposed to know that information. +/// For example, this protects against the fact that browsers allow any website to connect to websockets on 127.0.0.1 +/// b) is important because on multi-user systems another user can potentially impersonate us (the server) and trick the client +/// into sending private information to that other server instead of to us. +/// +/// This function takes an owned `HyperWebsocketStream` to avoid accidental misuse elsewhere before authentication. +/// +/// TODO: This is a basic challenge response authentication with nonces. Is there something more standard we could use? +pub async fn try_auth_websocket_client( + mut websocket: HyperWebsocketStream, + secret: &str, +) -> Result { + // First we send the client a challenge to authenticate the client to us ... + let challenge = generate_token(); + let json = serde_json::to_string(&AuthMsgChallenge { + challenge: &challenge, + })?; + websocket.send(Message::Binary(json.into())).await?; + + let response = websocket + .next() + .await + .context("auth response 1 missing")??; + let response: AuthMsgResponseClient = serde_json::from_str(response.to_text()?)?; + + if sha512hex(format!("{}:{}:{}", secret, challenge, response.client_nonce).as_str()) + == response.hash + { + // ... then we authenticate to the client + let server_nonce = generate_token(); + let hash = + sha512hex(format!("{}:{}:{}", secret, response.challenge, server_nonce).as_str()); + let json = serde_json::to_string(&AuthMsgResponseServer { + server_nonce: &server_nonce, + hash: &hash, + })?; + websocket.send(Message::Binary(json.into())).await?; + + Ok(websocket) + } else { + log::info!("Websocket client authentication failed."); + let json = serde_json::to_string(&AuthFailResponseServer { auth: false })?; + websocket.send(Message::Binary(json.into())).await?; + Err(AuthError.into()) + } +} diff --git a/crates/typst-preview/src/lib.rs b/crates/typst-preview/src/lib.rs index 15ac432de..9f1e658f5 100644 --- a/crates/typst-preview/src/lib.rs +++ b/crates/typst-preview/src/lib.rs @@ -33,19 +33,6 @@ type Message = WsMessage; pub trait CompileHost: SourceFileServer + EditorServer {} -/// Get the HTML for the frontend by a given preview mode and server to connect -pub fn frontend_html(html: &str, mode: PreviewMode, to: &str) -> String { - let mode = match mode { - PreviewMode::Document => "Doc", - PreviewMode::Slide => "Slide", - }; - - html.replace("ws://127.0.0.1:23625", to).replace( - "preview-arg:previewMode:Doc", - format!("preview-arg:previewMode:{mode}").as_str(), - ) -} - /// Shortcut to create a previewer. pub async fn preview( arguments: PreviewArgs, @@ -82,12 +69,11 @@ impl Previewer { + futures::Stream> + Send + 'static, - S: 'static, - SFut: Future + Send + 'static, + S: Send + 'static, >( &mut self, - mut streams: mpsc::UnboundedReceiver, - caster: impl Fn(S) -> Result + Send + Sync + Copy + 'static, + mut streams: mpsc::UnboundedReceiver, + caster: impl Fn(S) -> C + Send + Sync + Copy + 'static, ) { let idle_timeout = reflexo_typst::time::Duration::from_secs(5); let (conn_handler, shutdown_tx, mut shutdown_data_plane_rx) = @@ -98,7 +84,7 @@ impl Previewer { let h = conn_handler.clone(); let alive_tx = alive_tx.clone(); tokio::spawn(async move { - let conn: C = caster(conn.await).unwrap(); + let conn: C = caster(conn); tokio::pin!(conn); if h.enable_partial_rendering { diff --git a/editors/vscode/src/features/preview-compat.ts b/editors/vscode/src/features/preview-compat.ts index 3f41b872d..3418c2fa4 100644 --- a/editors/vscode/src/features/preview-compat.ts +++ b/editors/vscode/src/features/preview-compat.ts @@ -287,10 +287,11 @@ export const launchPreviewCompat = async (task: LaunchInBrowserTask | LaunchInWe const enableCursor = vscode.workspace.getConfiguration().get("typst-preview.cursorIndicator") || false; await watchEditorFiles(); - const { serverProcess, controlPlanePort, dataPlanePort, staticFilePort } = await launchCli( + const { serverProcess, controlPlanePort, dataPlanePort, secret, staticFilePort } = await launchCli( task.kind === "browser", ); + const addonΠserver = new Addon2Server( controlPlanePort, enableCursor, @@ -320,7 +321,7 @@ export const launchPreviewCompat = async (task: LaunchInBrowserTask | LaunchInWe }); let connectUrl = `ws://127.0.0.1:${dataPlanePort}`; - contentPreviewProvider.then((p) => p.postActivate(connectUrl)); + contentPreviewProvider.then((p) => p.postActivate(connectUrl, secret)); let panel: vscode.WebviewPanel | undefined = undefined; if (task.kind == "webview") { panel = await openPreviewInWebView({ @@ -328,11 +329,12 @@ export const launchPreviewCompat = async (task: LaunchInBrowserTask | LaunchInWe task, activeEditor, dataPlanePort, + secret, webviewPanel, panelDispose() { activeTask.delete(bindDocument); serverProcess.kill(); - contentPreviewProvider.then((p) => p.postDeactivate(connectUrl)); + contentPreviewProvider.then((p) => p.postDeactivate(connectUrl, secret)); }, }); } @@ -412,14 +414,22 @@ export const launchPreviewCompat = async (task: LaunchInBrowserTask | LaunchInWe console.log( `Launched server, data plane port:${dataPlanePort}, control plane port:${controlPlanePort}, static file port:${staticFilePort}`, ); + const secret = '__unsafe-disable__'; if (openInBrowser) { - vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${staticFilePort}`)); + const wsUrl = `ws://127.0.0.1:${dataPlanePort}`; + const queryString = (new URLSearchParams({ + previewMode: task.mode === "doc" ? "Doc" : "Slide", + secret, + wsUrl, + })).toString(); + vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${staticFilePort}/#${queryString}`)); } // window.typstWebsocket.send("current"); return { serverProcess, dataPlanePort, controlPlanePort, + secret, staticFilePort, }; } @@ -479,6 +489,7 @@ export class Addon2Server { bindDocument: vscode.TextDocument, activeEditor: vscode.TextEditor, ) { + // We're talking to the typst-preview server here so no authentication. const conn = new WebSocket(`ws://127.0.0.1:${controlPlanePort}`); conn.addEventListener("message", async (message) => { const data = JSON.parse(message.data as string); diff --git a/editors/vscode/src/features/preview.ts b/editors/vscode/src/features/preview.ts index 6160d674e..6086bfcfe 100644 --- a/editors/vscode/src/features/preview.ts +++ b/editors/vscode/src/features/preview.ts @@ -211,6 +211,10 @@ interface OpenPreviewInWebViewArgs { * The server is already opened by the {@link launchImpl} function. */ dataPlanePort: string | number; + /** + * The secret used for websocket authentication. + */ + secret: string; /** * The existing webview panel to reuse. */ @@ -232,6 +236,7 @@ export async function openPreviewInWebView({ task, activeEditor, dataPlanePort, + secret, webviewPanel, panelDispose, }: OpenPreviewInWebViewArgs) { @@ -258,32 +263,37 @@ export async function openPreviewInWebView({ }); // Determines arguments for the preview HTML. - const previewMode = task.mode === "doc" ? "Doc" : "Slide"; - const previewState = { - mode: task.mode, - asPrimary: task.isNotPrimary, - uri: activeEditor.document.uri.toString(), + const htmlParams = { + type: "reconnect", + url: translateExternalURL(`ws://127.0.0.1:${dataPlanePort}`), + secret, + mode: task.mode === "doc" ? "Doc" : "Slide", + state: { + mode: task.mode, + asPrimary: task.isNotPrimary, + uri: activeEditor.document.uri.toString(), + }, + isContentPreview: false, }; - const previewStateEncoded = Buffer.from(JSON.stringify(previewState), "utf-8").toString("base64"); - // Substitutes arguments in the HTML content. let html = await getPreviewHtml(context); // todo: not needed anymore, but we should test it and remove it later. html = html.replace( /\/typst-webview-assets/g, `${panel.webview.asWebviewUri(vscode.Uri.file(fontendPath)).toString()}/typst-webview-assets`, ); - html = html.replace("preview-arg:previewMode:Doc", `preview-arg:previewMode:${previewMode}`); - html = html.replace("preview-arg:state:", `preview-arg:state:${previewStateEncoded}`); - html = html.replace( - "ws://127.0.0.1:23625", - translateExternalURL(`ws://127.0.0.1:${dataPlanePort}`), - ); // Sets the HTML content to the webview panel. // This will reload the webview panel if it's already opened. panel.webview.html = html; + // Push the parameters to the html frontend. + panel.webview.onDidReceiveMessage((data) => { + if(data.type !== "started") + return; + panel.webview.postMessage(htmlParams); + }); + // Forwards the localhost port to the external URL. Since WebSocket runs over HTTP, it should be fine. // https://code.visualstudio.com/api/advanced-topics/remote-extensions#forwarding-localhost await vscode.env.asExternalUri( @@ -328,8 +338,7 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) const disposes = new DisposeList(); registerPreviewTaskDispose(taskId, disposes); - - const { dataPlanePort, staticServerPort, isPrimary } = await invokeLspCommand(); + const { dataPlanePort, staticServerPort, secret, isPrimary } = await invokeLspCommand(); if (!dataPlanePort || !staticServerPort) { disposes.dispose(); throw new Error(`Failed to launch preview ${filePath}`); @@ -340,9 +349,9 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) if (isPrimary) { let connectUrl = translateExternalURL(`ws://127.0.0.1:${dataPlanePort}`); - contentPreviewProvider.then((p) => p.postActivate(connectUrl)); + contentPreviewProvider.then((p) => p.postActivate(connectUrl, secret)); disposes.add(() => { - contentPreviewProvider.then((p) => p.postDeactivate(connectUrl)); + contentPreviewProvider.then((p) => p.postDeactivate(connectUrl, secret)); }); } @@ -354,6 +363,7 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) task, activeEditor: editor, dataPlanePort, + secret, webviewPanel, panelDispose() { disposes.dispose(); @@ -363,7 +373,13 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) break; } case "browser": { - vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${staticServerPort}`)); + const wsUrl = translateExternalURL(`ws://127.0.0.1:${dataPlanePort}`) + const queryString = (new URLSearchParams({ + previewMode: task.mode === "doc" ? "Doc" : "Slide", + secret, + wsUrl, + })).toString(); + vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${staticServerPort}/#${queryString}`)); break; } } @@ -395,7 +411,7 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) const invertColorsArgs = ivArgs ? ["--invert-colors", JSON.stringify(ivArgs)] : []; const previewInSlideModeArgs = task.mode === "slide" ? ["--preview-mode=slide"] : []; const dataPlaneHostArgs = !isDev ? ["--data-plane-host", "127.0.0.1:0"] : []; - const { dataPlanePort, staticServerPort, isPrimary } = await tinymist.startPreview([ + const { dataPlanePort, staticServerPort, secret, isPrimary } = await tinymist.startPreview([ "--task-id", taskId, "--refresh-style", @@ -443,7 +459,7 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) disposes.add(vscode.window.onDidChangeTextEditorSelection(src2docHandler, 500)); } - return { staticServerPort, dataPlanePort, isPrimary }; + return { staticServerPort, dataPlanePort, secret, isPrimary }; } async function reportPosition( @@ -558,7 +574,8 @@ class ContentPreviewProvider implements vscode.WebviewViewProvider { _token: vscode.CancellationToken, ) { this._view = webviewView; // 将已经准备好的 HTML 设置为 Webview 内容 - + + // TODO: Can this `typst-webview-assets` thing be removed? const fontendPath = path.resolve(this.context.extensionPath, "out/frontend"); let html = this.htmlContent.replace( /\/typst-webview-assets/g, @@ -567,8 +584,6 @@ class ContentPreviewProvider implements vscode.WebviewViewProvider { .toString()}/typst-webview-assets`, ); - html = html.replace("ws://127.0.0.1:23625", ``); - webviewView.webview.options = { // Allow scripts in the webview enableScripts: true, @@ -601,20 +616,21 @@ class ContentPreviewProvider implements vscode.WebviewViewProvider { } current: any = undefined; - postActivate(url: string) { + postActivate(url: string, secret: string) { this.current = { type: "reconnect", url, + secret, mode: "Doc", isContentPreview: true, }; this.resetHost(); } - postDeactivate(url: string) { - if (this.current && this.current.url === url) { + postDeactivate(url: string, secret: string) { + if (this.current && this.current.url === url && this.current.secret === secret) { this.currentOutline = undefined; - this.postActivate(""); + this.postActivate("", ""); } } diff --git a/editors/vscode/src/lsp.ts b/editors/vscode/src/lsp.ts index 5aabc9f8b..250cd36ac 100644 --- a/editors/vscode/src/lsp.ts +++ b/editors/vscode/src/lsp.ts @@ -47,6 +47,10 @@ export interface PreviewResult { * The data plane address */ dataPlanePort?: number; + /** + * The secret used for websocket authentication + */ + secret: string, /** * Whether the preview content is provided by the primary compiler instance. This must be indicate by the CLI argument `--not-primary` * when starts a preview task by *LSP Command*. diff --git a/tools/typst-preview-frontend/index.html b/tools/typst-preview-frontend/index.html index 8e8e4170f..882f9d1aa 100644 --- a/tools/typst-preview-frontend/index.html +++ b/tools/typst-preview-frontend/index.html @@ -15,28 +15,12 @@