use crate::{ConsoleMessage, DataStore, LoadState, host::Surface};
use gpui::{AnyWindowHandle, App, Bounds, FocusHandle, Keystroke, Pixels, RenderImage, Window};
use std::{
cell::{Cell, RefCell},
rc::Rc,
sync::Arc,
};
#[cfg_attr(target_os = "macos", path = "page/macos.rs")]
#[cfg_attr(target_os = "windows", path = "page/windows.rs")]
#[cfg_attr(
not(any(target_os = "macos", target_os = "windows")),
path = "page/stub.rs"
)]
mod platform;
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
pub(crate) enum Report {
Pressed,
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
Key(Keystroke),
Moved,
Load(LoadState, String),
Title(String),
Opened(String),
Console(ConsoleMessage),
Still(Option<Arc<RenderImage>>),
Built,
}
enum Cover {
Off,
Capturing,
On(Option<Arc<RenderImage>>),
}
#[derive(Clone, Copy)]
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) enum Edit {
Copy,
Cut,
Paste,
SelectAll,
Undo,
Redo,
}
pub(crate) struct Page {
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
url: RefCell<String>,
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
pub(crate) user_agent: RefCell<Option<String>>,
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
pub(crate) store: RefCell<DataStore>,
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
pub(crate) console: Cell<bool>,
#[cfg(feature = "inspector")]
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
pub(crate) inspector: Cell<bool>,
#[cfg(any(target_os = "macos", target_os = "windows"))]
view: std::cell::OnceCell<Option<wry::WebView>>,
#[cfg(any(target_os = "macos", target_os = "windows"))]
building: Cell<bool>,
placed: Cell<Option<Bounds<Pixels>>>,
window: Cell<Option<AnyWindowHandle>>,
owner: Cell<u64>,
cover: RefCell<Cover>,
dropped: RefCell<Option<Arc<RenderImage>>>,
pub(crate) focus: FocusHandle,
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
reports: async_channel::Sender<Report>,
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
platform: platform::State,
}
impl Page {
pub(crate) const BINDS_EDITS: bool = platform::BINDS_EDITS;
pub(crate) fn new(
url: String,
focus: FocusHandle,
reports: async_channel::Sender<Report>,
cx: &App,
) -> Self {
Self {
url: RefCell::new(url),
user_agent: RefCell::new(None),
store: RefCell::new(DataStore::default()),
console: Cell::new(false),
#[cfg(feature = "inspector")]
inspector: Cell::new(false),
#[cfg(any(target_os = "macos", target_os = "windows"))]
view: std::cell::OnceCell::new(),
#[cfg(any(target_os = "macos", target_os = "windows"))]
building: Cell::new(false),
placed: Cell::new(None),
window: Cell::new(None),
owner: Cell::new(0),
cover: RefCell::new(Cover::Off),
dropped: RefCell::new(None),
focus,
reports,
platform: platform::State::new(cx),
}
}
}
impl Surface for Page {
fn focus(&self) -> Option<&FocusHandle> {
Some(&self.focus)
}
fn owner(&self) -> &Cell<u64> {
&self.owner
}
fn place(this: &Rc<Self>, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
Page::place(this, bounds, window, cx);
}
fn park(&self) {
Page::park(self);
}
fn cover(&self) {
if !matches!(*self.cover.borrow(), Cover::Off) {
return;
}
if self.placed.get().is_some() && self.capture() {
*self.cover.borrow_mut() = Cover::Capturing;
} else {
Page::park(self);
*self.cover.borrow_mut() = Cover::On(None);
}
}
fn still(&self) -> Option<Arc<RenderImage>> {
match &*self.cover.borrow() {
Cover::On(still) => still.clone(),
Cover::Off | Cover::Capturing => None,
}
}
fn take_dropped(&self) -> Option<Arc<RenderImage>> {
self.dropped.take()
}
}
impl Page {
pub(crate) fn captured(&self, still: Option<Arc<RenderImage>>) {
if matches!(*self.cover.borrow(), Cover::Capturing) {
Page::park(self);
*self.cover.borrow_mut() = Cover::On(still);
}
}
pub(crate) fn window(&self) -> Option<AnyWindowHandle> {
self.window.get()
}
fn uncover(&self) {
if let Cover::On(still) = self.cover.replace(Cover::Off) {
*self.dropped.borrow_mut() = still;
}
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
impl Page {
const PARKED: f64 = -20_000.0;
const MOVED: &str = "(() => {
const moved = () => window.ipc.postMessage('moved');
for (const name of ['pushState', 'replaceState']) {
const original = history[name];
history[name] = function (...args) {
const result = original.apply(this, args);
moved();
return result;
};
}
addEventListener('popstate', moved);
addEventListener('hashchange', moved);
})();";
const CONSOLE: &str = "(() => {
const handler = window.webkit?.messageHandlers?.ipc ?? window.chrome?.webview;
if (!handler) return;
const text = (value) => {
if (typeof value === 'string') return value;
if (value instanceof Error) return value.stack || String(value);
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
};
const send = (level, values) => {
try {
handler.postMessage(JSON.stringify({ level, text: values.map(text).join(' ') }));
} catch {}
};
for (const level of ['debug', 'log', 'info', 'warn', 'error']) {
const original = console[level];
console[level] = function (...values) {
send(level, values);
return original.apply(this, values);
};
}
addEventListener('error', (event) => send('error', [event.error ?? event.message]));
addEventListener('unhandledrejection', (event) =>
send('error', ['Uncaught (in promise)', event.reason]));
})();";
fn place(this: &Rc<Self>, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
this.uncover();
let Some(view) = this.view.get() else {
Self::request(this, bounds, window, cx);
return;
};
let Some(view) = view else { return };
if this.window.get() != Some(window.window_handle()) {
this.give_keys();
this.platform.parked();
if !platform::reparent(view, window) {
return;
}
this.window.set(Some(window.window_handle()));
this.placed.set(None);
}
let placed = this.placed.get();
if placed == Some(bounds) {
return;
}
let _ = view.set_bounds(rect(bounds));
let _ = view.set_visible(true);
this.placed.set(Some(bounds));
if placed.is_none() && this.focus.is_focused(window) {
this.take_keys();
}
}
fn request(this: &Rc<Self>, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
if this.building.replace(true) {
return;
}
let page = this.clone();
window
.spawn(cx, async move |cx| {
let Ok((handle, parent)) =
cx.update(|window, _| (window.window_handle(), Parent::of(window)))
else {
return;
};
let view = parent.and_then(|parent| page.build(bounds, &parent));
if view.is_some() {
page.window.set(Some(handle));
}
let _ = page.view.set(view);
let _ = page.reports.try_send(Report::Built);
})
.detach();
}
fn build(&self, bounds: Bounds<Pixels>, parent: &Parent) -> Option<wry::WebView> {
let url = self.url.borrow();
let (ipc, loads, titles, opened) = (
self.reports.clone(),
self.reports.clone(),
self.reports.clone(),
self.reports.clone(),
);
let store = self.store.borrow().clone();
let directory = store.directory.filter(|_| !store.incognito);
let view = with_context(directory, |context| {
let builder = match context {
Some(context) => wry::WebViewBuilder::new_with_web_context(context),
None => wry::WebViewBuilder::new(),
};
let builder = builder
.with_incognito(store.incognito)
.with_url(url.as_str())
.with_bounds(rect(bounds))
.with_visible(false)
.with_initialization_script(Self::MOVED)
.with_ipc_handler(move |request| {
let report = match request.body().as_str() {
"moved" => Report::Moved,
body => match logged(body, request.uri().to_string()) {
Some(message) => Report::Console(message),
None => return,
},
};
let _ = ipc.try_send(report);
})
.with_on_page_load_handler(move |event, url| {
let state = match event {
wry::PageLoadEvent::Started => LoadState::Started,
wry::PageLoadEvent::Finished => LoadState::Finished,
};
let _ = loads.try_send(Report::Load(state, url));
})
.with_document_title_changed_handler(move |title| {
let _ = titles.try_send(Report::Title(title));
})
.with_new_window_req_handler(move |url, _| {
let _ = opened.try_send(Report::Opened(url));
wry::NewWindowResponse::Deny
});
let user_agent = self
.user_agent
.borrow()
.clone()
.or_else(platform::default_user_agent);
let builder = match user_agent {
Some(user_agent) => builder.with_user_agent(user_agent),
None => builder,
};
let builder = if self.console.get() {
builder.with_initialization_script_for_main_only(Self::CONSOLE, false)
} else {
builder
};
#[cfg(feature = "inspector")]
let builder = builder.with_devtools(self.inspector.get());
let builder = platform::store(builder, store.identifier);
builder
.build_as_child(parent)
.inspect_err(|error| tracing::warn!(%error, url = %url, "webview: build"))
.ok()
})?;
self.platform.attach(&view, &self.reports);
Some(view)
}
fn built(&self) -> Option<&wry::WebView> {
self.view.get()?.as_ref()
}
fn capture(&self) -> bool {
let Some(view) = self.built() else {
return false;
};
let reports = self.reports.clone();
platform::capture(view, move |still| {
let _ = reports.try_send(Report::Still(still));
})
}
pub(crate) fn start(window: &Window, cx: &mut App) {
platform::start(window, cx);
}
pub(crate) fn watch_keys(&self, window: &Window) {
self.platform.watch_keys(window);
}
pub(crate) fn back(&self) {
if let Some(view) = self.built() {
platform::back(view);
}
}
pub(crate) fn forward(&self) {
if let Some(view) = self.built() {
platform::forward(view);
}
}
pub(crate) fn history(&self) -> (bool, bool) {
self.built().map_or((false, false), |view| {
(
view.can_go_back().unwrap_or(false),
view.can_go_forward().unwrap_or(false),
)
})
}
pub(crate) fn holds_keys(&self) -> bool {
self.built()
.is_some_and(|view| self.platform.holds_keys(view))
}
pub(crate) fn edit(&self, edit: Edit) {
if self.holds_keys() {
platform::edit(edit);
}
}
pub(crate) fn load(&self, url: String) {
match self.built() {
Some(view) => {
let _ = view.load_url(&url);
}
None => *self.url.borrow_mut() = url,
}
}
pub(crate) fn reload(&self) {
if let Some(view) = self.built() {
let _ = view.reload();
}
}
pub(crate) fn reload_bypassing_cache(&self) {
if let Some(view) = self.built() {
platform::reload_bypassing_cache(view);
}
}
pub(crate) fn location(&self) -> Option<String> {
self.built()?.url().ok()
}
#[cfg(feature = "inspector")]
pub(crate) fn open_inspector(&self) {
if let Some(view) = self.built() {
platform::open_inspector(view);
}
}
pub(crate) fn eval(&self, script: &str, done: impl Fn(String) + Send + 'static) -> bool {
self.built()
.is_some_and(|view| view.evaluate_script_with_callback(script, done).is_ok())
}
pub(crate) fn take_keys(&self) {
let Some(view) = self.built() else {
return;
};
if self.placed.get().is_some() && !platform::closed(view) {
let _ = view.focus();
}
}
pub(crate) fn give_keys(&self) {
if let Some(view) = self.built()
&& self.holds_keys()
{
platform::give_keys(view);
}
}
fn park(&self) {
let Some(view) = self.built() else {
return;
};
let Some(bounds) = self.placed.take() else {
return;
};
self.give_keys();
self.platform.parked();
let _ = view.set_visible(false);
if platform::closed(view) {
return;
}
let _ = view.set_bounds(wry::Rect {
position: wry::dpi::LogicalPosition::new(Self::PARKED, Self::PARKED).into(),
..rect(bounds)
});
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
struct Parent(raw_window_handle::RawWindowHandle);
#[cfg(any(target_os = "macos", target_os = "windows"))]
impl Parent {
fn of(window: &Window) -> Option<Self> {
raw_window_handle::HasWindowHandle::window_handle(window)
.ok()
.map(|handle| Self(handle.as_raw()))
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
impl raw_window_handle::HasWindowHandle for Parent {
fn window_handle(
&self,
) -> Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {
Ok(unsafe { raw_window_handle::WindowHandle::borrow_raw(self.0) })
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn logged(body: &str, source: String) -> Option<ConsoleMessage> {
#[derive(serde::Deserialize)]
struct Logged {
level: String,
text: String,
}
let Logged { level, text } = serde_json::from_str(body).ok()?;
use crate::ConsoleLevel;
let level = match level.as_str() {
"debug" => ConsoleLevel::Debug,
"log" => ConsoleLevel::Log,
"info" => ConsoleLevel::Info,
"warn" => ConsoleLevel::Warn,
"error" => ConsoleLevel::Error,
_ => return None,
};
Some(ConsoleMessage {
level,
text,
source,
})
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn with_context<T>(
directory: Option<std::path::PathBuf>,
build: impl FnOnce(Option<&mut wry::WebContext>) -> T,
) -> T {
match directory {
Some(directory) => shared_context(Some(directory), |context| build(Some(context))),
None => build(None),
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn shared_context<T>(
directory: Option<std::path::PathBuf>,
f: impl FnOnce(&mut wry::WebContext) -> T,
) -> T {
thread_local! {
static CONTEXTS: RefCell<
std::collections::HashMap<Option<std::path::PathBuf>, wry::WebContext>,
> = RefCell::default();
}
CONTEXTS.with_borrow_mut(|contexts| {
let context = contexts
.entry(directory.clone())
.or_insert_with(|| wry::WebContext::new(directory));
f(context)
})
}
pub(crate) fn clear_store(store: &DataStore, done: impl FnOnce(bool) + Send + 'static) {
platform::clear_store(store, done);
}
pub(crate) fn store_usage(
store: &DataStore,
done: impl FnOnce(Option<crate::Usage>) + Send + 'static,
) {
platform::store_usage(store, done);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn rect(bounds: Bounds<Pixels>) -> wry::Rect {
wry::Rect {
position: wry::dpi::LogicalPosition::new(
f64::from(f32::from(bounds.origin.x)),
f64::from(f32::from(bounds.origin.y)),
)
.into(),
size: wry::dpi::LogicalSize::new(
f64::from(f32::from(bounds.size.width)),
f64::from(f32::from(bounds.size.height)),
)
.into(),
}
}
#[cfg(all(test, any(target_os = "macos", target_os = "windows")))]
mod tests {
use super::logged;
use crate::ConsoleLevel;
#[test]
fn reads_a_posted_message() {
let message = logged(
r#"{"level":"warn","text":"low disk"}"#,
"https://a.test/".into(),
);
let message = message.expect("a console message");
assert_eq!(message.level, ConsoleLevel::Warn);
assert_eq!(message.text, "low disk");
assert_eq!(message.source, "https://a.test/");
}
#[test]
fn ignores_what_is_not_one() {
assert!(logged("moved", String::new()).is_none());
assert!(logged(r#"{"level":"trace","text":""}"#, String::new()).is_none());
}
}