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;
#[path = "servo_live_ipc.rs"]
mod ipc;
#[path = "servo_live_permission_grant.rs"]
mod permission_grant;
#[path = "servo_live_types.rs"]
mod types;
#[path = "servo_live_wire.rs"]
mod wire;
pub(crate) use permission_grant::ServoLivePermissionGrant;
pub(crate) use types::{
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
};
@@ -53,6 +56,7 @@ pub(crate) struct ServoLiveClient {
ipc: ServoLiveIpc,
timeouts: SidecarTimeouts,
active: bool,
pending_permission_consumptions: Vec<ServoLivePermissionGrant>,
#[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache,
#[cfg(target_os = "macos")]
@@ -115,6 +119,7 @@ impl ServoLiveClient {
ipc: ServoLiveIpc::spawn(stdin, stdout),
timeouts,
active: true,
pending_permission_consumptions: Vec::new(),
#[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache::new(),
#[cfg(target_os = "macos")]
@@ -155,6 +160,7 @@ impl ServoLiveClient {
hover_x: request.hover_x,
hover_y: request.hover_y,
typed_text: request.typed_text,
site_permission_generation: request.site_permission_generation,
site_permissions: request.site_permissions,
ready_surface_ids,
pending_surface_ids,
@@ -184,6 +190,7 @@ impl ServoLiveClient {
if reply.frame.is_some()
|| reply.surface_handle.is_some()
|| reply.current_surface_id.is_some()
|| !reply.permission_consumptions.is_empty()
{
return Err(ServoLiveError::InvalidResponse {
message: "handshake response contains a frame",
@@ -197,6 +204,7 @@ impl ServoLiveClient {
fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let reply = self.exchange(request, self.timeouts.request, "request")?;
self.pending_permission_consumptions.extend(reply.permission_consumptions);
if let Some(message) = reply.error {
return Err(ServoLiveError::SidecarFailed { message });
}
@@ -224,6 +232,10 @@ impl ServoLiveClient {
Ok(frame)
}
pub(crate) fn take_permission_consumptions(&mut self) -> Vec<ServoLivePermissionGrant> {
std::mem::take(&mut self.pending_permission_consumptions)
}
fn exchange(
&mut self,
request: LiveRequest,
+33 -4
View File
@@ -7,7 +7,7 @@ use std::{
};
use super::{
ServoLiveError, ServoLiveFrame,
ServoLiveError, ServoLiveFrame, ServoLivePermissionGrant,
wire::{
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) surface_handle: Option<LiveSurfaceHandle>,
pub(super) current_surface_id: Option<u64>,
pub(super) permission_consumptions: Vec<ServoLivePermissionGrant>,
}
struct IpcRequest {
@@ -194,6 +195,11 @@ fn read_reply(stdout: &mut impl BufRead) -> Result<IpcReply, ServoLiveError> {
frame: frame.transpose()?,
surface_handle: response.surface_handle,
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]
fn reply_rejects_oversized_frame_before_readback_allocation() {
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
);
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")]
#[test]
fn hardware_reply_uses_surface_without_rgba_allocation() -> Result<(), ServoLiveError> {
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},",
"\"current_surface_id\":7,",
"\"frame\":{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",",
@@ -307,7 +336,7 @@ mod tests {
#[cfg(target_os = "macos")]
fn hardware_header(current_surface_id: u64, handle_width: u32, handle_height: u32) -> String {
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::{collections::TryReserveError, io, path::PathBuf};
use ely_domain::SitePermissionDecision;
use serde::Serialize;
use thiserror::Error;
use super::wire::LiveFrameReport;
use super::{ServoLivePermissionGrant, wire::LiveFrameReport};
use crate::services::servo_sidecar_command::SidecarCommandError;
#[cfg(target_os = "macos")]
@@ -34,23 +33,27 @@ pub(crate) struct ServoLiveEnsureRequest {
pub(crate) hover_x: Option<u32>,
pub(crate) hover_y: Option<u32>,
pub(crate) typed_text: Option<String>,
pub(crate) site_permission_generation: u64,
pub(crate) site_permissions: Vec<ServoLiveSitePermission>,
pub(crate) allow_once_grants: Vec<ServoLivePermissionGrant>,
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct ServoLiveSitePermission {
pub(crate) origin: String,
pub(crate) feature: String,
pub(crate) decision: String,
pub(crate) state: String,
pub(crate) revision: u64,
}
impl ServoLiveSitePermission {
pub fn new(
origin: impl Into<String>,
feature: impl Into<String>,
decision: SitePermissionDecision,
state: impl Into<String>,
revision: u64,
) -> 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)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
#[error(transparent)]
SidecarCommand(#[from] SidecarCommandError),
}
@@ -380,7 +386,8 @@ impl ServoLiveError {
| Self::InvalidFrameByteCount { .. }
| Self::FrameAllocation { .. }
| Self::InvalidResponse { .. }
| Self::Json(_) => true,
| Self::Json(_)
| Self::Domain(_) => true,
#[cfg(target_os = "macos")]
Self::IOSurfaceImportFailed { .. }
| Self::IOSurfaceBackingFailed { .. }
+30 -2
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
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_BYTE_COUNT: usize = 256 * 1024 * 1024;
@@ -29,6 +29,7 @@ pub(super) enum LiveRequest {
hover_x: Option<u32>,
hover_y: Option<u32>,
typed_text: Option<String>,
site_permission_generation: u64,
site_permissions: Vec<ServoLiveSitePermission>,
ready_surface_ids: Vec<u64>,
pending_surface_ids: Vec<u64>,
@@ -54,6 +55,16 @@ pub(super) struct LiveResponse {
pub(super) surface_handle: Option<LiveSurfaceHandle>,
#[serde(default)]
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)]
@@ -98,7 +109,7 @@ fn default_device_pixel_ratio() -> f32 {
mod tests {
use serde_json::json;
use super::{LIVE_PROTOCOL_VERSION, LiveRequest};
use super::{LIVE_PROTOCOL_VERSION, LiveRequest, ServoLiveSitePermission};
#[test]
fn handshake_request_serializes_protocol_version() -> Result<(), serde_json::Error> {
@@ -138,4 +149,21 @@ mod tests {
);
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(())
}
}
+16 -8
View File
@@ -97,14 +97,11 @@ impl WebSurfaceStore {
// which destroys and reallocates the framebuffer — the source of
// the per-frame blank flash. The first ensure (when no prior
// `last_ensure_key` is set) always fires so the page can load.
let already_ensured =
self.surfaces.get(tab.id()).is_some_and(|surface| surface.last_ensure_key.is_some());
if already_ensured
&& self
.surfaces
.get(tab.id())
.is_some_and(|surface| surface.viewport_size_is_settling(Instant::now()))
{
let defer_resize = self
.surfaces
.get(tab.id())
.is_some_and(|surface| surface.should_defer_resize(&ensure_key, Instant::now()));
if defer_resize {
return false;
}
let previous_frame =
@@ -205,6 +202,7 @@ impl WebSurfaceStore {
}
}
WebSurfaceRuntimeFrame::Failed { tab_id, message } => {
self.surface_mut(&tab_id).last_ensure_key = None;
let had_ready = matches!(
self.surfaces.get(&tab_id).and_then(|surface| surface.state.as_ref()),
Some(WebSurfaceState::Ready(_))
@@ -221,6 +219,12 @@ impl WebSurfaceStore {
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Failed { message });
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)]
#[path = "web_surface_scope_tests.rs"]
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_domain::{BrowserTab, ProfileKind, TabId, UrlText};
use gpui::{AnyElement, Bounds, Context, Pixels, Point};
use crate::services::ProfileDataMode;
use crate::services::{ProfileDataMode, servo_live::ServoLivePermissionGrant};
use super::{
ElyShell,
@@ -65,11 +67,27 @@ impl ElyShell {
for metadata in result.page_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();
if url_changed || metadata_changed {
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 {
@@ -191,7 +209,7 @@ impl ElyShell {
changed |= self.web_surfaces.ensure_surface(
&visible.tab,
visible.profile_data_mode,
&visible.permissions,
visible.permissions.as_ref(),
);
}
changed
@@ -237,22 +255,37 @@ impl ElyShell {
struct VisibleWebSurfaceTab {
tab: BrowserTab,
profile_data_mode: ProfileDataMode,
permissions: Vec<WebSurfaceSitePermission>,
permissions: Arc<[WebSurfaceSitePermission]>,
}
fn visible_web_surface_tabs(
core: &BrowserCore,
tabs: Vec<BrowserTab>,
) -> Vec<VisibleWebSurfaceTab> {
tabs.into_iter()
.filter(|tab| super::web_surface::is_external_web_url(tab.url().as_str()))
.filter_map(|tab| {
let profile_data_mode =
core.profile_kind_for(tab.profile_id()).ok().map(profile_data_mode_from_kind)?;
let permissions = web_surface_site_permissions_for_core_tab(core, &tab);
Some(VisibleWebSurfaceTab { tab, profile_data_mode, permissions })
})
.collect()
let mut permission_cache = HashMap::new();
let mut visible = Vec::new();
for tab in tabs {
if !super::web_surface::is_external_web_url(tab.url().as_str()) {
continue;
}
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,
))
})
.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> {
@@ -269,3 +302,59 @@ fn profile_data_mode_from_kind(kind: ProfileKind) -> ProfileDataMode {
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;
#[cfg(test)]
use ely_browser_core::BrowserSnapshot;
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)]
pub(super) struct WebSurfaceSitePermission {
origin: SiteOrigin,
feature: SitePermissionFeature,
decision: SitePermissionDecision,
state: WebSurfaceSitePermissionState,
revision: u64,
}
impl WebSurfaceSitePermission {
pub(super) fn new(
origin: SiteOrigin,
feature: SitePermissionFeature,
decision: SitePermissionDecision,
state: WebSurfaceSitePermissionState,
revision: u64,
) -> Self {
Self { origin, feature, decision }
Self { origin, feature, state, revision }
}
pub(super) fn origin(&self) -> &SiteOrigin {
@@ -27,45 +44,52 @@ impl WebSurfaceSitePermission {
self.feature
}
pub(super) fn decision(&self) -> SitePermissionDecision {
self.decision
pub(super) fn state(&self) -> WebSurfaceSitePermissionState {
self.state
}
}
#[cfg(test)]
pub(super) fn web_surface_site_permissions_for_tab(
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 revision(&self) -> u64 {
self.revision
}
}
pub(super) fn web_surface_site_permissions_for_core_tab(
core: &BrowserCore,
tab: &BrowserTab,
) -> Vec<WebSurfaceSitePermission> {
let Ok(Some(origin)) = SiteOrigin::from_url(tab.url()) else {
return Vec::new();
};
let mut latest = HashMap::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)
.into_iter()
.map(|entry| {
WebSurfaceSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision())
})
.collect()
let mut permissions = Vec::new();
for entry in core.site_permissions_for_profile(tab.profile_id()) {
let key = (entry.origin().clone(), entry.feature());
let revision = latest.remove(&key).unwrap_or(0);
permissions.push(WebSurfaceSitePermission::new(
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)]
@@ -77,7 +101,7 @@ mod tests {
BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText,
};
use super::web_surface_site_permissions_for_tab;
use super::{WebSurfaceSitePermissionState, web_surface_site_permissions_for_core_tab};
#[test]
fn includes_matching_profile_and_origin_permissions() -> Result<(), Box<dyn Error>> {
@@ -91,17 +115,20 @@ mod tests {
let snapshot = core.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[0].origin().as_str(), "https://example.com");
assert_eq!(permissions[0].feature(), SitePermissionFeature::Camera);
assert_eq!(permissions[0].decision(), SitePermissionDecision::AllowAlways);
assert_eq!(
permissions[0].state(),
WebSurfaceSitePermissionState::Decision(SitePermissionDecision::AllowAlways),
);
Ok(())
}
#[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")?;
core.set_site_permission(
SiteOrigin::parse("https://other.test")?,
@@ -112,7 +139,30 @@ mod tests {
let snapshot = core.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(())
}
+23 -21
View File
@@ -1,6 +1,5 @@
use std::{
collections::BTreeMap,
path::PathBuf,
thread::JoinHandle,
time::{Duration, Instant},
};
@@ -9,7 +8,7 @@ use ely_domain::{BrowserTab, TabId};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveClient, ServoLiveEnsureRequest, ServoLiveSitePermission},
servo_live::{ServoLiveEnsureRequest, ServoLiveSitePermission},
servo_profile_data::cleanup_stale_transient_profile_data_dirs,
};
@@ -21,13 +20,15 @@ use super::{
WebSurfaceRuntimeScope, WebSurfaceSession, config_dir_for_scope, session_for_scope,
},
web_surface_runtime_wire::{
input_requests_history_navigation, log_ensure_submitted, pending_input_kind,
scroll_wire_fields,
allow_once_grants, input_requests_history_navigation, log_ensure_submitted,
pending_input_kind, scroll_wire_fields,
},
web_surface_state::WebSurfacePendingInput,
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::{
WebSurfaceEnsureResult, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
@@ -130,7 +131,9 @@ impl WebSurfaceRuntime {
hover_x: input.hover_point.map(|point| point.x()),
hover_y: input.hover_point.map(|point| point.y()),
typed_text: input.typed_text,
site_permission_generation: generation.value(),
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 {
@@ -168,22 +171,24 @@ impl WebSurfaceRuntime {
let poll_now = Instant::now();
for tab_id in visible_tab_ids {
let Some(scope) = self.sessions.get(tab_id).and_then(|session| {
session.cadence.should_poll(poll_now).then(|| session.scope.clone())
let Some((scope, generation)) = self.sessions.get(tab_id).and_then(|session| {
session
.cadence
.should_poll(poll_now)
.then(|| (session.scope.clone(), session.generation))
}) else {
continue;
};
let Some(generation) = generation else {
continue;
};
if !self.workers.contains_key(&scope) {
continue;
}
let generation = self.next_request_generation();
let submitted = self.workers.get(&scope).is_some_and(|scoped| {
let _ = self.workers.get(&scope).is_some_and(|scoped| {
scoped.worker.submit_poll(generation, tab_id.as_str().to_string())
});
if let Some(session) = self.sessions.get_mut(tab_id) {
if submitted {
session.generation = Some(generation);
}
session.cadence.note_poll_submitted(poll_now);
}
}
@@ -353,6 +358,12 @@ impl WebSurfaceRuntime {
}
}
WorkerResponse::RuntimeUnavailable => runtime_unavailable = true,
WorkerResponse::PermissionSnapshotAccepted(grant) => {
frames.push(WebSurfaceRuntimeFrame::PermissionSnapshotAccepted(grant));
}
WorkerResponse::PermissionConsumed(consumed) => {
frames.push(WebSurfaceRuntimeFrame::PermissionConsumed(consumed));
}
}
}
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)]
struct ScopeRetryState {
failure_count: u32,
@@ -121,12 +121,22 @@ fn pending_poll_advances_deadline_under_worker_backpressure() -> Result<(), Stri
let tab = web_tab("Backpressure")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
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));
runtime.tick(std::slice::from_ref(tab.id()));
thread::sleep(Duration::from_millis(10));
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
.next_poll_delay(std::slice::from_ref(tab.id()), std::time::Instant::now())
.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) worker: LiveRuntimeWorker,
@@ -2,6 +2,7 @@ use std::{collections::BTreeMap, path::PathBuf};
use crate::services::{
ProfileDataMode,
servo_live::ServoLivePermissionGrant,
servo_profile_data::{
TransientProfileDataDir, create_profile_data_dir, default_profile_data_root,
transient_profile_data_dir,
@@ -102,6 +103,8 @@ pub(super) struct WebSurfaceEnsureResult {
pub(super) enum WebSurfaceRuntimeFrame {
Ready { tab_id: TabId, frame: Box<WebSurfaceFrame>, url_change: Option<WebSurfaceUrlChange> },
Failed { tab_id: TabId, message: String },
PermissionSnapshotAccepted(ServoLivePermissionGrant),
PermissionConsumed(ServoLivePermissionGrant),
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -1,13 +1,13 @@
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::{
web_surface_cadence::WebSurfaceInputKind,
web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceSize},
web_surface_permissions::WebSurfaceSitePermission,
web_surface_permissions::{WebSurfaceSitePermission, WebSurfaceSitePermissionState},
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'))
}
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 {
if input.scroll_delta.is_some() {
WebSurfaceInputKind::Scroll
@@ -74,7 +95,8 @@ impl From<&WebSurfaceSitePermission> for ServoLiveSitePermission {
Self::new(
permission.origin().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 crate::services::ProfileDataMode;
use crate::services::servo_live::ServoLivePermissionGrant;
use super::{
web_surface_cadence::ACTIVE_POLL_INTERVAL,
@@ -110,6 +111,8 @@ pub(super) struct WebSurfaceTickResult {
pub(super) changed: bool,
pub(super) url_changes: Vec<WebSurfaceUrlChange>,
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.
@@ -196,6 +199,11 @@ impl PerTabSurface {
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(
&self,
profile_id: &ProfileId,
@@ -368,6 +376,31 @@ mod tests {
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]
fn profile_change_forces_ensure() {
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::{
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.
@@ -22,6 +30,10 @@ pub(super) trait LiveRuntimeClient {
fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError>;
fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError>;
fn take_permission_consumptions(&mut self) -> Vec<ServoLivePermissionGrant> {
Vec::new()
}
}
impl LiveRuntimeClient for ServoLiveClient {
@@ -39,6 +51,10 @@ impl LiveRuntimeClient for ServoLiveClient {
fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError> {
ServoLiveClient::close(self, tab_id).map_err(LiveRuntimeClientError::from)
}
fn take_permission_consumptions(&mut self) -> Vec<ServoLivePermissionGrant> {
ServoLiveClient::take_permission_consumptions(self)
}
}
#[derive(Debug)]
@@ -88,6 +104,8 @@ pub(super) enum WorkerResponse {
Frame { generation: RequestGeneration, tab_id: String, frame: ServoLiveFrame },
Failed { generation: RequestGeneration, tab_id: String, message: String },
RuntimeUnavailable,
PermissionSnapshotAccepted(ServoLivePermissionGrant),
PermissionConsumed(ServoLivePermissionGrant),
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -97,10 +115,14 @@ impl RequestGeneration {
pub(super) const fn new(value: u64) -> Self {
Self(value)
}
pub(super) const fn value(self) -> u64 {
self.0
}
}
enum WorkerRequest {
Ensure { generation: RequestGeneration, request: ServoLiveEnsureRequest },
Ensure { generation: RequestGeneration, request: Box<ServoLiveEnsureRequest> },
Poll { generation: RequestGeneration, tab_id: String },
}
@@ -190,7 +212,7 @@ impl LiveRuntimeWorker {
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
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) {
let replace_tail = pending.back().is_some_and(|tail| {
matches!(tail, WorkerRequest::Poll { .. })
@@ -394,15 +416,26 @@ fn run_worker(
let exit_after_dispatch = match work {
Work::Close(tab_id) => {
let _ = client.close(tab_id);
forward_permission_consumptions(&mut *client, &response_tx);
false
}
Work::Request(WorkerRequest::Ensure { generation, request }) => {
Work::Request(WorkerRequest::Ensure { generation, mut request }) => {
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 }) => {
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),
}
/// 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)]
#[path = "web_surface_worker_tests.rs"]
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,
};
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
use crate::services::servo_live::{
ServoLiveEnsureRequest, ServoLiveFrame, ServoLivePermissionGrant, ServoLiveSitePermission,
};
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature};
use super::{
LiveRuntimeClient, LiveRuntimeClientError, LiveRuntimeWorker, RequestGeneration, WorkerResponse,
@@ -16,6 +19,7 @@ enum RecordedInput {
Click(String),
Text(String),
Hover(String),
Permission(String),
}
struct SlowRecordingClient {
@@ -27,6 +31,31 @@ struct SlowRecordingClient {
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 {
fn ensure(
&mut self,
@@ -117,6 +146,52 @@ fn queued_edge_inputs_for_one_tab_are_preserved() -> Result<(), String> {
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]
fn queued_tabs_are_dispatched_round_robin() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
@@ -191,6 +266,32 @@ fn responses_keep_their_request_generation() -> Result<(), String> {
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]
fn hover_updates_coalesce_to_latest_state() -> Result<(), String> {
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 {
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)
} else if request.click_x.is_some() {
RecordedInput::Click(tab_id)
@@ -356,6 +459,8 @@ fn ensure_request(tab_id: &str, input: RecordedInput) -> ServoLiveEnsureRequest
hover_x: hover.then_some(1),
hover_y: hover.then_some(1),
typed_text: text.then(|| "text".to_string()),
site_permission_generation: 1,
site_permissions: Vec::new(),
allow_once_grants: Vec::new(),
}
}