diff --git a/crates/ely_app/src/services/servo_sidecar.rs b/crates/ely_app/src/services/servo_sidecar.rs index a68a385..572c95b 100644 --- a/crates/ely_app/src/services/servo_sidecar.rs +++ b/crates/ely_app/src/services/servo_sidecar.rs @@ -10,7 +10,7 @@ use ely_domain::ProfileId; use serde::Deserialize; use thiserror::Error; -pub use super::servo_sidecar_request::SidecarSnapshotRequest; +pub use super::servo_sidecar_request::{SidecarSitePermission, SidecarSnapshotRequest}; use super::{ servo_profile_data::{ @@ -112,6 +112,9 @@ impl ServoSidecarClient { if let Some(typed_text) = request.typed_text.as_deref() { command.arg("--type-text").arg(typed_text); } + for permission in &request.site_permissions { + command.arg("--site-permission").arg(permission.to_arg()); + } let mut child = command .stdout(Stdio::piped()) diff --git a/crates/ely_app/src/services/servo_sidecar_request.rs b/crates/ely_app/src/services/servo_sidecar_request.rs index d7a2e6b..70bc510 100644 --- a/crates/ely_app/src/services/servo_sidecar_request.rs +++ b/crates/ely_app/src/services/servo_sidecar_request.rs @@ -1,4 +1,4 @@ -use ely_domain::{ProfileId, UrlText}; +use ely_domain::{ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText}; use super::ProfileDataMode; @@ -13,6 +13,7 @@ pub struct SidecarSnapshotRequest { pub(in crate::services) scroll_y: i32, pub(in crate::services) click_point: Option, pub(in crate::services) typed_text: Option, + pub(in crate::services) site_permissions: Vec, } impl SidecarSnapshotRequest { @@ -28,6 +29,7 @@ impl SidecarSnapshotRequest { scroll_y: 0, click_point: None, typed_text: None, + site_permissions: Vec::new(), } } @@ -56,6 +58,12 @@ impl SidecarSnapshotRequest { self } + #[must_use] + pub fn with_site_permissions(mut self, site_permissions: Vec) -> Self { + self.site_permissions = site_permissions; + self + } + #[cfg(test)] pub(crate) fn typed_text_for_test(&self) -> Option<&str> { self.typed_text.as_deref() @@ -77,3 +85,45 @@ pub(in crate::services) struct SidecarClickPoint { pub(in crate::services) x: u32, pub(in crate::services) y: u32, } + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SidecarSitePermission { + origin: SiteOrigin, + feature: SitePermissionFeature, + decision: SitePermissionDecision, +} + +impl SidecarSitePermission { + #[must_use] + pub fn new( + origin: SiteOrigin, + feature: SitePermissionFeature, + decision: SitePermissionDecision, + ) -> Self { + Self { origin, feature, decision } + } + + pub(in crate::services) fn to_arg(&self) -> String { + serde_json::json!({ + "origin": self.origin.as_str(), + "feature": self.feature.as_str(), + "decision": self.decision.as_str(), + }) + .to_string() + } + + #[cfg(test)] + pub(crate) fn origin(&self) -> &SiteOrigin { + &self.origin + } + + #[cfg(test)] + pub(crate) fn feature(&self) -> SitePermissionFeature { + self.feature + } + + #[cfg(test)] + pub(crate) fn decision(&self) -> SitePermissionDecision { + self.decision + } +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 5b4cdb8..195af18 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -23,6 +23,7 @@ mod web_surface_frame; mod web_surface_geometry; mod web_surface_image; mod web_surface_keyboard; +mod web_surface_permissions; mod web_surface_state; mod web_surface_view; diff --git a/crates/ely_app/src/shell/web_surface_controller.rs b/crates/ely_app/src/shell/web_surface_controller.rs index 30dcc0c..4e96206 100644 --- a/crates/ely_app/src/shell/web_surface_controller.rs +++ b/crates/ely_app/src/shell/web_surface_controller.rs @@ -4,13 +4,14 @@ use gpui::{AnyElement, Bounds, Context, Pixels, Point}; use crate::services::{ ProfileDataMode, - servo_sidecar::{ServoSidecarError, SidecarSnapshot}, + servo_sidecar::{ServoSidecarError, SidecarSitePermission, SidecarSnapshot}, }; use super::{ ElyShell, web_surface_frame::WebSurfaceFrame, web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset, WebSurfaceSize}, + web_surface_permissions::sidecar_site_permissions_for_tab, web_surface_state::{WebSurfaceRequest, WebSurfaceState}, web_surface_view::{ render_failed_web_surface, render_loading_web_surface, render_ready_web_surface, @@ -37,8 +38,9 @@ impl ElyShell { let Some(profile_data_mode) = profile_data_mode_for(tab, snapshot) else { return render_failed_web_surface(tab, "Profile context is unavailable.", state_entity); }; + let site_permissions = sidecar_site_permissions_for_tab(tab, snapshot); - self.ensure_external_web_frame(tab, profile_data_mode, cx); + self.ensure_external_web_frame(tab, profile_data_mode, site_permissions, cx); match self.web_surfaces.state(tab.id()) { Some(WebSurfaceState::Ready(frame)) => { @@ -60,6 +62,7 @@ impl ElyShell { &mut self, tab: &BrowserTab, profile_data_mode: ProfileDataMode, + site_permissions: Vec, cx: &mut Context, ) { let Some(request) = self.web_surfaces.prepare_request(tab, profile_data_mode) else { @@ -74,8 +77,9 @@ impl ElyShell { click_point, typed_text, client, - snapshot_request, + mut snapshot_request, } = request; + snapshot_request = snapshot_request.with_site_permissions(site_permissions); let pending_frame = PendingWebSurfaceFrame { tab_id, requested_url, diff --git a/crates/ely_app/src/shell/web_surface_permissions.rs b/crates/ely_app/src/shell/web_surface_permissions.rs new file mode 100644 index 0000000..60a20ec --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_permissions.rs @@ -0,0 +1,91 @@ +use ely_browser_core::BrowserSnapshot; +use ely_domain::{BrowserTab, SiteOrigin}; + +use crate::services::servo_sidecar::SidecarSitePermission; + +pub(super) fn sidecar_site_permissions_for_tab( + tab: &BrowserTab, + snapshot: &BrowserSnapshot, +) -> Vec { + let Ok(Some(origin)) = SiteOrigin::from_url(tab.url()) else { + return Vec::new(); + }; + + snapshot + .site_permissions + .iter() + .filter(|entry| entry.profile_id() == tab.profile_id()) + .filter(|entry| entry.origin() == &origin) + .map(|entry| { + SidecarSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use ely_browser_core::{BrowserCore, BrowserSnapshot, InitialBrowserConfig}; + use ely_domain::{ + BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText, + }; + + use super::sidecar_site_permissions_for_tab; + + #[test] + fn includes_matching_profile_and_origin_permissions() -> Result<(), Box> { + let mut core = browser_core_for("https://example.com/page")?; + let origin = SiteOrigin::parse("https://example.com")?; + core.set_site_permission( + origin, + SitePermissionFeature::Camera, + SitePermissionDecision::AllowAlways, + )?; + + let snapshot = core.snapshot()?; + let tab = active_tab(&snapshot)?; + let permissions = sidecar_site_permissions_for_tab(tab, &snapshot); + + assert_eq!(permissions.len(), 1); + assert_eq!(permissions[0].origin().as_str(), "https://example.com"); + assert_eq!(permissions[0].feature(), SitePermissionFeature::Camera); + assert_eq!(permissions[0].decision(), SitePermissionDecision::AllowAlways); + Ok(()) + } + + #[test] + fn filters_other_origins() -> Result<(), Box> { + let mut core = browser_core_for("https://example.com/page")?; + core.set_site_permission( + SiteOrigin::parse("https://other.test")?, + SitePermissionFeature::Location, + SitePermissionDecision::AllowOnce, + )?; + + let snapshot = core.snapshot()?; + let tab = active_tab(&snapshot)?; + + assert!(sidecar_site_permissions_for_tab(tab, &snapshot).is_empty()); + Ok(()) + } + + fn browser_core_for(url: &str) -> Result> { + let mut core = BrowserCore::new(InitialBrowserConfig { + space_name: "Work".to_string(), + space_icon: "W".to_string(), + profile_name: "Default".to_string(), + new_tab_destination: Default::default(), + })?; + core.open_tab(UrlText::parse(url)?); + Ok(core) + } + + fn active_tab(snapshot: &BrowserSnapshot) -> Result<&BrowserTab, Box> { + snapshot + .tabs + .iter() + .find(|tab| tab.id() == &snapshot.active_tab_id) + .ok_or_else(|| std::io::Error::other("active tab missing").into()) + } +} diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index 238a36f..2841395 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -11,6 +11,12 @@ pub enum DomainError { #[error("invalid site origin: {value}")] InvalidSiteOrigin { value: String }, + #[error("invalid site permission feature: {value}")] + InvalidSitePermissionFeature { value: String }, + + #[error("invalid site permission decision: {value}")] + InvalidSitePermissionDecision { value: String }, + #[error("invalid file name: {value}")] InvalidFileName { value: String }, diff --git a/crates/ely_domain/src/site_permission.rs b/crates/ely_domain/src/site_permission.rs index 360e38a..06266f9 100644 --- a/crates/ely_domain/src/site_permission.rs +++ b/crates/ely_domain/src/site_permission.rs @@ -142,6 +142,28 @@ impl SitePermissionFeature { } } + pub fn parse(value: &str) -> Result { + match value { + "camera" => Ok(Self::Camera), + "microphone" => Ok(Self::Microphone), + "screen-capture" => Ok(Self::ScreenCapture), + "location" => Ok(Self::Location), + "notifications" => Ok(Self::Notifications), + "clipboard-read" => Ok(Self::ClipboardRead), + "clipboard-write" => Ok(Self::ClipboardWrite), + "downloads" => Ok(Self::Downloads), + "popups" => Ok(Self::Popups), + "autoplay" => Ok(Self::Autoplay), + "webusb" => Ok(Self::WebUsb), + "webhid" => Ok(Self::WebHid), + "webserial" => Ok(Self::WebSerial), + "storage-persistence" => Ok(Self::StoragePersistence), + "insecure-content" => Ok(Self::InsecureContent), + "certificate-exception" => Ok(Self::CertificateException), + _ => Err(DomainError::InvalidSitePermissionFeature { value: value.to_string() }), + } + } + #[must_use] pub fn label(&self) -> &'static str { match self { @@ -166,6 +188,24 @@ impl SitePermissionFeature { } impl SitePermissionDecision { + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::AllowOnce => "allow-once", + Self::AllowAlways => "allow-always", + Self::DenyAlways => "deny-always", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "allow-once" => Ok(Self::AllowOnce), + "allow-always" => Ok(Self::AllowAlways), + "deny-always" => Ok(Self::DenyAlways), + _ => Err(DomainError::InvalidSitePermissionDecision { value: value.to_string() }), + } + } + #[must_use] pub fn label(&self) -> &'static str { match self { diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs index c09ff36..3aa591e 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -6,8 +6,8 @@ use std::{ use ely_domain::TabId; use ely_servo_host::{ - KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, ScrollRequest, - ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, + KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PermissionRequest, + ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState, }; use thiserror::Error; @@ -58,6 +58,7 @@ fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { )?; let tab_id = TabId::new(); let webview_id = host.create_webview(tab_id.clone(), args.profile_id.clone())?; + apply_site_permissions(&mut host, &webview_id, &args)?; host.navigate(NavigationRequest { webview_id: webview_id.clone(), @@ -100,6 +101,26 @@ fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { std::process::exit(0); } +fn apply_site_permissions( + host: &mut SoftwareServoHost, + webview_id: &ely_domain::WebViewId, + args: &SnapshotArgs, +) -> Result<(), SidecarError> { + for permission in &args.site_permissions { + host.set_permission( + PermissionRequest { + webview_id: webview_id.clone(), + profile_id: args.profile_id.clone(), + origin: permission.origin.clone(), + feature: permission.feature, + }, + permission.decision.into(), + )?; + } + + Ok(()) +} + fn apply_scroll_if_requested( host: &mut SoftwareServoHost, webview_id: &ely_domain::WebViewId, diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs index bb7fa7e..19c83da 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs @@ -1,6 +1,7 @@ use std::{env, num::ParseIntError, path::PathBuf}; -use ely_domain::{ProfileId, UrlText}; +use ely_domain::{ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText}; +use serde::Deserialize; use thiserror::Error; pub(super) enum SidecarCommand { @@ -20,6 +21,7 @@ pub(super) struct SnapshotArgs { pub(super) drag_points: Option, pub(super) touch_point: Option, pub(super) typed_text: Option, + pub(super) site_permissions: Vec, } #[derive(Clone, Copy)] @@ -34,6 +36,12 @@ pub(super) struct DragPoints { pub(super) to: ClickPoint, } +pub(super) struct SidecarSitePermission { + pub(super) origin: SiteOrigin, + pub(super) feature: SitePermissionFeature, + pub(super) decision: SitePermissionDecision, +} + #[derive(Debug, Error)] pub(super) enum SidecarArgsError { #[error("missing sidecar command")] @@ -74,6 +82,13 @@ pub(super) enum SidecarArgsError { #[error("{name} path is empty")] EmptyPath { name: &'static str }, + #[error("invalid --site-permission JSON: {value}")] + InvalidSitePermissionJson { + value: String, + #[source] + source: serde_json::Error, + }, + #[error(transparent)] Domain(#[from] ely_domain::DomainError), } @@ -116,6 +131,7 @@ fn parse_snapshot_args( let mut touch_x = None; let mut touch_y = None; let mut typed_text = None; + let mut site_permissions = Vec::new(); while let Some(name) = args.next() { match name.as_str() { @@ -195,6 +211,8 @@ fn parse_snapshot_args( )?) } "--type-text" => typed_text = Some(next_argument(&mut args, "--type-text")?), + "--site-permission" => site_permissions + .push(parse_site_permission(next_argument(&mut args, "--site-permission")?)?), _ => return Err(SidecarArgsError::UnknownArgument { value: name }), } } @@ -234,6 +252,25 @@ fn parse_snapshot_args( drag_points, touch_point, typed_text, + site_permissions, + }) +} + +#[derive(Deserialize)] +struct SitePermissionArg { + origin: String, + feature: String, + decision: String, +} + +fn parse_site_permission(value: String) -> Result { + let parsed: SitePermissionArg = serde_json::from_str(&value) + .map_err(|source| SidecarArgsError::InvalidSitePermissionJson { value, source })?; + + Ok(SidecarSitePermission { + origin: SiteOrigin::parse(parsed.origin)?, + feature: SitePermissionFeature::parse(parsed.feature.as_str())?, + decision: SitePermissionDecision::parse(parsed.decision.as_str())?, }) } @@ -278,7 +315,7 @@ mod tests { use std::{env, path::PathBuf}; use super::{SidecarArgsError, SidecarCommand, parse_command}; - use ely_domain::{DomainError, ProfileId}; + use ely_domain::{DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature}; #[test] fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> { @@ -321,11 +358,35 @@ mod tests { )); } + #[test] + fn parses_snapshot_site_permissions() -> Result<(), SidecarArgsError> { + let profile_id = ProfileId::new(); + let profile_data_dir = env::temp_dir().join(profile_id.as_str()); + let mut command = snapshot_command_args(&profile_id, profile_data_dir); + command.push("--site-permission".to_string()); + command.push( + r#"{"origin":"https://example.com","feature":"camera","decision":"allow-once"}"# + .to_string(), + ); + + let SidecarCommand::Snapshot(args) = parse_command(command)?; + assert_eq!(args.site_permissions.len(), 1); + let permission = &args.site_permissions[0]; + assert_eq!(permission.origin.as_str(), "https://example.com"); + assert_eq!(permission.feature, SitePermissionFeature::Camera); + assert_eq!(permission.decision, SitePermissionDecision::AllowOnce); + Ok(()) + } + fn parse_snapshot_command( profile_id: &ProfileId, profile_data_dir: PathBuf, ) -> Result { - parse_command([ + parse_command(snapshot_command_args(profile_id, profile_data_dir)) + } + + fn snapshot_command_args(profile_id: &ProfileId, profile_data_dir: PathBuf) -> Vec { + [ "ely_servo_sidecar".to_string(), "snapshot".to_string(), "--url".to_string(), @@ -340,6 +401,8 @@ mod tests { "64".to_string(), "--height".to_string(), "64".to_string(), - ]) + ] + .into_iter() + .collect() } } diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index 5026e24..7e00c6c 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -1,4 +1,6 @@ -use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText, WebViewId}; +use ely_domain::{ + ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, TabId, UrlText, WebViewId, +}; use crate::ServoHostError; @@ -276,6 +278,16 @@ pub enum PermissionDecision { DenyAlways, } +impl From for PermissionDecision { + fn from(decision: SitePermissionDecision) -> Self { + match decision { + SitePermissionDecision::AllowOnce => Self::AllowOnce, + SitePermissionDecision::AllowAlways => Self::AllowAlways, + SitePermissionDecision::DenyAlways => Self::DenyAlways, + } + } +} + pub trait ServoHost { fn create_webview( &mut self,