From c45f7bc6d02aafdc5c9a533c1a68d41de6d4d1ed Mon Sep 17 00:00:00 2001 From: tmistele <> Date: Sat, 28 Dec 2024 22:45:06 -0500 Subject: [PATCH] feat: websocket authentication: first rough prototype --- Cargo.lock | 14 +- Cargo.toml | 6 +- crates/tinymist/Cargo.toml | 2 + crates/tinymist/src/tool/preview.rs | 128 ++++++++++------ crates/tinymist/src/tool/preview/auth.rs | 100 +++++++++++++ crates/typst-preview/src/lib.rs | 22 +-- editors/vscode/src/extension.ts | 1 + editors/vscode/src/features/preview-compat.ts | 16 +- editors/vscode/src/features/preview.ts | 48 ++++-- tools/typst-preview-frontend/index.html | 18 ++- tools/typst-preview-frontend/src/main.js | 23 +-- tools/typst-preview-frontend/src/ws.ts | 64 ++++---- tools/typst-preview-frontend/src/ws/auth.ts | 138 ++++++++++++++++++ 13 files changed, 433 insertions(+), 147 deletions(-) create mode 100644 crates/tinymist/src/tool/preview/auth.rs create mode 100644 tools/typst-preview-frontend/src/ws/auth.ts diff --git a/Cargo.lock b/Cargo.lock index 7e3275ad0..ed22fd74b 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.14 (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.14" -[[package]] -name = "tinymist-assets" -version = "0.12.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "072cf76e3688fc38a67a60f5199cb71048df141128561baef6b8a7b25da919d5" - [[package]] name = "tinymist-derive" version = "0.12.14" @@ -3989,7 +3985,7 @@ dependencies = [ "serde", "serde_json", "tar", - "tinymist-assets 0.12.14 (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.14 (registry+https://github.com/rust-lang/crates.io-index)", + "tinymist-assets", "tokio", "typst", "typst-assets", diff --git a/Cargo.toml b/Cargo.toml index e0aa85f0d..87a3d748a 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.14" } +# TODO: temporarily for dev +# tinymist-assets = { version = "0.12.14" } + 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..ea9483fa1 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; @@ -263,6 +265,7 @@ pub struct StartPreviewResponse { static_server_port: Option, static_server_addr: Option, data_plane_port: Option, + secret: String, is_primary: bool, } @@ -340,10 +343,10 @@ 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, secret.clone(), websocket_tx).await; let addr = srv.addr; log::info!("PreviewTask({task_id}): preview server listening on: {addr}"); @@ -351,6 +354,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 +403,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: String, + 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 +428,24 @@ 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. + if let Ok(websocket) = + auth::try_auth_websocket_client(websocket, &secret).await + { + let _ = websocket_tx.send(websocket); + } else { + log::error!("Websocket client authentication failed"); + } + }); // Return the response so the spawned future can continue. Ok(response) @@ -432,9 +453,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() @@ -575,12 +604,14 @@ pub async fn preview_main(args: PreviewCliArgs) -> anyhow::Result<()> { 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; + // TODO: How to test this control plane thing? Where is it used? + let secret = auth::generate_token(); + + let srv = make_http_server(false, args.control_plane_host, secret, 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 +672,28 @@ 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 secret = auth::generate_token(); 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, 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, 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}"); 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(format!( + "http://{static_server_addr}/#secret={secret}&previewMode={}", + match args.preview_mode { + PreviewMode::Document => "Doc", + PreviewMode::Slide => "Slide", + } + )) { log::error!("failed to open browser: {e}"); }; } @@ -741,29 +776,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..baae8549e --- /dev/null +++ b/crates/tinymist/src/tool/preview/auth.rs @@ -0,0 +1,100 @@ +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, + cnonce: &'a str, +} + +#[derive(Serialize, Debug)] +struct AuthMsgResponseServer<'a> { + hash: &'a str, + snonce: &'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.cnonce).as_str()) == response.hash { + // ... then we authenticate to the client + let snonce = generate_token(); + let hash = sha512hex(format!("{}{}{}", secret, response.challenge, snonce).as_str()); + let json = serde_json::to_string(&AuthMsgResponseServer { + snonce: &snonce, + 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/extension.ts b/editors/vscode/src/extension.ts index 10257888b..706da52e7 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -433,6 +433,7 @@ export interface PreviewResult { staticServerPort?: number; staticServerAddr?: string; dataPlanePort?: number; + secret: string; isPrimary?: boolean; } diff --git a/editors/vscode/src/features/preview-compat.ts b/editors/vscode/src/features/preview-compat.ts index 65187b929..514c072d1 100644 --- a/editors/vscode/src/features/preview-compat.ts +++ b/editors/vscode/src/features/preview-compat.ts @@ -277,6 +277,8 @@ export const launchPreviewCompat = async (task: LaunchInBrowserTask | LaunchInWe task.kind === "browser", ); + const secret = '__no_secret_because_typst-preview_doesnt_support_it__'; + const addonΠserver = new Addon2Server( controlPlanePort, enableCursor, @@ -306,7 +308,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 launchPreviewInWebView({ @@ -314,11 +316,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)); }, }); } @@ -399,7 +402,13 @@ export const launchPreviewCompat = async (task: LaunchInBrowserTask | LaunchInWe `Launched server, data plane port:${dataPlanePort}, control plane port:${controlPlanePort}, static file port:${staticFilePort}`, ); 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: '__no_secret_because_typst-preview_doesnt_support_it__', + wsUrl, + })).toString(); + vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${staticFilePort}/#${queryString}`)); } // window.typstWebsocket.send("current"); return { @@ -465,6 +474,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 d4048ddd1..b22cb05f7 100644 --- a/editors/vscode/src/features/preview.ts +++ b/editors/vscode/src/features/preview.ts @@ -163,6 +163,7 @@ export async function launchPreviewInWebView({ task, activeEditor, dataPlanePort, + secret, webviewPanel, panelDispose, }: { @@ -170,6 +171,7 @@ export async function launchPreviewInWebView({ task: LaunchInWebViewTask; activeEditor: vscode.TextEditor; dataPlanePort: string | number; + secret: string; webviewPanel?: vscode.WebviewPanel; panelDispose: () => void; }) { @@ -197,6 +199,7 @@ export async function launchPreviewInWebView({ // 将已经准备好的 HTML 设置为 Webview 内容 let html = await getPreviewHtmlCompat(context); + // TODO: what's this? Is this still needed? html = html.replace( /\/typst-webview-assets/g, `${panel.webview.asWebviewUri(vscode.Uri.file(fontendPath)).toString()}/typst-webview-assets`, @@ -208,12 +211,17 @@ export async function launchPreviewInWebView({ uri: activeEditor.document.uri.toString(), }; const previewStateEncoded = Buffer.from(JSON.stringify(previewState), "utf-8").toString("base64"); - 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}`), - ); + const wsUrl = translateExternalURL(`ws://127.0.0.1:${dataPlanePort}`) + const queryString = (new URLSearchParams({ + previewMode, + wsUrl, + secret, + state: previewStateEncoded, + })).toString(); + + // We now put secret information into the html, but that's okay since the webview cannot be accessed + // from outside VSCode. + html = html.replace('__VSCODE_SECRET_PARAMETERS = undefined', '__VSCODE_SECRET_PARAMETERS = '+JSON.stringify(queryString)); panel.webview.html = html; // 虽然配置的是 http,但是如果是桌面客户端,任何 tcp 连接都支持,这也就包括了 ws @@ -251,7 +259,7 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) const enableCursor = getPreviewConfCompat("cursorIndicator") || false; const disposes = new DisposeList(); registerPreviewTaskDispose(taskId, disposes); - const { dataPlanePort, staticServerPort, isPrimary } = await launchCommand(); + const { dataPlanePort, staticServerPort, secret, isPrimary } = await launchCommand(); if (!dataPlanePort || !staticServerPort) { disposes.dispose(); throw new Error(`Failed to launch preview ${filePath}`); @@ -262,9 +270,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)); }); } @@ -276,6 +284,7 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) task, activeEditor: editor, dataPlanePort, + secret, webviewPanel, panelDispose() { disposes.dispose(); @@ -285,7 +294,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; } } @@ -317,7 +332,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 commandStartPreview([ + const { dataPlanePort, staticServerPort, secret, isPrimary } = await commandStartPreview([ "--task-id", taskId, "--refresh-style", @@ -365,7 +380,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( @@ -543,20 +558,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/tools/typst-preview-frontend/index.html b/tools/typst-preview-frontend/index.html index 8e8e4170f..99f7bfa84 100644 --- a/tools/typst-preview-frontend/index.html +++ b/tools/typst-preview-frontend/index.html @@ -15,6 +15,19 @@