`gpui_web` currently embeds IBM Plex Sans and Lilex into every browser application during platform construction. Applications such as Delta already provide their own fonts, so those binaries contain overlapping font bundles and register the same faces twice. This change starts the web platform with an empty font database, matching the ownership boundary used by native applications: the application selects and registers its fonts before opening a window. Delta already follows that sequence. GPUI's browser-capable examples now load the former eight-face bundle explicitly through shared example support, while the standalone `hello_web` example demonstrates registering a minimal application font directly. Testing performed: - `cargo check -p gpui --examples --target wasm32-unknown-unknown` - `cargo check -p gpui --examples` - `cargo check -p gpui_web --target wasm32-unknown-unknown` - `cargo check --manifest-path crates/gpui_web/examples/hello_web/Cargo.toml --target wasm32-unknown-unknown` - `./script/clippy -p gpui_web --target wasm32-unknown-unknown` - `cargo fmt -p gpui -p gpui_web --check` Release Notes: - N/A
771 lines
28 KiB
Rust
771 lines
28 KiB
Rust
use crate::dispatcher::WebDispatcher;
|
|
use crate::display::WebDisplay;
|
|
use crate::events::EventListenerHandle;
|
|
use crate::http_client::FetchHttpClient;
|
|
use crate::keyboard::WebKeyboardLayout;
|
|
use crate::window::WebWindow;
|
|
use anyhow::Result;
|
|
use futures::channel::oneshot;
|
|
use gpui::{
|
|
Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardReadError,
|
|
ClipboardString, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, Image, ImageFormat,
|
|
Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout,
|
|
PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, ThermalState,
|
|
WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError,
|
|
};
|
|
use gpui_wgpu::{PreparedWebGraphics, WebBackendPreference, WgpuContext, wgpu};
|
|
use std::{
|
|
cell::{Cell, RefCell},
|
|
path::{Path, PathBuf},
|
|
rc::Rc,
|
|
sync::Arc,
|
|
};
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
/// Provides the GPUI platform implementation for web browsers.
|
|
///
|
|
/// The platform starts with an empty font database. Applications must add fonts
|
|
/// through [`gpui::App::text_system`] before opening a window.
|
|
pub struct WebPlatform {
|
|
browser_window: web_sys::Window,
|
|
dispatcher: Arc<WebDispatcher>,
|
|
background_executor: BackgroundExecutor,
|
|
foreground_executor: ForegroundExecutor,
|
|
text_system: Arc<dyn PlatformTextSystem>,
|
|
active_window: Rc<RefCell<Option<AnyWindowHandle>>>,
|
|
active_display: Rc<dyn PlatformDisplay>,
|
|
callbacks: RefCell<WebPlatformCallbacks>,
|
|
backend_preference: WebBackendPreference,
|
|
wgpu_context: Rc<RefCell<Option<WgpuContext>>>,
|
|
prepared_window: Rc<RefCell<Option<PreparedWebWindow>>>,
|
|
window_lifecycle: Rc<Cell<WebWindowLifecycle>>,
|
|
cursor_visible: Rc<Cell<bool>>,
|
|
last_cursor_css: Rc<Cell<&'static str>>,
|
|
_cursor_restore_listeners: Vec<EventListenerHandle>,
|
|
}
|
|
|
|
struct PreparedWebWindow {
|
|
canvas: web_sys::HtmlCanvasElement,
|
|
surface: wgpu::Surface<'static>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum WebWindowLifecycle {
|
|
Available,
|
|
Open,
|
|
Closed,
|
|
Unavailable,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum WebWindowError {
|
|
AlreadyOpen,
|
|
ReopeningUnsupported,
|
|
UnsupportedWindowKind(&'static str),
|
|
/// Graphics initialization has not completed yet; retrying after it
|
|
/// finishes (e.g. from the `Platform::run` callback) can succeed.
|
|
GraphicsInitializationPending,
|
|
/// Graphics initialization or an earlier window creation failed;
|
|
/// retrying cannot succeed.
|
|
GraphicsUnavailable,
|
|
}
|
|
|
|
impl std::fmt::Display for WebWindowError {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::AlreadyOpen => formatter.write_str(
|
|
"GPUI web supports only one top-level window; a window is already open",
|
|
),
|
|
Self::ReopeningUnsupported => formatter.write_str(
|
|
"reopening the GPUI web top-level window after it closes is not supported",
|
|
),
|
|
Self::UnsupportedWindowKind(kind) => write!(
|
|
formatter,
|
|
"GPUI web does not support {kind} as a separate top-level window; render it inside the normal window instead"
|
|
),
|
|
Self::GraphicsInitializationPending => formatter.write_str(
|
|
"browser graphics initialization has not completed yet; open windows from the callback passed to Platform::run",
|
|
),
|
|
Self::GraphicsUnavailable => formatter.write_str(
|
|
"browser graphics are unavailable because graphics initialization or an earlier window creation failed",
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for WebWindowError {}
|
|
|
|
#[derive(Default)]
|
|
struct WebPlatformCallbacks {
|
|
open_urls: Option<Box<dyn FnMut(Vec<String>)>>,
|
|
quit: Option<Box<dyn FnMut() -> bool>>,
|
|
reopen: Option<Box<dyn FnMut()>>,
|
|
app_menu_action: Option<Box<dyn FnMut(&dyn Action)>>,
|
|
will_open_app_menu: Option<Box<dyn FnMut()>>,
|
|
validate_app_menu_command: Option<Box<dyn FnMut(&dyn Action) -> bool>>,
|
|
keyboard_layout_change: Option<Box<dyn FnMut()>>,
|
|
thermal_state_change: Option<Box<dyn FnMut()>>,
|
|
}
|
|
|
|
impl WebPlatform {
|
|
pub fn new(allow_multi_threading: bool) -> Self {
|
|
Self::new_with_backend(allow_multi_threading, WebBackendPreference::Auto)
|
|
}
|
|
|
|
pub fn new_with_backend(
|
|
allow_multi_threading: bool,
|
|
backend_preference: WebBackendPreference,
|
|
) -> Self {
|
|
let browser_window =
|
|
web_sys::window().expect("must be running in a browser window context");
|
|
let dispatcher = Arc::new(WebDispatcher::new(
|
|
browser_window.clone(),
|
|
allow_multi_threading,
|
|
));
|
|
let background_executor = BackgroundExecutor::new(dispatcher.clone());
|
|
let foreground_executor = ForegroundExecutor::new(dispatcher.clone());
|
|
let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new_without_system_fonts(
|
|
"IBM Plex Sans",
|
|
));
|
|
let text_system: Arc<dyn PlatformTextSystem> = text_system;
|
|
let active_display: Rc<dyn PlatformDisplay> =
|
|
Rc::new(WebDisplay::new(browser_window.clone()));
|
|
|
|
let cursor_visible = Rc::new(Cell::new(true));
|
|
let last_cursor_css = Rc::new(Cell::new("default"));
|
|
let cursor_restore_listeners = cursor_restore_listeners(
|
|
&browser_window,
|
|
cursor_visible.clone(),
|
|
last_cursor_css.clone(),
|
|
);
|
|
|
|
Self {
|
|
browser_window,
|
|
dispatcher,
|
|
background_executor,
|
|
foreground_executor,
|
|
text_system,
|
|
active_window: Rc::new(RefCell::new(None)),
|
|
active_display,
|
|
callbacks: RefCell::new(WebPlatformCallbacks::default()),
|
|
backend_preference,
|
|
wgpu_context: Rc::new(RefCell::new(None)),
|
|
prepared_window: Rc::new(RefCell::new(None)),
|
|
window_lifecycle: Rc::new(Cell::new(WebWindowLifecycle::Available)),
|
|
cursor_visible,
|
|
last_cursor_css,
|
|
_cursor_restore_listeners: cursor_restore_listeners,
|
|
}
|
|
}
|
|
|
|
/// Returns an HTTP client that runs browser Fetch operations on this platform's main thread.
|
|
pub fn fetch_http_client(&self) -> FetchHttpClient {
|
|
FetchHttpClient::new(self.dispatcher.clone())
|
|
}
|
|
|
|
/// Returns a browser Fetch HTTP client with the given reported user agent.
|
|
pub fn fetch_http_client_with_user_agent(
|
|
&self,
|
|
user_agent: &str,
|
|
) -> anyhow::Result<FetchHttpClient> {
|
|
FetchHttpClient::with_user_agent(self.dispatcher.clone(), user_agent)
|
|
}
|
|
}
|
|
|
|
async fn initialize_graphics(
|
|
browser_window: &web_sys::Window,
|
|
preference: WebBackendPreference,
|
|
) -> anyhow::Result<(
|
|
web_sys::HtmlCanvasElement,
|
|
WgpuContext,
|
|
wgpu::Surface<'static>,
|
|
)> {
|
|
match preference {
|
|
WebBackendPreference::Auto => {
|
|
let webgpu_canvas = WebWindow::prepare_canvas(browser_window)?;
|
|
let webgpu_result = if wgpu::util::is_browser_webgpu_supported().await {
|
|
WgpuContext::new_web(&webgpu_canvas, WebBackendPreference::WebGpu).await
|
|
} else {
|
|
Err(anyhow::anyhow!(
|
|
"browser WebGPU probe did not return a usable adapter"
|
|
))
|
|
};
|
|
match webgpu_result {
|
|
Ok(PreparedWebGraphics { context, surface }) => {
|
|
return Ok((webgpu_canvas, context, surface));
|
|
}
|
|
Err(webgpu_error) => {
|
|
let canvas: &web_sys::Element = webgpu_canvas.as_ref();
|
|
canvas.remove();
|
|
log::warn!(
|
|
"WebGPU initialization failed; falling back to WebGL2: {webgpu_error:#}"
|
|
);
|
|
|
|
let webgl_canvas =
|
|
WebWindow::prepare_canvas(browser_window).map_err(|error| {
|
|
anyhow::anyhow!(
|
|
"WebGPU initialization failed: {webgpu_error:#}. \
|
|
Failed to prepare a replacement canvas for WebGL2: {error:#}"
|
|
)
|
|
})?;
|
|
match WgpuContext::new_web(&webgl_canvas, WebBackendPreference::WebGl).await {
|
|
Ok(PreparedWebGraphics { context, surface }) => {
|
|
Ok((webgl_canvas, context, surface))
|
|
}
|
|
Err(webgl_error) => {
|
|
let canvas: &web_sys::Element = webgl_canvas.as_ref();
|
|
canvas.remove();
|
|
Err(anyhow::anyhow!(
|
|
"No browser graphics backend could be initialized. \
|
|
Tried WebGPU, then WebGL2. \
|
|
WebGPU failure: {webgpu_error:#}. \
|
|
WebGL2 failure: {webgl_error:#}"
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
WebBackendPreference::WebGpu | WebBackendPreference::WebGl => {
|
|
let backend_name = if preference == WebBackendPreference::WebGpu {
|
|
"WebGPU"
|
|
} else {
|
|
"WebGL2"
|
|
};
|
|
let canvas = WebWindow::prepare_canvas(browser_window)?;
|
|
match WgpuContext::new_web(&canvas, preference).await {
|
|
Ok(PreparedWebGraphics { context, surface }) => Ok((canvas, context, surface)),
|
|
Err(error) => {
|
|
let canvas: &web_sys::Element = canvas.as_ref();
|
|
canvas.remove();
|
|
Err(anyhow::anyhow!(
|
|
"No browser graphics backend could be initialized. \
|
|
Only {backend_name} was tried because the application requested \
|
|
it explicitly. {backend_name} failure: {error:#}"
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Platform for WebPlatform {
|
|
fn background_executor(&self) -> BackgroundExecutor {
|
|
self.background_executor.clone()
|
|
}
|
|
|
|
fn foreground_executor(&self) -> ForegroundExecutor {
|
|
self.foreground_executor.clone()
|
|
}
|
|
|
|
fn text_system(&self) -> Arc<dyn PlatformTextSystem> {
|
|
self.text_system.clone()
|
|
}
|
|
|
|
fn run(&self, on_finish_launching: Box<dyn 'static + FnOnce()>) {
|
|
let wgpu_context = self.wgpu_context.clone();
|
|
let prepared_window = self.prepared_window.clone();
|
|
let window_lifecycle = self.window_lifecycle.clone();
|
|
let browser_window = self.browser_window.clone();
|
|
let backend_preference = self.backend_preference;
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match initialize_graphics(&browser_window, backend_preference).await {
|
|
Ok((canvas, context, surface)) => {
|
|
log::info!(
|
|
"Browser graphics initialized successfully with {:?}",
|
|
context.backend()
|
|
);
|
|
*wgpu_context.borrow_mut() = Some(context);
|
|
*prepared_window.borrow_mut() = Some(PreparedWebWindow { canvas, surface });
|
|
on_finish_launching();
|
|
}
|
|
Err(error) => {
|
|
window_lifecycle.set(WebWindowLifecycle::Unavailable);
|
|
log::error!("Failed to initialize browser graphics: {error:#}");
|
|
show_graphics_unavailable_message(&browser_window, &error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn quit(&self) {
|
|
log::warn!("WebPlatform::quit called, but quitting is not supported in the browser .");
|
|
}
|
|
|
|
fn restart(&self, _binary_path: Option<PathBuf>, _arguments: Vec<std::ffi::OsString>) {}
|
|
|
|
fn activate(&self, _ignoring_other_apps: bool) {}
|
|
|
|
fn hide(&self) {}
|
|
|
|
fn hide_other_apps(&self) {}
|
|
|
|
fn unhide_other_apps(&self) {}
|
|
|
|
fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
|
|
vec![self.active_display.clone()]
|
|
}
|
|
|
|
fn primary_display(&self) -> Option<Rc<dyn PlatformDisplay>> {
|
|
Some(self.active_display.clone())
|
|
}
|
|
|
|
fn active_window(&self) -> Option<AnyWindowHandle> {
|
|
*self.active_window.borrow()
|
|
}
|
|
|
|
fn open_window(
|
|
&self,
|
|
handle: AnyWindowHandle,
|
|
params: WindowParams,
|
|
) -> anyhow::Result<Box<dyn PlatformWindow>> {
|
|
match ¶ms.kind {
|
|
WindowKind::Normal => {}
|
|
WindowKind::AnchoredPopup(_) => return Err(PopupNotSupportedError.into()),
|
|
WindowKind::PopUp => {
|
|
return Err(WebWindowError::UnsupportedWindowKind("popup windows").into());
|
|
}
|
|
WindowKind::Floating => {
|
|
return Err(WebWindowError::UnsupportedWindowKind("floating windows").into());
|
|
}
|
|
WindowKind::Dialog => {
|
|
return Err(WebWindowError::UnsupportedWindowKind("dialog windows").into());
|
|
}
|
|
}
|
|
|
|
match self.window_lifecycle.get() {
|
|
WebWindowLifecycle::Open => return Err(WebWindowError::AlreadyOpen.into()),
|
|
WebWindowLifecycle::Closed => {
|
|
return Err(WebWindowError::ReopeningUnsupported.into());
|
|
}
|
|
WebWindowLifecycle::Unavailable => {
|
|
return Err(WebWindowError::GraphicsUnavailable.into());
|
|
}
|
|
WebWindowLifecycle::Available => {}
|
|
}
|
|
|
|
let context_ref = self.wgpu_context.borrow();
|
|
let context = context_ref
|
|
.as_ref()
|
|
.ok_or(WebWindowError::GraphicsInitializationPending)?;
|
|
let prepared_window = self
|
|
.prepared_window
|
|
.borrow_mut()
|
|
.take()
|
|
.ok_or(WebWindowError::GraphicsInitializationPending)?;
|
|
let canvas = prepared_window.canvas;
|
|
let canvas_for_cleanup = canvas.clone();
|
|
|
|
let window = WebWindow::new(
|
|
handle,
|
|
params,
|
|
context,
|
|
canvas,
|
|
prepared_window.surface,
|
|
self.browser_window.clone(),
|
|
self.window_lifecycle.clone(),
|
|
self.active_window.clone(),
|
|
);
|
|
match window {
|
|
Ok(window) => {
|
|
self.window_lifecycle.set(WebWindowLifecycle::Open);
|
|
*self.active_window.borrow_mut() = Some(handle);
|
|
Ok(Box::new(window))
|
|
}
|
|
Err(error) => {
|
|
let canvas: &web_sys::Element = canvas_for_cleanup.as_ref();
|
|
canvas.remove();
|
|
self.window_lifecycle.set(WebWindowLifecycle::Unavailable);
|
|
Err(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn window_appearance(&self) -> WindowAppearance {
|
|
let Ok(Some(media_query)) = self
|
|
.browser_window
|
|
.match_media("(prefers-color-scheme: dark)")
|
|
else {
|
|
return WindowAppearance::Light;
|
|
};
|
|
if media_query.matches() {
|
|
WindowAppearance::Dark
|
|
} else {
|
|
WindowAppearance::Light
|
|
}
|
|
}
|
|
|
|
fn open_url(&self, url: &str) {
|
|
if let Err(error) = self.browser_window.open_with_url(url) {
|
|
log::warn!("Failed to open URL '{url}': {error:?}");
|
|
}
|
|
}
|
|
|
|
fn on_open_urls(&self, callback: Box<dyn FnMut(Vec<String>)>) {
|
|
self.callbacks.borrow_mut().open_urls = Some(callback);
|
|
}
|
|
|
|
fn register_url_scheme(&self, _url: &str) -> Task<Result<()>> {
|
|
Task::ready(Ok(()))
|
|
}
|
|
|
|
fn prompt_for_paths(
|
|
&self,
|
|
_options: PathPromptOptions,
|
|
) -> oneshot::Receiver<Result<Option<Vec<PathBuf>>>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
tx.send(Err(anyhow::anyhow!(
|
|
"prompt_for_paths is not supported on the web"
|
|
)))
|
|
.ok();
|
|
rx
|
|
}
|
|
|
|
fn prompt_for_new_path(
|
|
&self,
|
|
_directory: &Path,
|
|
_suggested_name: Option<&str>,
|
|
) -> oneshot::Receiver<Result<Option<PathBuf>>> {
|
|
let (sender, receiver) = oneshot::channel();
|
|
sender
|
|
.send(Err(anyhow::anyhow!(
|
|
"prompt_for_new_path is not supported on the web"
|
|
)))
|
|
.ok();
|
|
receiver
|
|
}
|
|
|
|
fn can_select_mixed_files_and_dirs(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn reveal_path(&self, _path: &Path) {}
|
|
|
|
fn open_with_system(&self, _path: &Path) {}
|
|
|
|
fn on_quit(&self, callback: Box<dyn FnMut() -> bool>) {
|
|
self.callbacks.borrow_mut().quit = Some(callback);
|
|
}
|
|
|
|
fn on_reopen(&self, callback: Box<dyn FnMut()>) {
|
|
self.callbacks.borrow_mut().reopen = Some(callback);
|
|
}
|
|
|
|
fn on_system_wake(&self, _callback: Box<dyn FnMut()>) {}
|
|
|
|
fn set_menus(&self, _menus: Vec<Menu>, _keymap: &Keymap) {}
|
|
|
|
fn set_dock_menu(&self, _menu: Vec<MenuItem>, _keymap: &Keymap) {}
|
|
|
|
fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>) {
|
|
self.callbacks.borrow_mut().app_menu_action = Some(callback);
|
|
}
|
|
|
|
fn on_will_open_app_menu(&self, callback: Box<dyn FnMut()>) {
|
|
self.callbacks.borrow_mut().will_open_app_menu = Some(callback);
|
|
}
|
|
|
|
fn on_validate_app_menu_command(&self, callback: Box<dyn FnMut(&dyn Action) -> bool>) {
|
|
self.callbacks.borrow_mut().validate_app_menu_command = Some(callback);
|
|
}
|
|
|
|
fn thermal_state(&self) -> ThermalState {
|
|
ThermalState::Nominal
|
|
}
|
|
|
|
fn on_thermal_state_change(&self, callback: Box<dyn FnMut()>) {
|
|
self.callbacks.borrow_mut().thermal_state_change = Some(callback);
|
|
}
|
|
|
|
fn compositor_name(&self) -> &'static str {
|
|
"Web"
|
|
}
|
|
|
|
fn app_path(&self) -> Result<PathBuf> {
|
|
Err(anyhow::anyhow!("app_path is not available on the web"))
|
|
}
|
|
|
|
fn path_for_auxiliary_executable(&self, _name: &str) -> Result<PathBuf> {
|
|
Err(anyhow::anyhow!(
|
|
"path_for_auxiliary_executable is not available on the web"
|
|
))
|
|
}
|
|
|
|
fn set_cursor_style(&self, style: CursorStyle) {
|
|
let css_cursor = match style {
|
|
CursorStyle::Arrow => "default",
|
|
CursorStyle::IBeam => "text",
|
|
CursorStyle::Crosshair => "crosshair",
|
|
CursorStyle::ClosedHand => "grabbing",
|
|
CursorStyle::OpenHand => "grab",
|
|
CursorStyle::PointingHand => "pointer",
|
|
CursorStyle::ResizeLeft | CursorStyle::ResizeRight | CursorStyle::ResizeLeftRight => {
|
|
"ew-resize"
|
|
}
|
|
CursorStyle::ResizeUp | CursorStyle::ResizeDown | CursorStyle::ResizeUpDown => {
|
|
"ns-resize"
|
|
}
|
|
CursorStyle::ResizeUpLeftDownRight => "nesw-resize",
|
|
CursorStyle::ResizeUpRightDownLeft => "nwse-resize",
|
|
CursorStyle::ResizeColumn => "col-resize",
|
|
CursorStyle::ResizeRow => "row-resize",
|
|
CursorStyle::IBeamCursorForVerticalLayout => "vertical-text",
|
|
CursorStyle::OperationNotAllowed => "not-allowed",
|
|
CursorStyle::DragLink => "alias",
|
|
CursorStyle::DragCopy => "copy",
|
|
CursorStyle::ContextualMenu => "context-menu",
|
|
};
|
|
|
|
self.last_cursor_css.set(css_cursor);
|
|
if self.cursor_visible.get() {
|
|
set_body_cursor(&self.browser_window, css_cursor);
|
|
}
|
|
}
|
|
|
|
fn hide_cursor_until_mouse_moves(&self) {
|
|
if !self.cursor_visible.replace(false) {
|
|
return;
|
|
}
|
|
set_body_cursor(&self.browser_window, "none");
|
|
}
|
|
|
|
fn is_cursor_visible(&self) -> bool {
|
|
self.cursor_visible.get()
|
|
}
|
|
|
|
fn should_auto_hide_scrollbars(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn read_from_clipboard(&self) -> Option<ClipboardItem> {
|
|
None
|
|
}
|
|
|
|
fn read_from_clipboard_async(&self) -> Task<Result<Option<ClipboardItem>, ClipboardReadError>> {
|
|
let navigator = self.browser_window.navigator();
|
|
// `navigator.clipboard` is undefined outside secure contexts; probing
|
|
// first avoids a wasm-bindgen abort from invoking a method on
|
|
// `undefined`.
|
|
let clipboard_available = js_sys::Reflect::get(navigator.as_ref(), &"clipboard".into())
|
|
.is_ok_and(|clipboard| !clipboard.is_undefined() && !clipboard.is_null());
|
|
if !clipboard_available {
|
|
return Task::ready(Err(ClipboardReadError::Unavailable));
|
|
}
|
|
// `read()` must be called synchronously so it is still covered by the
|
|
// user activation (e.g. the click on a context-menu item) that
|
|
// triggered this read.
|
|
let read = navigator.clipboard().read();
|
|
self.foreground_executor.spawn(async move {
|
|
let items = wasm_bindgen_futures::JsFuture::from(read)
|
|
.await
|
|
.map_err(clipboard_read_rejection_error)?;
|
|
let mut entries = Vec::new();
|
|
let mut saw_unsupported_type = false;
|
|
for item in js_sys::Array::from(&items).iter() {
|
|
let item: web_sys::ClipboardItem = item.unchecked_into();
|
|
for mime_type in item.types().iter() {
|
|
let Some(mime_type) = mime_type.as_string() else {
|
|
continue;
|
|
};
|
|
// Only fetch blobs for types we can convert; copies from
|
|
// other web apps routinely carry `text/html` and
|
|
// `web application/...` custom formats whose `getType`
|
|
// fetches would be wasted or rejected.
|
|
if mime_type == "text/plain" {
|
|
match read_clipboard_item_text(&item, &mime_type).await {
|
|
Ok(text) if !text.is_empty() => {
|
|
entries.push(ClipboardEntry::String(ClipboardString::new(text)));
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) => log_clipboard_entry_error(&mime_type, &error),
|
|
}
|
|
} else if let Some(format) = ImageFormat::from_mime_type(&mime_type) {
|
|
match read_clipboard_item_bytes(&item, &mime_type).await {
|
|
Ok(bytes) => {
|
|
entries
|
|
.push(ClipboardEntry::Image(Image::from_bytes(format, bytes)));
|
|
}
|
|
Err(error) => log_clipboard_entry_error(&mime_type, &error),
|
|
}
|
|
} else {
|
|
saw_unsupported_type = true;
|
|
}
|
|
}
|
|
}
|
|
if !entries.is_empty() {
|
|
Ok(Some(ClipboardItem { entries }))
|
|
} else if saw_unsupported_type {
|
|
Err(ClipboardReadError::UnsupportedContent)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn write_to_clipboard(&self, item: ClipboardItem) {
|
|
if let Some(text) = item.text()
|
|
&& let Some(window) = web_sys::window()
|
|
{
|
|
// Fire-and-forget; called synchronously inside the user's input
|
|
// event, which satisfies the browser's user-activation requirement.
|
|
drop(window.navigator().clipboard().write_text(&text));
|
|
}
|
|
}
|
|
|
|
fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {
|
|
Task::ready(Err(anyhow::anyhow!(
|
|
"credential storage is not available on the web"
|
|
)))
|
|
}
|
|
|
|
fn read_credentials(&self, _url: &str) -> Task<Result<Option<(String, Vec<u8>)>>> {
|
|
Task::ready(Ok(None))
|
|
}
|
|
|
|
fn delete_credentials(&self, _url: &str) -> Task<Result<()>> {
|
|
Task::ready(Err(anyhow::anyhow!(
|
|
"credential storage is not available on the web"
|
|
)))
|
|
}
|
|
|
|
fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout> {
|
|
Box::new(WebKeyboardLayout)
|
|
}
|
|
|
|
fn keyboard_mapper(&self) -> Rc<dyn PlatformKeyboardMapper> {
|
|
Rc::new(DummyKeyboardMapper)
|
|
}
|
|
|
|
fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>) {
|
|
self.callbacks.borrow_mut().keyboard_layout_change = Some(callback);
|
|
}
|
|
}
|
|
|
|
/// Maps a `navigator.clipboard.read()` rejection to a [`ClipboardReadError`].
|
|
///
|
|
/// Only `NotAllowedError` and `SecurityError` mean the user or browser
|
|
/// refused access; other rejections (e.g. `DataError`) indicate content the
|
|
/// clipboard could not represent, where "allow clipboard access" guidance
|
|
/// would mislead.
|
|
fn clipboard_read_rejection_error(error: JsValue) -> ClipboardReadError {
|
|
match js_error_name(&error).as_deref() {
|
|
Some("NotAllowedError") | Some("SecurityError") => {
|
|
ClipboardReadError::Denied(js_error_message(&error))
|
|
}
|
|
_ => ClipboardReadError::UnsupportedContent,
|
|
}
|
|
}
|
|
|
|
async fn read_clipboard_item_blob(
|
|
item: &web_sys::ClipboardItem,
|
|
mime_type: &str,
|
|
) -> Result<web_sys::Blob, JsValue> {
|
|
let blob = wasm_bindgen_futures::JsFuture::from(item.get_type(mime_type)).await?;
|
|
Ok(blob.unchecked_into())
|
|
}
|
|
|
|
async fn read_clipboard_item_text(
|
|
item: &web_sys::ClipboardItem,
|
|
mime_type: &str,
|
|
) -> Result<String, JsValue> {
|
|
let blob = read_clipboard_item_blob(item, mime_type).await?;
|
|
let text = wasm_bindgen_futures::JsFuture::from(blob.text()).await?;
|
|
text.as_string()
|
|
.ok_or_else(|| JsValue::from_str("blob text is not a string"))
|
|
}
|
|
|
|
async fn read_clipboard_item_bytes(
|
|
item: &web_sys::ClipboardItem,
|
|
mime_type: &str,
|
|
) -> Result<Vec<u8>, JsValue> {
|
|
let blob = read_clipboard_item_blob(item, mime_type).await?;
|
|
read_blob_bytes(&blob).await
|
|
}
|
|
|
|
pub(crate) async fn read_blob_bytes(blob: &web_sys::Blob) -> Result<Vec<u8>, JsValue> {
|
|
let buffer = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await?;
|
|
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
|
}
|
|
|
|
fn js_error_name(error: &JsValue) -> Option<String> {
|
|
js_sys::Reflect::get(error, &"name".into())
|
|
.ok()
|
|
.and_then(|name| name.as_string())
|
|
}
|
|
|
|
pub(crate) fn js_error_message(error: &JsValue) -> String {
|
|
js_sys::Reflect::get(error, &"message".into())
|
|
.ok()
|
|
.and_then(|message| message.as_string())
|
|
.or_else(|| error.as_string())
|
|
.unwrap_or_else(|| "unknown browser error".to_string())
|
|
}
|
|
|
|
fn log_clipboard_entry_error(mime_type: &str, error: &JsValue) {
|
|
log::warn!(
|
|
"failed to read clipboard entry with type {mime_type}: {}",
|
|
js_error_message(error)
|
|
);
|
|
}
|
|
|
|
fn cursor_restore_listeners(
|
|
browser_window: &web_sys::Window,
|
|
cursor_visible: Rc<Cell<bool>>,
|
|
last_cursor_css: Rc<Cell<&'static str>>,
|
|
) -> Vec<EventListenerHandle> {
|
|
let mut handles = Vec::new();
|
|
let Some(document) = browser_window.document() else {
|
|
return handles;
|
|
};
|
|
|
|
let mut add_listener = |target: &web_sys::EventTarget, event_name: &'static str| {
|
|
let browser_window = browser_window.clone();
|
|
let cursor_visible = cursor_visible.clone();
|
|
let last_cursor_css = last_cursor_css.clone();
|
|
handles.push(EventListenerHandle::add(
|
|
target,
|
|
event_name,
|
|
move |_event: JsValue| {
|
|
if !cursor_visible.replace(true) {
|
|
set_body_cursor(&browser_window, last_cursor_css.get());
|
|
}
|
|
},
|
|
));
|
|
};
|
|
|
|
let document_target: &web_sys::EventTarget = document.as_ref();
|
|
let window_target: &web_sys::EventTarget = browser_window.as_ref();
|
|
|
|
add_listener(document_target, "mousemove");
|
|
add_listener(document_target, "mouseenter");
|
|
add_listener(window_target, "blur");
|
|
add_listener(document_target, "visibilitychange");
|
|
|
|
handles
|
|
}
|
|
|
|
fn show_graphics_unavailable_message(browser_window: &web_sys::Window, error: &anyhow::Error) {
|
|
let Some(document) = browser_window.document() else {
|
|
return;
|
|
};
|
|
let Some(body) = document.body() else {
|
|
return;
|
|
};
|
|
let Ok(message) = document.create_element("p") else {
|
|
return;
|
|
};
|
|
message.set_text_content(Some(&format!(
|
|
"Failed to initialize browser graphics: {error}"
|
|
)));
|
|
body.append_child(&message).ok();
|
|
}
|
|
|
|
fn set_body_cursor(browser_window: &web_sys::Window, css_cursor: &str) {
|
|
if let Some(document) = browser_window.document()
|
|
&& let Some(body) = document.body()
|
|
&& let Err(error) = body.style().set_property("cursor", css_cursor)
|
|
{
|
|
log::warn!("Failed to set cursor style: {error:?}");
|
|
}
|
|
}
|