diff --git a/crates/shell/src/capability.rs b/crates/shell/src/capability.rs index 1c4d42ac4d..9fe8108b7a 100644 --- a/crates/shell/src/capability.rs +++ b/crates/shell/src/capability.rs @@ -8,6 +8,19 @@ use std::path::{Path, PathBuf}; use cap_std::{ambient_authority, fs::Dir}; +/// Whether a script may hand `url` to the system URL opener. +/// +/// One rule for every route out: `Link.href`, `cx.open_url`, and a link in +/// `TextView` content. The scheme check is the part that matters. Without it +/// any of them becomes a way to hand an arbitrary URI to whatever handler the +/// desktop has registered for its scheme, which is a considerably larger thing +/// than opening a page. +pub(crate) fn is_openable_url(url: &str) -> bool { + reqwest::Url::parse(url).is_ok_and(|parsed| { + matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some() + }) +} + /// A capability grant. Every field is private so adding a capability later is /// not a breaking change for embedders. #[derive(Clone, Debug, Default)] diff --git a/crates/shell/src/engine/quickjs/host.rs b/crates/shell/src/engine/quickjs/host.rs index 2f5a5795ad..5f8ce7adfe 100644 --- a/crates/shell/src/engine/quickjs/host.rs +++ b/crates/shell/src/engine/quickjs/host.rs @@ -46,7 +46,7 @@ use rquickjs::{ use serde_json::Value as Json; use crate::{ - capability::{Access, Capabilities, CapabilityError, Grant}, + capability::{Access, Capabilities, CapabilityError, Grant, is_openable_url}, policy::Policy, scope, storage::{Storage, persist}, @@ -108,14 +108,10 @@ pub fn install(_ctx: &Ctx<'_>, module: &Object<'_>) -> JsResult<()> { /// imperative half of a pair whose declarative half is ungated, which reads as /// protection without being any. /// -/// The scheme check is the part that matters. Without it this becomes a way to -/// hand an arbitrary URI to whatever handler the desktop has registered for -/// its scheme, which is a considerably larger thing than opening a page. +/// The rule itself is [`is_openable_url`], shared with `href` and the +/// `TextView` default link handler. fn open_url(ctx: Ctx<'_>, url: String) -> JsResult<()> { - let valid = reqwest::Url::parse(&url).is_ok_and(|parsed| { - matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some() - }); - if !valid { + if !is_openable_url(&url) { return Err(Exception::throw_type( &ctx, "cx.open_url(url) expects an absolute HTTP(S) URL with a host", diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index 2d3b6efdc8..058834dcd0 100644 --- a/crates/shell/src/engine/quickjs/mod.rs +++ b/crates/shell/src/engine/quickjs/mod.rs @@ -37,6 +37,7 @@ use smallvec::SmallVec; use crate::{ ArgumentDescriptor, ArgumentSchema, ComponentArgument, ComponentCallbackArgument, ComponentCallbackValue, ComponentDataValue, ComponentPayload, FrozenComponentRegistry, + capability::is_openable_url, dependencies::{GitDependencyStore, MaterializedDependency}, entities::{EntityHandle, EntityStore}, host_modules::HostValue, @@ -8102,10 +8103,7 @@ impl ShellRuntime { let Some(target) = bridged.first().and_then(|value| value.as_str().ok()) else { return Err(Exception::throw_type(ctx, "href(url) expects a string")); }; - let valid = reqwest::Url::parse(target).is_ok_and(|url| { - matches!(url.scheme(), "http" | "https") && url.host_str().is_some() - }); - if !valid { + if !is_openable_url(target) { return Err(Exception::throw_type( ctx, "href(url) expects an absolute HTTP(S) URL with a host", diff --git a/crates/shell/src/materialize.rs b/crates/shell/src/materialize.rs index bb0834d4bf..2541b15237 100644 --- a/crates/shell/src/materialize.rs +++ b/crates/shell/src/materialize.rs @@ -138,6 +138,7 @@ use gpui_base::{ mod components; use crate::{ + capability::is_openable_url, engine::ShellRuntime, scroll::Scrollable, snapshot::RenderSnapshot, @@ -1073,6 +1074,20 @@ fn materialize_component( view = view.on_link_click(move |url, _event, window, cx| { route.emit(crate::HostValue::from(url.to_string()), window, cx); }); + } else { + view = view.on_link_click(|url, event, _, cx| { + // Preserve Base's activation behavior, but apply Shell's URL rules. + let activate = match event { + gpui::ClickEvent::Mouse(click) => { + matches!(click.up.button, MouseButton::Left | MouseButton::Middle) + } + gpui::ClickEvent::Keyboard(_) => true, + gpui::ClickEvent::Touch(click) => !click.long_press, + }; + if activate && is_openable_url(url) { + cx.open_url(url); + } + }); } Styled::style(&mut view).refine(&refinement); view.into_any_element() diff --git a/crates/shell/src/tests/render.rs b/crates/shell/src/tests/render.rs index 21d8336c84..3bb9bca423 100644 --- a/crates/shell/src/tests/render.rs +++ b/crates/shell/src/tests/render.rs @@ -241,6 +241,124 @@ export default class RichText extends View { assert!(tree.contains(":on_link_click(fn)"), "{tree}"); } +fn mount_text_view_link( + cx: &mut TestAppContext, + source: &str, +) -> (gpui::Entity, VisualTestContext) { + cx.update(crate::init); + let runtime = ShellRuntime::new_isolated().expect("runtime"); + cx.update(|cx| runtime.set_global(cx)); + let view_type = runtime + .load_source("text-view-link.js", source) + .expect("load"); + let window = cx.add_window(move |window, cx| { + let view = runtime + .instantiate_view_with_policy(&view_type, Rc::new(Policy::new()), window, cx) + .expect("instantiate"); + RootedScriptView(view) + }); + let mut context = VisualTestContext::from_window(*window.deref(), cx); + context.update(|window, cx| window.draw(cx).clear(cx)); + context.run_until_parked(); + context.update(|window, cx| window.draw(cx).clear(cx)); + let view = window + .root(&mut context) + .expect("root") + .read_with(&context, |root, _| root.0.clone()); + (view, context) +} + +#[gpui::test] +fn text_view_default_links_follow_shell_url_rules(cx: &mut TestAppContext) { + use gpui::MouseButton; + + for format in ["markdown", "html"] { + for (url, allowed) in [ + ("file:///tmp/text-view-link", false), + ("test:example", false), + ("/docs", false), + ("https://", false), + ("http://example.com/docs", true), + ("https://example.com/docs", true), + ] { + for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] { + let text = match format { + "html" => format!(r#"example"#), + _ => format!("[example]({url})"), + }; + let text = serde_json::to_string(&text).expect("text"); + let source = format!( + r#" +import {{ View }} from "gpui-kit"; +import {{ TextView }} from "gpui-base"; +export default class RichText extends View {{ + render() {{ return TextView.{format}("link", {text}); }} +}} +"# + ); + // opened_url is app-wide; each case must start without an earlier open. + let mut app = cx.new_app(); + let (_view, mut context) = mount_text_view_link(&mut app, &source); + context.simulate_mouse_down(point(px(10.), px(10.)), button, Modifiers::default()); + context.simulate_mouse_up(point(px(10.), px(10.)), button, Modifiers::default()); + let expected = (allowed && button != MouseButton::Right).then(|| url.to_owned()); + assert_eq!( + context.opened_url(), + expected, + "{format}: {url}, {button:?}" + ); + app.quit(); + } + } + } +} + +#[gpui::test] +fn text_view_link_callback_still_replaces_default_opening(cx: &mut TestAppContext) { + use gpui::MouseButton; + + for format in ["markdown", "html"] { + for url in ["file:///tmp/text-view-link", "https://example.com/docs"] { + for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] { + let text = match format { + "html" => format!(r#"example"#), + _ => format!("[example]({url})"), + }; + let text = serde_json::to_string(&text).expect("text"); + let source = format!( + r#" +import {{ View }} from "gpui-kit"; +import {{ TextView }} from "gpui-base"; +export default class RichText extends View {{ + render() {{ + if (this.clicked) return "callback:" + this.clicked; + return TextView.{format}("link", {text}).on_link_click((url, cx) => {{ + this.clicked = url; + cx.notify(); + }}); + }} +}} +"# + ); + let mut app = cx.new_app(); + let (view, mut context) = mount_text_view_link(&mut app, &source); + context.simulate_mouse_down(point(px(10.), px(10.)), button, Modifiers::default()); + context.simulate_mouse_up(point(px(10.), px(10.)), button, Modifiers::default()); + context.run_until_parked(); + context.update(|window, cx| window.draw(cx).clear(cx)); + let tree = context + .update(|_, cx| view.read(cx).snapshot().expect("snapshot").debug_tree()); + assert!( + tree.contains(&format!("callback:{url}")), + "{format}: {url}, {button:?}: {tree}" + ); + assert_eq!(context.opened_url(), None, "{format}: {url}, {button:?}"); + app.quit(); + } + } + } +} + #[gpui::test] fn a_script_view_produces_an_element_description(cx: &mut TestAppContext) { cx.update(|cx| crate::init(cx));