fix(permissions): make profile snapshots authoritative

This commit is contained in:
2026-07-09 21:56:36 -04:00
parent c28ec2bee8
commit b422ac1631
47 changed files with 1918 additions and 403 deletions
+12
View File
@@ -13,11 +13,14 @@ use std::collections::BTreeSet;
mod iosurface_importer; mod iosurface_importer;
#[path = "servo_live_ipc.rs"] #[path = "servo_live_ipc.rs"]
mod ipc; mod ipc;
#[path = "servo_live_permission_grant.rs"]
mod permission_grant;
#[path = "servo_live_types.rs"] #[path = "servo_live_types.rs"]
mod types; mod types;
#[path = "servo_live_wire.rs"] #[path = "servo_live_wire.rs"]
mod wire; mod wire;
pub(crate) use permission_grant::ServoLivePermissionGrant;
pub(crate) use types::{ pub(crate) use types::{
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
}; };
@@ -53,6 +56,7 @@ pub(crate) struct ServoLiveClient {
ipc: ServoLiveIpc, ipc: ServoLiveIpc,
timeouts: SidecarTimeouts, timeouts: SidecarTimeouts,
active: bool, active: bool,
pending_permission_consumptions: Vec<ServoLivePermissionGrant>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache, iosurface_cache: IOSurfaceCache,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -115,6 +119,7 @@ impl ServoLiveClient {
ipc: ServoLiveIpc::spawn(stdin, stdout), ipc: ServoLiveIpc::spawn(stdin, stdout),
timeouts, timeouts,
active: true, active: true,
pending_permission_consumptions: Vec::new(),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache::new(), iosurface_cache: IOSurfaceCache::new(),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -155,6 +160,7 @@ impl ServoLiveClient {
hover_x: request.hover_x, hover_x: request.hover_x,
hover_y: request.hover_y, hover_y: request.hover_y,
typed_text: request.typed_text, typed_text: request.typed_text,
site_permission_generation: request.site_permission_generation,
site_permissions: request.site_permissions, site_permissions: request.site_permissions,
ready_surface_ids, ready_surface_ids,
pending_surface_ids, pending_surface_ids,
@@ -184,6 +190,7 @@ impl ServoLiveClient {
if reply.frame.is_some() if reply.frame.is_some()
|| reply.surface_handle.is_some() || reply.surface_handle.is_some()
|| reply.current_surface_id.is_some() || reply.current_surface_id.is_some()
|| !reply.permission_consumptions.is_empty()
{ {
return Err(ServoLiveError::InvalidResponse { return Err(ServoLiveError::InvalidResponse {
message: "handshake response contains a frame", message: "handshake response contains a frame",
@@ -197,6 +204,7 @@ impl ServoLiveClient {
fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> { fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let reply = self.exchange(request, self.timeouts.request, "request")?; let reply = self.exchange(request, self.timeouts.request, "request")?;
self.pending_permission_consumptions.extend(reply.permission_consumptions);
if let Some(message) = reply.error { if let Some(message) = reply.error {
return Err(ServoLiveError::SidecarFailed { message }); return Err(ServoLiveError::SidecarFailed { message });
} }
@@ -224,6 +232,10 @@ impl ServoLiveClient {
Ok(frame) Ok(frame)
} }
pub(crate) fn take_permission_consumptions(&mut self) -> Vec<ServoLivePermissionGrant> {
std::mem::take(&mut self.pending_permission_consumptions)
}
fn exchange( fn exchange(
&mut self, &mut self,
request: LiveRequest, request: LiveRequest,
+33 -4
View File
@@ -7,7 +7,7 @@ use std::{
}; };
use super::{ use super::{
ServoLiveError, ServoLiveFrame, ServoLiveError, ServoLiveFrame, ServoLivePermissionGrant,
wire::{ wire::{
LiveRequest, LiveResponse, LiveSurfaceHandle, MAX_FRAME_BYTE_COUNT, MAX_FRAME_DIMENSION, LiveRequest, LiveResponse, LiveSurfaceHandle, MAX_FRAME_BYTE_COUNT, MAX_FRAME_DIMENSION,
}, },
@@ -26,6 +26,7 @@ pub(super) struct IpcReply {
pub(super) frame: Option<ServoLiveFrame>, pub(super) frame: Option<ServoLiveFrame>,
pub(super) surface_handle: Option<LiveSurfaceHandle>, pub(super) surface_handle: Option<LiveSurfaceHandle>,
pub(super) current_surface_id: Option<u64>, pub(super) current_surface_id: Option<u64>,
pub(super) permission_consumptions: Vec<ServoLivePermissionGrant>,
} }
struct IpcRequest { struct IpcRequest {
@@ -194,6 +195,11 @@ fn read_reply(stdout: &mut impl BufRead) -> Result<IpcReply, ServoLiveError> {
frame: frame.transpose()?, frame: frame.transpose()?,
surface_handle: response.surface_handle, surface_handle: response.surface_handle,
current_surface_id: response.current_surface_id, current_surface_id: response.current_surface_id,
permission_consumptions: response
.permission_consumptions
.into_iter()
.map(ServoLivePermissionGrant::try_from)
.collect::<Result<Vec<_>, _>>()?,
}) })
} }
@@ -240,7 +246,7 @@ mod tests {
#[test] #[test]
fn reply_rejects_oversized_frame_before_readback_allocation() { fn reply_rejects_oversized_frame_before_readback_allocation() {
let header = format!( let header = format!(
"{{\"protocol_version\":2,\"error\":null,\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":{0},\"height\":{0},\"device_pixel_ratio\":1.0,\"css_viewport_width\":{0},\"css_viewport_height\":{0},\"rgba_byte_count\":1073741824,\"pixels_changed\":true}}}}\n", "{{\"protocol_version\":3,\"error\":null,\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":{0},\"height\":{0},\"device_pixel_ratio\":1.0,\"css_viewport_width\":{0},\"css_viewport_height\":{0},\"rgba_byte_count\":1073741824,\"pixels_changed\":true}}}}\n",
MAX_FRAME_DIMENSION MAX_FRAME_DIMENSION
); );
let mut input = Cursor::new(header.into_bytes()); let mut input = Cursor::new(header.into_bytes());
@@ -251,11 +257,34 @@ mod tests {
)); ));
} }
#[test]
fn reply_parses_permission_consumption_without_a_frame() -> Result<(), ServoLiveError> {
let profile_id = ely_domain::ProfileId::new();
let header = format!(
"{{\"protocol_version\":3,\"error\":null,\"frame\":null,\"permission_consumptions\":[{{\"profile_id\":\"{}\",\"origin\":\"https://example.com\",\"feature\":\"camera\",\"grant_revision\":7}}]}}\n",
profile_id.as_str(),
);
let mut input = Cursor::new(header.into_bytes());
let reply = read_reply(&mut input)?;
let [consumed] = reply.permission_consumptions.as_slice() else {
return Err(ServoLiveError::InvalidResponse {
message: "permission consumption was missing",
});
};
assert_eq!(consumed.profile_id(), &profile_id);
assert_eq!(consumed.origin().as_str(), "https://example.com");
assert_eq!(consumed.feature(), ely_domain::SitePermissionFeature::Camera);
assert_eq!(consumed.grant_revision(), 7);
Ok(())
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[test] #[test]
fn hardware_reply_uses_surface_without_rgba_allocation() -> Result<(), ServoLiveError> { fn hardware_reply_uses_surface_without_rgba_allocation() -> Result<(), ServoLiveError> {
let header = concat!( let header = concat!(
"{\"protocol_version\":2,\"error\":null,", "{\"protocol_version\":3,\"error\":null,",
"\"surface_handle\":{\"mach_port_name\":91,\"surface_id\":7,\"width\":64,\"height\":48},", "\"surface_handle\":{\"mach_port_name\":91,\"surface_id\":7,\"width\":64,\"height\":48},",
"\"current_surface_id\":7,", "\"current_surface_id\":7,",
"\"frame\":{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",", "\"frame\":{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",",
@@ -307,7 +336,7 @@ mod tests {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn hardware_header(current_surface_id: u64, handle_width: u32, handle_height: u32) -> String { fn hardware_header(current_surface_id: u64, handle_width: u32, handle_height: u32) -> String {
format!( format!(
"{{\"protocol_version\":2,\"error\":null,\"surface_handle\":{{\"mach_port_name\":91,\"surface_id\":7,\"width\":{handle_width},\"height\":{handle_height}}},\"current_surface_id\":{current_surface_id},\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,\"css_viewport_width\":64,\"css_viewport_height\":48,\"rgba_byte_count\":0,\"pixels_changed\":true}}}}\n" "{{\"protocol_version\":3,\"error\":null,\"surface_handle\":{{\"mach_port_name\":91,\"surface_id\":7,\"width\":{handle_width},\"height\":{handle_height}}},\"current_surface_id\":{current_surface_id},\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,\"css_viewport_width\":64,\"css_viewport_height\":48,\"rgba_byte_count\":0,\"pixels_changed\":true}}}}\n"
) )
} }
} }
@@ -0,0 +1,51 @@
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature};
use super::wire::LivePermissionConsumption;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ServoLivePermissionGrant {
profile_id: ProfileId,
origin: SiteOrigin,
feature: SitePermissionFeature,
grant_revision: u64,
}
impl ServoLivePermissionGrant {
pub(crate) fn new(
profile_id: ProfileId,
origin: SiteOrigin,
feature: SitePermissionFeature,
grant_revision: u64,
) -> Self {
Self { profile_id, origin, feature, grant_revision }
}
pub(crate) fn profile_id(&self) -> &ProfileId {
&self.profile_id
}
pub(crate) fn origin(&self) -> &SiteOrigin {
&self.origin
}
pub(crate) fn feature(&self) -> SitePermissionFeature {
self.feature
}
pub(crate) fn grant_revision(&self) -> u64 {
self.grant_revision
}
}
impl TryFrom<LivePermissionConsumption> for ServoLivePermissionGrant {
type Error = ely_domain::DomainError;
fn try_from(consumed: LivePermissionConsumption) -> Result<Self, Self::Error> {
Ok(Self {
profile_id: ProfileId::parse(consumed.profile_id)?,
origin: SiteOrigin::parse(consumed.origin)?,
feature: SitePermissionFeature::parse(&consumed.feature)?,
grant_revision: consumed.grant_revision,
})
}
}
@@ -2,11 +2,10 @@
use std::sync::Arc; use std::sync::Arc;
use std::{collections::TryReserveError, io, path::PathBuf}; use std::{collections::TryReserveError, io, path::PathBuf};
use ely_domain::SitePermissionDecision;
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use super::wire::LiveFrameReport; use super::{ServoLivePermissionGrant, wire::LiveFrameReport};
use crate::services::servo_sidecar_command::SidecarCommandError; use crate::services::servo_sidecar_command::SidecarCommandError;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -34,23 +33,27 @@ pub(crate) struct ServoLiveEnsureRequest {
pub(crate) hover_x: Option<u32>, pub(crate) hover_x: Option<u32>,
pub(crate) hover_y: Option<u32>, pub(crate) hover_y: Option<u32>,
pub(crate) typed_text: Option<String>, pub(crate) typed_text: Option<String>,
pub(crate) site_permission_generation: u64,
pub(crate) site_permissions: Vec<ServoLiveSitePermission>, pub(crate) site_permissions: Vec<ServoLiveSitePermission>,
pub(crate) allow_once_grants: Vec<ServoLivePermissionGrant>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub(crate) struct ServoLiveSitePermission { pub(crate) struct ServoLiveSitePermission {
pub(crate) origin: String, pub(crate) origin: String,
pub(crate) feature: String, pub(crate) feature: String,
pub(crate) decision: String, pub(crate) state: String,
pub(crate) revision: u64,
} }
impl ServoLiveSitePermission { impl ServoLiveSitePermission {
pub fn new( pub fn new(
origin: impl Into<String>, origin: impl Into<String>,
feature: impl Into<String>, feature: impl Into<String>,
decision: SitePermissionDecision, state: impl Into<String>,
revision: u64,
) -> Self { ) -> Self {
Self { origin: origin.into(), feature: feature.into(), decision: decision.as_str().into() } Self { origin: origin.into(), feature: feature.into(), state: state.into(), revision }
} }
} }
@@ -364,6 +367,9 @@ pub(crate) enum ServoLiveError {
#[error(transparent)] #[error(transparent)]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
#[error(transparent)] #[error(transparent)]
SidecarCommand(#[from] SidecarCommandError), SidecarCommand(#[from] SidecarCommandError),
} }
@@ -380,7 +386,8 @@ impl ServoLiveError {
| Self::InvalidFrameByteCount { .. } | Self::InvalidFrameByteCount { .. }
| Self::FrameAllocation { .. } | Self::FrameAllocation { .. }
| Self::InvalidResponse { .. } | Self::InvalidResponse { .. }
| Self::Json(_) => true, | Self::Json(_)
| Self::Domain(_) => true,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
Self::IOSurfaceImportFailed { .. } Self::IOSurfaceImportFailed { .. }
| Self::IOSurfaceBackingFailed { .. } | Self::IOSurfaceBackingFailed { .. }
+30 -2
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use super::ServoLiveSitePermission; use super::ServoLiveSitePermission;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2; pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384; pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024; pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
@@ -29,6 +29,7 @@ pub(super) enum LiveRequest {
hover_x: Option<u32>, hover_x: Option<u32>,
hover_y: Option<u32>, hover_y: Option<u32>,
typed_text: Option<String>, typed_text: Option<String>,
site_permission_generation: u64,
site_permissions: Vec<ServoLiveSitePermission>, site_permissions: Vec<ServoLiveSitePermission>,
ready_surface_ids: Vec<u64>, ready_surface_ids: Vec<u64>,
pending_surface_ids: Vec<u64>, pending_surface_ids: Vec<u64>,
@@ -54,6 +55,16 @@ pub(super) struct LiveResponse {
pub(super) surface_handle: Option<LiveSurfaceHandle>, pub(super) surface_handle: Option<LiveSurfaceHandle>,
#[serde(default)] #[serde(default)]
pub(super) current_surface_id: Option<u64>, pub(super) current_surface_id: Option<u64>,
#[serde(default)]
pub(super) permission_consumptions: Vec<LivePermissionConsumption>,
}
#[derive(Deserialize)]
pub(super) struct LivePermissionConsumption {
pub(super) profile_id: String,
pub(super) origin: String,
pub(super) feature: String,
pub(super) grant_revision: u64,
} }
#[derive(Clone, Copy, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Deserialize)]
@@ -98,7 +109,7 @@ fn default_device_pixel_ratio() -> f32 {
mod tests { mod tests {
use serde_json::json; use serde_json::json;
use super::{LIVE_PROTOCOL_VERSION, LiveRequest}; use super::{LIVE_PROTOCOL_VERSION, LiveRequest, ServoLiveSitePermission};
#[test] #[test]
fn handshake_request_serializes_protocol_version() -> Result<(), serde_json::Error> { fn handshake_request_serializes_protocol_version() -> Result<(), serde_json::Error> {
@@ -138,4 +149,21 @@ mod tests {
); );
Ok(()) Ok(())
} }
#[test]
fn site_permission_serializes_revision() -> Result<(), serde_json::Error> {
let permission =
ServoLiveSitePermission::new("https://example.com", "camera", "allow-once", 7);
assert_eq!(
serde_json::to_value(permission)?,
json!({
"origin": "https://example.com",
"feature": "camera",
"state": "allow-once",
"revision": 7,
}),
);
Ok(())
}
} }
+14 -6
View File
@@ -97,14 +97,11 @@ impl WebSurfaceStore {
// which destroys and reallocates the framebuffer — the source of // which destroys and reallocates the framebuffer — the source of
// the per-frame blank flash. The first ensure (when no prior // the per-frame blank flash. The first ensure (when no prior
// `last_ensure_key` is set) always fires so the page can load. // `last_ensure_key` is set) always fires so the page can load.
let already_ensured = let defer_resize = self
self.surfaces.get(tab.id()).is_some_and(|surface| surface.last_ensure_key.is_some());
if already_ensured
&& self
.surfaces .surfaces
.get(tab.id()) .get(tab.id())
.is_some_and(|surface| surface.viewport_size_is_settling(Instant::now())) .is_some_and(|surface| surface.should_defer_resize(&ensure_key, Instant::now()));
{ if defer_resize {
return false; return false;
} }
let previous_frame = let previous_frame =
@@ -205,6 +202,7 @@ impl WebSurfaceStore {
} }
} }
WebSurfaceRuntimeFrame::Failed { tab_id, message } => { WebSurfaceRuntimeFrame::Failed { tab_id, message } => {
self.surface_mut(&tab_id).last_ensure_key = None;
let had_ready = matches!( let had_ready = matches!(
self.surfaces.get(&tab_id).and_then(|surface| surface.state.as_ref()), self.surfaces.get(&tab_id).and_then(|surface| surface.state.as_ref()),
Some(WebSurfaceState::Ready(_)) Some(WebSurfaceState::Ready(_))
@@ -221,6 +219,12 @@ impl WebSurfaceStore {
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Failed { message }); self.surface_mut(&tab_id).state = Some(WebSurfaceState::Failed { message });
result.changed = true; result.changed = true;
} }
WebSurfaceRuntimeFrame::PermissionSnapshotAccepted(grant) => {
result.permission_transfers.push(grant);
}
WebSurfaceRuntimeFrame::PermissionConsumed(consumed) => {
result.permission_consumptions.push(consumed);
}
} }
} }
@@ -381,3 +385,7 @@ mod web_surface_hardware_import_tests;
#[cfg(test)] #[cfg(test)]
#[path = "web_surface_scope_tests.rs"] #[path = "web_surface_scope_tests.rs"]
mod web_surface_scope_tests; mod web_surface_scope_tests;
#[cfg(test)]
#[path = "web_surface_permission_lifecycle_tests.rs"]
mod web_surface_permission_lifecycle_tests;
@@ -1,8 +1,10 @@
use std::{collections::HashMap, sync::Arc};
use ely_browser_core::{BrowserCore, BrowserSnapshot}; use ely_browser_core::{BrowserCore, BrowserSnapshot};
use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText}; use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText};
use gpui::{AnyElement, Bounds, Context, Pixels, Point}; use gpui::{AnyElement, Bounds, Context, Pixels, Point};
use crate::services::ProfileDataMode; use crate::services::{ProfileDataMode, servo_live::ServoLivePermissionGrant};
use super::{ use super::{
ElyShell, ElyShell,
@@ -65,11 +67,27 @@ impl ElyShell {
for metadata in result.page_metadata { for metadata in result.page_metadata {
metadata_changed |= self.apply_web_surface_page_metadata(metadata); metadata_changed |= self.apply_web_surface_page_metadata(metadata);
} }
let mut permission_changed = false;
if let super::ShellState::Ready(core) = &mut self.state {
for grant in result.permission_transfers {
permission_changed |= core
.transfer_site_permission_once(
grant.profile_id(),
grant.origin(),
grant.feature(),
grant.grant_revision(),
)
.unwrap_or(false);
}
for consumed in result.permission_consumptions {
permission_changed |= apply_permission_consumption(core, &consumed);
}
}
let sync_changed = self.drain_sync_updates(); let sync_changed = self.drain_sync_updates();
if url_changed || metadata_changed { if url_changed || metadata_changed {
self.schedule_cloud_sync_upload(cx); self.schedule_cloud_sync_upload(cx);
} }
result.changed || url_changed || metadata_changed || sync_changed result.changed || url_changed || metadata_changed || permission_changed || sync_changed
} }
pub(super) fn external_web_surface_tick_delay(&self) -> std::time::Duration { pub(super) fn external_web_surface_tick_delay(&self) -> std::time::Duration {
@@ -191,7 +209,7 @@ impl ElyShell {
changed |= self.web_surfaces.ensure_surface( changed |= self.web_surfaces.ensure_surface(
&visible.tab, &visible.tab,
visible.profile_data_mode, visible.profile_data_mode,
&visible.permissions, visible.permissions.as_ref(),
); );
} }
changed changed
@@ -237,22 +255,37 @@ impl ElyShell {
struct VisibleWebSurfaceTab { struct VisibleWebSurfaceTab {
tab: BrowserTab, tab: BrowserTab,
profile_data_mode: ProfileDataMode, profile_data_mode: ProfileDataMode,
permissions: Vec<WebSurfaceSitePermission>, permissions: Arc<[WebSurfaceSitePermission]>,
} }
fn visible_web_surface_tabs( fn visible_web_surface_tabs(
core: &BrowserCore, core: &BrowserCore,
tabs: Vec<BrowserTab>, tabs: Vec<BrowserTab>,
) -> Vec<VisibleWebSurfaceTab> { ) -> Vec<VisibleWebSurfaceTab> {
tabs.into_iter() let mut permission_cache = HashMap::new();
.filter(|tab| super::web_surface::is_external_web_url(tab.url().as_str())) let mut visible = Vec::new();
.filter_map(|tab| { for tab in tabs {
let profile_data_mode = if !super::web_surface::is_external_web_url(tab.url().as_str()) {
core.profile_kind_for(tab.profile_id()).ok().map(profile_data_mode_from_kind)?; continue;
let permissions = web_surface_site_permissions_for_core_tab(core, &tab); }
Some(VisibleWebSurfaceTab { tab, profile_data_mode, permissions }) let Ok(kind) = core.profile_kind_for(tab.profile_id()) else {
continue;
};
let permissions = permission_cache
.entry(tab.profile_id().clone())
.or_insert_with(|| {
Arc::<[WebSurfaceSitePermission]>::from(web_surface_site_permissions_for_core_tab(
core, &tab,
))
}) })
.collect() .clone();
visible.push(VisibleWebSurfaceTab {
tab,
profile_data_mode: profile_data_mode_from_kind(kind),
permissions,
});
}
visible
} }
fn profile_data_mode_for(tab: &BrowserTab, snapshot: &BrowserSnapshot) -> Option<ProfileDataMode> { fn profile_data_mode_for(tab: &BrowserTab, snapshot: &BrowserSnapshot) -> Option<ProfileDataMode> {
@@ -269,3 +302,59 @@ fn profile_data_mode_from_kind(kind: ProfileKind) -> ProfileDataMode {
ProfileKind::Private => ProfileDataMode::Transient, ProfileKind::Private => ProfileDataMode::Transient,
} }
} }
fn apply_permission_consumption(
core: &mut BrowserCore,
consumed: &ServoLivePermissionGrant,
) -> bool {
let transferred = core
.transfer_site_permission_once(
consumed.profile_id(),
consumed.origin(),
consumed.feature(),
consumed.grant_revision(),
)
.unwrap_or(false);
let finished = core
.finish_site_permission_once(
consumed.profile_id(),
consumed.origin(),
consumed.feature(),
consumed.grant_revision(),
)
.unwrap_or(false);
transferred || finished
}
#[cfg(test)]
mod tests {
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::{SiteOrigin, SitePermissionDecision, SitePermissionFeature};
use super::{ServoLivePermissionGrant, apply_permission_consumption};
#[test]
fn consumption_receipt_finishes_a_pending_allow_once_grant()
-> Result<(), Box<dyn std::error::Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let profile_id = core.snapshot()?.active_profile_id;
let origin = SiteOrigin::parse("https://example.com")?;
core.set_site_permission(
origin.clone(),
SitePermissionFeature::Camera,
SitePermissionDecision::AllowOnce,
)?;
let revision =
core.site_permission_revision(&profile_id, &origin, SitePermissionFeature::Camera);
let consumed = ServoLivePermissionGrant::new(
profile_id,
origin,
SitePermissionFeature::Camera,
revision,
);
assert!(apply_permission_consumption(&mut core, &consumed));
assert!(core.snapshot()?.site_permissions.is_empty());
Ok(())
}
}
@@ -0,0 +1,136 @@
use ely_domain::{
BrowserTab, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, SpaceId,
TabId, UrlText,
};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
};
use super::{WebSurfaceSitePermission, WebSurfaceStore};
use crate::shell::{
web_surface_permissions::WebSurfaceSitePermissionState,
web_surface_runtime::WebSurfaceRuntime,
web_surface_state::WebSurfaceInputOutcome,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
};
struct AcceptingClient;
struct RejectingClient;
static REJECTED_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
impl LiveRuntimeClient for AcceptingClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl LiveRuntimeClient for RejectingClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
REJECTED_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst);
Err(LiveRuntimeClientError::Message("ensure rejected".to_string()))
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
#[test]
fn successful_worker_ensure_confirms_allow_once_transfer() -> Result<(), String> {
let runtime = WebSurfaceRuntime::new_with_client_factory(|_| Ok(Box::new(AcceptingClient)));
let mut store = WebSurfaceStore::new_with_runtime(runtime);
let (tab, permission) = tab_and_permission()?;
let profile_id = tab.profile_id().clone();
record_viewport(&mut store, &tab);
assert!(store.ensure_surface(
&tab,
ProfileDataMode::Transient,
std::slice::from_ref(&permission),
));
store.flush_runtime_for_test();
let result = store.tick(std::slice::from_ref(tab.id()));
assert_eq!(
result.permission_transfers,
vec![crate::services::servo_live::ServoLivePermissionGrant::new(
profile_id,
permission.origin().clone(),
permission.feature(),
permission.revision(),
)],
);
Ok(())
}
#[test]
fn rejected_worker_ensure_keeps_allow_once_untransferred() -> Result<(), String> {
REJECTED_ENSURE_COUNT.store(0, Ordering::SeqCst);
let runtime = WebSurfaceRuntime::new_with_client_factory(|_| Ok(Box::new(RejectingClient)));
let mut store = WebSurfaceStore::new_with_runtime(runtime);
let (tab, permission) = tab_and_permission()?;
record_viewport(&mut store, &tab);
assert!(store.ensure_surface(
&tab,
ProfileDataMode::Transient,
std::slice::from_ref(&permission),
));
store.flush_runtime_for_test();
let result = store.tick(std::slice::from_ref(tab.id()));
assert!(result.permission_transfers.is_empty());
let _ =
store.ensure_surface(&tab, ProfileDataMode::Transient, std::slice::from_ref(&permission));
store.flush_runtime_for_test();
let _ = store.tick(std::slice::from_ref(tab.id()));
assert_eq!(REJECTED_ENSURE_COUNT.load(Ordering::SeqCst), 2);
Ok(())
}
fn tab_and_permission() -> Result<(BrowserTab, WebSurfaceSitePermission), String> {
let profile_id = ProfileId::new();
let tab = BrowserTab::new(
TabId::new(),
SpaceId::new(),
profile_id,
"Web",
UrlText::parse("https://example.com/").map_err(|error| error.to_string())?,
);
let permission = WebSurfaceSitePermission::new(
SiteOrigin::parse("https://example.com").map_err(|error| error.to_string())?,
SitePermissionFeature::Camera,
WebSurfaceSitePermissionState::Decision(SitePermissionDecision::AllowOnce),
7,
);
Ok((tab, permission))
}
fn record_viewport(store: &mut WebSurfaceStore, tab: &BrowserTab) {
let bounds = gpui::Bounds::new(
gpui::point(gpui::px(0.0), gpui::px(0.0)),
gpui::size(gpui::px(640.0), gpui::px(480.0)),
);
assert_eq!(store.record_viewport_size(tab.id(), bounds, 1.0), WebSurfaceInputOutcome::Applied);
}
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -1,22 +1,39 @@
use std::collections::HashMap;
use ely_browser_core::BrowserCore; use ely_browser_core::BrowserCore;
#[cfg(test)]
use ely_browser_core::BrowserSnapshot;
use ely_domain::{BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature}; use ely_domain::{BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum WebSurfaceSitePermissionState {
Decision(SitePermissionDecision),
TransferredAllowOnce,
}
impl WebSurfaceSitePermissionState {
pub(super) fn as_str(self) -> &'static str {
match self {
Self::Decision(decision) => decision.as_str(),
Self::TransferredAllowOnce => "transferred-allow-once",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfaceSitePermission { pub(super) struct WebSurfaceSitePermission {
origin: SiteOrigin, origin: SiteOrigin,
feature: SitePermissionFeature, feature: SitePermissionFeature,
decision: SitePermissionDecision, state: WebSurfaceSitePermissionState,
revision: u64,
} }
impl WebSurfaceSitePermission { impl WebSurfaceSitePermission {
pub(super) fn new( pub(super) fn new(
origin: SiteOrigin, origin: SiteOrigin,
feature: SitePermissionFeature, feature: SitePermissionFeature,
decision: SitePermissionDecision, state: WebSurfaceSitePermissionState,
revision: u64,
) -> Self { ) -> Self {
Self { origin, feature, decision } Self { origin, feature, state, revision }
} }
pub(super) fn origin(&self) -> &SiteOrigin { pub(super) fn origin(&self) -> &SiteOrigin {
@@ -27,45 +44,52 @@ impl WebSurfaceSitePermission {
self.feature self.feature
} }
pub(super) fn decision(&self) -> SitePermissionDecision { pub(super) fn state(&self) -> WebSurfaceSitePermissionState {
self.decision self.state
} }
}
#[cfg(test)] pub(super) fn revision(&self) -> u64 {
pub(super) fn web_surface_site_permissions_for_tab( self.revision
tab: &BrowserTab, }
snapshot: &BrowserSnapshot,
) -> Vec<WebSurfaceSitePermission> {
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| {
WebSurfaceSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision())
})
.collect()
} }
pub(super) fn web_surface_site_permissions_for_core_tab( pub(super) fn web_surface_site_permissions_for_core_tab(
core: &BrowserCore, core: &BrowserCore,
tab: &BrowserTab, tab: &BrowserTab,
) -> Vec<WebSurfaceSitePermission> { ) -> Vec<WebSurfaceSitePermission> {
let Ok(Some(origin)) = SiteOrigin::from_url(tab.url()) else { let mut latest = HashMap::new();
return Vec::new(); for event in core.site_permission_audit_events_for_profile(tab.profile_id()) {
}; let key = (event.origin().clone(), event.feature());
let revision = latest.entry(key).or_insert(0_u64);
*revision = revision.saturating_add(1);
}
core.site_permissions_for_profile_origin(tab.profile_id(), &origin) let mut permissions = Vec::new();
.into_iter() for entry in core.site_permissions_for_profile(tab.profile_id()) {
.map(|entry| { let key = (entry.origin().clone(), entry.feature());
WebSurfaceSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision()) let revision = latest.remove(&key).unwrap_or(0);
}) permissions.push(WebSurfaceSitePermission::new(
.collect() entry.origin().clone(),
entry.feature(),
WebSurfaceSitePermissionState::Decision(entry.decision()),
revision,
));
}
for (entry, grant_revision) in core.transferred_site_permissions_for_profile(tab.profile_id()) {
let key = (entry.origin().clone(), entry.feature());
latest.remove(&key);
permissions.push(WebSurfaceSitePermission::new(
entry.origin().clone(),
entry.feature(),
WebSurfaceSitePermissionState::TransferredAllowOnce,
grant_revision,
));
}
permissions.sort_by(|left, right| {
(left.origin().as_str(), left.feature().as_str())
.cmp(&(right.origin().as_str(), right.feature().as_str()))
});
permissions
} }
#[cfg(test)] #[cfg(test)]
@@ -77,7 +101,7 @@ mod tests {
BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText, BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText,
}; };
use super::web_surface_site_permissions_for_tab; use super::{WebSurfaceSitePermissionState, web_surface_site_permissions_for_core_tab};
#[test] #[test]
fn includes_matching_profile_and_origin_permissions() -> Result<(), Box<dyn Error>> { fn includes_matching_profile_and_origin_permissions() -> Result<(), Box<dyn Error>> {
@@ -91,17 +115,20 @@ mod tests {
let snapshot = core.snapshot()?; let snapshot = core.snapshot()?;
let tab = active_tab(&snapshot)?; let tab = active_tab(&snapshot)?;
let permissions = web_surface_site_permissions_for_tab(tab, &snapshot); let permissions = web_surface_site_permissions_for_core_tab(&core, tab);
assert_eq!(permissions.len(), 1); assert_eq!(permissions.len(), 1);
assert_eq!(permissions[0].origin().as_str(), "https://example.com"); assert_eq!(permissions[0].origin().as_str(), "https://example.com");
assert_eq!(permissions[0].feature(), SitePermissionFeature::Camera); assert_eq!(permissions[0].feature(), SitePermissionFeature::Camera);
assert_eq!(permissions[0].decision(), SitePermissionDecision::AllowAlways); assert_eq!(
permissions[0].state(),
WebSurfaceSitePermissionState::Decision(SitePermissionDecision::AllowAlways),
);
Ok(()) Ok(())
} }
#[test] #[test]
fn filters_other_origins() -> Result<(), Box<dyn Error>> { fn includes_the_complete_profile_snapshot() -> Result<(), Box<dyn Error>> {
let mut core = browser_core_for("https://example.com/page")?; let mut core = browser_core_for("https://example.com/page")?;
core.set_site_permission( core.set_site_permission(
SiteOrigin::parse("https://other.test")?, SiteOrigin::parse("https://other.test")?,
@@ -112,7 +139,30 @@ mod tests {
let snapshot = core.snapshot()?; let snapshot = core.snapshot()?;
let tab = active_tab(&snapshot)?; let tab = active_tab(&snapshot)?;
assert!(web_surface_site_permissions_for_tab(tab, &snapshot).is_empty()); let permissions = web_surface_site_permissions_for_core_tab(&core, tab);
assert_eq!(permissions.len(), 1);
assert_eq!(permissions[0].origin().as_str(), "https://other.test");
assert_eq!(permissions[0].revision(), 1);
assert_eq!(
permissions[0].state(),
WebSurfaceSitePermissionState::Decision(SitePermissionDecision::AllowOnce),
);
let profile_id = tab.profile_id().clone();
let origin = SiteOrigin::parse("https://other.test")?;
assert!(core.transfer_site_permission_once(
&profile_id,
&origin,
SitePermissionFeature::Location,
1,
)?);
let transferred = web_surface_site_permissions_for_core_tab(&core, tab);
assert_eq!(transferred[0].state(), WebSurfaceSitePermissionState::TransferredAllowOnce);
assert_eq!(transferred[0].revision(), 1);
core.revoke_site_permission(&origin, SitePermissionFeature::Location)?;
let revoked = web_surface_site_permissions_for_core_tab(&core, tab);
assert!(revoked.is_empty());
Ok(()) Ok(())
} }
+23 -21
View File
@@ -1,6 +1,5 @@
use std::{ use std::{
collections::BTreeMap, collections::BTreeMap,
path::PathBuf,
thread::JoinHandle, thread::JoinHandle,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
@@ -9,7 +8,7 @@ use ely_domain::{BrowserTab, TabId};
use crate::services::{ use crate::services::{
ProfileDataMode, ProfileDataMode,
servo_live::{ServoLiveClient, ServoLiveEnsureRequest, ServoLiveSitePermission}, servo_live::{ServoLiveEnsureRequest, ServoLiveSitePermission},
servo_profile_data::cleanup_stale_transient_profile_data_dirs, servo_profile_data::cleanup_stale_transient_profile_data_dirs,
}; };
@@ -21,13 +20,15 @@ use super::{
WebSurfaceRuntimeScope, WebSurfaceSession, config_dir_for_scope, session_for_scope, WebSurfaceRuntimeScope, WebSurfaceSession, config_dir_for_scope, session_for_scope,
}, },
web_surface_runtime_wire::{ web_surface_runtime_wire::{
input_requests_history_navigation, log_ensure_submitted, pending_input_kind, allow_once_grants, input_requests_history_navigation, log_ensure_submitted,
scroll_wire_fields, pending_input_kind, scroll_wire_fields,
}, },
web_surface_state::WebSurfacePendingInput, web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, RequestGeneration, WorkerResponse}, web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, RequestGeneration, WorkerResponse},
}; };
use cleanup::{ScopedWorker, shutdown_scoped_worker}; use cleanup::{
LiveRuntimeClientFactory, ScopedWorker, new_servo_live_client, shutdown_scoped_worker,
};
pub(super) use super::web_surface_runtime_session::{ pub(super) use super::web_surface_runtime_session::{
WebSurfaceEnsureResult, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind, WebSurfaceEnsureResult, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
@@ -130,7 +131,9 @@ impl WebSurfaceRuntime {
hover_x: input.hover_point.map(|point| point.x()), hover_x: input.hover_point.map(|point| point.x()),
hover_y: input.hover_point.map(|point| point.y()), hover_y: input.hover_point.map(|point| point.y()),
typed_text: input.typed_text, typed_text: input.typed_text,
site_permission_generation: generation.value(),
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(), site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
allow_once_grants: allow_once_grants(tab.profile_id(), permissions),
}; };
let Some(scoped) = self.workers.get(&scope) else { let Some(scoped) = self.workers.get(&scope) else {
@@ -168,22 +171,24 @@ impl WebSurfaceRuntime {
let poll_now = Instant::now(); let poll_now = Instant::now();
for tab_id in visible_tab_ids { for tab_id in visible_tab_ids {
let Some(scope) = self.sessions.get(tab_id).and_then(|session| { let Some((scope, generation)) = self.sessions.get(tab_id).and_then(|session| {
session.cadence.should_poll(poll_now).then(|| session.scope.clone()) session
.cadence
.should_poll(poll_now)
.then(|| (session.scope.clone(), session.generation))
}) else { }) else {
continue; continue;
}; };
let Some(generation) = generation else {
continue;
};
if !self.workers.contains_key(&scope) { if !self.workers.contains_key(&scope) {
continue; continue;
} }
let generation = self.next_request_generation(); let _ = self.workers.get(&scope).is_some_and(|scoped| {
let submitted = self.workers.get(&scope).is_some_and(|scoped| {
scoped.worker.submit_poll(generation, tab_id.as_str().to_string()) scoped.worker.submit_poll(generation, tab_id.as_str().to_string())
}); });
if let Some(session) = self.sessions.get_mut(tab_id) { if let Some(session) = self.sessions.get_mut(tab_id) {
if submitted {
session.generation = Some(generation);
}
session.cadence.note_poll_submitted(poll_now); session.cadence.note_poll_submitted(poll_now);
} }
} }
@@ -353,6 +358,12 @@ impl WebSurfaceRuntime {
} }
} }
WorkerResponse::RuntimeUnavailable => runtime_unavailable = true, WorkerResponse::RuntimeUnavailable => runtime_unavailable = true,
WorkerResponse::PermissionSnapshotAccepted(grant) => {
frames.push(WebSurfaceRuntimeFrame::PermissionSnapshotAccepted(grant));
}
WorkerResponse::PermissionConsumed(consumed) => {
frames.push(WebSurfaceRuntimeFrame::PermissionConsumed(consumed));
}
} }
} }
runtime_unavailable runtime_unavailable
@@ -448,15 +459,6 @@ impl Drop for WebSurfaceRuntime {
} }
} }
pub(super) type LiveRuntimeClientFactory =
fn(PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String>;
fn new_servo_live_client(config_dir: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
ServoLiveClient::new(config_dir)
.map(|client| Box::new(client) as Box<dyn LiveRuntimeClient>)
.map_err(|error| error.to_string())
}
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct ScopeRetryState { struct ScopeRetryState {
failure_count: u32, failure_count: u32,
@@ -121,12 +121,22 @@ fn pending_poll_advances_deadline_under_worker_backpressure() -> Result<(), Stri
let tab = web_tab("Backpressure")?; let tab = web_tab("Backpressure")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?; runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test(); runtime.flush_for_test();
let ensure_generation = runtime
.sessions
.get(tab.id())
.and_then(|session| session.generation)
.ok_or_else(|| "ensure generation was missing".to_string())?;
thread::sleep(Duration::from_millis(10)); thread::sleep(Duration::from_millis(10));
runtime.tick(std::slice::from_ref(tab.id())); runtime.tick(std::slice::from_ref(tab.id()));
thread::sleep(Duration::from_millis(10)); thread::sleep(Duration::from_millis(10));
runtime.tick(std::slice::from_ref(tab.id())); runtime.tick(std::slice::from_ref(tab.id()));
assert_eq!(
runtime.sessions.get(tab.id()).and_then(|session| session.generation),
Some(ensure_generation),
);
let delay = runtime let delay = runtime
.next_poll_delay(std::slice::from_ref(tab.id()), std::time::Instant::now()) .next_poll_delay(std::slice::from_ref(tab.id()), std::time::Instant::now())
.ok_or_else(|| "visible tab lost its poll deadline".to_string())?; .ok_or_else(|| "visible tab lost its poll deadline".to_string())?;
@@ -1,6 +1,19 @@
use crate::services::servo_profile_data::TransientProfileDataDir; use std::path::PathBuf;
use super::LiveRuntimeWorker; use crate::services::{servo_live::ServoLiveClient, servo_profile_data::TransientProfileDataDir};
use super::{LiveRuntimeClient, LiveRuntimeWorker};
pub(super) type LiveRuntimeClientFactory =
fn(PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String>;
pub(super) fn new_servo_live_client(
config_dir: PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
ServoLiveClient::new(config_dir)
.map(|client| Box::new(client) as Box<dyn LiveRuntimeClient>)
.map_err(|error| error.to_string())
}
pub(super) struct ScopedWorker { pub(super) struct ScopedWorker {
pub(super) worker: LiveRuntimeWorker, pub(super) worker: LiveRuntimeWorker,
@@ -2,6 +2,7 @@ use std::{collections::BTreeMap, path::PathBuf};
use crate::services::{ use crate::services::{
ProfileDataMode, ProfileDataMode,
servo_live::ServoLivePermissionGrant,
servo_profile_data::{ servo_profile_data::{
TransientProfileDataDir, create_profile_data_dir, default_profile_data_root, TransientProfileDataDir, create_profile_data_dir, default_profile_data_root,
transient_profile_data_dir, transient_profile_data_dir,
@@ -102,6 +103,8 @@ pub(super) struct WebSurfaceEnsureResult {
pub(super) enum WebSurfaceRuntimeFrame { pub(super) enum WebSurfaceRuntimeFrame {
Ready { tab_id: TabId, frame: Box<WebSurfaceFrame>, url_change: Option<WebSurfaceUrlChange> }, Ready { tab_id: TabId, frame: Box<WebSurfaceFrame>, url_change: Option<WebSurfaceUrlChange> },
Failed { tab_id: TabId, message: String }, Failed { tab_id: TabId, message: String },
PermissionSnapshotAccepted(ServoLivePermissionGrant),
PermissionConsumed(ServoLivePermissionGrant),
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -1,13 +1,13 @@
use std::time::Instant; use std::time::Instant;
use ely_domain::BrowserTab; use ely_domain::{BrowserTab, ProfileId, SitePermissionDecision};
use crate::services::servo_live::ServoLiveSitePermission; use crate::services::servo_live::{ServoLivePermissionGrant, ServoLiveSitePermission};
use super::{ use super::{
web_surface_cadence::WebSurfaceInputKind, web_surface_cadence::WebSurfaceInputKind,
web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceSize}, web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceSize},
web_surface_permissions::WebSurfaceSitePermission, web_surface_permissions::{WebSurfaceSitePermission, WebSurfaceSitePermissionState},
web_surface_state::WebSurfacePendingInput, web_surface_state::WebSurfacePendingInput,
}; };
@@ -30,6 +30,27 @@ pub(super) fn input_requests_history_navigation(input: &WebSurfacePendingInput)
|| input.typed_text.as_deref().is_some_and(|text| text.contains('\n')) || input.typed_text.as_deref().is_some_and(|text| text.contains('\n'))
} }
pub(super) fn allow_once_grants(
profile_id: &ProfileId,
permissions: &[WebSurfaceSitePermission],
) -> Vec<ServoLivePermissionGrant> {
permissions
.iter()
.filter(|permission| {
permission.state()
== WebSurfaceSitePermissionState::Decision(SitePermissionDecision::AllowOnce)
})
.map(|permission| {
ServoLivePermissionGrant::new(
profile_id.clone(),
permission.origin().clone(),
permission.feature(),
permission.revision(),
)
})
.collect()
}
pub(super) fn pending_input_kind(input: &WebSurfacePendingInput) -> WebSurfaceInputKind { pub(super) fn pending_input_kind(input: &WebSurfacePendingInput) -> WebSurfaceInputKind {
if input.scroll_delta.is_some() { if input.scroll_delta.is_some() {
WebSurfaceInputKind::Scroll WebSurfaceInputKind::Scroll
@@ -74,7 +95,8 @@ impl From<&WebSurfaceSitePermission> for ServoLiveSitePermission {
Self::new( Self::new(
permission.origin().as_str(), permission.origin().as_str(),
permission.feature().as_str(), permission.feature().as_str(),
permission.decision(), permission.state().as_str(),
permission.revision(),
) )
} }
} }
@@ -4,6 +4,7 @@ use ely_domain::{ProfileId, TabId};
use gpui::{Bounds, Pixels}; use gpui::{Bounds, Pixels};
use crate::services::ProfileDataMode; use crate::services::ProfileDataMode;
use crate::services::servo_live::ServoLivePermissionGrant;
use super::{ use super::{
web_surface_cadence::ACTIVE_POLL_INTERVAL, web_surface_cadence::ACTIVE_POLL_INTERVAL,
@@ -110,6 +111,8 @@ pub(super) struct WebSurfaceTickResult {
pub(super) changed: bool, pub(super) changed: bool,
pub(super) url_changes: Vec<WebSurfaceUrlChange>, pub(super) url_changes: Vec<WebSurfaceUrlChange>,
pub(super) page_metadata: Vec<WebSurfacePageMetadata>, pub(super) page_metadata: Vec<WebSurfacePageMetadata>,
pub(super) permission_transfers: Vec<ServoLivePermissionGrant>,
pub(super) permission_consumptions: Vec<ServoLivePermissionGrant>,
} }
/// All per-tab surface invariants in one owner. /// All per-tab surface invariants in one owner.
@@ -196,6 +199,11 @@ impl PerTabSurface {
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input() self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
} }
pub(super) fn should_defer_resize(&self, key: &WebSurfaceEnsureKey, now: Instant) -> bool {
self.last_ensure_key.as_ref().is_some_and(|last| last.permissions == key.permissions)
&& self.viewport_size_is_settling(now)
}
pub(super) fn has_scope( pub(super) fn has_scope(
&self, &self,
profile_id: &ProfileId, profile_id: &ProfileId,
@@ -368,6 +376,31 @@ mod tests {
assert!(surface.should_ensure(&new_key)); assert!(surface.should_ensure(&new_key));
} }
#[test]
fn permission_change_bypasses_viewport_resize_debounce()
-> Result<(), Box<dyn std::error::Error>> {
let now = Instant::now();
let profile_id = ProfileId::new();
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
let resized_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
let mut new_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
new_key.permissions.push(WebSurfaceSitePermission::new(
ely_domain::SiteOrigin::parse("https://example.com")?,
ely_domain::SitePermissionFeature::Camera,
crate::shell::web_surface_permissions::WebSurfaceSitePermissionState::Decision(
ely_domain::SitePermissionDecision::DenyAlways,
),
1,
));
let mut surface = PerTabSurface::new();
surface.mark_ensured(old_key);
surface.mark_viewport_size_changed(now);
assert!(surface.should_defer_resize(&resized_key, now));
assert!(!surface.should_defer_resize(&new_key, now));
Ok(())
}
#[test] #[test]
fn profile_change_forces_ensure() { fn profile_change_forces_ensure() {
let old_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new()); let old_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
+38 -59
View File
@@ -7,6 +7,14 @@ use std::{
use crate::services::servo_live::{ use crate::services::servo_live::{
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
ServoLivePermissionGrant,
};
#[path = "web_surface_worker_dispatch.rs"]
mod dispatch;
use dispatch::{
dispatch_result, forward_permission_consumptions, preserve_latest_hover,
request_has_ordered_input,
}; };
/// Blocking transport for one profile-scoped Servo sidecar. /// Blocking transport for one profile-scoped Servo sidecar.
@@ -22,6 +30,10 @@ pub(super) trait LiveRuntimeClient {
fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError>; fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError>;
fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError>; fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError>;
fn take_permission_consumptions(&mut self) -> Vec<ServoLivePermissionGrant> {
Vec::new()
}
} }
impl LiveRuntimeClient for ServoLiveClient { impl LiveRuntimeClient for ServoLiveClient {
@@ -39,6 +51,10 @@ impl LiveRuntimeClient for ServoLiveClient {
fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError> { fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError> {
ServoLiveClient::close(self, tab_id).map_err(LiveRuntimeClientError::from) ServoLiveClient::close(self, tab_id).map_err(LiveRuntimeClientError::from)
} }
fn take_permission_consumptions(&mut self) -> Vec<ServoLivePermissionGrant> {
ServoLiveClient::take_permission_consumptions(self)
}
} }
#[derive(Debug)] #[derive(Debug)]
@@ -88,6 +104,8 @@ pub(super) enum WorkerResponse {
Frame { generation: RequestGeneration, tab_id: String, frame: ServoLiveFrame }, Frame { generation: RequestGeneration, tab_id: String, frame: ServoLiveFrame },
Failed { generation: RequestGeneration, tab_id: String, message: String }, Failed { generation: RequestGeneration, tab_id: String, message: String },
RuntimeUnavailable, RuntimeUnavailable,
PermissionSnapshotAccepted(ServoLivePermissionGrant),
PermissionConsumed(ServoLivePermissionGrant),
} }
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -97,10 +115,14 @@ impl RequestGeneration {
pub(super) const fn new(value: u64) -> Self { pub(super) const fn new(value: u64) -> Self {
Self(value) Self(value)
} }
pub(super) const fn value(self) -> u64 {
self.0
}
} }
enum WorkerRequest { enum WorkerRequest {
Ensure { generation: RequestGeneration, request: ServoLiveEnsureRequest }, Ensure { generation: RequestGeneration, request: Box<ServoLiveEnsureRequest> },
Poll { generation: RequestGeneration, tab_id: String }, Poll { generation: RequestGeneration, tab_id: String },
} }
@@ -190,7 +212,7 @@ impl LiveRuntimeWorker {
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message }); let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
return; return;
} }
let mut request = WorkerRequest::Ensure { generation, request }; let mut request = WorkerRequest::Ensure { generation, request: Box::new(request) };
if let Some(pending) = q.pending.get_mut(&tab_id) { if let Some(pending) = q.pending.get_mut(&tab_id) {
let replace_tail = pending.back().is_some_and(|tail| { let replace_tail = pending.back().is_some_and(|tail| {
matches!(tail, WorkerRequest::Poll { .. }) matches!(tail, WorkerRequest::Poll { .. })
@@ -394,15 +416,26 @@ fn run_worker(
let exit_after_dispatch = match work { let exit_after_dispatch = match work {
Work::Close(tab_id) => { Work::Close(tab_id) => {
let _ = client.close(tab_id); let _ = client.close(tab_id);
forward_permission_consumptions(&mut *client, &response_tx);
false false
} }
Work::Request(WorkerRequest::Ensure { generation, request }) => { Work::Request(WorkerRequest::Ensure { generation, mut request }) => {
let tab_id = request.tab_id.clone(); let tab_id = request.tab_id.clone();
dispatch_result(&response_tx, generation, tab_id, client.ensure(request)) let allow_once_grants = std::mem::take(&mut request.allow_once_grants);
let result = client.ensure(*request);
if result.is_ok() {
for grant in allow_once_grants {
let _ = response_tx.send(WorkerResponse::PermissionSnapshotAccepted(grant));
}
}
forward_permission_consumptions(&mut *client, &response_tx);
dispatch_result(&response_tx, generation, tab_id, result)
} }
Work::Request(WorkerRequest::Poll { generation, tab_id }) => { Work::Request(WorkerRequest::Poll { generation, tab_id }) => {
let request_tab_id = tab_id.clone(); let request_tab_id = tab_id.clone();
dispatch_result(&response_tx, generation, request_tab_id, client.poll(tab_id)) let result = client.poll(tab_id);
forward_permission_consumptions(&mut *client, &response_tx);
dispatch_result(&response_tx, generation, request_tab_id, result)
} }
}; };
@@ -424,60 +457,6 @@ enum Work {
Request(WorkerRequest), Request(WorkerRequest),
} }
/// Forward a single client result to the response channel. Returns
/// `true` when the worker should exit.
fn dispatch_result(
response_tx: &mpsc::Sender<WorkerResponse>,
generation: RequestGeneration,
tab_id: String,
result: Result<Option<ServoLiveFrame>, LiveRuntimeClientError>,
) -> bool {
match result {
Ok(Some(frame)) => {
let _ = response_tx.send(WorkerResponse::Frame { generation, tab_id, frame });
false
}
Ok(None) => false,
Err(error) => {
let unavailable = error.is_runtime_unavailable();
let message = error.to_string();
let _ = response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
if unavailable {
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
return true;
}
false
}
}
}
fn request_has_ordered_input(request: &WorkerRequest) -> bool {
let WorkerRequest::Ensure { request, .. } = request else {
return false;
};
request.scroll_delta_x != 0
|| request.scroll_delta_y != 0
|| request.scroll_point_x.is_some()
|| request.scroll_point_y.is_some()
|| request.click_x.is_some()
|| request.click_y.is_some()
|| request.typed_text.is_some()
}
fn preserve_latest_hover(latest: &mut WorkerRequest, previous: &WorkerRequest) {
let (
WorkerRequest::Ensure { request: latest, .. },
WorkerRequest::Ensure { request: previous, .. },
) = (latest, previous)
else {
return;
};
if latest.hover_x.is_none() && latest.hover_y.is_none() {
latest.hover_x = previous.hover_x;
latest.hover_y = previous.hover_y;
}
}
#[cfg(test)] #[cfg(test)]
#[path = "web_surface_worker_tests.rs"] #[path = "web_surface_worker_tests.rs"]
mod tests; mod tests;
@@ -0,0 +1,69 @@
use std::sync::mpsc;
use crate::services::servo_live::ServoLiveFrame;
use super::{
LiveRuntimeClient, LiveRuntimeClientError, RequestGeneration, WorkerRequest, WorkerResponse,
};
pub(super) fn forward_permission_consumptions(
client: &mut dyn LiveRuntimeClient,
response_tx: &mpsc::Sender<WorkerResponse>,
) {
for consumption in client.take_permission_consumptions() {
let _ = response_tx.send(WorkerResponse::PermissionConsumed(consumption));
}
}
pub(super) fn dispatch_result(
response_tx: &mpsc::Sender<WorkerResponse>,
generation: RequestGeneration,
tab_id: String,
result: Result<Option<ServoLiveFrame>, LiveRuntimeClientError>,
) -> bool {
match result {
Ok(Some(frame)) => {
let _ = response_tx.send(WorkerResponse::Frame { generation, tab_id, frame });
false
}
Ok(None) => false,
Err(error) => {
let unavailable = error.is_runtime_unavailable();
let message = error.to_string();
let _ = response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
if unavailable {
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
return true;
}
false
}
}
}
pub(super) fn request_has_ordered_input(request: &WorkerRequest) -> bool {
let WorkerRequest::Ensure { request, .. } = request else {
return false;
};
request.scroll_delta_x != 0
|| request.scroll_delta_y != 0
|| request.scroll_point_x.is_some()
|| request.scroll_point_y.is_some()
|| request.click_x.is_some()
|| request.click_y.is_some()
|| request.typed_text.is_some()
|| request.site_permissions.iter().any(|permission| permission.state == "allow-once")
}
pub(super) fn preserve_latest_hover(latest: &mut WorkerRequest, previous: &WorkerRequest) {
let (
WorkerRequest::Ensure { request: latest, .. },
WorkerRequest::Ensure { request: previous, .. },
) = (latest, previous)
else {
return;
};
if latest.hover_x.is_none() && latest.hover_y.is_none() {
latest.hover_x = previous.hover_x;
latest.hover_y = previous.hover_y;
}
}
@@ -3,7 +3,10 @@ use std::{
time::Duration, time::Duration,
}; };
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame}; use crate::services::servo_live::{
ServoLiveEnsureRequest, ServoLiveFrame, ServoLivePermissionGrant, ServoLiveSitePermission,
};
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature};
use super::{ use super::{
LiveRuntimeClient, LiveRuntimeClientError, LiveRuntimeWorker, RequestGeneration, WorkerResponse, LiveRuntimeClient, LiveRuntimeClientError, LiveRuntimeWorker, RequestGeneration, WorkerResponse,
@@ -16,6 +19,7 @@ enum RecordedInput {
Click(String), Click(String),
Text(String), Text(String),
Hover(String), Hover(String),
Permission(String),
} }
struct SlowRecordingClient { struct SlowRecordingClient {
@@ -27,6 +31,31 @@ struct SlowRecordingClient {
struct GenerationClient; struct GenerationClient;
struct ConsumptionClient {
pending: Vec<ServoLivePermissionGrant>,
}
impl LiveRuntimeClient for ConsumptionClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::Message("ensure failed".to_string()))
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
fn take_permission_consumptions(&mut self) -> Vec<ServoLivePermissionGrant> {
std::mem::take(&mut self.pending)
}
}
impl LiveRuntimeClient for GenerationClient { impl LiveRuntimeClient for GenerationClient {
fn ensure( fn ensure(
&mut self, &mut self,
@@ -117,6 +146,52 @@ fn queued_edge_inputs_for_one_tab_are_preserved() -> Result<(), String> {
Ok(()) Ok(())
} }
#[test]
fn allow_once_transfer_is_preserved_ahead_of_idle_updates() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let client_calls = calls.clone();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls: client_calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: false,
}))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
let mut transfer = ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string()));
transfer.site_permissions.push(ServoLiveSitePermission::new(
"https://example.com",
"camera",
"allow-once",
7,
));
worker.submit_ensure(RequestGeneration::new(2), transfer);
worker.submit_ensure(
RequestGeneration::new(3),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert_eq!(
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
vec![
RecordedInput::Idle("tab-a".to_string()),
RecordedInput::Permission("tab-a".to_string()),
RecordedInput::Idle("tab-a".to_string()),
],
);
Ok(())
}
#[test] #[test]
fn queued_tabs_are_dispatched_round_robin() -> Result<(), String> { fn queued_tabs_are_dispatched_round_robin() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new())); let calls = Arc::new(Mutex::new(Vec::new()));
@@ -191,6 +266,32 @@ fn responses_keep_their_request_generation() -> Result<(), String> {
Ok(()) Ok(())
} }
#[test]
fn permission_consumption_is_forwarded_after_ensure_error() -> Result<(), String> {
let profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com").map_err(|error| error.to_string())?;
let grant = ServoLivePermissionGrant::new(profile_id, origin, SitePermissionFeature::Camera, 7);
let expected = grant.clone();
let consumed = grant.clone();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(ConsumptionClient { pending: vec![consumed] }))
})?;
let mut request = ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string()));
request.allow_once_grants.push(grant);
worker.submit_ensure(RequestGeneration::new(1), request);
worker.wait_until_idle();
assert!(matches!(
worker.drain_responses().as_slice(),
[
WorkerResponse::PermissionConsumed(consumed),
WorkerResponse::Failed { message, .. },
] if consumed == &expected && message == "ensure failed"
));
Ok(())
}
#[test] #[test]
fn hover_updates_coalesce_to_latest_state() -> Result<(), String> { fn hover_updates_coalesce_to_latest_state() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new())); let calls = Arc::new(Mutex::new(Vec::new()));
@@ -321,7 +422,9 @@ fn poisoned_queue_still_shuts_down_worker() -> Result<(), String> {
fn recorded_input(request: &ServoLiveEnsureRequest) -> RecordedInput { fn recorded_input(request: &ServoLiveEnsureRequest) -> RecordedInput {
let tab_id = request.tab_id.clone(); let tab_id = request.tab_id.clone();
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 { if request.site_permissions.iter().any(|permission| permission.state == "allow-once") {
RecordedInput::Permission(tab_id)
} else if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
RecordedInput::Scroll(tab_id) RecordedInput::Scroll(tab_id)
} else if request.click_x.is_some() { } else if request.click_x.is_some() {
RecordedInput::Click(tab_id) RecordedInput::Click(tab_id)
@@ -356,6 +459,8 @@ fn ensure_request(tab_id: &str, input: RecordedInput) -> ServoLiveEnsureRequest
hover_x: hover.then_some(1), hover_x: hover.then_some(1),
hover_y: hover.then_some(1), hover_y: hover.then_some(1),
typed_text: text.then(|| "text".to_string()), typed_text: text.then(|| "text".to_string()),
site_permission_generation: 1,
site_permissions: Vec::new(), site_permissions: Vec::new(),
allow_once_grants: Vec::new(),
} }
} }
+8
View File
@@ -143,6 +143,12 @@ pub struct BrowserSnapshot {
pub command_query: String, pub command_query: String,
} }
#[derive(Debug)]
struct TransferredSitePermission {
entry: SitePermissionEntry,
grant_revision: u64,
}
#[derive(Debug)] #[derive(Debug)]
pub struct BrowserCore { pub struct BrowserCore {
spaces: Vec<Space>, spaces: Vec<Space>,
@@ -153,6 +159,7 @@ pub struct BrowserCore {
notes: Vec<NoteEntry>, notes: Vec<NoteEntry>,
reading_list: Vec<ReadingListEntry>, reading_list: Vec<ReadingListEntry>,
site_permissions: Vec<SitePermissionEntry>, site_permissions: Vec<SitePermissionEntry>,
transferred_site_permissions: Vec<TransferredSitePermission>,
site_permission_audit_events: Vec<SitePermissionAuditEvent>, site_permission_audit_events: Vec<SitePermissionAuditEvent>,
download_entries: Vec<DownloadEntry>, download_entries: Vec<DownloadEntry>,
history_entries: Vec<HistoryEntry>, history_entries: Vec<HistoryEntry>,
@@ -238,6 +245,7 @@ impl BrowserCore {
notes: Vec::new(), notes: Vec::new(),
reading_list: Vec::new(), reading_list: Vec::new(),
site_permissions: Vec::new(), site_permissions: Vec::new(),
transferred_site_permissions: Vec::new(),
site_permission_audit_events: Vec::new(), site_permission_audit_events: Vec::new(),
download_entries: Vec::new(), download_entries: Vec::new(),
history_entries: Vec::new(), history_entries: Vec::new(),
@@ -224,6 +224,8 @@ pub(super) struct ElyLocalSitePermissionAuditRecord {
enum ElyLocalSitePermissionAuditActionRecord { enum ElyLocalSitePermissionAuditActionRecord {
Set { decision: String }, Set { decision: String },
Revoked, Revoked,
Transferred,
Consumed,
} }
impl ElyLocalProfileRecord { impl ElyLocalProfileRecord {
@@ -436,6 +438,8 @@ impl ElyLocalSitePermissionAuditActionRecord {
Self::Set { decision: decision.as_str().to_string() } Self::Set { decision: decision.as_str().to_string() }
} }
SitePermissionAuditAction::Revoked => Self::Revoked, SitePermissionAuditAction::Revoked => Self::Revoked,
SitePermissionAuditAction::Transferred => Self::Transferred,
SitePermissionAuditAction::Consumed => Self::Consumed,
} }
} }
} }
@@ -11,7 +11,7 @@ use super::local_data_export_records::{
}; };
use crate::CoreError; use crate::CoreError;
pub const ELYDATA_SCHEMA_VERSION: u16 = 1; pub const ELYDATA_SCHEMA_VERSION: u16 = 2;
pub const ELYDATA_FILE_EXTENSION: &str = "elydata"; pub const ELYDATA_FILE_EXTENSION: &str = "elydata";
impl BrowserCore { impl BrowserCore {
@@ -79,6 +79,7 @@ impl BrowserCore {
.site_permissions .site_permissions
.iter() .iter()
.filter(|entry| entry.profile_id() == profile_id) .filter(|entry| entry.profile_id() == profile_id)
.filter(|entry| entry.decision() != ely_domain::SitePermissionDecision::AllowOnce)
.map(ElyLocalSitePermissionRecord::from_site_permission) .map(ElyLocalSitePermissionRecord::from_site_permission)
.collect(), .collect(),
site_permission_audit_events: self site_permission_audit_events: self
@@ -132,6 +132,7 @@ impl BrowserCore {
.site_permissions .site_permissions
.iter() .iter()
.filter(|entry| entry.profile_id() == profile_id) .filter(|entry| entry.profile_id() == profile_id)
.filter(|entry| entry.decision() != ely_domain::SitePermissionDecision::AllowOnce)
.count(), .count(),
site_permission_audit_events: self site_permission_audit_events: self
.site_permission_audit_events .site_permission_audit_events
@@ -75,6 +75,15 @@ impl BrowserCore {
true true
} }
}); });
self.transferred_site_permissions.retain(|permission| {
if permission.entry.profile_id() == profile_id && permission.entry.origin() == origin {
revoked_permissions
.push((permission.entry.origin().clone(), permission.entry.feature()));
false
} else {
true
}
});
let revoked_count = revoked_permissions.len(); let revoked_count = revoked_permissions.len();
for (origin, feature) in revoked_permissions { for (origin, feature) in revoked_permissions {
@@ -7,7 +7,7 @@ use ely_domain::{
use crate::CoreError; use crate::CoreError;
use super::BrowserCore; use super::{BrowserCore, TransferredSitePermission};
impl BrowserCore { impl BrowserCore {
pub fn set_site_permission( pub fn set_site_permission(
@@ -45,21 +45,112 @@ impl BrowserCore {
pub(super) fn visible_site_permissions(&self) -> Vec<SitePermissionEntry> { pub(super) fn visible_site_permissions(&self) -> Vec<SitePermissionEntry> {
self.site_permissions self.site_permissions
.iter() .iter()
.chain(self.transferred_site_permissions.iter().map(|permission| &permission.entry))
.filter(|entry| entry.profile_id() == &self.active_profile_id) .filter(|entry| entry.profile_id() == &self.active_profile_id)
.cloned() .cloned()
.collect() .collect()
} }
pub fn site_permissions_for_profile_origin( pub fn site_permissions_for_profile(&self, profile_id: &ProfileId) -> Vec<SitePermissionEntry> {
self.site_permissions
.iter()
.filter(|entry| entry.profile_id() == profile_id)
.cloned()
.collect()
}
pub fn transferred_site_permissions_for_profile(
&self,
profile_id: &ProfileId,
) -> Vec<(SitePermissionEntry, u64)> {
self.transferred_site_permissions
.iter()
.filter(|permission| permission.entry.profile_id() == profile_id)
.map(|permission| (permission.entry.clone(), permission.grant_revision))
.collect()
}
pub fn site_permission_audit_events_for_profile(
&self,
profile_id: &ProfileId,
) -> Vec<SitePermissionAuditEvent> {
self.site_permission_audit_events
.iter()
.filter(|event| event.profile_id() == profile_id)
.cloned()
.collect()
}
pub fn site_permission_revision(
&self, &self,
profile_id: &ProfileId, profile_id: &ProfileId,
origin: &SiteOrigin, origin: &SiteOrigin,
) -> Vec<SitePermissionEntry> { feature: SitePermissionFeature,
self.site_permissions ) -> u64 {
self.site_permission_audit_events
.iter() .iter()
.filter(|entry| entry.profile_id() == profile_id && entry.origin() == origin) .filter(|event| {
.cloned() event.profile_id() == profile_id
.collect() && event.origin() == origin
&& event.feature() == feature
})
.fold(0_u64, |revision, _| revision.saturating_add(1))
}
pub fn transfer_site_permission_once(
&mut self,
profile_id: &ProfileId,
origin: &SiteOrigin,
feature: SitePermissionFeature,
revision: u64,
) -> Result<bool, CoreError> {
self.require_profile(profile_id)?;
if self.site_permission_revision(profile_id, origin, feature) != revision {
return Ok(false);
}
let Some(index) = self.site_permission_entry_index(profile_id, origin, feature) else {
return Ok(false);
};
if self.site_permissions[index].decision() != SitePermissionDecision::AllowOnce {
return Ok(false);
}
let entry = self.site_permissions.remove(index);
self.record_site_permission_audit_event(
profile_id.clone(),
entry.origin().clone(),
feature,
SitePermissionAuditAction::Transferred,
);
self.transferred_site_permissions
.push(TransferredSitePermission { entry, grant_revision: revision });
Ok(true)
}
pub fn finish_site_permission_once(
&mut self,
profile_id: &ProfileId,
origin: &SiteOrigin,
feature: SitePermissionFeature,
grant_revision: u64,
) -> Result<bool, CoreError> {
self.require_profile(profile_id)?;
let Some(index) = self.transferred_site_permissions.iter().position(|permission| {
permission.entry.profile_id() == profile_id
&& permission.entry.origin() == origin
&& permission.entry.feature() == feature
&& permission.grant_revision == grant_revision
}) else {
return Ok(false);
};
let permission = self.transferred_site_permissions.remove(index);
self.record_site_permission_audit_event(
profile_id.clone(),
permission.entry.origin().clone(),
feature,
SitePermissionAuditAction::Consumed,
);
Ok(true)
} }
pub(super) fn visible_site_permission_audit_events(&self) -> Vec<SitePermissionAuditEvent> { pub(super) fn visible_site_permission_audit_events(&self) -> Vec<SitePermissionAuditEvent> {
@@ -79,6 +170,12 @@ impl BrowserCore {
) -> Result<(), CoreError> { ) -> Result<(), CoreError> {
self.require_profile(profile_id)?; self.require_profile(profile_id)?;
self.transferred_site_permissions.retain(|permission| {
permission.entry.profile_id() != profile_id
|| permission.entry.origin() != &origin
|| permission.entry.feature() != feature
});
if let Some(entry) = self.site_permission_entry_mut(profile_id, &origin, feature) { if let Some(entry) = self.site_permission_entry_mut(profile_id, &origin, feature) {
if entry.decision() == decision { if entry.decision() == decision {
return Ok(()); return Ok(());
@@ -109,11 +206,20 @@ impl BrowserCore {
feature: SitePermissionFeature, feature: SitePermissionFeature,
) -> Result<(), CoreError> { ) -> Result<(), CoreError> {
self.require_profile(profile_id)?; self.require_profile(profile_id)?;
let Some(index) = self.site_permission_entry_index(profile_id, origin, feature) else { let entry =
if let Some(index) = self.site_permission_entry_index(profile_id, origin, feature) {
self.site_permissions.remove(index)
} else if let Some(index) =
self.transferred_site_permissions.iter().position(|permission| {
permission.entry.profile_id() == profile_id
&& permission.entry.origin() == origin
&& permission.entry.feature() == feature
})
{
self.transferred_site_permissions.remove(index).entry
} else {
return Ok(()); return Ok(());
}; };
let entry = self.site_permissions.remove(index);
self.record_site_permission_audit_event( self.record_site_permission_audit_event(
profile_id.clone(), profile_id.clone(),
entry.origin().clone(), entry.origin().clone(),
@@ -138,6 +244,15 @@ impl BrowserCore {
true true
} }
}); });
self.transferred_site_permissions.retain(|permission| {
if permission.entry.profile_id() == profile_id {
revoked_permissions
.push((permission.entry.origin().clone(), permission.entry.feature()));
false
} else {
true
}
});
let revoked_count = revoked_permissions.len(); let revoked_count = revoked_permissions.len();
for (origin, feature) in revoked_permissions { for (origin, feature) in revoked_permissions {
@@ -15,6 +15,7 @@ impl BrowserCore {
self.site_permissions self.site_permissions
.iter() .iter()
.filter(|entry| self.profile_allows_cloud_sync(entry.profile_id())) .filter(|entry| self.profile_allows_cloud_sync(entry.profile_id()))
.filter(|entry| entry.decision() != SitePermissionDecision::AllowOnce)
.collect() .collect()
} }
@@ -30,6 +31,15 @@ impl BrowserCore {
SitePermissionFeature::parse(&record.feature).map_err(snapshot_schema_error)?; SitePermissionFeature::parse(&record.feature).map_err(snapshot_schema_error)?;
let decision = let decision =
SitePermissionDecision::parse(&record.decision).map_err(snapshot_schema_error)?; SitePermissionDecision::parse(&record.decision).map_err(snapshot_schema_error)?;
if decision == SitePermissionDecision::AllowOnce {
summary.record_skipped();
return Ok(());
}
self.transferred_site_permissions.retain(|permission| {
permission.entry.profile_id() != &profile_id
|| permission.entry.origin() != &origin
|| permission.entry.feature() != feature
});
let existing_index = self.site_permissions.iter().position(|entry| { let existing_index = self.site_permissions.iter().position(|entry| {
entry.profile_id() == &profile_id entry.profile_id() == &profile_id
&& entry.origin() == &origin && entry.origin() == &origin
@@ -51,3 +61,32 @@ impl BrowserCore {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{InitialBrowserConfig, sync_engine::SyncSnapshotApplySummary};
#[test]
fn legacy_allow_once_sync_record_is_skipped() -> Result<(), Box<dyn std::error::Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let profile_id = core.snapshot()?.active_profile_id;
let record = SitePermissionSyncRecord {
profile_id: profile_id.as_str().to_string(),
origin: "https://example.com".to_string(),
feature: "camera".to_string(),
decision: "allow-once".to_string(),
};
let mut summary = SyncSnapshotApplySummary::default();
core.apply_site_permission_sync_record(
record,
&mut summary,
&SyncSnapshotApplyContext::default(),
)?;
assert_eq!(summary.skipped(), 1);
assert!(core.snapshot()?.site_permissions.is_empty());
Ok(())
}
}
+26
View File
@@ -136,6 +136,32 @@ fn export_local_data_command_opens_privacy_security_page() -> Result<(), Box<dyn
Ok(()) Ok(())
} }
#[test]
fn local_data_export_omits_ephemeral_allow_once_state() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let profile_id = core.snapshot()?.active_profile_id;
let origin = SiteOrigin::parse("https://example.com")?;
let feature = SitePermissionFeature::Camera;
core.set_site_permission(origin.clone(), feature, SitePermissionDecision::AllowOnce)?;
let document: serde_json::Value =
serde_json::from_str(&core.export_local_data_package_json()?)?;
let inventory = core.active_profile_local_data_inventory();
assert_eq!(inventory.site_permissions(), 0);
assert_eq!(array_len(&document, "site_permissions"), 0);
assert_eq!(array_len(&document, "site_permission_audit_events"), 1);
let revision = core.site_permission_revision(&profile_id, &origin, feature);
assert!(core.transfer_site_permission_once(&profile_id, &origin, feature, revision)?);
assert!(core.finish_site_permission_once(&profile_id, &origin, feature, revision)?);
let consumed: serde_json::Value =
serde_json::from_str(&core.export_local_data_package_json()?)?;
assert_eq!(array_len(&consumed, "site_permission_audit_events"), 3);
assert_eq!(consumed["site_permission_audit_events"][2]["action"]["kind"], "consumed");
Ok(())
}
fn array_len(document: &serde_json::Value, field: &str) -> usize { fn array_len(document: &serde_json::Value, field: &str) -> usize {
document[field].as_array().map_or(0, Vec::len) document[field].as_array().map_or(0, Vec::len)
} }
+10 -1
View File
@@ -91,8 +91,17 @@ fn clear_active_profile_site_data_reports_removed_counts() -> Result<(), Box<dyn
core.set_site_permission( core.set_site_permission(
origin.clone(), origin.clone(),
SitePermissionFeature::Camera, SitePermissionFeature::Camera,
SitePermissionDecision::AllowAlways, SitePermissionDecision::AllowOnce,
)?; )?;
let profile_id = core.snapshot()?.active_profile_id;
let revision =
core.site_permission_revision(&profile_id, &origin, SitePermissionFeature::Camera);
assert!(core.transfer_site_permission_once(
&profile_id,
&origin,
SitePermissionFeature::Camera,
revision,
)?);
core.set_site_permission( core.set_site_permission(
origin, origin,
SitePermissionFeature::Notifications, SitePermissionFeature::Notifications,
@@ -140,6 +140,73 @@ fn clear_site_permissions_without_entries_is_empty_change() -> Result<(), Box<dy
Ok(()) Ok(())
} }
#[test]
fn allow_once_transfer_and_finish_require_the_token_revision() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let snapshot = core.snapshot()?;
let profile_id = snapshot.active_profile_id;
let origin = SiteOrigin::parse("https://example.com")?;
let feature = SitePermissionFeature::Camera;
core.set_site_permission(origin.clone(), feature, SitePermissionDecision::AllowOnce)?;
let revision = core.site_permission_revision(&profile_id, &origin, feature);
assert!(!core.transfer_site_permission_once(&profile_id, &origin, feature, revision + 1,)?);
assert!(core.transfer_site_permission_once(&profile_id, &origin, feature, revision)?);
let snapshot = core.snapshot()?;
assert_eq!(snapshot.site_permissions.len(), 1);
assert_eq!(
snapshot.site_permission_audit_events.last().map(|event| event.action()),
Some(&SitePermissionAuditAction::Transferred),
);
assert!(!core.finish_site_permission_once(&profile_id, &origin, feature, revision + 1)?);
assert!(core.finish_site_permission_once(&profile_id, &origin, feature, revision)?);
let snapshot = core.snapshot()?;
assert!(snapshot.site_permissions.is_empty());
assert_eq!(
snapshot.site_permission_audit_events.last().map(|event| event.action()),
Some(&SitePermissionAuditAction::Consumed),
);
Ok(())
}
#[test]
fn clear_site_permissions_revokes_transferred_allow_once() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let profile_id = core.snapshot()?.active_profile_id;
let origin = SiteOrigin::parse("https://example.com")?;
let feature = SitePermissionFeature::Camera;
core.set_site_permission(origin.clone(), feature, SitePermissionDecision::AllowOnce)?;
let revision = core.site_permission_revision(&profile_id, &origin, feature);
assert!(core.transfer_site_permission_once(&profile_id, &origin, feature, revision)?);
assert_eq!(core.clear_active_profile_site_permissions()?, 1);
let snapshot = core.snapshot()?;
assert!(snapshot.site_permissions.is_empty());
assert_eq!(
snapshot.site_permission_audit_events.last().map(|event| event.action()),
Some(&SitePermissionAuditAction::Revoked),
);
Ok(())
}
#[test]
fn revoke_then_readd_advances_permission_revision() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let profile_id = core.snapshot()?.active_profile_id;
let origin = SiteOrigin::parse("https://example.com")?;
let feature = SitePermissionFeature::Location;
core.set_site_permission(origin.clone(), feature, SitePermissionDecision::AllowOnce)?;
let first_revision = core.site_permission_revision(&profile_id, &origin, feature);
core.revoke_site_permission(&origin, feature)?;
core.set_site_permission(origin.clone(), feature, SitePermissionDecision::AllowOnce)?;
assert!(core.site_permission_revision(&profile_id, &origin, feature) > first_revision);
Ok(())
}
#[test] #[test]
fn site_settings_command_opens_active_origin() -> Result<(), Box<dyn Error>> { fn site_settings_command_opens_active_origin() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
@@ -98,3 +98,25 @@ fn sync_snapshot_omits_paused_site_permissions() -> Result<(), Box<dyn Error>> {
assert!(snapshot.site_permissions.is_empty()); assert!(snapshot.site_permissions.is_empty());
Ok(()) Ok(())
} }
#[test]
fn sync_snapshot_omits_allow_once_permissions() -> Result<(), Box<dyn Error>> {
let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let source_home_tab_id = source.snapshot()?.active_tab_id;
source.set_tab_sync_enabled(&source_home_tab_id, false)?;
source.set_site_permission(
SiteOrigin::parse("https://example.com")?,
SitePermissionFeature::Camera,
SitePermissionDecision::AllowOnce,
)?;
let bytes = source.build_sync_snapshot_bytes()?;
let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let summary = target.apply_sync_snapshot_bytes(&bytes)?;
assert_eq!(summary.imported(), 0);
assert_eq!(summary.updated(), 0);
assert_eq!(summary.skipped(), 0);
assert!(target.snapshot()?.site_permissions.is_empty());
Ok(())
}
+2
View File
@@ -46,6 +46,8 @@ pub struct SitePermissionEntry {
pub enum SitePermissionAuditAction { pub enum SitePermissionAuditAction {
Set(SitePermissionDecision), Set(SitePermissionDecision),
Revoked, Revoked,
Transferred,
Consumed,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -74,6 +74,8 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
request, request,
) )
}); });
let outcome = outcome
.map(|outcome| outcome.with_permission_consumptions(host.take_consumed_permissions()));
write_outcome(&mut stdout, outcome)?; write_outcome(&mut stdout, outcome)?;
if should_shutdown { if should_shutdown {
break; break;
@@ -126,6 +128,7 @@ fn handle_request(
hover_x, hover_x,
hover_y, hover_y,
typed_text, typed_text,
site_permission_generation,
site_permissions, site_permissions,
ready_surface_ids, ready_surface_ids,
pending_surface_ids, pending_surface_ids,
@@ -138,7 +141,13 @@ fn handle_request(
let session = let session =
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?; ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)?; apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)?;
apply_permissions(host, session, &profile, site_permissions)?; apply_permissions(
host,
session,
&profile,
site_permission_generation,
site_permissions,
)?;
if session.requested_url != url.as_str() { if session.requested_url != url.as_str() {
let servo_current_url = let servo_current_url =
host.snapshot(&session.webview_id)?.url().map(str::to_string); host.snapshot(&session.webview_id)?.url().map(str::to_string);
@@ -13,7 +13,9 @@ pub(super) fn write_outcome(
if let Some(frame) = outcome.frame.as_ref() if let Some(frame) = outcome.frame.as_ref()
&& let Err(error) = validate_frame(frame.width(), frame.height(), frame.rgba_bytes().len()) && let Err(error) = validate_frame(frame.width(), frame.height(), frame.rgba_bytes().len())
{ {
let consumptions = std::mem::take(&mut outcome.response.permission_consumptions);
outcome = LiveOutcome::error(error.to_string()); outcome = LiveOutcome::error(error.to_string());
outcome.response.permission_consumptions = consumptions;
} }
serde_json::to_writer(&mut *stdout, &outcome.response)?; serde_json::to_writer(&mut *stdout, &outcome.response)?;
@@ -39,11 +41,12 @@ mod tests {
#[test] #[test]
fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> { fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> {
let profile_id = ely_domain::ProfileId::new();
let frame = ely_servo_host::RenderedFrame::from_rgba_bytes(2, 2, vec![0; 4]); let frame = ely_servo_host::RenderedFrame::from_rgba_bytes(2, 2, vec![0; 4]);
let snapshot = ely_servo_host::WebViewSnapshot::new( let snapshot = ely_servo_host::WebViewSnapshot::new(
ely_domain::WebViewId::new(), ely_domain::WebViewId::new(),
ely_domain::TabId::new(), ely_domain::TabId::new(),
ely_domain::ProfileId::new(), profile_id.clone(),
ely_servo_host::WebViewState::Complete, ely_servo_host::WebViewState::Complete,
None, None,
None, None,
@@ -52,13 +55,23 @@ mod tests {
let report = let report =
super::super::live_protocol::LiveFrameReport::new(&snapshot, &frame, 1.0, true); super::super::live_protocol::LiveFrameReport::new(&snapshot, &frame, 1.0, true);
let mut output = Vec::new(); let mut output = Vec::new();
let outcome = LiveOutcome::frame(report, frame).with_permission_consumptions(vec![
ely_servo_host::ConsumedPermission {
profile_id: profile_id.clone(),
origin: ely_domain::SiteOrigin::parse("https://example.com")?,
feature: ely_domain::SitePermissionFeature::Camera,
grant_revision: 7,
},
]);
write_outcome(&mut output, Ok(LiveOutcome::frame(report, frame)))?; write_outcome(&mut output, Ok(outcome))?;
assert!(output.ends_with(b"\n")); assert!(output.ends_with(b"\n"));
let response: serde_json::Value = serde_json::from_slice(&output)?; let response: serde_json::Value = serde_json::from_slice(&output)?;
assert!(response["error"].as_str().is_some()); assert!(response["error"].as_str().is_some());
assert!(response["frame"].is_null()); assert!(response["frame"].is_null());
assert_eq!(response["permission_consumptions"][0]["profile_id"], profile_id.as_str());
assert_eq!(response["permission_consumptions"][0]["grant_revision"], 7);
Ok(()) Ok(())
} }
} }
@@ -1,7 +1,8 @@
use std::io; use std::io;
use ely_servo_host::{ use ely_servo_host::{
IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState, ConsumedPermission, IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot,
WebViewState,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
@@ -9,7 +10,7 @@ use thiserror::Error;
#[cfg(all(feature = "hardware-render", target_os = "macos"))] #[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::iosurface_mach::IOSurfaceMachError; use super::iosurface_mach::IOSurfaceMachError;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2; pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384; pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024; pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
@@ -47,6 +48,7 @@ pub(super) enum LiveRequest {
hover_y: Option<u32>, hover_y: Option<u32>,
#[serde(default)] #[serde(default)]
typed_text: Option<String>, typed_text: Option<String>,
site_permission_generation: u64,
#[serde(default)] #[serde(default)]
site_permissions: Vec<LiveSitePermission>, site_permissions: Vec<LiveSitePermission>,
#[serde(default)] #[serde(default)]
@@ -79,7 +81,8 @@ const fn default_device_pixel_ratio() -> f32 {
pub(super) struct LiveSitePermission { pub(super) struct LiveSitePermission {
pub(super) origin: String, pub(super) origin: String,
pub(super) feature: String, pub(super) feature: String,
pub(super) decision: String, pub(super) state: String,
pub(super) revision: u64,
} }
pub(super) struct LiveOutcome { pub(super) struct LiveOutcome {
@@ -100,6 +103,15 @@ impl LiveOutcome {
Self { response: LiveResponse::frame(report), frame: Some(frame) } Self { response: LiveResponse::frame(report), frame: Some(frame) }
} }
pub(super) fn with_permission_consumptions(
mut self,
consumptions: Vec<ConsumedPermission>,
) -> Self {
self.response.permission_consumptions =
consumptions.into_iter().map(LivePermissionConsumption::from).collect();
self
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))] #[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) fn surface(report: LiveFrameReport) -> Self { pub(super) fn surface(report: LiveFrameReport) -> Self {
Self { response: LiveResponse::frame(report), frame: None } Self { response: LiveResponse::frame(report), frame: None }
@@ -115,6 +127,27 @@ pub(super) struct LiveResponse {
pub(super) surface_handle: Option<IOSurfaceHandle>, pub(super) surface_handle: Option<IOSurfaceHandle>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub(super) current_surface_id: Option<u64>, pub(super) current_surface_id: Option<u64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(super) permission_consumptions: Vec<LivePermissionConsumption>,
}
#[derive(Debug, Serialize)]
pub(super) struct LivePermissionConsumption {
pub(super) profile_id: String,
pub(super) origin: String,
pub(super) feature: String,
pub(super) grant_revision: u64,
}
impl From<ConsumedPermission> for LivePermissionConsumption {
fn from(consumed: ConsumedPermission) -> Self {
Self {
profile_id: consumed.profile_id.as_str().to_string(),
origin: consumed.origin.as_str().to_string(),
feature: consumed.feature.as_str().to_string(),
grant_revision: consumed.grant_revision,
}
}
} }
impl LiveResponse { impl LiveResponse {
@@ -125,6 +158,7 @@ impl LiveResponse {
frame: None, frame: None,
surface_handle: None, surface_handle: None,
current_surface_id: None, current_surface_id: None,
permission_consumptions: Vec::new(),
} }
} }
@@ -135,6 +169,7 @@ impl LiveResponse {
frame: None, frame: None,
surface_handle: None, surface_handle: None,
current_surface_id: None, current_surface_id: None,
permission_consumptions: Vec::new(),
} }
} }
@@ -145,6 +180,7 @@ impl LiveResponse {
frame: Some(frame), frame: Some(frame),
surface_handle: None, surface_handle: None,
current_surface_id: None, current_surface_id: None,
permission_consumptions: Vec::new(),
} }
} }
} }
@@ -332,7 +368,7 @@ mod tests {
#[test] #[test]
fn ensure_defaults_optional_input_fields() -> Result<(), serde_json::Error> { fn ensure_defaults_optional_input_fields() -> Result<(), serde_json::Error> {
let request = serde_json::from_str::<LiveRequest>( let request = serde_json::from_str::<LiveRequest>(
r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600}"#, r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600,"site_permission_generation":0}"#,
)?; )?;
assert!(matches!( assert!(matches!(
@@ -353,12 +389,21 @@ mod tests {
#[test] #[test]
fn handshake_deserializes_protocol_version() -> Result<(), serde_json::Error> { fn handshake_deserializes_protocol_version() -> Result<(), serde_json::Error> {
let request = let request =
serde_json::from_str::<LiveRequest>(r#"{"type":"handshake","protocol_version":2}"#)?; serde_json::from_str::<LiveRequest>(r#"{"type":"handshake","protocol_version":3}"#)?;
assert!(matches!(request, LiveRequest::Handshake { protocol_version: 2 })); assert!(matches!(request, LiveRequest::Handshake { protocol_version: 3 }));
Ok(()) Ok(())
} }
#[test]
fn site_permission_requires_revision() {
let request = serde_json::from_str::<LiveRequest>(
r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600,"site_permission_generation":0,"site_permissions":[{"origin":"https://example.com","feature":"camera","state":"allow-once"}]}"#,
);
assert!(request.is_err());
}
#[test] #[test]
fn frame_layout_enforces_dimension_and_byte_limits() { fn frame_layout_enforces_dimension_and_byte_limits() {
assert!(matches!( assert!(matches!(
@@ -3,7 +3,8 @@ use std::collections::{HashMap, hash_map::Entry};
use ely_domain::{ProfileId, TabId, validate_zoom_percent}; use ely_domain::{ProfileId, TabId, validate_zoom_percent};
use ely_servo_host::{ use ely_servo_host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest, HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest,
PermissionDecision, PermissionRequest, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost, PermissionDecision, PermissionSnapshotEntry, PermissionSnapshotRequest,
PermissionSnapshotState, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost,
ServoSurfaceSize, SoftwareServoHost, ServoSurfaceSize, SoftwareServoHost,
}; };
@@ -132,22 +133,41 @@ pub(super) fn apply_permissions(
host: &mut SoftwareServoHost, host: &mut SoftwareServoHost,
session: &LiveSession, session: &LiveSession,
profile_id: &ProfileId, profile_id: &ProfileId,
generation: u64,
permissions: Vec<LiveSitePermission>, permissions: Vec<LiveSitePermission>,
) -> Result<(), LiveSidecarError> { ) -> Result<(), LiveSidecarError> {
for permission in permissions { let entries = permissions
host.set_permission( .into_iter()
PermissionRequest { .map(|permission| {
webview_id: session.webview_id.clone(), let state = match permission.state.as_str() {
profile_id: profile_id.clone(), "allow-once" => PermissionSnapshotState::Decision(PermissionDecision::AllowOnce),
"allow-always" => {
PermissionSnapshotState::Decision(PermissionDecision::AllowAlways)
}
"deny-always" => PermissionSnapshotState::Decision(PermissionDecision::DenyAlways),
"transferred-allow-once" => PermissionSnapshotState::TransferredAllowOnce,
value => {
return Err(ely_domain::DomainError::InvalidSitePermissionDecision {
value: value.to_string(),
}
.into());
}
};
Ok(PermissionSnapshotEntry {
origin: ely_domain::SiteOrigin::parse(permission.origin)?, origin: ely_domain::SiteOrigin::parse(permission.origin)?,
feature: ely_domain::SitePermissionFeature::parse(&permission.feature)?, feature: ely_domain::SitePermissionFeature::parse(&permission.feature)?,
}, state,
PermissionDecision::from(ely_domain::SitePermissionDecision::parse( revision: permission.revision,
&permission.decision, })
)?), })
)?; .collect::<Result<Vec<_>, LiveSidecarError>>()?;
} host.replace_permissions(PermissionSnapshotRequest {
Ok(()) webview_id: session.webview_id.clone(),
profile_id: profile_id.clone(),
generation,
entries,
})
.map_err(LiveSidecarError::from)
} }
pub(super) struct LiveInput { pub(super) struct LiveInput {
+8 -1
View File
@@ -1,4 +1,4 @@
use ely_domain::{ProfileId, WebViewId}; use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, WebViewId};
use thiserror::Error; use thiserror::Error;
#[derive(Clone, Debug, Error, Eq, PartialEq)] #[derive(Clone, Debug, Error, Eq, PartialEq)]
@@ -15,6 +15,13 @@ pub enum ServoHostError {
#[error("permission profile mismatch for {webview_id}: expected {expected}, got {actual}")] #[error("permission profile mismatch for {webview_id}: expected {expected}, got {actual}")]
PermissionProfileMismatch { webview_id: WebViewId, expected: ProfileId, actual: ProfileId }, PermissionProfileMismatch { webview_id: WebViewId, expected: ProfileId, actual: ProfileId },
#[error("duplicate permission snapshot entry for {profile_id} {origin:?} {feature:?}")]
DuplicatePermissionSnapshotEntry {
profile_id: ProfileId,
origin: SiteOrigin,
feature: SitePermissionFeature,
},
#[error("servo runtime is already started in this process")] #[error("servo runtime is already started in this process")]
RuntimeAlreadyStarted, RuntimeAlreadyStarted,
+35 -12
View File
@@ -314,21 +314,43 @@ pub struct KeyboardTextRequest {
pub text: String, pub text: String,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PermissionRequest {
pub webview_id: WebViewId,
pub profile_id: ProfileId,
pub origin: SiteOrigin,
pub feature: SitePermissionFeature,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PermissionDecision { pub enum PermissionDecision {
AllowOnce, AllowOnce,
AllowAlways, AllowAlways,
DenyAlways, DenyAlways,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PermissionSnapshotState {
Decision(PermissionDecision),
TransferredAllowOnce,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PermissionSnapshotEntry {
pub origin: SiteOrigin,
pub feature: SitePermissionFeature,
pub state: PermissionSnapshotState,
pub revision: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PermissionSnapshotRequest {
pub webview_id: WebViewId,
pub profile_id: ProfileId,
pub generation: u64,
pub entries: Vec<PermissionSnapshotEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConsumedPermission {
pub profile_id: ProfileId,
pub origin: SiteOrigin,
pub feature: SitePermissionFeature,
pub grant_revision: u64,
}
impl From<SitePermissionDecision> for PermissionDecision { impl From<SitePermissionDecision> for PermissionDecision {
fn from(decision: SitePermissionDecision) -> Self { fn from(decision: SitePermissionDecision) -> Self {
match decision { match decision {
@@ -366,12 +388,13 @@ pub trait ServoHost {
fn type_text(&mut self, request: KeyboardTextRequest) -> Result<(), ServoHostError>; fn type_text(&mut self, request: KeyboardTextRequest) -> Result<(), ServoHostError>;
fn set_permission( fn replace_permissions(
&mut self, &mut self,
request: PermissionRequest, request: PermissionSnapshotRequest,
decision: PermissionDecision,
) -> Result<(), ServoHostError>; ) -> Result<(), ServoHostError>;
fn take_consumed_permissions(&mut self) -> Vec<ConsumedPermission>;
fn state(&self, webview_id: &WebViewId) -> Result<WebViewState, ServoHostError>; fn state(&self, webview_id: &WebViewId) -> Result<WebViewState, ServoHostError>;
fn snapshot(&self, webview_id: &WebViewId) -> Result<WebViewSnapshot, ServoHostError>; fn snapshot(&self, webview_id: &WebViewId) -> Result<WebViewSnapshot, ServoHostError>;
+3 -2
View File
@@ -20,8 +20,9 @@ pub use error::ServoHostError;
#[cfg(feature = "hardware-render")] #[cfg(feature = "hardware-render")]
pub use hardware_rendering_context::HardwareOffscreenContext; pub use hardware_rendering_context::HardwareOffscreenContext;
pub use host::{ pub use host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest, ConsumedPermission, HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest,
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision,
PermissionSnapshotEntry, PermissionSnapshotRequest, PermissionSnapshotState, RenderedFrame,
RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest, RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest,
WebViewSnapshot, WebViewSnapshotPending, WebViewState, WebViewSnapshot, WebViewSnapshotPending, WebViewState,
}; };
+36 -34
View File
@@ -1,6 +1,5 @@
use std::{ use std::{
cell::RefCell, collections::{HashMap, HashSet},
collections::HashMap,
path::PathBuf, path::PathBuf,
rc::Rc, rc::Rc,
sync::{ sync::{
@@ -13,8 +12,8 @@ use dpi::PhysicalSize;
use ely_domain::{ProfileId, TabId, WebViewId}; use ely_domain::{ProfileId, TabId, WebViewId};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use servo::{ use servo::{
DevicePoint, DeviceVector2D, Opts, Preferences, Scroll, Servo, ServoBuilder, WebViewBuilder, DevicePoint, DeviceVector2D, Opts, Scroll, Servo, ServoBuilder, WebViewBuilder, WebViewPoint,
WebViewPoint, WebViewVector, WebViewVector,
}; };
#[path = "runtime_context.rs"] #[path = "runtime_context.rs"]
@@ -24,20 +23,25 @@ mod runtime_context;
mod runtime_hardware; mod runtime_hardware;
#[path = "runtime_paint.rs"] #[path = "runtime_paint.rs"]
mod runtime_paint; mod runtime_paint;
#[path = "runtime_preferences.rs"]
mod runtime_preferences;
use runtime_context::hidpi_scale_from_factor; use runtime_context::hidpi_scale_from_factor;
pub use runtime_context::{RenderingContextKind, ServoSurfaceSize}; pub use runtime_context::{RenderingContextKind, ServoSurfaceSize};
use runtime_preferences::ely_servo_preferences;
use url::Url; use url::Url;
use crate::{ use crate::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest, ConsumedPermission, HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest,
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest,
ResizeRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest, WebViewSnapshot, PermissionSnapshotRequest, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost,
WebViewState, ServoHostError, TouchTapRequest, WebViewSnapshot, WebViewState,
runtime_input::{ runtime_input::{
send_keyboard_text, send_mouse_click, send_mouse_drag, send_mouse_hover, send_touch_tap, send_keyboard_text, send_mouse_click, send_mouse_drag, send_mouse_hover, send_touch_tap,
}, },
runtime_permissions::{PermissionStore, set_permission_decision}, runtime_permissions::{
PermissionStore, drain_consumed_permissions, replace_permission_decisions,
},
runtime_waker::ServoWakeFlag, runtime_waker::ServoWakeFlag,
runtime_webview::{HostWebView, HostWebViewDelegate}, runtime_webview::{HostWebView, HostWebViewDelegate},
}; };
@@ -135,7 +139,7 @@ impl SoftwareServoHost {
default_surface_size: size, default_surface_size: size,
rendering_context_kind, rendering_context_kind,
webviews: HashMap::new(), webviews: HashMap::new(),
permissions: Rc::new(RefCell::new(HashMap::new())), permissions: PermissionStore::default(),
wake_requested, wake_requested,
last_rendered_frame: None, last_rendered_frame: None,
}) })
@@ -166,25 +170,6 @@ fn install_rustls_provider() {
}); });
} }
fn ely_servo_preferences() -> Preferences {
// `Preferences::default()` is Servo's conservative *library* default: it ships
// modern-layout features off even though servo-layout/Stylo implement them and
// Servo's own servoshell browser enables them. `Servo::new` forwards these to
// Stylo via `prefs::set`, so an embedder building a browser must turn them on or
// pages render wrong:
// - `layout.grid.enabled` off => Stylo blockifies `display: grid`, collapsing
// grid layouts into one stacked column (the "broken" modern-site render).
// - `layout.variable_fonts.enabled` off => `font-variation-settings` and
// variable weight/width axes are ignored, so a variable font only ever
// renders its default instance (every requested weight looks identical).
Preferences {
dom_intersection_observer_enabled: true,
layout_grid_enabled: true,
layout_variable_fonts_enabled: true,
..Preferences::default()
}
}
impl ServoHost for SoftwareServoHost { impl ServoHost for SoftwareServoHost {
fn create_webview( fn create_webview(
&mut self, &mut self,
@@ -332,10 +317,9 @@ impl ServoHost for SoftwareServoHost {
Ok(()) Ok(())
} }
fn set_permission( fn replace_permissions(
&mut self, &mut self,
request: PermissionRequest, request: PermissionSnapshotRequest,
decision: PermissionDecision,
) -> Result<(), ServoHostError> { ) -> Result<(), ServoHostError> {
let webview = self let webview = self
.webviews .webviews
@@ -348,11 +332,29 @@ impl ServoHost for SoftwareServoHost {
actual: request.profile_id, actual: request.profile_id,
}); });
} }
let mut keys = HashSet::new();
set_permission_decision(&self.permissions, request, decision); for entry in &request.entries {
if !keys.insert((entry.origin.clone(), entry.feature)) {
return Err(ServoHostError::DuplicatePermissionSnapshotEntry {
profile_id: request.profile_id,
origin: entry.origin.clone(),
feature: entry.feature,
});
}
}
replace_permission_decisions(
&self.permissions,
&request.profile_id,
request.generation,
request.entries,
);
Ok(()) Ok(())
} }
fn take_consumed_permissions(&mut self) -> Vec<ConsumedPermission> {
drain_consumed_permissions(&self.permissions)
}
fn state(&self, webview_id: &WebViewId) -> Result<WebViewState, ServoHostError> { fn state(&self, webview_id: &WebViewId) -> Result<WebViewState, ServoHostError> {
Ok(self.webview(webview_id)?.state()) Ok(self.webview(webview_id)?.state())
} }
+2 -4
View File
@@ -53,16 +53,14 @@ fn consume_pending_then_paint(delegate: &HostWebViewDelegate, paint: impl FnOnce
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use ely_domain::ProfileId; use ely_domain::ProfileId;
use super::{HostWebViewDelegate, consume_pending_then_paint}; use super::{HostWebViewDelegate, consume_pending_then_paint};
use crate::runtime_permissions::PermissionStore;
#[test] #[test]
fn frame_arriving_during_paint_remains_pending() { fn frame_arriving_during_paint_remains_pending() {
let delegate = let delegate = HostWebViewDelegate::new(ProfileId::new(), PermissionStore::default());
HostWebViewDelegate::new(ProfileId::new(), Rc::new(RefCell::new(HashMap::new())));
delegate.mark_frame_ready(); delegate.mark_frame_ready();
consume_pending_then_paint(&delegate, || delegate.mark_frame_ready()); consume_pending_then_paint(&delegate, || delegate.mark_frame_ready());
+100 -130
View File
@@ -3,9 +3,25 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc};
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature}; use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature};
use servo::WebView; use servo::WebView;
use crate::{PermissionDecision, PermissionRequest}; use crate::{
ConsumedPermission, PermissionDecision, PermissionSnapshotEntry, PermissionSnapshotState,
};
pub(super) type PermissionStore = Rc<RefCell<HashMap<PermissionKey, PermissionDecision>>>; pub(super) type PermissionStore = Rc<RefCell<PermissionState>>;
#[derive(Default)]
pub(super) struct PermissionState {
entries: HashMap<PermissionKey, StoredPermission>,
generations: HashMap<ProfileId, u64>,
consumed: Vec<ConsumedPermission>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct StoredPermission {
decision: PermissionDecision,
grant_revision: u64,
consumed: bool,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(super) struct PermissionKey { pub(super) struct PermissionKey {
@@ -20,14 +36,64 @@ impl PermissionKey {
} }
} }
pub(super) fn set_permission_decision( pub(super) fn replace_permission_decisions(
permissions: &PermissionStore, permissions: &PermissionStore,
request: PermissionRequest, profile_id: &ProfileId,
decision: PermissionDecision, generation: u64,
entries: Vec<PermissionSnapshotEntry>,
) { ) {
permissions let mut state = permissions.borrow_mut();
.borrow_mut() if state.generations.get(profile_id).is_some_and(|current| generation < *current) {
.insert(PermissionKey::new(request.profile_id, request.origin, request.feature), decision); return;
}
let mut freshly_consumed = Vec::new();
let replacements = entries
.into_iter()
.map(|entry| {
let key = PermissionKey::new(profile_id.clone(), entry.origin, entry.feature);
let stored = match entry.state {
PermissionSnapshotState::Decision(decision) => {
let consumed = decision == PermissionDecision::AllowOnce
&& state.entries.get(&key).is_some_and(|current| {
current.decision == PermissionDecision::AllowOnce
&& current.grant_revision == entry.revision
&& current.consumed
});
StoredPermission { decision, grant_revision: entry.revision, consumed }
}
PermissionSnapshotState::TransferredAllowOnce => match state.entries.get(&key) {
Some(current)
if current.decision == PermissionDecision::AllowOnce
&& current.grant_revision == entry.revision =>
{
StoredPermission {
decision: PermissionDecision::AllowOnce,
grant_revision: current.grant_revision,
consumed: current.consumed,
}
}
_ => {
freshly_consumed.push(ConsumedPermission {
profile_id: profile_id.clone(),
origin: key.origin.clone(),
feature: key.feature,
grant_revision: entry.revision,
});
StoredPermission {
decision: PermissionDecision::AllowOnce,
grant_revision: entry.revision,
consumed: true,
}
}
},
};
(key, stored)
})
.collect::<Vec<_>>();
state.entries.retain(|key, _| &key.profile_id != profile_id);
state.entries.extend(replacements);
state.consumed.extend(freshly_consumed);
state.generations.insert(profile_id.clone(), generation);
} }
pub(super) fn permission_decision_for_webview( pub(super) fn permission_decision_for_webview(
@@ -49,11 +115,31 @@ fn take_permission_decision(
feature: SitePermissionFeature, feature: SitePermissionFeature,
) -> Option<PermissionDecision> { ) -> Option<PermissionDecision> {
let key = PermissionKey::new(profile_id.clone(), origin, feature); let key = PermissionKey::new(profile_id.clone(), origin, feature);
let mut permissions = permissions.borrow_mut(); let mut state = permissions.borrow_mut();
match permissions.get(&key).cloned() { let (decision, grant_revision) = {
Some(PermissionDecision::AllowOnce) => permissions.remove(&key), let stored = state.entries.get_mut(&key)?;
decision => decision, match stored.decision {
PermissionDecision::AllowOnce if stored.consumed => return None,
PermissionDecision::AllowOnce => {
stored.consumed = true;
(PermissionDecision::AllowOnce, Some(stored.grant_revision))
} }
decision => (decision, None),
}
};
if let Some(grant_revision) = grant_revision {
state.consumed.push(ConsumedPermission {
profile_id: key.profile_id,
origin: key.origin,
feature: key.feature,
grant_revision,
});
}
Some(decision)
}
pub(super) fn drain_consumed_permissions(permissions: &PermissionStore) -> Vec<ConsumedPermission> {
std::mem::take(&mut permissions.borrow_mut().consumed)
} }
fn site_origin_for_webview(webview: &WebView, fallback_url: Option<String>) -> Option<SiteOrigin> { fn site_origin_for_webview(webview: &WebView, fallback_url: Option<String>) -> Option<SiteOrigin> {
@@ -87,121 +173,5 @@ fn site_permission_feature_for_servo(
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "runtime_permissions_tests.rs"]
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature}; mod tests;
use crate::{PermissionDecision, PermissionRequest};
use super::{
PermissionStore, set_permission_decision, site_permission_feature_for_servo,
take_permission_decision,
};
#[test]
fn keeps_disabled_servo_permissions_out_of_site_settings() {
for feature in [
servo::PermissionFeature::ScreenWakeLock(servo::WakeLockType::Screen),
servo::PermissionFeature::Gamepad,
] {
assert_eq!(site_permission_feature_for_servo(feature), None);
}
}
#[test]
fn allow_once_is_consumed_after_one_matching_origin_request()
-> Result<(), Box<dyn std::error::Error>> {
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com/path")?;
set_permission_decision(
&permissions,
PermissionRequest {
webview_id: ely_domain::WebViewId::new(),
profile_id: profile_id.clone(),
origin: origin.clone(),
feature: SitePermissionFeature::Camera,
},
PermissionDecision::AllowOnce,
);
assert_eq!(
take_permission_decision(
&permissions,
&profile_id,
origin.clone(),
SitePermissionFeature::Camera,
),
Some(PermissionDecision::AllowOnce)
);
assert_eq!(
take_permission_decision(
&permissions,
&profile_id,
origin,
SitePermissionFeature::Camera
),
None
);
Ok(())
}
#[test]
fn allow_always_stays_scoped_to_profile_origin_and_feature()
-> Result<(), Box<dyn std::error::Error>> {
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let other_profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
let other_origin = SiteOrigin::parse("https://example.org")?;
set_permission_decision(
&permissions,
PermissionRequest {
webview_id: ely_domain::WebViewId::new(),
profile_id: profile_id.clone(),
origin: origin.clone(),
feature: SitePermissionFeature::Notifications,
},
PermissionDecision::AllowAlways,
);
assert_eq!(
take_permission_decision(
&permissions,
&profile_id,
origin.clone(),
SitePermissionFeature::Notifications,
),
Some(PermissionDecision::AllowAlways)
);
assert_eq!(
take_permission_decision(
&permissions,
&other_profile_id,
origin,
SitePermissionFeature::Notifications,
),
None
);
assert_eq!(
take_permission_decision(
&permissions,
&profile_id,
other_origin,
SitePermissionFeature::Notifications,
),
None
);
assert_eq!(
take_permission_decision(
&permissions,
&profile_id,
SiteOrigin::parse("https://example.com")?,
SitePermissionFeature::Camera,
),
None
);
Ok(())
}
}
@@ -0,0 +1,349 @@
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature};
use crate::{
ConsumedPermission, PermissionDecision, PermissionSnapshotEntry, PermissionSnapshotState,
runtime_permissions::{
PermissionStore, drain_consumed_permissions, replace_permission_decisions,
site_permission_feature_for_servo, take_permission_decision,
},
};
#[test]
fn keeps_disabled_servo_permissions_out_of_site_settings() {
for feature in [
servo::PermissionFeature::ScreenWakeLock(servo::WakeLockType::Screen),
servo::PermissionFeature::Gamepad,
] {
assert_eq!(site_permission_feature_for_servo(feature), None);
}
}
#[test]
fn allow_once_is_consumed_after_one_matching_request() -> Result<(), Box<dyn std::error::Error>> {
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
replace(
&permissions,
&profile_id,
1,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowOnce,
1,
)],
);
assert_eq!(take(&permissions, &profile_id, &origin), Some(PermissionDecision::AllowOnce));
assert_eq!(take(&permissions, &profile_id, &origin), None);
assert_eq!(
drain_consumed_permissions(&permissions),
vec![ConsumedPermission {
profile_id,
origin,
feature: SitePermissionFeature::Camera,
grant_revision: 1,
}],
);
Ok(())
}
#[test]
fn durable_permissions_stay_scoped_to_profile_origin_and_feature()
-> Result<(), Box<dyn std::error::Error>> {
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let other_profile = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
replace(
&permissions,
&profile_id,
1,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowAlways,
1,
)],
);
assert_eq!(take(&permissions, &profile_id, &origin), Some(PermissionDecision::AllowAlways));
assert_eq!(take(&permissions, &other_profile, &origin), None);
assert_eq!(
take_permission_decision(
&permissions,
&profile_id,
SiteOrigin::parse("https://other.test")?,
SitePermissionFeature::Camera,
),
None,
);
Ok(())
}
#[test]
fn authoritative_snapshot_removes_missing_durable_entries() -> Result<(), Box<dyn std::error::Error>>
{
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let other_profile = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
let other_origin = SiteOrigin::parse("https://other.test")?;
replace(
&permissions,
&profile_id,
1,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowAlways,
1,
)],
);
replace(
&permissions,
&other_profile,
1,
vec![decision(
other_origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::DenyAlways,
1,
)],
);
replace(&permissions, &profile_id, 2, Vec::new());
assert_eq!(take(&permissions, &profile_id, &origin), None);
assert_eq!(
take(&permissions, &other_profile, &other_origin),
Some(PermissionDecision::DenyAlways)
);
Ok(())
}
#[test]
fn transferred_marker_preserves_existing_token_and_fails_closed_for_fresh_host()
-> Result<(), Box<dyn std::error::Error>> {
let profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
let marker = state(
origin.clone(),
SitePermissionFeature::Camera,
PermissionSnapshotState::TransferredAllowOnce,
1,
);
let live = PermissionStore::default();
replace(
&live,
&profile_id,
1,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowOnce,
1,
)],
);
replace(&live, &profile_id, 2, vec![marker.clone()]);
let restarted = PermissionStore::default();
replace(&restarted, &profile_id, 2, vec![marker]);
assert!(drain_consumed_permissions(&live).is_empty());
assert_eq!(
drain_consumed_permissions(&restarted),
vec![ConsumedPermission {
profile_id: profile_id.clone(),
origin: origin.clone(),
feature: SitePermissionFeature::Camera,
grant_revision: 1,
}],
);
assert_eq!(take(&live, &profile_id, &origin), Some(PermissionDecision::AllowOnce));
assert_eq!(take(&restarted, &profile_id, &origin), None);
assert_eq!(
drain_consumed_permissions(&live),
vec![ConsumedPermission {
profile_id,
origin,
feature: SitePermissionFeature::Camera,
grant_revision: 1,
}],
);
Ok(())
}
#[test]
fn empty_snapshot_removes_transferred_allow_once() -> Result<(), Box<dyn std::error::Error>> {
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
replace(
&permissions,
&profile_id,
1,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowOnce,
1,
)],
);
replace(
&permissions,
&profile_id,
2,
vec![state(
origin.clone(),
SitePermissionFeature::Camera,
PermissionSnapshotState::TransferredAllowOnce,
1,
)],
);
replace(&permissions, &profile_id, 3, Vec::new());
assert_eq!(take(&permissions, &profile_id, &origin), None);
Ok(())
}
#[test]
fn mismatched_transferred_revision_consumes_the_replacement_token()
-> Result<(), Box<dyn std::error::Error>> {
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
replace(
&permissions,
&profile_id,
1,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowOnce,
1,
)],
);
replace(
&permissions,
&profile_id,
2,
vec![state(
origin.clone(),
SitePermissionFeature::Camera,
PermissionSnapshotState::TransferredAllowOnce,
2,
)],
);
assert_eq!(take(&permissions, &profile_id, &origin), None);
assert_eq!(
drain_consumed_permissions(&permissions),
vec![ConsumedPermission {
profile_id,
origin,
feature: SitePermissionFeature::Camera,
grant_revision: 2,
}],
);
Ok(())
}
#[test]
fn stale_profile_generation_cannot_restore_revoked_permission()
-> Result<(), Box<dyn std::error::Error>> {
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
let old =
decision(origin.clone(), SitePermissionFeature::Camera, PermissionDecision::AllowAlways, 1);
replace(&permissions, &profile_id, 1, vec![old.clone()]);
replace(&permissions, &profile_id, 2, Vec::new());
replace(&permissions, &profile_id, 1, vec![old]);
assert_eq!(take(&permissions, &profile_id, &origin), None);
Ok(())
}
#[test]
fn same_revision_stays_consumed_and_new_revision_rearms_token()
-> Result<(), Box<dyn std::error::Error>> {
let permissions = PermissionStore::default();
let profile_id = ProfileId::new();
let origin = SiteOrigin::parse("https://example.com")?;
replace(
&permissions,
&profile_id,
1,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowOnce,
1,
)],
);
assert_eq!(take(&permissions, &profile_id, &origin), Some(PermissionDecision::AllowOnce));
replace(
&permissions,
&profile_id,
2,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowOnce,
1,
)],
);
assert_eq!(take(&permissions, &profile_id, &origin), None);
replace(
&permissions,
&profile_id,
3,
vec![decision(
origin.clone(),
SitePermissionFeature::Camera,
PermissionDecision::AllowOnce,
3,
)],
);
assert_eq!(take(&permissions, &profile_id, &origin), Some(PermissionDecision::AllowOnce));
Ok(())
}
fn replace(
permissions: &PermissionStore,
profile_id: &ProfileId,
generation: u64,
entries: Vec<PermissionSnapshotEntry>,
) {
replace_permission_decisions(permissions, profile_id, generation, entries);
}
fn decision(
origin: SiteOrigin,
feature: SitePermissionFeature,
decision: PermissionDecision,
revision: u64,
) -> PermissionSnapshotEntry {
state(origin, feature, PermissionSnapshotState::Decision(decision), revision)
}
fn state(
origin: SiteOrigin,
feature: SitePermissionFeature,
state: PermissionSnapshotState,
revision: u64,
) -> PermissionSnapshotEntry {
PermissionSnapshotEntry { origin, feature, state, revision }
}
fn take(
permissions: &PermissionStore,
profile_id: &ProfileId,
origin: &SiteOrigin,
) -> Option<PermissionDecision> {
take_permission_decision(permissions, profile_id, origin.clone(), SitePermissionFeature::Camera)
}
@@ -0,0 +1,49 @@
use servo::Preferences;
pub(super) fn ely_servo_preferences() -> Preferences {
// Servo's current permission request omits the requesting principal. WebRTC,
// Clipboard, and Bluetooth also contain paths that bypass the embedder broker.
// Keep every affected API explicitly gated until those contracts are complete.
Preferences {
dom_async_clipboard_enabled: false,
dom_bluetooth_enabled: false,
dom_geolocation_enabled: false,
dom_notification_enabled: false,
dom_permissions_enabled: false,
dom_storage_manager_api_enabled: false,
dom_wakelock_enabled: false,
dom_webrtc_enabled: false,
dom_intersection_observer_enabled: true,
layout_grid_enabled: true,
layout_variable_fonts_enabled: true,
..Preferences::default()
}
}
#[cfg(test)]
mod tests {
use super::ely_servo_preferences;
#[test]
fn unsafe_permission_surfaces_stay_gated() {
let preferences = ely_servo_preferences();
assert!(!preferences.dom_async_clipboard_enabled);
assert!(!preferences.dom_bluetooth_enabled);
assert!(!preferences.dom_geolocation_enabled);
assert!(!preferences.dom_notification_enabled);
assert!(!preferences.dom_permissions_enabled);
assert!(!preferences.dom_storage_manager_api_enabled);
assert!(!preferences.dom_wakelock_enabled);
assert!(!preferences.dom_webrtc_enabled);
}
#[test]
fn required_rendering_features_stay_enabled() {
let preferences = ely_servo_preferences();
assert!(preferences.dom_intersection_observer_enabled);
assert!(preferences.layout_grid_enabled);
assert!(preferences.layout_variable_fonts_enabled);
}
}
+2 -5
View File
@@ -184,16 +184,13 @@ impl WebViewDelegate for HostWebViewDelegate {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use ely_domain::ProfileId; use ely_domain::ProfileId;
use super::HostWebViewDelegate; use super::{HostWebViewDelegate, PermissionStore};
#[test] #[test]
fn metadata_changes_are_separate_from_pending_frame() { fn metadata_changes_are_separate_from_pending_frame() {
let delegate = let delegate = HostWebViewDelegate::new(ProfileId::new(), PermissionStore::default());
HostWebViewDelegate::new(ProfileId::new(), Rc::new(RefCell::new(HashMap::new())));
assert!(!delegate.has_pending_frame()); assert!(!delegate.has_pending_frame());
assert!(!delegate.has_pending_metadata()); assert!(!delegate.has_pending_metadata());
+68
View File
@@ -156,6 +156,74 @@ fn live_sidecar_rejects_oversized_frame_dimensions() -> Result<(), Box<dyn Error
Ok(()) Ok(())
} }
#[test]
fn live_sidecar_rejects_duplicate_permission_snapshot_entries() -> Result<(), Box<dyn Error>> {
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let mut sidecar = Sidecar::spawn(root.path())?;
let mut ensure = ensure_request(&tab_id, &profile_id, "about:blank");
ensure["site_permissions"] = json!([
{
"origin": "https://example.com",
"feature": "camera",
"state": "allow-always",
"revision": 1,
},
{
"origin": "https://example.com",
"feature": "camera",
"state": "deny-always",
"revision": 2,
},
]);
let response = sidecar.exchange(&ensure)?;
assert!(response.error.as_deref().is_some_and(|error| error.contains("duplicate permission")));
sidecar.shutdown()?;
Ok(())
}
#[test]
fn live_sidecar_accepts_permission_snapshot_lifecycle() -> Result<(), Box<dyn Error>> {
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let mut sidecar = Sidecar::spawn(root.path())?;
let mut ensure = ensure_request(&tab_id, &profile_id, "about:blank");
for (generation, state, revision) in [(1, "allow-once", 1), (2, "transferred-allow-once", 2)] {
ensure["site_permission_generation"] = json!(generation);
ensure["site_permissions"] = json!([{
"origin": "https://example.com",
"feature": "camera",
"state": state,
"revision": revision,
}]);
let response = sidecar.exchange(&ensure)?;
assert!(response.error.is_none(), "state={state} error={:?}", response.error);
}
ensure["site_permission_generation"] = json!(3);
ensure["site_permissions"] = json!([]);
let response = sidecar.exchange(&ensure)?;
assert!(response.error.is_none(), "empty snapshot error={:?}", response.error);
ensure["site_permission_generation"] = json!(1);
ensure["site_permissions"] = json!([{
"origin": "https://example.com",
"feature": "camera",
"state": "allow-once",
"revision": 1,
}]);
let response = sidecar.exchange(&ensure)?;
assert!(response.error.is_none(), "stale snapshot error={:?}", response.error);
sidecar.shutdown()?;
Ok(())
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))] #[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[test] #[test]
fn hardware_sidecar_transfers_a_real_iosurface_mach_descriptor() -> Result<(), Box<dyn Error>> { fn hardware_sidecar_transfers_a_real_iosurface_mach_descriptor() -> Result<(), Box<dyn Error>> {
@@ -19,7 +19,7 @@ use serde_json::{Value, json};
pub(super) const WIDTH: u32 = 360; pub(super) const WIDTH: u32 = 360;
pub(super) const HEIGHT: u32 = 240; pub(super) const HEIGHT: u32 = 240;
pub(super) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20); pub(super) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20);
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2; pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384; pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024; const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
@@ -33,6 +33,7 @@ pub(super) fn ensure_request(tab_id: &TabId, profile_id: &ProfileId, url: &str)
"height": HEIGHT, "height": HEIGHT,
"page_zoom_percent": 100, "page_zoom_percent": 100,
"device_pixel_ratio": 1.0, "device_pixel_ratio": 1.0,
"site_permission_generation": 0,
"site_permissions": [], "site_permissions": [],
}) })
} }
+17 -12
View File
@@ -9,8 +9,9 @@ use std::{
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText}; use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText};
use ely_servo_host::{ use ely_servo_host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest,
PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScrollRequest, PageZoomRequest, PermissionDecision, PermissionSnapshotEntry, PermissionSnapshotRequest,
ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState, PermissionSnapshotState, ResizeRequest, ScrollRequest, ServoHost, ServoHostError,
ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState,
}; };
const MINIMUM_CONTENT_PIXELS: u64 = 1_000; const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
@@ -202,25 +203,29 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
assert_eq!(snapshot.profile_id(), &profile_id); assert_eq!(snapshot.profile_id(), &profile_id);
assert_eq!(snapshot.state(), &WebViewState::Created); assert_eq!(snapshot.state(), &WebViewState::Created);
host.set_permission( host.replace_permissions(PermissionSnapshotRequest {
PermissionRequest {
webview_id: webview_id.clone(), webview_id: webview_id.clone(),
profile_id: profile_id.clone(), profile_id: profile_id.clone(),
generation: 1,
entries: vec![PermissionSnapshotEntry {
origin: SiteOrigin::parse("https://example.com")?, origin: SiteOrigin::parse("https://example.com")?,
feature: SitePermissionFeature::Camera, feature: SitePermissionFeature::Camera,
}, state: PermissionSnapshotState::Decision(PermissionDecision::AllowOnce),
PermissionDecision::AllowOnce, revision: 1,
)?; }],
})?;
let other_profile_id = ProfileId::new(); let other_profile_id = ProfileId::new();
let mismatch = host.set_permission( let mismatch = host.replace_permissions(PermissionSnapshotRequest {
PermissionRequest {
webview_id: webview_id.clone(), webview_id: webview_id.clone(),
profile_id: other_profile_id.clone(), profile_id: other_profile_id.clone(),
generation: 2,
entries: vec![PermissionSnapshotEntry {
origin: SiteOrigin::parse("https://example.com")?, origin: SiteOrigin::parse("https://example.com")?,
feature: SitePermissionFeature::Camera, feature: SitePermissionFeature::Camera,
}, state: PermissionSnapshotState::Decision(PermissionDecision::AllowAlways),
PermissionDecision::AllowAlways, revision: 1,
); }],
});
assert!( assert!(
matches!( matches!(
mismatch, mismatch,
+10 -1
View File
@@ -50,12 +50,21 @@ transfers the IOSurface send right; the JSON port number is diagnostic metadata.
The protocol supports: The protocol supports:
- `handshake`: verify protocol version `2` before accepting browser commands. - `handshake`: verify protocol version `3` before accepting browser commands.
- `ensure`: create or update a WebView, navigation, viewport, zoom, permissions, and input. - `ensure`: create or update a WebView, navigation, viewport, zoom, permissions, and input.
- `poll`: advance Servo and return pending frame or metadata state. - `poll`: advance Servo and return pending frame or metadata state.
- `close`: destroy one tab's WebView. - `close`: destroy one tab's WebView.
- `shutdown`: acknowledge graceful process shutdown so Servo flushes profile storage. - `shutdown`: acknowledge graceful process shutdown so Servo flushes profile storage.
Each `ensure` carries the complete permission snapshot for its Profile. The sidecar
atomically replaces that Profile at the newest request generation, so cross-tab round-robin cannot
restore an older grant. Entries carry per-key revisions plus explicit `transferred-allow-once`
state and full-snapshot removal semantics. BrowserCore moves an accepted one-time grant into
revocable transferred state, the worker preserves the grant request from idle
coalescing, and the sidecar returns a consumption receipt after the first matching request. A fresh
worker interprets transferred state as consumed and returns the same completion receipt, which
keeps process-restart behavior fail closed.
`ensure` and `poll` carry `ready_surface_ids` and `pending_surface_ids`. The app distinguishes `ensure` and `poll` carry `ready_surface_ids` and `pending_surface_ids`. The app distinguishes
completed imports from handles queued behind bounded importer backpressure. Cache entries retain completed imports from handles queued behind bounded importer backpressure. Cache entries retain
the IOSurface reference and keep only a weak reference to an active `CVPixelBuffer` backing. The the IOSurface reference and keep only a weak reference to an active `CVPixelBuffer` backing. The