update
@@ -38,7 +38,7 @@ jobs:
|
|||||||
- name: Test Servo engine host
|
- name: Test Servo engine host
|
||||||
run: cargo test -p ely_servo_host --features servo-engine --test software_host
|
run: cargo test -p ely_servo_host --features servo-engine --test software_host
|
||||||
|
|
||||||
- name: Test PRD site compatibility
|
- name: Test embedded PRD site compatibility
|
||||||
run: scripts/verify_prd_site_rendering.sh
|
run: scripts/verify_prd_site_rendering.sh
|
||||||
|
|
||||||
- name: Audit source file size
|
- name: Audit source file size
|
||||||
|
|||||||
@@ -2243,24 +2243,17 @@ name = "ely_app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"core-foundation 0.10.0",
|
|
||||||
"core-video",
|
|
||||||
"directories",
|
"directories",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"ely_browser_core",
|
"ely_browser_core",
|
||||||
"ely_design_system",
|
"ely_design_system",
|
||||||
"ely_domain",
|
"ely_domain",
|
||||||
|
"ely_servo_host",
|
||||||
"ely_sync_client",
|
"ely_sync_client",
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-component",
|
"gpui-component",
|
||||||
"gpui-component-assets",
|
"gpui-component-assets",
|
||||||
"image",
|
"image",
|
||||||
"io-surface",
|
|
||||||
"mach2",
|
|
||||||
"objc2",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-foundation",
|
|
||||||
"objc2-io-surface",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -2270,7 +2263,6 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2309,16 +2301,12 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"ely_domain",
|
"ely_domain",
|
||||||
"euclid",
|
"euclid",
|
||||||
"gleam",
|
"naga 26.0.0",
|
||||||
"glow",
|
"raw-window-handle",
|
||||||
"image",
|
"rustls",
|
||||||
"log",
|
|
||||||
"mach2",
|
|
||||||
"objc2-io-surface",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"servo",
|
"servo",
|
||||||
"surfman",
|
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -2498,17 +2486,6 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "erased-serde"
|
|
||||||
version = "0.4.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_core",
|
|
||||||
"typeid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -3521,8 +3498,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_http_client"
|
name = "gpui_http_client"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "23822b0a6d2c5e6a42507980a0ab3848610ea908942c8ef98187f646f690335e"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
@@ -3593,8 +3568,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_sum_tree"
|
name = "gpui_sum_tree"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"log",
|
"log",
|
||||||
@@ -3604,8 +3577,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_util"
|
name = "gpui_util"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "68faea25903ae524de9af83990b9aa51bcbc8dd085929ac0aea7fd41905e05c3"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-fs",
|
"async-fs",
|
||||||
@@ -5288,7 +5259,6 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
|
||||||
"value-bag",
|
"value-bag",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6142,8 +6112,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
|
||||||
"libc",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -6158,7 +6126,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8052,15 +8019,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_fmt"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed"
|
|
||||||
dependencies = [
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.149"
|
||||||
@@ -10186,84 +10144,6 @@ dependencies = [
|
|||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sval"
|
|
||||||
version = "2.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2eb9318255ebd817902d7e279d8f8e39b35b1b9954decd5eb9ea0e30e5fd2b6a"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sval_buffer"
|
|
||||||
version = "2.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "12571299185e653fdb0fbfe36cd7f6529d39d4e747a60b15a3f34574b7b97c61"
|
|
||||||
dependencies = [
|
|
||||||
"sval",
|
|
||||||
"sval_ref",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sval_dynamic"
|
|
||||||
version = "2.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39526f24e997706c0de7f03fb7371f7f5638b66a504ded508e20ad173d0a3677"
|
|
||||||
dependencies = [
|
|
||||||
"sval",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sval_fmt"
|
|
||||||
version = "2.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "933dd3bb26965d682280fcc49400ac2a05036f4ee1e6dbd61bf8402d5a5c3a54"
|
|
||||||
dependencies = [
|
|
||||||
"itoa",
|
|
||||||
"ryu",
|
|
||||||
"sval",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sval_json"
|
|
||||||
version = "2.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a0cda08f6d5c9948024a6551077557b1fdcc3880ff2f20ae839667d2ec2d87ed"
|
|
||||||
dependencies = [
|
|
||||||
"itoa",
|
|
||||||
"ryu",
|
|
||||||
"sval",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sval_nested"
|
|
||||||
version = "2.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "88d49d5e6c1f9fd0e53515819b03a97ca4eb1bff5c8ee097c43391c09ecfb19f"
|
|
||||||
dependencies = [
|
|
||||||
"sval",
|
|
||||||
"sval_buffer",
|
|
||||||
"sval_ref",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sval_ref"
|
|
||||||
version = "2.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "14f876c5a78405375b4e19cbb9554407513b59c93dea12dc6a4af4e1d30899ca"
|
|
||||||
dependencies = [
|
|
||||||
"sval",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sval_serde"
|
|
||||||
version = "2.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5f9ccd3b7f7200239a655e517dd3fd48d960b9111ad24bd6a5e055bef17607c7"
|
|
||||||
dependencies = [
|
|
||||||
"serde_core",
|
|
||||||
"sval",
|
|
||||||
"sval_nested",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "svg_fmt"
|
name = "svg_fmt"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@@ -11061,12 +10941,6 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typeid"
|
|
||||||
version = "1.0.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
@@ -11381,36 +11255,6 @@ name = "value-bag"
|
|||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||||
dependencies = [
|
|
||||||
"value-bag-serde1",
|
|
||||||
"value-bag-sval2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "value-bag-serde1"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "16530907bfe2999a1773ca5900a65101e092c70f642f25cc23ca0c43573262c5"
|
|
||||||
dependencies = [
|
|
||||||
"erased-serde",
|
|
||||||
"serde_core",
|
|
||||||
"serde_fmt",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "value-bag-sval2"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d00ae130edd690eaa877e4f40605d534790d1cf1d651e7685bd6a144521b251f"
|
|
||||||
dependencies = [
|
|
||||||
"sval",
|
|
||||||
"sval_buffer",
|
|
||||||
"sval_dynamic",
|
|
||||||
"sval_fmt",
|
|
||||||
"sval_json",
|
|
||||||
"sval_ref",
|
|
||||||
"sval_serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
@@ -13304,8 +13148,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "zed-sum-tree"
|
name = "zed-sum-tree"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -37,8 +37,12 @@ url = "2.5.4"
|
|||||||
uuid = { version = "1.12.1", features = ["v7"] }
|
uuid = { version = "1.12.1", features = ["v7"] }
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
# Local GPUI 0.2.2 patch: macOS `surface(CVPixelBuffer)` accepts Servo's BGRA IOSurfaces.
|
# Local GPUI 0.2.2 patch used by the native shell while the upstream crate is pinned.
|
||||||
gpui = { path = "third_party/gpui" }
|
gpui = { path = "third_party/gpui" }
|
||||||
|
gpui_http_client = { path = "third_party/gpui_support/gpui_http_client" }
|
||||||
|
gpui_sum_tree = { path = "third_party/gpui_support/gpui_sum_tree" }
|
||||||
|
gpui_util = { path = "third_party/gpui_support/gpui_util" }
|
||||||
|
zed-sum-tree = { path = "third_party/gpui_support/zed-sum-tree" }
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "deny"
|
unsafe_code = "deny"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ cargo fmt --all --check
|
|||||||
cargo check --workspace --all-targets
|
cargo check --workspace --all-targets
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
cargo test --workspace --all-targets
|
cargo test --workspace --all-targets
|
||||||
cargo check -p ely_servo_host --features servo-engine,hardware-render --all-targets
|
cargo check -p ely_servo_host --features servo-engine --all-targets
|
||||||
cargo clippy -p ely_servo_host --features servo-engine,hardware-render --all-targets -- -D warnings
|
cargo clippy -p ely_servo_host --features servo-engine --all-targets -- -D warnings
|
||||||
cargo test -p ely_servo_host --features servo-engine --test software_host
|
cargo test -p ely_servo_host --features servo-engine --test software_host
|
||||||
scripts/verify_prd_site_rendering.sh
|
scripts/verify_prd_site_rendering.sh
|
||||||
scripts/verify_windows_app_manifest.sh
|
scripts/verify_windows_app_manifest.sh
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ ed25519-dalek.workspace = true
|
|||||||
ely_browser_core = { path = "../ely_browser_core" }
|
ely_browser_core = { path = "../ely_browser_core" }
|
||||||
ely_design_system = { path = "../ely_design_system" }
|
ely_design_system = { path = "../ely_design_system" }
|
||||||
ely_domain = { path = "../ely_domain" }
|
ely_domain = { path = "../ely_domain" }
|
||||||
|
ely_servo_host = { path = "../ely_servo_host", features = ["servo-engine"] }
|
||||||
ely_sync_client = { path = "../ely_sync_client" }
|
ely_sync_client = { path = "../ely_sync_client" }
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
@@ -29,20 +30,6 @@ tracing-subscriber.workspace = true
|
|||||||
ureq.workspace = true
|
ureq.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
core-foundation = "0.10"
|
|
||||||
# Pin to the same minor as gpui 0.2.2 so we share its `CVPixelBuffer`
|
|
||||||
# type — gpui's `Surface::From<CVPixelBuffer>` only matches the
|
|
||||||
# version it built against.
|
|
||||||
core-video = "0.4"
|
|
||||||
io-surface = "0.16"
|
|
||||||
mach2 = "0.6"
|
|
||||||
objc2 = "0.6"
|
|
||||||
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
|
|
||||||
objc2-foundation = { version = "0.3.1", features = ["NSDictionary", "NSString", "NSValue"] }
|
|
||||||
objc2-io-surface = "0.3.2"
|
|
||||||
uuid.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
gpui = { workspace = true, features = ["test-support"] }
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
|
|
||||||
|
|||||||
@@ -324,15 +324,17 @@ fn quit(_: &Quit, cx: &mut App) {
|
|||||||
cx.quit();
|
cx.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TRACING_FILTER: &str = "ely_app=info,ely_servo_host=warn,ely=info";
|
||||||
|
|
||||||
/// Install the global tracing subscriber. `RUST_LOG` drives the
|
/// Install the global tracing subscriber. `RUST_LOG` drives the
|
||||||
/// filter; absent it, only `warn` and above leak through so day-to-day
|
/// filter; absent it, app-owned targets stay visible and Servo internals
|
||||||
/// runs stay quiet. The perf target is silent by default —
|
/// stay opt-in. `RUST_LOG=ely::servo::perf=info` flips on the frame-time
|
||||||
/// `RUST_LOG=ely::servo::perf=info` flips on the frame-time stream
|
/// stream. We swallow re-init errors so tests that share process state
|
||||||
/// without touching the rest of the app. We swallow re-init errors so
|
/// with main can reuse this path.
|
||||||
/// tests that share the process state with main don't blow up.
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
use tracing_subscriber::{EnvFilter, fmt};
|
use tracing_subscriber::{EnvFilter, fmt};
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new(DEFAULT_TRACING_FILTER));
|
||||||
let _ = fmt().with_env_filter(filter).with_target(true).try_init();
|
let _ = fmt().with_env_filter(filter).with_target(true).try_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::BTreeMap,
|
|
||||||
ffi::CString,
|
|
||||||
mem,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use mach2::{
|
|
||||||
bootstrap::{bootstrap_port, bootstrap_register},
|
|
||||||
kern_return::KERN_SUCCESS,
|
|
||||||
mach_port::{mach_port_allocate, mach_port_destroy, mach_port_insert_right},
|
|
||||||
message::{
|
|
||||||
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TIMEOUT_NONE, MACH_MSG_TYPE_MAKE_SEND,
|
|
||||||
MACH_RCV_MSG, MACH_RCV_TIMED_OUT, MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t,
|
|
||||||
mach_msg_header_t, mach_msg_port_descriptor_t, mach_msg_trailer_t,
|
|
||||||
},
|
|
||||||
port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t},
|
|
||||||
traps::mach_task_self,
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
|
|
||||||
const SERVICE_PREFIX: &str = "com.ely.browser.iosurface";
|
|
||||||
|
|
||||||
pub(crate) struct IOSurfaceMachReceiver {
|
|
||||||
service_name: String,
|
|
||||||
receive_port: mach_port_t,
|
|
||||||
pending_ports: BTreeMap<u64, mach_port_t>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub(crate) enum IOSurfaceMachError {
|
|
||||||
#[error("Mach service name contains an interior nul byte")]
|
|
||||||
InvalidServiceName,
|
|
||||||
|
|
||||||
#[error("mach_port_allocate returned {code}")]
|
|
||||||
AllocatePort { code: i32 },
|
|
||||||
|
|
||||||
#[error("mach_port_insert_right returned {code}")]
|
|
||||||
InsertSendRight { code: i32 },
|
|
||||||
|
|
||||||
#[error("bootstrap_register returned {code}")]
|
|
||||||
RegisterService { code: i32 },
|
|
||||||
|
|
||||||
#[error("mach_msg receive timed out for IOSurface surface {surface_id:#x}")]
|
|
||||||
ReceiveTimedOut { surface_id: u64 },
|
|
||||||
|
|
||||||
#[error("mach_msg receive returned {code}")]
|
|
||||||
Receive { code: i32 },
|
|
||||||
|
|
||||||
#[error("received unexpected Mach message id {message_id}")]
|
|
||||||
UnexpectedMessage { message_id: i32 },
|
|
||||||
|
|
||||||
#[error("received invalid IOSurface Mach message")]
|
|
||||||
InvalidMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IOSurfaceMachReceiver {
|
|
||||||
pub(crate) fn new() -> Result<Self, IOSurfaceMachError> {
|
|
||||||
let service_name = unique_service_name();
|
|
||||||
let service_name_c = CString::new(service_name.as_str())
|
|
||||||
.map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
|
|
||||||
let mut receive_port = MACH_PORT_NULL;
|
|
||||||
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let task = unsafe { mach_task_self() };
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let allocate =
|
|
||||||
unsafe { mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &mut receive_port) };
|
|
||||||
if allocate != KERN_SUCCESS {
|
|
||||||
return Err(IOSurfaceMachError::AllocatePort { code: allocate });
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let insert = unsafe {
|
|
||||||
mach_port_insert_right(task, receive_port, receive_port, MACH_MSG_TYPE_MAKE_SEND)
|
|
||||||
};
|
|
||||||
if insert != KERN_SUCCESS {
|
|
||||||
destroy_port(receive_port);
|
|
||||||
return Err(IOSurfaceMachError::InsertSendRight { code: insert });
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
#[allow(deprecated)]
|
|
||||||
let register = unsafe {
|
|
||||||
bootstrap_register(bootstrap_port, service_name_c.as_ptr() as *mut _, receive_port)
|
|
||||||
};
|
|
||||||
if register != KERN_SUCCESS {
|
|
||||||
destroy_port(receive_port);
|
|
||||||
return Err(IOSurfaceMachError::RegisterService { code: register });
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self { service_name, receive_port, pending_ports: BTreeMap::new() })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn service_name(&self) -> &str {
|
|
||||||
self.service_name.as_str()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn receive_port_for_surface(
|
|
||||||
&mut self,
|
|
||||||
surface_id: u64,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<mach_port_t, IOSurfaceMachError> {
|
|
||||||
if let Some(port) = self.pending_ports.remove(&surface_id) {
|
|
||||||
return Ok(port);
|
|
||||||
}
|
|
||||||
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
||||||
if remaining.is_zero() {
|
|
||||||
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
|
|
||||||
}
|
|
||||||
let Some(received) = self.receive_one(remaining)? else {
|
|
||||||
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
|
|
||||||
};
|
|
||||||
if received.surface_id == surface_id {
|
|
||||||
return Ok(received.mach_port);
|
|
||||||
}
|
|
||||||
self.pending_ports.insert(received.surface_id, received.mach_port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn receive_one(
|
|
||||||
&self,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<Option<ReceivedSurfacePort>, IOSurfaceMachError> {
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let mut received_message: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
|
|
||||||
let timeout_ms = timeout_millis(timeout);
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let result = unsafe {
|
|
||||||
mach_msg(
|
|
||||||
&mut received_message.message.header,
|
|
||||||
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
|
|
||||||
0,
|
|
||||||
mem::size_of::<ReceivedIOSurfacePortMessage>() as u32,
|
|
||||||
self.receive_port,
|
|
||||||
timeout_ms,
|
|
||||||
MACH_PORT_NULL,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if result == MACH_RCV_TIMED_OUT {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if result != MACH_MSG_SUCCESS {
|
|
||||||
return Err(IOSurfaceMachError::Receive { code: result });
|
|
||||||
}
|
|
||||||
let message = &mut received_message.message;
|
|
||||||
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID {
|
|
||||||
destroy_message(message);
|
|
||||||
return Err(IOSurfaceMachError::UnexpectedMessage {
|
|
||||||
message_id: message.header.msgh_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if message.body.msgh_descriptor_count != 1
|
|
||||||
|| message.surface_port.type_ != MACH_MSG_PORT_DESCRIPTOR as u8
|
|
||||||
|| message.surface_port.name == MACH_PORT_NULL
|
|
||||||
{
|
|
||||||
destroy_message(message);
|
|
||||||
return Err(IOSurfaceMachError::InvalidMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(ReceivedSurfacePort {
|
|
||||||
surface_id: message.surface_id,
|
|
||||||
mach_port: message.surface_port.name,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for IOSurfaceMachReceiver {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
for port in std::mem::take(&mut self.pending_ports).into_values() {
|
|
||||||
deallocate_port(port);
|
|
||||||
}
|
|
||||||
destroy_port(self.receive_port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReceivedSurfacePort {
|
|
||||||
surface_id: u64,
|
|
||||||
mach_port: mach_port_t,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct IOSurfacePortMessage {
|
|
||||||
header: mach_msg_header_t,
|
|
||||||
body: mach_msg_body_t,
|
|
||||||
surface_port: mach_msg_port_descriptor_t,
|
|
||||||
surface_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct ReceivedIOSurfacePortMessage {
|
|
||||||
message: IOSurfacePortMessage,
|
|
||||||
_trailer: mach_msg_trailer_t,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_service_name() -> String {
|
|
||||||
format!("{SERVICE_PREFIX}.{}", Uuid::now_v7().as_simple())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn timeout_millis(timeout: Duration) -> u32 {
|
|
||||||
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(MACH_MSG_TIMEOUT_NONE + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn destroy_message(message: &mut IOSurfacePortMessage) {
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe {
|
|
||||||
mach2::message::mach_msg_destroy(&mut message.header);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn destroy_port(port: mach_port_t) {
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let task = unsafe { mach_task_self() };
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe {
|
|
||||||
let _ = mach_port_destroy(task, port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deallocate_port(port: mach_port_t) {
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let task = unsafe { mach_task_self() };
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe {
|
|
||||||
let _ = mach2::mach_port::mach_port_deallocate(task, port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
//! macOS-only import of cross-process IOSurface handles into
|
|
||||||
//! `CVPixelBuffer`s that preserve the sidecar's IOSurface identity.
|
|
||||||
//!
|
|
||||||
//! `T10.4` originally imported the IOSurface into an `MTLTexture`
|
|
||||||
//! directly. GPUI 0.2.2 exposes `Window::paint_surface` /
|
|
||||||
//! `elements::surface::Surface` for `CVPixelBuffer`; the local GPUI
|
|
||||||
//! patch adds a BGRA fragment pipeline for Servo's hardware
|
|
||||||
//! IOSurfaces, so this cache is the renderer-side handoff point.
|
|
||||||
//!
|
|
||||||
//! Lifetime contract:
|
|
||||||
//!
|
|
||||||
//! * `IOSurfaceCreateMachPort` (sidecar side) gives the receiver a
|
|
||||||
//! send right whose refcount is 1 in our task. After we resolve
|
|
||||||
//! the surface and wrap it in a CVPixelBuffer, the mach port has
|
|
||||||
//! done its job.
|
|
||||||
//! * `CVPixelBufferCreateWithIOSurface` retains the IOSurface for
|
|
||||||
//! the pixel buffer's lifetime. We `mach_port_deallocate`
|
|
||||||
//! immediately so the receiver process doesn't accumulate idle
|
|
||||||
//! mach send rights.
|
|
||||||
//! * Dropping `IOSurfaceCache` releases every cached
|
|
||||||
//! `CVPixelBuffer`, which in turn releases each retained
|
|
||||||
//! IOSurface. The sidecar still holds its own retain via surfman,
|
|
||||||
//! so the IOSurface itself outlives our cache for as long as the
|
|
||||||
//! sidecar keeps painting.
|
|
||||||
|
|
||||||
#![cfg(target_os = "macos")]
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use core_foundation::base::TCFType as _;
|
|
||||||
use core_video::pixel_buffer::CVPixelBuffer;
|
|
||||||
#[allow(deprecated)]
|
|
||||||
use io_surface::IOSurface;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
/// Cache of imported `CVPixelBuffer`s keyed by IOSurface identity.
|
|
||||||
/// Constructed lazily by the renderer-side client on the first
|
|
||||||
/// hardware-path frame.
|
|
||||||
pub(crate) struct IOSurfaceCache {
|
|
||||||
pixel_buffers: HashMap<u64, CachedPixelBuffer>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type
|
|
||||||
// Apple documents as safe to share across threads. The cache is owned
|
|
||||||
// by ServoLiveClient which now lives on the LiveRuntimeWorker thread,
|
|
||||||
// so the auto-Send check (rightly) rejects the raw pointer inside the
|
|
||||||
// crate's `CVPixelBuffer`. The pointer is atomically refcounted CFTypeRef
|
|
||||||
// and only mutated via Mach IPC, which is itself thread-safe.
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe impl Send for IOSurfaceCache {}
|
|
||||||
|
|
||||||
struct CachedPixelBuffer {
|
|
||||||
pixel_buffer: CVPixelBuffer,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub(crate) enum SurfaceImportError {
|
|
||||||
#[error("IOSurfaceLookupFromMachPort returned null for port 0x{port:x}")]
|
|
||||||
LookupFailed { port: u32 },
|
|
||||||
#[error("CVPixelBufferCreateWithIOSurface returned status {status}")]
|
|
||||||
PixelBufferBuildFailed { status: i32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IOSurfaceCache {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { pixel_buffers: HashMap::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import an IOSurface published by the sidecar's
|
|
||||||
/// `surface_handle` field. Idempotent on `surface_id` plus pixel
|
|
||||||
/// dimensions: duplicate handles for the same sized IOSurface are
|
|
||||||
/// discarded, while a resized IOSurface that reuses the same
|
|
||||||
/// `surface_id` replaces the cached pixel buffer.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn import(
|
|
||||||
&mut self,
|
|
||||||
mach_port_name: u32,
|
|
||||||
surface_id: u64,
|
|
||||||
) -> Result<(), SurfaceImportError> {
|
|
||||||
let pixel_buffer = import_pixel_buffer_from_mach_port(mach_port_name)?;
|
|
||||||
self.insert_pixel_buffer(surface_id, pixel_buffer);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn insert_pixel_buffer(&mut self, surface_id: u64, pixel_buffer: CVPixelBuffer) {
|
|
||||||
let width = pixel_buffer.get_width() as u32;
|
|
||||||
let height = pixel_buffer.get_height() as u32;
|
|
||||||
|
|
||||||
if self
|
|
||||||
.pixel_buffers
|
|
||||||
.get(&surface_id)
|
|
||||||
.is_some_and(|cached| cached.width == width && cached.height == height)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.pixel_buffers.insert(surface_id, CachedPixelBuffer { pixel_buffer, width, height });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up an already-imported pixel buffer by `surface_id`. The
|
|
||||||
/// receiver's per-frame `current_surface_id` selects which of the
|
|
||||||
/// swap chain's rotating front/back surfaces to sample. Returns a
|
|
||||||
/// clone (CVPixelBuffer is reference-counted; cloning is a cheap
|
|
||||||
/// atomic increment) so the caller can hand it to GPUI's
|
|
||||||
/// `surface(...)` element without holding a borrow on the cache.
|
|
||||||
pub fn pixel_buffer_for(&self, surface_id: u64) -> Option<CVPixelBuffer> {
|
|
||||||
self.pixel_buffers.get(&surface_id).map(|cached| cached.pixel_buffer.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn surface_ids(&self) -> Vec<u64> {
|
|
||||||
self.pixel_buffers.keys().copied().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn cached_surface_count(&self) -> usize {
|
|
||||||
self.pixel_buffers.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn import_pixel_buffer_from_mach_port(
|
|
||||||
mach_port_name: u32,
|
|
||||||
) -> Result<CVPixelBuffer, SurfaceImportError> {
|
|
||||||
let result = build_pixel_buffer_from_mach_port(mach_port_name);
|
|
||||||
deallocate_mach_port(mach_port_name);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_pixel_buffer_from_mach_port(
|
|
||||||
mach_port_name: u32,
|
|
||||||
) -> Result<CVPixelBuffer, SurfaceImportError> {
|
|
||||||
let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name)
|
|
||||||
else {
|
|
||||||
return Err(SurfaceImportError::LookupFailed { port: mach_port_name });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Both objc2-io-surface and the legacy `io_surface` crate wrap
|
|
||||||
// the same C `__IOSurface` pointer. CVPixelBufferCreateWithIOSurface
|
|
||||||
// (via core-video) expects the legacy crate's wrapper. Reach for
|
|
||||||
// the raw pointer and let TCFType CFRetain it independently so
|
|
||||||
// both Rust handles can drop without double-freeing.
|
|
||||||
let raw_ptr: *const std::ffi::c_void =
|
|
||||||
(&*iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void;
|
|
||||||
#[allow(deprecated)]
|
|
||||||
let io_surface_view: IOSurface = {
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe {
|
|
||||||
IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
CVPixelBuffer::from_io_surface(&io_surface_view, None)
|
|
||||||
.map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Release one send right against the mach port we received. The
|
|
||||||
/// IOSurface itself stays alive because the `CVPixelBuffer` (or the
|
|
||||||
/// sidecar's surfman) still retain it.
|
|
||||||
fn deallocate_mach_port(port: u32) {
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let result = unsafe { mach_port_deallocate(mach_task_self_, port) };
|
|
||||||
if result != KERN_SUCCESS {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "ely::servo::iosurface",
|
|
||||||
mach_port_name = port,
|
|
||||||
kern_result = result,
|
|
||||||
"mach_port_deallocate returned non-success",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const KERN_SUCCESS: i32 = 0;
|
|
||||||
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe extern "C" {
|
|
||||||
/// Global mach task port for the running process. Defined in
|
|
||||||
/// `mach/mach_init.h` as `extern mach_port_t mach_task_self_;`.
|
|
||||||
static mach_task_self_: u32;
|
|
||||||
|
|
||||||
/// Releases one send right against `name` within `task`. We only
|
|
||||||
/// ever call this with our own task; the IOSurface keeps its
|
|
||||||
/// retain via the CVPixelBuffer so this just frees our port slot.
|
|
||||||
fn mach_port_deallocate(task: u32, name: u32) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::IOSurfaceCache;
|
|
||||||
use objc2_core_foundation::{
|
|
||||||
CFDictionary, CFIndex, CFNumber, CFRetained, CFString, kCFAllocatorDefault,
|
|
||||||
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
|
|
||||||
};
|
|
||||||
use objc2_io_surface::{
|
|
||||||
IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, kIOSurfaceHeight,
|
|
||||||
kIOSurfacePixelFormat, kIOSurfaceWidth,
|
|
||||||
};
|
|
||||||
use std::os::raw::c_void;
|
|
||||||
|
|
||||||
/// Build a CPU-backed IOSurface from scratch, the same way
|
|
||||||
/// surfman's macOS backend does.
|
|
||||||
///
|
|
||||||
/// BGRA8 (four-cc '32BGRA'), width + height + bytes_per_element
|
|
||||||
/// + bytes_per_row live in a Core Foundation properties dictionary.
|
|
||||||
///
|
|
||||||
/// The pointer-casts mirror
|
|
||||||
/// `surfman::platform::macos::system::surface::create_io_surface`.
|
|
||||||
fn build_local_iosurface(width: u32, height: u32) -> Result<CFRetained<IOSurfaceRef>, String> {
|
|
||||||
let pixel_format: i32 = i32::from_be_bytes(*b"BGRA");
|
|
||||||
let bytes_per_element: i32 = 4;
|
|
||||||
let bytes_per_row: i32 = (width as i32) * bytes_per_element;
|
|
||||||
|
|
||||||
let width_num = CFNumber::new_i32(width as i32);
|
|
||||||
let height_num = CFNumber::new_i32(height as i32);
|
|
||||||
let bpe_num = CFNumber::new_i32(bytes_per_element);
|
|
||||||
let bpr_num = CFNumber::new_i32(bytes_per_row);
|
|
||||||
let pf_num = CFNumber::new_i32(pixel_format);
|
|
||||||
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe {
|
|
||||||
let keys: [&CFString; 5] = [
|
|
||||||
kIOSurfaceWidth,
|
|
||||||
kIOSurfaceHeight,
|
|
||||||
kIOSurfaceBytesPerElement,
|
|
||||||
kIOSurfaceBytesPerRow,
|
|
||||||
kIOSurfacePixelFormat,
|
|
||||||
];
|
|
||||||
let values: [&CFNumber; 5] = [&width_num, &height_num, &bpe_num, &bpr_num, &pf_num];
|
|
||||||
let keys_ptr: *mut *const c_void = keys.as_ptr() as *mut *const c_void;
|
|
||||||
let values_ptr: *mut *const c_void = values.as_ptr() as *mut *const c_void;
|
|
||||||
let properties = CFDictionary::new(
|
|
||||||
kCFAllocatorDefault,
|
|
||||||
keys_ptr,
|
|
||||||
values_ptr,
|
|
||||||
keys.len() as CFIndex,
|
|
||||||
&kCFTypeDictionaryKeyCallBacks,
|
|
||||||
&kCFTypeDictionaryValueCallBacks,
|
|
||||||
)
|
|
||||||
.ok_or_else(|| "CFDictionaryCreate returned null".to_string())?;
|
|
||||||
IOSurfaceRef::new(&properties)
|
|
||||||
.ok_or_else(|| "IOSurfaceCreate returned null".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn imports_local_iosurface_into_pixel_buffer() -> Result<(), String> {
|
|
||||||
let mut cache = IOSurfaceCache::new();
|
|
||||||
let iosurface = build_local_iosurface(64, 48)?;
|
|
||||||
let mach_port = iosurface.create_mach_port();
|
|
||||||
assert!(mach_port != 0, "IOSurfaceCreateMachPort must yield a real port");
|
|
||||||
let surface_id: u64 = 0xDEAD_BEEFu64;
|
|
||||||
|
|
||||||
cache.import(mach_port, surface_id).map_err(|error| error.to_string())?;
|
|
||||||
|
|
||||||
let pixel_buffer = cache
|
|
||||||
.pixel_buffer_for(surface_id)
|
|
||||||
.ok_or_else(|| "imported pixel buffer was missing".to_string())?;
|
|
||||||
assert_eq!(
|
|
||||||
pixel_buffer.get_width() as u32,
|
|
||||||
64,
|
|
||||||
"CVPixelBuffer width must match the source IOSurface",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
pixel_buffer.get_height() as u32,
|
|
||||||
48,
|
|
||||||
"CVPixelBuffer height must match the source IOSurface",
|
|
||||||
);
|
|
||||||
assert_eq!(cache.cached_surface_count(), 1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn second_import_with_same_surface_id_is_idempotent() -> Result<(), String> {
|
|
||||||
let mut cache = IOSurfaceCache::new();
|
|
||||||
let iosurface = build_local_iosurface(64, 48)?;
|
|
||||||
let port_a = iosurface.create_mach_port();
|
|
||||||
let port_b = iosurface.create_mach_port();
|
|
||||||
assert!(port_a != 0 && port_b != 0 && port_a != port_b);
|
|
||||||
|
|
||||||
cache.import(port_a, 0xAAAA_AAAA).map_err(|error| error.to_string())?;
|
|
||||||
// Same surface_id → defensive dedup path; port_b is deallocated
|
|
||||||
// without minting a duplicate CVPixelBuffer.
|
|
||||||
cache.import(port_b, 0xAAAA_AAAA).map_err(|error| error.to_string())?;
|
|
||||||
assert_eq!(cache.cached_surface_count(), 1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn same_surface_id_with_changed_dimensions_replaces_pixel_buffer() -> Result<(), String> {
|
|
||||||
let mut cache = IOSurfaceCache::new();
|
|
||||||
let initial = build_local_iosurface(64, 48)?;
|
|
||||||
let resized = build_local_iosurface(96, 72)?;
|
|
||||||
let surface_id = 0xBBBB_BBBB;
|
|
||||||
|
|
||||||
cache.import(initial.create_mach_port(), surface_id).map_err(|error| error.to_string())?;
|
|
||||||
cache.import(resized.create_mach_port(), surface_id).map_err(|error| error.to_string())?;
|
|
||||||
|
|
||||||
let pixel_buffer = cache
|
|
||||||
.pixel_buffer_for(surface_id)
|
|
||||||
.ok_or_else(|| "resized pixel buffer was missing".to_string())?;
|
|
||||||
assert_eq!(pixel_buffer.get_width() as u32, 96);
|
|
||||||
assert_eq!(pixel_buffer.get_height() as u32, 72);
|
|
||||||
assert_eq!(cache.cached_surface_count(), 1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
pub mod download_checksums;
|
pub mod download_checksums;
|
||||||
pub mod download_files;
|
pub mod download_files;
|
||||||
pub mod http_downloads;
|
pub mod http_downloads;
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub(crate) mod iosurface_mach;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub(crate) mod iosurface_metal;
|
|
||||||
pub mod plugin_package_store;
|
pub mod plugin_package_store;
|
||||||
pub mod plugin_packages;
|
pub mod plugin_packages;
|
||||||
pub mod plugin_signatures;
|
pub mod plugin_signatures;
|
||||||
pub mod servo_live;
|
pub mod servo_live;
|
||||||
pub(crate) mod servo_profile_data;
|
pub(crate) mod servo_profile_data;
|
||||||
mod servo_sidecar_command;
|
|
||||||
|
|
||||||
pub(crate) use servo_profile_data::ProfileDataMode;
|
pub(crate) use servo_profile_data::ProfileDataMode;
|
||||||
|
|
||||||
|
|||||||
@@ -1,286 +1,300 @@
|
|||||||
use std::{
|
use std::{collections::BTreeMap, path::PathBuf};
|
||||||
io::{BufRead, BufReader, Read, Write},
|
|
||||||
path::PathBuf,
|
use ely_domain::{
|
||||||
process::{Child, ChildStdin, ChildStdout, Stdio},
|
ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, TabId, UrlText, WebViewId,
|
||||||
|
};
|
||||||
|
use ely_servo_host::{
|
||||||
|
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest,
|
||||||
|
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest,
|
||||||
|
ScrollRequest, ServoHost, ServoSurfaceSize, SoftwareServoHost,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[path = "servo_live_iosurface_importer.rs"]
|
|
||||||
mod iosurface_importer;
|
|
||||||
#[path = "servo_live_types.rs"]
|
#[path = "servo_live_types.rs"]
|
||||||
mod types;
|
mod types;
|
||||||
/// Environment variable that lets the user pick the rendering context
|
|
||||||
/// kind used by the spawned sidecar. Accepted values: `software`
|
|
||||||
/// and `hardware`. macOS defaults to the hardware path and receives
|
|
||||||
/// IOSurface mach send rights over a side Mach channel.
|
|
||||||
#[path = "servo_live_wire.rs"]
|
|
||||||
mod wire;
|
|
||||||
|
|
||||||
pub(crate) use types::{
|
pub(crate) use types::{
|
||||||
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
|
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::servo_sidecar_command::{
|
|
||||||
SidecarRenderingContext, default_sidecar_command, rendering_context_from_env,
|
|
||||||
};
|
|
||||||
use wire::{
|
|
||||||
LiveRequest, LiveResponse, LiveSurfaceHandle, log_frame_perf, log_iosurface_current,
|
|
||||||
log_iosurface_handle,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use super::iosurface_metal::IOSurfaceCache;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker};
|
|
||||||
|
|
||||||
pub(crate) struct ServoLiveClient {
|
pub(crate) struct ServoLiveClient {
|
||||||
child: Child,
|
host: SoftwareServoHost,
|
||||||
stdin: ChildStdin,
|
sessions: BTreeMap<String, DirectWebViewSession>,
|
||||||
stdout: BufReader<ChildStdout>,
|
|
||||||
/// Cache of imported `CVPixelBuffer`s keyed by surface_id. Built
|
|
||||||
/// lazily on the first `surface_handle` the sidecar publishes —
|
|
||||||
/// software-path tabs never trigger construction.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
iosurface_cache: IOSurfaceCache,
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
iosurface_importer: Option<IOSurfaceImportWorker>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServoLiveClient {
|
impl ServoLiveClient {
|
||||||
pub fn new(profile_data_dir: PathBuf) -> Result<Self, ServoLiveError> {
|
pub fn new(profile_data_dir: PathBuf) -> Result<Self, ServoLiveError> {
|
||||||
let command_target = default_sidecar_command()?;
|
let host = SoftwareServoHost::new_with_config_dir(
|
||||||
if let Some(path) = command_target.missing_binary_path() {
|
ServoSurfaceSize::new(1, 1),
|
||||||
return Err(ServoLiveError::SidecarBinaryUnavailable { path: path.to_path_buf() });
|
Some(profile_data_dir),
|
||||||
}
|
)?;
|
||||||
|
Ok(Self { host, sessions: BTreeMap::new() })
|
||||||
let rendering_context = rendering_context_from_env();
|
|
||||||
let mut command = command_target.command();
|
|
||||||
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
|
|
||||||
command.arg("--rendering-context").arg(rendering_context.cli_arg());
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let iosurface_importer = if rendering_context == SidecarRenderingContext::Hardware {
|
|
||||||
let receiver = super::iosurface_mach::IOSurfaceMachReceiver::new()?;
|
|
||||||
command.arg("--iosurface-mach-service").arg(receiver.service_name());
|
|
||||||
Some(
|
|
||||||
IOSurfaceImportWorker::new(receiver)
|
|
||||||
.map_err(ServoLiveError::IOSurfaceImportWorker)?,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let mut child = command
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.map_err(ServoLiveError::Command)?;
|
|
||||||
let stdin = child.stdin.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdin" })?;
|
|
||||||
let stdout =
|
|
||||||
child.stdout.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdout" })?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
child,
|
|
||||||
stdin,
|
|
||||||
stdout: BufReader::new(stdout),
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
iosurface_cache: IOSurfaceCache::new(),
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
iosurface_importer,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure(
|
pub fn ensure(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: ServoLiveEnsureRequest,
|
request: ServoLiveEnsureRequest,
|
||||||
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||||
#[cfg(target_os = "macos")]
|
let tab_id = TabId::parse(request.tab_id.clone())?;
|
||||||
self.drain_iosurface_imports()?;
|
let profile_id = ProfileId::parse(request.profile_id.clone())?;
|
||||||
let ready_surface_ids = self.ready_surface_ids();
|
let requested_url = UrlText::parse(request.url.clone())?;
|
||||||
self.request(LiveRequest::Ensure {
|
let webview_id = self.ensure_webview(&request, &tab_id, &profile_id)?;
|
||||||
tab_id: request.tab_id,
|
|
||||||
profile_id: request.profile_id,
|
self.apply_viewport(&request, &webview_id)?;
|
||||||
url: request.url,
|
self.apply_permissions(&request, &webview_id, &profile_id)?;
|
||||||
|
self.apply_navigation(&request, &webview_id, tab_id, requested_url)?;
|
||||||
|
self.apply_input(&request, &webview_id)?;
|
||||||
|
self.host.tick();
|
||||||
|
if !self.session_uses_native_surface(&request.tab_id) {
|
||||||
|
return Err(ServoLiveError::NativeSurfaceUnavailable);
|
||||||
|
}
|
||||||
|
self.host.paint_without_readback_with_completion(&webview_id, false)?;
|
||||||
|
|
||||||
|
let frame = self.frame_from_session(&request.tab_id, &webview_id)?;
|
||||||
|
Ok(Some(frame))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||||
|
let Some(session) = self.sessions.get(&tab_id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let webview_id = session.webview_id.clone();
|
||||||
|
let uses_native_surface = session.native_surface_id.is_some();
|
||||||
|
|
||||||
|
self.host.tick();
|
||||||
|
if !self.host.snapshot(&webview_id)?.has_pending_frame() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !uses_native_surface {
|
||||||
|
return Err(ServoLiveError::NativeSurfaceUnavailable);
|
||||||
|
}
|
||||||
|
self.host.paint_without_readback_with_completion(&webview_id, false)?;
|
||||||
|
self.frame_from_session(&tab_id, &webview_id).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> {
|
||||||
|
let Some(session) = self.sessions.remove(&tab_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
self.host.close_webview(&session.webview_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_webview(
|
||||||
|
&mut self,
|
||||||
|
request: &ServoLiveEnsureRequest,
|
||||||
|
tab_id: &TabId,
|
||||||
|
profile_id: &ProfileId,
|
||||||
|
) -> Result<WebViewId, ServoLiveError> {
|
||||||
|
if self
|
||||||
|
.sessions
|
||||||
|
.get(&request.tab_id)
|
||||||
|
.is_some_and(|session| session.profile_id != *profile_id)
|
||||||
|
&& let Some(session) = self.sessions.remove(&request.tab_id)
|
||||||
|
{
|
||||||
|
self.host.close_webview(&session.webview_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let native_surface_id =
|
||||||
|
request.native_surface.as_ref().map(gpui::NativeSurfaceHandle::identity);
|
||||||
|
if self
|
||||||
|
.sessions
|
||||||
|
.get(&request.tab_id)
|
||||||
|
.is_some_and(|session| session.native_surface_id != native_surface_id)
|
||||||
|
&& let Some(session) = self.sessions.remove(&request.tab_id)
|
||||||
|
{
|
||||||
|
self.host.close_webview(&session.webview_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(session) = self.sessions.get(&request.tab_id) {
|
||||||
|
return Ok(session.webview_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let surface_size = ServoSurfaceSize::new(request.width, request.height);
|
||||||
|
let webview_id = match request.native_surface.as_ref() {
|
||||||
|
Some(native_surface) => self.host.create_webview_with_native_surface(
|
||||||
|
tab_id.clone(),
|
||||||
|
profile_id.clone(),
|
||||||
|
surface_size,
|
||||||
|
native_surface,
|
||||||
|
)?,
|
||||||
|
None => self.host.create_webview_with_size(
|
||||||
|
tab_id.clone(),
|
||||||
|
profile_id.clone(),
|
||||||
|
surface_size,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
self.sessions.insert(
|
||||||
|
request.tab_id.clone(),
|
||||||
|
DirectWebViewSession {
|
||||||
|
webview_id: webview_id.clone(),
|
||||||
|
profile_id: profile_id.clone(),
|
||||||
|
requested_url: None,
|
||||||
width: request.width,
|
width: request.width,
|
||||||
height: request.height,
|
height: request.height,
|
||||||
page_zoom_percent: request.page_zoom_percent,
|
page_zoom_percent: request.page_zoom_percent,
|
||||||
device_pixel_ratio: request.device_pixel_ratio,
|
device_pixel_ratio: request.device_pixel_ratio,
|
||||||
scroll_delta_x: request.scroll_delta_x,
|
native_surface_id,
|
||||||
scroll_delta_y: request.scroll_delta_y,
|
},
|
||||||
scroll_point_x: request.scroll_point_x,
|
);
|
||||||
scroll_point_y: request.scroll_point_y,
|
Ok(webview_id)
|
||||||
click_x: request.click_x,
|
|
||||||
click_y: request.click_y,
|
|
||||||
hover_x: request.hover_x,
|
|
||||||
hover_y: request.hover_y,
|
|
||||||
typed_text: request.typed_text,
|
|
||||||
site_permissions: request.site_permissions,
|
|
||||||
ready_surface_ids,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
fn apply_viewport(
|
||||||
#[cfg(target_os = "macos")]
|
&mut self,
|
||||||
self.drain_iosurface_imports()?;
|
request: &ServoLiveEnsureRequest,
|
||||||
let ready_surface_ids = self.ready_surface_ids();
|
webview_id: &WebViewId,
|
||||||
self.request(LiveRequest::Poll { tab_id, ready_surface_ids })
|
) -> Result<(), ServoLiveError> {
|
||||||
}
|
let Some(session) = self.sessions.get_mut(&request.tab_id) else {
|
||||||
|
|
||||||
pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> {
|
|
||||||
self.request(LiveRequest::Close { tab_id }).map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
|
||||||
serde_json::to_writer(&mut self.stdin, &request)?;
|
|
||||||
self.stdin.write_all(b"\n").map_err(ServoLiveError::Command)?;
|
|
||||||
self.stdin.flush().map_err(ServoLiveError::Command)?;
|
|
||||||
|
|
||||||
let mut line = String::new();
|
|
||||||
let bytes = self.stdout.read_line(&mut line).map_err(ServoLiveError::Command)?;
|
|
||||||
if bytes == 0 {
|
|
||||||
return Err(ServoLiveError::SidecarExited);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: LiveResponse = serde_json::from_str(&line)?;
|
|
||||||
if let Some(error) = response.error {
|
|
||||||
return Err(ServoLiveError::SidecarFailed { message: error });
|
|
||||||
}
|
|
||||||
let surface_handle = response.surface_handle;
|
|
||||||
let current_surface_id = response.current_surface_id;
|
|
||||||
|
|
||||||
if let Some(perf) = response.perf.as_ref() {
|
|
||||||
log_frame_perf(perf);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(handle) = surface_handle.as_ref() {
|
|
||||||
log_iosurface_handle(handle);
|
|
||||||
}
|
|
||||||
if let Some(surface_id) = current_surface_id {
|
|
||||||
log_iosurface_current(surface_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report) = response.frame else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sanity bound the byte count advertised by the sidecar
|
|
||||||
// header so a buggy or hostile sidecar can't park us on
|
|
||||||
// `read_exact` for an arbitrarily-sized buffer. The honest
|
|
||||||
// upper limit is `width * height * 4` (RGBA8); `0` is the
|
|
||||||
// explicit "hardware path active, sample the IOSurface"
|
|
||||||
// signal; any other byte count is a protocol violation.
|
|
||||||
let pixel_byte_count =
|
|
||||||
(report.width as u64).saturating_mul(report.height as u64).saturating_mul(4);
|
|
||||||
let advertised = report.rgba_byte_count as u64;
|
|
||||||
if advertised != 0 && advertised != pixel_byte_count {
|
|
||||||
return Err(ServoLiveError::FrameBudgetExceeded {
|
|
||||||
advertised: report.rgba_byte_count,
|
|
||||||
pixel_budget: pixel_byte_count,
|
|
||||||
width: report.width,
|
|
||||||
height: report.height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raw frame bytes follow the JSON header on the same pipe for
|
|
||||||
// software frames. `read_exact` drains BufReader's buffer first
|
|
||||||
// (the line read never crosses the `\n` boundary) and then
|
|
||||||
// pulls the rest straight from the child's stdout.
|
|
||||||
let mut rgba_bytes = vec![0u8; report.rgba_byte_count];
|
|
||||||
if report.rgba_byte_count > 0 {
|
|
||||||
self.stdout.read_exact(&mut rgba_bytes).map_err(ServoLiveError::FrameRead)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_software_payload = report.rgba_byte_count > 0;
|
|
||||||
let mut frame = ServoLiveFrame::from_parts(report, rgba_bytes);
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
if let Some(handle) = surface_handle.as_ref() {
|
|
||||||
self.queue_iosurface_handle(*handle)?;
|
|
||||||
self.drain_iosurface_imports()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
if let Some(surface_id) = current_surface_id {
|
|
||||||
let pixel_buffer = self.iosurface_cache.pixel_buffer_for(surface_id);
|
|
||||||
if pixel_buffer.is_none() && !has_software_payload {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
frame.set_pixel_buffer(pixel_buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(frame))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
impl ServoLiveClient {
|
|
||||||
fn ready_surface_ids(&self) -> Vec<u64> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
impl ServoLiveClient {
|
|
||||||
fn ready_surface_ids(&self) -> Vec<u64> {
|
|
||||||
self.iosurface_cache.surface_ids()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn queue_iosurface_handle(&mut self, handle: LiveSurfaceHandle) -> Result<(), ServoLiveError> {
|
|
||||||
let Some(importer) = self.iosurface_importer.as_ref() else {
|
|
||||||
return Err(ServoLiveError::IOSurfaceImportFailed {
|
|
||||||
surface_id: handle.surface_id,
|
|
||||||
mach_port_name: handle.mach_port_name,
|
|
||||||
message: "IOSurface import worker is unavailable".to_string(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed {
|
|
||||||
surface_id: failure.surface_id,
|
|
||||||
mach_port_name: failure.mach_port_name,
|
|
||||||
message: failure.message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> {
|
|
||||||
let Some(importer) = self.iosurface_importer.as_ref() else {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
for result in importer.drain() {
|
|
||||||
match result {
|
if session.width != request.width || session.height != request.height {
|
||||||
IOSurfaceImportResult::Imported(imported) => {
|
self.host.resize(ResizeRequest {
|
||||||
self.iosurface_cache
|
webview_id: webview_id.clone(),
|
||||||
.insert_pixel_buffer(imported.surface_id, imported.pixel_buffer);
|
width: request.width,
|
||||||
tracing::info!(
|
height: request.height,
|
||||||
target: "ely::servo::iosurface",
|
})?;
|
||||||
surface_id = imported.surface_id,
|
session.width = request.width;
|
||||||
width = imported.width,
|
session.height = request.height;
|
||||||
height = imported.height,
|
|
||||||
"imported IOSurface into CVPixelBuffer cache",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
IOSurfaceImportResult::Failed(failure) => {
|
|
||||||
tracing::warn!(
|
if session.device_pixel_ratio != request.device_pixel_ratio {
|
||||||
target: "ely::servo::iosurface",
|
self.host.set_hidpi_scale(HidpiScaleRequest {
|
||||||
surface_id = failure.surface_id,
|
webview_id: webview_id.clone(),
|
||||||
width = failure.width,
|
scale_factor: request.device_pixel_ratio,
|
||||||
height = failure.height,
|
})?;
|
||||||
mach_port_name = failure.mach_port_name,
|
session.device_pixel_ratio = request.device_pixel_ratio;
|
||||||
message = %failure.message,
|
|
||||||
"IOSurface import worker failed",
|
|
||||||
);
|
|
||||||
return Err(ServoLiveError::IOSurfaceImportFailed {
|
|
||||||
surface_id: failure.surface_id,
|
|
||||||
mach_port_name: failure.mach_port_name,
|
|
||||||
message: failure.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if session.page_zoom_percent != request.page_zoom_percent {
|
||||||
|
self.host.set_page_zoom(PageZoomRequest {
|
||||||
|
webview_id: webview_id.clone(),
|
||||||
|
zoom_factor: f32::from(request.page_zoom_percent) / 100.0,
|
||||||
|
})?;
|
||||||
|
session.page_zoom_percent = request.page_zoom_percent;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_permissions(
|
||||||
|
&mut self,
|
||||||
|
request: &ServoLiveEnsureRequest,
|
||||||
|
webview_id: &WebViewId,
|
||||||
|
profile_id: &ProfileId,
|
||||||
|
) -> Result<(), ServoLiveError> {
|
||||||
|
for permission in &request.site_permissions {
|
||||||
|
let origin = SiteOrigin::parse(permission.origin.clone())?;
|
||||||
|
let feature = SitePermissionFeature::parse(permission.feature.as_str())?;
|
||||||
|
let decision = SitePermissionDecision::parse(permission.decision.as_str())?;
|
||||||
|
self.host.set_permission(
|
||||||
|
PermissionRequest {
|
||||||
|
webview_id: webview_id.clone(),
|
||||||
|
profile_id: profile_id.clone(),
|
||||||
|
origin,
|
||||||
|
feature,
|
||||||
|
},
|
||||||
|
PermissionDecision::from(decision),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_navigation(
|
||||||
|
&mut self,
|
||||||
|
request: &ServoLiveEnsureRequest,
|
||||||
|
webview_id: &WebViewId,
|
||||||
|
tab_id: TabId,
|
||||||
|
requested_url: UrlText,
|
||||||
|
) -> Result<(), ServoLiveError> {
|
||||||
|
let should_navigate = self
|
||||||
|
.sessions
|
||||||
|
.get(&request.tab_id)
|
||||||
|
.and_then(|session| session.requested_url.as_deref())
|
||||||
|
.is_none_or(|current| current != requested_url.as_str());
|
||||||
|
if should_navigate {
|
||||||
|
self.host.navigate(NavigationRequest {
|
||||||
|
webview_id: webview_id.clone(),
|
||||||
|
tab_id,
|
||||||
|
url: requested_url.clone(),
|
||||||
|
})?;
|
||||||
|
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
|
||||||
|
session.requested_url = Some(requested_url.as_str().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_input(
|
||||||
|
&mut self,
|
||||||
|
request: &ServoLiveEnsureRequest,
|
||||||
|
webview_id: &WebViewId,
|
||||||
|
) -> Result<(), ServoLiveError> {
|
||||||
|
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
|
||||||
|
let point_x = request.scroll_point_x.ok_or(ServoLiveError::MissingScrollPoint)?;
|
||||||
|
let point_y = request.scroll_point_y.ok_or(ServoLiveError::MissingScrollPoint)?;
|
||||||
|
self.host.scroll(ScrollRequest {
|
||||||
|
webview_id: webview_id.clone(),
|
||||||
|
delta_x: request.scroll_delta_x,
|
||||||
|
delta_y: request.scroll_delta_y,
|
||||||
|
point_x,
|
||||||
|
point_y,
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for ServoLiveClient {
|
if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) {
|
||||||
fn drop(&mut self) {
|
self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?;
|
||||||
let _ = self.child.kill();
|
}
|
||||||
let _ = self.child.wait();
|
|
||||||
|
if let (Some(x), Some(y)) = (request.click_x, request.click_y) {
|
||||||
|
self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = request.typed_text.as_ref() {
|
||||||
|
self.host.type_text(KeyboardTextRequest {
|
||||||
|
webview_id: webview_id.clone(),
|
||||||
|
text: text.clone(),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_from_session(
|
||||||
|
&self,
|
||||||
|
tab_id: &str,
|
||||||
|
webview_id: &WebViewId,
|
||||||
|
) -> Result<ServoLiveFrame, ServoLiveError> {
|
||||||
|
let Some(session) = self.sessions.get(tab_id) else {
|
||||||
|
return Err(ServoLiveError::Host(ely_servo_host::ServoHostError::WebViewNotFound {
|
||||||
|
id: webview_id.clone(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
if session.native_surface_id.is_some() {
|
||||||
|
let snapshot = self.host.snapshot(webview_id)?;
|
||||||
|
return Ok(ServoLiveFrame::from_presented(
|
||||||
|
snapshot,
|
||||||
|
session.width,
|
||||||
|
session.height,
|
||||||
|
session.device_pixel_ratio,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(ServoLiveError::NativeSurfaceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_uses_native_surface(&self, tab_id: &str) -> bool {
|
||||||
|
self.sessions.get(tab_id).is_some_and(|session| session.native_surface_id.is_some())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DirectWebViewSession {
|
||||||
|
webview_id: WebViewId,
|
||||||
|
profile_id: ProfileId,
|
||||||
|
requested_url: Option<String>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
page_zoom_percent: u16,
|
||||||
|
device_pixel_ratio: f32,
|
||||||
|
native_surface_id: Option<usize>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
#![cfg(target_os = "macos")]
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
io,
|
|
||||||
sync::mpsc,
|
|
||||||
thread::{self, JoinHandle},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use core_video::pixel_buffer::CVPixelBuffer;
|
|
||||||
|
|
||||||
use crate::services::{
|
|
||||||
iosurface_mach::IOSurfaceMachReceiver, iosurface_metal::import_pixel_buffer_from_mach_port,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::wire::LiveSurfaceHandle;
|
|
||||||
|
|
||||||
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1);
|
|
||||||
|
|
||||||
pub(super) struct IOSurfaceImportWorker {
|
|
||||||
request_tx: Option<mpsc::Sender<LiveSurfaceHandle>>,
|
|
||||||
result_rx: mpsc::Receiver<IOSurfaceImportResult>,
|
|
||||||
thread: Option<JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IOSurfaceImportWorker {
|
|
||||||
pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result<Self, io::Error> {
|
|
||||||
let (request_tx, request_rx) = mpsc::channel();
|
|
||||||
let (result_tx, result_rx) = mpsc::channel();
|
|
||||||
let thread = thread::Builder::new()
|
|
||||||
.name("ely-iosurface-import".to_string())
|
|
||||||
.spawn(move || run_import_worker(receiver, request_rx, result_tx))?;
|
|
||||||
|
|
||||||
Ok(Self { request_tx: Some(request_tx), result_rx, thread: Some(thread) })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn submit(&self, handle: LiveSurfaceHandle) -> Result<(), IOSurfaceImportFailure> {
|
|
||||||
let Some(request_tx) = self.request_tx.as_ref() else {
|
|
||||||
return Err(IOSurfaceImportFailure::worker_stopped(handle));
|
|
||||||
};
|
|
||||||
request_tx.send(handle).map_err(|error| IOSurfaceImportFailure::worker_stopped(error.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn drain(&self) -> Vec<IOSurfaceImportResult> {
|
|
||||||
let mut results = Vec::new();
|
|
||||||
while let Ok(result) = self.result_rx.try_recv() {
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
results
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for IOSurfaceImportWorker {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.request_tx.take();
|
|
||||||
if let Some(thread) = self.thread.take() {
|
|
||||||
let _ = thread.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) enum IOSurfaceImportResult {
|
|
||||||
Imported(ImportedIOSurface),
|
|
||||||
Failed(IOSurfaceImportFailure),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct ImportedIOSurface {
|
|
||||||
pub(super) surface_id: u64,
|
|
||||||
pub(super) width: u32,
|
|
||||||
pub(super) height: u32,
|
|
||||||
pub(super) pixel_buffer: CVPixelBuffer,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: CVPixelBuffer is a CoreFoundation object with atomic
|
|
||||||
// retain/release semantics. This wrapper crosses from the importer
|
|
||||||
// thread to the live worker thread; GPUI presentation already receives
|
|
||||||
// the same handle through ServoLiveFrame's Send contract.
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe impl Send for ImportedIOSurface {}
|
|
||||||
|
|
||||||
pub(super) struct IOSurfaceImportFailure {
|
|
||||||
pub(super) surface_id: u64,
|
|
||||||
pub(super) width: u32,
|
|
||||||
pub(super) height: u32,
|
|
||||||
pub(super) mach_port_name: u32,
|
|
||||||
pub(super) message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IOSurfaceImportFailure {
|
|
||||||
fn worker_stopped(handle: LiveSurfaceHandle) -> Self {
|
|
||||||
Self {
|
|
||||||
surface_id: handle.surface_id,
|
|
||||||
width: handle.width,
|
|
||||||
height: handle.height,
|
|
||||||
mach_port_name: handle.mach_port_name,
|
|
||||||
message: "IOSurface import worker stopped".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_import_worker(
|
|
||||||
mut receiver: IOSurfaceMachReceiver,
|
|
||||||
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
|
|
||||||
result_tx: mpsc::Sender<IOSurfaceImportResult>,
|
|
||||||
) {
|
|
||||||
while let Ok(handle) = request_rx.recv() {
|
|
||||||
let result = import_surface_handle(&mut receiver, handle);
|
|
||||||
if result_tx.send(result).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn import_surface_handle(
|
|
||||||
receiver: &mut IOSurfaceMachReceiver,
|
|
||||||
handle: LiveSurfaceHandle,
|
|
||||||
) -> IOSurfaceImportResult {
|
|
||||||
let mach_port_name = match receiver.receive_port_for_surface(handle.surface_id, RECEIVE_TIMEOUT)
|
|
||||||
{
|
|
||||||
Ok(mach_port_name) => mach_port_name,
|
|
||||||
Err(error) => {
|
|
||||||
return IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
|
||||||
surface_id: handle.surface_id,
|
|
||||||
width: handle.width,
|
|
||||||
height: handle.height,
|
|
||||||
mach_port_name: handle.mach_port_name,
|
|
||||||
message: error.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match import_pixel_buffer_from_mach_port(mach_port_name) {
|
|
||||||
Ok(pixel_buffer) => IOSurfaceImportResult::Imported(ImportedIOSurface {
|
|
||||||
surface_id: handle.surface_id,
|
|
||||||
width: handle.width,
|
|
||||||
height: handle.height,
|
|
||||||
pixel_buffer,
|
|
||||||
}),
|
|
||||||
Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
|
||||||
surface_id: handle.surface_id,
|
|
||||||
width: handle.width,
|
|
||||||
height: handle.height,
|
|
||||||
mach_port_name,
|
|
||||||
message: error.to_string(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
use std::{io, path::PathBuf};
|
|
||||||
|
|
||||||
use ely_domain::SitePermissionDecision;
|
use ely_domain::SitePermissionDecision;
|
||||||
|
use ely_servo_host::{ServoHostError, WebViewSnapshot, WebViewState};
|
||||||
|
use gpui::NativeSurfaceHandle;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use super::wire::LiveFrameReport;
|
|
||||||
use crate::services::servo_sidecar_command::SidecarCommandError;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use crate::services::iosurface_mach::IOSurfaceMachError;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use core_video::pixel_buffer::CVPixelBuffer;
|
|
||||||
|
|
||||||
pub(crate) struct ServoLiveEnsureRequest {
|
pub(crate) struct ServoLiveEnsureRequest {
|
||||||
pub(crate) tab_id: String,
|
pub(crate) tab_id: String,
|
||||||
pub(crate) profile_id: String,
|
pub(crate) profile_id: String,
|
||||||
@@ -20,10 +12,9 @@ pub(crate) struct ServoLiveEnsureRequest {
|
|||||||
pub(crate) height: u32,
|
pub(crate) height: u32,
|
||||||
pub(crate) page_zoom_percent: u16,
|
pub(crate) page_zoom_percent: u16,
|
||||||
/// Display scale factor (1.0 standard, 2.0 Retina). Servo's
|
/// Display scale factor (1.0 standard, 2.0 Retina). Servo's
|
||||||
/// WebView lays out CSS pixels = device pixels / hidpi factor;
|
/// WebView lays out CSS pixels = device pixels / hidpi factor.
|
||||||
/// without this, a Retina viewport gets desktop-CSS-pixel layout
|
|
||||||
/// and every visible element renders at half its expected size.
|
|
||||||
pub(crate) device_pixel_ratio: f32,
|
pub(crate) device_pixel_ratio: f32,
|
||||||
|
pub(crate) native_surface: Option<NativeSurfaceHandle>,
|
||||||
pub(crate) scroll_delta_x: i32,
|
pub(crate) scroll_delta_x: i32,
|
||||||
pub(crate) scroll_delta_y: i32,
|
pub(crate) scroll_delta_y: i32,
|
||||||
pub(crate) scroll_point_x: Option<u32>,
|
pub(crate) scroll_point_x: Option<u32>,
|
||||||
@@ -68,54 +59,37 @@ pub(crate) struct ServoLiveFrame {
|
|||||||
content_pixel_count: u64,
|
content_pixel_count: u64,
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
sample_hash: u64,
|
sample_hash: u64,
|
||||||
rgba_bytes: Vec<u8>,
|
rgba_bytes: Option<Vec<u8>>,
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pixel_buffer: Option<CVPixelBuffer>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type
|
|
||||||
// Apple documents as safe to share across threads. The Rust core-video
|
|
||||||
// crate does not mark it Send, so the worker thread needs this opt-in
|
|
||||||
// to ship hardware frames back to the UI thread via mpsc::Sender.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe impl Send for ServoLiveFrame {}
|
|
||||||
|
|
||||||
impl ServoLiveFrame {
|
impl ServoLiveFrame {
|
||||||
pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Vec<u8>) -> Self {
|
pub(super) fn from_presented(
|
||||||
let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report);
|
snapshot: WebViewSnapshot,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
device_pixel_ratio: f32,
|
||||||
|
) -> Self {
|
||||||
|
let (css_viewport_width, css_viewport_height) =
|
||||||
|
css_viewport_size(width, height, device_pixel_ratio);
|
||||||
Self {
|
Self {
|
||||||
loaded_url: report.loaded_url,
|
loaded_url: snapshot.url().map(str::to_string),
|
||||||
title: report.title,
|
title: snapshot.title().map(str::to_string),
|
||||||
render_state: report.state,
|
render_state: render_state_label(snapshot.state()).to_string(),
|
||||||
width: report.width,
|
width,
|
||||||
height: report.height,
|
height,
|
||||||
device_pixel_ratio: report.device_pixel_ratio,
|
device_pixel_ratio,
|
||||||
css_viewport_width,
|
css_viewport_width,
|
||||||
css_viewport_height,
|
css_viewport_height,
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
non_white_pixel_count: report.non_white_pixel_count,
|
non_white_pixel_count: 1,
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
content_pixel_count: report.content_pixel_count,
|
content_pixel_count: 1,
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
sample_hash: report.sample_hash,
|
sample_hash: 0,
|
||||||
rgba_bytes,
|
rgba_bytes: None,
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pixel_buffer: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub(super) fn set_pixel_buffer(&mut self, pixel_buffer: Option<CVPixelBuffer>) {
|
|
||||||
self.pixel_buffer = pixel_buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[must_use]
|
|
||||||
pub fn pixel_buffer(&self) -> Option<&CVPixelBuffer> {
|
|
||||||
self.pixel_buffer.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn loaded_url(&self) -> Option<&str> {
|
pub fn loaded_url(&self) -> Option<&str> {
|
||||||
self.loaded_url.as_deref()
|
self.loaded_url.as_deref()
|
||||||
@@ -175,7 +149,7 @@ impl ServoLiveFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn into_rgba_bytes(self) -> Vec<u8> {
|
pub fn into_rgba_bytes(self) -> Option<Vec<u8>> {
|
||||||
self.rgba_bytes
|
self.rgba_bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,113 +170,50 @@ impl ServoLiveFrame {
|
|||||||
content_pixel_count: 0,
|
content_pixel_count: 0,
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
sample_hash: 0,
|
sample_hash: 0,
|
||||||
rgba_bytes,
|
rgba_bytes: Some(rgba_bytes),
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pixel_buffer: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, target_os = "macos"))]
|
|
||||||
pub(crate) fn for_test_with_pixel_buffer(
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
pixel_buffer: CVPixelBuffer,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
loaded_url: Some("https://example.com/".to_string()),
|
|
||||||
title: Some("Example".to_string()),
|
|
||||||
render_state: "complete".to_string(),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
device_pixel_ratio: 1.0,
|
|
||||||
css_viewport_width: width,
|
|
||||||
css_viewport_height: height,
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
|
||||||
non_white_pixel_count: 0,
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
|
||||||
content_pixel_count: 0,
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
|
||||||
sample_hash: 0,
|
|
||||||
rgba_bytes: Vec::new(),
|
|
||||||
pixel_buffer: Some(pixel_buffer),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) {
|
fn render_state_label(state: &WebViewState) -> &'static str {
|
||||||
let dpr = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 {
|
match state {
|
||||||
report.device_pixel_ratio
|
WebViewState::Created => "created",
|
||||||
|
WebViewState::Loading => "loading",
|
||||||
|
WebViewState::Complete => "complete",
|
||||||
|
WebViewState::Sleeping => "sleeping",
|
||||||
|
WebViewState::Crashed => "crashed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
|
||||||
|
let scale = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
|
||||||
|
device_pixel_ratio
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
let fallback_width = ((report.width as f32) / dpr).round().max(1.0) as u32;
|
|
||||||
let fallback_height = ((report.height as f32) / dpr).round().max(1.0) as u32;
|
|
||||||
(
|
(
|
||||||
if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width },
|
((width as f32) / scale).round().max(1.0) as u32,
|
||||||
if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height },
|
((height as f32) / scale).round().max(1.0) as u32,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub(crate) enum ServoLiveError {
|
pub(crate) enum ServoLiveError {
|
||||||
#[error("servo sidecar binary is unavailable at {path}")]
|
#[error("servo native surface is unavailable")]
|
||||||
SidecarBinaryUnavailable { path: PathBuf },
|
NativeSurfaceUnavailable,
|
||||||
|
|
||||||
#[error("failed to run servo live sidecar: {0}")]
|
#[error("servo scroll input is missing a viewport point")]
|
||||||
Command(#[source] io::Error),
|
MissingScrollPoint,
|
||||||
|
|
||||||
#[error("servo live sidecar pipe is unavailable: {name}")]
|
|
||||||
PipeUnavailable { name: &'static str },
|
|
||||||
|
|
||||||
#[error("servo live sidecar exited")]
|
|
||||||
SidecarExited,
|
|
||||||
|
|
||||||
#[error("servo live sidecar failed: {message}")]
|
|
||||||
SidecarFailed { message: String },
|
|
||||||
|
|
||||||
#[error("failed to read servo live frame bytes: {0}")]
|
|
||||||
FrameRead(#[source] io::Error),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"servo live sidecar advertised {advertised} frame bytes which exceeds \
|
|
||||||
the {width}x{height} pixel budget ({pixel_budget} bytes)"
|
|
||||||
)]
|
|
||||||
FrameBudgetExceeded { advertised: usize, pixel_budget: u64, width: u32, height: u32 },
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[error(
|
|
||||||
"servo live IOSurface import failed for surface {surface_id:#x} \
|
|
||||||
mach port 0x{mach_port_name:x}: {message}"
|
|
||||||
)]
|
|
||||||
IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String },
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[error("failed to spawn servo live IOSurface importer: {0}")]
|
|
||||||
IOSurfaceImportWorker(#[source] io::Error),
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[error(transparent)]
|
|
||||||
IOSurfaceMach(#[from] IOSurfaceMachError),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Json(#[from] serde_json::Error),
|
Domain(#[from] ely_domain::DomainError),
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
SidecarCommand(#[from] SidecarCommandError),
|
Host(#[from] ServoHostError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServoLiveError {
|
impl ServoLiveError {
|
||||||
pub(crate) fn is_sidecar_process_unusable(&self) -> bool {
|
pub(crate) fn is_runtime_unavailable(&self) -> bool {
|
||||||
match self {
|
matches!(self, Self::Host(ServoHostError::RuntimeAlreadyStarted))
|
||||||
Self::SidecarExited => true,
|
|
||||||
Self::Command(error) | Self::FrameRead(error) => matches!(
|
|
||||||
error.kind(),
|
|
||||||
io::ErrorKind::BrokenPipe
|
|
||||||
| io::ErrorKind::ConnectionAborted
|
|
||||||
| io::ErrorKind::ConnectionReset
|
|
||||||
| io::ErrorKind::UnexpectedEof
|
|
||||||
),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use super::ServoLiveSitePermission;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub(super) enum LiveRequest {
|
|
||||||
Ensure {
|
|
||||||
tab_id: String,
|
|
||||||
profile_id: String,
|
|
||||||
url: String,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
page_zoom_percent: u16,
|
|
||||||
device_pixel_ratio: f32,
|
|
||||||
scroll_delta_x: i32,
|
|
||||||
scroll_delta_y: i32,
|
|
||||||
scroll_point_x: Option<u32>,
|
|
||||||
scroll_point_y: Option<u32>,
|
|
||||||
click_x: Option<u32>,
|
|
||||||
click_y: Option<u32>,
|
|
||||||
hover_x: Option<u32>,
|
|
||||||
hover_y: Option<u32>,
|
|
||||||
typed_text: Option<String>,
|
|
||||||
site_permissions: Vec<ServoLiveSitePermission>,
|
|
||||||
ready_surface_ids: Vec<u64>,
|
|
||||||
},
|
|
||||||
Poll {
|
|
||||||
tab_id: String,
|
|
||||||
ready_surface_ids: Vec<u64>,
|
|
||||||
},
|
|
||||||
Close {
|
|
||||||
tab_id: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(super) struct LiveResponse {
|
|
||||||
pub(super) error: Option<String>,
|
|
||||||
pub(super) frame: Option<LiveFrameReport>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(super) perf: Option<LiveFramePerfSummary>,
|
|
||||||
/// Hardware path only: present on the first frame after a new
|
|
||||||
/// IOSurface is bound (initial paint, resize, surfman swap chain
|
|
||||||
/// rotation). T10.4 will turn this into an imported Metal texture;
|
|
||||||
/// for now we log it on the `ely::servo::iosurface` target so the
|
|
||||||
/// pipeline is observable end-to-end without yet wiring it into
|
|
||||||
/// the renderer.
|
|
||||||
#[serde(default)]
|
|
||||||
pub(super) surface_handle: Option<LiveSurfaceHandle>,
|
|
||||||
/// Hardware path only: which previously-imported IOSurface to
|
|
||||||
/// sample this frame. surfman's attached swap chain rotates the
|
|
||||||
/// bound surface, so this id alternates between the values the
|
|
||||||
/// receiver has already imported via `surface_handle`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub(super) current_surface_id: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wire mirror of `ely_servo_host::IOSurfaceHandle`. Duplicated rather
|
|
||||||
/// than imported because `ely_app` only talks to the sidecar via
|
|
||||||
/// stdin/stdout JSON — it has no crate dependency on `ely_servo_host`
|
|
||||||
/// and adding one just to share a four-field struct would pull the
|
|
||||||
/// Servo dep tree into the renderer process.
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
|
||||||
pub(super) struct LiveSurfaceHandle {
|
|
||||||
pub(super) mach_port_name: u32,
|
|
||||||
pub(super) surface_id: u64,
|
|
||||||
pub(super) width: u32,
|
|
||||||
pub(super) height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggregated frame-stage timings rolled up every N frames by the
|
|
||||||
/// sidecar. We accept anything matching the wire shape and let the
|
|
||||||
/// `tracing` event echo the percentiles verbatim — the sidecar is
|
|
||||||
/// the source of truth for histogram boundaries.
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(super) struct LiveFramePerfSummary {
|
|
||||||
window: u32,
|
|
||||||
context: String,
|
|
||||||
paint_p50_us: u64,
|
|
||||||
paint_p95_us: u64,
|
|
||||||
paint_p99_us: u64,
|
|
||||||
encode_p50_us: u64,
|
|
||||||
encode_p95_us: u64,
|
|
||||||
encode_p99_us: u64,
|
|
||||||
write_p50_us: u64,
|
|
||||||
write_p95_us: u64,
|
|
||||||
write_p99_us: u64,
|
|
||||||
total_p50_us: u64,
|
|
||||||
total_p95_us: u64,
|
|
||||||
total_p99_us: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(super) struct LiveFrameReport {
|
|
||||||
pub(super) loaded_url: Option<String>,
|
|
||||||
pub(super) title: Option<String>,
|
|
||||||
pub(super) state: String,
|
|
||||||
pub(super) width: u32,
|
|
||||||
pub(super) height: u32,
|
|
||||||
#[serde(default = "default_device_pixel_ratio")]
|
|
||||||
pub(super) device_pixel_ratio: f32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(super) css_viewport_width: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(super) css_viewport_height: u32,
|
|
||||||
pub(super) rgba_byte_count: usize,
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
|
||||||
pub(super) non_white_pixel_count: u64,
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
|
||||||
pub(super) content_pixel_count: u64,
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
|
||||||
pub(super) sample_hash: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_device_pixel_ratio() -> f32 {
|
|
||||||
1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-frame tag that tells the renderer which already-imported
|
|
||||||
/// `MTLTexture` to sample. Emitted at `trace` instead of `info` because
|
|
||||||
/// it fires every frame on the hardware path; the import event above
|
|
||||||
/// is the rare `info` and this trace is the steady-state breadcrumb.
|
|
||||||
pub(super) fn log_iosurface_current(surface_id: u64) {
|
|
||||||
tracing::trace!(
|
|
||||||
target: "ely::servo::iosurface",
|
|
||||||
surface_id,
|
|
||||||
"iosurface_current",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit one structured `tracing` event per IOSurface handover, on a
|
|
||||||
/// dedicated target so `RUST_LOG=ely::servo::iosurface=info` lights up
|
|
||||||
/// the cross-process surface pipeline without pulling in everything
|
|
||||||
/// else. The renderer (T10.4) will turn the same handle into an
|
|
||||||
/// imported Metal texture; today the event is the observable contract
|
|
||||||
/// that T10.3 plumbing is alive.
|
|
||||||
pub(super) fn log_iosurface_handle(handle: &LiveSurfaceHandle) {
|
|
||||||
tracing::info!(
|
|
||||||
target: "ely::servo::iosurface",
|
|
||||||
mach_port_name = handle.mach_port_name,
|
|
||||||
surface_id = handle.surface_id,
|
|
||||||
width = handle.width,
|
|
||||||
height = handle.height,
|
|
||||||
"iosurface_handle",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit one structured `tracing` event per perf summary, on a
|
|
||||||
/// dedicated target so `RUST_LOG=ely::servo::perf=info` flips the
|
|
||||||
/// stream on without dragging the rest of the app along. Filtering
|
|
||||||
/// happens upstream in the subscriber — this call is a single
|
|
||||||
/// pointer + integer push.
|
|
||||||
pub(super) fn log_frame_perf(summary: &LiveFramePerfSummary) {
|
|
||||||
tracing::info!(
|
|
||||||
target: "ely::servo::perf",
|
|
||||||
window = summary.window,
|
|
||||||
context = %summary.context,
|
|
||||||
paint_p50_us = summary.paint_p50_us,
|
|
||||||
paint_p95_us = summary.paint_p95_us,
|
|
||||||
paint_p99_us = summary.paint_p99_us,
|
|
||||||
encode_p50_us = summary.encode_p50_us,
|
|
||||||
encode_p95_us = summary.encode_p95_us,
|
|
||||||
encode_p99_us = summary.encode_p99_us,
|
|
||||||
write_p50_us = summary.write_p50_us,
|
|
||||||
write_p95_us = summary.write_p95_us,
|
|
||||||
write_p99_us = summary.write_p99_us,
|
|
||||||
total_p50_us = summary.total_p50_us,
|
|
||||||
total_p95_us = summary.total_p95_us,
|
|
||||||
total_p99_us = summary.total_p99_us,
|
|
||||||
"frame_perf",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_request_serializes_to_wire() -> Result<(), serde_json::Error> {
|
|
||||||
let value =
|
|
||||||
serde_json::to_value(LiveRequest::Close { tab_id: "tab-live-close".to_string() })?;
|
|
||||||
|
|
||||||
assert_eq!(value, json!({"type": "close", "tab_id": "tab-live-close"}));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
use std::{
|
|
||||||
env, io,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
process::Command,
|
|
||||||
};
|
|
||||||
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR";
|
|
||||||
const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT";
|
|
||||||
const SOFTWARE_SIDECAR_FEATURES: &str = "servo-engine";
|
|
||||||
const HARDWARE_SIDECAR_FEATURES: &str = "servo-engine,hardware-render";
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(super) enum SidecarCommandTarget {
|
|
||||||
Binary(PathBuf),
|
|
||||||
Cargo { manifest_path: PathBuf },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidecarCommandTarget {
|
|
||||||
pub(super) fn command(&self) -> Command {
|
|
||||||
match self {
|
|
||||||
Self::Binary(path) => Command::new(path),
|
|
||||||
Self::Cargo { manifest_path } => {
|
|
||||||
let mut command = Command::new("cargo");
|
|
||||||
command
|
|
||||||
.arg("run")
|
|
||||||
.arg("--quiet")
|
|
||||||
.arg("--manifest-path")
|
|
||||||
.arg(manifest_path)
|
|
||||||
.arg("-p")
|
|
||||||
.arg("ely_servo_host")
|
|
||||||
.arg("--features")
|
|
||||||
.arg(sidecar_features_from_env())
|
|
||||||
.arg("--bin")
|
|
||||||
.arg("ely_servo_sidecar")
|
|
||||||
.arg("--");
|
|
||||||
command
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
|
|
||||||
match self {
|
|
||||||
Self::Binary(path) if !path.is_file() => Some(path.as_path()),
|
|
||||||
Self::Binary(_) | Self::Cargo { .. } => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) enum SidecarRenderingContext {
|
|
||||||
Software,
|
|
||||||
Hardware,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidecarRenderingContext {
|
|
||||||
pub(super) fn cli_arg(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Software => "software",
|
|
||||||
Self::Hardware => "hardware",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sidecar_features(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Software => SOFTWARE_SIDECAR_FEATURES,
|
|
||||||
Self::Hardware => HARDWARE_SIDECAR_FEATURES,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub(crate) enum SidecarCommandError {
|
|
||||||
#[error("current executable path is unavailable: {0}")]
|
|
||||||
CurrentExecutable(#[source] io::Error),
|
|
||||||
|
|
||||||
#[error("current executable directory is unavailable for {path}")]
|
|
||||||
CurrentExecutableDirectoryUnavailable { path: PathBuf },
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, SidecarCommandError> {
|
|
||||||
if let Some(path) = env::var_os(SIDECAR_PATH_ENV) {
|
|
||||||
return Ok(SidecarCommandTarget::Binary(PathBuf::from(path)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?;
|
|
||||||
let exe_dir = current_exe.parent().ok_or_else(|| {
|
|
||||||
SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
|
|
||||||
})?;
|
|
||||||
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
|
|
||||||
if adjacent_sidecar.is_file() && is_macos_app_bundle_exe_dir(exe_dir) {
|
|
||||||
return Ok(SidecarCommandTarget::Binary(adjacent_sidecar));
|
|
||||||
}
|
|
||||||
let workspace_manifest = workspace_manifest_path();
|
|
||||||
let workspace_target_sidecar =
|
|
||||||
workspace_manifest.as_ref().and_then(|path| workspace_target_sidecar_path(path));
|
|
||||||
let adjacent_is_workspace_target =
|
|
||||||
workspace_target_sidecar.as_ref().is_some_and(|path| path == &adjacent_sidecar);
|
|
||||||
let workspace_target_sidecar_exists =
|
|
||||||
workspace_target_sidecar.as_ref().is_some_and(|path| path.is_file());
|
|
||||||
let prefer_cargo_hardware_sidecar = rendering_context_from_env()
|
|
||||||
== SidecarRenderingContext::Hardware
|
|
||||||
&& (adjacent_is_workspace_target || workspace_target_sidecar_exists)
|
|
||||||
&& workspace_manifest.as_ref().is_some_and(|path| path.is_file());
|
|
||||||
if adjacent_sidecar.is_file() && !prefer_cargo_hardware_sidecar {
|
|
||||||
return Ok(SidecarCommandTarget::Binary(adjacent_sidecar));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(manifest_path) = workspace_manifest {
|
|
||||||
if let Some(target_sidecar) = workspace_target_sidecar
|
|
||||||
&& target_sidecar.is_file()
|
|
||||||
&& !prefer_cargo_hardware_sidecar
|
|
||||||
{
|
|
||||||
return Ok(SidecarCommandTarget::Binary(target_sidecar));
|
|
||||||
}
|
|
||||||
if manifest_path.is_file() {
|
|
||||||
return Ok(SidecarCommandTarget::Cargo { manifest_path });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(SidecarCommandTarget::Binary(adjacent_sidecar))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workspace_manifest_path() -> Option<PathBuf> {
|
|
||||||
option_env!("ELY_WORKSPACE_MANIFEST").map(PathBuf::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn rendering_context_from_env() -> SidecarRenderingContext {
|
|
||||||
let raw = env::var(RENDERING_CONTEXT_ENV).ok();
|
|
||||||
rendering_context_selection(raw.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sidecar_features_from_env() -> &'static str {
|
|
||||||
rendering_context_from_env().sidecar_features()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rendering_context_selection(raw: Option<&str>) -> SidecarRenderingContext {
|
|
||||||
match raw.map(str::to_lowercase).as_deref() {
|
|
||||||
Some("software") => SidecarRenderingContext::Software,
|
|
||||||
Some("hardware") => SidecarRenderingContext::Hardware,
|
|
||||||
_ => default_rendering_context(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_rendering_context() -> SidecarRenderingContext {
|
|
||||||
if cfg!(target_os = "macos") {
|
|
||||||
SidecarRenderingContext::Hardware
|
|
||||||
} else {
|
|
||||||
SidecarRenderingContext::Software
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workspace_target_sidecar_path(manifest_path: &Path) -> Option<PathBuf> {
|
|
||||||
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
|
|
||||||
Some(manifest_path.parent()?.join("target").join(profile).join(sidecar_binary_name()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sidecar_binary_name() -> String {
|
|
||||||
format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_macos_app_bundle_exe_dir(path: &Path) -> bool {
|
|
||||||
path.file_name().is_some_and(|name| name == "MacOS")
|
|
||||||
&& path
|
|
||||||
.parent()
|
|
||||||
.is_some_and(|contents| contents.file_name().is_some_and(|name| name == "Contents"))
|
|
||||||
&& path
|
|
||||||
.parent()
|
|
||||||
.and_then(Path::parent)
|
|
||||||
.is_some_and(|bundle| bundle.extension().is_some_and(|extension| extension == "app"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
HARDWARE_SIDECAR_FEATURES, SOFTWARE_SIDECAR_FEATURES, SidecarRenderingContext,
|
|
||||||
is_macos_app_bundle_exe_dir, rendering_context_selection,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hardware_rendering_context_enables_hardware_sidecar_feature() {
|
|
||||||
let context = rendering_context_selection(Some("hardware"));
|
|
||||||
assert_eq!(context, SidecarRenderingContext::Hardware);
|
|
||||||
assert_eq!(context.sidecar_features(), HARDWARE_SIDECAR_FEATURES);
|
|
||||||
assert_eq!(
|
|
||||||
rendering_context_selection(Some("HARDWARE")),
|
|
||||||
SidecarRenderingContext::Hardware
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn software_rendering_context_uses_software_sidecar_feature() {
|
|
||||||
let context = rendering_context_selection(Some("software"));
|
|
||||||
assert_eq!(context, SidecarRenderingContext::Software);
|
|
||||||
assert_eq!(context.sidecar_features(), SOFTWARE_SIDECAR_FEATURES);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recognizes_macos_app_bundle_executable_directory() {
|
|
||||||
assert!(is_macos_app_bundle_exe_dir(std::path::Path::new(
|
|
||||||
"/tmp/ELY Browser.app/Contents/MacOS"
|
|
||||||
)));
|
|
||||||
assert!(!is_macos_app_bundle_exe_dir(std::path::Path::new("/tmp/target/debug")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn defaults_to_hardware_rendering_context_on_macos() {
|
|
||||||
assert_eq!(rendering_context_selection(None), SidecarRenderingContext::Hardware);
|
|
||||||
assert_eq!(rendering_context_selection(Some("garbage")), SidecarRenderingContext::Hardware);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
#[test]
|
|
||||||
fn defaults_to_software_rendering_context_off_macos() {
|
|
||||||
assert_eq!(rendering_context_selection(None), SidecarRenderingContext::Software);
|
|
||||||
assert_eq!(rendering_context_selection(Some("garbage")), SidecarRenderingContext::Software);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
//! GPUI test harness for the input pipeline.
|
//! GPUI test harness for the input pipeline.
|
||||||
//!
|
//!
|
||||||
//! Twelve sidecar-side commits and one shell-side commit had all claimed to
|
//! Twelve renderer-side commits and one shell-side commit had all claimed to
|
||||||
//! fix "click does nothing" while the user kept reporting the same symptom.
|
//! fix "click does nothing" while the user kept reporting the same symptom.
|
||||||
//! The roundtable consensus: every store-layer test passed GREEN, every
|
//! The roundtable consensus: every store-layer test passed GREEN, every
|
||||||
//! sidecar integration test passed GREEN, but nothing in the repo exercised
|
//! renderer integration test passed GREEN, but nothing in the repo exercised
|
||||||
//! the real GPUI event tree (`render_input_overlay` + window-level mouse
|
//! the real GPUI event tree (`render_input_overlay` + window-level mouse
|
||||||
//! handlers + sidebar capture interactions). This module is that missing
|
//! handlers + sidebar capture interactions). This module is that missing
|
||||||
//! holdout set.
|
//! holdout set.
|
||||||
@@ -984,8 +984,7 @@ async fn baseline_overlay_div_receives_simulated_click(cx: &mut TestAppContext)
|
|||||||
/// **byte-identical RGBA payloads must produce the same underlying
|
/// **byte-identical RGBA payloads must produce the same underlying
|
||||||
/// `Arc<RenderImage>`**. Today they do not — every `from_live_frame`
|
/// `Arc<RenderImage>`**. Today they do not — every `from_live_frame`
|
||||||
/// blindly reallocates. The fix path is either dedup the upload
|
/// blindly reallocates. The fix path is either dedup the upload
|
||||||
/// against the last bytes or switch to `OffscreenRenderingContext` +
|
/// against the last bytes or switch to direct platform-surface presentation.
|
||||||
/// IOSurface so the GPU texture is the source of truth.
|
|
||||||
///
|
///
|
||||||
/// Regression guard: with the single-slot `LAST_FRAME_IMAGE` cache in
|
/// Regression guard: with the single-slot `LAST_FRAME_IMAGE` cache in
|
||||||
/// `web_surface_frame.rs`, two `ServoLiveFrame` inputs carrying
|
/// `web_surface_frame.rs`, two `ServoLiveFrame` inputs carrying
|
||||||
@@ -1028,7 +1027,7 @@ fn identical_live_frames_share_render_image_arc() -> Result<(), String> {
|
|||||||
distinct Arc<RenderImage> instances (first={:p}, second={:p}). \
|
distinct Arc<RenderImage> instances (first={:p}, second={:p}). \
|
||||||
WebSurfaceFrame::from_parts must dedup the upload against the \
|
WebSurfaceFrame::from_parts must dedup the upload against the \
|
||||||
previous frame's bytes, or the rendering pipeline must switch \
|
previous frame's bytes, or the rendering pipeline must switch \
|
||||||
to a GPU-side source of truth (IOSurface) so per-frame host \
|
to direct platform-surface presentation so per-frame host \
|
||||||
allocations stop entirely.",
|
allocations stop entirely.",
|
||||||
Arc::as_ptr(first_image),
|
Arc::as_ptr(first_image),
|
||||||
Arc::as_ptr(second_image),
|
Arc::as_ptr(second_image),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use ely_domain::{BrowserTab, TabId};
|
use ely_domain::{BrowserTab, TabId};
|
||||||
use gpui::{Bounds, Pixels, Point};
|
use gpui::{Bounds, NativeSurfaceHandle, Pixels, Point};
|
||||||
|
|
||||||
use crate::services::ProfileDataMode;
|
use crate::services::ProfileDataMode;
|
||||||
|
|
||||||
@@ -56,8 +56,22 @@ impl WebSurfaceStore {
|
|||||||
else {
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let ensure_key =
|
let native_surface =
|
||||||
WebSurfaceEnsureKey::new(requested_url.clone(), size, tab.zoom_percent(), permissions);
|
self.surfaces.get(tab.id()).and_then(|surface| surface.native_surface.clone());
|
||||||
|
#[cfg(not(test))]
|
||||||
|
let Some(native_surface) = native_surface else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let ensure_key = WebSurfaceEnsureKey::new(
|
||||||
|
requested_url.clone(),
|
||||||
|
size,
|
||||||
|
#[cfg(test)]
|
||||||
|
native_surface.as_ref(),
|
||||||
|
#[cfg(not(test))]
|
||||||
|
Some(&native_surface),
|
||||||
|
tab.zoom_percent(),
|
||||||
|
permissions,
|
||||||
|
);
|
||||||
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
|
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -65,7 +79,29 @@ impl WebSurfaceStore {
|
|||||||
let previous_frame =
|
let previous_frame =
|
||||||
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
|
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
|
||||||
|
|
||||||
match self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input) {
|
#[cfg(test)]
|
||||||
|
let ensure_result = match native_surface {
|
||||||
|
Some(native_surface) => self.runtime.ensure_tab_with_native_surface(
|
||||||
|
tab,
|
||||||
|
size,
|
||||||
|
native_surface,
|
||||||
|
profile_data_mode,
|
||||||
|
permissions,
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
None => self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input),
|
||||||
|
};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
let ensure_result = self.runtime.ensure_tab_with_native_surface(
|
||||||
|
tab,
|
||||||
|
size,
|
||||||
|
native_surface,
|
||||||
|
profile_data_mode,
|
||||||
|
permissions,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
match ensure_result {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
self.surface_mut(tab.id()).mark_ensured(ensure_key);
|
self.surface_mut(tab.id()).mark_ensured(ensure_key);
|
||||||
if result.started_loading {
|
if result.started_loading {
|
||||||
@@ -241,6 +277,24 @@ impl WebSurfaceStore {
|
|||||||
WebSurfaceInputOutcome::Applied
|
WebSurfaceInputOutcome::Applied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn record_native_surface(
|
||||||
|
&mut self,
|
||||||
|
tab_id: &TabId,
|
||||||
|
native_surface: NativeSurfaceHandle,
|
||||||
|
) -> WebSurfaceInputOutcome {
|
||||||
|
let surface = self.surface_mut(tab_id);
|
||||||
|
if surface
|
||||||
|
.native_surface
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|current| current.identity() == native_surface.identity())
|
||||||
|
{
|
||||||
|
return WebSurfaceInputOutcome::NoChange;
|
||||||
|
}
|
||||||
|
surface.native_surface = Some(native_surface);
|
||||||
|
surface.last_ensure_key = None;
|
||||||
|
WebSurfaceInputOutcome::Applied
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn record_hover_point(
|
pub(super) fn record_hover_point(
|
||||||
&mut self,
|
&mut self,
|
||||||
tab_id: &TabId,
|
tab_id: &TabId,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, NativeSurfaceHandle, Pixels, Point};
|
||||||
|
|
||||||
use crate::services::ProfileDataMode;
|
use crate::services::ProfileDataMode;
|
||||||
|
|
||||||
@@ -101,6 +101,25 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn record_external_web_surface(
|
||||||
|
&mut self,
|
||||||
|
tab_id: TabId,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
scale_factor: f32,
|
||||||
|
native_surface: NativeSurfaceHandle,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let viewport_changed =
|
||||||
|
self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor)
|
||||||
|
== WebSurfaceInputOutcome::Applied;
|
||||||
|
let surface_changed = self.web_surfaces.record_native_surface(&tab_id, native_surface)
|
||||||
|
== WebSurfaceInputOutcome::Applied;
|
||||||
|
|
||||||
|
if viewport_changed || surface_changed {
|
||||||
|
self.flush_external_web_surface_tick(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn scroll_external_web_viewport(
|
pub(super) fn scroll_external_web_viewport(
|
||||||
&mut self,
|
&mut self,
|
||||||
tab_id: TabId,
|
tab_id: TabId,
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ use std::hash::Hasher;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ahash::AHasher;
|
use ahash::AHasher;
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
|
||||||
use core_video::{pixel_buffer::kCVPixelBufferLock_ReadOnly, r#return::kCVReturnSuccess};
|
|
||||||
use gpui::RenderImage;
|
use gpui::RenderImage;
|
||||||
use image::{ImageBuffer, Rgba};
|
use image::{ImageBuffer, Rgba};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -47,11 +43,7 @@ pub(super) struct WebSurfaceFrame {
|
|||||||
content_pixel_count: u64,
|
content_pixel_count: u64,
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
sample_hash: u64,
|
sample_hash: u64,
|
||||||
/// Software-path image built from RGBA bytes.
|
|
||||||
pub(super) image: Option<Arc<RenderImage>>,
|
pub(super) image: Option<Arc<RenderImage>>,
|
||||||
/// Hardware-path IOSurface imported from the sidecar.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub(super) pixel_buffer: Option<CVPixelBuffer>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebSurfaceFrame {
|
impl WebSurfaceFrame {
|
||||||
@@ -61,8 +53,6 @@ impl WebSurfaceFrame {
|
|||||||
zoom_percent: u16,
|
zoom_percent: u16,
|
||||||
frame: ServoLiveFrame,
|
frame: ServoLiveFrame,
|
||||||
) -> Result<Self, WebSurfaceError> {
|
) -> Result<Self, WebSurfaceError> {
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let pixel_buffer = frame.pixel_buffer().cloned();
|
|
||||||
Self::from_parts(WebSurfaceFrameParts {
|
Self::from_parts(WebSurfaceFrameParts {
|
||||||
requested_url,
|
requested_url,
|
||||||
loaded_url: frame.loaded_url().map(str::to_string),
|
loaded_url: frame.loaded_url().map(str::to_string),
|
||||||
@@ -84,45 +74,24 @@ impl WebSurfaceFrame {
|
|||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
sample_hash: frame.sample_hash(),
|
sample_hash: frame.sample_hash(),
|
||||||
rgba_bytes: frame.into_rgba_bytes(),
|
rgba_bytes: frame.into_rgba_bytes(),
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pixel_buffer,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
|
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let has_pixel_buffer = parts.pixel_buffer.is_some();
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let has_pixel_buffer = false;
|
|
||||||
|
|
||||||
if parts.rgba_bytes.is_empty() && !has_pixel_buffer {
|
|
||||||
return Err(WebSurfaceError::MissingRenderablePayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() {
|
|
||||||
validate_hardware_pixel_buffer(pixel_buffer, parts.width, parts.height)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
let pixel_sample = pixel_sample_for_parts(&parts)?;
|
let pixel_sample = pixel_sample_for_parts(&parts)?;
|
||||||
|
|
||||||
let image = if parts.rgba_bytes.is_empty() {
|
let image = match parts.rgba_bytes {
|
||||||
None
|
Some(mut bytes) => {
|
||||||
} else {
|
if bytes.is_empty() {
|
||||||
// Servo's `read_pixels(gl::RGBA, gl::UNSIGNED_BYTE)` writes
|
return Err(WebSurfaceError::MissingRenderablePayload);
|
||||||
// R-G-B-A in memory order. GPUI's `RenderImage` is documented
|
}
|
||||||
// as "in BGRA format" and uploads via
|
// Servo's RGBA8 readback is converted once for GPUI's BGRA upload path.
|
||||||
// `MTLPixelFormat::BGRA8Unorm`, which reads B-G-R-A. Hand the bytes across
|
|
||||||
// unchanged and the Metal sampler treats R as B (and vice
|
|
||||||
// versa) — every coloured pixel renders with R and B swapped.
|
|
||||||
// Swap once here so the rest of the pipeline (dedup hash,
|
|
||||||
// image buffer, GPU upload) all operate on the same BGRA
|
|
||||||
// representation.
|
|
||||||
let mut bytes = parts.rgba_bytes;
|
|
||||||
swap_red_blue_in_place(&mut bytes);
|
swap_red_blue_in_place(&mut bytes);
|
||||||
let bytes_hash = rgba_hash(&bytes);
|
let bytes_hash = rgba_hash(&bytes);
|
||||||
Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?)
|
Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -146,8 +115,6 @@ impl WebSurfaceFrame {
|
|||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
sample_hash: pixel_sample.sample_hash,
|
sample_hash: pixel_sample.sample_hash,
|
||||||
image,
|
image,
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pixel_buffer: parts.pixel_buffer,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,15 +181,13 @@ impl WebSurfaceFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool {
|
pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool {
|
||||||
#[cfg(target_os = "macos")]
|
let image_matches = match (self.image.as_ref(), other.image.as_ref()) {
|
||||||
if self.pixel_buffer.is_some() || other.pixel_buffer.is_some() {
|
(Some(image), Some(other_image)) => Arc::ptr_eq(image, other_image),
|
||||||
return false;
|
(None, None) => true,
|
||||||
}
|
_ => false,
|
||||||
let (Some(image), Some(other_image)) = (self.image.as_ref(), other.image.as_ref()) else {
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Arc::ptr_eq(image, other_image)
|
image_matches
|
||||||
&& self.requested_url == other.requested_url
|
&& self.requested_url == other.requested_url
|
||||||
&& self.loaded_url == other.loaded_url
|
&& self.loaded_url == other.loaded_url
|
||||||
&& self.title == other.title
|
&& self.title == other.title
|
||||||
@@ -239,7 +204,6 @@ impl WebSurfaceFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
|
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
|
||||||
// Sidecar readback suppresses blank initial frames before publication.
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
{
|
{
|
||||||
Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0)
|
Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0)
|
||||||
@@ -267,16 +231,9 @@ impl WebSurfaceFrame {
|
|||||||
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
pub(super) fn has_hardware_surface(&self) -> bool {
|
pub(super) fn has_hardware_surface(&self) -> bool {
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
self.pixel_buffer.is_some()
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
{
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
struct WebSurfaceFrameParts {
|
struct WebSurfaceFrameParts {
|
||||||
requested_url: String,
|
requested_url: String,
|
||||||
@@ -298,67 +255,15 @@ struct WebSurfaceFrameParts {
|
|||||||
content_pixel_count: u64,
|
content_pixel_count: u64,
|
||||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||||
sample_hash: u64,
|
sample_hash: u64,
|
||||||
rgba_bytes: Vec<u8>,
|
rgba_bytes: Option<Vec<u8>>,
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pixel_buffer: Option<CVPixelBuffer>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub(super) enum WebSurfaceError {
|
pub(super) enum WebSurfaceError {
|
||||||
#[error("invalid servo frame buffer for {width}x{height}")]
|
#[error("invalid servo frame buffer for {width}x{height}")]
|
||||||
InvalidFrameBuffer { width: u32, height: u32 },
|
InvalidFrameBuffer { width: u32, height: u32 },
|
||||||
#[error("servo live frame did not include a software image or hardware IOSurface")]
|
#[error("servo live frame did not include renderable pixels")]
|
||||||
MissingRenderablePayload,
|
MissingRenderablePayload,
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[error(
|
|
||||||
"servo hardware surface size {actual_width}x{actual_height} did not match frame report {expected_width}x{expected_height}"
|
|
||||||
)]
|
|
||||||
HardwareSurfaceSizeMismatch {
|
|
||||||
expected_width: u32,
|
|
||||||
expected_height: u32,
|
|
||||||
actual_width: usize,
|
|
||||||
actual_height: usize,
|
|
||||||
},
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[error("servo hardware surface pixel format 0x{actual:x} is unsupported; expected 32BGRA")]
|
|
||||||
UnsupportedHardwareSurfaceFormat { actual: u32 },
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
|
||||||
#[error("servo hardware surface lock failed with status {status}")]
|
|
||||||
HardwareSurfaceLockFailed { status: i32 },
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
|
||||||
#[error("servo hardware surface unlock failed with status {status}")]
|
|
||||||
HardwareSurfaceUnlockFailed { status: i32 },
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
|
||||||
#[error("servo hardware surface base address is unavailable")]
|
|
||||||
HardwareSurfaceBaseAddressUnavailable,
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
|
||||||
#[error("servo hardware surface row stride {bytes_per_row} is too small for width {width}")]
|
|
||||||
HardwareSurfaceRowStrideTooSmall { width: usize, bytes_per_row: usize },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn validate_hardware_pixel_buffer(
|
|
||||||
pixel_buffer: &CVPixelBuffer,
|
|
||||||
expected_width: u32,
|
|
||||||
expected_height: u32,
|
|
||||||
) -> Result<(), WebSurfaceError> {
|
|
||||||
let actual_width = pixel_buffer.get_width();
|
|
||||||
let actual_height = pixel_buffer.get_height();
|
|
||||||
if actual_width != expected_width as usize || actual_height != expected_height as usize {
|
|
||||||
return Err(WebSurfaceError::HardwareSurfaceSizeMismatch {
|
|
||||||
expected_width,
|
|
||||||
expected_height,
|
|
||||||
actual_width,
|
|
||||||
actual_height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let actual_format = pixel_buffer.get_pixel_format();
|
|
||||||
if actual_format != kCVPixelFormatType_32BGRA {
|
|
||||||
return Err(WebSurfaceError::UnsupportedHardwareSurfaceFormat { actual: actual_format });
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's
|
/// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's
|
||||||
@@ -387,11 +292,6 @@ struct WebSurfacePixelSample {
|
|||||||
fn pixel_sample_for_parts(
|
fn pixel_sample_for_parts(
|
||||||
parts: &WebSurfaceFrameParts,
|
parts: &WebSurfaceFrameParts,
|
||||||
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
|
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() {
|
|
||||||
return sample_hardware_pixel_buffer(pixel_buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(WebSurfacePixelSample {
|
Ok(WebSurfacePixelSample {
|
||||||
non_white_pixel_count: parts.non_white_pixel_count,
|
non_white_pixel_count: parts.non_white_pixel_count,
|
||||||
content_pixel_count: parts.content_pixel_count,
|
content_pixel_count: parts.content_pixel_count,
|
||||||
@@ -399,82 +299,6 @@ fn pixel_sample_for_parts(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
|
||||||
fn sample_hardware_pixel_buffer(
|
|
||||||
pixel_buffer: &CVPixelBuffer,
|
|
||||||
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
|
|
||||||
let lock_status = pixel_buffer.lock_base_address(kCVPixelBufferLock_ReadOnly);
|
|
||||||
if lock_status != kCVReturnSuccess {
|
|
||||||
return Err(WebSurfaceError::HardwareSurfaceLockFailed { status: lock_status });
|
|
||||||
}
|
|
||||||
|
|
||||||
let sample = sample_locked_hardware_pixel_buffer(pixel_buffer);
|
|
||||||
let unlock_status = pixel_buffer.unlock_base_address(kCVPixelBufferLock_ReadOnly);
|
|
||||||
if unlock_status != kCVReturnSuccess {
|
|
||||||
return Err(WebSurfaceError::HardwareSurfaceUnlockFailed { status: unlock_status });
|
|
||||||
}
|
|
||||||
|
|
||||||
sample
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
|
||||||
fn sample_locked_hardware_pixel_buffer(
|
|
||||||
pixel_buffer: &CVPixelBuffer,
|
|
||||||
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
|
|
||||||
let width = pixel_buffer.get_width();
|
|
||||||
let height = pixel_buffer.get_height();
|
|
||||||
let bytes_per_row = pixel_buffer.get_bytes_per_row();
|
|
||||||
let row_width = width.saturating_mul(4);
|
|
||||||
if bytes_per_row < row_width {
|
|
||||||
return Err(WebSurfaceError::HardwareSurfaceRowStrideTooSmall { width, bytes_per_row });
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let base_address = unsafe { pixel_buffer.get_base_address() };
|
|
||||||
if base_address.is_null() {
|
|
||||||
return Err(WebSurfaceError::HardwareSurfaceBaseAddressUnavailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
let byte_len = bytes_per_row.saturating_mul(height);
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let bytes = unsafe { std::slice::from_raw_parts(base_address.cast::<u8>(), byte_len) };
|
|
||||||
Ok(sample_bgra_rows(bytes, width, height, bytes_per_row))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
|
||||||
fn sample_bgra_rows(
|
|
||||||
bytes: &[u8],
|
|
||||||
width: usize,
|
|
||||||
height: usize,
|
|
||||||
bytes_per_row: usize,
|
|
||||||
) -> WebSurfacePixelSample {
|
|
||||||
let mut non_white_pixel_count = 0;
|
|
||||||
let mut content_pixel_count = 0;
|
|
||||||
let mut sample_hash = 0xcbf29ce484222325_u64;
|
|
||||||
|
|
||||||
for y in 0..height {
|
|
||||||
let row_start = y * bytes_per_row;
|
|
||||||
let row = &bytes[row_start..row_start + width * 4];
|
|
||||||
for (x, pixel) in row.chunks_exact(4).enumerate() {
|
|
||||||
let [blue, green, red, alpha] = [pixel[0], pixel[1], pixel[2], pixel[3]];
|
|
||||||
if alpha > 0 && (red < 245 || green < 245 || blue < 245) {
|
|
||||||
non_white_pixel_count += 1;
|
|
||||||
}
|
|
||||||
if alpha > 0 && (red < 220 || green < 220 || blue < 220) {
|
|
||||||
content_pixel_count += 1;
|
|
||||||
}
|
|
||||||
if (y * width + x).is_multiple_of(97) {
|
|
||||||
for byte in [red, green, blue, alpha] {
|
|
||||||
sample_hash ^= u64::from(byte);
|
|
||||||
sample_hash = sample_hash.wrapping_mul(0x100000001b3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WebSurfacePixelSample { non_white_pixel_count, content_pixel_count, sample_hash }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_render_image(
|
fn resolve_render_image(
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use ely_domain::{BrowserTab, ProfileId, TabId};
|
use ely_domain::{BrowserTab, ProfileId, TabId};
|
||||||
|
use gpui::NativeSurfaceHandle;
|
||||||
|
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
ProfileDataMode,
|
ProfileDataMode,
|
||||||
@@ -23,7 +24,9 @@ use super::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub(super) struct WebSurfaceRuntime {
|
pub(super) struct WebSurfaceRuntime {
|
||||||
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
|
worker: Option<ScopedWorker>,
|
||||||
|
direct_client: Option<ScopedDirectClient>,
|
||||||
|
pending_direct_responses: Vec<WorkerResponse>,
|
||||||
sessions: BTreeMap<TabId, WebSurfaceSession>,
|
sessions: BTreeMap<TabId, WebSurfaceSession>,
|
||||||
client_factory: LiveRuntimeClientFactory,
|
client_factory: LiveRuntimeClientFactory,
|
||||||
}
|
}
|
||||||
@@ -31,7 +34,9 @@ pub(super) struct WebSurfaceRuntime {
|
|||||||
impl WebSurfaceRuntime {
|
impl WebSurfaceRuntime {
|
||||||
pub(super) fn new() -> Self {
|
pub(super) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
workers: BTreeMap::new(),
|
worker: None,
|
||||||
|
direct_client: None,
|
||||||
|
pending_direct_responses: Vec::new(),
|
||||||
sessions: BTreeMap::new(),
|
sessions: BTreeMap::new(),
|
||||||
client_factory: new_servo_live_client,
|
client_factory: new_servo_live_client,
|
||||||
}
|
}
|
||||||
@@ -39,9 +44,16 @@ impl WebSurfaceRuntime {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
|
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
|
||||||
Self { workers: BTreeMap::new(), sessions: BTreeMap::new(), client_factory }
|
Self {
|
||||||
|
worker: None,
|
||||||
|
direct_client: None,
|
||||||
|
pending_direct_responses: Vec::new(),
|
||||||
|
sessions: BTreeMap::new(),
|
||||||
|
client_factory,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(super) fn ensure_tab(
|
pub(super) fn ensure_tab(
|
||||||
&mut self,
|
&mut self,
|
||||||
tab: &BrowserTab,
|
tab: &BrowserTab,
|
||||||
@@ -49,11 +61,48 @@ impl WebSurfaceRuntime {
|
|||||||
profile_data_mode: ProfileDataMode,
|
profile_data_mode: ProfileDataMode,
|
||||||
permissions: &[WebSurfaceSitePermission],
|
permissions: &[WebSurfaceSitePermission],
|
||||||
input: WebSurfacePendingInput,
|
input: WebSurfacePendingInput,
|
||||||
|
) -> Result<WebSurfaceEnsureResult, String> {
|
||||||
|
self.ensure_tab_inner(tab, size, None, profile_data_mode, permissions, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ensure_tab_with_native_surface(
|
||||||
|
&mut self,
|
||||||
|
tab: &BrowserTab,
|
||||||
|
size: WebSurfaceSize,
|
||||||
|
native_surface: NativeSurfaceHandle,
|
||||||
|
profile_data_mode: ProfileDataMode,
|
||||||
|
permissions: &[WebSurfaceSitePermission],
|
||||||
|
input: WebSurfacePendingInput,
|
||||||
|
) -> Result<WebSurfaceEnsureResult, String> {
|
||||||
|
self.ensure_tab_inner(
|
||||||
|
tab,
|
||||||
|
size,
|
||||||
|
Some(native_surface),
|
||||||
|
profile_data_mode,
|
||||||
|
permissions,
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_tab_inner(
|
||||||
|
&mut self,
|
||||||
|
tab: &BrowserTab,
|
||||||
|
size: WebSurfaceSize,
|
||||||
|
native_surface: Option<NativeSurfaceHandle>,
|
||||||
|
profile_data_mode: ProfileDataMode,
|
||||||
|
permissions: &[WebSurfaceSitePermission],
|
||||||
|
input: WebSurfacePendingInput,
|
||||||
) -> Result<WebSurfaceEnsureResult, String> {
|
) -> Result<WebSurfaceEnsureResult, String> {
|
||||||
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
|
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
|
||||||
|
let use_direct_client = native_surface.is_some();
|
||||||
|
if use_direct_client {
|
||||||
|
self.ensure_direct_client(scope.clone())?;
|
||||||
|
} else {
|
||||||
self.ensure_worker(scope.clone())?;
|
self.ensure_worker(scope.clone())?;
|
||||||
|
}
|
||||||
|
|
||||||
let requested_url = tab.url().as_str().to_string();
|
let requested_url = tab.url().as_str().to_string();
|
||||||
|
let tab_id_string = tab.id().as_str().to_string();
|
||||||
let zoom_percent = tab.zoom_percent();
|
let zoom_percent = tab.zoom_percent();
|
||||||
let enqueued_at = input.enqueued_at;
|
let enqueued_at = input.enqueued_at;
|
||||||
let input_kind = pending_input_kind(&input);
|
let input_kind = pending_input_kind(&input);
|
||||||
@@ -88,6 +137,7 @@ impl WebSurfaceRuntime {
|
|||||||
height: size.height,
|
height: size.height,
|
||||||
page_zoom_percent: zoom_percent,
|
page_zoom_percent: zoom_percent,
|
||||||
device_pixel_ratio: size.device_pixel_ratio_f32(),
|
device_pixel_ratio: size.device_pixel_ratio_f32(),
|
||||||
|
native_surface,
|
||||||
scroll_delta_x,
|
scroll_delta_x,
|
||||||
scroll_delta_y,
|
scroll_delta_y,
|
||||||
scroll_point_x,
|
scroll_point_x,
|
||||||
@@ -100,10 +150,15 @@ impl WebSurfaceRuntime {
|
|||||||
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
|
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(scoped) = self.workers.get(&scope) else {
|
if use_direct_client {
|
||||||
|
let response = self.ensure_direct(request, tab_id_string.clone())?;
|
||||||
|
self.pending_direct_responses.extend(response);
|
||||||
|
} else {
|
||||||
|
let Some(scoped) = self.worker.as_ref() else {
|
||||||
return Err("Servo worker was created but is no longer registered".to_string());
|
return Err("Servo worker was created but is no longer registered".to_string());
|
||||||
};
|
};
|
||||||
scoped.worker.submit_ensure(request);
|
scoped.worker.submit_ensure(request);
|
||||||
|
}
|
||||||
if let Some(session) = self.sessions.get_mut(tab.id()) {
|
if let Some(session) = self.sessions.get_mut(tab.id()) {
|
||||||
session.cadence.note_poll_submitted(submitted_at);
|
session.cadence.note_poll_submitted(submitted_at);
|
||||||
}
|
}
|
||||||
@@ -114,15 +169,149 @@ impl WebSurfaceRuntime {
|
|||||||
|
|
||||||
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
|
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
let mut dead_scopes = Vec::new();
|
|
||||||
let scopes: Vec<WebSurfaceRuntimeScope> = self.workers.keys().cloned().collect();
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
for scope in scopes {
|
let mut responses = std::mem::take(&mut self.pending_direct_responses);
|
||||||
let responses = self
|
responses.extend(
|
||||||
.workers
|
self.worker.as_ref().map(|scoped| scoped.worker.drain_responses()).unwrap_or_default(),
|
||||||
.get(&scope)
|
);
|
||||||
.map(|scoped| scoped.worker.drain_responses())
|
let runtime_unavailable = self.collect_responses(responses, now, &mut frames);
|
||||||
.unwrap_or_default();
|
if runtime_unavailable {
|
||||||
|
self.remove_worker();
|
||||||
|
self.remove_direct_client();
|
||||||
|
}
|
||||||
|
|
||||||
|
let poll_now = Instant::now();
|
||||||
|
let mut direct_polls = Vec::new();
|
||||||
|
for tab_id in visible_tab_ids {
|
||||||
|
let Some(session) = self.sessions.get_mut(tab_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !session.cadence.should_poll(poll_now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(scoped) = self.worker.as_ref() {
|
||||||
|
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
|
||||||
|
session.cadence.note_poll_submitted(poll_now);
|
||||||
|
} else if self.direct_client.is_some() {
|
||||||
|
direct_polls.push(tab_id.as_str().to_string());
|
||||||
|
session.cadence.note_poll_submitted(poll_now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !direct_polls.is_empty() {
|
||||||
|
let (responses, runtime_unavailable) = self.poll_direct(direct_polls);
|
||||||
|
let unavailable_from_responses =
|
||||||
|
self.collect_responses(responses, Instant::now(), &mut frames);
|
||||||
|
if runtime_unavailable || unavailable_from_responses {
|
||||||
|
self.remove_direct_client();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frames
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn next_poll_delay(
|
||||||
|
&self,
|
||||||
|
visible_tab_ids: &[TabId],
|
||||||
|
now: Instant,
|
||||||
|
) -> Option<Duration> {
|
||||||
|
visible_tab_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tab_id| self.sessions.get(tab_id))
|
||||||
|
.filter(|_| self.worker.is_some() || self.direct_client.is_some())
|
||||||
|
.map(|session| session.cadence.next_poll_delay(now))
|
||||||
|
.min()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn close_tab(&mut self, tab_id: &TabId) {
|
||||||
|
if self.sessions.remove(tab_id).is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let direct_result = self
|
||||||
|
.direct_client
|
||||||
|
.as_mut()
|
||||||
|
.map(|scoped| scoped.client.close(tab_id.as_str().to_string()));
|
||||||
|
if direct_result.as_ref().is_some_and(|result| {
|
||||||
|
result.as_ref().is_err_and(|error| error.is_runtime_unavailable())
|
||||||
|
}) {
|
||||||
|
self.remove_direct_client();
|
||||||
|
}
|
||||||
|
if let Some(scoped) = self.worker.as_ref() {
|
||||||
|
scoped.worker.submit_close(tab_id.as_str().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
|
||||||
|
if self.worker.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
|
||||||
|
let client_factory = self.client_factory;
|
||||||
|
let worker = LiveRuntimeWorker::new(move || client_factory(config_dir))?;
|
||||||
|
self.worker = Some(ScopedWorker { worker, transient_profile_data_dir });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_direct_client(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
|
||||||
|
if self.direct_client.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
|
||||||
|
let client = (self.client_factory)(config_dir)?;
|
||||||
|
self.direct_client = Some(ScopedDirectClient { client, transient_profile_data_dir });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_direct(
|
||||||
|
&mut self,
|
||||||
|
request: ServoLiveEnsureRequest,
|
||||||
|
tab_id: String,
|
||||||
|
) -> Result<Option<WorkerResponse>, String> {
|
||||||
|
let Some(scoped) = self.direct_client.as_mut() else {
|
||||||
|
return Err("Servo client was created but is no longer registered".to_string());
|
||||||
|
};
|
||||||
|
match scoped.client.ensure(request) {
|
||||||
|
Ok(Some(frame)) => Ok(Some(WorkerResponse::Frame { tab_id, frame })),
|
||||||
|
Ok(None) => Ok(None),
|
||||||
|
Err(error) => {
|
||||||
|
let message = error.to_string();
|
||||||
|
if error.is_runtime_unavailable() {
|
||||||
|
self.remove_direct_client();
|
||||||
|
}
|
||||||
|
Err(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_direct(&mut self, tab_ids: Vec<String>) -> (Vec<WorkerResponse>, bool) {
|
||||||
|
let Some(scoped) = self.direct_client.as_mut() else {
|
||||||
|
return (Vec::new(), false);
|
||||||
|
};
|
||||||
|
let mut responses = Vec::new();
|
||||||
|
let mut runtime_unavailable = false;
|
||||||
|
for tab_id in tab_ids {
|
||||||
|
match scoped.client.poll(tab_id.clone()) {
|
||||||
|
Ok(Some(frame)) => responses.push(WorkerResponse::Frame { tab_id, frame }),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) if error.is_runtime_unavailable() => {
|
||||||
|
runtime_unavailable = true;
|
||||||
|
responses.push(WorkerResponse::RuntimeUnavailable);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
responses.push(WorkerResponse::Failed { tab_id, message: error.to_string() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(responses, runtime_unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_responses(
|
||||||
|
&mut self,
|
||||||
|
responses: Vec<WorkerResponse>,
|
||||||
|
now: Instant,
|
||||||
|
frames: &mut Vec<WebSurfaceRuntimeFrame>,
|
||||||
|
) -> bool {
|
||||||
|
let mut runtime_unavailable = false;
|
||||||
for response in responses {
|
for response in responses {
|
||||||
match response {
|
match response {
|
||||||
WorkerResponse::Frame { tab_id, frame } => {
|
WorkerResponse::Frame { tab_id, frame } => {
|
||||||
@@ -144,11 +333,8 @@ impl WebSurfaceRuntime {
|
|||||||
frame,
|
frame,
|
||||||
) {
|
) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
let url_change = session.url_change_for(
|
let url_change =
|
||||||
&tab_id_obj,
|
session.url_change_for(&tab_id_obj, requested_url.as_str(), &frame);
|
||||||
requested_url.as_str(),
|
|
||||||
&frame,
|
|
||||||
);
|
|
||||||
frames.push(WebSurfaceRuntimeFrame::Ready {
|
frames.push(WebSurfaceRuntimeFrame::Ready {
|
||||||
tab_id: tab_id_obj,
|
tab_id: tab_id_obj,
|
||||||
frame: Box::new(frame),
|
frame: Box::new(frame),
|
||||||
@@ -167,63 +353,12 @@ impl WebSurfaceRuntime {
|
|||||||
};
|
};
|
||||||
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
|
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
|
||||||
}
|
}
|
||||||
WorkerResponse::SidecarExited => dead_scopes.push(scope.clone()),
|
WorkerResponse::RuntimeUnavailable => {
|
||||||
|
runtime_unavailable = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for scope in dead_scopes {
|
runtime_unavailable
|
||||||
self.workers.remove(&scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
let poll_now = Instant::now();
|
|
||||||
for tab_id in visible_tab_ids {
|
|
||||||
let Some(session) = self.sessions.get_mut(tab_id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !session.cadence.should_poll(poll_now) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(scoped) = self.workers.get(&session.scope) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
|
|
||||||
session.cadence.note_poll_submitted(poll_now);
|
|
||||||
}
|
|
||||||
|
|
||||||
frames
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn next_poll_delay(
|
|
||||||
&self,
|
|
||||||
visible_tab_ids: &[TabId],
|
|
||||||
now: Instant,
|
|
||||||
) -> Option<Duration> {
|
|
||||||
visible_tab_ids
|
|
||||||
.iter()
|
|
||||||
.filter_map(|tab_id| self.sessions.get(tab_id))
|
|
||||||
.filter(|session| self.workers.contains_key(&session.scope))
|
|
||||||
.map(|session| session.cadence.next_poll_delay(now))
|
|
||||||
.min()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn close_tab(&mut self, tab_id: &TabId) {
|
|
||||||
let Some(session) = self.sessions.remove(tab_id) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(scoped) = self.workers.get(&session.scope) {
|
|
||||||
scoped.worker.submit_close(tab_id.as_str().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
|
|
||||||
if self.workers.contains_key(&scope) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
|
|
||||||
let client = (self.client_factory)(config_dir)?;
|
|
||||||
let worker = LiveRuntimeWorker::new(client)?;
|
|
||||||
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir });
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lookup_session_tab_id(&self, tab_id: &str) -> Option<TabId> {
|
fn lookup_session_tab_id(&self, tab_id: &str) -> Option<TabId> {
|
||||||
@@ -232,7 +367,7 @@ impl WebSurfaceRuntime {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) fn client_count_for_test(&self) -> usize {
|
pub(super) fn client_count_for_test(&self) -> usize {
|
||||||
self.workers.len()
|
usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -242,23 +377,34 @@ impl WebSurfaceRuntime {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) fn flush_for_test(&self) {
|
pub(super) fn flush_for_test(&self) {
|
||||||
for scoped in self.workers.values() {
|
if let Some(scoped) = self.worker.as_ref() {
|
||||||
scoped.worker.wait_until_idle();
|
scoped.worker.wait_until_idle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_worker(&mut self) {
|
||||||
|
let Some(scoped) = self.worker.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(path) = scoped.transient_profile_data_dir {
|
||||||
|
let _ = fs::remove_dir_all(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_direct_client(&mut self) {
|
||||||
|
let Some(scoped) = self.direct_client.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(path) = scoped.transient_profile_data_dir {
|
||||||
|
let _ = fs::remove_dir_all(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for WebSurfaceRuntime {
|
impl Drop for WebSurfaceRuntime {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let transient_profile_data_dirs = self
|
self.remove_worker();
|
||||||
.workers
|
self.remove_direct_client();
|
||||||
.values()
|
|
||||||
.filter_map(|scoped| scoped.transient_profile_data_dir.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
self.workers.clear();
|
|
||||||
for path in transient_profile_data_dirs {
|
|
||||||
let _ = fs::remove_dir_all(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +422,11 @@ struct ScopedWorker {
|
|||||||
transient_profile_data_dir: Option<PathBuf>,
|
transient_profile_data_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ScopedDirectClient {
|
||||||
|
client: Box<dyn LiveRuntimeClient>,
|
||||||
|
transient_profile_data_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
pub(super) struct WebSurfaceRuntimeScope {
|
pub(super) struct WebSurfaceRuntimeScope {
|
||||||
profile_id: ProfileId,
|
profile_id: ProfileId,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
|||||||
static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String> {
|
fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), String> {
|
||||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
|
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
|
||||||
let first_profile = ProfileId::new();
|
let first_profile = ProfileId::new();
|
||||||
let second_profile = ProfileId::new();
|
let second_profile = ProfileId::new();
|
||||||
@@ -60,7 +60,7 @@ fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String>
|
|||||||
|
|
||||||
runtime.flush_for_test();
|
runtime.flush_for_test();
|
||||||
|
|
||||||
assert_eq!(runtime.client_count_for_test(), 2);
|
assert_eq!(runtime.client_count_for_test(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime.session_scope_for_test(first_tab.id()),
|
runtime.session_scope_for_test(first_tab.id()),
|
||||||
Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)),
|
Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)),
|
||||||
@@ -183,7 +183,7 @@ fn identical_ready_software_frame_keeps_tick_unchanged() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidecar_exit_removes_dead_runtime_client() -> Result<(), String> {
|
fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
|
||||||
RECOVERY_FACTORY_COUNT.store(0, Ordering::SeqCst);
|
RECOVERY_FACTORY_COUNT.store(0, Ordering::SeqCst);
|
||||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(recovery_client_factory);
|
let mut runtime = WebSurfaceRuntime::new_with_client_factory(recovery_client_factory);
|
||||||
let profile = ProfileId::new();
|
let profile = ProfileId::new();
|
||||||
@@ -281,7 +281,7 @@ fn session_scope_change_resets_tab_state() {
|
|||||||
|
|
||||||
struct FakeLiveRuntimeClient;
|
struct FakeLiveRuntimeClient;
|
||||||
struct IdleSkipLiveRuntimeClient;
|
struct IdleSkipLiveRuntimeClient;
|
||||||
struct SidecarExitLiveRuntimeClient;
|
struct RuntimeUnavailableLiveRuntimeClient;
|
||||||
struct FailingLiveRuntimeClient;
|
struct FailingLiveRuntimeClient;
|
||||||
struct RepeatedFrameLiveRuntimeClient;
|
struct RepeatedFrameLiveRuntimeClient;
|
||||||
|
|
||||||
@@ -322,20 +322,20 @@ impl LiveRuntimeClient for IdleSkipLiveRuntimeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveRuntimeClient for SidecarExitLiveRuntimeClient {
|
impl LiveRuntimeClient for RuntimeUnavailableLiveRuntimeClient {
|
||||||
fn ensure(
|
fn ensure(
|
||||||
&mut self,
|
&mut self,
|
||||||
_request: ServoLiveEnsureRequest,
|
_request: ServoLiveEnsureRequest,
|
||||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||||
Err(LiveRuntimeClientError::SidecarExited)
|
Err(LiveRuntimeClientError::RuntimeUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||||
Err(LiveRuntimeClientError::SidecarExited)
|
Err(LiveRuntimeClientError::RuntimeUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||||
Err(LiveRuntimeClientError::SidecarExited)
|
Err(LiveRuntimeClientError::RuntimeUnavailable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ impl LiveRuntimeClient for FailingLiveRuntimeClient {
|
|||||||
_request: ServoLiveEnsureRequest,
|
_request: ServoLiveEnsureRequest,
|
||||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||||
FAILING_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst);
|
FAILING_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||||
Err(LiveRuntimeClientError::SidecarExited)
|
Err(LiveRuntimeClientError::RuntimeUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||||
@@ -392,7 +392,7 @@ fn recovery_client_factory(
|
|||||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||||
let factory_call = RECOVERY_FACTORY_COUNT.fetch_add(1, Ordering::SeqCst);
|
let factory_call = RECOVERY_FACTORY_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||||
if factory_call == 0 {
|
if factory_call == 0 {
|
||||||
return Ok(Box::new(SidecarExitLiveRuntimeClient));
|
return Ok(Box::new(RuntimeUnavailableLiveRuntimeClient));
|
||||||
}
|
}
|
||||||
Ok(Box::new(FakeLiveRuntimeClient))
|
Ok(Box::new(FakeLiveRuntimeClient))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use ely_domain::TabId;
|
use ely_domain::TabId;
|
||||||
use gpui::{Bounds, Pixels};
|
use gpui::{Bounds, NativeSurfaceHandle, Pixels};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
web_surface_cadence::ACTIVE_POLL_INTERVAL,
|
web_surface_cadence::ACTIVE_POLL_INTERVAL,
|
||||||
@@ -126,6 +126,7 @@ pub(super) struct WebSurfaceTickResult {
|
|||||||
pub(super) struct PerTabSurface {
|
pub(super) struct PerTabSurface {
|
||||||
pub(super) viewport_bounds: Option<Bounds<Pixels>>,
|
pub(super) viewport_bounds: Option<Bounds<Pixels>>,
|
||||||
pub(super) viewport_size: Option<WebSurfaceSize>,
|
pub(super) viewport_size: Option<WebSurfaceSize>,
|
||||||
|
pub(super) native_surface: Option<NativeSurfaceHandle>,
|
||||||
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
|
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
|
||||||
pub(super) hover_point: Option<WebSurfaceClickPoint>,
|
pub(super) hover_point: Option<WebSurfaceClickPoint>,
|
||||||
last_hover_enqueued_at: Option<Instant>,
|
last_hover_enqueued_at: Option<Instant>,
|
||||||
@@ -145,6 +146,7 @@ impl PerTabSurface {
|
|||||||
Self {
|
Self {
|
||||||
viewport_bounds: None,
|
viewport_bounds: None,
|
||||||
viewport_size: None,
|
viewport_size: None,
|
||||||
|
native_surface: None,
|
||||||
last_ensure_key: None,
|
last_ensure_key: None,
|
||||||
hover_point: None,
|
hover_point: None,
|
||||||
last_hover_enqueued_at: None,
|
last_hover_enqueued_at: None,
|
||||||
@@ -228,6 +230,7 @@ const HOVER_INPUT_MIN_INTERVAL: Duration = Duration::from_millis(32);
|
|||||||
pub(super) struct WebSurfaceEnsureKey {
|
pub(super) struct WebSurfaceEnsureKey {
|
||||||
requested_url: String,
|
requested_url: String,
|
||||||
size: WebSurfaceSize,
|
size: WebSurfaceSize,
|
||||||
|
native_surface_id: Option<usize>,
|
||||||
zoom_percent: u16,
|
zoom_percent: u16,
|
||||||
permissions: Vec<WebSurfaceSitePermission>,
|
permissions: Vec<WebSurfaceSitePermission>,
|
||||||
}
|
}
|
||||||
@@ -236,10 +239,17 @@ impl WebSurfaceEnsureKey {
|
|||||||
pub(super) fn new(
|
pub(super) fn new(
|
||||||
requested_url: String,
|
requested_url: String,
|
||||||
size: WebSurfaceSize,
|
size: WebSurfaceSize,
|
||||||
|
native_surface: Option<&NativeSurfaceHandle>,
|
||||||
zoom_percent: u16,
|
zoom_percent: u16,
|
||||||
permissions: &[WebSurfaceSitePermission],
|
permissions: &[WebSurfaceSitePermission],
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { requested_url, size, zoom_percent, permissions: permissions.to_vec() }
|
Self {
|
||||||
|
requested_url,
|
||||||
|
size,
|
||||||
|
native_surface_id: native_surface.map(NativeSurfaceHandle::identity),
|
||||||
|
zoom_percent,
|
||||||
|
permissions: permissions.to_vec(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +307,7 @@ mod tests {
|
|||||||
WebSurfaceEnsureKey::new(
|
WebSurfaceEnsureKey::new(
|
||||||
url.to_string(),
|
url.to_string(),
|
||||||
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
|
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
|
||||||
|
None,
|
||||||
100,
|
100,
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box<dy
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
input.scroll_delta.map(|delta| (delta.x(), delta.y())),
|
input.scroll_delta.map(|delta| (delta.x(), delta.y())),
|
||||||
Some((0, 140)),
|
Some((0, 140)),
|
||||||
"scroll delta should reach the sidecar",
|
"scroll delta should reach the Servo runtime",
|
||||||
);
|
);
|
||||||
assert_eq!(input.scroll_offset.y(), 140);
|
assert_eq!(input.scroll_offset.y(), 140);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -118,7 +118,7 @@ fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box<dy
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
input.typed_text.as_deref(),
|
input.typed_text.as_deref(),
|
||||||
Some("hi"),
|
Some("hi"),
|
||||||
"buffered keystrokes from before AND after the scroll must reach the sidecar",
|
"buffered keystrokes from before AND after the scroll must reach the Servo runtime",
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -421,141 +421,7 @@ fn empty_live_frame_payload_is_rejected() -> Result<(), String> {
|
|||||||
Ok(_) => return Err("empty Servo frame payload reached Ready state".to_string()),
|
Ok(_) => return Err("empty Servo frame payload reached Ready state".to_string()),
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(error.to_string(), "servo live frame did not include renderable pixels",);
|
||||||
error.to_string(),
|
|
||||||
"servo live frame did not include a software image or hardware IOSurface",
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn hardware_live_frame_with_pixel_buffer_skips_software_image() -> Result<(), String> {
|
|
||||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
|
||||||
|
|
||||||
use crate::services::servo_live::ServoLiveFrame;
|
|
||||||
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
||||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
||||||
|
|
||||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 1, 1, None)
|
|
||||||
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
|
||||||
let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer);
|
|
||||||
let frame = WebSurfaceFrame::from_live_frame(
|
|
||||||
"https://example.com/".to_string(),
|
|
||||||
WebSurfaceScrollOffset::default(),
|
|
||||||
100,
|
|
||||||
live,
|
|
||||||
)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
|
|
||||||
assert!(frame.image.is_none(), "hardware frame should use the CVPixelBuffer surface path");
|
|
||||||
assert!(frame.pixel_buffer.is_some(), "hardware frame should carry the imported CVPixelBuffer");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn hardware_live_frame_rejects_mismatched_surface_size() -> Result<(), String> {
|
|
||||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
|
||||||
|
|
||||||
use crate::services::servo_live::ServoLiveFrame;
|
|
||||||
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
||||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
||||||
|
|
||||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None)
|
|
||||||
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
|
||||||
let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer);
|
|
||||||
let result = WebSurfaceFrame::from_live_frame(
|
|
||||||
"https://example.com/".to_string(),
|
|
||||||
WebSurfaceScrollOffset::default(),
|
|
||||||
100,
|
|
||||||
live,
|
|
||||||
);
|
|
||||||
|
|
||||||
let error = match result {
|
|
||||||
Ok(_) => return Err("mismatched hardware surface reached Ready state".to_string()),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
assert_eq!(error.to_string(), "servo hardware surface size 2x1 did not match frame report 1x1",);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn hardware_live_frame_rejects_unsupported_surface_format() -> Result<(), String> {
|
|
||||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange};
|
|
||||||
|
|
||||||
use crate::services::servo_live::ServoLiveFrame;
|
|
||||||
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
||||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
||||||
|
|
||||||
let pixel_buffer =
|
|
||||||
CVPixelBuffer::new(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, 2, 2, None)
|
|
||||||
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
|
||||||
let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 2, pixel_buffer);
|
|
||||||
let result = WebSurfaceFrame::from_live_frame(
|
|
||||||
"https://example.com/".to_string(),
|
|
||||||
WebSurfaceScrollOffset::default(),
|
|
||||||
100,
|
|
||||||
live,
|
|
||||||
);
|
|
||||||
|
|
||||||
let error = match result {
|
|
||||||
Ok(_) => return Err("unsupported hardware surface format reached Ready state".to_string()),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
"servo hardware surface pixel format 0x34323066 is unsupported; expected 32BGRA",
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(target_os = "macos", feature = "live-site-smoke"))]
|
|
||||||
#[test]
|
|
||||||
fn hardware_live_frame_samples_bgra_surface_pixels() -> Result<(), String> {
|
|
||||||
use core_video::{
|
|
||||||
pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA},
|
|
||||||
r#return::kCVReturnSuccess,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::services::servo_live::ServoLiveFrame;
|
|
||||||
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
||||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
||||||
|
|
||||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None)
|
|
||||||
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
|
||||||
let lock_status = pixel_buffer.lock_base_address(0);
|
|
||||||
if lock_status != kCVReturnSuccess {
|
|
||||||
return Err(format!("CVPixelBufferLockBaseAddress returned status {lock_status}"));
|
|
||||||
}
|
|
||||||
let bytes_per_row = pixel_buffer.get_bytes_per_row();
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe {
|
|
||||||
let base_address = pixel_buffer.get_base_address().cast::<u8>();
|
|
||||||
let bytes = std::slice::from_raw_parts_mut(base_address, bytes_per_row);
|
|
||||||
bytes[0..8].copy_from_slice(&[
|
|
||||||
0, 0, 255, 255, // red in BGRA memory order
|
|
||||||
255, 255, 255, 255,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
let unlock_status = pixel_buffer.unlock_base_address(0);
|
|
||||||
if unlock_status != kCVReturnSuccess {
|
|
||||||
return Err(format!("CVPixelBufferUnlockBaseAddress returned status {unlock_status}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 1, pixel_buffer);
|
|
||||||
let frame = WebSurfaceFrame::from_live_frame(
|
|
||||||
"https://example.com/".to_string(),
|
|
||||||
WebSurfaceScrollOffset::default(),
|
|
||||||
100,
|
|
||||||
live,
|
|
||||||
)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
|
|
||||||
assert_eq!(frame.non_white_pixel_count(), 1);
|
|
||||||
assert_eq!(frame.content_pixel_count(), 1);
|
|
||||||
assert_ne!(frame.sample_hash(), 0);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,28 @@
|
|||||||
use ely_domain::{BrowserTab, TabId};
|
use ely_domain::{BrowserTab, TabId};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Corners, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton,
|
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
||||||
ObjectFit, ParentElement, Styled, StyledImage, Window, canvas, div, img, px, rgb, surface,
|
ParentElement, Styled, Window, canvas, div, native_surface, px, rgb,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ElyShell, web_surface_frame::WebSurfaceFrame,
|
ElyShell, web_surface_frame::WebSurfaceFrame,
|
||||||
web_surface_geometry::servo_scroll_delta_from_wheel_delta,
|
web_surface_geometry::servo_scroll_delta_from_wheel_delta,
|
||||||
};
|
};
|
||||||
use ely_design_system::{colors, spacing};
|
use ely_design_system::colors;
|
||||||
|
|
||||||
pub(super) fn render_ready_web_surface(
|
pub(super) fn render_ready_web_surface(
|
||||||
frame: &WebSurfaceFrame,
|
_frame: &WebSurfaceFrame,
|
||||||
tab: &BrowserTab,
|
tab: &BrowserTab,
|
||||||
state_entity: Entity<ElyShell>,
|
state_entity: Entity<ElyShell>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
#[cfg(target_os = "macos")]
|
render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity))
|
||||||
if let Some(pixel_buffer) = frame.pixel_buffer.as_ref() {
|
|
||||||
return render_web_surface(
|
|
||||||
tab,
|
|
||||||
state_entity,
|
|
||||||
surface(pixel_buffer.clone())
|
|
||||||
.size_full()
|
|
||||||
.corner_radii(web_surface_corner_radii())
|
|
||||||
.object_fit(ObjectFit::Fill),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(image) = frame.image.as_ref() {
|
|
||||||
return render_web_surface(
|
|
||||||
tab,
|
|
||||||
state_entity,
|
|
||||||
img(ImageSource::Render(image.clone())).size_full().object_fit(ObjectFit::Fill),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
render_web_surface(
|
|
||||||
tab,
|
|
||||||
state_entity,
|
|
||||||
error_page("Web surface frame did not include renderable pixels."),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn render_loading_web_surface(
|
pub(super) fn render_loading_web_surface(
|
||||||
tab: &BrowserTab,
|
tab: &BrowserTab,
|
||||||
state_entity: Entity<ElyShell>,
|
state_entity: Entity<ElyShell>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
render_web_surface(tab, state_entity, div().size_full())
|
render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn render_failed_web_surface(
|
pub(super) fn render_failed_web_surface(
|
||||||
@@ -56,15 +33,6 @@ pub(super) fn render_failed_web_surface(
|
|||||||
render_web_surface(tab, state_entity, error_page(message))
|
render_web_surface(tab, state_entity, error_page(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn web_surface_corner_radii() -> Corners<gpui::Pixels> {
|
|
||||||
Corners {
|
|
||||||
top_left: px(0.0),
|
|
||||||
top_right: px(0.0),
|
|
||||||
bottom_right: px(spacing::RADIUS_CARD),
|
|
||||||
bottom_left: px(spacing::RADIUS_CARD),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn error_page(message: &str) -> impl IntoElement {
|
fn error_page(message: &str) -> impl IntoElement {
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -107,6 +75,18 @@ fn render_web_surface(
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_native_web_surface(tab: &BrowserTab, state_entity: Entity<ElyShell>) -> impl IntoElement {
|
||||||
|
let tab_id = tab.id().clone();
|
||||||
|
let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into());
|
||||||
|
native_surface(element_id, move |surface, bounds, window: &mut Window, cx: &mut App| {
|
||||||
|
let scale_factor = window.scale_factor();
|
||||||
|
state_entity.update(cx, |shell, cx| {
|
||||||
|
shell.record_external_web_surface(tab_id.clone(), bounds, scale_factor, surface, cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.size_full()
|
||||||
|
}
|
||||||
|
|
||||||
fn render_input_overlay(
|
fn render_input_overlay(
|
||||||
tab_id: TabId,
|
tab_id: TabId,
|
||||||
url: String,
|
url: String,
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ use crate::services::servo_live::{
|
|||||||
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
|
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// IPC surface for the per-profile Servo sidecar.
|
/// Blocking surface for the embedded Servo runtime.
|
||||||
///
|
///
|
||||||
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
|
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
|
||||||
/// fake. The contract: every call is blocking and may run for tens of
|
/// fake. The contract: every call is blocking and may run for tens of
|
||||||
/// milliseconds. Implementations live on the worker thread, never the
|
/// milliseconds. Implementations live on the worker thread.
|
||||||
/// UI thread.
|
pub(super) trait LiveRuntimeClient {
|
||||||
pub(super) trait LiveRuntimeClient: Send {
|
|
||||||
fn ensure(
|
fn ensure(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: ServoLiveEnsureRequest,
|
request: ServoLiveEnsureRequest,
|
||||||
@@ -45,20 +44,20 @@ impl LiveRuntimeClient for ServoLiveClient {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(super) enum LiveRuntimeClientError {
|
pub(super) enum LiveRuntimeClientError {
|
||||||
SidecarExited,
|
RuntimeUnavailable,
|
||||||
Message(String),
|
Message(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveRuntimeClientError {
|
impl LiveRuntimeClientError {
|
||||||
pub(super) fn is_sidecar_exited(&self) -> bool {
|
pub(super) fn is_runtime_unavailable(&self) -> bool {
|
||||||
matches!(self, Self::SidecarExited)
|
matches!(self, Self::RuntimeUnavailable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for LiveRuntimeClientError {
|
impl std::fmt::Display for LiveRuntimeClientError {
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::SidecarExited => formatter.write_str("servo live sidecar exited"),
|
Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"),
|
||||||
Self::Message(message) => formatter.write_str(message),
|
Self::Message(message) => formatter.write_str(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,8 +71,8 @@ impl From<String> for LiveRuntimeClientError {
|
|||||||
|
|
||||||
impl From<ServoLiveError> for LiveRuntimeClientError {
|
impl From<ServoLiveError> for LiveRuntimeClientError {
|
||||||
fn from(error: ServoLiveError) -> Self {
|
fn from(error: ServoLiveError) -> Self {
|
||||||
if error.is_sidecar_process_unusable() {
|
if error.is_runtime_unavailable() {
|
||||||
return Self::SidecarExited;
|
return Self::RuntimeUnavailable;
|
||||||
}
|
}
|
||||||
Self::Message(error.to_string())
|
Self::Message(error.to_string())
|
||||||
}
|
}
|
||||||
@@ -89,7 +88,7 @@ impl From<io::Error> for LiveRuntimeClientError {
|
|||||||
pub(super) enum WorkerResponse {
|
pub(super) enum WorkerResponse {
|
||||||
Frame { tab_id: String, frame: ServoLiveFrame },
|
Frame { tab_id: String, frame: ServoLiveFrame },
|
||||||
Failed { tab_id: String, message: String },
|
Failed { tab_id: String, message: String },
|
||||||
SidecarExited,
|
RuntimeUnavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WorkerRequest {
|
enum WorkerRequest {
|
||||||
@@ -116,9 +115,9 @@ struct WorkerQueue {
|
|||||||
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
|
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
|
||||||
/// a non-blocking API: submit ensure/poll/close, then drain responses.
|
/// a non-blocking API: submit ensure/poll/close, then drain responses.
|
||||||
///
|
///
|
||||||
/// The UI thread never blocks on Servo IPC. Submissions push into a
|
/// The UI thread never blocks on Servo. Submissions push into a
|
||||||
/// coalescing queue (latest request per tab wins). The worker thread
|
/// coalescing queue (latest request per tab wins). The worker thread
|
||||||
/// drains the queue, runs the blocking IPC, and emits responses on a
|
/// drains the queue, runs the blocking calls, and emits responses on a
|
||||||
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
|
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
|
||||||
pub(super) struct LiveRuntimeWorker {
|
pub(super) struct LiveRuntimeWorker {
|
||||||
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
|
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
|
||||||
@@ -127,7 +126,9 @@ pub(super) struct LiveRuntimeWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LiveRuntimeWorker {
|
impl LiveRuntimeWorker {
|
||||||
pub(super) fn new(client: Box<dyn LiveRuntimeClient>) -> Result<Self, String> {
|
pub(super) fn new(
|
||||||
|
client_factory: impl FnOnce() -> Result<Box<dyn LiveRuntimeClient>, String> + Send + 'static,
|
||||||
|
) -> Result<Self, String> {
|
||||||
let queue = Arc::new((
|
let queue = Arc::new((
|
||||||
Mutex::new(WorkerQueue {
|
Mutex::new(WorkerQueue {
|
||||||
pending: BTreeMap::new(),
|
pending: BTreeMap::new(),
|
||||||
@@ -138,13 +139,35 @@ impl LiveRuntimeWorker {
|
|||||||
Condvar::new(),
|
Condvar::new(),
|
||||||
));
|
));
|
||||||
let (response_tx, response_rx) = mpsc::channel();
|
let (response_tx, response_rx) = mpsc::channel();
|
||||||
|
let (init_tx, init_rx) = mpsc::channel();
|
||||||
let queue_for_thread = queue.clone();
|
let queue_for_thread = queue.clone();
|
||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("ely-servo-live".to_string())
|
.name("ely-servo-runtime".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
|
let client = match client_factory() {
|
||||||
|
Ok(client) => {
|
||||||
|
let _ = init_tx.send(Ok(()));
|
||||||
|
client
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = init_tx.send(Err(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
run_worker(client, queue_for_thread, response_tx);
|
run_worker(client, queue_for_thread, response_tx);
|
||||||
})
|
})
|
||||||
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
|
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
|
||||||
|
match init_rx.recv() {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
let _ = thread.join();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = thread.join();
|
||||||
|
return Err(format!("servo live worker initialization failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(Self { queue, response_rx, thread: Some(thread) })
|
Ok(Self { queue, response_rx, thread: Some(thread) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +340,7 @@ enum Work {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forward a single client result to the response channel. Returns
|
/// Forward a single client result to the response channel. Returns
|
||||||
/// `true` when the worker should exit (sidecar process died).
|
/// `true` when the worker should exit.
|
||||||
fn dispatch_result(
|
fn dispatch_result(
|
||||||
response_tx: &mpsc::Sender<WorkerResponse>,
|
response_tx: &mpsc::Sender<WorkerResponse>,
|
||||||
tab_id: String,
|
tab_id: String,
|
||||||
@@ -330,11 +353,11 @@ fn dispatch_result(
|
|||||||
}
|
}
|
||||||
Ok(None) => false,
|
Ok(None) => false,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let exited = error.is_sidecar_exited();
|
let unavailable = error.is_runtime_unavailable();
|
||||||
let message = error.to_string();
|
let message = error.to_string();
|
||||||
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
|
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
|
||||||
if exited {
|
if unavailable {
|
||||||
let _ = response_tx.send(WorkerResponse::SidecarExited);
|
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -7,41 +7,30 @@ rust-version.workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
servo-engine = ["dep:dpi", "dep:euclid", "dep:serde", "dep:serde_json", "dep:servo", "dep:url"]
|
servo-engine = [
|
||||||
hardware-render = [
|
"dep:dpi",
|
||||||
"servo-engine",
|
"dep:euclid",
|
||||||
"dep:gleam",
|
"dep:naga",
|
||||||
"dep:glow",
|
"dep:raw-window-handle",
|
||||||
"dep:image",
|
"dep:rustls",
|
||||||
"dep:log",
|
"dep:serde",
|
||||||
"dep:mach2",
|
"dep:serde_json",
|
||||||
"dep:surfman",
|
"dep:servo",
|
||||||
"dep:objc2-io-surface",
|
"dep:url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "ely_servo_sidecar"
|
|
||||||
path = "src/bin/ely_servo_sidecar.rs"
|
|
||||||
required-features = ["servo-engine"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dpi = { workspace = true, optional = true }
|
dpi = { workspace = true, optional = true }
|
||||||
ely_domain = { path = "../ely_domain" }
|
ely_domain = { path = "../ely_domain" }
|
||||||
euclid = { version = "0.22", optional = true }
|
euclid = { version = "0.22", optional = true }
|
||||||
gleam = { version = "0.15", optional = true }
|
naga = { version = "26.0.0", features = ["termcolor"], optional = true }
|
||||||
glow = { version = "0.16", optional = true }
|
raw-window-handle = { version = "0.6", optional = true }
|
||||||
image = { workspace = true, optional = true }
|
rustls = { version = "0.23.40", default-features = false, features = ["std", "aws_lc_rs"], optional = true }
|
||||||
log = { version = "0.4", optional = true }
|
|
||||||
serde = { workspace = true, optional = true }
|
serde = { workspace = true, optional = true }
|
||||||
serde_json = { workspace = true, optional = true }
|
serde_json = { workspace = true, optional = true }
|
||||||
servo = { workspace = true, optional = true }
|
servo = { workspace = true, optional = true }
|
||||||
surfman = { version = "0.11", optional = true }
|
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
url = { workspace = true, optional = true }
|
url = { workspace = true, optional = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
mach2 = { version = "0.6", optional = true }
|
|
||||||
objc2-io-surface = { version = "0.3.2", optional = true }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
use std::{
|
|
||||||
io::Write,
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use ely_domain::TabId;
|
|
||||||
use ely_servo_host::{
|
|
||||||
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest,
|
|
||||||
PermissionRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize,
|
|
||||||
SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState,
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[path = "ely_servo_sidecar/args.rs"]
|
|
||||||
mod args;
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
#[path = "ely_servo_sidecar/iosurface_mach.rs"]
|
|
||||||
mod iosurface_mach;
|
|
||||||
#[path = "ely_servo_sidecar/live.rs"]
|
|
||||||
mod live;
|
|
||||||
#[path = "ely_servo_sidecar/live_output.rs"]
|
|
||||||
mod live_output;
|
|
||||||
#[path = "ely_servo_sidecar/live_protocol.rs"]
|
|
||||||
mod live_protocol;
|
|
||||||
#[path = "ely_servo_sidecar/live_session.rs"]
|
|
||||||
mod live_session;
|
|
||||||
#[path = "ely_servo_sidecar/perf.rs"]
|
|
||||||
mod perf;
|
|
||||||
#[path = "ely_servo_sidecar/report.rs"]
|
|
||||||
mod report;
|
|
||||||
|
|
||||||
use args::{SidecarCommand, SnapshotArgs};
|
|
||||||
use report::{SnapshotInputChanges, SnapshotReport};
|
|
||||||
|
|
||||||
const WAIT_ITERATIONS: usize = 5_000;
|
|
||||||
const WAIT_INTERVAL: Duration = Duration::from_millis(2);
|
|
||||||
const RENDER_TIMEOUT: Duration = Duration::from_secs(20);
|
|
||||||
const VISIBLE_FRAME_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
|
|
||||||
const INPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
|
|
||||||
|
|
||||||
fn main() -> Result<(), SidecarError> {
|
|
||||||
match args::parse_env_command()? {
|
|
||||||
SidecarCommand::Live(args) => live::run_live(args).map_err(SidecarError::Live),
|
|
||||||
SidecarCommand::Snapshot(args) => run_snapshot(args),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
enum SidecarError {
|
|
||||||
#[error("timed out rendering {url}: {snapshot:?}")]
|
|
||||||
RenderTimeout { url: String, snapshot: Box<WebViewSnapshot> },
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Args(#[from] args::SidecarArgsError),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Host(#[from] ServoHostError),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Live(#[from] live::LiveSidecarError),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Json(#[from] serde_json::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> {
|
|
||||||
std::fs::create_dir_all(&args.profile_data_dir)?;
|
|
||||||
let mut host = SoftwareServoHost::new_with_config_dir(
|
|
||||||
ServoSurfaceSize::new(args.width, args.height),
|
|
||||||
Some(args.profile_data_dir.clone()),
|
|
||||||
)?;
|
|
||||||
let tab_id = TabId::new();
|
|
||||||
let webview_id = host.create_webview(tab_id.clone(), args.profile_id.clone())?;
|
|
||||||
apply_site_permissions(&mut host, &webview_id, &args)?;
|
|
||||||
host.set_page_zoom(PageZoomRequest {
|
|
||||||
webview_id: webview_id.clone(),
|
|
||||||
zoom_factor: f32::from(args.page_zoom_percent) / 100.0,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
host.navigate(NavigationRequest {
|
|
||||||
webview_id: webview_id.clone(),
|
|
||||||
tab_id,
|
|
||||||
url: args.url.clone(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let snapshot = wait_for_frame(&mut host, &webview_id, args.url.as_str())?;
|
|
||||||
let (snapshot, scroll_changed_frame) =
|
|
||||||
apply_scroll_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
|
||||||
let (snapshot, click_changed_frame) =
|
|
||||||
apply_click_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
|
||||||
let (snapshot, drag_changed_frame) =
|
|
||||||
apply_drag_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
|
||||||
let (snapshot, touch_changed_frame) =
|
|
||||||
apply_touch_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
|
||||||
let (snapshot, text_changed_frame) =
|
|
||||||
apply_text_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
|
||||||
let frame = host.last_rendered_frame()?;
|
|
||||||
std::fs::write(&args.rgba_out, frame.rgba_bytes())?;
|
|
||||||
|
|
||||||
let mut stdout = std::io::stdout().lock();
|
|
||||||
serde_json::to_writer(
|
|
||||||
&mut stdout,
|
|
||||||
&SnapshotReport::new(
|
|
||||||
&args,
|
|
||||||
&snapshot,
|
|
||||||
&frame,
|
|
||||||
SnapshotInputChanges {
|
|
||||||
scroll: scroll_changed_frame,
|
|
||||||
click: click_changed_frame,
|
|
||||||
drag: drag_changed_frame,
|
|
||||||
touch: touch_changed_frame,
|
|
||||||
text: text_changed_frame,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)?;
|
|
||||||
stdout.write_all(b"\n")?;
|
|
||||||
stdout.flush()?;
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_site_permissions(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
args: &SnapshotArgs,
|
|
||||||
) -> Result<(), SidecarError> {
|
|
||||||
for permission in &args.site_permissions {
|
|
||||||
host.set_permission(
|
|
||||||
PermissionRequest {
|
|
||||||
webview_id: webview_id.clone(),
|
|
||||||
profile_id: args.profile_id.clone(),
|
|
||||||
origin: permission.origin.clone(),
|
|
||||||
feature: permission.feature,
|
|
||||||
},
|
|
||||||
permission.decision.into(),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_scroll_if_requested(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
args: &SnapshotArgs,
|
|
||||||
snapshot: WebViewSnapshot,
|
|
||||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
|
||||||
if args.scroll_x == 0 && args.scroll_y == 0 {
|
|
||||||
return Ok((snapshot, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
|
||||||
host.scroll(ScrollRequest {
|
|
||||||
webview_id: webview_id.clone(),
|
|
||||||
delta_x: args.scroll_x,
|
|
||||||
delta_y: args.scroll_y,
|
|
||||||
point_x: 0,
|
|
||||||
point_y: 0,
|
|
||||||
})?;
|
|
||||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_click_if_requested(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
args: &SnapshotArgs,
|
|
||||||
snapshot: WebViewSnapshot,
|
|
||||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
|
||||||
let Some(click_point) = args.click_point else {
|
|
||||||
return Ok((snapshot, false));
|
|
||||||
};
|
|
||||||
|
|
||||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
|
||||||
host.click(MouseClickRequest {
|
|
||||||
webview_id: webview_id.clone(),
|
|
||||||
x: click_point.x,
|
|
||||||
y: click_point.y,
|
|
||||||
})?;
|
|
||||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_drag_if_requested(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
args: &SnapshotArgs,
|
|
||||||
snapshot: WebViewSnapshot,
|
|
||||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
|
||||||
let Some(drag_points) = args.drag_points else {
|
|
||||||
return Ok((snapshot, false));
|
|
||||||
};
|
|
||||||
|
|
||||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
|
||||||
host.drag(MouseDragRequest {
|
|
||||||
webview_id: webview_id.clone(),
|
|
||||||
from_x: drag_points.from.x,
|
|
||||||
from_y: drag_points.from.y,
|
|
||||||
to_x: drag_points.to.x,
|
|
||||||
to_y: drag_points.to.y,
|
|
||||||
})?;
|
|
||||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_touch_if_requested(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
args: &SnapshotArgs,
|
|
||||||
snapshot: WebViewSnapshot,
|
|
||||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
|
||||||
let Some(touch_point) = args.touch_point else {
|
|
||||||
return Ok((snapshot, false));
|
|
||||||
};
|
|
||||||
|
|
||||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
|
||||||
host.touch_tap(TouchTapRequest {
|
|
||||||
webview_id: webview_id.clone(),
|
|
||||||
x: touch_point.x,
|
|
||||||
y: touch_point.y,
|
|
||||||
})?;
|
|
||||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_text_if_requested(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
args: &SnapshotArgs,
|
|
||||||
snapshot: WebViewSnapshot,
|
|
||||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
|
||||||
let Some(typed_text) = args.typed_text.as_ref() else {
|
|
||||||
return Ok((snapshot, false));
|
|
||||||
};
|
|
||||||
|
|
||||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
|
||||||
host.type_text(KeyboardTextRequest {
|
|
||||||
webview_id: webview_id.clone(),
|
|
||||||
text: typed_text.clone(),
|
|
||||||
})?;
|
|
||||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_for_frame(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
url: &str,
|
|
||||||
) -> Result<WebViewSnapshot, SidecarError> {
|
|
||||||
let started_at = Instant::now();
|
|
||||||
let mut latest_rendered_snapshot = None;
|
|
||||||
let mut visible_frame_hash = None;
|
|
||||||
let mut visible_frame_last_changed_at = None;
|
|
||||||
|
|
||||||
for _ in 0..WAIT_ITERATIONS {
|
|
||||||
if started_at.elapsed() >= RENDER_TIMEOUT {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
host.tick();
|
|
||||||
let snapshot = host.snapshot(webview_id)?;
|
|
||||||
if snapshot.has_pending_frame() {
|
|
||||||
host.paint(webview_id)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = host.snapshot(webview_id)?;
|
|
||||||
if let Ok(frame) = host.last_rendered_frame()
|
|
||||||
&& frame.non_white_pixel_count() > 0
|
|
||||||
&& frame.content_pixel_count() > 0
|
|
||||||
{
|
|
||||||
let current_hash = frame.sample_hash();
|
|
||||||
if visible_frame_hash != Some(current_hash) {
|
|
||||||
visible_frame_hash = Some(current_hash);
|
|
||||||
visible_frame_last_changed_at = Some(Instant::now());
|
|
||||||
}
|
|
||||||
|
|
||||||
if snapshot.state() == &WebViewState::Complete {
|
|
||||||
return Ok(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
latest_rendered_snapshot = Some(snapshot.clone());
|
|
||||||
if snapshot.url().is_some()
|
|
||||||
&& visible_frame_last_changed_at
|
|
||||||
.is_some_and(|changed_at| changed_at.elapsed() >= VISIBLE_FRAME_SETTLE_TIMEOUT)
|
|
||||||
{
|
|
||||||
return Ok(snapshot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(WAIT_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(snapshot) = latest_rendered_snapshot {
|
|
||||||
return Ok(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(SidecarError::RenderTimeout {
|
|
||||||
url: url.to_string(),
|
|
||||||
snapshot: Box::new(host.snapshot(webview_id)?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_for_changed_or_settled_frame(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
previous_frame_hash: u64,
|
|
||||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
|
||||||
let started_at = Instant::now();
|
|
||||||
let mut latest_snapshot = host.snapshot(webview_id)?;
|
|
||||||
|
|
||||||
for _ in 0..WAIT_ITERATIONS {
|
|
||||||
if started_at.elapsed() >= INPUT_SETTLE_TIMEOUT {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
host.tick();
|
|
||||||
let snapshot = host.snapshot(webview_id)?;
|
|
||||||
if snapshot.has_pending_frame() {
|
|
||||||
host.paint(webview_id)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
latest_snapshot = host.snapshot(webview_id)?;
|
|
||||||
let changed_frame = host.last_rendered_frame().is_ok_and(|frame| {
|
|
||||||
frame.non_white_pixel_count() > 0 && frame.sample_hash() != previous_frame_hash
|
|
||||||
});
|
|
||||||
if changed_frame {
|
|
||||||
return Ok((latest_snapshot, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(WAIT_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((latest_snapshot, false))
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
use std::{env, num::ParseIntError, path::PathBuf};
|
|
||||||
|
|
||||||
use ely_domain::{
|
|
||||||
DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature,
|
|
||||||
UrlText, validate_zoom_percent,
|
|
||||||
};
|
|
||||||
use ely_servo_host::RenderingContextKind;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
pub(super) enum SidecarCommand {
|
|
||||||
Live(LiveArgs),
|
|
||||||
Snapshot(SnapshotArgs),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct LiveArgs {
|
|
||||||
pub(super) profile_data_dir: PathBuf,
|
|
||||||
pub(super) iosurface_mach_service: Option<String>,
|
|
||||||
/// Rendering context the host's webviews are built against.
|
|
||||||
/// Defaults to [`RenderingContextKind::Software`], which keeps
|
|
||||||
/// the binary's behaviour bit-identical to pre-flag builds.
|
|
||||||
/// `Hardware` is only accepted when the `hardware-render`
|
|
||||||
/// feature is compiled in (otherwise the `SoftwareServoHost`
|
|
||||||
/// constructor returns `HardwareRenderUnavailable`).
|
|
||||||
pub(super) rendering_context_kind: RenderingContextKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct SnapshotArgs {
|
|
||||||
pub(super) url: UrlText,
|
|
||||||
pub(super) profile_id: ProfileId,
|
|
||||||
pub(super) profile_data_dir: PathBuf,
|
|
||||||
pub(super) rgba_out: PathBuf,
|
|
||||||
pub(super) width: u32,
|
|
||||||
pub(super) height: u32,
|
|
||||||
pub(super) scroll_x: i32,
|
|
||||||
pub(super) scroll_y: i32,
|
|
||||||
pub(super) page_zoom_percent: u16,
|
|
||||||
pub(super) click_point: Option<ClickPoint>,
|
|
||||||
pub(super) drag_points: Option<DragPoints>,
|
|
||||||
pub(super) touch_point: Option<ClickPoint>,
|
|
||||||
pub(super) typed_text: Option<String>,
|
|
||||||
pub(super) site_permissions: Vec<SidecarSitePermission>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) struct ClickPoint {
|
|
||||||
pub(super) x: u32,
|
|
||||||
pub(super) y: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) struct DragPoints {
|
|
||||||
pub(super) from: ClickPoint,
|
|
||||||
pub(super) to: ClickPoint,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct SidecarSitePermission {
|
|
||||||
pub(super) origin: SiteOrigin,
|
|
||||||
pub(super) feature: SitePermissionFeature,
|
|
||||||
pub(super) decision: SitePermissionDecision,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub(super) enum SidecarArgsError {
|
|
||||||
#[error("missing sidecar command")]
|
|
||||||
MissingCommand,
|
|
||||||
|
|
||||||
#[error("unknown sidecar command: {value}")]
|
|
||||||
UnknownCommand { value: String },
|
|
||||||
|
|
||||||
#[error("missing argument value for {name}")]
|
|
||||||
MissingArgumentValue { name: &'static str },
|
|
||||||
|
|
||||||
#[error("missing required argument: {name}")]
|
|
||||||
MissingRequiredArgument { name: &'static str },
|
|
||||||
|
|
||||||
#[error("unknown argument: {value}")]
|
|
||||||
UnknownArgument { value: String },
|
|
||||||
|
|
||||||
#[error("{name} must be an integer: {value}")]
|
|
||||||
InvalidInteger {
|
|
||||||
name: &'static str,
|
|
||||||
value: String,
|
|
||||||
#[source]
|
|
||||||
source: ParseIntError,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("{name} must be greater than zero")]
|
|
||||||
ZeroDimension { name: &'static str },
|
|
||||||
|
|
||||||
#[error("--click-x and --click-y must be provided together")]
|
|
||||||
IncompleteClickPoint,
|
|
||||||
|
|
||||||
#[error("--drag-from-x, --drag-from-y, --drag-to-x, and --drag-to-y must be provided together")]
|
|
||||||
IncompleteDragPoints,
|
|
||||||
|
|
||||||
#[error("--touch-x and --touch-y must be provided together")]
|
|
||||||
IncompleteTouchPoint,
|
|
||||||
|
|
||||||
#[error("{name} path is empty")]
|
|
||||||
EmptyPath { name: &'static str },
|
|
||||||
|
|
||||||
#[error("invalid --site-permission JSON: {value}")]
|
|
||||||
InvalidSitePermissionJson {
|
|
||||||
value: String,
|
|
||||||
#[source]
|
|
||||||
source: serde_json::Error,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"invalid --rendering-context value: {value:?} (expected \"software\" or \
|
|
||||||
\"hardware\")"
|
|
||||||
)]
|
|
||||||
InvalidRenderingContext { value: String },
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Domain(#[from] ely_domain::DomainError),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn parse_env_command() -> Result<SidecarCommand, SidecarArgsError> {
|
|
||||||
parse_command(env::args())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_command(
|
|
||||||
args: impl IntoIterator<Item = String>,
|
|
||||||
) -> Result<SidecarCommand, SidecarArgsError> {
|
|
||||||
let mut args = args.into_iter();
|
|
||||||
let _program_name = args.next();
|
|
||||||
let command = args.next().ok_or(SidecarArgsError::MissingCommand)?;
|
|
||||||
|
|
||||||
match command.as_str() {
|
|
||||||
"live" => parse_live_args(args).map(SidecarCommand::Live),
|
|
||||||
"snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot),
|
|
||||||
_ => Err(SidecarArgsError::UnknownCommand { value: command }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_live_args(args: impl IntoIterator<Item = String>) -> Result<LiveArgs, SidecarArgsError> {
|
|
||||||
let mut args = args.into_iter();
|
|
||||||
let mut profile_data_dir = None;
|
|
||||||
let mut iosurface_mach_service = None;
|
|
||||||
let mut rendering_context_kind = RenderingContextKind::default();
|
|
||||||
|
|
||||||
while let Some(name) = args.next() {
|
|
||||||
match name.as_str() {
|
|
||||||
"--profile-data-dir" => {
|
|
||||||
profile_data_dir = Some(parse_path(
|
|
||||||
"--profile-data-dir",
|
|
||||||
next_argument(&mut args, "--profile-data-dir")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--rendering-context" => {
|
|
||||||
let value = next_argument(&mut args, "--rendering-context")?;
|
|
||||||
rendering_context_kind = match value.as_str() {
|
|
||||||
"software" => RenderingContextKind::Software,
|
|
||||||
"hardware" => RenderingContextKind::Hardware,
|
|
||||||
_ => return Err(SidecarArgsError::InvalidRenderingContext { value }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
"--iosurface-mach-service" => {
|
|
||||||
iosurface_mach_service =
|
|
||||||
Some(next_argument(&mut args, "--iosurface-mach-service")?);
|
|
||||||
}
|
|
||||||
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(LiveArgs {
|
|
||||||
profile_data_dir: profile_data_dir
|
|
||||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
|
|
||||||
iosurface_mach_service,
|
|
||||||
rendering_context_kind,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_snapshot_args(
|
|
||||||
args: impl IntoIterator<Item = String>,
|
|
||||||
) -> Result<SnapshotArgs, SidecarArgsError> {
|
|
||||||
let mut args = args.into_iter();
|
|
||||||
let mut url = None;
|
|
||||||
let mut profile_id = None;
|
|
||||||
let mut profile_data_dir = None;
|
|
||||||
let mut rgba_out = None;
|
|
||||||
let mut width = None;
|
|
||||||
let mut height = None;
|
|
||||||
let mut scroll_x = 0;
|
|
||||||
let mut scroll_y = 0;
|
|
||||||
let mut page_zoom_percent = DEFAULT_ZOOM_PERCENT;
|
|
||||||
let mut click_x = None;
|
|
||||||
let mut click_y = None;
|
|
||||||
let mut drag_from_x = None;
|
|
||||||
let mut drag_from_y = None;
|
|
||||||
let mut drag_to_x = None;
|
|
||||||
let mut drag_to_y = None;
|
|
||||||
let mut touch_x = None;
|
|
||||||
let mut touch_y = None;
|
|
||||||
let mut typed_text = None;
|
|
||||||
let mut site_permissions = Vec::new();
|
|
||||||
|
|
||||||
while let Some(name) = args.next() {
|
|
||||||
match name.as_str() {
|
|
||||||
"--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?),
|
|
||||||
"--profile-id" => {
|
|
||||||
profile_id = Some(ProfileId::parse(next_argument(&mut args, "--profile-id")?)?)
|
|
||||||
}
|
|
||||||
"--profile-data-dir" => {
|
|
||||||
profile_data_dir = Some(parse_path(
|
|
||||||
"--profile-data-dir",
|
|
||||||
next_argument(&mut args, "--profile-data-dir")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--rgba-out" => {
|
|
||||||
rgba_out = Some(parse_path("--rgba-out", next_argument(&mut args, "--rgba-out")?)?)
|
|
||||||
}
|
|
||||||
"--width" => {
|
|
||||||
width = Some(parse_dimension("--width", next_argument(&mut args, "--width")?)?)
|
|
||||||
}
|
|
||||||
"--height" => {
|
|
||||||
height = Some(parse_dimension("--height", next_argument(&mut args, "--height")?)?)
|
|
||||||
}
|
|
||||||
"--scroll-x" => {
|
|
||||||
scroll_x =
|
|
||||||
parse_scroll_delta("--scroll-x", next_argument(&mut args, "--scroll-x")?)?
|
|
||||||
}
|
|
||||||
"--scroll-y" => {
|
|
||||||
scroll_y =
|
|
||||||
parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)?
|
|
||||||
}
|
|
||||||
"--page-zoom-percent" => {
|
|
||||||
page_zoom_percent = parse_zoom_percent(
|
|
||||||
"--page-zoom-percent",
|
|
||||||
next_argument(&mut args, "--page-zoom-percent")?,
|
|
||||||
)?
|
|
||||||
}
|
|
||||||
"--click-x" => {
|
|
||||||
click_x = Some(parse_click_coordinate(
|
|
||||||
"--click-x",
|
|
||||||
next_argument(&mut args, "--click-x")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--click-y" => {
|
|
||||||
click_y = Some(parse_click_coordinate(
|
|
||||||
"--click-y",
|
|
||||||
next_argument(&mut args, "--click-y")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--drag-from-x" => {
|
|
||||||
drag_from_x = Some(parse_click_coordinate(
|
|
||||||
"--drag-from-x",
|
|
||||||
next_argument(&mut args, "--drag-from-x")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--drag-from-y" => {
|
|
||||||
drag_from_y = Some(parse_click_coordinate(
|
|
||||||
"--drag-from-y",
|
|
||||||
next_argument(&mut args, "--drag-from-y")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--drag-to-x" => {
|
|
||||||
drag_to_x = Some(parse_click_coordinate(
|
|
||||||
"--drag-to-x",
|
|
||||||
next_argument(&mut args, "--drag-to-x")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--drag-to-y" => {
|
|
||||||
drag_to_y = Some(parse_click_coordinate(
|
|
||||||
"--drag-to-y",
|
|
||||||
next_argument(&mut args, "--drag-to-y")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--touch-x" => {
|
|
||||||
touch_x = Some(parse_click_coordinate(
|
|
||||||
"--touch-x",
|
|
||||||
next_argument(&mut args, "--touch-x")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--touch-y" => {
|
|
||||||
touch_y = Some(parse_click_coordinate(
|
|
||||||
"--touch-y",
|
|
||||||
next_argument(&mut args, "--touch-y")?,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
"--type-text" => typed_text = Some(next_argument(&mut args, "--type-text")?),
|
|
||||||
"--site-permission" => site_permissions
|
|
||||||
.push(parse_site_permission(next_argument(&mut args, "--site-permission")?)?),
|
|
||||||
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let click_point = match (click_x, click_y) {
|
|
||||||
(Some(x), Some(y)) => Some(ClickPoint { x, y }),
|
|
||||||
(None, None) => None,
|
|
||||||
_ => return Err(SidecarArgsError::IncompleteClickPoint),
|
|
||||||
};
|
|
||||||
let drag_points = match (drag_from_x, drag_from_y, drag_to_x, drag_to_y) {
|
|
||||||
(Some(from_x), Some(from_y), Some(to_x), Some(to_y)) => Some(DragPoints {
|
|
||||||
from: ClickPoint { x: from_x, y: from_y },
|
|
||||||
to: ClickPoint { x: to_x, y: to_y },
|
|
||||||
}),
|
|
||||||
(None, None, None, None) => None,
|
|
||||||
_ => return Err(SidecarArgsError::IncompleteDragPoints),
|
|
||||||
};
|
|
||||||
let touch_point = match (touch_x, touch_y) {
|
|
||||||
(Some(x), Some(y)) => Some(ClickPoint { x, y }),
|
|
||||||
(None, None) => None,
|
|
||||||
_ => return Err(SidecarArgsError::IncompleteTouchPoint),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(SnapshotArgs {
|
|
||||||
url: url.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--url" })?,
|
|
||||||
profile_id: profile_id
|
|
||||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-id" })?,
|
|
||||||
profile_data_dir: profile_data_dir
|
|
||||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
|
|
||||||
rgba_out: rgba_out
|
|
||||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--rgba-out" })?,
|
|
||||||
width: width.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--width" })?,
|
|
||||||
height: height.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--height" })?,
|
|
||||||
scroll_x,
|
|
||||||
scroll_y,
|
|
||||||
page_zoom_percent,
|
|
||||||
click_point,
|
|
||||||
drag_points,
|
|
||||||
touch_point,
|
|
||||||
typed_text,
|
|
||||||
site_permissions,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SitePermissionArg {
|
|
||||||
origin: String,
|
|
||||||
feature: String,
|
|
||||||
decision: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_site_permission(value: String) -> Result<SidecarSitePermission, SidecarArgsError> {
|
|
||||||
let parsed: SitePermissionArg = serde_json::from_str(&value)
|
|
||||||
.map_err(|source| SidecarArgsError::InvalidSitePermissionJson { value, source })?;
|
|
||||||
|
|
||||||
Ok(SidecarSitePermission {
|
|
||||||
origin: SiteOrigin::parse(parsed.origin)?,
|
|
||||||
feature: SitePermissionFeature::parse(parsed.feature.as_str())?,
|
|
||||||
decision: SitePermissionDecision::parse(parsed.decision.as_str())?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_argument(
|
|
||||||
args: &mut impl Iterator<Item = String>,
|
|
||||||
name: &'static str,
|
|
||||||
) -> Result<String, SidecarArgsError> {
|
|
||||||
args.next().ok_or(SidecarArgsError::MissingArgumentValue { name })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_dimension(name: &'static str, value: String) -> Result<u32, SidecarArgsError> {
|
|
||||||
let dimension = value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger {
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
if dimension == 0 {
|
|
||||||
return Err(SidecarArgsError::ZeroDimension { name });
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(dimension)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_scroll_delta(name: &'static str, value: String) -> Result<i32, SidecarArgsError> {
|
|
||||||
value.parse::<i32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_click_coordinate(name: &'static str, value: String) -> Result<u32, SidecarArgsError> {
|
|
||||||
value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_zoom_percent(name: &'static str, value: String) -> Result<u16, SidecarArgsError> {
|
|
||||||
let percent = value.parse::<u16>().map_err(|source| SidecarArgsError::InvalidInteger {
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
Ok(validate_zoom_percent(percent)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_path(name: &'static str, value: String) -> Result<PathBuf, SidecarArgsError> {
|
|
||||||
if value.trim().is_empty() {
|
|
||||||
return Err(SidecarArgsError::EmptyPath { name });
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(PathBuf::from(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "args_tests.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
use std::{env, path::PathBuf};
|
|
||||||
|
|
||||||
use super::{SidecarArgsError, SidecarCommand, parse_command};
|
|
||||||
use ely_domain::{
|
|
||||||
DEFAULT_ZOOM_PERCENT, DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature,
|
|
||||||
};
|
|
||||||
use ely_servo_host::RenderingContextKind;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> {
|
|
||||||
let profile_id = ProfileId::new();
|
|
||||||
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
|
|
||||||
let args = parse_snapshot_command(&profile_id, profile_data_dir.clone())?;
|
|
||||||
|
|
||||||
assert_eq!(args.profile_id, profile_id);
|
|
||||||
assert_eq!(args.profile_data_dir, profile_data_dir);
|
|
||||||
assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_invalid_snapshot_profile_id() {
|
|
||||||
let command = parse_command(
|
|
||||||
[
|
|
||||||
"ely_servo_sidecar",
|
|
||||||
"snapshot",
|
|
||||||
"--url",
|
|
||||||
"https://example.com",
|
|
||||||
"--profile-id",
|
|
||||||
"profile_invalid",
|
|
||||||
"--profile-data-dir",
|
|
||||||
"/tmp/profile",
|
|
||||||
"--rgba-out",
|
|
||||||
"/tmp/frame.rgba",
|
|
||||||
"--width",
|
|
||||||
"64",
|
|
||||||
"--height",
|
|
||||||
"64",
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(str::to_string),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(command, Err(SidecarArgsError::Domain(DomainError::InvalidEntityId { .. }))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_snapshot_site_permissions() -> Result<(), SidecarArgsError> {
|
|
||||||
let profile_id = ProfileId::new();
|
|
||||||
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
|
|
||||||
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
|
|
||||||
command.push("--site-permission".to_string());
|
|
||||||
command.push(
|
|
||||||
r#"{"origin":"https://example.com","feature":"camera","decision":"allow-once"}"#
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let args = snapshot_args(parse_command(command)?);
|
|
||||||
assert_eq!(args.site_permissions.len(), 1);
|
|
||||||
let permission = &args.site_permissions[0];
|
|
||||||
assert_eq!(permission.origin.as_str(), "https://example.com");
|
|
||||||
assert_eq!(permission.feature, SitePermissionFeature::Camera);
|
|
||||||
assert_eq!(permission.decision, SitePermissionDecision::AllowOnce);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_snapshot_page_zoom_percent() -> Result<(), SidecarArgsError> {
|
|
||||||
let profile_id = ProfileId::new();
|
|
||||||
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
|
|
||||||
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
|
|
||||||
command.push("--page-zoom-percent".to_string());
|
|
||||||
command.push("125".to_string());
|
|
||||||
|
|
||||||
let args = snapshot_args(parse_command(command)?);
|
|
||||||
assert_eq!(args.page_zoom_percent, 125);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_out_of_range_snapshot_page_zoom_percent() {
|
|
||||||
let profile_id = ProfileId::new();
|
|
||||||
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
|
|
||||||
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
|
|
||||||
command.push("--page-zoom-percent".to_string());
|
|
||||||
command.push("5".to_string());
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
parse_command(command),
|
|
||||||
Err(SidecarArgsError::Domain(DomainError::InvalidZoomPercent { value: 5, .. }))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_snapshot_command(
|
|
||||||
profile_id: &ProfileId,
|
|
||||||
profile_data_dir: PathBuf,
|
|
||||||
) -> Result<super::SnapshotArgs, SidecarArgsError> {
|
|
||||||
Ok(snapshot_args(parse_command(snapshot_command_args(profile_id, profile_data_dir))?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_args(command: SidecarCommand) -> super::SnapshotArgs {
|
|
||||||
match command {
|
|
||||||
SidecarCommand::Snapshot(args) => args,
|
|
||||||
SidecarCommand::Live(_) => unreachable!("expected snapshot command"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_command_args(profile_id: &ProfileId, profile_data_dir: PathBuf) -> Vec<String> {
|
|
||||||
[
|
|
||||||
"ely_servo_sidecar".to_string(),
|
|
||||||
"snapshot".to_string(),
|
|
||||||
"--url".to_string(),
|
|
||||||
"https://example.com".to_string(),
|
|
||||||
"--profile-id".to_string(),
|
|
||||||
profile_id.as_str().to_string(),
|
|
||||||
"--profile-data-dir".to_string(),
|
|
||||||
profile_data_dir.display().to_string(),
|
|
||||||
"--rgba-out".to_string(),
|
|
||||||
"/tmp/frame.rgba".to_string(),
|
|
||||||
"--width".to_string(),
|
|
||||||
"64".to_string(),
|
|
||||||
"--height".to_string(),
|
|
||||||
"64".to_string(),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_live(extra_args: &[&str]) -> Result<super::LiveArgs, SidecarArgsError> {
|
|
||||||
let base = ["ely_servo_sidecar", "live", "--profile-data-dir", "/tmp/sidecar-live"];
|
|
||||||
let argv: Vec<String> =
|
|
||||||
base.iter().chain(extra_args.iter()).map(|s| (*s).to_string()).collect();
|
|
||||||
let SidecarCommand::Live(args) = parse_command(argv)? else {
|
|
||||||
return Err(SidecarArgsError::UnknownCommand {
|
|
||||||
value: "live-extracted-as-snapshot".into(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Ok(args)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_defaults_rendering_context_to_software() -> Result<(), SidecarArgsError> {
|
|
||||||
let args = parse_live(&[])?;
|
|
||||||
assert_eq!(args.rendering_context_kind, RenderingContextKind::Software);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_accepts_explicit_software_rendering_context() -> Result<(), SidecarArgsError> {
|
|
||||||
let args = parse_live(&["--rendering-context", "software"])?;
|
|
||||||
assert_eq!(args.rendering_context_kind, RenderingContextKind::Software);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_accepts_explicit_hardware_rendering_context() -> Result<(), SidecarArgsError> {
|
|
||||||
let args = parse_live(&["--rendering-context", "hardware"])?;
|
|
||||||
assert_eq!(args.rendering_context_kind, RenderingContextKind::Hardware);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_accepts_iosurface_mach_service_name() -> Result<(), SidecarArgsError> {
|
|
||||||
let args = parse_live(&["--iosurface-mach-service", "com.ely.test.iosurface"])?;
|
|
||||||
assert_eq!(args.iosurface_mach_service.as_deref(), Some("com.ely.test.iosurface"));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_rejects_unknown_rendering_context_value() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse_live(&["--rendering-context", "gpu"]),
|
|
||||||
Err(SidecarArgsError::InvalidRenderingContext { value }) if value == "gpu"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_requires_rendering_context_value() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse_live(&["--rendering-context"]),
|
|
||||||
Err(SidecarArgsError::MissingArgumentValue { name: "--rendering-context" })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
use std::{ffi::CString, mem, time::Duration};
|
|
||||||
|
|
||||||
use mach2::{
|
|
||||||
bootstrap::{bootstrap_look_up, bootstrap_port},
|
|
||||||
kern_return::KERN_SUCCESS,
|
|
||||||
mach_port::mach_port_deallocate,
|
|
||||||
message::{
|
|
||||||
MACH_MSG_SUCCESS, MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS,
|
|
||||||
MACH_MSGH_BITS_COMPLEX, MACH_SEND_MSG, MACH_SEND_TIMEOUT, mach_msg, mach_msg_body_t,
|
|
||||||
mach_msg_header_t, mach_msg_port_descriptor_t,
|
|
||||||
},
|
|
||||||
port::{MACH_PORT_NULL, mach_port_t},
|
|
||||||
traps::mach_task_self,
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use super::live_protocol::{LiveOutcome, LiveSidecarError};
|
|
||||||
|
|
||||||
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
|
|
||||||
const SEND_TIMEOUT: Duration = Duration::from_secs(1);
|
|
||||||
|
|
||||||
pub(super) struct IOSurfaceMachSender {
|
|
||||||
send_port: mach_port_t,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub(super) enum IOSurfaceMachError {
|
|
||||||
#[error("Mach service name contains an interior nul byte")]
|
|
||||||
InvalidServiceName,
|
|
||||||
|
|
||||||
#[error("bootstrap_look_up returned {code}")]
|
|
||||||
LookupService { code: i32 },
|
|
||||||
|
|
||||||
#[error("mach_msg send returned {code}")]
|
|
||||||
Send { code: i32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IOSurfaceMachSender {
|
|
||||||
pub(super) fn connect(service_name: &str) -> Result<Self, IOSurfaceMachError> {
|
|
||||||
let service_name =
|
|
||||||
CString::new(service_name).map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
|
|
||||||
let mut send_port = MACH_PORT_NULL;
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let result =
|
|
||||||
unsafe { bootstrap_look_up(bootstrap_port, service_name.as_ptr(), &mut send_port) };
|
|
||||||
if result != KERN_SUCCESS {
|
|
||||||
return Err(IOSurfaceMachError::LookupService { code: result });
|
|
||||||
}
|
|
||||||
Ok(Self { send_port })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn send_surface_port(
|
|
||||||
&mut self,
|
|
||||||
surface_id: u64,
|
|
||||||
mach_port: mach_port_t,
|
|
||||||
) -> Result<(), IOSurfaceMachError> {
|
|
||||||
let mut message = IOSurfacePortMessage {
|
|
||||||
header: mach_msg_header_t {
|
|
||||||
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) | MACH_MSGH_BITS_COMPLEX,
|
|
||||||
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
|
|
||||||
msgh_remote_port: self.send_port,
|
|
||||||
msgh_local_port: MACH_PORT_NULL,
|
|
||||||
msgh_voucher_port: MACH_PORT_NULL,
|
|
||||||
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
|
|
||||||
},
|
|
||||||
body: mach_msg_body_t { msgh_descriptor_count: 1 },
|
|
||||||
surface_port: mach_msg_port_descriptor_t::new(mach_port, MACH_MSG_TYPE_MOVE_SEND),
|
|
||||||
surface_id,
|
|
||||||
};
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let result = unsafe {
|
|
||||||
mach_msg(
|
|
||||||
&mut message.header,
|
|
||||||
MACH_SEND_MSG | MACH_SEND_TIMEOUT,
|
|
||||||
message.header.msgh_size,
|
|
||||||
0,
|
|
||||||
MACH_PORT_NULL,
|
|
||||||
timeout_millis(SEND_TIMEOUT),
|
|
||||||
MACH_PORT_NULL,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if result != MACH_MSG_SUCCESS {
|
|
||||||
destroy_message(&mut message);
|
|
||||||
return Err(IOSurfaceMachError::Send { code: result });
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for IOSurfaceMachSender {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let task = unsafe { mach_task_self() };
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe {
|
|
||||||
let _ = mach_port_deallocate(task, self.send_port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn send_surface_port_if_needed(
|
|
||||||
sender: Option<&mut IOSurfaceMachSender>,
|
|
||||||
outcome: &mut Result<LiveOutcome, LiveSidecarError>,
|
|
||||||
) {
|
|
||||||
let (Some(sender), Ok(live_outcome)) = (sender, outcome.as_ref()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(handle) = live_outcome.response.surface_handle else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Err(error) = sender.send_surface_port(handle.surface_id, handle.mach_port_name) {
|
|
||||||
*outcome = Err(LiveSidecarError::IOSurfaceMach(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct IOSurfacePortMessage {
|
|
||||||
header: mach_msg_header_t,
|
|
||||||
body: mach_msg_body_t,
|
|
||||||
surface_port: mach_msg_port_descriptor_t,
|
|
||||||
surface_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn timeout_millis(timeout: Duration) -> u32 {
|
|
||||||
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn destroy_message(message: &mut IOSurfacePortMessage) {
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
unsafe {
|
|
||||||
mach2::message::mach_msg_destroy(&mut message.header);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
fs,
|
|
||||||
io::{self, BufRead},
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
|
|
||||||
use ely_domain::{ProfileId, TabId, UrlText};
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
use ely_servo_host::ServoHostError;
|
|
||||||
use ely_servo_host::{
|
|
||||||
IOSurfaceIdentity, NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize,
|
|
||||||
SoftwareServoHost,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::args::LiveArgs;
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
use super::iosurface_mach::{IOSurfaceMachSender, send_surface_port_if_needed};
|
|
||||||
use super::live_output::{populate_surface_fields, write_outcome};
|
|
||||||
pub(super) use super::live_protocol::LiveSidecarError;
|
|
||||||
use super::live_protocol::{LiveFrameReport, LiveOutcome, LiveRequest, PartialFrameTimings};
|
|
||||||
use super::live_session::{
|
|
||||||
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, ensure_session,
|
|
||||||
};
|
|
||||||
use super::perf::{FramePerfAggregator, FramePerfSummary, elapsed_ns};
|
|
||||||
|
|
||||||
pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> {
|
|
||||||
let LiveArgs { profile_data_dir, iosurface_mach_service, rendering_context_kind } = args;
|
|
||||||
let publish_readback_surface_fields = true;
|
|
||||||
let require_client_ready_surfaces = iosurface_mach_service.is_some();
|
|
||||||
fs::create_dir_all(&profile_data_dir)?;
|
|
||||||
let context_label = rendering_context_label(rendering_context_kind);
|
|
||||||
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
|
|
||||||
ServoSurfaceSize::new(1, 1),
|
|
||||||
Some(profile_data_dir),
|
|
||||||
rendering_context_kind,
|
|
||||||
)?;
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
let mut iosurface_mach_sender =
|
|
||||||
iosurface_mach_service.as_deref().map(IOSurfaceMachSender::connect).transpose()?;
|
|
||||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
let _ = iosurface_mach_service;
|
|
||||||
let mut sessions = HashMap::new();
|
|
||||||
let mut perf =
|
|
||||||
FramePerfAggregator::new(context_label, FramePerfAggregator::DEFAULT_WINDOW_SIZE);
|
|
||||||
let mut pending_summary: Option<FramePerfSummary> = None;
|
|
||||||
let mut published_surface_ids: HashMap<String, HashSet<IOSurfaceIdentity>> = HashMap::new();
|
|
||||||
let stdin = io::stdin();
|
|
||||||
let mut stdout = io::stdout().lock();
|
|
||||||
|
|
||||||
for line in stdin.lock().lines() {
|
|
||||||
let line = line?;
|
|
||||||
if line.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `frame_started_at` is the honest start of the end-to-end
|
|
||||||
// frame: a request just arrived and we're about to do
|
|
||||||
// everything required to put bytes back on the pipe. The
|
|
||||||
// matching stop is the `stdout.flush()` inside
|
|
||||||
// `write_outcome`.
|
|
||||||
let frame_started_at = Instant::now();
|
|
||||||
let outcome = match serde_json::from_str::<LiveRequest>(&line) {
|
|
||||||
Ok(request) => handle_request(
|
|
||||||
&mut host,
|
|
||||||
&mut sessions,
|
|
||||||
&mut published_surface_ids,
|
|
||||||
rendering_context_kind,
|
|
||||||
publish_readback_surface_fields,
|
|
||||||
require_client_ready_surfaces,
|
|
||||||
request,
|
|
||||||
),
|
|
||||||
Err(error) => Err(LiveSidecarError::Json(error)),
|
|
||||||
};
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
let outcome = {
|
|
||||||
let mut outcome = outcome;
|
|
||||||
send_surface_port_if_needed(iosurface_mach_sender.as_mut(), &mut outcome);
|
|
||||||
outcome
|
|
||||||
};
|
|
||||||
write_outcome(&mut stdout, &mut perf, &mut pending_summary, outcome, frame_started_at)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn rendering_context_label(kind: RenderingContextKind) -> &'static str {
|
|
||||||
match kind {
|
|
||||||
RenderingContextKind::Software => "software",
|
|
||||||
RenderingContextKind::Hardware => "hardware",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_request(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
sessions: &mut HashMap<String, LiveSession>,
|
|
||||||
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
|
|
||||||
rendering_context_kind: RenderingContextKind,
|
|
||||||
publish_readback_surface_fields: bool,
|
|
||||||
require_client_ready_surfaces: bool,
|
|
||||||
request: LiveRequest,
|
|
||||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
|
||||||
match request {
|
|
||||||
LiveRequest::Ensure {
|
|
||||||
tab_id,
|
|
||||||
profile_id,
|
|
||||||
url,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
page_zoom_percent,
|
|
||||||
device_pixel_ratio,
|
|
||||||
scroll_delta_x,
|
|
||||||
scroll_delta_y,
|
|
||||||
scroll_point_x,
|
|
||||||
scroll_point_y,
|
|
||||||
click_x,
|
|
||||||
click_y,
|
|
||||||
hover_x,
|
|
||||||
hover_y,
|
|
||||||
typed_text,
|
|
||||||
site_permissions,
|
|
||||||
ready_surface_ids,
|
|
||||||
} => {
|
|
||||||
let tab = TabId::parse(tab_id.clone())?;
|
|
||||||
let profile = ProfileId::parse(profile_id)?;
|
|
||||||
let url = UrlText::parse(url)?;
|
|
||||||
let session =
|
|
||||||
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
|
|
||||||
|
|
||||||
if apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)? {
|
|
||||||
session.awaiting_visible_frame = true;
|
|
||||||
}
|
|
||||||
apply_permissions(host, session, &profile, site_permissions)?;
|
|
||||||
if session.requested_url != url.as_str() {
|
|
||||||
host.navigate(NavigationRequest {
|
|
||||||
webview_id: session.webview_id.clone(),
|
|
||||||
tab_id: tab,
|
|
||||||
url: url.clone(),
|
|
||||||
})?;
|
|
||||||
session.requested_url = url.as_str().to_string();
|
|
||||||
session.scroll_x = 0;
|
|
||||||
session.scroll_y = 0;
|
|
||||||
session.awaiting_visible_frame = true;
|
|
||||||
// New URL: the previous tab's pixels are no longer
|
|
||||||
// valid evidence that "we have visible content"; let
|
|
||||||
// the gate skip blank loading frames again.
|
|
||||||
session.ever_visible_frame = false;
|
|
||||||
}
|
|
||||||
let input = LiveInput {
|
|
||||||
scroll_delta_x,
|
|
||||||
scroll_delta_y,
|
|
||||||
scroll_point_x,
|
|
||||||
scroll_point_y,
|
|
||||||
click_x,
|
|
||||||
click_y,
|
|
||||||
hover_x,
|
|
||||||
hover_y,
|
|
||||||
typed_text,
|
|
||||||
};
|
|
||||||
if apply_input(host, session, input)? {
|
|
||||||
// The app tick calls this sidecar synchronously from
|
|
||||||
// GPUI's update path. Mark that a fresh frame is
|
|
||||||
// desired, then let poll_frame take one event-loop
|
|
||||||
// step; a later 16 ms app tick will poll again if
|
|
||||||
// Servo has not painted yet.
|
|
||||||
session.awaiting_visible_frame = true;
|
|
||||||
}
|
|
||||||
let webview_id = session.webview_id.clone();
|
|
||||||
let mut outcome = poll_frame(
|
|
||||||
host,
|
|
||||||
session,
|
|
||||||
rendering_context_kind,
|
|
||||||
payloadless_readiness(
|
|
||||||
&tab_id,
|
|
||||||
published_surface_ids,
|
|
||||||
&ready_surface_ids,
|
|
||||||
require_client_ready_surfaces,
|
|
||||||
),
|
|
||||||
)?;
|
|
||||||
populate_surface_fields(
|
|
||||||
host,
|
|
||||||
&webview_id,
|
|
||||||
&tab_id,
|
|
||||||
published_surface_ids,
|
|
||||||
publish_readback_surface_fields,
|
|
||||||
&mut outcome,
|
|
||||||
);
|
|
||||||
Ok(outcome)
|
|
||||||
}
|
|
||||||
LiveRequest::Poll { tab_id, ready_surface_ids } => {
|
|
||||||
let Some(session) = sessions.get_mut(&tab_id) else {
|
|
||||||
return Ok(LiveOutcome::empty());
|
|
||||||
};
|
|
||||||
let webview_id = session.webview_id.clone();
|
|
||||||
let mut outcome = poll_frame(
|
|
||||||
host,
|
|
||||||
session,
|
|
||||||
rendering_context_kind,
|
|
||||||
payloadless_readiness(
|
|
||||||
&tab_id,
|
|
||||||
published_surface_ids,
|
|
||||||
&ready_surface_ids,
|
|
||||||
require_client_ready_surfaces,
|
|
||||||
),
|
|
||||||
)?;
|
|
||||||
populate_surface_fields(
|
|
||||||
host,
|
|
||||||
&webview_id,
|
|
||||||
&tab_id,
|
|
||||||
published_surface_ids,
|
|
||||||
publish_readback_surface_fields,
|
|
||||||
&mut outcome,
|
|
||||||
);
|
|
||||||
Ok(outcome)
|
|
||||||
}
|
|
||||||
LiveRequest::Close { tab_id } => {
|
|
||||||
if let Some(session) = sessions.remove(&tab_id) {
|
|
||||||
host.close_webview(&session.webview_id);
|
|
||||||
}
|
|
||||||
published_surface_ids.remove(&tab_id);
|
|
||||||
Ok(LiveOutcome::empty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_frame(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &mut LiveSession,
|
|
||||||
rendering_context_kind: RenderingContextKind,
|
|
||||||
readiness: PayloadlessReadiness<'_>,
|
|
||||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
|
||||||
host.tick();
|
|
||||||
let snapshot = host.snapshot(&session.webview_id)?;
|
|
||||||
let has_pending_frame = snapshot.has_pending_frame();
|
|
||||||
if !should_paint_live_frame(has_pending_frame, session.awaiting_visible_frame) {
|
|
||||||
return Ok(LiveOutcome::empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
let (outcome, has_visible_content) =
|
|
||||||
paint_pending_frame(host, session, rendering_context_kind, readiness, has_pending_frame)?;
|
|
||||||
if has_visible_content {
|
|
||||||
session.awaiting_visible_frame = false;
|
|
||||||
session.ever_visible_frame = true;
|
|
||||||
return Ok(outcome);
|
|
||||||
}
|
|
||||||
if !session.awaiting_visible_frame {
|
|
||||||
return Ok(outcome);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(LiveOutcome::empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_paint_live_frame(has_pending_frame: bool, awaiting_visible_frame: bool) -> bool {
|
|
||||||
has_pending_frame || awaiting_visible_frame
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_pending_frame(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &mut LiveSession,
|
|
||||||
rendering_context_kind: RenderingContextKind,
|
|
||||||
readiness: PayloadlessReadiness<'_>,
|
|
||||||
has_pending_frame: bool,
|
|
||||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
|
||||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
let _ = readiness;
|
|
||||||
|
|
||||||
match rendering_context_kind {
|
|
||||||
RenderingContextKind::Software => paint_readback_frame(host, session, !has_pending_frame),
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
RenderingContextKind::Hardware => {
|
|
||||||
paint_hardware_surface_frame(host, session, readiness, has_pending_frame)
|
|
||||||
}
|
|
||||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
RenderingContextKind::Hardware => paint_readback_frame(host, session, !has_pending_frame),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_readback_frame(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &LiveSession,
|
|
||||||
wait_for_completion: bool,
|
|
||||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
|
||||||
let paint_started_at = Instant::now();
|
|
||||||
host.paint_with_readback(&session.webview_id, wait_for_completion)?;
|
|
||||||
let snapshot = host.snapshot(&session.webview_id)?;
|
|
||||||
let frame = host.last_rendered_frame()?;
|
|
||||||
let paint_ns = elapsed_ns(paint_started_at);
|
|
||||||
let encode_started_at = Instant::now();
|
|
||||||
let has_visible_content = session.ever_visible_frame
|
|
||||||
|| (frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0);
|
|
||||||
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
|
|
||||||
let encode_ns = elapsed_ns(encode_started_at);
|
|
||||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
|
||||||
Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
fn paint_hardware_surface_frame(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &LiveSession,
|
|
||||||
readiness: PayloadlessReadiness<'_>,
|
|
||||||
has_pending_frame: bool,
|
|
||||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
|
||||||
if !session.ever_visible_frame {
|
|
||||||
return paint_initial_hardware_surface_frame(host, session, !has_pending_frame);
|
|
||||||
}
|
|
||||||
if !payloadless_surface_pool_ready(readiness, session.width, session.height) {
|
|
||||||
return paint_readback_frame(host, session, !has_pending_frame);
|
|
||||||
}
|
|
||||||
let (outcome, _) = paint_hardware_surface_report(host, session, !has_pending_frame)?;
|
|
||||||
Ok((outcome, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
fn paint_initial_hardware_surface_frame(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &LiveSession,
|
|
||||||
wait_for_completion: bool,
|
|
||||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
|
||||||
let paint_started_at = Instant::now();
|
|
||||||
host.paint_with_readback(&session.webview_id, wait_for_completion)?;
|
|
||||||
let snapshot = host.snapshot(&session.webview_id)?;
|
|
||||||
let frame = host.last_rendered_frame()?;
|
|
||||||
let paint_ns = elapsed_ns(paint_started_at);
|
|
||||||
let encode_started_at = Instant::now();
|
|
||||||
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
|
|
||||||
let has_visible_content = frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0;
|
|
||||||
let encode_ns = elapsed_ns(encode_started_at);
|
|
||||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
|
||||||
Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
fn paint_hardware_surface_report(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &LiveSession,
|
|
||||||
wait_for_completion: bool,
|
|
||||||
) -> Result<(LiveOutcome, IOSurfaceIdentity), LiveSidecarError> {
|
|
||||||
let paint_started_at = Instant::now();
|
|
||||||
host.paint_without_readback_with_completion(&session.webview_id, wait_for_completion)?;
|
|
||||||
let snapshot = host.snapshot(&session.webview_id)?;
|
|
||||||
let identity = host.peek_iosurface_identity(&session.webview_id)?.ok_or_else(|| {
|
|
||||||
ServoHostError::HardwareSurfaceUnavailable { id: session.webview_id.clone() }
|
|
||||||
})?;
|
|
||||||
let paint_ns = elapsed_ns(paint_started_at);
|
|
||||||
let encode_started_at = Instant::now();
|
|
||||||
let report = LiveFrameReport::from_surface(
|
|
||||||
&snapshot,
|
|
||||||
identity.width,
|
|
||||||
identity.height,
|
|
||||||
session.device_pixel_ratio(),
|
|
||||||
);
|
|
||||||
let encode_ns = elapsed_ns(encode_started_at);
|
|
||||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
|
||||||
Ok((LiveOutcome::from_report(report, timings), identity))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
fn payloadless_surface_pool_ready(
|
|
||||||
readiness: PayloadlessReadiness<'_>,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
) -> bool {
|
|
||||||
let Some(published) = readiness.published_surface_ids.get(readiness.tab_id) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let matching = published
|
|
||||||
.iter()
|
|
||||||
.filter(|identity| identity.width == width && identity.height == height)
|
|
||||||
.copied()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if matching.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
!readiness.require_client_ready_surfaces
|
|
||||||
|| matching
|
|
||||||
.iter()
|
|
||||||
.any(|identity| readiness.ready_surface_ids.contains(&identity.surface_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct PayloadlessReadiness<'a> {
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
tab_id: &'a str,
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
ready_surface_ids: &'a [u64],
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
require_client_ready_surfaces: bool,
|
|
||||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
_marker: std::marker::PhantomData<&'a ()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn payloadless_readiness<'a>(
|
|
||||||
tab_id: &'a str,
|
|
||||||
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
|
|
||||||
ready_surface_ids: &'a [u64],
|
|
||||||
require_client_ready_surfaces: bool,
|
|
||||||
) -> PayloadlessReadiness<'a> {
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
{
|
|
||||||
PayloadlessReadiness {
|
|
||||||
tab_id,
|
|
||||||
published_surface_ids,
|
|
||||||
ready_surface_ids,
|
|
||||||
require_client_ready_surfaces,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
{
|
|
||||||
let _ = (tab_id, published_surface_ids, ready_surface_ids, require_client_ready_surfaces);
|
|
||||||
PayloadlessReadiness { _marker: std::marker::PhantomData }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn awaiting_visible_frame_forces_paint_without_pending_flag() {
|
|
||||||
assert!(should_paint_live_frame(false, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn idle_poll_waits_for_pending_frame() {
|
|
||||||
assert!(!should_paint_live_frame(false, false));
|
|
||||||
assert!(should_paint_live_frame(true, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
#[test]
|
|
||||||
fn payloadless_pool_accepts_one_client_ready_surface() {
|
|
||||||
let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]);
|
|
||||||
|
|
||||||
assert!(!payloadless_surface_pool_ready(readiness(&published, &[], true), 800, 600));
|
|
||||||
assert!(payloadless_surface_pool_ready(readiness(&published, &[7], true), 800, 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
#[test]
|
|
||||||
fn payloadless_pool_uses_published_surfaces_for_no_mach_clients() {
|
|
||||||
let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]);
|
|
||||||
|
|
||||||
assert!(payloadless_surface_pool_ready(readiness(&published, &[], false), 800, 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
fn readiness<'a>(
|
|
||||||
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
|
|
||||||
ready_surface_ids: &'a [u64],
|
|
||||||
require_client_ready_surfaces: bool,
|
|
||||||
) -> PayloadlessReadiness<'a> {
|
|
||||||
PayloadlessReadiness {
|
|
||||||
tab_id: "tab",
|
|
||||||
published_surface_ids,
|
|
||||||
ready_surface_ids,
|
|
||||||
require_client_ready_surfaces,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
fn published_identities(
|
|
||||||
identities: [IOSurfaceIdentity; 2],
|
|
||||||
) -> HashMap<String, HashSet<IOSurfaceIdentity>> {
|
|
||||||
let mut published = HashMap::new();
|
|
||||||
published.insert("tab".to_string(), identities.into_iter().collect());
|
|
||||||
published
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity {
|
|
||||||
IOSurfaceIdentity { surface_id, width, height }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
io::Write,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
use ely_servo_host::IOSurfaceHandle;
|
|
||||||
use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost};
|
|
||||||
|
|
||||||
use super::live_protocol::{LiveOutcome, LiveSidecarError, PartialFrameTimings};
|
|
||||||
use super::perf::{FramePerfAggregator, FramePerfSummary, FrameStageTimings, elapsed_ns};
|
|
||||||
|
|
||||||
/// Populate the hardware surface protocol fields on `outcome`. Readback
|
|
||||||
/// warm-up frames publish IOSurface handles so the app can import them
|
|
||||||
/// on its dedicated importer thread before steady-state payloadless
|
|
||||||
/// frames select the rotating surface ids. Two pieces of state ride out
|
|
||||||
/// together:
|
|
||||||
///
|
|
||||||
/// * `current_surface_id` — set on every payload-bearing hardware
|
|
||||||
/// frame so the receiver knows which previously-imported
|
|
||||||
/// `MTLTexture` to sample THIS frame. surfman's attached swap
|
|
||||||
/// chain rotates front/back surfaces, so this alternates between
|
|
||||||
/// a small set of ids.
|
|
||||||
/// * `surface_handle` — populated only the first time the sidecar
|
|
||||||
/// sees a given `surface_id`; the receiver imports the IOSurface
|
|
||||||
/// once and caches the resulting Metal texture. Minting a fresh
|
|
||||||
/// mach port per frame would leak ports — `IOSurfaceCreateMachPort`
|
|
||||||
/// hands out a new send right each call and they don't free
|
|
||||||
/// automatically until the receiver `mach_port_deallocate`s.
|
|
||||||
pub(super) fn populate_surface_fields(
|
|
||||||
host: &SoftwareServoHost,
|
|
||||||
webview_id: &ely_domain::WebViewId,
|
|
||||||
tab_id: &str,
|
|
||||||
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
|
|
||||||
publish_readback_surface_fields: bool,
|
|
||||||
outcome: &mut LiveOutcome,
|
|
||||||
) {
|
|
||||||
if outcome.response.frame.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if outcome.frame.is_some() && !publish_readback_surface_fields {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
{
|
|
||||||
let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Err(message) = require_report_matches_surface_identity(outcome, identity) {
|
|
||||||
*outcome = LiveOutcome::error(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let handle = if surface_has_been_published(published_surface_ids, tab_id, identity) {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
host.current_iosurface_handle(webview_id).ok().flatten()
|
|
||||||
};
|
|
||||||
let publication = surface_publication_for(published_surface_ids, tab_id, identity, handle);
|
|
||||||
outcome.response.current_surface_id = publication.current_surface_id;
|
|
||||||
outcome.response.surface_handle = publication.surface_handle;
|
|
||||||
}
|
|
||||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
{
|
|
||||||
let _ = (host, webview_id, tab_id, published_surface_ids, publish_readback_surface_fields);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
fn surface_has_been_published(
|
|
||||||
published_surface_ids: &HashMap<String, HashSet<IOSurfaceIdentity>>,
|
|
||||||
tab_id: &str,
|
|
||||||
identity: IOSurfaceIdentity,
|
|
||||||
) -> bool {
|
|
||||||
published_surface_ids.get(tab_id).is_some_and(|published| published.contains(&identity))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct SurfacePublication {
|
|
||||||
current_surface_id: Option<u64>,
|
|
||||||
surface_handle: Option<IOSurfaceHandle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
fn surface_publication_for(
|
|
||||||
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
|
|
||||||
tab_id: &str,
|
|
||||||
identity: IOSurfaceIdentity,
|
|
||||||
handle: Option<IOSurfaceHandle>,
|
|
||||||
) -> SurfacePublication {
|
|
||||||
if surface_has_been_published(published_surface_ids, tab_id, identity) {
|
|
||||||
return SurfacePublication {
|
|
||||||
current_surface_id: Some(identity.surface_id),
|
|
||||||
surface_handle: None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(handle) = handle.filter(|handle| handle_matches_identity(*handle, identity)) else {
|
|
||||||
return SurfacePublication { current_surface_id: None, surface_handle: None };
|
|
||||||
};
|
|
||||||
|
|
||||||
published_surface_ids
|
|
||||||
.entry(tab_id.to_string())
|
|
||||||
.or_default()
|
|
||||||
.insert(IOSurfaceIdentity::from_handle(handle));
|
|
||||||
SurfacePublication { current_surface_id: Some(handle.surface_id), surface_handle: Some(handle) }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
fn handle_matches_identity(handle: IOSurfaceHandle, identity: IOSurfaceIdentity) -> bool {
|
|
||||||
handle.surface_id == identity.surface_id
|
|
||||||
&& handle.width == identity.width
|
|
||||||
&& handle.height == identity.height
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
fn require_report_matches_surface_identity(
|
|
||||||
outcome: &LiveOutcome,
|
|
||||||
identity: IOSurfaceIdentity,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let Some(frame) = outcome.response.frame.as_ref() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if frame.width == identity.width && frame.height == identity.height {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(format!(
|
|
||||||
"servo hardware surface size {}x{} did not match frame report {}x{}",
|
|
||||||
identity.width, identity.height, frame.width, frame.height,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialise the response then stream the optional raw RGBA frame on
|
|
||||||
/// the same stdout pipe. The client reads the JSON line, takes
|
|
||||||
/// `rgba_byte_count` from the report, then reads that many bytes
|
|
||||||
/// from the same stream — no temp file round-trip.
|
|
||||||
///
|
|
||||||
/// After the bytes hit the pipe we fold paint/encode/write/total
|
|
||||||
/// timings into the aggregator. `total_ns` is the wall-clock span
|
|
||||||
/// from `frame_started_at` (request arrival) to the stdout flush
|
|
||||||
/// returning, so it captures every per-frame cost outside the three
|
|
||||||
/// measured stages. Any summary the aggregator emits is stashed on
|
|
||||||
/// `pending_summary` and rides out on the *next* response, because
|
|
||||||
/// the protocol is one-line-per-response and an unsolicited summary
|
|
||||||
/// line would desync the main process's read loop.
|
|
||||||
pub(super) fn write_outcome(
|
|
||||||
stdout: &mut impl Write,
|
|
||||||
perf: &mut FramePerfAggregator,
|
|
||||||
pending_summary: &mut Option<FramePerfSummary>,
|
|
||||||
outcome: Result<LiveOutcome, LiveSidecarError>,
|
|
||||||
frame_started_at: Instant,
|
|
||||||
) -> Result<(), LiveSidecarError> {
|
|
||||||
let mut outcome = outcome.unwrap_or_else(|error| LiveOutcome::error(error.to_string()));
|
|
||||||
let partial_timings = outcome.partial_timings.take();
|
|
||||||
let frame_present = outcome.response.frame.is_some();
|
|
||||||
if let Some(summary) = pending_summary.take() {
|
|
||||||
outcome.response.perf = Some(summary);
|
|
||||||
}
|
|
||||||
// Payloadless hardware frames carry only the IOSurface selector.
|
|
||||||
let drop_rgba_payload =
|
|
||||||
outcome.response.current_surface_id.is_some() && outcome.frame.is_none();
|
|
||||||
if drop_rgba_payload && let Some(report) = outcome.response.frame.as_mut() {
|
|
||||||
report.rgba_byte_count = 0;
|
|
||||||
}
|
|
||||||
let write_started_at = Instant::now();
|
|
||||||
serde_json::to_writer(&mut *stdout, &outcome.response)?;
|
|
||||||
stdout.write_all(b"\n")?;
|
|
||||||
if !drop_rgba_payload && let Some(frame) = outcome.frame.as_ref() {
|
|
||||||
stdout.write_all(frame.rgba_bytes())?;
|
|
||||||
}
|
|
||||||
stdout.flush()?;
|
|
||||||
if frame_present {
|
|
||||||
let write_ns = elapsed_ns(write_started_at);
|
|
||||||
let total_ns = elapsed_ns(frame_started_at);
|
|
||||||
let partial = partial_timings.unwrap_or(PartialFrameTimings { paint_ns: 0, encode_ns: 0 });
|
|
||||||
let timings = FrameStageTimings::from_durations(
|
|
||||||
Duration::from_nanos(partial.paint_ns),
|
|
||||||
Duration::from_nanos(partial.encode_ns),
|
|
||||||
Duration::from_nanos(write_ns),
|
|
||||||
Duration::from_nanos(total_ns),
|
|
||||||
);
|
|
||||||
if let Some(summary) = perf.record(timings) {
|
|
||||||
*pending_summary = Some(summary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::{collections::HashMap, error::Error, time::Instant};
|
|
||||||
|
|
||||||
use ely_servo_host::{IOSurfaceHandle, IOSurfaceIdentity};
|
|
||||||
|
|
||||||
use super::super::{
|
|
||||||
live_protocol::{LiveFrameReport, LiveOutcome, PartialFrameTimings},
|
|
||||||
perf::FramePerfAggregator,
|
|
||||||
};
|
|
||||||
use super::{require_report_matches_surface_identity, surface_publication_for, write_outcome};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unpublished_surface_without_handle_leaves_selector_empty() {
|
|
||||||
let mut published = HashMap::new();
|
|
||||||
let publication =
|
|
||||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None);
|
|
||||||
|
|
||||||
assert_eq!(publication.current_surface_id, None);
|
|
||||||
assert!(publication.surface_handle.is_none());
|
|
||||||
assert!(published.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unpublished_surface_with_matching_handle_publishes_selector_and_handle() {
|
|
||||||
let mut published = HashMap::new();
|
|
||||||
let handle = handle(7, 800, 600);
|
|
||||||
let publication =
|
|
||||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle));
|
|
||||||
|
|
||||||
assert_eq!(publication.current_surface_id, Some(7));
|
|
||||||
assert_eq!(publication.surface_handle, Some(handle));
|
|
||||||
assert!(published.get("tab-1").is_some_and(|ids| ids.contains(&identity(7, 800, 600))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn published_surface_reuses_selector_without_republishing_handle() {
|
|
||||||
let mut published = HashMap::new();
|
|
||||||
let handle = handle(7, 800, 600);
|
|
||||||
let _ =
|
|
||||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle));
|
|
||||||
let publication =
|
|
||||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None);
|
|
||||||
|
|
||||||
assert_eq!(publication.current_surface_id, Some(7));
|
|
||||||
assert!(publication.surface_handle.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn same_surface_id_with_changed_dimensions_republishes_handle() {
|
|
||||||
let mut published = HashMap::new();
|
|
||||||
let initial = handle(7, 800, 600);
|
|
||||||
let resized = handle(7, 1024, 768);
|
|
||||||
|
|
||||||
let _ =
|
|
||||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(initial));
|
|
||||||
let publication =
|
|
||||||
surface_publication_for(&mut published, "tab-1", identity(7, 1024, 768), Some(resized));
|
|
||||||
|
|
||||||
assert_eq!(publication.current_surface_id, Some(7));
|
|
||||||
assert_eq!(publication.surface_handle, Some(resized));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hardware_report_mismatch_is_reported() -> Result<(), Box<dyn Error>> {
|
|
||||||
let outcome = LiveOutcome::from_report(
|
|
||||||
report_with_size(2180, 1586),
|
|
||||||
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
let error = match require_report_matches_surface_identity(&outcome, identity(7, 2168, 1566))
|
|
||||||
{
|
|
||||||
Ok(()) => return Err("mismatched IOSurface dimensions must be reported".into()),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
error,
|
|
||||||
"servo hardware surface size 2168x1566 did not match frame report 2180x1586",
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mismatched_handle_leaves_surface_unpublished() {
|
|
||||||
let mut published = HashMap::new();
|
|
||||||
let publication = surface_publication_for(
|
|
||||||
&mut published,
|
|
||||||
"tab-1",
|
|
||||||
identity(7, 800, 600),
|
|
||||||
Some(handle(8, 800, 600)),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(publication.current_surface_id, None);
|
|
||||||
assert!(publication.surface_handle.is_none());
|
|
||||||
assert!(published.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn payloadless_surface_report_records_perf_and_writes_no_rgba() -> Result<(), Box<dyn Error>> {
|
|
||||||
let mut outcome = LiveOutcome::from_report(
|
|
||||||
report_with_byte_count(16),
|
|
||||||
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
|
|
||||||
);
|
|
||||||
outcome.response.current_surface_id = Some(7);
|
|
||||||
let mut stdout = Vec::new();
|
|
||||||
let mut perf = FramePerfAggregator::new("hardware", 1);
|
|
||||||
let mut pending_summary = None;
|
|
||||||
|
|
||||||
write_outcome(&mut stdout, &mut perf, &mut pending_summary, Ok(outcome), Instant::now())?;
|
|
||||||
|
|
||||||
let Some(newline_index) = stdout.iter().position(|byte| *byte == b'\n') else {
|
|
||||||
return Err("response newline missing".into());
|
|
||||||
};
|
|
||||||
let line = std::str::from_utf8(&stdout[..newline_index])?;
|
|
||||||
let response: serde_json::Value = serde_json::from_str(line)?;
|
|
||||||
let rgba_byte_count = response
|
|
||||||
.get("frame")
|
|
||||||
.and_then(|frame| frame.get("rgba_byte_count"))
|
|
||||||
.and_then(serde_json::Value::as_u64);
|
|
||||||
|
|
||||||
assert_eq!(rgba_byte_count, Some(0));
|
|
||||||
assert!(stdout[newline_index + 1..].is_empty());
|
|
||||||
assert!(pending_summary.is_some());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity {
|
|
||||||
IOSurfaceIdentity { surface_id, width, height }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle(surface_id: u64, width: u32, height: u32) -> IOSurfaceHandle {
|
|
||||||
IOSurfaceHandle { mach_port_name: 42, surface_id, width, height }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn report_with_byte_count(rgba_byte_count: usize) -> LiveFrameReport {
|
|
||||||
let mut report = report_with_size(2, 2);
|
|
||||||
report.rgba_byte_count = rgba_byte_count;
|
|
||||||
report
|
|
||||||
}
|
|
||||||
|
|
||||||
fn report_with_size(width: u32, height: u32) -> LiveFrameReport {
|
|
||||||
LiveFrameReport {
|
|
||||||
loaded_url: Some("https://example.com/".to_string()),
|
|
||||||
title: Some("Example".to_string()),
|
|
||||||
state: "complete",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
device_pixel_ratio: 1.0,
|
|
||||||
css_viewport_width: width,
|
|
||||||
css_viewport_height: height,
|
|
||||||
rgba_byte_count: 0,
|
|
||||||
non_white_pixel_count: 0,
|
|
||||||
content_pixel_count: 0,
|
|
||||||
sample_hash: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
//! Wire types for the sidecar live loop. Split out of `live.rs` to
|
|
||||||
//! keep the hot loop and protocol surface in separate files.
|
|
||||||
|
|
||||||
use std::io;
|
|
||||||
|
|
||||||
use ely_servo_host::{
|
|
||||||
IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
use super::iosurface_mach::IOSurfaceMachError;
|
|
||||||
use super::perf::FramePerfSummary;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub(super) enum LiveRequest {
|
|
||||||
Ensure {
|
|
||||||
tab_id: String,
|
|
||||||
profile_id: String,
|
|
||||||
url: String,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
page_zoom_percent: u16,
|
|
||||||
/// Display scale factor reported by the host's window
|
|
||||||
/// (1.0 standard, 2.0 Retina). The sidecar plumbs this into
|
|
||||||
/// Servo's `WebView::set_hidpi_scale_factor` so CSS layout
|
|
||||||
/// happens at logical-pixel dimensions instead of physical.
|
|
||||||
/// Defaults to 1.0 for backward compatibility if a client
|
|
||||||
/// (e.g. the live perf bench) omits the field.
|
|
||||||
#[serde(default = "default_device_pixel_ratio")]
|
|
||||||
device_pixel_ratio: f32,
|
|
||||||
scroll_delta_x: i32,
|
|
||||||
scroll_delta_y: i32,
|
|
||||||
scroll_point_x: Option<u32>,
|
|
||||||
scroll_point_y: Option<u32>,
|
|
||||||
click_x: Option<u32>,
|
|
||||||
click_y: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
hover_x: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
hover_y: Option<u32>,
|
|
||||||
typed_text: Option<String>,
|
|
||||||
site_permissions: Vec<LiveSitePermission>,
|
|
||||||
#[serde(default)]
|
|
||||||
ready_surface_ids: Vec<u64>,
|
|
||||||
},
|
|
||||||
Poll {
|
|
||||||
tab_id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
ready_surface_ids: Vec<u64>,
|
|
||||||
},
|
|
||||||
Close {
|
|
||||||
tab_id: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_device_pixel_ratio() -> f32 {
|
|
||||||
1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(super) struct LiveSitePermission {
|
|
||||||
pub origin: String,
|
|
||||||
pub feature: String,
|
|
||||||
pub decision: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Partial stage timings captured inside `poll_frame` before the
|
|
||||||
/// write phase. Combined with the write-stage duration measured by
|
|
||||||
/// `write_outcome` to form a full set of frame timings.
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub(super) struct PartialFrameTimings {
|
|
||||||
pub paint_ns: u64,
|
|
||||||
pub encode_ns: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A response plus an optional software RGBA payload and partial stage
|
|
||||||
/// timings. Software frames carry `RenderedFrame` so the write step
|
|
||||||
/// can stream its existing rgba slice straight onto the pipe; hardware
|
|
||||||
/// surface frames carry only a `LiveFrameReport`.
|
|
||||||
pub(super) struct LiveOutcome {
|
|
||||||
pub response: LiveResponse,
|
|
||||||
pub frame: Option<RenderedFrame>,
|
|
||||||
pub partial_timings: Option<PartialFrameTimings>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LiveOutcome {
|
|
||||||
pub fn empty() -> Self {
|
|
||||||
Self { response: LiveResponse::empty(), frame: None, partial_timings: None }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error(message: String) -> Self {
|
|
||||||
Self { response: LiveResponse::error(message), frame: None, partial_timings: None }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_frame(
|
|
||||||
report: LiveFrameReport,
|
|
||||||
frame: RenderedFrame,
|
|
||||||
partial_timings: PartialFrameTimings,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
response: LiveResponse::frame(report),
|
|
||||||
frame: Some(frame),
|
|
||||||
partial_timings: Some(partial_timings),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
|
||||||
pub fn from_report(report: LiveFrameReport, partial_timings: PartialFrameTimings) -> Self {
|
|
||||||
Self {
|
|
||||||
response: LiveResponse::frame(report),
|
|
||||||
frame: None,
|
|
||||||
partial_timings: Some(partial_timings),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub(super) struct LiveResponse {
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub frame: Option<LiveFrameReport>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub perf: Option<FramePerfSummary>,
|
|
||||||
/// Populated on the first frame the sidecar emits for a given
|
|
||||||
/// surface — initial paint, after a resize, or whenever surfman
|
|
||||||
/// rotates its swap chain to a surface we haven't seen yet. The
|
|
||||||
/// receiver imports the IOSurface (via
|
|
||||||
/// `IOSurfaceLookupFromMachPort`) once per `surface_id` and caches
|
|
||||||
/// the resulting Metal texture. Always `None` on the software
|
|
||||||
/// path.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub surface_handle: Option<IOSurfaceHandle>,
|
|
||||||
/// Populated on every hardware paint frame. Tells the receiver
|
|
||||||
/// which previously-imported IOSurface to sample THIS frame. The
|
|
||||||
/// surfman attached swap chain rotates between front/back
|
|
||||||
/// surfaces, so this id alternates between the values the receiver
|
|
||||||
/// has already imported. Always `None` on the software path.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub current_surface_id: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LiveResponse {
|
|
||||||
fn empty() -> Self {
|
|
||||||
Self {
|
|
||||||
error: None,
|
|
||||||
frame: None,
|
|
||||||
perf: None,
|
|
||||||
surface_handle: None,
|
|
||||||
current_surface_id: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn frame(frame: LiveFrameReport) -> Self {
|
|
||||||
Self {
|
|
||||||
error: None,
|
|
||||||
frame: Some(frame),
|
|
||||||
perf: None,
|
|
||||||
surface_handle: None,
|
|
||||||
current_surface_id: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn error(message: String) -> Self {
|
|
||||||
Self {
|
|
||||||
error: Some(message),
|
|
||||||
frame: None,
|
|
||||||
perf: None,
|
|
||||||
surface_handle: None,
|
|
||||||
current_surface_id: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub(super) struct LiveFrameReport {
|
|
||||||
pub loaded_url: Option<String>,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub state: &'static str,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
pub device_pixel_ratio: f32,
|
|
||||||
pub css_viewport_width: u32,
|
|
||||||
pub css_viewport_height: u32,
|
|
||||||
pub rgba_byte_count: usize,
|
|
||||||
pub non_white_pixel_count: u64,
|
|
||||||
pub content_pixel_count: u64,
|
|
||||||
pub sample_hash: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LiveFrameReport {
|
|
||||||
pub fn new(snapshot: &WebViewSnapshot, frame: &RenderedFrame, device_pixel_ratio: f32) -> Self {
|
|
||||||
let (css_viewport_width, css_viewport_height) =
|
|
||||||
css_viewport_size(frame.width(), frame.height(), device_pixel_ratio);
|
|
||||||
Self {
|
|
||||||
loaded_url: snapshot.url().map(str::to_string),
|
|
||||||
title: snapshot.title().map(str::to_string),
|
|
||||||
state: state_label(snapshot.state()),
|
|
||||||
width: frame.width(),
|
|
||||||
height: frame.height(),
|
|
||||||
device_pixel_ratio,
|
|
||||||
css_viewport_width,
|
|
||||||
css_viewport_height,
|
|
||||||
rgba_byte_count: frame.rgba_bytes().len(),
|
|
||||||
non_white_pixel_count: frame.non_white_pixel_count(),
|
|
||||||
content_pixel_count: frame.content_pixel_count(),
|
|
||||||
sample_hash: frame.sample_hash(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
pub fn from_surface(
|
|
||||||
snapshot: &WebViewSnapshot,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
device_pixel_ratio: f32,
|
|
||||||
) -> Self {
|
|
||||||
let (css_viewport_width, css_viewport_height) =
|
|
||||||
css_viewport_size(width, height, device_pixel_ratio);
|
|
||||||
Self {
|
|
||||||
loaded_url: snapshot.url().map(str::to_string),
|
|
||||||
title: snapshot.title().map(str::to_string),
|
|
||||||
state: state_label(snapshot.state()),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
device_pixel_ratio,
|
|
||||||
css_viewport_width,
|
|
||||||
css_viewport_height,
|
|
||||||
rgba_byte_count: 0,
|
|
||||||
non_white_pixel_count: 0,
|
|
||||||
content_pixel_count: 0,
|
|
||||||
sample_hash: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
|
|
||||||
let dpr = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
|
|
||||||
device_pixel_ratio
|
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
};
|
|
||||||
(
|
|
||||||
((width as f32) / dpr).round().max(1.0) as u32,
|
|
||||||
((height as f32) / dpr).round().max(1.0) as u32,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_label(state: &WebViewState) -> &'static str {
|
|
||||||
match state {
|
|
||||||
WebViewState::Created => "created",
|
|
||||||
WebViewState::Loading => "loading",
|
|
||||||
WebViewState::Complete => "complete",
|
|
||||||
WebViewState::Sleeping => "sleeping",
|
|
||||||
WebViewState::Crashed => "crashed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub(super) enum LiveSidecarError {
|
|
||||||
#[error("live session is unavailable after creation")]
|
|
||||||
SessionUnavailable,
|
|
||||||
|
|
||||||
#[error("scroll input requires both scroll_point_x and scroll_point_y")]
|
|
||||||
IncompleteScrollPoint,
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Domain(#[from] ely_domain::DomainError),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Host(#[from] ServoHostError),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Io(#[from] io::Error),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Json(#[from] serde_json::Error),
|
|
||||||
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
#[error(transparent)]
|
|
||||||
IOSurfaceMach(#[from] IOSurfaceMachError),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_request_deserializes_from_wire() -> Result<(), serde_json::Error> {
|
|
||||||
let request =
|
|
||||||
serde_json::from_str::<LiveRequest>(r#"{"type":"close","tab_id":"tab-live-close"}"#)?;
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
request,
|
|
||||||
LiveRequest::Close { tab_id } if tab_id == "tab-live-close"
|
|
||||||
));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use ely_domain::{DEFAULT_ZOOM_PERCENT, ProfileId, TabId};
|
|
||||||
use ely_servo_host::{
|
|
||||||
KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest, PermissionDecision,
|
|
||||||
PermissionRequest, ResizeRequest, ScrollRequest, ServoHost, ServoSurfaceSize,
|
|
||||||
SoftwareServoHost,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::live_protocol::{LiveSidecarError, LiveSitePermission};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(super) struct LiveSession {
|
|
||||||
pub(super) webview_id: ely_domain::WebViewId,
|
|
||||||
pub(super) requested_url: String,
|
|
||||||
pub(super) width: u32,
|
|
||||||
pub(super) height: u32,
|
|
||||||
page_zoom_percent: u16,
|
|
||||||
/// Last hidpi factor pushed to Servo, encoded as `(scale × 1000)`.
|
|
||||||
/// Stored as a u32 so equality is cheap and stable across the
|
|
||||||
/// f32 jitter that JSON parsing can introduce. Init to 0 so the
|
|
||||||
/// first apply_layout call always pushes a real value.
|
|
||||||
hidpi_scale_milli: u32,
|
|
||||||
pub(super) scroll_x: i32,
|
|
||||||
pub(super) scroll_y: i32,
|
|
||||||
pub(super) awaiting_visible_frame: bool,
|
|
||||||
/// Sticky for the lifetime of a single URL: flipped to `true`
|
|
||||||
/// the first time `poll_frame` sees a paint with real content
|
|
||||||
/// (non-white, non-empty) and reset to `false` on every navigate.
|
|
||||||
/// After it's `true`, the visible-content gate stops gating:
|
|
||||||
/// scroll/click/hover/type all return on the first
|
|
||||||
/// `has_pending_frame=true` (~3 ms) instead of waiting the full
|
|
||||||
/// `LIVE_FRAME_WAIT_TIMEOUT`. The gate stays armed for the
|
|
||||||
/// initial paint of each new URL so loading frames are still
|
|
||||||
/// skipped.
|
|
||||||
pub(super) ever_visible_frame: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LiveSession {
|
|
||||||
fn new(webview_id: ely_domain::WebViewId, _width: u32, _height: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
webview_id,
|
|
||||||
requested_url: String::new(),
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
page_zoom_percent: DEFAULT_ZOOM_PERCENT,
|
|
||||||
hidpi_scale_milli: 0,
|
|
||||||
scroll_x: 0,
|
|
||||||
scroll_y: 0,
|
|
||||||
awaiting_visible_frame: false,
|
|
||||||
ever_visible_frame: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn device_pixel_ratio(&self) -> f32 {
|
|
||||||
hidpi_scale_milli_to_f32(self.hidpi_scale_milli)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn ensure_session<'a>(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
sessions: &'a mut HashMap<String, LiveSession>,
|
|
||||||
key: String,
|
|
||||||
tab_id: &TabId,
|
|
||||||
profile_id: &ProfileId,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
) -> Result<&'a mut LiveSession, LiveSidecarError> {
|
|
||||||
if !sessions.contains_key(&key) {
|
|
||||||
let webview_id = host.create_webview_with_size(
|
|
||||||
tab_id.clone(),
|
|
||||||
profile_id.clone(),
|
|
||||||
ServoSurfaceSize::new(width, height),
|
|
||||||
)?;
|
|
||||||
sessions.insert(key.clone(), LiveSession::new(webview_id, width, height));
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions.get_mut(&key).ok_or(LiveSidecarError::SessionUnavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn apply_layout(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &mut LiveSession,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
page_zoom_percent: u16,
|
|
||||||
device_pixel_ratio: f32,
|
|
||||||
) -> Result<bool, LiveSidecarError> {
|
|
||||||
let mut changed = false;
|
|
||||||
// Push the device pixel ratio BEFORE resize. Servo's WebView
|
|
||||||
// defaults hidpi to 1.0; without this the first layout treats
|
|
||||||
// physical-pixel viewport widths as CSS-pixel widths and the page
|
|
||||||
// lays out half the size you'd expect on a Retina display.
|
|
||||||
let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio);
|
|
||||||
if session.hidpi_scale_milli != hidpi_scale_milli {
|
|
||||||
host.set_hidpi_scale(ely_servo_host::HidpiScaleRequest {
|
|
||||||
webview_id: session.webview_id.clone(),
|
|
||||||
scale_factor: hidpi_scale_milli_to_f32(hidpi_scale_milli),
|
|
||||||
})?;
|
|
||||||
session.hidpi_scale_milli = hidpi_scale_milli;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.width != width || session.height != height {
|
|
||||||
host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?;
|
|
||||||
session.width = width;
|
|
||||||
session.height = height;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.page_zoom_percent != page_zoom_percent {
|
|
||||||
host.set_page_zoom(PageZoomRequest {
|
|
||||||
webview_id: session.webview_id.clone(),
|
|
||||||
zoom_factor: f32::from(page_zoom_percent) / 100.0,
|
|
||||||
})?;
|
|
||||||
session.page_zoom_percent = page_zoom_percent;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn apply_permissions(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &LiveSession,
|
|
||||||
profile_id: &ProfileId,
|
|
||||||
permissions: Vec<LiveSitePermission>,
|
|
||||||
) -> Result<(), LiveSidecarError> {
|
|
||||||
for permission in permissions {
|
|
||||||
host.set_permission(
|
|
||||||
PermissionRequest {
|
|
||||||
webview_id: session.webview_id.clone(),
|
|
||||||
profile_id: profile_id.clone(),
|
|
||||||
origin: ely_domain::SiteOrigin::parse(permission.origin)?,
|
|
||||||
feature: ely_domain::SitePermissionFeature::parse(permission.feature.as_str())?,
|
|
||||||
},
|
|
||||||
PermissionDecision::from(ely_domain::SitePermissionDecision::parse(
|
|
||||||
permission.decision.as_str(),
|
|
||||||
)?),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn apply_input(
|
|
||||||
host: &mut SoftwareServoHost,
|
|
||||||
session: &mut LiveSession,
|
|
||||||
input: LiveInput,
|
|
||||||
) -> Result<bool, LiveSidecarError> {
|
|
||||||
let mut changed = false;
|
|
||||||
if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 {
|
|
||||||
let (point_x, point_y) = input.scroll_point()?;
|
|
||||||
host.scroll(ScrollRequest {
|
|
||||||
webview_id: session.webview_id.clone(),
|
|
||||||
delta_x: input.scroll_delta_x,
|
|
||||||
delta_y: input.scroll_delta_y,
|
|
||||||
point_x,
|
|
||||||
point_y,
|
|
||||||
})?;
|
|
||||||
session.scroll_x = positive_scroll_component(session.scroll_x, input.scroll_delta_x);
|
|
||||||
session.scroll_y = positive_scroll_component(session.scroll_y, input.scroll_delta_y);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let (Some(x), Some(y)) = (input.hover_x, input.hover_y) {
|
|
||||||
host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let (Some(x), Some(y)) = (input.click_x, input.click_y) {
|
|
||||||
host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(text) = input.typed_text {
|
|
||||||
host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct LiveInput {
|
|
||||||
pub(super) scroll_delta_x: i32,
|
|
||||||
pub(super) scroll_delta_y: i32,
|
|
||||||
pub(super) scroll_point_x: Option<u32>,
|
|
||||||
pub(super) scroll_point_y: Option<u32>,
|
|
||||||
pub(super) click_x: Option<u32>,
|
|
||||||
pub(super) click_y: Option<u32>,
|
|
||||||
pub(super) hover_x: Option<u32>,
|
|
||||||
pub(super) hover_y: Option<u32>,
|
|
||||||
pub(super) typed_text: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LiveInput {
|
|
||||||
fn scroll_point(&self) -> Result<(u32, u32), LiveSidecarError> {
|
|
||||||
let point = match (self.scroll_point_x, self.scroll_point_y) {
|
|
||||||
(Some(x), Some(y)) => (x, y),
|
|
||||||
_ => return Err(LiveSidecarError::IncompleteScrollPoint),
|
|
||||||
};
|
|
||||||
Ok(point)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_hidpi_scale_milli(scale: f32) -> u32 {
|
|
||||||
if !scale.is_finite() || scale <= 0.0 {
|
|
||||||
return 1_000;
|
|
||||||
}
|
|
||||||
let scaled = (scale * 1_000.0).round();
|
|
||||||
scaled.clamp(500.0, 5_000.0) as u32
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hidpi_scale_milli_to_f32(milli: u32) -> f32 {
|
|
||||||
milli as f32 / 1_000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn positive_scroll_component(current: i32, delta: i32) -> i32 {
|
|
||||||
let value = i64::from(current) + i64::from(delta);
|
|
||||||
value.clamp(0, i64::from(i32::MAX)) as i32
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_live_session_forces_first_resize_after_hidpi() {
|
|
||||||
let session = LiveSession::new(ely_domain::WebViewId::new(), 1280, 720);
|
|
||||||
|
|
||||||
assert_ne!(
|
|
||||||
session.width, 1280,
|
|
||||||
"first apply_layout must resize after hidpi has been pushed",
|
|
||||||
);
|
|
||||||
assert_ne!(
|
|
||||||
session.height, 720,
|
|
||||||
"first apply_layout must resize after hidpi has been pushed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
//! Per-frame paint→encode→write stage timings for the live sidecar
|
|
||||||
//! loop, plus exact fixed-window percentile summaries so the main
|
|
||||||
//! process can read out p50/p95/p99 latencies.
|
|
||||||
//!
|
|
||||||
//! Why this lives next to `live.rs`: the sidecar already owns the hot
|
|
||||||
//! loop. Sampling here costs one `Instant::now()` per stage boundary.
|
|
||||||
//! Each stage keeps one preallocated window of nanosecond samples and
|
|
||||||
//! sorts only at the 60-frame summary boundary, so steady-state record
|
|
||||||
//! cost stays a single push per stage while p95 remains exact enough
|
|
||||||
//! for 120 fps gates.
|
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// Per-frame stage timings captured by the live loop.
|
|
||||||
///
|
|
||||||
/// `total_ns` is the real wall-clock span from request arrival to the
|
|
||||||
/// stdout flush returning, so it captures every byte of overhead
|
|
||||||
/// outside paint/encode/write (snapshot reads, JSON parse, scratch
|
|
||||||
/// allocations). It is measured at the loop boundary, not summed.
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub(super) struct FrameStageTimings {
|
|
||||||
pub paint_ns: u64,
|
|
||||||
pub encode_ns: u64,
|
|
||||||
pub write_ns: u64,
|
|
||||||
pub total_ns: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FrameStageTimings {
|
|
||||||
pub(super) fn from_durations(
|
|
||||||
paint: Duration,
|
|
||||||
encode: Duration,
|
|
||||||
write: Duration,
|
|
||||||
total: Duration,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
paint_ns: duration_to_ns(paint),
|
|
||||||
encode_ns: duration_to_ns(encode),
|
|
||||||
write_ns: duration_to_ns(write),
|
|
||||||
total_ns: duration_to_ns(total),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn duration_to_ns(duration: Duration) -> u64 {
|
|
||||||
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saturating elapsed-ns helper. `Instant::elapsed` is monotonic but
|
|
||||||
/// the cast can still overflow on the (impossible) hour-long frame.
|
|
||||||
pub(super) fn elapsed_ns(start: Instant) -> u64 {
|
|
||||||
duration_to_ns(start.elapsed())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct StageSamples {
|
|
||||||
values: Vec<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StageSamples {
|
|
||||||
fn new(window_size: usize) -> Self {
|
|
||||||
Self { values: Vec::with_capacity(window_size) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record(&mut self, ns: u64) {
|
|
||||||
self.values.push(ns);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn len(&self) -> usize {
|
|
||||||
self.values.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percentiles_us(&self) -> StagePercentiles {
|
|
||||||
let mut sorted = self.values.clone();
|
|
||||||
sorted.sort_unstable();
|
|
||||||
StagePercentiles {
|
|
||||||
p50: percentile_us(&sorted, 0.50),
|
|
||||||
p95: percentile_us(&sorted, 0.95),
|
|
||||||
p99: percentile_us(&sorted, 0.99),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset(&mut self) {
|
|
||||||
self.values.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct StagePercentiles {
|
|
||||||
p50: u64,
|
|
||||||
p95: u64,
|
|
||||||
p99: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percentile_us(sorted_ns: &[u64], percentile: f64) -> u64 {
|
|
||||||
if sorted_ns.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let target = ((sorted_ns.len() as f64) * percentile).ceil() as usize;
|
|
||||||
let index = target.max(1).min(sorted_ns.len()) - 1;
|
|
||||||
ns_to_us_ceil(sorted_ns[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ns_to_us_ceil(ns: u64) -> u64 {
|
|
||||||
ns.div_ceil(1_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggregates a rolling window of [`FrameStageTimings`] across N
|
|
||||||
/// frames, exposing one [`FramePerfSummary`] per window flush.
|
|
||||||
pub(super) struct FramePerfAggregator {
|
|
||||||
window_size: usize,
|
|
||||||
paint: StageSamples,
|
|
||||||
encode: StageSamples,
|
|
||||||
write: StageSamples,
|
|
||||||
total: StageSamples,
|
|
||||||
context_label: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FramePerfAggregator {
|
|
||||||
pub(super) const DEFAULT_WINDOW_SIZE: u32 = 60;
|
|
||||||
|
|
||||||
pub(super) fn new(context_label: &'static str, window_size: u32) -> Self {
|
|
||||||
let window_size = usize::try_from(window_size.max(1)).unwrap_or(usize::MAX);
|
|
||||||
Self {
|
|
||||||
window_size,
|
|
||||||
paint: StageSamples::new(window_size),
|
|
||||||
encode: StageSamples::new(window_size),
|
|
||||||
write: StageSamples::new(window_size),
|
|
||||||
total: StageSamples::new(window_size),
|
|
||||||
context_label,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn record(&mut self, timings: FrameStageTimings) -> Option<FramePerfSummary> {
|
|
||||||
self.paint.record(timings.paint_ns);
|
|
||||||
self.encode.record(timings.encode_ns);
|
|
||||||
self.write.record(timings.write_ns);
|
|
||||||
self.total.record(timings.total_ns);
|
|
||||||
if self.paint.len() < self.window_size {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let paint = self.paint.percentiles_us();
|
|
||||||
let encode = self.encode.percentiles_us();
|
|
||||||
let write = self.write.percentiles_us();
|
|
||||||
let total = self.total.percentiles_us();
|
|
||||||
let summary = FramePerfSummary {
|
|
||||||
window: u32::try_from(self.paint.len()).unwrap_or(u32::MAX),
|
|
||||||
context: self.context_label,
|
|
||||||
paint_p50_us: paint.p50,
|
|
||||||
paint_p95_us: paint.p95,
|
|
||||||
paint_p99_us: paint.p99,
|
|
||||||
encode_p50_us: encode.p50,
|
|
||||||
encode_p95_us: encode.p95,
|
|
||||||
encode_p99_us: encode.p99,
|
|
||||||
write_p50_us: write.p50,
|
|
||||||
write_p95_us: write.p95,
|
|
||||||
write_p99_us: write.p99,
|
|
||||||
total_p50_us: total.p50,
|
|
||||||
total_p95_us: total.p95,
|
|
||||||
total_p99_us: total.p99,
|
|
||||||
};
|
|
||||||
self.paint.reset();
|
|
||||||
self.encode.reset();
|
|
||||||
self.write.reset();
|
|
||||||
self.total.reset();
|
|
||||||
Some(summary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, serde::Serialize)]
|
|
||||||
pub(super) struct FramePerfSummary {
|
|
||||||
pub window: u32,
|
|
||||||
pub context: &'static str,
|
|
||||||
pub paint_p50_us: u64,
|
|
||||||
pub paint_p95_us: u64,
|
|
||||||
pub paint_p99_us: u64,
|
|
||||||
pub encode_p50_us: u64,
|
|
||||||
pub encode_p95_us: u64,
|
|
||||||
pub encode_p99_us: u64,
|
|
||||||
pub write_p50_us: u64,
|
|
||||||
pub write_p95_us: u64,
|
|
||||||
pub write_p99_us: u64,
|
|
||||||
pub total_p50_us: u64,
|
|
||||||
pub total_p95_us: u64,
|
|
||||||
pub total_p99_us: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{FramePerfAggregator, FrameStageTimings, percentile_us};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn percentile_us_uses_nearest_rank_and_ceils_microseconds() {
|
|
||||||
let sorted_ns = [1, 1_000, 1_001];
|
|
||||||
assert_eq!(percentile_us(&sorted_ns, 0.50), 1);
|
|
||||||
assert_eq!(percentile_us(&sorted_ns, 0.95), 2);
|
|
||||||
assert_eq!(percentile_us(&sorted_ns, 0.99), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aggregator_emits_summary_after_window_size_records() -> Result<(), &'static str> {
|
|
||||||
let mut aggregator =
|
|
||||||
FramePerfAggregator::new("software", FramePerfAggregator::DEFAULT_WINDOW_SIZE);
|
|
||||||
for index in 0..(FramePerfAggregator::DEFAULT_WINDOW_SIZE - 1) {
|
|
||||||
let result = aggregator.record(constant_timing());
|
|
||||||
assert!(result.is_none(), "should not flush at frame {index}");
|
|
||||||
}
|
|
||||||
let summary = aggregator
|
|
||||||
.record(constant_timing())
|
|
||||||
.ok_or("aggregator must flush at window boundary")?;
|
|
||||||
assert_eq!(summary.window, FramePerfAggregator::DEFAULT_WINDOW_SIZE);
|
|
||||||
assert_eq!(summary.context, "software");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aggregator_resets_after_flush_so_next_window_starts_fresh() {
|
|
||||||
let mut aggregator = FramePerfAggregator::new("hardware", 2);
|
|
||||||
let _ = aggregator.record(constant_timing());
|
|
||||||
let summary = aggregator.record(constant_timing());
|
|
||||||
assert!(summary.is_some(), "expected first flush");
|
|
||||||
let after_flush = aggregator.record(constant_timing());
|
|
||||||
assert!(after_flush.is_none(), "aggregator must zero counters after flush");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aggregator_percentiles_track_increasing_paint_durations() -> Result<(), &'static str> {
|
|
||||||
let mut aggregator = FramePerfAggregator::new("software", 4);
|
|
||||||
let paint_durations_us = [10u64, 100, 1_000, 10_000];
|
|
||||||
let mut summary = None;
|
|
||||||
for paint_us in paint_durations_us {
|
|
||||||
summary = aggregator.record(FrameStageTimings::from_durations(
|
|
||||||
Duration::from_micros(paint_us),
|
|
||||||
Duration::from_micros(1),
|
|
||||||
Duration::from_micros(1),
|
|
||||||
Duration::from_micros(paint_us + 2),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let summary = summary.ok_or("4-frame window must flush")?;
|
|
||||||
assert_eq!(summary.paint_p50_us, 100);
|
|
||||||
assert_eq!(summary.paint_p95_us, 10_000);
|
|
||||||
assert_eq!(summary.paint_p99_us, 10_000);
|
|
||||||
assert_eq!(summary.total_p50_us, 102);
|
|
||||||
assert_eq!(summary.total_p95_us, 10_002);
|
|
||||||
assert_eq!(summary.total_p99_us, 10_002);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn constant_timing() -> FrameStageTimings {
|
|
||||||
FrameStageTimings::from_durations(
|
|
||||||
Duration::from_micros(2_000),
|
|
||||||
Duration::from_micros(500),
|
|
||||||
Duration::from_micros(100),
|
|
||||||
Duration::from_micros(2_600),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
use ely_servo_host::{RenderedFrame, WebViewSnapshot, WebViewState};
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use super::args::SnapshotArgs;
|
|
||||||
|
|
||||||
pub(super) struct SnapshotInputChanges {
|
|
||||||
pub(super) scroll: bool,
|
|
||||||
pub(super) click: bool,
|
|
||||||
pub(super) drag: bool,
|
|
||||||
pub(super) touch: bool,
|
|
||||||
pub(super) text: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub(super) struct SnapshotReport {
|
|
||||||
requested_url: String,
|
|
||||||
profile_id: String,
|
|
||||||
loaded_url: Option<String>,
|
|
||||||
title: Option<String>,
|
|
||||||
rgba_path: String,
|
|
||||||
state: &'static str,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
rgba_byte_count: usize,
|
|
||||||
opaque_pixel_count: u64,
|
|
||||||
non_white_pixel_count: u64,
|
|
||||||
content_pixel_count: u64,
|
|
||||||
sample_hash: u64,
|
|
||||||
scroll_x: i32,
|
|
||||||
scroll_y: i32,
|
|
||||||
page_zoom_percent: u16,
|
|
||||||
scroll_changed_frame: bool,
|
|
||||||
click_x: Option<u32>,
|
|
||||||
click_y: Option<u32>,
|
|
||||||
click_changed_frame: bool,
|
|
||||||
drag_from_x: Option<u32>,
|
|
||||||
drag_from_y: Option<u32>,
|
|
||||||
drag_to_x: Option<u32>,
|
|
||||||
drag_to_y: Option<u32>,
|
|
||||||
drag_changed_frame: bool,
|
|
||||||
touch_x: Option<u32>,
|
|
||||||
touch_y: Option<u32>,
|
|
||||||
touch_changed_frame: bool,
|
|
||||||
typed_text_byte_count: usize,
|
|
||||||
text_changed_frame: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SnapshotReport {
|
|
||||||
pub(super) fn new(
|
|
||||||
args: &SnapshotArgs,
|
|
||||||
snapshot: &WebViewSnapshot,
|
|
||||||
frame: &RenderedFrame,
|
|
||||||
changes: SnapshotInputChanges,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
requested_url: args.url.as_str().to_string(),
|
|
||||||
profile_id: snapshot.profile_id().as_str().to_string(),
|
|
||||||
loaded_url: snapshot.url().map(str::to_string),
|
|
||||||
title: snapshot.title().map(str::to_string),
|
|
||||||
rgba_path: args.rgba_out.display().to_string(),
|
|
||||||
state: state_label(snapshot.state()),
|
|
||||||
width: frame.width(),
|
|
||||||
height: frame.height(),
|
|
||||||
rgba_byte_count: frame.rgba_bytes().len(),
|
|
||||||
opaque_pixel_count: frame.opaque_pixel_count(),
|
|
||||||
non_white_pixel_count: frame.non_white_pixel_count(),
|
|
||||||
content_pixel_count: frame.content_pixel_count(),
|
|
||||||
sample_hash: frame.sample_hash(),
|
|
||||||
scroll_x: args.scroll_x,
|
|
||||||
scroll_y: args.scroll_y,
|
|
||||||
page_zoom_percent: args.page_zoom_percent,
|
|
||||||
scroll_changed_frame: changes.scroll,
|
|
||||||
click_x: args.click_point.map(|point| point.x),
|
|
||||||
click_y: args.click_point.map(|point| point.y),
|
|
||||||
click_changed_frame: changes.click,
|
|
||||||
drag_from_x: args.drag_points.map(|points| points.from.x),
|
|
||||||
drag_from_y: args.drag_points.map(|points| points.from.y),
|
|
||||||
drag_to_x: args.drag_points.map(|points| points.to.x),
|
|
||||||
drag_to_y: args.drag_points.map(|points| points.to.y),
|
|
||||||
drag_changed_frame: changes.drag,
|
|
||||||
touch_x: args.touch_point.map(|point| point.x),
|
|
||||||
touch_y: args.touch_point.map(|point| point.y),
|
|
||||||
touch_changed_frame: changes.touch,
|
|
||||||
typed_text_byte_count: args.typed_text.as_ref().map_or(0, String::len),
|
|
||||||
text_changed_frame: changes.text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_label(state: &WebViewState) -> &'static str {
|
|
||||||
match state {
|
|
||||||
WebViewState::Created => "created",
|
|
||||||
WebViewState::Loading => "loading",
|
|
||||||
WebViewState::Complete => "complete",
|
|
||||||
WebViewState::Sleeping => "sleeping",
|
|
||||||
WebViewState::Crashed => "crashed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,21 +24,6 @@ pub enum ServoHostError {
|
|||||||
#[error("servo rendering context could not be made current")]
|
#[error("servo rendering context could not be made current")]
|
||||||
RenderingContextNotCurrent,
|
RenderingContextNotCurrent,
|
||||||
|
|
||||||
#[error(
|
|
||||||
"hardware rendering context requested but the `hardware-render` feature \
|
|
||||||
was not compiled in; rebuild with --features servo-engine,hardware-render"
|
|
||||||
)]
|
|
||||||
HardwareRenderUnavailable,
|
|
||||||
|
|
||||||
#[error("servo rendered frame is unavailable")]
|
#[error("servo rendered frame is unavailable")]
|
||||||
RenderedFrameUnavailable,
|
RenderedFrameUnavailable,
|
||||||
|
|
||||||
#[error("servo hardware surface is unavailable for {id}")]
|
|
||||||
HardwareSurfaceUnavailable { id: WebViewId },
|
|
||||||
|
|
||||||
#[error("servo screenshot capture timed out for {id}")]
|
|
||||||
ScreenshotTimedOut { id: WebViewId },
|
|
||||||
|
|
||||||
#[error("servo screenshot capture failed: {reason}")]
|
|
||||||
ScreenshotUnavailable { reason: String },
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,398 +0,0 @@
|
|||||||
//! Headless hardware [`RenderingContext`] for Servo, vendored from
|
|
||||||
//! `servo-paint-api`'s private `SurfmanRenderingContext` and reshaped
|
|
||||||
//! so it can be constructed without a `RawWindowHandle`.
|
|
||||||
//!
|
|
||||||
//! Why this file exists: `servo-paint-api 0.1` exposes three
|
|
||||||
//! constructors — `SoftwareRenderingContext` (CPU-only),
|
|
||||||
//! `WindowRenderingContext` (requires `DisplayHandle + WindowHandle`),
|
|
||||||
//! and `OffscreenRenderingContext` (must be a child of a
|
|
||||||
//! `WindowRenderingContext`). The sidecar process has no window, so
|
|
||||||
//! none of the three works for us when we want **hardware**
|
|
||||||
//! rasterising. The underlying `SurfmanRenderingContext` glue *can*
|
|
||||||
//! drive a hardware adapter against a `SurfaceType::Generic`
|
|
||||||
//! offscreen surface — that's exactly what we need — but its
|
|
||||||
//! constructor is `fn new` (private). Until Servo accepts an upstream
|
|
||||||
//! PR exposing a headless hardware constructor, this file vendors the
|
|
||||||
//! minimal slice of glue we need.
|
|
||||||
//!
|
|
||||||
//! Scope kept deliberately narrow:
|
|
||||||
//!
|
|
||||||
//! * `prepare_for_rendering`, `read_to_image`, `size`, `resize`,
|
|
||||||
//! `present`, `make_current`, `gleam_gl_api`, `glow_gl_api`, and
|
|
||||||
//! `connection` are vendored. `connection` is mandatory:
|
|
||||||
//! `servo-paint`'s painter calls `rendering_context.connection()
|
|
||||||
//! .expect("Failed to get connection")` while constructing its
|
|
||||||
//! painter, so a `None` default panics the compositor before the
|
|
||||||
//! first frame is ever painted.
|
|
||||||
//! * `create_texture`/`destroy_texture` still fall through to the
|
|
||||||
//! trait defaults — Servo only reaches for them when sharing
|
|
||||||
//! surfman surfaces with its compositor for WebGL/WebGPU, which
|
|
||||||
//! this readback path does not exercise.
|
|
||||||
//! * No `RefreshDriver`. The sidecar drives its own polling loop.
|
|
||||||
//! * The reading path inlines `read_framebuffer_to_image` from the
|
|
||||||
//! same upstream file so we don't take a dependency on a private
|
|
||||||
//! helper that may change shape.
|
|
||||||
//!
|
|
||||||
//! This is feature-gated on `hardware-render`. The default build path
|
|
||||||
//! (and every existing test in this repo) keeps using
|
|
||||||
//! `SoftwareRenderingContext`; the hardware constructor only exists
|
|
||||||
//! when the feature is enabled, which is also when the additional
|
|
||||||
//! surfman/gleam/glow deps are pulled in.
|
|
||||||
|
|
||||||
#![cfg(feature = "hardware-render")]
|
|
||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
|
||||||
use std::rc::Rc;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use dpi::PhysicalSize;
|
|
||||||
use euclid::Size2D;
|
|
||||||
use gleam::gl::{self, Gl};
|
|
||||||
use image::RgbaImage;
|
|
||||||
use servo::{DeviceIntRect, RenderingContext};
|
|
||||||
use surfman::chains::{PreserveBuffer, SwapChain, SwapChainAPI};
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use surfman::platform::macos::cgl::surface::NativeSurface;
|
|
||||||
use surfman::{
|
|
||||||
Connection, Context, ContextAttributeFlags, ContextAttributes, Device, Error as SurfmanError,
|
|
||||||
GLApi, NativeWidget, Surface, SurfaceAccess, SurfaceType,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A headless hardware-backed [`RenderingContext`].
|
|
||||||
///
|
|
||||||
/// Construct with [`HardwareOffscreenContext::new`]; drop normally to
|
|
||||||
/// release the surfman context, surface, and swap chain.
|
|
||||||
pub struct HardwareOffscreenContext {
|
|
||||||
size: Cell<PhysicalSize<u32>>,
|
|
||||||
inner: SurfmanInner,
|
|
||||||
swap_chain: SwapChain<Device>,
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
held_presented_surface: RefCell<Option<Surface>>,
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
last_presented_iosurface: RefCell<Option<PresentedIOSurface>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HardwareOffscreenContext {
|
|
||||||
/// Build a new hardware context with an offscreen
|
|
||||||
/// [`SurfaceType::Generic`] surface of the requested size.
|
|
||||||
///
|
|
||||||
/// Uses `Connection::new()` to pick the platform default
|
|
||||||
/// (CGL on macOS — which backs surfaces with `IOSurface`s —
|
|
||||||
/// EGL on Linux, WGL on Windows) and `create_adapter()` for the
|
|
||||||
/// real GPU adapter. Falls back nowhere: if the host can't give
|
|
||||||
/// us a hardware GL context, the returned `Err` carries the
|
|
||||||
/// surfman cause and the caller is expected to either retry with
|
|
||||||
/// the software path or surface the failure.
|
|
||||||
pub fn new(size: PhysicalSize<u32>) -> Result<Self, SurfmanError> {
|
|
||||||
let connection = Connection::new()?;
|
|
||||||
let adapter = connection.create_adapter()?;
|
|
||||||
let inner = SurfmanInner::new(&connection, &adapter)?;
|
|
||||||
let surfman_size = Size2D::new(size.width as i32, size.height as i32);
|
|
||||||
let surface = inner.create_surface(SurfaceType::Generic { size: surfman_size })?;
|
|
||||||
inner.bind_surface(surface)?;
|
|
||||||
inner.make_current()?;
|
|
||||||
let swap_chain = inner.create_attached_swap_chain()?;
|
|
||||||
Ok(Self {
|
|
||||||
size: Cell::new(size),
|
|
||||||
inner,
|
|
||||||
swap_chain,
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
held_presented_surface: RefCell::new(None),
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
last_presented_iosurface: RefCell::new(None),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for HardwareOffscreenContext {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let device = &mut self.inner.device.borrow_mut();
|
|
||||||
let context = &mut self.inner.context.borrow_mut();
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
self.destroy_held_presented_surface(device, context);
|
|
||||||
let _ = self.swap_chain.destroy(device, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderingContext for HardwareOffscreenContext {
|
|
||||||
fn prepare_for_rendering(&self) {
|
|
||||||
self.inner.prepare_for_rendering();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
|
|
||||||
self.inner.read_to_image(source_rectangle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn size(&self) -> PhysicalSize<u32> {
|
|
||||||
self.size.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resize(&self, size: PhysicalSize<u32>) {
|
|
||||||
if self.size.get() == size {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.size.set(size);
|
|
||||||
|
|
||||||
let device = &mut self.inner.device.borrow_mut();
|
|
||||||
let context = &mut self.inner.context.borrow_mut();
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
self.destroy_held_presented_surface(device, context);
|
|
||||||
let size = Size2D::new(size.width as i32, size.height as i32);
|
|
||||||
let _ = self.swap_chain.resize(device, context, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn present(&self) {
|
|
||||||
let device = &mut self.inner.device.borrow_mut();
|
|
||||||
let context = &mut self.inner.context.borrow_mut();
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
self.recycle_held_presented_surface();
|
|
||||||
let _ = self.swap_chain.swap_buffers(device, context, PreserveBuffer::No);
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
self.capture_presented_iosurface(device);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_current(&self) -> Result<(), SurfmanError> {
|
|
||||||
self.inner.make_current()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn gleam_gl_api(&self) -> Rc<dyn Gl> {
|
|
||||||
self.inner.gleam_gl.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn glow_gl_api(&self) -> Arc<glow::Context> {
|
|
||||||
self.inner.glow_gl.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connection(&self) -> Option<Connection> {
|
|
||||||
Some(self.inner.device.borrow().connection())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use crate::iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
impl HardwareOffscreenContext {
|
|
||||||
/// Cheap, non-mutating identity probe of the IOSurface that was
|
|
||||||
/// just presented. Used by the sidecar to dedup mach port creation.
|
|
||||||
pub fn peek_iosurface_identity(&self) -> Result<Option<IOSurfaceIdentity>, SurfmanError> {
|
|
||||||
Ok(self.last_presented_iosurface.borrow().as_ref().map(|surface| surface.identity))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot the just-presented IOSurface and return its mach port
|
|
||||||
/// name plus dimensions and stable surface id. Increments the
|
|
||||||
/// IOSurface's mach-port use count; the
|
|
||||||
/// receiving process holds it via `IOSurfaceLookupFromMachPort` and
|
|
||||||
/// is responsible for `mach_port_deallocate` once the import is
|
|
||||||
/// finished.
|
|
||||||
pub fn current_iosurface_mach_port(&self) -> Result<IOSurfaceHandle, SurfmanError> {
|
|
||||||
let presented = self.last_presented_iosurface.borrow();
|
|
||||||
let presented = presented.as_ref().ok_or(SurfmanError::Failed)?;
|
|
||||||
let mach_port = presented.native.0.create_mach_port();
|
|
||||||
Ok(IOSurfaceHandle {
|
|
||||||
mach_port_name: mach_port,
|
|
||||||
surface_id: presented.identity.surface_id,
|
|
||||||
width: presented.identity.width,
|
|
||||||
height: presented.identity.height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capture_presented_iosurface(&self, device: &mut Device) {
|
|
||||||
let Some(surface) = self.swap_chain.take_pending_surface() else {
|
|
||||||
self.last_presented_iosurface.borrow_mut().take();
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let info = device.surface_info(&surface);
|
|
||||||
let native = device.native_surface(&surface);
|
|
||||||
let identity = IOSurfaceIdentity {
|
|
||||||
surface_id: info.id.0 as u64,
|
|
||||||
width: u32::try_from(info.size.width).unwrap_or(0),
|
|
||||||
height: u32::try_from(info.size.height).unwrap_or(0),
|
|
||||||
};
|
|
||||||
self.held_presented_surface.replace(Some(surface));
|
|
||||||
self.last_presented_iosurface.replace(Some(PresentedIOSurface { identity, native }));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recycle_held_presented_surface(&self) {
|
|
||||||
if let Some(surface) = self.held_presented_surface.borrow_mut().take() {
|
|
||||||
self.swap_chain.recycle_surface(surface);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn destroy_held_presented_surface(&self, device: &mut Device, context: &mut Context) {
|
|
||||||
self.last_presented_iosurface.borrow_mut().take();
|
|
||||||
if let Some(mut surface) = self.held_presented_surface.borrow_mut().take() {
|
|
||||||
let _ = device.destroy_surface(context, &mut surface);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
struct PresentedIOSurface {
|
|
||||||
identity: IOSurfaceIdentity,
|
|
||||||
native: NativeSurface,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trimmed mirror of `paint_api::rendering_context::SurfmanRenderingContext`.
|
|
||||||
///
|
|
||||||
/// Only the methods the public type above actually uses are kept; the
|
|
||||||
/// upstream original also wires up texture sharing, refresh drivers,
|
|
||||||
/// and several other knobs that Servo's compositor reaches into but
|
|
||||||
/// the embedder's headless readback path does not.
|
|
||||||
struct SurfmanInner {
|
|
||||||
gleam_gl: Rc<dyn Gl>,
|
|
||||||
glow_gl: Arc<glow::Context>,
|
|
||||||
device: RefCell<Device>,
|
|
||||||
context: RefCell<Context>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for SurfmanInner {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let device = &mut self.device.borrow_mut();
|
|
||||||
let context = &mut self.context.borrow_mut();
|
|
||||||
let _ = device.destroy_context(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SurfmanInner {
|
|
||||||
fn new(connection: &Connection, adapter: &surfman::Adapter) -> Result<Self, SurfmanError> {
|
|
||||||
let device = connection.create_device(adapter)?;
|
|
||||||
|
|
||||||
let flags = ContextAttributeFlags::ALPHA
|
|
||||||
| ContextAttributeFlags::DEPTH
|
|
||||||
| ContextAttributeFlags::STENCIL;
|
|
||||||
let gl_api = connection.gl_api();
|
|
||||||
let version = match &gl_api {
|
|
||||||
GLApi::GLES => surfman::GLVersion { major: 3, minor: 0 },
|
|
||||||
GLApi::GL => surfman::GLVersion { major: 3, minor: 2 },
|
|
||||||
};
|
|
||||||
let context_descriptor =
|
|
||||||
device.create_context_descriptor(&ContextAttributes { flags, version })?;
|
|
||||||
let context = device.create_context(&context_descriptor, None)?;
|
|
||||||
|
|
||||||
// Loading the GL function pointers requires unsafe ABI calls
|
|
||||||
// through surfman's `get_proc_address` — these are the same
|
|
||||||
// calls the upstream `SurfmanRenderingContext::new` makes,
|
|
||||||
// and they're sound for the same reason: surfman guarantees
|
|
||||||
// the returned function pointers match the requested API.
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let gleam_gl = {
|
|
||||||
match gl_api {
|
|
||||||
GLApi::GL => unsafe {
|
|
||||||
gl::GlFns::load_with(|name| device.get_proc_address(&context, name))
|
|
||||||
},
|
|
||||||
GLApi::GLES => unsafe {
|
|
||||||
gl::GlesFns::load_with(|name| device.get_proc_address(&context, name))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
#[expect(unsafe_code)]
|
|
||||||
let glow_gl = unsafe {
|
|
||||||
glow::Context::from_loader_function(|name| device.get_proc_address(&context, name))
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
gleam_gl,
|
|
||||||
glow_gl: Arc::new(glow_gl),
|
|
||||||
device: RefCell::new(device),
|
|
||||||
context: RefCell::new(context),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_surface(
|
|
||||||
&self,
|
|
||||||
surface_type: SurfaceType<NativeWidget>,
|
|
||||||
) -> Result<Surface, SurfmanError> {
|
|
||||||
let device = &mut self.device.borrow_mut();
|
|
||||||
let context = &self.context.borrow();
|
|
||||||
device.create_surface(context, SurfaceAccess::GPUOnly, surface_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bind_surface(&self, surface: Surface) -> Result<(), SurfmanError> {
|
|
||||||
let device = &self.device.borrow();
|
|
||||||
let context = &mut self.context.borrow_mut();
|
|
||||||
device.bind_surface_to_context(context, surface).map_err(|(err, mut surface)| {
|
|
||||||
let _ = device.destroy_surface(context, &mut surface);
|
|
||||||
err
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_attached_swap_chain(&self) -> Result<SwapChain<Device>, SurfmanError> {
|
|
||||||
let device = &mut self.device.borrow_mut();
|
|
||||||
let context = &mut self.context.borrow_mut();
|
|
||||||
SwapChain::create_attached(device, context, SurfaceAccess::GPUOnly)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_current(&self) -> Result<(), SurfmanError> {
|
|
||||||
let device = &self.device.borrow();
|
|
||||||
let context = &self.context.borrow();
|
|
||||||
device.make_context_current(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn framebuffer_id(&self) -> u32 {
|
|
||||||
let device = &self.device.borrow();
|
|
||||||
let context = &self.context.borrow();
|
|
||||||
device
|
|
||||||
.context_surface_info(context)
|
|
||||||
.unwrap_or(None)
|
|
||||||
.and_then(|info| info.framebuffer_object)
|
|
||||||
.map_or(0, |framebuffer| framebuffer.0.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_for_rendering(&self) {
|
|
||||||
let framebuffer_id = self.framebuffer_id();
|
|
||||||
self.gleam_gl.bind_framebuffer(gleam::gl::FRAMEBUFFER, framebuffer_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inlined copy of `Framebuffer::read_framebuffer_to_image` from
|
|
||||||
/// `paint-api`. Reads the bound framebuffer into a `Vec<u8>`,
|
|
||||||
/// flips it vertically (GL's origin is bottom-left, the rest of
|
|
||||||
/// the embedder expects top-left), and returns it as an
|
|
||||||
/// [`RgbaImage`]. Returns `None` if `RgbaImage::from_raw` rejects
|
|
||||||
/// the buffer (size mismatch); GL errors are logged but don't
|
|
||||||
/// abort the read — the caller can decide whether a corrupt
|
|
||||||
/// frame is recoverable.
|
|
||||||
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
|
|
||||||
let framebuffer_id = self.framebuffer_id();
|
|
||||||
self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, framebuffer_id);
|
|
||||||
// Working around an OSMesa headless bug carried forward from
|
|
||||||
// the upstream implementation, see servo/servo#18606.
|
|
||||||
self.gleam_gl.bind_vertex_array(0);
|
|
||||||
|
|
||||||
let mut pixels = self.gleam_gl.read_pixels(
|
|
||||||
source_rectangle.min.x,
|
|
||||||
source_rectangle.min.y,
|
|
||||||
source_rectangle.width(),
|
|
||||||
source_rectangle.height(),
|
|
||||||
gl::RGBA,
|
|
||||||
gl::UNSIGNED_BYTE,
|
|
||||||
);
|
|
||||||
let gl_error = self.gleam_gl.get_error();
|
|
||||||
if gl_error != gl::NO_ERROR {
|
|
||||||
log::warn!("GL error 0x{gl_error:x} after read_pixels in hardware offscreen context");
|
|
||||||
}
|
|
||||||
|
|
||||||
let source_rectangle = source_rectangle.to_usize();
|
|
||||||
let stride = source_rectangle.width().checked_mul(4)?;
|
|
||||||
let mirror = pixels.clone();
|
|
||||||
for y in 0..source_rectangle.height() {
|
|
||||||
let dst_start = y.checked_mul(stride)?;
|
|
||||||
let src_start = (source_rectangle.height().checked_sub(y + 1)?).checked_mul(stride)?;
|
|
||||||
let dst_end = dst_start.checked_add(stride)?;
|
|
||||||
let src_end = src_start.checked_add(stride)?;
|
|
||||||
if dst_end > pixels.len() || src_end > mirror.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
pixels[dst_start..dst_end].clone_from_slice(&mirror[src_start..src_end]);
|
|
||||||
}
|
|
||||||
|
|
||||||
RgbaImage::from_raw(
|
|
||||||
source_rectangle.width() as u32,
|
|
||||||
source_rectangle.height() as u32,
|
|
||||||
pixels,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -284,11 +284,6 @@ pub struct KeyboardTextRequest {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct ScreenshotRequest {
|
|
||||||
pub webview_id: WebViewId,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct PermissionRequest {
|
pub struct PermissionRequest {
|
||||||
pub webview_id: WebViewId,
|
pub webview_id: WebViewId,
|
||||||
@@ -341,11 +336,6 @@ pub trait ServoHost {
|
|||||||
|
|
||||||
fn type_text(&mut self, request: KeyboardTextRequest) -> Result<(), ServoHostError>;
|
fn type_text(&mut self, request: KeyboardTextRequest) -> Result<(), ServoHostError>;
|
||||||
|
|
||||||
fn capture_screenshot(
|
|
||||||
&mut self,
|
|
||||||
request: ScreenshotRequest,
|
|
||||||
) -> Result<RenderedFrame, ServoHostError>;
|
|
||||||
|
|
||||||
fn set_permission(
|
fn set_permission(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: PermissionRequest,
|
request: PermissionRequest,
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
//! Cross-process IOSurface descriptor types.
|
|
||||||
//!
|
|
||||||
//! These wire types live outside `hardware_rendering_context` (which
|
|
||||||
//! is hardware-render + macOS gated) so the sidecar's JSON protocol
|
|
||||||
//! can carry an `Option<IOSurfaceHandle>` regardless of feature
|
|
||||||
//! flags. The receiver always knows how to parse the field; if no
|
|
||||||
//! sender ever populates it (software-only build), it's just `None`
|
|
||||||
//! on every frame.
|
|
||||||
//!
|
|
||||||
//! Minting an [`IOSurfaceHandle`] requires a hardware surfman context
|
|
||||||
//! and a macOS host. That part lives in
|
|
||||||
//! [`crate::hardware_rendering_context`].
|
|
||||||
|
|
||||||
/// Cross-process handle to a hardware surface: the receiving process
|
|
||||||
/// rebuilds an `IOSurfaceRef` from `mach_port_name` and imports it as
|
|
||||||
/// a Metal texture without copying pixels.
|
|
||||||
///
|
|
||||||
/// `surface_id` is the stable surfman `SurfaceID` (a pointer-shaped
|
|
||||||
/// `usize` widened to `u64` for the wire). Together with `width` and
|
|
||||||
/// `height` it lets the receiver dedup imported IOSurfaces. The pixel
|
|
||||||
/// dimensions are part of the identity because a resize can reuse the
|
|
||||||
/// same surfman id for a newly-sized IOSurface. `width` and `height`
|
|
||||||
/// are reported in surface pixels (post-DPR).
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "servo-engine", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub struct IOSurfaceHandle {
|
|
||||||
pub mach_port_name: u32,
|
|
||||||
pub surface_id: u64,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Identity-only peek of the currently bound IOSurface. Distinguishes
|
|
||||||
/// "same surface as last frame" from "resize/swap rotated to a new
|
|
||||||
/// surface" without minting a fresh mach port (mach ports are a scarce
|
|
||||||
/// kernel resource and `IOSurfaceCreateMachPort` is not cheap).
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
||||||
pub struct IOSurfaceIdentity {
|
|
||||||
pub surface_id: u64,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IOSurfaceIdentity {
|
|
||||||
pub fn from_handle(handle: IOSurfaceHandle) -> Self {
|
|
||||||
Self { surface_id: handle.surface_id, width: handle.width, height: handle.height }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
mod error;
|
mod error;
|
||||||
#[cfg(feature = "hardware-render")]
|
|
||||||
mod hardware_rendering_context;
|
|
||||||
mod host;
|
mod host;
|
||||||
mod iosurface_handle;
|
|
||||||
#[cfg(feature = "servo-engine")]
|
#[cfg(feature = "servo-engine")]
|
||||||
mod keyboard;
|
mod keyboard;
|
||||||
#[cfg(feature = "servo-engine")]
|
#[cfg(feature = "servo-engine")]
|
||||||
@@ -17,14 +14,11 @@ mod runtime_waker;
|
|||||||
mod runtime_webview;
|
mod runtime_webview;
|
||||||
|
|
||||||
pub use error::ServoHostError;
|
pub use error::ServoHostError;
|
||||||
#[cfg(feature = "hardware-render")]
|
|
||||||
pub use hardware_rendering_context::HardwareOffscreenContext;
|
|
||||||
pub use host::{
|
pub use host::{
|
||||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
|
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
|
||||||
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
|
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
|
||||||
RenderedFrameSummary, ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost,
|
RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest,
|
||||||
TouchTapRequest, WebViewSnapshot, WebViewState,
|
WebViewSnapshot, WebViewState,
|
||||||
};
|
};
|
||||||
pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
|
|
||||||
#[cfg(feature = "servo-engine")]
|
#[cfg(feature = "servo-engine")]
|
||||||
pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost};
|
pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost};
|
||||||
|
|||||||
@@ -4,18 +4,17 @@ use std::{
|
|||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc, Once,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use dpi::PhysicalSize;
|
use dpi::PhysicalSize;
|
||||||
use ely_domain::{ProfileId, TabId, WebViewId};
|
use ely_domain::{ProfileId, TabId, WebViewId};
|
||||||
|
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||||
use servo::{
|
use servo::{
|
||||||
DevicePoint, DeviceVector2D, Opts, Scroll, Servo, ServoBuilder, WebViewBuilder, WebViewPoint,
|
DevicePoint, DeviceVector2D, Opts, Preferences, Scroll, Servo, ServoBuilder, WebViewBuilder,
|
||||||
WebViewVector,
|
WebViewPoint, WebViewVector,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "runtime_context.rs"]
|
#[path = "runtime_context.rs"]
|
||||||
@@ -28,8 +27,8 @@ use url::Url;
|
|||||||
use crate::{
|
use crate::{
|
||||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
|
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
|
||||||
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
|
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
|
||||||
ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest,
|
ResizeRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest, WebViewSnapshot,
|
||||||
WebViewSnapshot, WebViewState,
|
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,
|
||||||
},
|
},
|
||||||
@@ -39,8 +38,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false);
|
static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false);
|
||||||
const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20);
|
static RUSTLS_PROVIDER: Once = Once::new();
|
||||||
const SCREENSHOT_POLL_INTERVAL: Duration = Duration::from_millis(2);
|
|
||||||
|
|
||||||
pub struct SoftwareServoHost {
|
pub struct SoftwareServoHost {
|
||||||
servo: Servo,
|
servo: Servo,
|
||||||
@@ -70,12 +68,6 @@ impl SoftwareServoHost {
|
|||||||
config_dir: Option<PathBuf>,
|
config_dir: Option<PathBuf>,
|
||||||
rendering_context_kind: RenderingContextKind,
|
rendering_context_kind: RenderingContextKind,
|
||||||
) -> Result<Self, ServoHostError> {
|
) -> Result<Self, ServoHostError> {
|
||||||
if rendering_context_kind == RenderingContextKind::Hardware
|
|
||||||
&& !cfg!(feature = "hardware-render")
|
|
||||||
{
|
|
||||||
return Err(ServoHostError::HardwareRenderUnavailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
if SERVO_RUNTIME_STARTED
|
if SERVO_RUNTIME_STARTED
|
||||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||||
.is_err()
|
.is_err()
|
||||||
@@ -96,7 +88,22 @@ impl SoftwareServoHost {
|
|||||||
profile_id: ProfileId,
|
profile_id: ProfileId,
|
||||||
size: ServoSurfaceSize,
|
size: ServoSurfaceSize,
|
||||||
) -> Result<WebViewId, ServoHostError> {
|
) -> Result<WebViewId, ServoHostError> {
|
||||||
self.create_webview_in_context(tab_id, profile_id, size)
|
let handles = self.new_rendering_context(size)?;
|
||||||
|
self.create_webview_in_context(tab_id, profile_id, handles)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_webview_with_native_surface<S>(
|
||||||
|
&mut self,
|
||||||
|
tab_id: TabId,
|
||||||
|
profile_id: ProfileId,
|
||||||
|
size: ServoSurfaceSize,
|
||||||
|
native_surface: &S,
|
||||||
|
) -> Result<WebViewId, ServoHostError>
|
||||||
|
where
|
||||||
|
S: HasDisplayHandle + HasWindowHandle + ?Sized,
|
||||||
|
{
|
||||||
|
let handles = self.new_rendering_context_for_native_surface(size, native_surface)?;
|
||||||
|
self.create_webview_in_context(tab_id, profile_id, handles)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint and present the current surface without RGBA readback.
|
/// Paint and present the current surface without RGBA readback.
|
||||||
@@ -121,8 +128,10 @@ impl SoftwareServoHost {
|
|||||||
config_dir: Option<PathBuf>,
|
config_dir: Option<PathBuf>,
|
||||||
rendering_context_kind: RenderingContextKind,
|
rendering_context_kind: RenderingContextKind,
|
||||||
) -> Result<Self, ServoHostError> {
|
) -> Result<Self, ServoHostError> {
|
||||||
|
install_rustls_provider();
|
||||||
let wake_requested = Arc::new(AtomicBool::new(false));
|
let wake_requested = Arc::new(AtomicBool::new(false));
|
||||||
let mut builder = ServoBuilder::default()
|
let mut builder = ServoBuilder::default()
|
||||||
|
.preferences(ely_servo_preferences())
|
||||||
.event_loop_waker(Box::new(ServoWakeFlag::new(wake_requested.clone())));
|
.event_loop_waker(Box::new(ServoWakeFlag::new(wake_requested.clone())));
|
||||||
if let Some(config_dir) = config_dir {
|
if let Some(config_dir) = config_dir {
|
||||||
builder = builder.opts(Opts { config_dir: Some(config_dir), ..Opts::default() });
|
builder = builder.opts(Opts { config_dir: Some(config_dir), ..Opts::default() });
|
||||||
@@ -183,13 +192,24 @@ impl SoftwareServoHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn install_rustls_provider() {
|
||||||
|
RUSTLS_PROVIDER.call_once(|| {
|
||||||
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ely_servo_preferences() -> Preferences {
|
||||||
|
Preferences { dom_intersection_observer_enabled: true, ..Preferences::default() }
|
||||||
|
}
|
||||||
|
|
||||||
impl ServoHost for SoftwareServoHost {
|
impl ServoHost for SoftwareServoHost {
|
||||||
fn create_webview(
|
fn create_webview(
|
||||||
&mut self,
|
&mut self,
|
||||||
tab_id: TabId,
|
tab_id: TabId,
|
||||||
profile_id: ProfileId,
|
profile_id: ProfileId,
|
||||||
) -> Result<WebViewId, ServoHostError> {
|
) -> Result<WebViewId, ServoHostError> {
|
||||||
self.create_webview_in_context(tab_id, profile_id, self.default_surface_size)
|
let handles = self.new_rendering_context(self.default_surface_size)?;
|
||||||
|
self.create_webview_in_context(tab_id, profile_id, handles)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError> {
|
fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError> {
|
||||||
@@ -313,41 +333,6 @@ impl ServoHost for SoftwareServoHost {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capture_screenshot(
|
|
||||||
&mut self,
|
|
||||||
request: ScreenshotRequest,
|
|
||||||
) -> Result<RenderedFrame, ServoHostError> {
|
|
||||||
let webview = self.webview(&request.webview_id)?.webview.clone();
|
|
||||||
let captured_image = Rc::new(RefCell::new(None));
|
|
||||||
let callback_image = captured_image.clone();
|
|
||||||
webview.take_screenshot(None, move |result| {
|
|
||||||
callback_image.replace(Some(result));
|
|
||||||
});
|
|
||||||
|
|
||||||
let started_at = Instant::now();
|
|
||||||
while captured_image.borrow().is_none() {
|
|
||||||
if started_at.elapsed() >= SCREENSHOT_TIMEOUT {
|
|
||||||
return Err(ServoHostError::ScreenshotTimedOut { id: request.webview_id.clone() });
|
|
||||||
}
|
|
||||||
|
|
||||||
self.tick();
|
|
||||||
if self.snapshot(&request.webview_id)?.has_pending_frame() {
|
|
||||||
self.paint(&request.webview_id)?;
|
|
||||||
}
|
|
||||||
thread::sleep(SCREENSHOT_POLL_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(result) = captured_image.borrow_mut().take() else {
|
|
||||||
return Err(ServoHostError::RenderedFrameUnavailable);
|
|
||||||
};
|
|
||||||
let image = result.map_err(|error| ServoHostError::ScreenshotUnavailable {
|
|
||||||
reason: format!("{error:?}"),
|
|
||||||
})?;
|
|
||||||
let frame = RenderedFrame::from_rgba_bytes(image.width(), image.height(), image.into_raw());
|
|
||||||
self.last_rendered_frame = Some(frame.clone());
|
|
||||||
Ok(frame)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_permission(
|
fn set_permission(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: PermissionRequest,
|
request: PermissionRequest,
|
||||||
@@ -392,15 +377,20 @@ impl ServoHost for SoftwareServoHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for SoftwareServoHost {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
SERVO_RUNTIME_STARTED.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SoftwareServoHost {
|
impl SoftwareServoHost {
|
||||||
fn create_webview_in_context(
|
fn create_webview_in_context(
|
||||||
&mut self,
|
&mut self,
|
||||||
tab_id: TabId,
|
tab_id: TabId,
|
||||||
profile_id: ProfileId,
|
profile_id: ProfileId,
|
||||||
size: ServoSurfaceSize,
|
handles: runtime_context::RenderingContextHandles,
|
||||||
) -> Result<WebViewId, ServoHostError> {
|
) -> Result<WebViewId, ServoHostError> {
|
||||||
let webview_id = WebViewId::new();
|
let webview_id = WebViewId::new();
|
||||||
let handles = self.new_rendering_context(size)?;
|
|
||||||
let delegate =
|
let delegate =
|
||||||
Rc::new(HostWebViewDelegate::new(profile_id.clone(), self.permissions.clone()));
|
Rc::new(HostWebViewDelegate::new(profile_id.clone(), self.permissions.clone()));
|
||||||
let webview = WebViewBuilder::new(&self.servo, handles.rendering_context.clone())
|
let webview = WebViewBuilder::new(&self.servo, handles.rendering_context.clone())
|
||||||
@@ -420,8 +410,6 @@ impl SoftwareServoHost {
|
|||||||
tab_id,
|
tab_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
rendering_context: handles.rendering_context,
|
rendering_context: handles.rendering_context,
|
||||||
#[cfg(feature = "hardware-render")]
|
|
||||||
hardware_context: handles.hardware_context,
|
|
||||||
webview,
|
webview,
|
||||||
delegate,
|
delegate,
|
||||||
requested_url: None,
|
requested_url: None,
|
||||||
@@ -431,42 +419,6 @@ impl SoftwareServoHost {
|
|||||||
Ok(webview_id)
|
Ok(webview_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cheap peek at the IOSurface identity bound to this webview's
|
|
||||||
/// hardware context. Returns `None` for software webviews and on
|
|
||||||
/// non-macOS hosts; otherwise the surfman `SurfaceID`-derived
|
|
||||||
/// identity plus dimensions. Used by the sidecar's live loop to
|
|
||||||
/// dedup mach port creation.
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
pub fn peek_iosurface_identity(
|
|
||||||
&self,
|
|
||||||
webview_id: &WebViewId,
|
|
||||||
) -> Result<Option<crate::IOSurfaceIdentity>, ServoHostError> {
|
|
||||||
let webview = self.webview(webview_id)?;
|
|
||||||
let Some(hardware) = webview.hardware_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
hardware.peek_iosurface_identity().map_err(|_| ServoHostError::RenderingContextUnavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mint a fresh mach port for the IOSurface bound to this
|
|
||||||
/// webview's hardware context. The caller is responsible for
|
|
||||||
/// transferring the port to the receiving process; if no transfer
|
|
||||||
/// happens, the port leaks. Software webviews return `None`.
|
|
||||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
|
||||||
pub fn current_iosurface_handle(
|
|
||||||
&self,
|
|
||||||
webview_id: &WebViewId,
|
|
||||||
) -> Result<Option<crate::IOSurfaceHandle>, ServoHostError> {
|
|
||||||
let webview = self.webview(webview_id)?;
|
|
||||||
let Some(hardware) = webview.hardware_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
hardware
|
|
||||||
.current_iosurface_mach_port()
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|_| ServoHostError::RenderingContextUnavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn webview(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> {
|
fn webview(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> {
|
||||||
self.webviews
|
self.webviews
|
||||||
.get(webview_id)
|
.get(webview_id)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::{
|
|||||||
|
|
||||||
use dpi::PhysicalSize;
|
use dpi::PhysicalSize;
|
||||||
use euclid::Scale;
|
use euclid::Scale;
|
||||||
|
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||||
use servo::{
|
use servo::{
|
||||||
DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel,
|
DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel,
|
||||||
RenderingContext,
|
RenderingContext,
|
||||||
@@ -62,31 +63,16 @@ impl ServoSurfaceSize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Selects the `RenderingContext` implementation each webview gets.
|
/// Selects the `RenderingContext` implementation each webview gets.
|
||||||
///
|
|
||||||
/// `Software` uses Servo's built-in `SoftwareRenderingContext`, which
|
|
||||||
/// rasterises on the CPU. `Hardware` uses the vendored
|
|
||||||
/// [`HardwareOffscreenContext`](crate::HardwareOffscreenContext),
|
|
||||||
/// which rasterises through the real GPU adapter against a
|
|
||||||
/// `SurfaceType::Generic` offscreen surface. The `Hardware` variant
|
|
||||||
/// is only available when the `hardware-render` feature is enabled;
|
|
||||||
/// requesting it without the feature is a configuration error
|
|
||||||
/// surfaced via `ServoHostError::HardwareRenderUnavailable`.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum RenderingContextKind {
|
pub enum RenderingContextKind {
|
||||||
#[default]
|
#[default]
|
||||||
Software,
|
Software,
|
||||||
Hardware,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pair of rendering-context handles produced by
|
/// Pair of rendering-context handles produced by
|
||||||
/// [`SoftwareServoHost::new_rendering_context`]. The trait-object
|
/// [`SoftwareServoHost::new_rendering_context`].
|
||||||
/// handle drives Servo's compositor; the concrete hardware handle is
|
|
||||||
/// kept on the side so the host can call macOS-specific methods
|
|
||||||
/// (IOSurface mach port extraction) without downcasting.
|
|
||||||
pub(super) struct RenderingContextHandles {
|
pub(super) struct RenderingContextHandles {
|
||||||
pub(super) rendering_context: Rc<dyn RenderingContext>,
|
pub(super) rendering_context: Rc<dyn RenderingContext>,
|
||||||
#[cfg(feature = "hardware-render")]
|
|
||||||
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoftwareServoHost {
|
impl SoftwareServoHost {
|
||||||
@@ -103,27 +89,31 @@ impl SoftwareServoHost {
|
|||||||
rendering_context
|
rendering_context
|
||||||
.make_current()
|
.make_current()
|
||||||
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
|
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
|
||||||
Ok(RenderingContextHandles {
|
Ok(RenderingContextHandles { rendering_context })
|
||||||
rendering_context,
|
|
||||||
#[cfg(feature = "hardware-render")]
|
|
||||||
hardware_context: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
#[cfg(feature = "hardware-render")]
|
}
|
||||||
RenderingContextKind::Hardware => {
|
}
|
||||||
let hardware = Rc::new(
|
|
||||||
crate::HardwareOffscreenContext::new(size.physical())
|
pub(super) fn new_rendering_context_for_native_surface<S>(
|
||||||
|
&self,
|
||||||
|
size: ServoSurfaceSize,
|
||||||
|
native_surface: &S,
|
||||||
|
) -> Result<RenderingContextHandles, ServoHostError>
|
||||||
|
where
|
||||||
|
S: HasDisplayHandle + HasWindowHandle + ?Sized,
|
||||||
|
{
|
||||||
|
let display_handle = native_surface
|
||||||
|
.display_handle()
|
||||||
|
.map_err(|_| ServoHostError::RenderingContextUnavailable)?;
|
||||||
|
let window_handle = native_surface
|
||||||
|
.window_handle()
|
||||||
|
.map_err(|_| ServoHostError::RenderingContextUnavailable)?;
|
||||||
|
let rendering_context = Rc::new(
|
||||||
|
servo::WindowRenderingContext::new(display_handle, window_handle, size.physical())
|
||||||
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
|
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
|
||||||
);
|
);
|
||||||
hardware.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
|
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
|
||||||
Ok(RenderingContextHandles {
|
Ok(RenderingContextHandles { rendering_context })
|
||||||
rendering_context: hardware.clone(),
|
|
||||||
hardware_context: Some(hardware),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "hardware-render"))]
|
|
||||||
RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spin Servo's event loop until the webview's delegate observes a
|
/// Spin Servo's event loop until the webview's delegate observes a
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ pub(super) struct HostWebView {
|
|||||||
pub(super) tab_id: TabId,
|
pub(super) tab_id: TabId,
|
||||||
pub(super) profile_id: ProfileId,
|
pub(super) profile_id: ProfileId,
|
||||||
pub(super) rendering_context: Rc<dyn RenderingContext>,
|
pub(super) rendering_context: Rc<dyn RenderingContext>,
|
||||||
/// Parallel concrete handle when the rendering context is the
|
|
||||||
/// vendored hardware path. `None` for software webviews. Lets the
|
|
||||||
/// host call macOS-specific methods (IOSurface mach port
|
|
||||||
/// extraction) without downcasting `dyn RenderingContext`.
|
|
||||||
#[cfg(feature = "hardware-render")]
|
|
||||||
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
|
|
||||||
pub(super) webview: WebView,
|
pub(super) webview: WebView,
|
||||||
pub(super) delegate: Rc<HostWebViewDelegate>,
|
pub(super) delegate: Rc<HostWebViewDelegate>,
|
||||||
pub(super) requested_url: Option<String>,
|
pub(super) requested_url: Option<String>,
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
//! Smoke test for the vendored hardware [`RenderingContext`].
|
|
||||||
//!
|
|
||||||
//! Runs only when the `hardware-render` feature is enabled. The test
|
|
||||||
//! degrades gracefully when the host machine lacks a hardware GL
|
|
||||||
//! adapter (CI sandboxes, no-GPU containers): construction returns
|
|
||||||
//! `Err`, the test logs the cause, and reports `ok` — proving the
|
|
||||||
//! vendored constructor is wired up correctly without falsely
|
|
||||||
//! marking the suite green when a GPU is actually expected and
|
|
||||||
//! missing. Inverting that check (turning a GPU-missing host into a
|
|
||||||
//! hard failure) is left for downstream CI configuration once the
|
|
||||||
//! hardware path is wired into the sidecar binary.
|
|
||||||
|
|
||||||
#![cfg(feature = "hardware-render")]
|
|
||||||
|
|
||||||
use dpi::PhysicalSize;
|
|
||||||
use ely_servo_host::HardwareOffscreenContext;
|
|
||||||
use servo::RenderingContext;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn constructs_or_explains_why_not() {
|
|
||||||
let size = PhysicalSize::new(640, 480);
|
|
||||||
match HardwareOffscreenContext::new(size) {
|
|
||||||
Ok(context) => {
|
|
||||||
// We don't drive Servo here — just confirm the vendored
|
|
||||||
// glue produced a live context. Construction is the
|
|
||||||
// failure mode this smoke test guards against; once a
|
|
||||||
// context exists the real Servo paint path exercises the
|
|
||||||
// rest of the surface.
|
|
||||||
drop(context);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!(
|
|
||||||
"hardware GL adapter not available on this host \
|
|
||||||
(acceptable in headless / no-GPU environments): {error:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn extracts_iosurface_mach_port_from_current_surface() -> Result<(), String> {
|
|
||||||
let width = 256;
|
|
||||||
let height = 192;
|
|
||||||
let context = match HardwareOffscreenContext::new(PhysicalSize::new(width, height)) {
|
|
||||||
Ok(context) => context,
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!(
|
|
||||||
"hardware GL adapter not available on this host \
|
|
||||||
(acceptable in headless / no-GPU environments): {error:?}"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
context.prepare_for_rendering();
|
|
||||||
context.present();
|
|
||||||
let first = context
|
|
||||||
.current_iosurface_mach_port()
|
|
||||||
.map_err(|error| format!("first IOSurface mach port extraction failed: {error:?}"))?;
|
|
||||||
assert!(
|
|
||||||
first.mach_port_name != 0,
|
|
||||||
"IOSurfaceCreateMachPort must return a non-null mach_port_t (got 0)"
|
|
||||||
);
|
|
||||||
assert_eq!(first.width, width, "reported width must match surface width");
|
|
||||||
assert_eq!(first.height, height, "reported height must match surface height");
|
|
||||||
|
|
||||||
// The unbind/rebind cycle must leave the context usable: a second
|
|
||||||
// call should still produce a valid mach port without panicking on
|
|
||||||
// a stale `Framebuffer::None`.
|
|
||||||
let second = context
|
|
||||||
.current_iosurface_mach_port()
|
|
||||||
.map_err(|error| format!("repeated mach port extraction failed: {error:?}"))?;
|
|
||||||
assert!(second.mach_port_name != 0);
|
|
||||||
assert_eq!(second.width, width);
|
|
||||||
assert_eq!(second.height, height);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,494 +0,0 @@
|
|||||||
//! Manual sidecar perf bench; ignored by normal CI.
|
|
||||||
#![cfg(feature = "servo-engine")]
|
|
||||||
|
|
||||||
#[path = "live_perf_bench/pixels.rs"]
|
|
||||||
mod pixels;
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
env,
|
|
||||||
error::Error,
|
|
||||||
fs,
|
|
||||||
io::{BufRead, BufReader, Read, Write},
|
|
||||||
path::PathBuf,
|
|
||||||
process::{Child, ChildStdin, ChildStdout, Command, Stdio},
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use ely_domain::{ProfileId, TabId};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
const DEFAULT_FRAMES: u32 = 240;
|
|
||||||
const VIEWPORT_WIDTH: u32 = 1024;
|
|
||||||
const VIEWPORT_HEIGHT: u32 = 768;
|
|
||||||
const SCROLL_STEP_PX: i32 = 4;
|
|
||||||
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20);
|
|
||||||
const SCROLL_PAGE_DATA_URL: &str = "data:text/html,\
|
|
||||||
<!doctype html><meta charset=utf-8><title>perf</title>\
|
|
||||||
<style>html,body{margin:0;padding:0}\
|
|
||||||
body{height:8000px;background:linear-gradient(180deg,red,teal,navy,white,crimson)}\
|
|
||||||
div.row{height:80px;border-bottom:2px solid rgba(0,0,0,.5);color:white;font:24px/80px sans-serif;padding-left:24px}\
|
|
||||||
</style>\
|
|
||||||
<script>for(let i=0;i<100;i++){let d=document.createElement('div');d.className='row';d.textContent='row '+i;document.body.appendChild(d)}</script>";
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
struct LiveResponse {
|
|
||||||
error: Option<String>,
|
|
||||||
frame: Option<LiveFrameReport>,
|
|
||||||
#[serde(default)]
|
|
||||||
perf: Option<FramePerfSummary>,
|
|
||||||
#[serde(default)]
|
|
||||||
surface_handle: Option<BenchSurfaceHandle>,
|
|
||||||
#[serde(default)]
|
|
||||||
current_surface_id: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone, Copy)]
|
|
||||||
struct BenchSurfaceHandle {
|
|
||||||
mach_port_name: u32,
|
|
||||||
surface_id: u64,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
struct LiveFrameReport {
|
|
||||||
rgba_byte_count: usize,
|
|
||||||
#[serde(default)]
|
|
||||||
width: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
height: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
device_pixel_ratio: f32,
|
|
||||||
#[serde(default)]
|
|
||||||
css_viewport_width: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
css_viewport_height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
|
||||||
struct FramePerfSummary {
|
|
||||||
window: u32,
|
|
||||||
context: String,
|
|
||||||
paint_p50_us: u64,
|
|
||||||
paint_p95_us: u64,
|
|
||||||
paint_p99_us: u64,
|
|
||||||
encode_p50_us: u64,
|
|
||||||
encode_p95_us: u64,
|
|
||||||
encode_p99_us: u64,
|
|
||||||
write_p50_us: u64,
|
|
||||||
write_p95_us: u64,
|
|
||||||
write_p99_us: u64,
|
|
||||||
total_p50_us: u64,
|
|
||||||
total_p95_us: u64,
|
|
||||||
total_p99_us: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "manual bench: spawns sidecar, scrolls a data: URL for N frames"]
|
|
||||||
fn run_live_bench() -> Result<(), Box<dyn Error>> {
|
|
||||||
let kind = env::var("ELY_PERF_KIND").unwrap_or_else(|_| "software".to_string());
|
|
||||||
let frames: u32 = env::var("ELY_PERF_FRAMES")
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.parse().ok())
|
|
||||||
.unwrap_or(DEFAULT_FRAMES);
|
|
||||||
let url = env::var("ELY_PERF_URL").unwrap_or_else(|_| SCROLL_PAGE_DATA_URL.to_string());
|
|
||||||
|
|
||||||
let profile_id = ProfileId::new();
|
|
||||||
let tab = TabId::new();
|
|
||||||
let profile_data_dir = env::temp_dir().join(format!(
|
|
||||||
"ely-perf-bench-{}-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
kind,
|
|
||||||
profile_id.as_str()
|
|
||||||
));
|
|
||||||
fs::create_dir_all(&profile_data_dir)?;
|
|
||||||
|
|
||||||
let mut child = spawn_sidecar(&kind, &profile_data_dir)?;
|
|
||||||
let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?;
|
|
||||||
let stdout = child.stdout.take().ok_or("sidecar stdout missing")?;
|
|
||||||
let mut reader = BufReader::new(stdout);
|
|
||||||
|
|
||||||
let outcome = match drive_bench(&mut stdin, &mut reader, &kind, &tab, &profile_id, &url, frames)
|
|
||||||
{
|
|
||||||
Ok(outcome) => outcome,
|
|
||||||
Err(error) => {
|
|
||||||
drop(stdin);
|
|
||||||
let _ = child.kill();
|
|
||||||
cleanup(&profile_data_dir)?;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
drop(stdin);
|
|
||||||
let _ = child.wait();
|
|
||||||
cleanup(&profile_data_dir)?;
|
|
||||||
|
|
||||||
print_summaries(&kind, frames, &outcome.summaries);
|
|
||||||
print_surface_handles(&kind, &outcome.surface_handles);
|
|
||||||
print_current_surface_summary(&kind, &outcome.current_surface_ids);
|
|
||||||
eprintln!(
|
|
||||||
"\n=== ELY_PERF_KIND={kind} readback_rgba_bytes={} surface_rgba_bytes={} ===",
|
|
||||||
outcome.readback_rgba_bytes, outcome.surface_rgba_bytes,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!outcome.summaries.is_empty(),
|
|
||||||
"expected at least one FramePerfSummary across {frames} frames"
|
|
||||||
);
|
|
||||||
if kind == "hardware" {
|
|
||||||
assert!(
|
|
||||||
!outcome.surface_handles.is_empty(),
|
|
||||||
"hardware live path must publish IOSurface handles"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!outcome.current_surface_ids.is_empty(),
|
|
||||||
"hardware live path must report current_surface_id selectors"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
assert!(
|
|
||||||
outcome.surface_handles.is_empty(),
|
|
||||||
"software path must never publish an IOSurface handle"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
outcome.current_surface_ids.is_empty(),
|
|
||||||
"software path must never report current_surface_id"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let viewport_bytes = (1024u64) * (768u64) * 4;
|
|
||||||
let total_rgba_bytes = outcome.readback_rgba_bytes + outcome.surface_rgba_bytes;
|
|
||||||
assert!(
|
|
||||||
total_rgba_bytes >= viewport_bytes,
|
|
||||||
"{kind} path delivered only {total_rgba_bytes} bytes — expected at least one full frame ({})",
|
|
||||||
viewport_bytes,
|
|
||||||
);
|
|
||||||
if kind == "hardware" {
|
|
||||||
let full_readback_budget = viewport_bytes * u64::from(frames);
|
|
||||||
assert_eq!(
|
|
||||||
outcome.readback_rgba_bytes, viewport_bytes,
|
|
||||||
"hardware path should read back only the initial visible frame"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
total_rgba_bytes < full_readback_budget,
|
|
||||||
"hardware path stayed on full readback: {total_rgba_bytes} >= {full_readback_budget}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BenchOutcome {
|
|
||||||
summaries: Vec<FramePerfSummary>,
|
|
||||||
surface_handles: Vec<BenchSurfaceHandle>,
|
|
||||||
current_surface_ids: Vec<u64>,
|
|
||||||
readback_rgba_bytes: u64,
|
|
||||||
surface_rgba_bytes: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_sidecar(kind: &str, profile_data_dir: &PathBuf) -> Result<Child, Box<dyn Error>> {
|
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
|
|
||||||
command
|
|
||||||
.arg("live")
|
|
||||||
.arg("--profile-data-dir")
|
|
||||||
.arg(profile_data_dir)
|
|
||||||
.arg("--rendering-context")
|
|
||||||
.arg(kind)
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::inherit());
|
|
||||||
Ok(command.spawn()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drive_bench(
|
|
||||||
stdin: &mut ChildStdin,
|
|
||||||
reader: &mut BufReader<ChildStdout>,
|
|
||||||
kind: &str,
|
|
||||||
tab: &TabId,
|
|
||||||
profile_id: &ProfileId,
|
|
||||||
url: &str,
|
|
||||||
frames: u32,
|
|
||||||
) -> Result<BenchOutcome, Box<dyn Error>> {
|
|
||||||
let mut summaries = Vec::new();
|
|
||||||
let mut surface_handles = Vec::new();
|
|
||||||
let mut current_surface_ids = Vec::new();
|
|
||||||
let mut readback_rgba_bytes: u64 = 0;
|
|
||||||
let mut surface_rgba_bytes: u64 = 0;
|
|
||||||
|
|
||||||
let navigate = build_ensure(tab, profile_id, url, 0, 0, false);
|
|
||||||
write_request(stdin, &navigate)?;
|
|
||||||
let response = read_response(reader, RESPONSE_TIMEOUT)?;
|
|
||||||
assert_frame_viewport_report(&response);
|
|
||||||
record_summary(&response, kind, &mut summaries);
|
|
||||||
record_surface_handle(&response, kind, &mut surface_handles);
|
|
||||||
record_current_surface_id(&response, &mut current_surface_ids);
|
|
||||||
record_rgba_bytes(&response, &mut readback_rgba_bytes, &mut surface_rgba_bytes);
|
|
||||||
|
|
||||||
let mut accumulated_scroll = 0;
|
|
||||||
let mut painted_frames = 0;
|
|
||||||
let max_attempts = frames.saturating_mul(4).max(frames + 10);
|
|
||||||
for attempt in 0..max_attempts {
|
|
||||||
if painted_frames >= frames {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let scroll_delta_y =
|
|
||||||
if painted_frames % 80 == 79 { -SCROLL_STEP_PX * 60 } else { SCROLL_STEP_PX };
|
|
||||||
accumulated_scroll += scroll_delta_y;
|
|
||||||
let request = build_ensure(tab, profile_id, url, 0, scroll_delta_y, true);
|
|
||||||
write_request(stdin, &request)?;
|
|
||||||
let response = read_response(reader, RESPONSE_TIMEOUT)?;
|
|
||||||
if let Some(error) = response.error.as_ref() {
|
|
||||||
return Err(format!("sidecar error at attempt {attempt}: {error}").into());
|
|
||||||
}
|
|
||||||
if response.frame.is_some() {
|
|
||||||
painted_frames += 1;
|
|
||||||
}
|
|
||||||
assert_frame_viewport_report(&response);
|
|
||||||
record_summary(&response, kind, &mut summaries);
|
|
||||||
record_surface_handle(&response, kind, &mut surface_handles);
|
|
||||||
record_current_surface_id(&response, &mut current_surface_ids);
|
|
||||||
record_rgba_bytes(&response, &mut readback_rgba_bytes, &mut surface_rgba_bytes);
|
|
||||||
}
|
|
||||||
assert_eq!(painted_frames, frames, "bench did not receive the requested painted frame count");
|
|
||||||
let _ = accumulated_scroll;
|
|
||||||
for _ in 0..5 {
|
|
||||||
if !summaries.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let poll = build_poll(tab);
|
|
||||||
write_request(stdin, &poll)?;
|
|
||||||
let response = read_response(reader, RESPONSE_TIMEOUT)?;
|
|
||||||
record_summary(&response, kind, &mut summaries);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(BenchOutcome {
|
|
||||||
summaries,
|
|
||||||
surface_handles,
|
|
||||||
current_surface_ids,
|
|
||||||
readback_rgba_bytes,
|
|
||||||
surface_rgba_bytes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_rgba_bytes(
|
|
||||||
response: &LiveResponse,
|
|
||||||
readback_rgba_bytes: &mut u64,
|
|
||||||
surface_rgba_bytes: &mut u64,
|
|
||||||
) {
|
|
||||||
let rgba_byte_count = response.frame.as_ref().map_or(0, |frame| frame.rgba_byte_count as u64);
|
|
||||||
if rgba_byte_count > 0 {
|
|
||||||
*readback_rgba_bytes += rgba_byte_count;
|
|
||||||
} else if response.current_surface_id.is_some() {
|
|
||||||
*surface_rgba_bytes += rgba_byte_count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_frame_viewport_report(response: &LiveResponse) {
|
|
||||||
let Some(frame) = response.frame.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let dpr = if frame.device_pixel_ratio.is_finite() && frame.device_pixel_ratio > 0.0 {
|
|
||||||
frame.device_pixel_ratio
|
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
};
|
|
||||||
let expected_width = ((frame.width as f32) / dpr).round().max(1.0) as u32;
|
|
||||||
let expected_height = ((frame.height as f32) / dpr).round().max(1.0) as u32;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
frame.css_viewport_width, expected_width,
|
|
||||||
"CSS viewport width must match physical width divided by DPR",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
frame.css_viewport_height, expected_height,
|
|
||||||
"CSS viewport height must match physical height divided by DPR",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_surface_handle(
|
|
||||||
response: &LiveResponse,
|
|
||||||
kind: &str,
|
|
||||||
surface_handles: &mut Vec<BenchSurfaceHandle>,
|
|
||||||
) {
|
|
||||||
if let Some(handle) = response.surface_handle {
|
|
||||||
eprintln!(
|
|
||||||
"[iosurface {kind}] new surface_id=0x{:x} mach_port=0x{:x} {}x{}",
|
|
||||||
handle.surface_id, handle.mach_port_name, handle.width, handle.height,
|
|
||||||
);
|
|
||||||
surface_handles.push(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_current_surface_id(response: &LiveResponse, current_surface_ids: &mut Vec<u64>) {
|
|
||||||
if let Some(surface_id) = response.current_surface_id {
|
|
||||||
current_surface_ids.push(surface_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_surface_handles(kind: &str, surface_handles: &[BenchSurfaceHandle]) {
|
|
||||||
eprintln!(
|
|
||||||
"\n=== ELY_PERF_KIND={kind} iosurface_imports={} (one per unique surface) ===",
|
|
||||||
surface_handles.len()
|
|
||||||
);
|
|
||||||
for (index, handle) in surface_handles.iter().enumerate() {
|
|
||||||
eprintln!(
|
|
||||||
"{:<4} surface_id=0x{:x} mach_port=0x{:x} {}x{}",
|
|
||||||
index, handle.surface_id, handle.mach_port_name, handle.width, handle.height,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_current_surface_summary(kind: &str, current_surface_ids: &[u64]) {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
let mut counts: BTreeMap<u64, u32> = BTreeMap::new();
|
|
||||||
for id in current_surface_ids {
|
|
||||||
*counts.entry(*id).or_default() += 1;
|
|
||||||
}
|
|
||||||
eprintln!("\n=== ELY_PERF_KIND={kind} current_surface_id histogram (per-frame selector) ===",);
|
|
||||||
for (surface_id, count) in counts.iter() {
|
|
||||||
eprintln!("surface_id=0x{:x} frames={}", surface_id, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_ensure(
|
|
||||||
tab: &TabId,
|
|
||||||
profile_id: &ProfileId,
|
|
||||||
url: &str,
|
|
||||||
scroll_dx: i32,
|
|
||||||
scroll_dy: i32,
|
|
||||||
include_hover: bool,
|
|
||||||
) -> String {
|
|
||||||
let hover_x = if include_hover { Some(256u32) } else { None };
|
|
||||||
let hover_y = if include_hover { Some(256u32) } else { None };
|
|
||||||
let scroll_point = if scroll_dx != 0 || scroll_dy != 0 { Some((256u32, 256u32)) } else { None };
|
|
||||||
let hover_x_json = match hover_x {
|
|
||||||
Some(value) => format!("{value}"),
|
|
||||||
None => "null".to_string(),
|
|
||||||
};
|
|
||||||
let hover_y_json = match hover_y {
|
|
||||||
Some(value) => format!("{value}"),
|
|
||||||
None => "null".to_string(),
|
|
||||||
};
|
|
||||||
let scroll_point_x_json = match scroll_point {
|
|
||||||
Some((x, _)) => format!("{x}"),
|
|
||||||
None => "null".to_string(),
|
|
||||||
};
|
|
||||||
let scroll_point_y_json = match scroll_point {
|
|
||||||
Some((_, y)) => format!("{y}"),
|
|
||||||
None => "null".to_string(),
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
r#"{{"type":"ensure","tab_id":"{tab}","profile_id":"{profile}","url":{url},"width":{w},"height":{h},"page_zoom_percent":100,"scroll_delta_x":{dx},"scroll_delta_y":{dy},"scroll_point_x":{sx},"scroll_point_y":{sy},"click_x":null,"click_y":null,"hover_x":{hx},"hover_y":{hy},"typed_text":null,"site_permissions":[]}}"#,
|
|
||||||
tab = tab.as_str(),
|
|
||||||
profile = profile_id.as_str(),
|
|
||||||
url = serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()),
|
|
||||||
w = VIEWPORT_WIDTH,
|
|
||||||
h = VIEWPORT_HEIGHT,
|
|
||||||
dx = scroll_dx,
|
|
||||||
dy = scroll_dy,
|
|
||||||
sx = scroll_point_x_json,
|
|
||||||
sy = scroll_point_y_json,
|
|
||||||
hx = hover_x_json,
|
|
||||||
hy = hover_y_json,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_poll(tab: &TabId) -> String {
|
|
||||||
format!(r#"{{"type":"poll","tab_id":"{}"}}"#, tab.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_request(stdin: &mut ChildStdin, request: &str) -> Result<(), Box<dyn Error>> {
|
|
||||||
stdin.write_all(request.as_bytes())?;
|
|
||||||
stdin.write_all(b"\n")?;
|
|
||||||
stdin.flush()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_response(
|
|
||||||
reader: &mut BufReader<ChildStdout>,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<LiveResponse, Box<dyn Error>> {
|
|
||||||
Ok(read_response_with_bytes(reader, timeout)?.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_response_with_bytes(
|
|
||||||
reader: &mut BufReader<ChildStdout>,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<(LiveResponse, Vec<u8>), Box<dyn Error>> {
|
|
||||||
let started_at = Instant::now();
|
|
||||||
let mut json_line = String::new();
|
|
||||||
loop {
|
|
||||||
json_line.clear();
|
|
||||||
let read_bytes = reader.read_line(&mut json_line)?;
|
|
||||||
if read_bytes == 0 {
|
|
||||||
return Err("sidecar closed stdout".into());
|
|
||||||
}
|
|
||||||
if json_line.trim().is_empty() {
|
|
||||||
if started_at.elapsed() >= timeout {
|
|
||||||
return Err("sidecar response timeout".into());
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(2));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let response: LiveResponse = serde_json::from_str(json_line.trim_end())?;
|
|
||||||
let mut rgba = Vec::new();
|
|
||||||
if let Some(frame) = response.frame.as_ref()
|
|
||||||
&& frame.rgba_byte_count > 0
|
|
||||||
{
|
|
||||||
rgba.resize(frame.rgba_byte_count, 0);
|
|
||||||
reader.read_exact(&mut rgba)?;
|
|
||||||
}
|
|
||||||
Ok((response, rgba))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_summary(response: &LiveResponse, kind: &str, summaries: &mut Vec<FramePerfSummary>) {
|
|
||||||
if let Some(perf) = response.perf.as_ref() {
|
|
||||||
assert_eq!(perf.context, kind, "sidecar context label must match requested kind");
|
|
||||||
eprintln!(
|
|
||||||
"[perf {kind}] window={} paint p50/p95/p99={}/{}/{} encode {}/{}/{} write {}/{}/{} total {}/{}/{} (µs)",
|
|
||||||
perf.window,
|
|
||||||
perf.paint_p50_us,
|
|
||||||
perf.paint_p95_us,
|
|
||||||
perf.paint_p99_us,
|
|
||||||
perf.encode_p50_us,
|
|
||||||
perf.encode_p95_us,
|
|
||||||
perf.encode_p99_us,
|
|
||||||
perf.write_p50_us,
|
|
||||||
perf.write_p95_us,
|
|
||||||
perf.write_p99_us,
|
|
||||||
perf.total_p50_us,
|
|
||||||
perf.total_p95_us,
|
|
||||||
perf.total_p99_us,
|
|
||||||
);
|
|
||||||
summaries.push(perf.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_summaries(kind: &str, frames: u32, summaries: &[FramePerfSummary]) {
|
|
||||||
eprintln!("\n=== ELY_PERF_KIND={kind} frames={frames} windows={} ===", summaries.len());
|
|
||||||
for summary in summaries {
|
|
||||||
eprintln!(
|
|
||||||
"win={} paint={}/{}/{} encode={}/{}/{} write={}/{}/{} total={}/{}/{}",
|
|
||||||
summary.window,
|
|
||||||
summary.paint_p50_us,
|
|
||||||
summary.paint_p95_us,
|
|
||||||
summary.paint_p99_us,
|
|
||||||
summary.encode_p50_us,
|
|
||||||
summary.encode_p95_us,
|
|
||||||
summary.encode_p99_us,
|
|
||||||
summary.write_p50_us,
|
|
||||||
summary.write_p95_us,
|
|
||||||
summary.write_p99_us,
|
|
||||||
summary.total_p50_us,
|
|
||||||
summary.total_p95_us,
|
|
||||||
summary.total_p99_us,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanup(profile_data_dir: &PathBuf) -> Result<(), Box<dyn Error>> {
|
|
||||||
match fs::remove_dir_all(profile_data_dir) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
use std::{
|
|
||||||
env,
|
|
||||||
error::Error,
|
|
||||||
fs,
|
|
||||||
io::BufReader,
|
|
||||||
process::{ChildStdin, ChildStdout},
|
|
||||||
};
|
|
||||||
|
|
||||||
use ely_domain::{ProfileId, TabId};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
RESPONSE_TIMEOUT, build_ensure, cleanup, read_response_with_bytes, spawn_sidecar, write_request,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOLID_RED_DATA_URL: &str =
|
|
||||||
"data:text/html,<body style=\"margin:0;background:%23ff0000;height:4000px\">";
|
|
||||||
const SOLID_BLUE_DATA_URL: &str =
|
|
||||||
"data:text/html,<body style=\"margin:0;background:%230000ff;height:4000px\">";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"]
|
|
||||||
fn red_data_url_yields_red_rgba() -> Result<(), Box<dyn Error>> {
|
|
||||||
assert_solid_color_renders("software", SOLID_RED_DATA_URL, ColorTarget::Red)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"]
|
|
||||||
fn blue_data_url_yields_blue_rgba() -> Result<(), Box<dyn Error>> {
|
|
||||||
assert_solid_color_renders("software", SOLID_BLUE_DATA_URL, ColorTarget::Blue)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum ColorTarget {
|
|
||||||
Red,
|
|
||||||
Blue,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ColorTarget {
|
|
||||||
fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ColorTarget::Red => "red",
|
|
||||||
ColorTarget::Blue => "blue",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_solid_color_renders(
|
|
||||||
kind: &str,
|
|
||||||
url: &str,
|
|
||||||
target: ColorTarget,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let profile_id = ProfileId::new();
|
|
||||||
let tab = TabId::new();
|
|
||||||
let profile_data_dir = env::temp_dir().join(format!(
|
|
||||||
"ely-pixel-{}-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
target.label(),
|
|
||||||
profile_id.as_str(),
|
|
||||||
));
|
|
||||||
fs::create_dir_all(&profile_data_dir)?;
|
|
||||||
|
|
||||||
let mut child = spawn_sidecar(kind, &profile_data_dir)?;
|
|
||||||
let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?;
|
|
||||||
let stdout = child.stdout.take().ok_or("sidecar stdout missing")?;
|
|
||||||
let mut reader = BufReader::new(stdout);
|
|
||||||
let outcome = drive_solid_color_render(&mut stdin, &mut reader, &tab, &profile_id, url, target);
|
|
||||||
|
|
||||||
drop(stdin);
|
|
||||||
let _ = child.wait();
|
|
||||||
cleanup(&profile_data_dir)?;
|
|
||||||
outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drive_solid_color_render(
|
|
||||||
stdin: &mut ChildStdin,
|
|
||||||
reader: &mut BufReader<ChildStdout>,
|
|
||||||
tab: &TabId,
|
|
||||||
profile_id: &ProfileId,
|
|
||||||
url: &str,
|
|
||||||
target: ColorTarget,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
let mut report = None;
|
|
||||||
for iteration in 0..30 {
|
|
||||||
let scroll_y = if iteration == 0 {
|
|
||||||
0
|
|
||||||
} else if iteration % 2 == 1 {
|
|
||||||
1
|
|
||||||
} else {
|
|
||||||
-1
|
|
||||||
};
|
|
||||||
let request = build_ensure(tab, profile_id, url, 0, scroll_y, false);
|
|
||||||
write_request(stdin, &request)?;
|
|
||||||
let (response, response_bytes) = read_response_with_bytes(reader, RESPONSE_TIMEOUT)?;
|
|
||||||
if let Some(error) = response.error.as_ref() {
|
|
||||||
return Err(format!("sidecar error: {error}").into());
|
|
||||||
}
|
|
||||||
if let Some(frame_report) = response.frame {
|
|
||||||
if !response_bytes.is_empty()
|
|
||||||
&& sample_matches_target(
|
|
||||||
&response_bytes,
|
|
||||||
frame_report.width,
|
|
||||||
frame_report.height,
|
|
||||||
target,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
report = Some(frame_report);
|
|
||||||
bytes = response_bytes;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if !response_bytes.is_empty() {
|
|
||||||
bytes = response_bytes;
|
|
||||||
report = Some(frame_report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let report = report.ok_or("never received a frame with bytes")?;
|
|
||||||
let width = report.width as usize;
|
|
||||||
let height = report.height as usize;
|
|
||||||
assert_eq!(bytes.len(), width * height * 4, "rgba byte count must match width * height * 4",);
|
|
||||||
|
|
||||||
let mut samples = Vec::new();
|
|
||||||
for fy in [1, 2, 3] {
|
|
||||||
for fx in [1, 2, 3] {
|
|
||||||
let x = width * fx / 4;
|
|
||||||
let y = height * fy / 4;
|
|
||||||
let idx = (y * width + x) * 4;
|
|
||||||
samples.push((x, y, bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eprintln!("[pixel sample {}] {:?}", target.label(), samples);
|
|
||||||
|
|
||||||
let hits =
|
|
||||||
samples.iter().filter(|(_x, _y, r, g, b, _a)| matches_color(*r, *g, *b, target)).count();
|
|
||||||
assert!(
|
|
||||||
hits >= 5,
|
|
||||||
"expected at least 5/9 center-quadrant pixels to be {} after rendering {}; got samples {:?}",
|
|
||||||
target.label(),
|
|
||||||
url,
|
|
||||||
samples,
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_matches_target(bytes: &[u8], width: u32, height: u32, target: ColorTarget) -> bool {
|
|
||||||
let w = width as usize;
|
|
||||||
let h = height as usize;
|
|
||||||
if bytes.len() < w * h * 4 || w == 0 || h == 0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let cx = w / 2;
|
|
||||||
let cy = h / 2;
|
|
||||||
let idx = (cy * w + cx) * 4;
|
|
||||||
matches_color(bytes[idx], bytes[idx + 1], bytes[idx + 2], target)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_color(r: u8, g: u8, b: u8, target: ColorTarget) -> bool {
|
|
||||||
match target {
|
|
||||||
ColorTarget::Red => r >= 200 && g <= 60 && b <= 60,
|
|
||||||
ColorTarget::Blue => r <= 60 && g <= 60 && b >= 200,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
#![cfg(feature = "servo-engine")]
|
|
||||||
|
|
||||||
use std::{collections::BTreeSet, error::Error, fs, path::PathBuf, process::Command};
|
|
||||||
|
|
||||||
use ely_domain::ProfileId;
|
|
||||||
|
|
||||||
#[path = "sidecar/site_cases.rs"]
|
|
||||||
mod site_cases;
|
|
||||||
#[path = "sidecar/support.rs"]
|
|
||||||
mod support;
|
|
||||||
|
|
||||||
use support::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_prd_reference_cases_cover_prd_urls() -> Result<(), Box<dyn Error>> {
|
|
||||||
let prd = fs::read_to_string(prd_path())?;
|
|
||||||
let prd_urls = prd_reference_urls(&prd);
|
|
||||||
let covered_urls = PRD_REFERENCE_SITE_COMPATIBILITY_CASES
|
|
||||||
.iter()
|
|
||||||
.map(|case| normalized_url(case.url))
|
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
let missing_urls = prd_urls
|
|
||||||
.iter()
|
|
||||||
.filter(|url| !covered_urls.contains(url.as_str()))
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
assert!(missing_urls.is_empty(), "missing PRD sidecar smoke cases: {missing_urls:?}");
|
|
||||||
assert_eq!(prd_urls.len(), PRD_REFERENCE_SITE_COMPATIBILITY_CASES.len());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_opens_and_renders_prd_sites_to_rgba_files() -> Result<(), Box<dyn Error>> {
|
|
||||||
for case in PRD_SITE_COMPATIBILITY_CASES {
|
|
||||||
for size in PRD_SITE_COMPATIBILITY_SIZES {
|
|
||||||
snapshot_prd_site(case, *size, ScrollOffset::ZERO)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_report_uses_requested_profile_id() -> Result<(), Box<dyn Error>> {
|
|
||||||
let profile_id = ProfileId::new();
|
|
||||||
let profile_data_dir = std::env::temp_dir().join(format!(
|
|
||||||
"ely-servo-sidecar-profile-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
profile_id.as_str()
|
|
||||||
));
|
|
||||||
let rgba_path = std::env::temp_dir().join(format!(
|
|
||||||
"ely-servo-sidecar-profile-test-{}-{}.rgba",
|
|
||||||
std::process::id(),
|
|
||||||
profile_id.as_str()
|
|
||||||
));
|
|
||||||
|
|
||||||
let output = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"))
|
|
||||||
.arg("snapshot")
|
|
||||||
.arg("--url")
|
|
||||||
.arg("data:text/html,%3Ctitle%3EProfile%20Probe%3C%2Ftitle%3EProfile%20Probe")
|
|
||||||
.arg("--profile-id")
|
|
||||||
.arg(profile_id.as_str())
|
|
||||||
.arg("--profile-data-dir")
|
|
||||||
.arg(&profile_data_dir)
|
|
||||||
.arg("--rgba-out")
|
|
||||||
.arg(&rgba_path)
|
|
||||||
.arg("--width")
|
|
||||||
.arg("64")
|
|
||||||
.arg("--height")
|
|
||||||
.arg("64")
|
|
||||||
.output()?;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stdout: {}\nstderr: {}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
|
||||||
assert_eq!(
|
|
||||||
report.get("profile_id").and_then(serde_json::Value::as_str),
|
|
||||||
Some(profile_id.as_str())
|
|
||||||
);
|
|
||||||
|
|
||||||
remove_file_if_present(rgba_path)?;
|
|
||||||
remove_dir_if_present(profile_data_dir)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prd_path() -> PathBuf {
|
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("PRD.md")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_file_if_present(path: PathBuf) -> Result<(), Box<dyn Error>> {
|
|
||||||
match fs::remove_file(path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_dir_if_present(path: PathBuf) -> Result<(), Box<dyn Error>> {
|
|
||||||
match fs::remove_dir_all(path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prd_reference_urls(prd: &str) -> Vec<String> {
|
|
||||||
prd.lines()
|
|
||||||
.filter(|line| line.starts_with("[R"))
|
|
||||||
.filter_map(|line| {
|
|
||||||
let start = line.find("https://")?;
|
|
||||||
let url = line[start..].split_whitespace().next()?;
|
|
||||||
Some(normalized_url(url))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalized_url(url: &str) -> String {
|
|
||||||
url.trim().trim_end_matches('/').to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_opens_and_renders_prd_reference_sites_to_rgba_files() -> Result<(), Box<dyn Error>> {
|
|
||||||
for case in PRD_REFERENCE_SITE_COMPATIBILITY_CASES {
|
|
||||||
snapshot_prd_site(case, PRD_REFERENCE_SITE_SIZE, ScrollOffset::ZERO)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_scrolls_prd_site_with_servo_input() -> Result<(), Box<dyn Error>> {
|
|
||||||
let scrolled_report =
|
|
||||||
snapshot_prd_site(&SERVO_SCROLL_SITE, SERVO_SCROLL_SIZE, SERVO_SCROLL_OFFSET)?;
|
|
||||||
|
|
||||||
assert_eq!(report_field_as_i64(&scrolled_report, "scroll_x")?, SERVO_SCROLL_OFFSET.x);
|
|
||||||
assert_eq!(report_field_as_i64(&scrolled_report, "scroll_y")?, SERVO_SCROLL_OFFSET.y);
|
|
||||||
assert_eq!(report_field_as_u64(&scrolled_report, "width")?, SERVO_SCROLL_SIZE.width);
|
|
||||||
assert!(report_field_as_bool(&scrolled_report, "scroll_changed_frame")?);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_clicks_page_with_servo_mouse_input() -> Result<(), Box<dyn Error>> {
|
|
||||||
let initial_report = snapshot_click_probe(None)?;
|
|
||||||
let clicked_report = snapshot_click_probe(Some(SERVO_CLICK_POINT))?;
|
|
||||||
|
|
||||||
assert_eq!(report_field_as_u64(&clicked_report, "click_x")?, SERVO_CLICK_POINT.x);
|
|
||||||
assert_eq!(report_field_as_u64(&clicked_report, "click_y")?, SERVO_CLICK_POINT.y);
|
|
||||||
assert!(report_field_as_bool(&clicked_report, "click_changed_frame")?);
|
|
||||||
assert_ne!(
|
|
||||||
report_field_as_u64(&initial_report, "sample_hash")?,
|
|
||||||
report_field_as_u64(&clicked_report, "sample_hash")?
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_drags_page_with_servo_mouse_input() -> Result<(), Box<dyn Error>> {
|
|
||||||
let initial_report = snapshot_drag_probe(None)?;
|
|
||||||
let drag_points = DragPoints { from: SERVO_DRAG_FROM, to: SERVO_DRAG_TO };
|
|
||||||
let dragged_report = snapshot_drag_probe(Some(drag_points))?;
|
|
||||||
|
|
||||||
assert_eq!(report_field_as_u64(&dragged_report, "drag_from_x")?, SERVO_DRAG_FROM.x);
|
|
||||||
assert_eq!(report_field_as_u64(&dragged_report, "drag_from_y")?, SERVO_DRAG_FROM.y);
|
|
||||||
assert_eq!(report_field_as_u64(&dragged_report, "drag_to_x")?, SERVO_DRAG_TO.x);
|
|
||||||
assert_eq!(report_field_as_u64(&dragged_report, "drag_to_y")?, SERVO_DRAG_TO.y);
|
|
||||||
assert!(report_field_as_bool(&dragged_report, "drag_changed_frame")?);
|
|
||||||
assert_ne!(
|
|
||||||
report_field_as_u64(&initial_report, "sample_hash")?,
|
|
||||||
report_field_as_u64(&dragged_report, "sample_hash")?
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_touches_page_with_servo_touch_input() -> Result<(), Box<dyn Error>> {
|
|
||||||
let initial_report = snapshot_touch_probe(None)?;
|
|
||||||
let touched_report = snapshot_touch_probe(Some(SERVO_TOUCH_POINT))?;
|
|
||||||
|
|
||||||
assert_eq!(report_field_as_u64(&touched_report, "touch_x")?, SERVO_TOUCH_POINT.x);
|
|
||||||
assert_eq!(report_field_as_u64(&touched_report, "touch_y")?, SERVO_TOUCH_POINT.y);
|
|
||||||
assert!(report_field_as_bool(&touched_report, "touch_changed_frame")?);
|
|
||||||
assert_ne!(
|
|
||||||
report_field_as_u64(&initial_report, "sample_hash")?,
|
|
||||||
report_field_as_u64(&touched_report, "sample_hash")?
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_types_text_with_servo_keyboard_input() -> Result<(), Box<dyn Error>> {
|
|
||||||
let initial_report = snapshot_text_probe(None)?;
|
|
||||||
let typed_report = snapshot_text_probe(Some(SERVO_TEXT_VALUE))?;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
report_field_as_u64(&typed_report, "typed_text_byte_count")?,
|
|
||||||
SERVO_TEXT_VALUE.len() as u64
|
|
||||||
);
|
|
||||||
assert!(report_field_as_bool(&typed_report, "text_changed_frame")?);
|
|
||||||
assert_ne!(
|
|
||||||
report_field_as_u64(&initial_report, "sample_hash")?,
|
|
||||||
report_field_as_u64(&typed_report, "sample_hash")?
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
pub(super) const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
|
|
||||||
PrdSiteCompatibilityCase { url: "https://github.com", title_fragment: "GitHub" },
|
|
||||||
PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" },
|
|
||||||
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" },
|
|
||||||
];
|
|
||||||
|
|
||||||
pub(super) const PRD_REFERENCE_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://blog.google/products-and-platforms/products/chrome/new-chrome-productivity-features/",
|
|
||||||
title_fragment: "Chrome",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://www.microsoft.com/en-us/edge/features/vertical-tabs",
|
|
||||||
title_fragment: "Microsoft Edge",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://resources.arc.net/hc/en-us/articles/19230755904151-Favorites-Top-Tabs-Across-Every-Space",
|
|
||||||
title_fragment: "Favorites",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://resources.arc.net/hc/en-us/articles/19228855311127-Auto-Archive-Clean-as-you-go",
|
|
||||||
title_fragment: "Auto Archive",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://vivaldi.com/features/workspaces/",
|
|
||||||
title_fragment: "Workspaces",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://help.vivaldi.com/desktop/tabs/tab-tiling/",
|
|
||||||
title_fragment: "Tab Tiling",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase { url: "https://www.gpui.rs/", title_fragment: "gpui" },
|
|
||||||
PrdSiteCompatibilityCase { url: "https://docs.rs/gpui", title_fragment: "gpui" },
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://zed.dev/blog/videogame",
|
|
||||||
title_fragment: "Leveraging Rust",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://github.com/longbridge/gpui-component/",
|
|
||||||
title_fragment: "gpui-component",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://github.com/zed-industries/awesome-gpui/",
|
|
||||||
title_fragment: "awesome-gpui",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" },
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://servo.org/blog/2026/04/13/servo-0.1.0-release/",
|
|
||||||
title_fragment: "Servo",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://developers.cloudflare.com/d1/",
|
|
||||||
title_fragment: "Cloudflare",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://developers.cloudflare.com/workers/platform/storage-options/",
|
|
||||||
title_fragment: "Cloudflare",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://developers.cloudflare.com/kv/concepts/how-kv-works/",
|
|
||||||
title_fragment: "Cloudflare",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://better-auth.com/blog/1-5",
|
|
||||||
title_fragment: "Better Auth",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://developers.cloudflare.com/d1/platform/limits/",
|
|
||||||
title_fragment: "Cloudflare",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://component-model.bytecodealliance.org/",
|
|
||||||
title_fragment: "WebAssembly Component Model",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://docs.wasmtime.dev/api/wasmtime/component/index.html",
|
|
||||||
title_fragment: "wasmtime",
|
|
||||||
},
|
|
||||||
PrdSiteCompatibilityCase {
|
|
||||||
url: "https://docs.wasmtime.dev/security.html",
|
|
||||||
title_fragment: "Wasmtime",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
pub(super) const PRD_SITE_COMPATIBILITY_SIZES: &[FrameSize] = &[
|
|
||||||
FrameSize { width: 640, height: 480 },
|
|
||||||
FrameSize { width: 934, height: 657 },
|
|
||||||
FrameSize { width: 1614, height: 980 },
|
|
||||||
];
|
|
||||||
pub(super) const PRD_REFERENCE_SITE_SIZE: FrameSize = FrameSize { width: 934, height: 657 };
|
|
||||||
pub(super) const SERVO_SCROLL_SITE: PrdSiteCompatibilityCase =
|
|
||||||
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" };
|
|
||||||
pub(super) const SERVO_SCROLL_SIZE: FrameSize = FrameSize { width: 934, height: 657 };
|
|
||||||
pub(super) const SERVO_SCROLL_OFFSET: ScrollOffset = ScrollOffset { x: 0, y: 480 };
|
|
||||||
pub(super) const SERVO_CLICK_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E";
|
|
||||||
pub(super) const SERVO_CLICK_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
|
|
||||||
pub(super) const SERVO_CLICK_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
|
|
||||||
pub(super) const SERVO_DRAG_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
|
|
||||||
pub(super) const SERVO_DRAG_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
|
|
||||||
pub(super) const SERVO_DRAG_FROM: ClickPoint = ClickPoint { x: 160, y: 120 };
|
|
||||||
pub(super) const SERVO_DRAG_TO: ClickPoint = ClickPoint { x: 320, y: 120 };
|
|
||||||
pub(super) const SERVO_TOUCH_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
|
|
||||||
pub(super) const SERVO_TOUCH_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
|
|
||||||
pub(super) const SERVO_TOUCH_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
|
|
||||||
pub(super) const SERVO_TEXT_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E";
|
|
||||||
pub(super) const SERVO_TEXT_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
|
|
||||||
pub(super) const SERVO_TEXT_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
|
|
||||||
pub(super) const SERVO_TEXT_VALUE: &str = "ely42";
|
|
||||||
|
|
||||||
pub(super) struct PrdSiteCompatibilityCase {
|
|
||||||
pub(super) url: &'static str,
|
|
||||||
pub(super) title_fragment: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) struct FrameSize {
|
|
||||||
pub(super) width: u64,
|
|
||||||
pub(super) height: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) struct ScrollOffset {
|
|
||||||
pub(super) x: i64,
|
|
||||||
pub(super) y: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScrollOffset {
|
|
||||||
pub(super) const ZERO: Self = Self { x: 0, y: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) struct ClickPoint {
|
|
||||||
pub(super) x: u64,
|
|
||||||
pub(super) y: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) struct DragPoints {
|
|
||||||
pub(super) from: ClickPoint,
|
|
||||||
pub(super) to: ClickPoint,
|
|
||||||
}
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
use std::{
|
|
||||||
error::Error,
|
|
||||||
io,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
process::{Child, Command, Output, Stdio},
|
|
||||||
sync::Mutex,
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use ely_domain::ProfileId;
|
|
||||||
|
|
||||||
pub(super) use super::site_cases::{
|
|
||||||
ClickPoint, DragPoints, FrameSize, PRD_REFERENCE_SITE_COMPATIBILITY_CASES,
|
|
||||||
PRD_REFERENCE_SITE_SIZE, PRD_SITE_COMPATIBILITY_CASES, PRD_SITE_COMPATIBILITY_SIZES,
|
|
||||||
PrdSiteCompatibilityCase, SERVO_CLICK_POINT, SERVO_DRAG_FROM, SERVO_DRAG_TO,
|
|
||||||
SERVO_SCROLL_OFFSET, SERVO_SCROLL_SITE, SERVO_SCROLL_SIZE, SERVO_TEXT_VALUE, SERVO_TOUCH_POINT,
|
|
||||||
ScrollOffset,
|
|
||||||
};
|
|
||||||
use super::site_cases::{
|
|
||||||
SERVO_CLICK_SIZE, SERVO_CLICK_URL, SERVO_DRAG_SIZE, SERVO_DRAG_URL, SERVO_TEXT_POINT,
|
|
||||||
SERVO_TEXT_SIZE, SERVO_TEXT_URL, SERVO_TOUCH_SIZE, SERVO_TOUCH_URL,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(super) const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
|
||||||
const SIDECAR_TIMEOUT: Duration = Duration::from_secs(45);
|
|
||||||
const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20);
|
|
||||||
const SIDECAR_COMMAND_COOLDOWN: Duration = Duration::from_millis(750);
|
|
||||||
const SIDECAR_RETRY_INTERVAL: Duration = Duration::from_millis(250);
|
|
||||||
const SIDECAR_MAX_ATTEMPTS: usize = 3;
|
|
||||||
static SIDECAR_COMMAND_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
|
||||||
struct SnapshotInput<'a> {
|
|
||||||
click_point: Option<ClickPoint>,
|
|
||||||
drag_points: Option<DragPoints>,
|
|
||||||
touch_point: Option<ClickPoint>,
|
|
||||||
typed_text: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn snapshot_prd_site(
|
|
||||||
case: &PrdSiteCompatibilityCase,
|
|
||||||
size: FrameSize,
|
|
||||||
scroll_offset: ScrollOffset,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
|
||||||
let site_name = case
|
|
||||||
.url
|
|
||||||
.chars()
|
|
||||||
.map(|character| if character.is_ascii_alphanumeric() { character } else { '-' })
|
|
||||||
.collect::<String>();
|
|
||||||
let output_path = std::env::temp_dir().join(format!(
|
|
||||||
"ely-servo-sidecar-{}-{site_name}-{}x{}-{}-{}.rgba",
|
|
||||||
std::process::id(),
|
|
||||||
size.width,
|
|
||||||
size.height,
|
|
||||||
scroll_offset.x,
|
|
||||||
scroll_offset.y
|
|
||||||
));
|
|
||||||
|
|
||||||
snapshot_prd_site_with_retry(case, &output_path, size, scroll_offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_prd_site_with_retry(
|
|
||||||
case: &PrdSiteCompatibilityCase,
|
|
||||||
output_path: &Path,
|
|
||||||
size: FrameSize,
|
|
||||||
scroll_offset: ScrollOffset,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
|
||||||
for attempt in 0..SIDECAR_MAX_ATTEMPTS {
|
|
||||||
match snapshot_prd_site_once(case, output_path, size, scroll_offset) {
|
|
||||||
Ok(report) => return Ok(report),
|
|
||||||
Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error),
|
|
||||||
Err(_) => remove_file_if_present(output_path)?,
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(SIDECAR_RETRY_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err("sidecar PRD snapshot retry did not produce output".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_prd_site_once(
|
|
||||||
case: &PrdSiteCompatibilityCase,
|
|
||||||
output_path: &Path,
|
|
||||||
size: FrameSize,
|
|
||||||
scroll_offset: ScrollOffset,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
|
||||||
let output = run_sidecar_snapshot_with_retry(
|
|
||||||
case.url,
|
|
||||||
output_path,
|
|
||||||
size,
|
|
||||||
scroll_offset,
|
|
||||||
SnapshotInput::default(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"{} {}x{}\nstatus: {:?}\nstdout: {}\nstderr: {}",
|
|
||||||
case.url,
|
|
||||||
size.width,
|
|
||||||
size.height,
|
|
||||||
output.status.code(),
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
|
||||||
assert_report_state_is_renderable(&report)?;
|
|
||||||
assert_eq!(report_field_as_u64(&report, "width")?, size.width, "{}", case.url);
|
|
||||||
assert_eq!(report_field_as_u64(&report, "height")?, size.height, "{}", case.url);
|
|
||||||
assert_eq!(
|
|
||||||
report_field_as_u64(&report, "rgba_byte_count")?,
|
|
||||||
size.width * size.height * 4,
|
|
||||||
"{}",
|
|
||||||
case.url
|
|
||||||
);
|
|
||||||
assert_report_text_contains(&report, "loaded_url", case.url)?;
|
|
||||||
assert_report_text_equals(&report, "requested_url", case.url)?;
|
|
||||||
assert_report_text_contains(&report, "title", case.title_fragment)?;
|
|
||||||
assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{}", case.url);
|
|
||||||
assert!(
|
|
||||||
report_field_as_u64(&report, "content_pixel_count")? >= MINIMUM_CONTENT_PIXELS,
|
|
||||||
"{}",
|
|
||||||
case.url
|
|
||||||
);
|
|
||||||
assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", case.url);
|
|
||||||
assert_eq!(std::fs::metadata(output_path)?.len(), size.width * size.height * 4);
|
|
||||||
|
|
||||||
log_prd_report(&report, case, size)?;
|
|
||||||
|
|
||||||
std::fs::remove_file(output_path)?;
|
|
||||||
Ok(report)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_file_if_present(path: &Path) -> Result<(), Box<dyn Error>> {
|
|
||||||
match std::fs::remove_file(path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn snapshot_click_probe(
|
|
||||||
click_point: Option<ClickPoint>,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
|
||||||
snapshot_probe(
|
|
||||||
SERVO_CLICK_URL,
|
|
||||||
SERVO_CLICK_SIZE,
|
|
||||||
"click",
|
|
||||||
SnapshotInput { click_point, ..SnapshotInput::default() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn snapshot_drag_probe(
|
|
||||||
drag_points: Option<DragPoints>,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
|
||||||
snapshot_probe(
|
|
||||||
SERVO_DRAG_URL,
|
|
||||||
SERVO_DRAG_SIZE,
|
|
||||||
"drag",
|
|
||||||
SnapshotInput { drag_points, ..SnapshotInput::default() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn snapshot_touch_probe(
|
|
||||||
touch_point: Option<ClickPoint>,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
|
||||||
snapshot_probe(
|
|
||||||
SERVO_TOUCH_URL,
|
|
||||||
SERVO_TOUCH_SIZE,
|
|
||||||
"touch",
|
|
||||||
SnapshotInput { touch_point, ..SnapshotInput::default() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn snapshot_text_probe(
|
|
||||||
typed_text: Option<&str>,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
|
||||||
snapshot_probe(
|
|
||||||
SERVO_TEXT_URL,
|
|
||||||
SERVO_TEXT_SIZE,
|
|
||||||
"text",
|
|
||||||
SnapshotInput {
|
|
||||||
click_point: typed_text.map(|_| SERVO_TEXT_POINT),
|
|
||||||
typed_text,
|
|
||||||
..SnapshotInput::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_probe(
|
|
||||||
url: &str,
|
|
||||||
size: FrameSize,
|
|
||||||
label: &'static str,
|
|
||||||
input: SnapshotInput<'_>,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
|
||||||
let output_path = std::env::temp_dir().join(format!(
|
|
||||||
"ely-servo-sidecar-{}-{label}-{}x{}.rgba",
|
|
||||||
std::process::id(),
|
|
||||||
size.width,
|
|
||||||
size.height
|
|
||||||
));
|
|
||||||
|
|
||||||
if output_path.exists() {
|
|
||||||
std::fs::remove_file(&output_path)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let output =
|
|
||||||
run_sidecar_snapshot_with_retry(url, &output_path, size, ScrollOffset::ZERO, input)?;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"{label} probe\nstatus: {:?}\nstdout: {}\nstderr: {}",
|
|
||||||
output.status.code(),
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
|
||||||
assert_eq!(report_field_as_u64(&report, "width")?, size.width);
|
|
||||||
assert_eq!(report_field_as_u64(&report, "height")?, size.height);
|
|
||||||
assert!(report_field_as_u64(&report, "content_pixel_count")? > 0);
|
|
||||||
assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4);
|
|
||||||
|
|
||||||
std::fs::remove_file(&output_path)?;
|
|
||||||
Ok(report)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_sidecar_snapshot(
|
|
||||||
site_url: &str,
|
|
||||||
output_path: &Path,
|
|
||||||
size: FrameSize,
|
|
||||||
scroll_offset: ScrollOffset,
|
|
||||||
input: SnapshotInput<'_>,
|
|
||||||
) -> Result<Output, Box<dyn Error>> {
|
|
||||||
let _guard = SIDECAR_COMMAND_LOCK
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| io::Error::other("sidecar command lock poisoned"))?;
|
|
||||||
let profile_id = ProfileId::new();
|
|
||||||
let profile_data_dir = temporary_profile_data_dir(&profile_id);
|
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
|
|
||||||
command
|
|
||||||
.arg("snapshot")
|
|
||||||
.arg("--url")
|
|
||||||
.arg(site_url)
|
|
||||||
.arg("--profile-id")
|
|
||||||
.arg(profile_id.as_str())
|
|
||||||
.arg("--profile-data-dir")
|
|
||||||
.arg(&profile_data_dir)
|
|
||||||
.arg("--rgba-out")
|
|
||||||
.arg(output_path)
|
|
||||||
.arg("--width")
|
|
||||||
.arg(size.width.to_string())
|
|
||||||
.arg("--height")
|
|
||||||
.arg(size.height.to_string());
|
|
||||||
if scroll_offset.x != 0 {
|
|
||||||
command.arg("--scroll-x").arg(scroll_offset.x.to_string());
|
|
||||||
}
|
|
||||||
if scroll_offset.y != 0 {
|
|
||||||
command.arg("--scroll-y").arg(scroll_offset.y.to_string());
|
|
||||||
}
|
|
||||||
if let Some(click_point) = input.click_point {
|
|
||||||
command.arg("--click-x").arg(click_point.x.to_string());
|
|
||||||
command.arg("--click-y").arg(click_point.y.to_string());
|
|
||||||
}
|
|
||||||
if let Some(drag_points) = input.drag_points {
|
|
||||||
command.arg("--drag-from-x").arg(drag_points.from.x.to_string());
|
|
||||||
command.arg("--drag-from-y").arg(drag_points.from.y.to_string());
|
|
||||||
command.arg("--drag-to-x").arg(drag_points.to.x.to_string());
|
|
||||||
command.arg("--drag-to-y").arg(drag_points.to.y.to_string());
|
|
||||||
}
|
|
||||||
if let Some(touch_point) = input.touch_point {
|
|
||||||
command.arg("--touch-x").arg(touch_point.x.to_string());
|
|
||||||
command.arg("--touch-y").arg(touch_point.y.to_string());
|
|
||||||
}
|
|
||||||
if let Some(typed_text) = input.typed_text {
|
|
||||||
command.arg("--type-text").arg(typed_text);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut child = command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
|
|
||||||
let started_at = Instant::now();
|
|
||||||
loop {
|
|
||||||
if child.try_wait()?.is_some() {
|
|
||||||
let output = child.wait_with_output()?;
|
|
||||||
remove_temporary_dir(&profile_data_dir)?;
|
|
||||||
thread::sleep(SIDECAR_COMMAND_COOLDOWN);
|
|
||||||
return Ok(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
if started_at.elapsed() >= SIDECAR_TIMEOUT {
|
|
||||||
terminate_child(child)?;
|
|
||||||
remove_temporary_dir(&profile_data_dir)?;
|
|
||||||
thread::sleep(SIDECAR_COMMAND_COOLDOWN);
|
|
||||||
return Err(format!(
|
|
||||||
"timed out rendering {site_url} at {}x{}",
|
|
||||||
size.width, size.height
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(SIDECAR_POLL_INTERVAL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn temporary_profile_data_dir(profile_id: &ProfileId) -> PathBuf {
|
|
||||||
std::env::temp_dir().join(format!(
|
|
||||||
"ely-servo-sidecar-profile-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
profile_id.as_str()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_temporary_dir(path: &Path) -> Result<(), Box<dyn Error>> {
|
|
||||||
match std::fs::remove_dir_all(path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_sidecar_snapshot_with_retry(
|
|
||||||
site_url: &str,
|
|
||||||
output_path: &std::path::Path,
|
|
||||||
size: FrameSize,
|
|
||||||
scroll_offset: ScrollOffset,
|
|
||||||
input: SnapshotInput<'_>,
|
|
||||||
) -> Result<Output, Box<dyn Error>> {
|
|
||||||
for attempt in 0..SIDECAR_MAX_ATTEMPTS {
|
|
||||||
if output_path.exists() {
|
|
||||||
std::fs::remove_file(output_path)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
match run_sidecar_snapshot(site_url, output_path, size, scroll_offset, input) {
|
|
||||||
Ok(output) if output.status.success() => return Ok(output),
|
|
||||||
Ok(output) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Ok(output),
|
|
||||||
Ok(_output) => {}
|
|
||||||
Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error),
|
|
||||||
Err(_error) => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(SIDECAR_RETRY_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err("sidecar snapshot retry did not produce output".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn terminate_child(mut child: Child) -> Result<(), Box<dyn Error>> {
|
|
||||||
match child.kill() {
|
|
||||||
Ok(()) => {
|
|
||||||
let _output = child.wait_with_output()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_report_text_equals(
|
|
||||||
report: &serde_json::Value,
|
|
||||||
field: &'static str,
|
|
||||||
expected: &str,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let value = report_field_as_text(report, field)?;
|
|
||||||
if value == expected { Ok(()) } else { Err(format!("{field}: {value}").into()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_report_text_contains(
|
|
||||||
report: &serde_json::Value,
|
|
||||||
field: &'static str,
|
|
||||||
fragment: &str,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let value = report_field_as_text(report, field)?;
|
|
||||||
if value.contains(fragment) { Ok(()) } else { Err(format!("{field}: {value}").into()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_report_state_is_renderable(report: &serde_json::Value) -> Result<(), Box<dyn Error>> {
|
|
||||||
let state = report_field_as_text(report, "state")?;
|
|
||||||
if matches!(state, "complete" | "loading") {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!("state: {state}").into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_prd_report(
|
|
||||||
report: &serde_json::Value,
|
|
||||||
case: &PrdSiteCompatibilityCase,
|
|
||||||
size: FrameSize,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
eprintln!(
|
|
||||||
"prd-live-site servo-sidecar url={} loaded={} title={} state={} size={}x{} content_pixels={} non_white_pixels={} sample_hash={}",
|
|
||||||
case.url,
|
|
||||||
report_field_as_text(report, "loaded_url")?,
|
|
||||||
report_field_as_text(report, "title")?,
|
|
||||||
report_field_as_text(report, "state")?,
|
|
||||||
size.width,
|
|
||||||
size.height,
|
|
||||||
report_field_as_u64(report, "content_pixel_count")?,
|
|
||||||
report_field_as_u64(report, "non_white_pixel_count")?,
|
|
||||||
report_field_as_u64(report, "sample_hash")?
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn report_field_as_text<'a>(
|
|
||||||
report: &'a serde_json::Value,
|
|
||||||
field: &'static str,
|
|
||||||
) -> Result<&'a str, Box<dyn Error>> {
|
|
||||||
report
|
|
||||||
.get(field)
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.ok_or_else(|| format!("missing text report field: {field}").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn report_field_as_bool(
|
|
||||||
report: &serde_json::Value,
|
|
||||||
field: &'static str,
|
|
||||||
) -> Result<bool, Box<dyn Error>> {
|
|
||||||
report
|
|
||||||
.get(field)
|
|
||||||
.and_then(serde_json::Value::as_bool)
|
|
||||||
.ok_or_else(|| format!("missing boolean report field: {field}").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn report_field_as_i64(
|
|
||||||
report: &serde_json::Value,
|
|
||||||
field: &'static str,
|
|
||||||
) -> Result<i64, Box<dyn Error>> {
|
|
||||||
report
|
|
||||||
.get(field)
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| format!("missing signed report field: {field}").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn report_field_as_u64(
|
|
||||||
report: &serde_json::Value,
|
|
||||||
field: &'static str,
|
|
||||||
) -> Result<u64, Box<dyn Error>> {
|
|
||||||
report
|
|
||||||
.get(field)
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.ok_or_else(|| format!("missing numeric report field: {field}").into())
|
|
||||||
}
|
|
||||||
@@ -11,9 +11,8 @@ 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, ScreenshotRequest,
|
PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScrollRequest,
|
||||||
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest,
|
ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState,
|
||||||
WebViewState,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
||||||
@@ -336,17 +335,6 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
|
|||||||
site.url
|
site.url
|
||||||
);
|
);
|
||||||
assert_rendered_frame_has_content(&host, site.url, MINIMUM_CONTENT_PIXELS)?;
|
assert_rendered_frame_has_content(&host, site.url, MINIMUM_CONTENT_PIXELS)?;
|
||||||
if site.url == "https://example.com" {
|
|
||||||
let screenshot =
|
|
||||||
host.capture_screenshot(ScreenshotRequest { webview_id: webview_id.clone() })?;
|
|
||||||
assert_frame_has_dimensions_and_content(
|
|
||||||
&screenshot,
|
|
||||||
"https://example.com screenshot",
|
|
||||||
INITIAL_WIDTH,
|
|
||||||
INITIAL_HEIGHT,
|
|
||||||
MINIMUM_CONTENT_PIXELS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash());
|
previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,46 +2,52 @@
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
ELY is a Servo-based browser. The page renderer is Servo itself, embedded in the application process and attached to a real platform rendering surface. The browser chrome can stay GPUI, while web content must follow Servo's embedder model:
|
ELY is a Servo-based browser. The page renderer lives in the application process and follows Servo's embedder model:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
┌──────────────────────────── ELY App Process ────────────────────────────┐
|
┌──────────────────────────── ELY App Process ────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
│ GPUI chrome │
|
│ GPUI chrome │
|
||||||
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ Sidebar Toolbar Tabs Settings │ │
|
│ │ Sidebar Toolbar Tabs Settings │ │
|
||||||
│ └───────────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ Servo content host │
|
│ Servo content host │
|
||||||
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ Servo + WebView + WindowRenderingContext │ │
|
│ │ Servo + WebView + RenderingContext │ │
|
||||||
│ │ notify_new_frame_ready -> window repaint -> paint -> present │ │
|
│ │ notify_new_frame_ready -> repaint -> paint -> present │ │
|
||||||
│ └───────────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
The normal page-display path excludes external rendering sidecars, stdout frame transport, RGBA frame payloads, cross-process IOSurface handoff, and GPUI `RenderImage` uploads for live web content.
|
The app process owns WebView lifecycle, navigation, input, permissions, frame readiness, and rendering context presentation.
|
||||||
|
|
||||||
## Root Cause
|
## Current Code Boundary
|
||||||
|
|
||||||
The current ELY page path is a remote-frame architecture:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GPUI shell
|
WebSurfaceStore
|
||||||
-> WebSurfaceStore
|
-> GPUI NativeSurface
|
||||||
-> LiveRuntimeWorker
|
-> LiveRuntimeWorker
|
||||||
-> ServoLiveClient
|
-> ServoLiveClient
|
||||||
-> ely_servo_sidecar stdin/stdout JSON
|
|
||||||
-> SoftwareServoHost
|
-> SoftwareServoHost
|
||||||
-> Servo WebView paint
|
-> servo::Servo + servo::WebView + servo::WindowRenderingContext
|
||||||
-> RGBA payload or IOSurface handle
|
|
||||||
-> GPUI surface/image element
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This makes page interaction depend on worker scheduling, IPC, polling cadence, surface import, frame object churn, and GPUI scene refresh. Hardware IOSurface reduces byte volume, while the architecture still behaves like a remote compositor.
|
`ServoLiveClient` is now an in-process adapter over `ely_servo_host::SoftwareServoHost`. It shares one Servo runtime across profile scopes, creates multiple WebViews inside that runtime, and routes scroll, hover, click, keyboard, resize, zoom, navigation, and permissions through the `ServoHost` API.
|
||||||
|
|
||||||
Servo's own embedder route is direct:
|
Normal page display now enters GPUI through `native_surface(...)`. GPUI creates a platform child surface for the content bounds, passes that raw handle into Servo's `WindowRenderingContext`, and presents with `paint_without_readback_with_completion`. The RGBA readback path remains inside `ely_servo_host` for low-level tests.
|
||||||
|
|
||||||
|
Current platform child surfaces:
|
||||||
|
|
||||||
|
- macOS: child `NSView` with an AppKit raw window handle.
|
||||||
|
- Windows: child `HWND` with a Win32 raw window handle.
|
||||||
|
- Linux/X11: child XCB window with XCB display/window handles.
|
||||||
|
- Linux/Wayland: child `wl_surface` attached as a `wl_subsurface` with Wayland display/surface handles.
|
||||||
|
|
||||||
|
## Upstream Servo Route
|
||||||
|
|
||||||
|
Servo's own shell route is the model for the final ELY rendering path:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Window event
|
Window event
|
||||||
@@ -57,7 +63,7 @@ Relevant upstream evidence from Servo `7c48af7`:
|
|||||||
- `ports/servoshell/window.rs` creates `WebViewBuilder::new(state.servo(), platform_window.rendering_context())`.
|
- `ports/servoshell/window.rs` creates `WebViewBuilder::new(state.servo(), platform_window.rendering_context())`.
|
||||||
- `ports/servoshell/window.rs` repaints with `webview.paint()` and `rendering_context().present()`.
|
- `ports/servoshell/window.rs` repaints with `webview.paint()` and `rendering_context().present()`.
|
||||||
- `ports/servoshell/running_app_state.rs` handles `notify_new_frame_ready` by marking the owning window for repaint.
|
- `ports/servoshell/running_app_state.rs` handles `notify_new_frame_ready` by marking the owning window for repaint.
|
||||||
- `components/paint/paint.rs` owns one WebRender painter per `RenderingContext` and explicitly avoids blocking paint on the constellation.
|
- `components/paint/paint.rs` owns one WebRender painter per `RenderingContext` and keeps paint coordination inside Servo's rendering pipeline.
|
||||||
|
|
||||||
## Target Boundaries
|
## Target Boundaries
|
||||||
|
|
||||||
@@ -73,11 +79,11 @@ crates/ely_app/src/servo_embed
|
|||||||
WebView lifecycle, repaint dispatch, and web input routing.
|
WebView lifecycle, repaint dispatch, and web input routing.
|
||||||
|
|
||||||
crates/ely_servo_host
|
crates/ely_servo_host
|
||||||
Transitional compatibility surface for explicit screenshots and isolated
|
Owns the current in-process compatibility adapter while the native
|
||||||
compatibility tools. Normal live page display leaves this crate.
|
platform rendering surface lands.
|
||||||
```
|
```
|
||||||
|
|
||||||
Each production source file in the new embedding path stays below 500 lines. Large responsibilities split by ownership:
|
Each production source file in the final embedding path stays below 500 lines. Large responsibilities split by ownership:
|
||||||
|
|
||||||
- `runtime.rs`: `Servo`, wake handling, webview registry.
|
- `runtime.rs`: `Servo`, wake handling, webview registry.
|
||||||
- `platform_view.rs`: platform content surface attachment.
|
- `platform_view.rs`: platform content surface attachment.
|
||||||
@@ -88,36 +94,33 @@ Each production source file in the new embedding path stays below 500 lines. Lar
|
|||||||
|
|
||||||
## Migration Slices
|
## Migration Slices
|
||||||
|
|
||||||
1. Create `servo_embed` as an in-process module behind the existing tab/domain model.
|
1. App process owns the Servo runtime and WebView registry.
|
||||||
2. Add a macOS platform content view using the GPUI window's raw AppKit handle.
|
2. App process routes web input and navigation directly into Servo.
|
||||||
3. Build Servo `WindowRenderingContext` or child context against the platform content view.
|
3. GPUI exposes one `NativeSurfaceHandle` element contract for platform child surfaces.
|
||||||
4. Move one active tab to in-process `Servo + WebView + RenderingContext`.
|
4. macOS creates a child `NSView` and hands the AppKit raw handle to Servo.
|
||||||
5. Route scroll, mouse, keyboard, resize, zoom, and navigation directly into the active `WebView`.
|
5. Windows creates a child `HWND` and hands the Win32 raw handle to Servo.
|
||||||
6. Replace `WebSurfaceStore` for normal web pages with the in-process host.
|
6. Linux/X11 creates a child XCB window and hands the XCB raw handle to Servo.
|
||||||
7. Delete sidecar spawning from normal page display.
|
7. Linux/Wayland creates a child `wl_surface`/subsurface and hands the Wayland raw handle to Servo.
|
||||||
8. Keep explicit page screenshot capture as a user-command path only.
|
8. GPUI web page display uses native content surface presentation for every platform target.
|
||||||
|
|
||||||
## Acceptance Gates
|
## Acceptance Gates
|
||||||
|
|
||||||
- `cargo run` opens a normal web page without starting `ely_servo_sidecar`.
|
- `cargo run` opens a normal web page with Servo inside the ELY app process.
|
||||||
- Scrolling a live web page uses Servo input events and Servo repaint callbacks.
|
- Scrolling a live web page uses Servo input events and Servo repaint callbacks.
|
||||||
- The page display path contains no RGBA frame payload transport.
|
- Page display presents through a Servo rendering context.
|
||||||
- The page display path contains no stdout JSON frame loop.
|
- macOS, Windows, Linux/X11, and Linux/Wayland each have a platform child surface implementation under the same `NativeSurfaceHandle` contract.
|
||||||
- The page display path contains no GPUI `RenderImage` upload for live web content.
|
- Address/search, tabs, spaces, profiles, settings, permissions, and sync compile through existing domain APIs.
|
||||||
- Address/search, tabs, spaces, profiles, settings, permissions, sync, and explicit screenshots continue to compile and behave through existing domain APIs.
|
|
||||||
- Every new source file stays below 500 lines.
|
- Every new source file stays below 500 lines.
|
||||||
- No user-facing frontend status, logs, debug panels, or explanatory clutter are added.
|
- User-facing chrome stays clean.
|
||||||
|
|
||||||
## First Implementation Target
|
## First Platform Surface Target
|
||||||
|
|
||||||
The first code slice is macOS content-view attachment:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GPUI Window
|
GPUI Window
|
||||||
-> raw AppKit NSView
|
-> GPUI NativeSurface element
|
||||||
-> ELY child NSView for web content bounds
|
-> platform child surface for web content bounds
|
||||||
-> Servo WindowRenderingContext
|
-> Servo WindowRenderingContext
|
||||||
-> Servo WebView
|
-> Servo WebView
|
||||||
```
|
```
|
||||||
|
|
||||||
This slice creates the real platform surface required by Servo's direct rendering model. Once the content view exists, Servo can paint into a native surface in the ELY app process, and the remote-frame path can be removed tab by tab.
|
This slice gives Servo a native surface in the ELY app process. The compatibility adapter remains a narrow bridge for tests and non-normal display probes.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"size": "20x20",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"filename": "icon-40.png",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "20x20",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"filename": "icon-60.png",
|
||||||
|
"scale": "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "29x29",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"filename": "icon-58.png",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "29x29",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"filename": "icon-87.png",
|
||||||
|
"scale": "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "40x40",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"filename": "icon-80.png",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "40x40",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"filename": "icon-120.png",
|
||||||
|
"scale": "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "60x60",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"filename": "icon-120.png",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "60x60",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"filename": "icon-180.png",
|
||||||
|
"scale": "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "76x76",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"filename": "icon-76.png",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "76x76",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"filename": "icon-152.png",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "83.5x83.5",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"filename": "icon-167.png",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": "1024x1024",
|
||||||
|
"idiom": "ios-marketing",
|
||||||
|
"filename": "icon-1024.png",
|
||||||
|
"scale": "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"version": 1,
|
||||||
|
"author": "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 595 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 617 B |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 595 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -3,21 +3,17 @@ set -euo pipefail
|
|||||||
|
|
||||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
binary_path="${repo_root}/target/debug/ely_app"
|
binary_path="${repo_root}/target/debug/ely_app"
|
||||||
sidecar_path="${repo_root}/target/debug/ely_servo_sidecar"
|
|
||||||
bundle_root="${repo_root}/target/macos/ELY Browser.app"
|
bundle_root="${repo_root}/target/macos/ELY Browser.app"
|
||||||
contents_dir="${bundle_root}/Contents"
|
contents_dir="${bundle_root}/Contents"
|
||||||
macos_dir="${contents_dir}/MacOS"
|
macos_dir="${contents_dir}/MacOS"
|
||||||
resources_dir="${contents_dir}/Resources"
|
resources_dir="${contents_dir}/Resources"
|
||||||
|
|
||||||
cargo build -p ely_app
|
cargo build -p ely_app
|
||||||
cargo build -p ely_servo_host --features servo-engine,hardware-render --bin ely_servo_sidecar
|
|
||||||
|
|
||||||
rm -rf "${bundle_root}"
|
rm -rf "${bundle_root}"
|
||||||
mkdir -p "${macos_dir}" "${resources_dir}"
|
mkdir -p "${macos_dir}" "${resources_dir}"
|
||||||
cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist"
|
cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist"
|
||||||
cp "${binary_path}" "${macos_dir}/ely_app"
|
cp "${binary_path}" "${macos_dir}/ely_app"
|
||||||
cp "${sidecar_path}" "${macos_dir}/ely_servo_sidecar"
|
|
||||||
chmod 755 "${macos_dir}/ely_app"
|
chmod 755 "${macos_dir}/ely_app"
|
||||||
chmod 755 "${macos_dir}/ely_servo_sidecar"
|
|
||||||
|
|
||||||
echo "${bundle_root}"
|
echo "${bundle_root}"
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate ELY Browser app icon — source PNG + all platform sizes."""
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
RENDER = 3072
|
||||||
|
FINAL = 1024
|
||||||
|
GRAD_SMALL = 512 # larger base for smoother gradient
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
OUT_DIR = os.path.join(BASE, "generated-icons")
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_gradient(size):
|
||||||
|
img = Image.new("RGB", (size, size))
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
nx, ny = x / size, y / size
|
||||||
|
r, g, b = 236.0, 224.0, 243.0
|
||||||
|
|
||||||
|
dx = (nx - 0.80) * 1.7
|
||||||
|
dy = (ny - 0.14) * 2.1
|
||||||
|
d = min(1.0, math.sqrt(dx * dx + dy * dy))
|
||||||
|
w = max(0.0, 1.0 - d) ** 1.7
|
||||||
|
r += (255 - r) * w; g += (195 - g) * w; b += (212 - b) * w
|
||||||
|
|
||||||
|
dx = (nx - 0.17) * 1.8
|
||||||
|
dy = (ny - 0.87) * 1.7
|
||||||
|
d = min(1.0, math.sqrt(dx * dx + dy * dy))
|
||||||
|
w = max(0.0, 1.0 - d) ** 1.7
|
||||||
|
r += (170 - r) * w; g += (190 - g) * w; b += (255 - b) * w
|
||||||
|
|
||||||
|
dx = (nx - 0.22) * 2.0
|
||||||
|
dy = (ny - 0.22) * 2.0
|
||||||
|
d = min(1.0, math.sqrt(dx * dx + dy * dy))
|
||||||
|
w = max(0.0, 1.0 - d) ** 2.2 * 0.35
|
||||||
|
r += (255 - r) * w; g += (222 - g) * w; b += (208 - b) * w
|
||||||
|
|
||||||
|
img.putpixel((x, y), (min(255, int(r)), min(255, int(g)), min(255, int(b))))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def draw_e_rounded(draw, cx, cy, sz):
|
||||||
|
"""Draw geometric E with slightly rounded bar ends."""
|
||||||
|
e_h = int(sz * 0.40)
|
||||||
|
e_w = int(sz * 0.30)
|
||||||
|
mid_w = int(e_w * 0.70)
|
||||||
|
bar = int(sz * 0.064)
|
||||||
|
cr = int(bar * 0.28) # corner radius for exposed ends
|
||||||
|
|
||||||
|
left = cx - e_w // 2
|
||||||
|
top = cy - e_h // 2
|
||||||
|
right = left + e_w
|
||||||
|
bottom = top + e_h
|
||||||
|
mid_y = cy
|
||||||
|
white = (255, 255, 255, 255)
|
||||||
|
|
||||||
|
# Vertical bar — full height, rounded top-left and bottom-left corners
|
||||||
|
draw.rounded_rectangle([left, top, left + bar, bottom], radius=cr, fill=white)
|
||||||
|
# Top bar — overlaps vertical bar at left end
|
||||||
|
draw.rounded_rectangle([left, top, right, top + bar], radius=cr, fill=white)
|
||||||
|
# Middle bar (shorter)
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[left, mid_y - bar // 2, left + mid_w, mid_y + bar // 2],
|
||||||
|
radius=cr, fill=white,
|
||||||
|
)
|
||||||
|
# Bottom bar
|
||||||
|
draw.rounded_rectangle([left, bottom - bar, right, bottom], radius=cr, fill=white)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Creating gradient (512x512)...")
|
||||||
|
grad_small = create_gradient(GRAD_SMALL)
|
||||||
|
grad = grad_small.resize((RENDER, RENDER), Image.BICUBIC)
|
||||||
|
# Slight blur to eliminate any banding
|
||||||
|
grad = grad.filter(ImageFilter.GaussianBlur(radius=2))
|
||||||
|
|
||||||
|
print("Building icon at 3072...")
|
||||||
|
corner_r = int(RENDER * 0.223)
|
||||||
|
mask = Image.new("L", (RENDER, RENDER), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle(
|
||||||
|
[0, 0, RENDER - 1, RENDER - 1], radius=corner_r, fill=255
|
||||||
|
)
|
||||||
|
|
||||||
|
icon = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
|
||||||
|
icon.paste(grad.convert("RGBA"), mask=mask)
|
||||||
|
|
||||||
|
cx, cy = RENDER // 2, RENDER // 2
|
||||||
|
|
||||||
|
# Subtle drop shadow for the E
|
||||||
|
shadow = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
|
||||||
|
draw_e_rounded(ImageDraw.Draw(shadow), cx, cy + int(RENDER * 0.005), RENDER)
|
||||||
|
# Convert white shadow pixels to dark translucent
|
||||||
|
sd = shadow.load()
|
||||||
|
for sy in range(RENDER):
|
||||||
|
for sx in range(RENDER):
|
||||||
|
_, _, _, a = sd[sx, sy]
|
||||||
|
if a > 0:
|
||||||
|
sd[sx, sy] = (25, 18, 12, int(a * 0.15))
|
||||||
|
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=int(RENDER * 0.01)))
|
||||||
|
icon = Image.alpha_composite(icon, shadow)
|
||||||
|
|
||||||
|
# White E glyph
|
||||||
|
glyph = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
|
||||||
|
draw_e_rounded(ImageDraw.Draw(glyph), cx, cy, RENDER)
|
||||||
|
icon = Image.alpha_composite(icon, glyph)
|
||||||
|
|
||||||
|
# Downscale to 1024
|
||||||
|
source = icon.resize((FINAL, FINAL), Image.LANCZOS)
|
||||||
|
src_path = os.path.join(OUT_DIR, "icon-source-1024.png")
|
||||||
|
source.save(src_path, "PNG")
|
||||||
|
print(f" Source: {src_path}")
|
||||||
|
|
||||||
|
# ---- Generate all platform sizes from source ----
|
||||||
|
print("Generating platform sizes...")
|
||||||
|
|
||||||
|
ios_dir = os.path.join(OUT_DIR, "ios")
|
||||||
|
android_dir = os.path.join(OUT_DIR, "android")
|
||||||
|
web_dir = os.path.join(OUT_DIR, "web")
|
||||||
|
macos_dir = os.path.join(OUT_DIR, "macos")
|
||||||
|
for d in [ios_dir, android_dir, web_dir, macos_dir]:
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
ios_sizes = [40, 60, 58, 87, 80, 120, 180, 76, 152, 167, 1024]
|
||||||
|
for s in ios_sizes:
|
||||||
|
resized = source.resize((s, s), Image.LANCZOS)
|
||||||
|
resized.save(os.path.join(ios_dir, f"icon-{s}.png"), "PNG")
|
||||||
|
print(f" iOS: {len(ios_sizes)} sizes")
|
||||||
|
|
||||||
|
# Write Contents.json for Xcode
|
||||||
|
contents = {
|
||||||
|
"images": [
|
||||||
|
{"size": "20x20", "idiom": "iphone", "filename": "icon-40.png", "scale": "2x"},
|
||||||
|
{"size": "20x20", "idiom": "iphone", "filename": "icon-60.png", "scale": "3x"},
|
||||||
|
{"size": "29x29", "idiom": "iphone", "filename": "icon-58.png", "scale": "2x"},
|
||||||
|
{"size": "29x29", "idiom": "iphone", "filename": "icon-87.png", "scale": "3x"},
|
||||||
|
{"size": "40x40", "idiom": "iphone", "filename": "icon-80.png", "scale": "2x"},
|
||||||
|
{"size": "40x40", "idiom": "iphone", "filename": "icon-120.png", "scale": "3x"},
|
||||||
|
{"size": "60x60", "idiom": "iphone", "filename": "icon-120.png", "scale": "2x"},
|
||||||
|
{"size": "60x60", "idiom": "iphone", "filename": "icon-180.png", "scale": "3x"},
|
||||||
|
{"size": "76x76", "idiom": "ipad", "filename": "icon-76.png", "scale": "1x"},
|
||||||
|
{"size": "76x76", "idiom": "ipad", "filename": "icon-152.png", "scale": "2x"},
|
||||||
|
{"size": "83.5x83.5", "idiom": "ipad", "filename": "icon-167.png", "scale": "2x"},
|
||||||
|
{"size": "1024x1024", "idiom": "ios-marketing", "filename": "icon-1024.png", "scale": "1x"},
|
||||||
|
],
|
||||||
|
"info": {"version": 1, "author": "xcode"},
|
||||||
|
}
|
||||||
|
with open(os.path.join(ios_dir, "Contents.json"), "w") as f:
|
||||||
|
json.dump(contents, f, indent=2)
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android_sizes = {"mdpi": 48, "hdpi": 72, "xhdpi": 96, "xxhdpi": 144, "xxxhdpi": 192}
|
||||||
|
for density, s in android_sizes.items():
|
||||||
|
resized = source.resize((s, s), Image.LANCZOS)
|
||||||
|
resized.save(os.path.join(android_dir, f"ic_launcher_{density}.png"), "PNG")
|
||||||
|
source.resize((512, 512), Image.LANCZOS).save(
|
||||||
|
os.path.join(android_dir, "playstore-512.png"), "PNG"
|
||||||
|
)
|
||||||
|
print(f" Android: {len(android_sizes) + 1} sizes")
|
||||||
|
|
||||||
|
# Web + PWA
|
||||||
|
web_sizes = [16, 32, 48, 72, 96, 128, 144, 152, 192, 384, 512]
|
||||||
|
for s in web_sizes:
|
||||||
|
resized = source.resize((s, s), Image.LANCZOS)
|
||||||
|
resized.save(os.path.join(web_dir, f"icon-{s}.png"), "PNG")
|
||||||
|
# Apple touch icon
|
||||||
|
source.resize((180, 180), Image.LANCZOS).save(
|
||||||
|
os.path.join(web_dir, "apple-touch-icon.png"), "PNG"
|
||||||
|
)
|
||||||
|
# Favicon .ico (multi-size)
|
||||||
|
ico_16 = source.resize((16, 16), Image.LANCZOS)
|
||||||
|
ico_32 = source.resize((32, 32), Image.LANCZOS)
|
||||||
|
ico_48 = source.resize((48, 48), Image.LANCZOS)
|
||||||
|
ico_16.save(os.path.join(web_dir, "favicon.ico"), format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
|
||||||
|
print(f" Web/PWA: {len(web_sizes) + 2} assets")
|
||||||
|
|
||||||
|
# macOS .iconset (for iconutil → .icns)
|
||||||
|
iconset_dir = os.path.join(macos_dir, "AppIcon.iconset")
|
||||||
|
os.makedirs(iconset_dir, exist_ok=True)
|
||||||
|
macos_sizes = {
|
||||||
|
"icon_16x16.png": 16,
|
||||||
|
"icon_16x16@2x.png": 32,
|
||||||
|
"icon_32x32.png": 32,
|
||||||
|
"icon_32x32@2x.png": 64,
|
||||||
|
"icon_128x128.png": 128,
|
||||||
|
"icon_128x128@2x.png": 256,
|
||||||
|
"icon_256x256.png": 256,
|
||||||
|
"icon_256x256@2x.png": 512,
|
||||||
|
"icon_512x512.png": 512,
|
||||||
|
"icon_512x512@2x.png": 1024,
|
||||||
|
}
|
||||||
|
for name, s in macos_sizes.items():
|
||||||
|
resized = source.resize((s, s), Image.LANCZOS)
|
||||||
|
resized.save(os.path.join(iconset_dir, name), "PNG")
|
||||||
|
print(f" macOS: {len(macos_sizes)} sizes in AppIcon.iconset")
|
||||||
|
|
||||||
|
print("\nDone! All icons in: generated-icons/")
|
||||||
|
print("To build macOS .icns: iconutil -c icns generated-icons/macos/AppIcon.iconset")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,6 +3,3 @@ set -euo pipefail
|
|||||||
|
|
||||||
echo "Verifying PRD live sites through the GPUI web surface adapter"
|
echo "Verifying PRD live sites through the GPUI web surface adapter"
|
||||||
cargo test -p ely_app --features live-site-smoke --all-targets -- --test-threads=1 --nocapture
|
cargo test -p ely_app --features live-site-smoke --all-targets -- --test-threads=1 --nocapture
|
||||||
|
|
||||||
echo "Verifying PRD live sites through Servo sidecar RGBA snapshots"
|
|
||||||
cargo test -p ely_servo_host --features servo-engine --test sidecar -- --test-threads=1 --nocapture
|
|
||||||
|
|||||||
@@ -167,10 +167,6 @@ version = "0.2"
|
|||||||
|
|
||||||
[dependencies.log]
|
[dependencies.log]
|
||||||
version = "0.4.16"
|
version = "0.4.16"
|
||||||
features = [
|
|
||||||
"kv_unstable_serde",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies.lyon]
|
[dependencies.lyon]
|
||||||
version = "1.0"
|
version = "1.0"
|
||||||
@@ -499,10 +495,6 @@ version = "0.5"
|
|||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies.log]
|
[target.'cfg(target_os = "macos")'.dependencies.log]
|
||||||
version = "0.4.16"
|
version = "0.4.16"
|
||||||
features = [
|
|
||||||
"kv_unstable_serde",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies.media]
|
[target.'cfg(target_os = "macos")'.dependencies.media]
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod div;
|
|||||||
mod image_cache;
|
mod image_cache;
|
||||||
mod img;
|
mod img;
|
||||||
mod list;
|
mod list;
|
||||||
|
mod native_surface;
|
||||||
mod surface;
|
mod surface;
|
||||||
mod svg;
|
mod svg;
|
||||||
mod text;
|
mod text;
|
||||||
@@ -19,6 +20,7 @@ pub use div::*;
|
|||||||
pub use image_cache::*;
|
pub use image_cache::*;
|
||||||
pub use img::*;
|
pub use img::*;
|
||||||
pub use list::*;
|
pub use list::*;
|
||||||
|
pub use native_surface::*;
|
||||||
pub use surface::*;
|
pub use surface::*;
|
||||||
pub use svg::*;
|
pub use svg::*;
|
||||||
pub use text::*;
|
pub use text::*;
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use crate::{
|
||||||
|
App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
|
||||||
|
NativeSurfaceHandle, Pixels, Style, StyleRefinement, Styled, Window,
|
||||||
|
};
|
||||||
|
use refineable::Refineable;
|
||||||
|
|
||||||
|
/// A native platform surface hosted inside a GPUI layout box.
|
||||||
|
pub struct NativeSurface {
|
||||||
|
id: ElementId,
|
||||||
|
on_surface: Option<Box<dyn FnMut(NativeSurfaceHandle, Bounds<Pixels>, &mut Window, &mut App)>>,
|
||||||
|
style: StyleRefinement,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a native platform surface element.
|
||||||
|
pub fn native_surface(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
on_surface: impl FnMut(NativeSurfaceHandle, Bounds<Pixels>, &mut Window, &mut App) + 'static,
|
||||||
|
) -> NativeSurface {
|
||||||
|
NativeSurface { id: id.into(), on_surface: Some(Box::new(on_surface)), style: Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for NativeSurface {
|
||||||
|
type RequestLayoutState = ();
|
||||||
|
type PrepaintState = ();
|
||||||
|
|
||||||
|
fn id(&self) -> Option<ElementId> {
|
||||||
|
Some(self.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_layout(
|
||||||
|
&mut self,
|
||||||
|
_global_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> (LayoutId, Self::RequestLayoutState) {
|
||||||
|
let mut style = Style::default();
|
||||||
|
style.refine(&self.style);
|
||||||
|
let layout_id = window.request_layout(style, [], cx);
|
||||||
|
(layout_id, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepaint(
|
||||||
|
&mut self,
|
||||||
|
_global_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
_bounds: Bounds<Pixels>,
|
||||||
|
_request_layout: &mut Self::RequestLayoutState,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut App,
|
||||||
|
) -> Self::PrepaintState {
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&mut self,
|
||||||
|
global_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
_: &mut Self::RequestLayoutState,
|
||||||
|
_: &mut Self::PrepaintState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let Some(global_id) = global_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(on_surface) = self.on_surface.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(surface) = window.with_element_state::<NativeSurfaceState, _>(
|
||||||
|
global_id,
|
||||||
|
|state, window| {
|
||||||
|
let surface = state
|
||||||
|
.and_then(|state| state.surface)
|
||||||
|
.or_else(|| window.create_native_surface());
|
||||||
|
if let Some(surface) = surface {
|
||||||
|
window.sync_native_surface(&surface, bounds);
|
||||||
|
return (Some(surface.clone()), NativeSurfaceState { surface: Some(surface) });
|
||||||
|
}
|
||||||
|
(None, NativeSurfaceState { surface: None })
|
||||||
|
},
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
on_surface(surface, bounds, window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoElement for NativeSurface {
|
||||||
|
type Element = Self;
|
||||||
|
|
||||||
|
fn into_element(self) -> Self::Element {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for NativeSurface {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NativeSurfaceState {
|
||||||
|
surface: Option<NativeSurfaceHandle>,
|
||||||
|
}
|
||||||