Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

@@ -0,0 +1,9 @@
//! CEF's helper processes inside an app bundle.
fn main() {
let Some(code) = ely_web_content::subprocess() else {
eprintln!("ely-web-helper: started without --type; CEF starts it");
std::process::exit(2);
};
std::process::exit(code);
}
+92
View File
@@ -0,0 +1,92 @@
//! AppKit's Command edit keys; off-screen Blink never gets them.
use cef::*;
const SHIFT: u32 = 1 << 1;
const CONTROL: u32 = 1 << 2;
const ALT: u32 = 1 << 3;
const COMMAND: u32 = 1 << 7;
/// What AppKit's standard bindings do, as page script.
pub(crate) fn script(windows_key: i32, modifiers: u32) -> Option<String> {
if modifiers & COMMAND == 0 || modifiers & (CONTROL | ALT) != 0 {
return None;
}
let alter = if modifiers & SHIFT != 0 {
"extend"
} else {
"move"
};
let (direction, unit) = match windows_key {
0x25 => ("backward", "lineboundary"),
0x27 => ("forward", "lineboundary"),
0x26 => ("backward", "documentboundary"),
0x28 => ("forward", "documentboundary"),
// Delete to the line's start or end.
0x08 | 0x2E if modifiers & SHIFT == 0 => {
let (direction, command) = match windows_key {
0x08 => ("backward", "delete"),
_ => ("forward", "forwardDelete"),
};
return Some(format!(
"{{const s=getSelection();if(s.isCollapsed)s.modify('extend','{direction}','lineboundary');document.execCommand('{command}')}}"
));
}
_ => return None,
};
Some(format!(
"{{const s=getSelection();s.modify('{alter}','{direction}','{unit}');s.focusNode?.parentElement?.scrollIntoView({{block:'nearest'}})}}"
))
}
wrap_keyboard_handler! {
pub(crate) struct Keys;
impl KeyboardHandler {
fn on_key_event(
&self,
browser: Option<&mut Browser>,
event: Option<&KeyEvent>,
_os_event: *mut u8,
) -> ::std::os::raw::c_int {
let (Some(browser), Some(event)) = (browser, event) else { return 0 };
let down = matches!(event.type_, KeyEventType::RAWKEYDOWN | KeyEventType::KEYDOWN);
if !down || event.focus_on_editable_field == 0 {
return 0;
}
let Some(script) = script(event.windows_key_code, event.modifiers) else { return 0 };
let Some(frame) = browser.focused_frame() else { return 0 };
log::debug!("web: AppKit edit key {}", event.windows_key_code);
frame.execute_java_script(Some(&CefString::from(script.as_str())), None, 0);
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_keys_only() {
assert!(
script(0x28, COMMAND)
.unwrap()
.contains("'move','forward','documentboundary'")
);
assert!(
script(0x25, COMMAND | SHIFT)
.unwrap()
.contains("'extend','backward','lineboundary'")
);
assert!(
script(0x08, COMMAND)
.unwrap()
.contains("execCommand('delete')")
);
assert!(script(0x2E, COMMAND).unwrap().contains("forwardDelete"));
assert_eq!(script(0x28, 0), None);
assert_eq!(script(0x28, COMMAND | ALT), None);
assert_eq!(script(0x41, COMMAND), None);
}
}
+277
View File
@@ -0,0 +1,277 @@
//! CEF itself: the framework, its processes, its message pump.
use std::{
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
time::Duration,
};
use anyhow::{Context as _, Result};
use cef::{args::Args, *};
use futures::{FutureExt as _, StreamExt as _, channel::mpsc};
use gpui::{App, AsyncApp, Global, WeakEntity};
use crate::{handlers, surface::Shared, view::WebView};
/// The framework beside the app, else the fetched one.
fn framework_dir() -> Result<PathBuf> {
const NAME: &str = "Chromium Embedded Framework.framework";
let exe = std::env::current_exe()?;
let mut places: Vec<PathBuf> = Vec::new();
if let Some(dir) = std::env::var_os("ELY_CEF") {
places.push(PathBuf::from(dir).join(NAME));
}
if let Some(contents) = exe.parent().and_then(Path::parent) {
places.push(contents.join("Frameworks").join(NAME));
// A helper sits in Frameworks/<App> Helper.app/Contents/MacOS.
if let Some(frameworks) = contents.parent().and_then(Path::parent) {
places.push(frameworks.join(NAME));
}
}
if let Some(built) = option_env!("CEF_PATH") {
places.push(Path::new(built).join(NAME));
}
places
.into_iter()
.find(|place| place.is_dir())
.with_context(|| format!("no {NAME} beside {} or in engines/cef", exe.display()))
}
static LOADED: OnceLock<std::result::Result<PathBuf, String>> = OnceLock::new();
/// Loads the framework once; every process needs it first.
fn load() -> Result<PathBuf> {
LOADED
.get_or_init(|| {
let dir = framework_dir().map_err(|error| format!("{error:#}"))?;
let library = dir.join("Chromium Embedded Framework");
let name = std::ffi::CString::new(library.as_os_str().as_encoded_bytes())
.map_err(|error| error.to_string())?;
if unsafe { load_library(Some(&*name.as_ptr().cast())) } != 1 {
return Err(format!("CEF did not load from {}", library.display()));
}
let _ = api_hash(sys::CEF_API_VERSION_LAST, 0);
log::info!("web: CEF loaded from {}", dir.display());
Ok(dir)
})
.clone()
.map_err(anyhow::Error::msg)
}
/// In a CEF helper process, runs it to its end.
pub fn subprocess() -> Option<i32> {
// CEF's own parser needs the framework; plain args do not.
if !std::env::args().any(|arg| arg.starts_with("--type=")) {
return None;
}
if let Err(error) = load() {
eprintln!("web: {error:#}");
return Some(1);
}
let args = Args::new();
let mut app = handlers::app();
let code = execute_process(
Some(args.as_main_args()),
Some(&mut app),
std::ptr::null_mut(),
);
Some(code)
}
/// The longest the pump waits between turns, as in cefclient.
const FALLBACK: Duration = Duration::from_millis(1000 / 30);
/// Pump work CEF asked for, from any thread.
static WAKE: Mutex<Option<mpsc::UnboundedSender<i64>>> = Mutex::new(None);
/// CEF wants its loop run within `delay_ms`.
pub(crate) fn schedule(delay_ms: i64) {
if let Some(wake) = WAKE.lock().expect("the pump lock").as_ref() {
let _ = wake.unbounded_send(delay_ms);
}
}
/// The live views the pump hands frames to.
#[derive(Default)]
pub(crate) struct Engine {
pub views: Vec<WeakEntity<WebView>>,
}
impl Global for Engine {}
thread_local! {
/// CEF calls asked inside GPUI's updates, run after them.
static LATER: std::cell::RefCell<Vec<Box<dyn FnOnce()>>> = Default::default();
}
/// Runs `work` on the pump's next turn.
pub(crate) fn later(work: impl FnOnce() + 'static) {
LATER.with_borrow_mut(|queue| queue.push(Box::new(work)));
schedule(0);
}
/// In asked order; work may ask for more.
fn run_later() {
loop {
let batch = LATER.with_borrow_mut(std::mem::take);
if batch.is_empty() {
return;
}
for work in batch {
work();
}
}
}
/// Browsers asked for, made on the pump's next turn.
static PENDING: Mutex<Vec<(Shared, String)>> = Mutex::new(Vec::new());
/// Why CEF could not start; every view is told.
static FAILED: Mutex<Option<String>> = Mutex::new(None);
pub(crate) fn failed() -> Option<String> {
FAILED.lock().expect("the start lock").clone()
}
/// Queues a browser for `surface` at `url`.
pub(crate) fn ask_browser(surface: Shared, url: String) {
PENDING
.lock()
.expect("the pending lock")
.push((surface, url));
schedule(0);
}
/// Outside GPUI's updates: CEF may spin the run loop.
fn make_browsers() {
let pending = std::mem::take(&mut *PENDING.lock().expect("the pending lock"));
for (surface, url) in pending {
if surface.lock().expect("the surface lock").closed {
continue;
}
// Off-screen pages work only in CEF's Alloy style.
let info = WindowInfo {
windowless_rendering_enabled: true as _,
runtime_style: RuntimeStyle::ALLOY,
..Default::default()
};
let settings = BrowserSettings {
windowless_frame_rate: 60,
..Default::default()
};
let made = browser_host_create_browser(
Some(&info),
Some(&mut handlers::client(&surface)),
Some(&url.as_str().into()),
Some(&settings),
None,
None,
);
if made != 1 {
surface.lock().expect("the surface lock").failed =
Some(format!("CEF made no browser for {url}"));
}
}
}
/// Starts CEF in this process; once per app.
pub fn start(data: &Path, cx: &mut App) -> Result<()> {
if cx.has_global::<Engine>() {
return Ok(());
}
crate::input::bind(cx);
let framework = load()?;
#[cfg(target_os = "macos")]
crate::mac::adopt_app_protocol()?;
let exe = std::env::current_exe()?;
// Bundled, CEF finds its helper apps; else this binary helps.
let bundled = framework.parent().is_some_and(|dir| {
let name = exe.file_name().unwrap_or_default().to_string_lossy();
dir.join(format!("{name} Helper.app")).is_dir()
});
let helper = if bundled {
String::new()
} else {
exe.to_string_lossy().into_owned()
};
let cache = data.join("web");
std::fs::create_dir_all(&cache)?;
let settings = Settings {
windowless_rendering_enabled: true as _,
external_message_pump: true as _,
no_sandbox: true as _,
browser_subprocess_path: helper.as_str().into(),
framework_dir_path: framework.to_string_lossy().as_ref().into(),
root_cache_path: cache.to_string_lossy().as_ref().into(),
persist_session_cookies: false as _,
log_severity: LogSeverity::WARNING,
..Default::default()
};
let (wake, mut asked) = mpsc::unbounded();
*WAKE.lock().expect("the pump lock") = Some(wake);
cx.set_global(Engine::default());
cx.spawn(async move |cx: &mut AsyncApp| {
// Not inside an update: CEF may run AppKit's loop here.
let args = Args::new();
let mut app = handlers::app();
let started = initialize(
Some(args.as_main_args()),
Some(&settings),
Some(&mut app),
std::ptr::null_mut(),
);
if started != 1 {
let why = held(&cache).unwrap_or_else(|| "CEF did not initialize".into());
log::error!("web: {why}");
*FAILED.lock().expect("the start lock") = Some(why);
cx.update(|cx| crate::view::deliver(cx));
return;
}
log::info!("web: CEF started, cache {}", cache.display());
let quitting = cx.update(|cx| cx.on_app_quit(|_| async { stop() }));
quitting.detach();
let mut due: Option<Duration> = Some(Duration::ZERO);
loop {
let timer = match due {
Some(delay) => cx.background_executor().timer(delay).boxed_local(),
None => futures::future::pending().boxed_local(),
};
futures::select_biased! {
delay = asked.next() => match delay {
Some(delay) => {
let delay = Duration::from_millis(delay.max(0) as u64);
due = Some(due.map_or(delay, |now| now.min(delay)));
continue;
}
None => return,
},
_ = timer.fuse() => {}
}
run_later();
make_browsers();
do_message_loop_work();
cx.update(|cx| crate::view::deliver(cx));
// As cefclient: CEF does not ask for all work.
due = Some(FALLBACK);
}
})
.detach();
Ok(())
}
/// Stops CEF as the app quits.
fn stop() {
WAKE.lock().expect("the pump lock").take();
shutdown();
log::info!("web: CEF stopped");
}
/// Why CEF would refuse this cache: another app holds it.
fn held(cache: &Path) -> Option<String> {
let lock = std::fs::read_link(cache.join("SingletonLock")).ok()?;
let lock = lock.to_string_lossy();
let pid: i32 = lock.rsplit('-').next()?.parse().ok()?;
// Only a live process holds it.
let alive = unsafe { libc::kill(pid, 0) } == 0;
alive.then(|| format!("another copy (process {pid}) is using {}", cache.display()))
}
+88
View File
@@ -0,0 +1,88 @@
//! The fence: where pages go, what they load.
use cef::{wrapper::message_router::MessageRouterBrowserSideHandlerCallbacks, *};
use crate::{handlers::router, surface::Shared};
wrap_request_handler! {
pub(crate) struct Requests {
surface: Shared,
}
impl RequestHandler {
fn on_before_browse(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
request: Option<&mut Request>,
user_gesture: ::std::os::raw::c_int,
_is_redirect: ::std::os::raw::c_int,
) -> ::std::os::raw::c_int {
let url = request.map(|r| CefString::from(&r.url()).to_string()).unwrap_or_default();
let mut surface = self.surface.lock().expect("the surface lock");
// Content stays bundled; a clicked link leaves for the system.
if !surface.navigable(&url) {
if user_gesture == 1 {
surface.leaving.push(url);
}
return 1;
}
drop(surface);
router().on_before_browse(browser.map(|b| b.clone()), frame.map(|f| f.clone()));
0
}
fn resource_request_handler(
&self,
_browser: Option<&mut Browser>,
_frame: Option<&mut Frame>,
_request: Option<&mut Request>,
_is_navigation: ::std::os::raw::c_int,
_is_download: ::std::os::raw::c_int,
_request_initiator: Option<&CefString>,
_disable_default_handling: Option<&mut ::std::os::raw::c_int>,
) -> Option<ResourceRequestHandler> {
Some(Resources::new(self.surface.clone()))
}
fn on_render_process_terminated(
&self,
browser: Option<&mut Browser>,
status: TerminationStatus,
error_code: ::std::os::raw::c_int,
_error_string: Option<&CefString>,
) {
router().on_render_process_terminated(browser.map(|b| b.clone()));
self.surface.lock().expect("the surface lock").failed =
Some(format!("the page stopped: {status:?} ({error_code})"));
}
}
}
wrap_resource_request_handler! {
struct Resources {
surface: Shared,
}
impl ResourceRequestHandler {
// Every request, scripts and images too, meets the fence.
fn on_before_resource_load(
&self,
_browser: Option<&mut Browser>,
_frame: Option<&mut Frame>,
request: Option<&mut Request>,
_callback: Option<&mut Callback>,
) -> ReturnValue {
let Some(request) = request else {
return ReturnValue::CANCEL;
};
let url = CefString::from(&request.url()).to_string();
let image = request.resource_type() == ResourceType::IMAGE;
if self.surface.lock().expect("the surface lock").loadable(&url, image) {
return ReturnValue::CONTINUE;
}
log::warn!("web: a request outside the allowed folders was refused");
ReturnValue::CANCEL
}
}
}
+441
View File
@@ -0,0 +1,441 @@
//! What CEF calls back: per process, and per browser.
use std::{
collections::HashMap,
sync::{Arc, Mutex, OnceLock},
};
use cef::{
wrapper::message_router::{
BrowserSideCallback, BrowserSideHandler, BrowserSideRouter, MessageRouterBrowserSide,
MessageRouterBrowserSideHandlerCallbacks, MessageRouterConfig, MessageRouterRendererSide,
MessageRouterRendererSideHandlerCallbacks, RendererSideRouter,
},
*,
};
use crate::{fence::Requests, surface::Shared};
/// The page calls `window.elyQuery({request, onSuccess, onFailure})`.
fn config() -> MessageRouterConfig {
MessageRouterConfig {
js_query_function: "elyQuery".into(),
js_cancel_function: "elyQueryCancel".into(),
..Default::default()
}
}
/// Each browser's surface, by CEF's browser id.
fn surfaces() -> &'static Mutex<HashMap<i32, Shared>> {
static SURFACES: OnceLock<Mutex<HashMap<i32, Shared>>> = OnceLock::new();
SURFACES.get_or_init(Default::default)
}
pub(crate) fn adopt(browser: &Browser, surface: Shared) {
surfaces()
.lock()
.expect("the surface lock")
.insert(browser.identifier(), surface);
}
fn surface_of(browser: Option<&Browser>) -> Option<Shared> {
let id = browser?.identifier();
surfaces()
.lock()
.expect("the surface lock")
.get(&id)
.cloned()
}
/// Queries go to the view whose page asked.
struct Queries;
impl BrowserSideHandler for Queries {
fn on_query_str(
&self,
browser: Option<Browser>,
_frame: Option<Frame>,
_query_id: i64,
request: &str,
_persistent: bool,
callback: Arc<Mutex<dyn BrowserSideCallback>>,
) -> bool {
let Some(surface) = surface_of(browser.as_ref()) else {
return false;
};
surface
.lock()
.expect("the surface lock")
.queries
.push((request.to_string(), callback));
true
}
}
pub(crate) fn router() -> &'static Arc<BrowserSideRouter> {
static ROUTER: OnceLock<Arc<BrowserSideRouter>> = OnceLock::new();
ROUTER.get_or_init(|| {
let router = BrowserSideRouter::new(config());
router.add_handler(Arc::new(Queries), true);
router
})
}
fn renderer_router() -> &'static Arc<RendererSideRouter> {
static ROUTER: OnceLock<Arc<RendererSideRouter>> = OnceLock::new();
ROUTER.get_or_init(|| RendererSideRouter::new(config()))
}
/// WebRTC reaches the network by UDP; pages lose it.
const NO_PEERS: &str = "for (const name of ['RTCPeerConnection', 'webkitRTCPeerConnection', \
'RTCDataChannel', 'RTCSessionDescription', 'RTCIceCandidate', 'RTCRtpSender', \
'RTCRtpReceiver', 'RTCRtpTransceiver']) delete globalThis[name];";
wrap_app! {
struct ElyApp;
impl App {
fn on_before_command_line_processing(
&self,
_process_type: Option<&CefString>,
command_line: Option<&mut CommandLine>,
) {
let Some(line) = command_line else { return };
// No keychain prompt; the content keeps no secrets.
line.append_switch(Some(&"use-mock-keychain".into()));
line.append_switch(Some(&"disable-extensions".into()));
// Offline pages: all traffic meets a dead proxy.
line.append_switch_with_value(Some(&"proxy-server".into()), Some(&"127.0.0.1:9".into()));
line.append_switch_with_value(
Some(&"proxy-bypass-list".into()),
Some(&"<-loopback>".into()),
);
// WebRTC sends UDP past proxies; it may use proxies only.
line.append_switch_with_value(
Some(&"force-webrtc-ip-handling-policy".into()),
Some(&"disable_non_proxied_udp".into()),
);
}
fn browser_process_handler(&self) -> Option<BrowserProcessHandler> {
Some(Pump::new())
}
fn render_process_handler(&self) -> Option<RenderProcessHandler> {
Some(Renderer::new())
}
}
}
pub(crate) fn app() -> cef::App {
ElyApp::new()
}
wrap_browser_process_handler! {
struct Pump;
impl BrowserProcessHandler {
fn on_schedule_message_pump_work(&self, delay_ms: i64) {
crate::engine::schedule(delay_ms);
}
}
}
wrap_render_process_handler! {
struct Renderer;
impl RenderProcessHandler {
fn on_context_created(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
context: Option<&mut V8Context>,
) {
// Before any page script: no peer connections at all.
if let Some(context) = context.as_ref() {
let code = CefString::from(NO_PEERS);
let (mut value, mut thrown) = (None, None);
if context.eval(Some(&code), None, 0, Some(&mut value), Some(&mut thrown)) != 1 {
eprintln!("web: WebRTC could not be removed from a page");
}
}
renderer_router().on_context_created(
browser.map(|b| b.clone()),
frame.map(|f| f.clone()),
context.map(|c| c.clone()),
);
}
fn on_context_released(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
context: Option<&mut V8Context>,
) {
renderer_router().on_context_released(
browser.map(|b| b.clone()),
frame.map(|f| f.clone()),
context.map(|c| c.clone()),
);
}
fn on_process_message_received(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
source_process: ProcessId,
message: Option<&mut ProcessMessage>,
) -> ::std::os::raw::c_int {
renderer_router().on_process_message_received(
browser.map(|b| b.clone()),
frame.map(|f| f.clone()),
Some(source_process),
message.map(|m| m.clone()),
) as _
}
}
}
wrap_render_handler! {
struct Paint {
surface: Shared,
}
impl RenderHandler {
fn view_rect(&self, _browser: Option<&mut Browser>, rect: Option<&mut Rect>) {
let Some(rect) = rect else { return };
let surface = self.surface.lock().expect("the surface lock");
// CEF needs a page at least one point square.
rect.width = (surface.size.0.round() as i32).max(1);
rect.height = (surface.size.1.round() as i32).max(1);
}
fn screen_info(
&self,
_browser: Option<&mut Browser>,
screen_info: Option<&mut ScreenInfo>,
) -> ::std::os::raw::c_int {
let Some(info) = screen_info else { return 0 };
info.device_scale_factor = self.surface.lock().expect("the surface lock").scale;
1
}
fn on_paint(
&self,
_browser: Option<&mut Browser>,
type_: PaintElementType,
_dirty_rects: Option<&[Rect]>,
buffer: *const u8,
width: ::std::os::raw::c_int,
height: ::std::os::raw::c_int,
) {
// Popups (select menus) are not drawn; pages avoid them.
if type_ != PaintElementType::VIEW || buffer.is_null() || width <= 0 || height <= 0 {
return;
}
let length = width as usize * height as usize * 4;
let bgra = unsafe { std::slice::from_raw_parts(buffer, length) }.to_vec();
self.surface.lock().expect("the surface lock").frame =
Some((width as u32, height as u32, bgra));
}
fn on_ime_composition_range_changed(
&self,
_browser: Option<&mut Browser>,
_selected_range: Option<&Range>,
character_bounds: Option<&[Rect]>,
) {
let bounds = character_bounds.unwrap_or_default();
self.surface.lock().expect("the surface lock").ime = bounds
.iter()
.map(|r| (r.x as f32, r.y as f32, r.width as f32, r.height as f32))
.collect();
}
}
}
wrap_display_handler! {
struct Display {
surface: Shared,
}
impl DisplayHandler {
fn on_console_message(
&self,
_browser: Option<&mut Browser>,
level: LogSeverity,
message: Option<&CefString>,
source: Option<&CefString>,
line: ::std::os::raw::c_int,
) -> ::std::os::raw::c_int {
// A page's warnings and errors, never its other chatter.
if level.get_raw() >= LogSeverity::WARNING.get_raw() {
let said = message.map(CefString::to_string).unwrap_or_default();
let at = source.map(CefString::to_string).unwrap_or_default();
log::warn!("web: page says {said} at {at}:{line}");
}
0
}
fn on_cursor_change(
&self,
_browser: Option<&mut Browser>,
_cursor: *mut u8,
type_: CursorType,
_custom_cursor_info: Option<&CursorInfo>,
) -> ::std::os::raw::c_int {
self.surface.lock().expect("the surface lock").cursor = type_;
1
}
}
}
wrap_load_handler! {
struct Load {
surface: Shared,
}
impl LoadHandler {
fn on_load_start(
&self,
_browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
_transition_type: TransitionType,
) {
if frame.is_some_and(|frame| frame.is_main() == 1) {
self.surface.lock().expect("the surface lock").errored = false;
}
}
fn on_load_error(
&self,
_browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
error_code: Errorcode,
error_text: Option<&CefString>,
failed_url: Option<&CefString>,
) {
// A stopped load is no failure; a fenced one is.
if !frame.is_some_and(|frame| frame.is_main() == 1) || error_code == Errorcode::ABORTED {
return;
}
let text = error_text.map(CefString::to_string).unwrap_or_default();
let url = failed_url.map(CefString::to_string).unwrap_or_default();
let mut surface = self.surface.lock().expect("the surface lock");
surface.errored = true;
surface.failed = Some(format!("{text} ({}) {url}", error_code.get_raw()));
}
fn on_load_end(
&self,
_browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
_http_status_code: ::std::os::raw::c_int,
) {
if frame.is_some_and(|frame| frame.is_main() == 1) {
let mut surface = self.surface.lock().expect("the surface lock");
surface.loaded = !surface.errored;
}
}
}
}
wrap_life_span_handler! {
struct Life {
surface: Shared,
}
impl LifeSpanHandler {
fn on_after_created(&self, browser: Option<&mut Browser>) {
let Some(browser) = browser.map(|b| b.clone()) else { return };
log::info!("web: browser {} made", browser.identifier());
adopt(&browser, self.surface.clone());
self.surface.lock().expect("the surface lock").browser = Some(browser);
}
fn on_before_close(&self, browser: Option<&mut Browser>) {
let browser = browser.map(|b| b.clone());
router().on_before_close(browser.clone());
if let Some(browser) = browser {
surfaces().lock().expect("the surface lock").remove(&browser.identifier());
}
// The browser holds these handlers; they hold the surface.
self.surface.lock().expect("the surface lock").browser = None;
}
}
}
wrap_client! {
struct Handlers {
paint: RenderHandler,
display: DisplayHandler,
load: LoadHandler,
life: LifeSpanHandler,
requests: RequestHandler,
keys: Option<KeyboardHandler>,
}
impl Client {
fn keyboard_handler(&self) -> Option<KeyboardHandler> {
self.keys.clone()
}
fn render_handler(&self) -> Option<RenderHandler> {
Some(self.paint.clone())
}
fn display_handler(&self) -> Option<DisplayHandler> {
Some(self.display.clone())
}
fn load_handler(&self) -> Option<LoadHandler> {
Some(self.load.clone())
}
fn life_span_handler(&self) -> Option<LifeSpanHandler> {
Some(self.life.clone())
}
fn request_handler(&self) -> Option<RequestHandler> {
Some(self.requests.clone())
}
fn on_process_message_received(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
source_process: ProcessId,
message: Option<&mut ProcessMessage>,
) -> ::std::os::raw::c_int {
router().on_process_message_received(
browser.map(|b| b.clone()),
frame.map(|f| f.clone()),
source_process,
message.map(|m| m.clone()),
) as _
}
}
}
/// A browser's handlers, all sharing its surface.
pub(crate) fn client(surface: &Shared) -> cef::Client {
Handlers::new(
Paint::new(surface.clone()),
Display::new(surface.clone()),
Load::new(surface.clone()),
Life::new(surface.clone()),
Requests::new(surface.clone()),
keys(),
)
}
#[cfg(target_os = "macos")]
fn keys() -> Option<KeyboardHandler> {
Some(crate::edit::Keys::new())
}
/// Elsewhere Chromium binds these keys itself.
#[cfg(not(target_os = "macos"))]
fn keys() -> Option<KeyboardHandler> {
None
}
+344
View File
@@ -0,0 +1,344 @@
//! GPUI's mouse, keys, focus and IME, as CEF events.
use std::ops::Range;
use cef::*;
use gpui::{
App, Bounds, Context, Div, Entity, EntityInputHandler, InteractiveElement as _, KeyDownEvent,
KeyUpEvent, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
Point, ScrollDelta, ScrollWheelEvent, UTF16Selection, Window, point, px, size,
};
use crate::{keys, view::WebView};
gpui::actions!(web, [PageTab, PageBackTab]);
/// The page's key context: Tab stays in the page.
const CONTEXT: &str = "WebView";
/// Tab and Shift-Tab go to the page, not to focus.
pub(crate) fn bind(cx: &mut App) {
cx.bind_keys([
gpui::KeyBinding::new("tab", PageTab, Some(CONTEXT)),
gpui::KeyBinding::new("shift-tab", PageBackTab, Some(CONTEXT)),
]);
}
/// CEF's `cef_event_flags_t` for these modifiers.
pub(crate) fn flags(modifiers: &Modifiers) -> u32 {
let mut flags = 0;
for (on, bit) in [
(modifiers.shift, 1 << 1),
(modifiers.control, 1 << 2),
(modifiers.alt, 1 << 3),
(modifiers.platform, 1 << 7),
] {
if on {
flags |= bit;
}
}
flags
}
fn button(button: MouseButton) -> Option<(MouseButtonType, u32)> {
match button {
MouseButton::Left => Some((MouseButtonType::LEFT, 1 << 4)),
MouseButton::Middle => Some((MouseButtonType::MIDDLE, 1 << 5)),
MouseButton::Right => Some((MouseButtonType::RIGHT, 1 << 6)),
_ => None,
}
}
impl WebView {
/// A window point as a page point.
fn at(&self, position: Point<Pixels>, modifiers: u32) -> MouseEvent {
let local = position - self.bounds.origin;
MouseEvent {
x: f32::from(local.x).round() as i32,
y: f32::from(local.y).round() as i32,
modifiers,
}
}
fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
window.focus(&self.focus, cx);
let Some((kind, held)) = button(event.button) else {
return;
};
let at = self.at(event.position, flags(&event.modifiers) | held);
let count = event.click_count as i32;
self.on_host(move |host| host.send_mouse_click_event(Some(&at), kind, false as _, count));
}
fn mouse_up(&mut self, event: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
let Some((kind, _)) = button(event.button) else {
return;
};
let at = self.at(event.position, flags(&event.modifiers));
let count = event.click_count as i32;
self.on_host(move |host| host.send_mouse_click_event(Some(&at), kind, true as _, count));
}
fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, _: &mut Context<Self>) {
let held = event
.pressed_button
.and_then(button)
.map_or(0, |(_, bit)| bit);
let at = self.at(event.position, flags(&event.modifiers) | held);
self.on_host(move |host| host.send_mouse_move_event(Some(&at), false as _));
}
fn scroll(&mut self, event: &ScrollWheelEvent, _: &mut Window, _: &mut Context<Self>) {
// Lines as Chromium counts them: forty points.
let delta = match event.delta {
ScrollDelta::Pixels(delta) => (f32::from(delta.x), f32::from(delta.y)),
ScrollDelta::Lines(delta) => (delta.x * 40.0, delta.y * 40.0),
};
let at = self.at(event.position, flags(&event.modifiers));
let (x, y) = (delta.0.round() as i32, delta.1.round() as i32);
self.on_host(move |host| host.send_mouse_wheel_event(Some(&at), x, y));
}
fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let stroke = &event.keystroke;
// Edit commands Chromium only takes from its own menu.
let edit = stroke.key.clone();
if keys::edit_chord(&stroke.modifiers) && matches!(edit.as_str(), "c" | "x" | "v" | "a") {
// The owner may take a paste, such as an image.
if edit == "v" && self.paste.clone().is_some_and(|take| take(window, cx)) {
return cx.stop_propagation();
}
self.on_browser(move |browser| {
let Some(frame) = browser.main_frame() else {
return;
};
match edit.as_str() {
"c" => frame.copy(),
"x" => frame.cut(),
"v" => frame.paste(),
_ => frame.select_all(),
}
});
return cx.stop_propagation();
}
let Some(code) = keys::code(&stroke.key) else {
return log::debug!("web: no key code for {}", stroke.key);
};
let event = KeyEvent {
modifiers: flags(&stroke.modifiers),
windows_key_code: code.windows,
native_key_code: code.native,
..Default::default()
};
let chord = stroke.modifiers.control || stroke.modifiers.platform;
match (keys::character(&stroke.key), &stroke.key_char) {
// Typed text arrives by the input handler, which sends it.
(None, Some(_)) if !chord => self.typing = Some(event),
(character, typed) => {
let character = character
.or_else(|| typed.as_deref().and_then(|t| t.encode_utf16().next()))
.unwrap_or(0);
self.press(event, character);
}
}
}
/// Tab, with Shift when `back`, down and up.
fn tab(&mut self, back: bool) {
let code = keys::code("tab").expect("tab has a key code");
let event = KeyEvent {
modifiers: if back { 1 << 1 } else { 0 },
windows_key_code: code.windows,
native_key_code: code.native,
..Default::default()
};
self.press(event.clone(), 0x09);
let up = KeyEvent {
type_: KeyEventType::KEYUP,
character: 0x09,
unmodified_character: 0x09,
..event
};
self.on_host(move |host| host.send_key_event(Some(&up)));
}
/// A key down and its character, as cefclient sends them.
fn press(&self, event: KeyEvent, character: u16) {
let mut event = KeyEvent {
type_: KeyEventType::KEYDOWN,
character,
unmodified_character: character,
..event
};
self.on_host(move |host| {
host.send_key_event(Some(&event));
event.type_ = KeyEventType::CHAR;
host.send_key_event(Some(&event));
});
}
fn key_up(&mut self, event: &KeyUpEvent, _: &mut Window, _: &mut Context<Self>) {
let stroke = &event.keystroke;
let Some(code) = keys::code(&stroke.key) else {
return;
};
// An up without its character reads as another down.
let character = keys::character(&stroke.key)
.or_else(|| {
stroke
.key_char
.as_deref()
.and_then(|t| t.encode_utf16().next())
})
.or_else(|| stroke.key.encode_utf16().next())
.unwrap_or(0);
let up = KeyEvent {
type_: KeyEventType::KEYUP,
modifiers: flags(&stroke.modifiers),
windows_key_code: code.windows,
native_key_code: code.native,
character,
unmodified_character: character,
..Default::default()
};
self.on_host(move |host| host.send_key_event(Some(&up)));
}
}
/// The view's listeners on its outer element.
pub(crate) fn listen(element: Div, cx: &mut Context<WebView>) -> Div {
element
.key_context(CONTEXT)
.on_action(cx.listener(|view, _: &PageTab, _, _| view.tab(false)))
.on_action(cx.listener(|view, _: &PageBackTab, _, _| view.tab(true)))
.on_mouse_down(MouseButton::Left, cx.listener(WebView::mouse_down))
.on_mouse_down(MouseButton::Right, cx.listener(WebView::mouse_down))
.on_mouse_down(MouseButton::Middle, cx.listener(WebView::mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(WebView::mouse_up))
.on_mouse_up(MouseButton::Right, cx.listener(WebView::mouse_up))
.on_mouse_up(MouseButton::Middle, cx.listener(WebView::mouse_up))
.on_mouse_move(cx.listener(WebView::mouse_move))
.on_scroll_wheel(cx.listener(WebView::scroll))
.on_key_down(cx.listener(WebView::key_down))
.on_key_up(cx.listener(WebView::key_up))
}
/// CEF's page focus follows the view's.
pub(crate) fn follow_focus(view: &Entity<WebView>, window: &mut Window, cx: &mut App) {
view.update(cx, |this, cx| {
let focus = this.focus.clone();
cx.on_focus(&focus, window, |view, _, _| {
view.on_host(|host| host.set_focus(true as _));
})
.detach();
cx.on_blur(&focus, window, |view, _, _| {
view.on_host(|host| host.set_focus(false as _));
})
.detach();
});
}
/// CEF's "no range": both ends at the maximum.
const NO_RANGE: cef::Range = cef::Range {
from: u32::MAX,
to: u32::MAX,
};
fn utf16(text: &str) -> Vec<u16> {
text.encode_utf16().collect()
}
impl EntityInputHandler for WebView {
/// The IME sees none of the page's text.
fn text_for_range(
&mut self,
_: Range<usize>,
_: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
None
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
None
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
self.composing.clone()
}
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
if self.composing.take().is_some() {
self.on_host(|host| host.ime_finish_composing_text(false as _));
}
}
fn replace_text_in_range(
&mut self,
_: Option<Range<usize>>,
text: &str,
_: &mut Window,
_: &mut Context<Self>,
) {
let typed = self.typing.take();
let was = self.composing.take();
let units = utf16(text);
// One character, no composition: a key press, as cefclient.
if let (Some(event), None, [character]) = (typed, was, units.as_slice()) {
return self.press(event, *character);
}
let text = CefString::from(text);
self.on_host(move |host| host.ime_commit_text(Some(&text), Some(&NO_RANGE), 0));
}
fn replace_and_mark_text_in_range(
&mut self,
_: Option<Range<usize>>,
new_text: &str,
new_selected_range: Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) {
let length = utf16(new_text).len();
self.composing = (length > 0).then_some(0..length);
let selection = new_selected_range.unwrap_or(length..length);
let range = cef::Range {
from: selection.start as _,
to: selection.end as _,
};
let text = CefString::from(new_text);
self.on_host(move |host| {
host.ime_set_composition(Some(&text), None, Some(&NO_RANGE), Some(&range))
});
}
fn bounds_for_range(
&mut self,
range: Range<usize>,
element: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let surface = self.surface.lock().expect("the surface lock");
let (x, y, w, h) = *surface.ime.get(range.start).or(surface.ime.last())?;
Some(Bounds::new(
element.origin + point(px(x), px(y)),
size(px(w), px(h)),
))
}
fn character_index_for_point(
&mut self,
_: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
None
}
}
+189
View File
@@ -0,0 +1,189 @@
//! GPUI key names as the codes Chromium reads.
use gpui::Modifiers;
/// Windows' virtual key, and macOS's hardware key.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Code {
pub windows: i32,
pub native: i32,
}
/// Letters, then digits, as macOS numbers its keys.
const MAC_LETTERS: [i32; 26] = [
0, 11, 8, 2, 14, 3, 5, 4, 34, 38, 40, 37, 46, 45, 31, 35, 12, 15, 1, 17, 32, 9, 13, 7, 16, 6,
];
const MAC_DIGITS: [i32; 10] = [29, 18, 19, 20, 21, 23, 22, 26, 28, 25];
const MAC_F: [i32; 12] = [122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111];
/// Named keys: GPUI's name, Windows, macOS.
const NAMED: &[(&str, i32, i32)] = &[
("backspace", 0x08, 51),
("tab", 0x09, 48),
("enter", 0x0D, 36),
("escape", 0x1B, 53),
("space", 0x20, 49),
("pageup", 0x21, 116),
("pagedown", 0x22, 121),
("end", 0x23, 119),
("home", 0x24, 115),
("left", 0x25, 123),
("up", 0x26, 126),
("right", 0x27, 124),
("down", 0x28, 125),
("delete", 0x2E, 117),
(";", 0xBA, 41),
("=", 0xBB, 24),
(",", 0xBC, 43),
("-", 0xBD, 27),
(".", 0xBE, 47),
("/", 0xBF, 44),
("`", 0xC0, 50),
("[", 0xDB, 33),
("\\", 0xDC, 42),
("]", 0xDD, 30),
("'", 0xDE, 39),
];
pub fn code(key: &str) -> Option<Code> {
if let Some(&(_, windows, native)) = NAMED.iter().find(|(name, _, _)| *name == key) {
return Some(Code { windows, native });
}
let mut chars = key.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
let n: usize = key.strip_prefix('f')?.parse().ok()?;
let native = *MAC_F.get(n.checked_sub(1)?)?;
return Some(Code {
windows: 0x6F + n as i32,
native,
});
};
match c {
'a'..='z' => Some(Code {
windows: c.to_ascii_uppercase() as i32,
native: MAC_LETTERS[(c as u8 - b'a') as usize],
}),
'0'..='9' => Some(Code {
windows: c as i32,
native: MAC_DIGITS[(c as u8 - b'0') as usize],
}),
_ => None,
}
}
/// The character AppKit gives keys that type none.
#[cfg(target_os = "macos")]
pub fn character(key: &str) -> Option<u16> {
let n: Option<u16> = key.strip_prefix('f').and_then(|n| n.parse().ok());
Some(match key {
"backspace" => 0x7F,
"tab" => 0x09,
"enter" => 0x0D,
"escape" => 0x1B,
"up" => 0xF700,
"down" => 0xF701,
"left" => 0xF702,
"right" => 0xF703,
"delete" => 0xF728,
"home" => 0xF729,
"end" => 0xF72B,
"pageup" => 0xF72C,
"pagedown" => 0xF72D,
_ => match n {
Some(n @ 1..=12) => 0xF703 + n,
_ => return None,
},
})
}
/// The control character other systems type, if any.
#[cfg(not(target_os = "macos"))]
pub fn character(key: &str) -> Option<u16> {
Some(match key {
"backspace" => 0x08,
"tab" => 0x09,
"enter" => 0x0D,
"escape" => 0x1B,
_ => return None,
})
}
/// The chord for cut, copy, paste and select-all here.
pub fn edit_chord(modifiers: &Modifiers) -> bool {
let primary = if cfg!(target_os = "macos") {
modifiers.platform && !modifiers.control
} else {
modifiers.control && !modifiers.platform
};
primary && !modifiers.alt && !modifiers.shift
}
#[cfg(test)]
mod tests {
use super::{Code, code};
#[test]
fn keys_carry_both_codes() {
assert_eq!(
code("a"),
Some(Code {
windows: 0x41,
native: 0
})
);
assert_eq!(
code("z"),
Some(Code {
windows: 0x5A,
native: 6
})
);
assert_eq!(
code("0"),
Some(Code {
windows: 0x30,
native: 29
})
);
assert_eq!(
code("enter"),
Some(Code {
windows: 0x0D,
native: 36
})
);
assert_eq!(
code("f1"),
Some(Code {
windows: 0x70,
native: 122
})
);
assert_eq!(
code("f12"),
Some(Code {
windows: 0x7B,
native: 111
})
);
assert_eq!(
code("["),
Some(Code {
windows: 0xDB,
native: 33
})
);
assert_eq!(code("f13"), None);
assert_eq!(code("é"), None);
}
#[cfg(target_os = "macos")]
#[test]
fn keys_without_text_carry_appkit_characters() {
assert_eq!(super::character("backspace"), Some(0x7F));
assert_eq!(super::character("up"), Some(0xF700));
assert_eq!(super::character("f1"), Some(0xF704));
assert_eq!(super::character("f12"), Some(0xF70F));
assert_eq!(super::character("a"), None);
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Bundled pages: CEF draws off screen, GPUI shows.
#[cfg(target_os = "macos")]
mod edit;
mod engine;
mod fence;
mod handlers;
mod input;
mod keys;
#[cfg(target_os = "macos")]
mod mac;
mod print;
mod surface;
mod tokens;
mod view;
pub use engine::{start, subprocess};
pub use surface::file_url;
pub use view::{Event, Reply, WebView};
+105
View File
@@ -0,0 +1,105 @@
//! Chromium's app protocol on GPUI's own `NSApplication` class.
use std::{cell::Cell, ffi::CStr};
use anyhow::{Result, bail};
use objc2::{
ffi,
runtime::{AnyClass, AnyObject, Bool, Imp, Sel},
sel,
};
thread_local! {
/// NSApp lives on the main thread; one flag serves it.
static SENDING: Cell<bool> = const { Cell::new(false) };
}
extern "C-unwind" fn is_handling(_this: *mut AnyObject, _cmd: Sel) -> Bool {
Bool::new(SENDING.get())
}
extern "C-unwind" fn set_handling(_this: *mut AnyObject, _cmd: Sel, on: Bool) {
SENDING.set(on.as_bool());
}
extern "C-unwind" fn send_event(this: *mut AnyObject, _cmd: Sel, event: *mut AnyObject) {
let was = SENDING.replace(true);
let base = AnyClass::get(c"NSApplication").expect("AppKit has NSApplication");
unsafe {
let _: () = objc2::msg_send![super(&*this, base), sendEvent: event];
}
SENDING.set(was);
}
fn add(class: &AnyClass, name: Sel, imp: Imp, types: &CStr) -> Result<()> {
let added = unsafe {
ffi::class_addMethod(
class as *const AnyClass as *mut AnyClass,
name,
imp,
types.as_ptr(),
)
};
if !added.as_bool() {
bail!("{} already has -{name}", class.name().to_string_lossy());
}
Ok(())
}
/// Gives NSApp's class what CEF checks before it runs.
pub fn adopt_app_protocol() -> Result<()> {
let Some(app) = (unsafe { objc2_app_kit_nsapp() }) else {
bail!("no NSApp yet");
};
let class = unsafe { &*ffi::object_getClass(app.cast()) };
if class.responds_to(sel!(isHandlingSendEvent)) {
return Ok(());
}
unsafe {
add(
class,
sel!(isHandlingSendEvent),
std::mem::transmute(is_handling as *const ()),
c"c@:",
)?;
add(
class,
sel!(setHandlingSendEvent:),
std::mem::transmute(set_handling as *const ()),
c"v@:c",
)?;
add(
class,
sel!(sendEvent:),
std::mem::transmute(send_event as *const ()),
c"v@:@",
)?;
}
// Chromium checks the two `Cr` protocols; CEF's own is header-only.
for (name, needed) in [
(c"CrAppProtocol", true),
(c"CrAppControlProtocol", true),
(c"CefAppProtocol", false),
] {
let protocol = unsafe { ffi::objc_getProtocol(name.as_ptr()) };
match (protocol.is_null(), needed) {
(true, true) => bail!("CEF did not register {}", name.to_string_lossy()),
(true, false) => log::info!("web: {} is not registered", name.to_string_lossy()),
(false, _) => unsafe {
ffi::class_addProtocol(class as *const AnyClass as *mut AnyClass, protocol);
},
}
}
log::info!(
"web: {} takes CefAppProtocol",
class.name().to_string_lossy()
);
Ok(())
}
/// The shared application, if made.
unsafe fn objc2_app_kit_nsapp() -> Option<*mut AnyObject> {
let class = AnyClass::get(c"NSApplication")?;
let app: *mut AnyObject = unsafe { objc2::msg_send![class, sharedApplication] };
(!app.is_null()).then_some(app)
}
+39
View File
@@ -0,0 +1,39 @@
//! A page as PDF, printed by Chromium itself (MARK-11).
use std::path::{Path, PathBuf};
use cef::*;
use crate::{surface::Shared, view::WebView};
wrap_pdf_print_callback! {
struct Printed {
surface: Shared,
}
impl PdfPrintCallback {
fn on_pdf_print_finished(&self, path: Option<&CefString>, ok: ::std::os::raw::c_int) {
let path = PathBuf::from(path.map(CefString::to_string).unwrap_or_default());
log::info!("web: printed {} ok {}", path.display(), ok != 0);
self.surface.lock().expect("the surface lock").printed.push((path, ok != 0));
}
}
}
impl WebView {
/// Prints the page to `path`; `Event::Printed` tells how.
pub fn print_pdf(&self, path: &Path) {
let (path, surface) = (
CefString::from(path.to_string_lossy().as_ref()),
self.surface.clone(),
);
self.on_host(move |host| {
let settings = PdfPrintSettings {
print_background: 1,
..Default::default()
};
let mut done = Printed::new(surface);
host.print_to_pdf(Some(&path), Some(&settings), Some(&mut done));
});
}
}
+275
View File
@@ -0,0 +1,275 @@
//! What CEF's handlers leave the view; the page fence.
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use cef::{wrapper::message_router::BrowserSideCallback, *};
pub(crate) type Shared = Arc<Mutex<Surface>>;
pub(crate) struct Surface {
/// Points, as GPUI lays the view out.
pub size: (f32, f32),
pub scale: f32,
pub frame: Option<(u32, u32, Vec<u8>)>,
/// Composing characters' boxes, in points.
pub ime: Vec<(f32, f32, f32, f32)>,
pub cursor: CursorType,
pub loaded: bool,
/// The main frame's load failed since it started.
pub errored: bool,
pub queries: Vec<(String, Arc<Mutex<dyn BrowserSideCallback>>)>,
pub leaving: Vec<String>,
/// PDFs printed: where, and whether it worked.
pub printed: Vec<(PathBuf, bool)>,
/// Why the page cannot show: load, renderer or start.
pub failed: Option<String>,
/// Made on the pump's turn, after the view.
pub browser: Option<Browser>,
/// The view went before its browser came.
pub closed: bool,
/// The only folder pages load from, real path.
root: PathBuf,
/// A folder images alone may also come from.
pub images: Option<PathBuf>,
}
impl Surface {
pub fn new(root: PathBuf, scale: f32) -> Self {
Surface {
size: (1.0, 1.0),
scale,
frame: None,
ime: Vec::new(),
cursor: CursorType::POINTER,
loaded: false,
errored: false,
queries: Vec::new(),
leaving: Vec::new(),
printed: Vec::new(),
failed: None,
browser: None,
closed: false,
root,
images: None,
}
}
/// A file under `root`, by its real path.
fn holds(root: &Path, url: &str) -> bool {
let Some(path) = url.strip_prefix("file://") else {
return false;
};
let path = path.split(['?', '#']).next().unwrap_or_default();
let Ok(path) = String::from_utf8(decoded(path)) else {
return false;
};
#[cfg(windows)]
let path = windows_path(&path);
std::fs::canonicalize(path).is_ok_and(|real| real.starts_with(root))
}
/// Where the page may go: its own files, or blank.
pub fn navigable(&self, url: &str) -> bool {
url == "about:blank" || Self::holds(&self.root, url)
}
/// What a page loads: its files, data, allowed images.
pub fn loadable(&self, url: &str, image: bool) -> bool {
let pictured = image
&& self
.images
.as_deref()
.is_some_and(|dir| Self::holds(dir, url));
self.navigable(url)
|| url.starts_with("data:")
|| url.starts_with("blob:file://")
|| pictured
}
}
impl Drop for Surface {
fn drop(&mut self) {
log::info!("web: a page's memory is freed");
}
}
/// `%XX` decoded; a file URL's path.
fn decoded(text: &str) -> Vec<u8> {
let bytes = text.as_bytes();
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
match (bytes[at], hex) {
(b'%', Some(byte)) => {
out.push(byte);
at += 3;
}
(byte, _) => {
out.push(byte);
at += 1;
}
}
}
out
}
/// Bytes as URL text, reserved ones escaped.
fn escaped(bytes: &[u8]) -> String {
let mut out = String::new();
for &byte in bytes {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
/// A path as a file URL, every reserved byte escaped.
pub fn file_url(path: &Path) -> String {
#[cfg(windows)]
return windows_url(&path.to_string_lossy());
#[cfg(not(windows))]
format!("file://{}", escaped(path.as_os_str().as_encoded_bytes()))
}
/// A drive or UNC path as `file:///C:/…` or `file://host/…`.
#[cfg(any(windows, test))]
fn windows_url(path: &str) -> String {
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(rest) => format!(r"\\{rest}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
};
let slashed = |text: &str| escaped(text.replace('\\', "/").as_bytes());
match path.strip_prefix(r"\\") {
Some(unc) => {
let (host, rest) = unc.split_once('\\').unwrap_or((unc, ""));
format!("file://{}/{}", slashed(host), slashed(rest))
}
None if path.get(1..2) == Some(":") => {
format!("file:///{}{}", &path[..2], slashed(&path[2..]))
}
None => format!("file:///{}", slashed(&path)),
}
}
/// A decoded file URL's rest as a Windows path.
#[cfg(any(windows, test))]
fn windows_path(rest: &str) -> String {
match rest.strip_prefix('/') {
Some(drive) => drive.replace('/', "\\"),
None => format!(r"\\{}", rest.replace('/', "\\")),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
fn surface(root: &Path) -> super::Surface {
super::Surface::new(std::fs::canonicalize(root).expect("real"), 1.0)
}
#[test]
fn only_real_files_under_the_root_load() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("content");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&outside).expect("makes");
std::fs::write(root.join("index.html"), "x").expect("writes");
std::fs::write(root.join("a #b?.html"), "x").expect("writes");
std::fs::write(outside.join("o.html"), "x").expect("writes");
let surface = surface(&root);
let url = |path: &Path| super::file_url(&std::fs::canonicalize(path).expect("real"));
assert!(surface.navigable(&url(&root.join("index.html"))));
assert!(
surface.navigable(&url(&root.join("a #b?.html"))),
"reserved bytes"
);
assert!(surface.navigable("about:blank"));
assert!(!surface.navigable(&url(&outside.join("o.html"))));
let up = format!("{}/../outside/o.html", super::file_url(&surface.root));
assert!(!surface.navigable(&up));
assert!(!surface.navigable(&format!("{}/missing.html", super::file_url(&surface.root))));
assert!(!surface.navigable("https://example.com/"));
assert!(
!surface.navigable("data:text/html,x")
&& surface.loadable("data:image/png;base64,x", true)
);
assert!(!surface.loadable("http://127.0.0.1:9/pixel", true));
}
#[test]
fn an_image_folder_admits_images_alone() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, docs) = (dir.path().join("content"), dir.path().join("docs"));
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&docs).expect("makes");
std::fs::write(docs.join("a.png"), "x").expect("writes");
let mut surface = surface(&root);
let picture = super::file_url(&std::fs::canonicalize(docs.join("a.png")).expect("real"));
assert!(
!surface.loadable(&picture, true),
"not before it is allowed"
);
surface.images = Some(std::fs::canonicalize(&docs).expect("real"));
assert!(surface.loadable(&picture, true));
assert!(!surface.loadable(&picture, false), "a script from there");
assert!(!surface.navigable(&picture));
}
#[cfg(unix)]
#[test]
fn a_link_out_of_the_root_is_outside() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("content");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&outside).expect("makes");
std::fs::write(outside.join("o.html"), "x").expect("writes");
std::os::unix::fs::symlink(&outside, root.join("link")).expect("links");
let surface = surface(&root);
let through = format!("{}/link/o.html", super::file_url(&surface.root));
assert!(!surface.navigable(&through));
}
#[test]
fn windows_paths_make_valid_file_urls_and_back() {
let cases = [
(r"C:\Users\A b\doc.md", "file:///C:/Users/A%20b/doc.md"),
(r"\\?\C:\notes\doc.md", "file:///C:/notes/doc.md"),
(r"\\server\share\doc.md", "file://server/share/doc.md"),
(r"\\?\UNC\server\share\doc.md", "file://server/share/doc.md"),
];
for (path, url) in cases {
assert_eq!(super::windows_url(path), url, "{path}");
}
assert_eq!(
super::windows_path("/C:/Users/A b/doc.md"),
r"C:\Users\A b\doc.md"
);
assert_eq!(
super::windows_path("server/share/doc.md"),
r"\\server\share\doc.md"
);
}
#[test]
fn percent_escapes_decode_and_bad_ones_stay() {
assert_eq!(super::decoded("a%20b%e4%b8%ad"), "a b中".as_bytes());
assert_eq!(super::decoded("100%"), b"100%");
assert_eq!(super::decoded("%zz"), b"%zz");
assert_eq!(
super::file_url(Path::new("/a b/中#?.html")),
"file:///a%20b/%E4%B8%AD%23%3F.html"
);
}
}
+77
View File
@@ -0,0 +1,77 @@
//! The app's theme as CSS variables on the page's root.
use ely_gpui_component::theme::{ActiveTheme as _, Palette};
use gpui::{App, Hsla, Rgba};
fn css(color: Hsla) -> String {
let rgba = Rgba::from(color);
let byte = |part: f32| (part.clamp(0.0, 1.0) * 255.0).round() as u8;
format!(
"rgba({}, {}, {}, {:.3})",
byte(rgba.r),
byte(rgba.g),
byte(rgba.b),
rgba.a
)
}
/// `--ely-*` names and values, palette then type.
pub fn tokens(cx: &App) -> Vec<(String, String)> {
let theme = cx.theme();
let colors: &Palette = &theme.colors;
let mut out: Vec<(String, String)> = [
("bg", colors.bg),
("surface", colors.surface),
("sunken", colors.sunken),
("hover", colors.hover),
("border", colors.border),
("fg", colors.fg),
("fg-muted", colors.fg_muted),
("fg-subtle", colors.fg_subtle),
("accent", colors.accent),
("on-accent", colors.on_accent),
("focus", colors.focus),
("link", colors.link),
("selection", colors.selection),
("danger", colors.danger),
("warning", colors.warning),
("success", colors.success),
("keyword", colors.syntax.keyword),
("string", colors.syntax.string),
("number", colors.syntax.number),
("comment", colors.syntax.comment),
("function", colors.syntax.function),
("type", colors.syntax.type_name),
("tag", colors.syntax.tag),
("attribute", colors.syntax.attribute),
("punctuation", colors.syntax.punctuation),
]
.into_iter()
.map(|(name, color)| (format!("--ely-{name}"), css(color)))
.collect();
out.push(("--ely-font".into(), format!("\"{}\"", theme.font_family)));
out.push(("--ely-mono".into(), format!("\"{}\"", theme.mono_family)));
out.push(("--ely-font-scale".into(), theme.font_scale.to_string()));
let scheme = if theme.is_dark() { "dark" } else { "light" };
out.push(("color-scheme".into(), scheme.into()));
out
}
/// Script that sets `tokens` on `:root`.
pub fn script(tokens: &[(String, String)]) -> String {
let pairs = serde_json::to_string(tokens).expect("strings serialize");
format!("for (const [k, v] of {pairs}) document.documentElement.style.setProperty(k, v);")
}
#[cfg(test)]
mod tests {
#[test]
fn a_script_sets_each_token_as_json() {
let tokens = vec![("--ely-fg".to_string(), "rgba(1, 2, 3, 1.000)".to_string())];
let script = super::script(&tokens);
assert!(
script.contains(r#"[["--ely-fg","rgba(1, 2, 3, 1.000)"]]"#),
"{script}"
);
}
}
+366
View File
@@ -0,0 +1,366 @@
//! A CEF page drawn by GPUI: frames in, events out.
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use anyhow::{Result, bail};
use cef::{wrapper::message_router::BrowserSideCallback, *};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
feedback::ErrorView,
primitives::IconName,
};
use gpui::{
AnyElement, App, AppContext as _, Bounds, Context, CursorStyle, ElementInputHandler, Entity,
EventEmitter, FocusHandle, Focusable, Hitbox, HitboxBehavior, InteractiveElement as _,
IntoElement, ParentElement as _, Pixels, Render, RenderImage, Styled as _, Window, canvas, div,
};
use image::{Frame, ImageBuffer, Rgba};
use crate::{
engine::Engine,
surface::{Shared, Surface, file_url},
};
/// A page's answer to one `elyQuery`.
#[derive(Clone)]
pub struct Reply(Arc<Mutex<dyn BrowserSideCallback>>);
impl Reply {
pub fn ok(&self, text: &str) {
self.0.lock().expect("the reply lock").success_str(text);
}
pub fn fail(&self, message: &str) {
self.0.lock().expect("the reply lock").failure(1, message);
}
}
/// What the page tells its owner.
pub enum Event {
Loaded,
Query(String, Reply),
/// A link to leave by; the owner decides.
Leave(String),
/// The page cannot show: load, start or run failed.
Failed(String),
/// A PDF print ended: where, and whether it worked.
Printed(PathBuf, bool),
}
pub struct WebView {
pub(crate) surface: Shared,
image: Option<Arc<RenderImage>>,
/// Replaced frames, freed from GPUI's atlas next paint.
stale: Vec<Arc<RenderImage>>,
pub(crate) focus: FocusHandle,
pub(crate) bounds: Bounds<Pixels>,
pub(crate) composing: Option<std::ops::Range<usize>>,
/// The key whose text the input handler brings next.
pub(crate) typing: Option<cef::KeyEvent>,
url: String,
/// Shown instead of the page until reloaded.
fault: Option<String>,
/// Takes a paste before the page; true when it did.
pub(crate) paste: Option<std::rc::Rc<dyn Fn(&mut Window, &mut App) -> bool>>,
}
impl EventEmitter<Event> for WebView {}
impl Focusable for WebView {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl WebView {
/// Its frames, for a closing owner to free.
pub fn retire_all(&mut self) -> ely_gpui_component::primitives::Retired {
let mut images = ely_gpui_component::primitives::Retired::default();
for image in self.image.take().into_iter().chain(self.stale.drain(..)) {
images.push(image);
}
images
}
/// Opens `page`, a file in `root`, in a new browser.
pub fn open(
page: &Path,
root: &Path,
window: &mut Window,
cx: &mut App,
) -> Result<Entity<Self>> {
if !cx.has_global::<Engine>() {
bail!("the web engine has not started");
}
let root = std::fs::canonicalize(root)?;
let page = std::fs::canonicalize(page)?;
let surface = Arc::new(Mutex::new(Surface::new(root, window.scale_factor())));
let url = file_url(&page);
if !surface.lock().expect("the surface lock").navigable(&url) {
bail!("{} is outside its content folder", page.display());
}
log::info!("web: a browser asked for {url}");
crate::engine::ask_browser(surface.clone(), url.clone());
let view = cx.new(|cx| WebView {
surface,
image: None,
stale: Vec::new(),
focus: cx.focus_handle(),
bounds: Bounds::default(),
composing: None,
typing: None,
url,
fault: None,
paste: None,
});
crate::input::follow_focus(&view, window, cx);
view.update(cx, |_, cx| {
cx.observe_global::<ely_gpui_component::theme::Theme>(|view, cx| view.theme(cx))
.detach();
});
cx.global_mut::<Engine>().views.push(view.downgrade());
Ok(view)
}
/// The browser, on the pump's next turn, outside any update.
pub(crate) fn on_browser(&self, work: impl FnOnce(&Browser) + 'static) {
let surface = self.surface.clone();
crate::engine::later(move || {
// Not held while CEF runs: its callbacks lock it too.
let browser = surface.lock().expect("the surface lock").browser.clone();
if let Some(browser) = browser {
work(&browser);
}
});
}
pub(crate) fn on_host(&self, work: impl FnOnce(&BrowserHost) + 'static) {
self.on_browser(move |browser| {
if let Some(host) = browser.host() {
work(&host);
}
});
}
/// Lets images under `dir` load; one folder at a time.
pub fn allow_images(&self, dir: &Path) -> Result<()> {
let dir = std::fs::canonicalize(dir)?;
log::info!("web: images may come from {}", dir.display());
self.surface.lock().expect("the surface lock").images = Some(dir);
Ok(())
}
/// `take` sees pastes first; true keeps them from the page.
pub fn intercept_paste(&mut self, take: impl Fn(&mut Window, &mut App) -> bool + 'static) {
self.paste = Some(std::rc::Rc::new(take));
}
/// Runs script in the page's main frame.
pub fn run_js(&self, code: &str) {
let code = CefString::from(code);
self.on_browser(move |browser| {
if let Some(frame) = browser.main_frame() {
frame.execute_java_script(Some(&code), None, 0);
}
});
}
/// The app's colors and fonts, into the page.
fn theme(&mut self, cx: &mut Context<Self>) {
self.run_js(&crate::tokens::script(&crate::tokens::tokens(cx)));
}
/// Zoom as a factor; CEF counts in 1.2 steps.
pub fn set_zoom(&self, factor: f64) {
let level = factor.ln() / 1.2f64.ln();
self.on_host(move |host| host.set_zoom_level(level));
}
/// Takes what the handlers left; says if anything came.
fn take(&mut self, cx: &mut Context<Self>) {
let (frame, events) = {
let mut surface = self.surface.lock().expect("the surface lock");
let mut events = Vec::new();
if std::mem::take(&mut surface.loaded) {
events.push(Event::Loaded);
}
for (request, callback) in surface.queries.drain(..) {
events.push(Event::Query(request, Reply(callback)));
}
events.extend(surface.leaving.drain(..).map(Event::Leave));
events.extend(
surface
.printed
.drain(..)
.map(|(path, ok)| Event::Printed(path, ok)),
);
events.extend(surface.failed.take().map(Event::Failed));
if let Some(why) = crate::engine::failed().filter(|_| !surface.closed) {
surface.closed = true;
events.push(Event::Failed(why));
}
(surface.frame.take(), events)
};
if let Some((width, height, bgra)) = frame {
match ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, bgra) {
Some(picture) => {
let image = Arc::new(RenderImage::new([Frame::new(picture)]));
self.stale.extend(self.image.replace(image));
cx.notify();
}
None => log::error!("web: a {width}×{height} frame was short"),
}
}
for event in events {
match &event {
// Each load starts unthemed; the tokens go in first.
Event::Loaded => {
self.fault = None;
self.theme(cx);
}
Event::Failed(why) => {
log::error!("web: {} failed: {why}", self.url);
self.fault = Some(why.clone());
}
_ => {}
}
cx.notify();
cx.emit(event);
}
}
/// Follows the room GPUI gave; CEF resizes to it.
fn place(&mut self, bounds: Bounds<Pixels>, scale: f32) {
self.bounds = bounds;
let size = (f32::from(bounds.size.width), f32::from(bounds.size.height));
let (resized, rescaled) = {
let mut surface = self.surface.lock().expect("the surface lock");
let changed = (surface.size != size, surface.scale != scale);
(surface.size, surface.scale) = (size, scale);
changed
};
if resized || rescaled {
self.on_host(move |host| {
if rescaled {
host.notify_screen_info_changed();
}
host.was_resized();
});
}
}
/// Tries the page again; a lost browser is made anew.
pub fn reload(&mut self, cx: &mut Context<Self>) {
self.fault = None;
let made = self
.surface
.lock()
.expect("the surface lock")
.browser
.is_some();
match made {
true => self.on_browser(|browser| browser.reload()),
false => crate::engine::ask_browser(self.surface.clone(), self.url.clone()),
}
cx.notify();
}
/// The fault, with a way to try again.
fn fault_view(&self, why: &str, cx: &mut Context<Self>) -> AnyElement {
let this = cx.weak_entity();
let title = ely_ui::i18n::text(cx, "web.failed", &[]);
let again = Button::new("web-reload", ely_ui::i18n::text(cx, "web.reload", &[]))
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
// The page, not the gone button, takes keys.
this.update(cx, |view, cx| {
view.reload(cx);
window.focus(&view.focus, cx);
})
.ok();
});
let view =
ErrorView::new("web-failed", IconName::TriangleAlert, title).detail(why.to_string());
// Without a running engine nothing can try again.
match crate::engine::failed() {
Some(_) => view.into_any_element(),
None => view.action(again).into_any_element(),
}
}
fn cursor(&self) -> CursorStyle {
match self.surface.lock().expect("the surface lock").cursor {
CursorType::HAND => CursorStyle::PointingHand,
CursorType::IBEAM => CursorStyle::IBeam,
CursorType::CROSS => CursorStyle::Crosshair,
CursorType::EASTWESTRESIZE | CursorType::COLUMNRESIZE => CursorStyle::ResizeLeftRight,
CursorType::NORTHSOUTHRESIZE | CursorType::ROWRESIZE => CursorStyle::ResizeUpDown,
CursorType::NOTALLOWED => CursorStyle::OperationNotAllowed,
_ => CursorStyle::Arrow,
}
}
}
impl Drop for WebView {
fn drop(&mut self) {
self.surface.lock().expect("the surface lock").closed = true;
self.on_host(|host| host.close_browser(true as _));
}
}
/// After each pump: every live view takes its news.
pub(crate) fn deliver(cx: &mut App) {
let views = std::mem::take(&mut cx.global_mut::<Engine>().views);
let alive: Vec<_> = views
.into_iter()
.filter(|view| view.update(cx, |view, cx| view.take(cx)).is_ok())
.collect();
cx.global_mut::<Engine>().views.extend(alive);
}
impl Render for WebView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if let Some(why) = self.fault.clone() {
return div()
.size_full()
.child(self.fault_view(&why, cx))
.into_any_element();
}
let this = cx.entity();
let paint = this.clone();
let element = canvas(
move |bounds, window, cx| {
let scale = window.scale_factor();
this.update(cx, |view, _| view.place(bounds, scale));
window.insert_hitbox(bounds, HitboxBehavior::Normal)
},
move |bounds, hitbox: Hitbox, window, cx| {
let (image, stale, cursor, focus) = paint.update(cx, |view, _| {
let stale = std::mem::take(&mut view.stale);
(view.image.clone(), stale, view.cursor(), view.focus.clone())
});
for old in stale {
if let Err(error) = window.drop_image(old) {
log::error!("web: an old frame stayed: {error}");
}
}
window.set_cursor_style(cursor, &hitbox);
window.handle_input(&focus, ElementInputHandler::new(bounds, paint.clone()), cx);
let Some(image) = image else { return };
let painted =
window.paint_image(bounds, bounds, Default::default(), image, 0, false);
if let Err(error) = painted {
log::error!("web: a frame was not painted: {error}");
}
},
)
.size_full();
crate::input::listen(div().size_full(), cx)
.track_focus(&self.focus)
.child(element)
.into_any_element()
}
}