Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/shell/src/capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
12 changes: 4 additions & 8 deletions crates/shell/src/engine/quickjs/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 2 additions & 4 deletions crates/shell/src/engine/quickjs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions crates/shell/src/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ use gpui_base::{
mod components;

use crate::{
capability::is_openable_url,
engine::ShellRuntime,
scroll::Scrollable,
snapshot::RenderSnapshot,
Expand Down Expand Up @@ -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()
Expand Down
118 changes: 118 additions & 0 deletions crates/shell/src/tests/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScriptView>, 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#"<a href="{url}">example</a>"#),
_ => 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#"<a href="{url}">example</a>"#),
_ => 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));
Expand Down
Loading