From 90a62217fdd033fb2dd99b6f2ee3a945ae6b9baa Mon Sep 17 00:00:00 2001 From: Aram Drevekenin Date: Wed, 15 May 2024 11:20:36 +0200 Subject: [PATCH] feat(layouts): allow consuming a layout from a url (#3351) * feat(cli): allow loading layouts directly from a url * feat(plugins): allow loading layouts directly from a url * style(fmt): rustfmt --- src/commands.rs | 1 + zellij-utils/src/data.rs | 3 +++ zellij-utils/src/downloader.rs | 25 ++++++++++++++++++ zellij-utils/src/input/actions.rs | 20 +++++++++++++-- zellij-utils/src/input/config.rs | 2 ++ zellij-utils/src/input/layout.rs | 38 ++++++++++++++++++++++++++++ zellij-utils/src/kdl/mod.rs | 1 + zellij-utils/src/plugin_api/event.rs | 5 ++++ zellij-utils/src/setup.rs | 20 ++++++++++++--- 9 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 152cfd08..796e34e7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -444,6 +444,7 @@ pub(crate) fn start_client(opts: CliArgs) { layout_dir.clone(), config_without_layout.clone(), ), + LayoutInfo::Url(url) => Layout::from_url(&url, config_without_layout.clone()), }; match new_session_layout { Ok(new_session_layout) => { diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 9914a797..c09c0d43 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -797,6 +797,7 @@ pub struct SessionInfo { pub enum LayoutInfo { BuiltIn(String), File(String), + Url(String), } impl LayoutInfo { @@ -804,12 +805,14 @@ impl LayoutInfo { match self { LayoutInfo::BuiltIn(name) => &name, LayoutInfo::File(name) => &name, + LayoutInfo::Url(url) => &url, } } pub fn is_builtin(&self) -> bool { match self { LayoutInfo::BuiltIn(_name) => true, LayoutInfo::File(_name) => false, + LayoutInfo::Url(_url) => false, } } } diff --git a/zellij-utils/src/downloader.rs b/zellij-utils/src/downloader.rs index aca0c8cd..b92a0ff0 100644 --- a/zellij-utils/src/downloader.rs +++ b/zellij-utils/src/downloader.rs @@ -16,6 +16,8 @@ pub enum DownloaderError { Io(#[source] std::io::Error), #[error("File name cannot be found in URL: {0}")] NotFoundFileName(String), + #[error("Failed to parse URL body: {0}")] + InvalidUrlBody(String), } #[derive(Debug)] @@ -110,6 +112,29 @@ impl Downloader { Ok(()) } + pub async fn download_without_cache(url: &str) -> Result { + // result is the stringified body + let client = surf::client().with(surf::middleware::Redirect::default()); + + let res = client + .get(url) + .header("Content-Type", "application/octet-stream") + .await + .map_err(|e| DownloaderError::Request(e))?; + + let mut downloaded_bytes: Vec = vec![]; + let mut stream = res.bytes(); + while let Some(byte) = stream.next().await { + let byte = byte.map_err(|e| DownloaderError::Io(e))?; + downloaded_bytes.push(byte); + } + + log::debug!("Download complete"); + let stringified = String::from_utf8(downloaded_bytes) + .map_err(|e| DownloaderError::InvalidUrlBody(format!("{}", e)))?; + + Ok(stringified) + } fn parse_name(&self, url: &str) -> Result { Url::parse(url) diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs index e36ca763..7cc3dd30 100644 --- a/zellij-utils/src/input/actions.rs +++ b/zellij-utils/src/input/actions.rs @@ -529,9 +529,25 @@ impl Action { let layout_dir = layout_dir .or_else(|| config.and_then(|c| c.options.layout_dir)) .or_else(|| get_layout_dir(find_default_config_dir())); - let (path_to_raw_layout, raw_layout, swap_layouts) = + + let (path_to_raw_layout, raw_layout, swap_layouts) = if let Some(layout_url) = + layout_path.to_str().and_then(|l| { + if l.starts_with("http://") || l.starts_with("https://") { + Some(l) + } else { + None + } + }) { + ( + layout_url.to_owned(), + Layout::stringified_from_url(layout_url) + .map_err(|e| format!("Failed to load layout: {}", e))?, + None, + ) + } else { Layout::stringified_from_path_or_default(Some(&layout_path), layout_dir) - .map_err(|e| format!("Failed to load layout: {}", e))?; + .map_err(|e| format!("Failed to load layout: {}", e))? + }; let layout = Layout::from_str(&raw_layout, path_to_raw_layout, swap_layouts.as_ref().map(|(f, p)| (f.as_str(), p.as_str())), cwd).map_err(|e| { let stringified_error = match e { ConfigError::KdlError(kdl_error) => { diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs index cf6620a9..56c6f338 100644 --- a/zellij-utils/src/input/config.rs +++ b/zellij-utils/src/input/config.rs @@ -96,6 +96,8 @@ pub enum ConfigError { PluginsError(#[from] PluginsConfigError), #[error("{0}")] ConversionError(#[from] ConversionError), + #[error("{0}")] + DownloadError(String), } impl ConfigError { diff --git a/zellij-utils/src/input/layout.rs b/zellij-utils/src/input/layout.rs index 05541026..94122d9f 100644 --- a/zellij-utils/src/input/layout.rs +++ b/zellij-utils/src/input/layout.rs @@ -8,6 +8,8 @@ // place. // If plugins should be able to depend on the layout system // then [`zellij-utils`] could be a proper place. +#[cfg(not(target_family = "wasm"))] +use crate::downloader::Downloader; use crate::{ data::{Direction, LayoutInfo}, home::{default_layout_dir, find_default_config_dir}, @@ -18,6 +20,8 @@ use crate::{ pane_size::{Constraint, Dimension, PaneGeom}, setup::{self}, }; +#[cfg(not(target_family = "wasm"))] +use async_std::task; use std::cmp::Ordering; use std::fmt::{Display, Formatter}; @@ -1145,6 +1149,7 @@ impl Layout { LayoutInfo::BuiltIn(layout_name) => { Self::stringified_from_default_assets(&PathBuf::from(layout_name))? }, + LayoutInfo::Url(url) => (url.clone(), Self::stringified_from_url(&url)?, None), }; Layout::from_kdl( &raw_layout, @@ -1179,6 +1184,20 @@ impl Layout { ), } } + pub fn stringified_from_url(url: &str) -> Result { + #[cfg(not(target_family = "wasm"))] + let raw_layout = task::block_on(async move { + let download = Downloader::download_without_cache(url).await; + match download { + Ok(stringified) => Ok(stringified), + Err(e) => Err(ConfigError::DownloadError(format!("{}", e))), + } + })?; + // silently fail - this should not happen in plugins and legacy architecture is hard + #[cfg(target_family = "wasm")] + let raw_layout = String::new(); + Ok(raw_layout) + } pub fn from_path_or_default( layout_path: Option<&PathBuf>, layout_dir: Option, @@ -1197,6 +1216,25 @@ impl Layout { let config = Config::from_kdl(&raw_layout, Some(config))?; // this merges the two config, with Ok((layout, config)) } + #[cfg(not(target_family = "wasm"))] + pub fn from_url(url: &str, config: Config) -> Result<(Layout, Config), ConfigError> { + let raw_layout = task::block_on(async move { + let download = Downloader::download_without_cache(url).await; + match download { + Ok(stringified) => Ok(stringified), + Err(e) => Err(ConfigError::DownloadError(format!("{}", e))), + } + })?; + let layout = Layout::from_kdl(&raw_layout, url.into(), None, None)?; + let config = Config::from_kdl(&raw_layout, Some(config))?; // this merges the two config, with + Ok((layout, config)) + } + #[cfg(target_family = "wasm")] + pub fn from_url(url: &str, config: Config) -> Result<(Layout, Config), ConfigError> { + Err(ConfigError::DownloadError(format!( + "Unsupported platform, cannot download layout from the web" + ))) + } pub fn from_path_or_default_without_config( layout_path: Option<&PathBuf>, layout_dir: Option, diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs index 1d0da5af..59c6705d 100644 --- a/zellij-utils/src/kdl/mod.rs +++ b/zellij-utils/src/kdl/mod.rs @@ -2166,6 +2166,7 @@ impl SessionInfo { let (layout_name, layout_source) = match layout_info { LayoutInfo::File(name) => (name.clone(), "file"), LayoutInfo::BuiltIn(name) => (name.clone(), "built-in"), + LayoutInfo::Url(url) => (url.clone(), "url"), }; let mut layout_node = KdlNode::new(format!("{}", layout_name)); let layout_source = KdlEntry::new_prop("source", layout_source); diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 04380cff..f4df31f2 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -545,6 +545,10 @@ impl TryFrom for ProtobufLayoutInfo { source: "built-in".to_owned(), name, }), + LayoutInfo::Url(name) => Ok(ProtobufLayoutInfo { + source: "url".to_owned(), + name, + }), } } } @@ -555,6 +559,7 @@ impl TryFrom for LayoutInfo { match protobuf_layout_info.source.as_str() { "file" => Ok(LayoutInfo::File(protobuf_layout_info.name)), "built-in" => Ok(LayoutInfo::BuiltIn(protobuf_layout_info.name)), + "url" => Ok(LayoutInfo::Url(protobuf_layout_info.name)), _ => Err("Unknown source for layout"), } } diff --git a/zellij-utils/src/setup.rs b/zellij-utils/src/setup.rs index e41a2061..780530d7 100644 --- a/zellij-utils/src/setup.rs +++ b/zellij-utils/src/setup.rs @@ -671,9 +671,23 @@ impl Setup { .and_then(|cli_options| cli_options.default_layout.clone()) }) .or_else(|| config.options.default_layout.clone()); - // we merge-override the config here because the layout might contain configuration - // that needs to take precedence - Layout::from_path_or_default(chosen_layout.as_ref(), layout_dir.clone(), config) + if let Some(layout_url) = chosen_layout + .as_ref() + .and_then(|l| l.to_str()) + .and_then(|l| { + if l.starts_with("http://") || l.starts_with("https://") { + Some(l) + } else { + None + } + }) + { + Layout::from_url(layout_url, config) + } else { + // we merge-override the config here because the layout might contain configuration + // that needs to take precedence + Layout::from_path_or_default(chosen_layout.as_ref(), layout_dir.clone(), config) + } } fn handle_setup_commands(cli_args: &CliArgs) { if let Some(Command::Setup(ref setup)) = &cli_args.command {