diff --git a/.vscode/settings.json b/.vscode/settings.json index 8171f50c61..1a8ced3869 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,12 +39,11 @@ "eslint.validate": ["javascript", "typescript", "svelte"], // Svelte config "svelte.plugin.svelte.compilerWarnings": { - // NOTICE: Keep this list in sync with the list in `frontend/vite.config.ts` - "css-unused-selector": "ignore", - "vite-plugin-svelte-css-no-scopable-elements": "ignore", - "a11y-no-static-element-interactions": "ignore", - "a11y-no-noninteractive-element-interactions": "ignore", - "a11y-click-events-have-key-events": "ignore" + "css-unused-selector": "ignore", // NOTICE: Keep this list in sync with the list in `frontend/vite.config.ts` + "vite-plugin-svelte-css-no-scopable-elements": "ignore", // NOTICE: Keep this list in sync with the list in `frontend/vite.config.ts` + "a11y-no-static-element-interactions": "ignore", // NOTICE: Keep this list in sync with the list in `frontend/vite.config.ts` + "a11y-no-noninteractive-element-interactions": "ignore", // NOTICE: Keep this list in sync with the list in `frontend/vite.config.ts` + "a11y-click-events-have-key-events": "ignore" // NOTICE: Keep this list in sync with the list in `frontend/vite.config.ts` }, // VS Code config "html.format.wrapLineLength": 200, diff --git a/Cargo.lock b/Cargo.lock index 9eab3f3b59..eea2f94344 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2077,14 +2077,19 @@ dependencies = [ name = "graphite-desktop" version = "0.1.0" dependencies = [ + "ash", "bytemuck", "cef", + "core-foundation", "derivative", "dirs", "futures", "glam", "graphite-desktop-wrapper", "include_dir", + "libc", + "objc2-io-surface", + "objc2-metal 0.3.1", "open", "rfd", "ron", @@ -2093,6 +2098,7 @@ dependencies = [ "tracing-subscriber", "vello", "wgpu", + "windows", "winit", ] @@ -3489,7 +3495,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -3544,6 +3550,19 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +dependencies = [ + "bitflags 2.9.1", + "libc", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-link-presentation" version = "0.2.2" @@ -3568,6 +3587,20 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f246c183239540aab1782457b35ab2040d4259175bd1d0c58e46ada7b47a874" +dependencies = [ + "bitflags 2.9.1", + "block2 0.6.1", + "dispatch2", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -3578,7 +3611,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] diff --git a/about.toml b/about.toml index e1ead699bf..7f9568c754 100644 --- a/about.toml +++ b/about.toml @@ -1,22 +1,22 @@ -# Keep this list in sync with those in `/deny.toml` and `/frontend/vite.config.ts`. accepted = [ - "Apache-2.0 WITH LLVM-exception", - "Apache-2.0", - "BSD-2-Clause", - "BSD-3-Clause", - "BSL-1.0", - "CC0-1.0", - "CDLA-Permissive-2.0", - "ISC", - "MIT-0", - "MIT", - "MPL-2.0", - "OpenSSL", - "Unicode-3.0", - "Unicode-DFS-2016", - "Zlib", - "NCSA", - "bzip2-1.0.6", + "Apache-2.0 WITH LLVM-exception", # Keep this list in sync with those in `/deny.toml` + "Apache-2.0", # Keep this list in sync with those in `/deny.toml` + "BSD-2-Clause", # Keep this list in sync with those in `/deny.toml` + "BSD-3-Clause", # Keep this list in sync with those in `/deny.toml` + "BSL-1.0", # Keep this list in sync with those in `/deny.toml` + "CC0-1.0", # Keep this list in sync with those in `/deny.toml` + "CDLA-Permissive-2.0", # Keep this list in sync with those in `/deny.toml` + "ISC", # Keep this list in sync with those in `/deny.toml` + "MIT-0", # Keep this list in sync with those in `/deny.toml` + "MIT", # Keep this list in sync with those in `/deny.toml` + "MPL-2.0", # Keep this list in sync with those in `/deny.toml` + "OpenSSL", # Keep this list in sync with those in `/deny.toml` + "Unicode-3.0", # Keep this list in sync with those in `/deny.toml` + "Unicode-DFS-2016", # Keep this list in sync with those in `/deny.toml` + "Zlib", # Keep this list in sync with those in `/deny.toml` + "NCSA", # Keep this list in sync with those in `/deny.toml` + "bzip2-1.0.6", # Keep this list in sync with those in `/deny.toml` + "OFL-1.1", # Keep this list in sync with those in `/deny.toml` ] workarounds = ["ring"] ignore-build-dependencies = true diff --git a/deny.toml b/deny.toml index 9f89b12283..3ffe58382f 100644 --- a/deny.toml +++ b/deny.toml @@ -63,25 +63,25 @@ ignore = [ # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. # -# Keep this list in sync with those in `/about.toml` and `/frontend/vite.config.ts`. allow = [ - "Apache-2.0 WITH LLVM-exception", - "Apache-2.0", - "BSD-2-Clause", - "BSD-3-Clause", - "BSL-1.0", - "CC0-1.0", - "CDLA-Permissive-2.0", - "ISC", - "MIT-0", - "MIT", - "MPL-2.0", - "OpenSSL", - "Unicode-3.0", - "Unicode-DFS-2016", - "Zlib", - "NCSA", - "bzip2-1.0.6", + "Apache-2.0 WITH LLVM-exception", # Keep this list in sync with those in `/about.toml` + "Apache-2.0", # Keep this list in sync with those in `/about.toml` + "BSD-2-Clause", # Keep this list in sync with those in `/about.toml` + "BSD-3-Clause", # Keep this list in sync with those in `/about.toml` + "BSL-1.0", # Keep this list in sync with those in `/about.toml` + "CC0-1.0", # Keep this list in sync with those in `/about.toml` + "CDLA-Permissive-2.0", # Keep this list in sync with those in `/about.toml` + "ISC", # Keep this list in sync with those in `/about.toml` + "MIT-0", # Keep this list in sync with those in `/about.toml` + "MIT", # Keep this list in sync with those in `/about.toml` + "MPL-2.0", # Keep this list in sync with those in `/about.toml` + "OpenSSL", # Keep this list in sync with those in `/about.toml` + "Unicode-3.0", # Keep this list in sync with those in `/about.toml` + "Unicode-DFS-2016", # Keep this list in sync with those in `/about.toml` + "Zlib", # Keep this list in sync with those in `/about.toml` + "NCSA", # Keep this list in sync with those in `/about.toml` + "bzip2-1.0.6", # Keep this list in sync with those in `/about.toml` + "OFL-1.1", # Keep this list in sync with those in `/about.toml` ] # The confidence threshold for detecting a license from license text. # The higher the value, the more closely the license text must be to the diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 8c4e3000b1..1ddf52622e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -9,9 +9,15 @@ edition = "2024" rust-version = "1.87" [features] -default = ["gpu"] +default = ["gpu", "accelerated_paint"] gpu = ["graphite-desktop-wrapper/gpu"] +# Hardware acceleration features +accelerated_paint = ["accelerated_paint_dmabuf", "accelerated_paint_d3d11", "accelerated_paint_iosurface"] +accelerated_paint_dmabuf = ["libc", "ash"] +accelerated_paint_d3d11 = ["windows", "ash"] +accelerated_paint_iosurface = ["objc2-io-surface", "objc2-metal", "core-foundation"] + [dependencies] # # Local dependencies graphite-desktop-wrapper = { path = "wrapper" } @@ -32,3 +38,26 @@ vello = { workspace = true } derivative = { workspace = true } rfd = { workspace = true } open = { workspace = true } + +# Hardware acceleration dependencies +ash = { version = "0.38", optional = true } + +# Windows-specific dependencies +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Direct3D12", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Win32_Foundation" +], optional = true } + +# macOS-specific dependencies +[target.'cfg(target_os = "macos")'.dependencies] +objc2-io-surface = { version = "0.3", optional = true } +objc2-metal = { version = "0.3", optional = true } +core-foundation = { version = "0.9", optional = true } + +# Linux-specific dependencies +[target.'cfg(target_os = "linux")'.dependencies] +libc = { version = "0.2", optional = true } diff --git a/desktop/src/app.rs b/desktop/src/app.rs index c12a9c72e7..2c77b68cab 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -1,18 +1,19 @@ use crate::CustomEvent; use crate::cef::WindowSize; -use crate::consts::APP_NAME; +use crate::consts::{APP_NAME, CEF_MESSAGE_LOOP_MAX_ITERATIONS}; use crate::render::GraphicsState; use graphite_desktop_wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage}; use graphite_desktop_wrapper::{DesktopWrapper, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; + use rfd::AsyncFileDialog; use std::sync::Arc; use std::sync::mpsc::Sender; +use std::sync::mpsc::SyncSender; use std::thread; use std::time::Duration; use std::time::Instant; use winit::application::ApplicationHandler; use winit::dpi::PhysicalSize; -use winit::event::StartCause; use winit::event::WindowEvent; use winit::event_loop::ActiveEventLoop; use winit::event_loop::ControlFlow; @@ -23,7 +24,7 @@ use winit::window::WindowId; use crate::cef; pub(crate) struct WinitApp { - cef_context: cef::Context, + cef_context: Box, window: Option>, cef_schedule: Option, window_size_sender: Sender, @@ -31,11 +32,23 @@ pub(crate) struct WinitApp { wgpu_context: WgpuContext, event_loop_proxy: EventLoopProxy, desktop_wrapper: DesktopWrapper, + last_ui_update: Instant, + avg_frame_time: f32, + start_render_sender: SyncSender<()>, } impl WinitApp { - pub(crate) fn new(cef_context: cef::Context, window_size_sender: Sender, wgpu_context: WgpuContext, event_loop_proxy: EventLoopProxy) -> Self { - let desktop_wrapper = DesktopWrapper::new(); + pub(crate) fn new(cef_context: Box, window_size_sender: Sender, wgpu_context: WgpuContext, event_loop_proxy: EventLoopProxy) -> Self { + let rendering_loop_proxy = event_loop_proxy.clone(); + let (start_render_sender, start_render_receiver) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + loop { + let result = futures::executor::block_on(DesktopWrapper::execute_node_graph()); + let _ = rendering_loop_proxy.send_event(CustomEvent::NodeGraphExecutionResult(result)); + let _ = start_render_receiver.recv(); + } + }); + Self { cef_context, window: None, @@ -44,7 +57,10 @@ impl WinitApp { window_size_sender, wgpu_context, event_loop_proxy, - desktop_wrapper, + desktop_wrapper: DesktopWrapper::new(), + last_ui_update: Instant::now(), + avg_frame_time: 0., + start_render_sender, } } @@ -55,7 +71,7 @@ impl WinitApp { tracing::error!("Failed to serialize frontend messages"); return; }; - self.cef_context.send_web_message(bytes.as_slice()); + self.cef_context.send_web_message(bytes); } DesktopFrontendMessage::OpenFileDialog { title, filters, context } => { let event_loop_proxy = self.event_loop_proxy.clone(); @@ -152,23 +168,20 @@ impl ApplicationHandler for WinitApp { // Set a timeout in case we miss any cef schedule requests let timeout = Instant::now() + Duration::from_millis(10); let wait_until = timeout.min(self.cef_schedule.unwrap_or(timeout)); - self.cef_context.work(); - - event_loop.set_control_flow(ControlFlow::WaitUntil(wait_until)); - } - - fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) { if let Some(schedule) = self.cef_schedule && schedule < Instant::now() { self.cef_schedule = None; - self.cef_context.work(); - } - if let StartCause::ResumeTimeReached { .. } = cause { - if let Some(window) = &self.window { - window.request_redraw(); + // Poll cef message loop multiple times to avoid message loop starvation + for _ in 0..CEF_MESSAGE_LOOP_MAX_ITERATIONS { + self.cef_context.work(); } } + if let Some(window) = &self.window.as_ref() { + window.request_redraw(); + } + + event_loop.set_control_flow(ControlFlow::WaitUntil(wait_until)); } fn resumed(&mut self, event_loop: &ActiveEventLoop) { @@ -177,7 +190,7 @@ impl ApplicationHandler for WinitApp { .with_min_inner_size(winit::dpi::LogicalSize::new(400, 300)) .with_inner_size(winit::dpi::LogicalSize::new(1200, 800)); - #[cfg(target_family = "unix")] + #[cfg(target_os = "linux")] { use crate::consts::APP_ID; use winit::platform::wayland::ActiveEventLoopExtWayland; @@ -220,6 +233,11 @@ impl ApplicationHandler for WinitApp { if let Some(graphics_state) = self.graphics_state.as_mut() { graphics_state.resize(texture.width(), texture.height()); graphics_state.bind_ui_texture(texture); + let elapsed = self.last_ui_update.elapsed().as_secs_f32(); + self.last_ui_update = Instant::now(); + if elapsed < 0.5 { + self.avg_frame_time = (self.avg_frame_time * 3. + elapsed) / 4.; + } } if let Some(window) = &self.window { window.request_redraw(); @@ -236,7 +254,7 @@ impl ApplicationHandler for WinitApp { } fn window_event(&mut self, event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) { - let Some(event) = self.cef_context.handle_window_event(event) else { return }; + self.cef_context.handle_window_event(&event); match event { WindowEvent::CloseRequested => { @@ -251,16 +269,18 @@ impl ApplicationHandler for WinitApp { WindowEvent::RedrawRequested => { let Some(ref mut graphics_state) = self.graphics_state else { return }; // Only rerender once we have a new ui texture to display - - match graphics_state.render() { - Ok(_) => {} - Err(wgpu::SurfaceError::Lost) => { - tracing::warn!("lost surface"); + if let Some(window) = &self.window { + match graphics_state.render(window.as_ref()) { + Ok(_) => {} + Err(wgpu::SurfaceError::Lost) => { + tracing::warn!("lost surface"); + } + Err(wgpu::SurfaceError::OutOfMemory) => { + event_loop.exit(); + } + Err(e) => tracing::error!("{:?}", e), } - Err(wgpu::SurfaceError::OutOfMemory) => { - event_loop.exit(); - } - Err(e) => tracing::error!("{:?}", e), + let _ = self.start_render_sender.try_send(()); } } // Currently not supported on wayland see https://github.com/rust-windowing/winit/issues/1881 diff --git a/desktop/src/cef.rs b/desktop/src/cef.rs index d8a4d99576..6424fa5935 100644 --- a/desktop/src/cef.rs +++ b/desktop/src/cef.rs @@ -1,3 +1,18 @@ +//! CEF (Chromium Embedded Framework) integration for Graphite Desktop +//! +//! This module provides CEF browser integration with hardware-accelerated texture sharing. +//! +//! # Hardware Acceleration +//! +//! The texture import system supports platform-specific hardware acceleration: +//! +//! - **Linux**: DMA-BUF via Vulkan external memory (`accelerated_paint_dmabuf` feature) +//! - **Windows**: D3D11 shared textures via either Vulkan or D3D12 interop (`accelerated_paint_d3d11` feature) +//! - **macOS**: IOSurface via Metal/Vulkan interop (`accelerated_paint_iosurface` feature) +//! +//! +//! The system gracefully falls back to CPU textures when hardware acceleration is unavailable. + use crate::CustomEvent; use crate::render::FrameBufferRef; use graphite_desktop_wrapper::{WgpuContext, deserialize_editor_message}; @@ -5,20 +20,29 @@ use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; use std::time::Instant; +mod consts; mod context; mod dirs; mod input; mod internal; mod ipc; +mod platform; mod scheme_handler; mod utility; -pub(crate) use context::{Context, InitError, Initialized, Setup, SetupError}; +#[cfg(feature = "accelerated_paint")] +mod texture_import; +#[cfg(feature = "accelerated_paint")] +use texture_import::SharedTextureHandle; + +pub(crate) use context::{CefContext, CefContextBuilder, InitError}; use winit::event_loop::EventLoopProxy; pub(crate) trait CefEventHandler: Clone { fn window_size(&self) -> WindowSize; fn draw<'a>(&self, frame_buffer: FrameBufferRef<'a>); + #[cfg(feature = "accelerated_paint")] + fn draw_gpu(&self, shared_texture: SharedTextureHandle); /// Scheudule the main event loop to run the cef event loop after the timeout /// [`_cef_browser_process_handler_t::on_schedule_message_pump_work`] for more documentation. fn schedule_cef_message_loop_work(&self, scheduled_time: Instant); @@ -128,4 +152,16 @@ impl CefEventHandler for CefHandler { }; let _ = self.event_loop_proxy.send_event(CustomEvent::DesktopWrapperMessage(desktop_wrapper_message)); } + + #[cfg(feature = "accelerated_paint")] + fn draw_gpu(&self, shared_texture: SharedTextureHandle) { + match shared_texture.import_texture(&self.wgpu_context.device) { + Ok(texture) => { + let _ = self.event_loop_proxy.send_event(CustomEvent::UiUpdate(texture)); + } + Err(e) => { + tracing::error!("Failed to import shared texture: {}", e); + } + } + } } diff --git a/desktop/src/cef/consts.rs b/desktop/src/cef/consts.rs new file mode 100644 index 0000000000..a0031c8852 --- /dev/null +++ b/desktop/src/cef/consts.rs @@ -0,0 +1,2 @@ +pub(crate) const GRAPHITE_SCHEME: &str = "graphite-static"; +pub(crate) const FRONTEND_DOMAIN: &str = "frontend"; diff --git a/desktop/src/cef/context.rs b/desktop/src/cef/context.rs index 5a0f642c2a..2880663a10 100644 --- a/desktop/src/cef/context.rs +++ b/desktop/src/cef/context.rs @@ -1,161 +1,15 @@ -use cef::sys::{CEF_API_VERSION_LAST, cef_resultcode_t}; -use cef::{App, BrowserSettings, Client, DictionaryValue, ImplBrowser, ImplBrowserHost, ImplCommandLine, RenderHandler, RequestContext, WindowInfo, browser_host_create_browser_sync, initialize}; -use cef::{Browser, CefString, Settings, api_hash, args::Args, execute_process}; -use thiserror::Error; -use winit::event::WindowEvent; +mod multithreaded; +mod singlethreaded; -use crate::cef::dirs::{cef_cache_dir, cef_data_dir}; +mod builder; +pub(crate) use builder::{CefContextBuilder, InitError}; -use super::input::InputState; -use super::ipc::{MessageType, SendMessage}; -use super::scheme_handler::{FRONTEND_DOMAIN, GRAPHITE_SCHEME}; -use super::{CefEventHandler, input}; +pub(crate) trait CefContext { + fn work(&mut self); -use super::internal::{BrowserProcessAppImpl, BrowserProcessClientImpl, RenderHandlerImpl, RenderProcessAppImpl}; + fn handle_window_event(&mut self, event: &winit::event::WindowEvent); -pub(crate) struct Setup {} -pub(crate) struct Initialized {} -pub(crate) trait ContextState {} -impl ContextState for Setup {} -impl ContextState for Initialized {} + fn notify_of_resize(&self); -pub(crate) struct Context { - args: Args, - pub(crate) browser: Option, - pub(crate) input_state: InputState, - marker: std::marker::PhantomData, -} - -impl Context { - pub(crate) fn new() -> Result, SetupError> { - #[cfg(target_os = "macos")] - let _loader = { - let loader = library_loader::LibraryLoader::new(&std::env::current_exe().unwrap(), false); - assert!(loader.load()); - loader - }; - let _ = api_hash(CEF_API_VERSION_LAST, 0); - - let args = Args::new(); - let cmd = args.as_cmd_line().unwrap(); - let switch = CefString::from("type"); - let is_browser_process = cmd.has_switch(Some(&switch)) != 1; - - if !is_browser_process { - let process_type = CefString::from(&cmd.switch_value(Some(&switch))); - let mut app = RenderProcessAppImpl::app(); - let ret = execute_process(Some(args.as_main_args()), Some(&mut app), std::ptr::null_mut()); - if ret >= 0 { - return Err(SetupError::SubprocessFailed(process_type.to_string())); - } else { - return Err(SetupError::Subprocess); - } - } - - Ok(Context { - args, - browser: None, - input_state: InputState::default(), - marker: std::marker::PhantomData::, - }) - } - - pub(crate) fn init(self, event_handler: impl CefEventHandler) -> Result, InitError> { - let settings = Settings { - windowless_rendering_enabled: 1, - multi_threaded_message_loop: 0, - external_message_pump: 1, - root_cache_path: cef_data_dir().to_str().map(CefString::from).unwrap(), - cache_path: cef_cache_dir().to_str().map(CefString::from).unwrap(), - ..Default::default() - }; - - // Attention! Wrapping this in an extra App is necessary, otherwise the program still compiles but segfaults - let mut cef_app = App::new(BrowserProcessAppImpl::new(event_handler.clone())); - - let result = initialize(Some(self.args.as_main_args()), Some(&settings), Some(&mut cef_app), std::ptr::null_mut()); - if result != 1 { - let cef_exit_code = cef::get_exit_code() as u32; - if cef_exit_code == cef_resultcode_t::CEF_RESULT_CODE_NORMAL_EXIT_PROCESS_NOTIFIED as u32 { - return Err(InitError::AlreadyRunning); - } - return Err(InitError::InitializationFailed(cef_exit_code)); - } - - let render_handler = RenderHandler::new(RenderHandlerImpl::new(event_handler.clone())); - let mut client = Client::new(BrowserProcessClientImpl::new(render_handler, event_handler.clone())); - - let url = CefString::from(format!("{GRAPHITE_SCHEME}://{FRONTEND_DOMAIN}/").as_str()); - - let window_info = WindowInfo { - windowless_rendering_enabled: 1, - ..Default::default() - }; - - let settings = BrowserSettings { - windowless_frame_rate: 60, - background_color: 0x0, - ..Default::default() - }; - - let browser = browser_host_create_browser_sync( - Some(&window_info), - Some(&mut client), - Some(&url), - Some(&settings), - Option::<&mut DictionaryValue>::None, - Option::<&mut RequestContext>::None, - ); - - Ok(Context { - args: self.args.clone(), - browser, - input_state: self.input_state.clone(), - marker: std::marker::PhantomData::, - }) - } -} - -impl Context { - pub(crate) fn work(&mut self) { - cef::do_message_loop_work(); - } - - pub(crate) fn handle_window_event(&mut self, event: WindowEvent) -> Option { - input::handle_window_event(self, event) - } - - pub(crate) fn notify_of_resize(&self) { - if let Some(browser) = &self.browser { - browser.host().unwrap().was_resized(); - } - } - - pub(crate) fn send_web_message(&self, message: &[u8]) { - self.send_message(MessageType::SendToJS, message); - } -} - -impl Drop for Context { - fn drop(&mut self) { - if self.browser.is_some() { - cef::shutdown(); - } - } -} - -#[derive(Error, Debug)] -pub(crate) enum SetupError { - #[error("this is the sub process should exit immediately")] - Subprocess, - #[error("subprocess returned non zero exit code")] - SubprocessFailed(String), -} - -#[derive(Error, Debug)] -pub(crate) enum InitError { - #[error("initialization failed")] - InitializationFailed(u32), - #[error("Another instance is already running")] - AlreadyRunning, + fn send_web_message(&self, message: Vec); } diff --git a/desktop/src/cef/context/builder.rs b/desktop/src/cef/context/builder.rs new file mode 100644 index 0000000000..c1a92b92ed --- /dev/null +++ b/desktop/src/cef/context/builder.rs @@ -0,0 +1,171 @@ +use cef::args::Args; +use cef::sys::{CEF_API_VERSION_LAST, cef_resultcode_t}; +use cef::{ + App, BrowserSettings, CefString, Client, DictionaryValue, ImplCommandLine, RenderHandler, RequestContext, Settings, WindowInfo, api_hash, browser_host_create_browser_sync, execute_process, +}; + +use super::CefContext; +use super::singlethreaded::SingleThreadedCefContext; +use crate::cef::CefHandler; +use crate::cef::consts::{FRONTEND_DOMAIN, GRAPHITE_SCHEME}; +use crate::cef::dirs::{cef_cache_dir, cef_data_dir}; +use crate::cef::input::InputState; +use crate::cef::internal::{BrowserProcessAppImpl, BrowserProcessClientImpl, RenderHandlerImpl, RenderProcessAppImpl}; + +pub(crate) struct CefContextBuilder { + pub(crate) args: Args, + pub(crate) is_sub_process: bool, +} + +unsafe impl Send for CefContextBuilder {} + +impl CefContextBuilder { + pub(crate) fn new() -> Self { + #[cfg(target_os = "macos")] + let _loader = { + let loader = library_loader::LibraryLoader::new(&std::env::current_exe().unwrap(), false); + assert!(loader.load()); + loader + }; + let _ = api_hash(CEF_API_VERSION_LAST, 0); + + let args = Args::new(); + let cmd = args.as_cmd_line().unwrap(); + let switch = CefString::from("type"); + let is_sub_process = cmd.has_switch(Some(&switch)) == 1; + + Self { args, is_sub_process } + } + + pub(crate) fn is_sub_process(&self) -> bool { + self.is_sub_process + } + + pub(crate) fn execute_sub_process(&self) -> SetupError { + let cmd = self.args.as_cmd_line().unwrap(); + let switch = CefString::from("type"); + let process_type = CefString::from(&cmd.switch_value(Some(&switch))); + let mut app = RenderProcessAppImpl::app(); + let ret = execute_process(Some(self.args.as_main_args()), Some(&mut app), std::ptr::null_mut()); + if ret >= 0 { + SetupError::SubprocessFailed(process_type.to_string()) + } else { + SetupError::Subprocess + } + } + + #[cfg(target_os = "macos")] + pub(crate) fn initialize(self, event_handler: CefHandler) -> Result { + let settings = Settings { + windowless_rendering_enabled: 1, + multi_threaded_message_loop: 0, + external_message_pump: 1, + root_cache_path: cef_data_dir().to_str().map(CefString::from).unwrap(), + cache_path: cef_cache_dir().to_str().map(CefString::from).unwrap(), + ..Default::default() + }; + + self.initialize_inner(&event_handler, settings)?; + + create_browser(event_handler) + } + + #[cfg(not(target_os = "macos"))] + pub(crate) fn initialize(self, event_handler: CefHandler) -> Result { + let settings = Settings { + windowless_rendering_enabled: 1, + multi_threaded_message_loop: 1, + root_cache_path: cef_data_dir().to_str().map(CefString::from).unwrap(), + cache_path: cef_cache_dir().to_str().map(CefString::from).unwrap(), + ..Default::default() + }; + + self.initialize_inner(&event_handler, settings)?; + + super::multithreaded::run_on_ui_thread(move || match create_browser(event_handler) { + Ok(context) => { + super::multithreaded::CONTEXT.with(|b| { + *b.borrow_mut() = Some(context); + }); + } + Err(e) => { + tracing::error!("Failed to initialize CEF context: {:?}", e); + std::process::exit(1); + } + }); + + Ok(super::multithreaded::MultiThreadedCefContextProxy) + } + + fn initialize_inner(self, event_handler: &CefHandler, settings: Settings) -> Result<(), InitError> { + let mut cef_app = App::new(BrowserProcessAppImpl::new(event_handler.clone())); + let result = cef::initialize(Some(self.args.as_main_args()), Some(&settings), Some(&mut cef_app), std::ptr::null_mut()); + // Attention! Wrapping this in an extra App is necessary, otherwise the program still compiles but segfaults + + if result != 1 { + let cef_exit_code = cef::get_exit_code() as u32; + if cef_exit_code == cef_resultcode_t::CEF_RESULT_CODE_NORMAL_EXIT_PROCESS_NOTIFIED as u32 { + return Err(InitError::AlreadyRunning); + } + return Err(InitError::InitializationFailed(cef_exit_code)); + } + Ok(()) + } +} + +fn create_browser(event_handler: CefHandler) -> Result { + let render_handler = RenderHandler::new(RenderHandlerImpl::new(event_handler.clone())); + let mut client = Client::new(BrowserProcessClientImpl::new(render_handler, event_handler.clone())); + + let url = CefString::from(format!("{GRAPHITE_SCHEME}://{FRONTEND_DOMAIN}/").as_str()); + + let window_info = WindowInfo { + windowless_rendering_enabled: 1, + #[cfg(feature = "accelerated_paint")] + shared_texture_enabled: if crate::cef::platform::should_enable_hardware_acceleration() { 1 } else { 0 }, + ..Default::default() + }; + + let settings = BrowserSettings { + windowless_frame_rate: crate::consts::CEF_WINDOWLESS_FRAME_RATE, + background_color: 0x0, + ..Default::default() + }; + + let browser = browser_host_create_browser_sync( + Some(&window_info), + Some(&mut client), + Some(&url), + Some(&settings), + Option::<&mut DictionaryValue>::None, + Option::<&mut RequestContext>::None, + ); + + if let Some(browser) = browser { + Ok(SingleThreadedCefContext { + browser, + input_state: InputState::default(), + }) + } else { + tracing::error!("Failed to create browser"); + Err(InitError::BrowserCreationFailed) + } +} + +#[derive(thiserror::Error, Debug)] +pub(crate) enum SetupError { + #[error("This is the sub process should exit immediately")] + Subprocess, + #[error("Subprocess returned non zero exit code")] + SubprocessFailed(String), +} + +#[derive(thiserror::Error, Debug)] +pub(crate) enum InitError { + #[error("Initialization failed")] + InitializationFailed(u32), + #[error("Browser creation failed")] + BrowserCreationFailed, + #[error("Another instance is already running")] + AlreadyRunning, +} diff --git a/desktop/src/cef/context/multithreaded.rs b/desktop/src/cef/context/multithreaded.rs new file mode 100644 index 0000000000..9434a99d86 --- /dev/null +++ b/desktop/src/cef/context/multithreaded.rs @@ -0,0 +1,67 @@ +use cef::sys::cef_thread_id_t; +use cef::{Task, ThreadId, post_task}; +use std::cell::RefCell; +use winit::event::WindowEvent; + +use crate::cef::internal::task::ClosureTask; + +use super::CefContext; +use super::singlethreaded::SingleThreadedCefContext; + +thread_local! { + pub(super) static CONTEXT: RefCell> = const { RefCell::new(None) }; +} + +pub(super) struct MultiThreadedCefContextProxy; + +impl CefContext for MultiThreadedCefContextProxy { + fn work(&mut self) { + // CEF handles its own message loop in multi-threaded mode + } + + fn handle_window_event(&mut self, event: &WindowEvent) { + let event_clone = event.clone(); + run_on_ui_thread(move || { + CONTEXT.with(|b| { + if let Some(context) = b.borrow_mut().as_mut() { + context.handle_window_event(&event_clone); + } + }); + }); + } + + fn notify_of_resize(&self) { + run_on_ui_thread(move || { + CONTEXT.with(|b| { + if let Some(context) = b.borrow_mut().as_mut() { + context.notify_of_resize(); + } + }); + }); + } + + fn send_web_message(&self, message: Vec) { + run_on_ui_thread(move || { + CONTEXT.with(|b| { + if let Some(context) = b.borrow_mut().as_mut() { + context.send_web_message(message); + } + }); + }); + } +} + +impl Drop for MultiThreadedCefContextProxy { + fn drop(&mut self) { + cef::shutdown(); + } +} + +pub(super) fn run_on_ui_thread(closure: F) +where + F: FnOnce() + Send + 'static, +{ + let closure_task = ClosureTask::new(closure); + let mut task = Task::new(closure_task); + post_task(ThreadId::from(cef_thread_id_t::TID_UI), Some(&mut task)); +} diff --git a/desktop/src/cef/context/singlethreaded.rs b/desktop/src/cef/context/singlethreaded.rs new file mode 100644 index 0000000000..2222c85215 --- /dev/null +++ b/desktop/src/cef/context/singlethreaded.rs @@ -0,0 +1,48 @@ +use cef::{Browser, ImplBrowser, ImplBrowserHost}; +use winit::event::WindowEvent; + +use crate::cef::input; +use crate::cef::input::InputState; +use crate::cef::ipc::{MessageType, SendMessage}; + +use super::CefContext; + +pub(super) struct SingleThreadedCefContext { + pub(super) browser: Browser, + pub(super) input_state: InputState, +} + +impl CefContext for SingleThreadedCefContext { + fn work(&mut self) { + cef::do_message_loop_work(); + } + + fn handle_window_event(&mut self, event: &WindowEvent) { + input::handle_window_event(&self.browser, &mut self.input_state, event) + } + + fn notify_of_resize(&self) { + self.browser.host().unwrap().was_resized(); + } + + fn send_web_message(&self, message: Vec) { + self.send_message(MessageType::SendToJS, &message); + } +} + +impl Drop for SingleThreadedCefContext { + fn drop(&mut self) { + cef::shutdown(); + } +} + +impl SendMessage for SingleThreadedCefContext { + fn send_message(&self, message_type: MessageType, message: &[u8]) { + let Some(frame) = self.browser.main_frame() else { + tracing::error!("Main frame is not available, cannot send message"); + return; + }; + + frame.send_message(message_type, message); + } +} diff --git a/desktop/src/cef/input.rs b/desktop/src/cef/input.rs index 103838a188..8b4e39ecdf 100644 --- a/desktop/src/cef/input.rs +++ b/desktop/src/cef/input.rs @@ -1,164 +1,153 @@ use cef::sys::{cef_event_flags_t, cef_key_event_type_t, cef_mouse_button_type_t}; -use cef::{ImplBrowser, ImplBrowserHost, KeyEvent, KeyEventType, MouseEvent}; +use cef::{Browser, ImplBrowser, ImplBrowserHost, KeyEvent, KeyEventType, MouseEvent}; use winit::dpi::PhysicalPosition; use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; -use super::context::{Context, Initialized}; - mod keymap; use keymap::{ToDomBits, ToVKBits}; -pub(crate) fn handle_window_event(context: &mut Context, event: WindowEvent) -> Option { +pub(crate) fn handle_window_event(browser: &Browser, input_state: &mut InputState, event: &WindowEvent) { match event { WindowEvent::CursorMoved { position, .. } => { - if let Some(browser) = &context.browser { - if let Some(host) = browser.host() { - host.set_focus(1); - } - - context.input_state.update_mouse_position(&position); - let mouse_event: MouseEvent = (&context.input_state).into(); - browser.host().unwrap().send_mouse_move_event(Some(&mouse_event), 0); + if let Some(host) = browser.host() { + host.set_focus(1); } + + input_state.update_mouse_position(position); + let mouse_event: MouseEvent = (input_state).into(); + browser.host().unwrap().send_mouse_move_event(Some(&mouse_event), 0); } WindowEvent::MouseInput { state, button, .. } => { - if let Some(browser) = &context.browser { - if let Some(host) = browser.host() { - host.set_focus(1); + if let Some(host) = browser.host() { + host.set_focus(1); - let mouse_up = match state { - ElementState::Pressed => 0, - ElementState::Released => 1, - }; + let mouse_up = match state { + ElementState::Pressed => 0, + ElementState::Released => 1, + }; - let cef_button = match button { - MouseButton::Left => Some(cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_LEFT)), - MouseButton::Right => Some(cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_RIGHT)), - MouseButton::Middle => Some(cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_MIDDLE)), - MouseButton::Forward => None, //TODO: Handle Forward button - MouseButton::Back => None, //TODO: Handle Back button - _ => None, - }; + let cef_button = match button { + MouseButton::Left => Some(cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_LEFT)), + MouseButton::Right => Some(cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_RIGHT)), + MouseButton::Middle => Some(cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_MIDDLE)), + MouseButton::Forward => None, //TODO: Handle Forward button + MouseButton::Back => None, //TODO: Handle Back button + _ => None, + }; - let mut mouse_state = context.input_state.mouse_state.clone(); - match button { - MouseButton::Left => { - mouse_state.left = match state { - ElementState::Pressed => true, - ElementState::Released => false, - } + let mut mouse_state = input_state.mouse_state.clone(); + match button { + MouseButton::Left => { + mouse_state.left = match state { + ElementState::Pressed => true, + ElementState::Released => false, } - MouseButton::Right => { - mouse_state.right = match state { - ElementState::Pressed => true, - ElementState::Released => false, - } - } - MouseButton::Middle => { - mouse_state.middle = match state { - ElementState::Pressed => true, - ElementState::Released => false, - } - } - _ => {} - }; - context.input_state.update_mouse_state(mouse_state); - - let mouse_event: MouseEvent = (&context.input_state).into(); - - if let Some(button) = cef_button { - host.send_mouse_click_event( - Some(&mouse_event), - button, - mouse_up, - 1, // click count - ); } + MouseButton::Right => { + mouse_state.right = match state { + ElementState::Pressed => true, + ElementState::Released => false, + } + } + MouseButton::Middle => { + mouse_state.middle = match state { + ElementState::Pressed => true, + ElementState::Released => false, + } + } + _ => {} + }; + input_state.update_mouse_state(mouse_state); + + let mouse_event: MouseEvent = input_state.into(); + + if let Some(button) = cef_button { + host.send_mouse_click_event( + Some(&mouse_event), + button, + mouse_up, + 1, // click count + ); } } } WindowEvent::MouseWheel { delta, phase: _, device_id: _, .. } => { - if let Some(browser) = &context.browser { - if let Some(host) = browser.host() { - let mouse_event = (&context.input_state).into(); - let line_width = 40; //feels about right, TODO: replace with correct value - let line_height = 30; //feels about right, TODO: replace with correct value - let (delta_x, delta_y) = match delta { - MouseScrollDelta::LineDelta(x, y) => (x * line_width as f32, y * line_height as f32), - MouseScrollDelta::PixelDelta(physical_position) => (physical_position.x as f32, physical_position.y as f32), - }; - host.send_mouse_wheel_event(Some(&mouse_event), delta_x as i32, delta_y as i32); - } + if let Some(host) = browser.host() { + let mouse_event = input_state.into(); + let line_width = 40; //feels about right, TODO: replace with correct value + let line_height = 30; //feels about right, TODO: replace with correct value + let (delta_x, delta_y) = match delta { + MouseScrollDelta::LineDelta(x, y) => (x * line_width as f32, y * line_height as f32), + MouseScrollDelta::PixelDelta(physical_position) => (physical_position.x as f32, physical_position.y as f32), + }; + host.send_mouse_wheel_event(Some(&mouse_event), delta_x as i32, delta_y as i32); } } WindowEvent::ModifiersChanged(modifiers) => { - context.input_state.update_modifiers(&modifiers.state()); + input_state.update_modifiers(&modifiers.state()); } WindowEvent::KeyboardInput { device_id: _, event, is_synthetic: _ } => { - if let Some(browser) = &context.browser { - if let Some(host) = browser.host() { - host.set_focus(1); + if let Some(host) = browser.host() { + host.set_focus(1); - let (named_key, character) = match &event.logical_key { - winit::keyboard::Key::Named(named_key) => ( - Some(named_key), - match named_key { - winit::keyboard::NamedKey::Space => Some(' '), - winit::keyboard::NamedKey::Enter => Some('\u{000d}'), - _ => None, - }, - ), - winit::keyboard::Key::Character(str) => { - let char = str.chars().next().unwrap_or('\0'); - (None, Some(char)) - } - _ => return None, - }; - - let mut key_event = KeyEvent { - size: size_of::(), - focus_on_editable_field: 1, - modifiers: context.input_state.cef_modifiers(&event.location, event.repeat).raw(), - is_system_key: 0, - ..Default::default() - }; - - if let Some(named_key) = named_key { - key_event.native_key_code = named_key.to_dom_bits(); - key_event.windows_key_code = named_key.to_vk_bits(); - } else if let Some(char) = character { - key_event.native_key_code = char.to_dom_bits(); - key_event.windows_key_code = char.to_vk_bits(); + let (named_key, character) = match &event.logical_key { + winit::keyboard::Key::Named(named_key) => ( + Some(named_key), + match named_key { + winit::keyboard::NamedKey::Space => Some(' '), + winit::keyboard::NamedKey::Enter => Some('\u{000d}'), + _ => None, + }, + ), + winit::keyboard::Key::Character(str) => { + let char = str.chars().next().unwrap_or('\0'); + (None, Some(char)) } + _ => return, + }; - match event.state { - ElementState::Pressed => { - key_event.type_ = KeyEventType::from(cef_key_event_type_t::KEYEVENT_RAWKEYDOWN); - host.send_key_event(Some(&key_event)); + let mut key_event = KeyEvent { + size: size_of::(), + focus_on_editable_field: 1, + modifiers: input_state.cef_modifiers(&event.location, event.repeat).raw(), + is_system_key: 0, + ..Default::default() + }; - if let Some(char) = character { - let mut buf = [0; 2]; - char.encode_utf16(&mut buf); - key_event.character = buf[0]; - let mut buf = [0; 2]; - char.to_lowercase().next().unwrap().encode_utf16(&mut buf); - key_event.unmodified_character = buf[0]; - - key_event.type_ = KeyEventType::from(cef_key_event_type_t::KEYEVENT_CHAR); - host.send_key_event(Some(&key_event)); - } - } - ElementState::Released => { - key_event.type_ = KeyEventType::from(cef_key_event_type_t::KEYEVENT_KEYUP); - host.send_key_event(Some(&key_event)); - } - }; + if let Some(named_key) = named_key { + key_event.native_key_code = named_key.to_dom_bits(); + key_event.windows_key_code = named_key.to_vk_bits(); + } else if let Some(char) = character { + key_event.native_key_code = char.to_dom_bits(); + key_event.windows_key_code = char.to_vk_bits(); } + + match event.state { + ElementState::Pressed => { + key_event.type_ = KeyEventType::from(cef_key_event_type_t::KEYEVENT_RAWKEYDOWN); + host.send_key_event(Some(&key_event)); + + if let Some(char) = character { + let mut buf = [0; 2]; + char.encode_utf16(&mut buf); + key_event.character = buf[0]; + let mut buf = [0; 2]; + char.to_lowercase().next().unwrap().encode_utf16(&mut buf); + key_event.unmodified_character = buf[0]; + + key_event.type_ = KeyEventType::from(cef_key_event_type_t::KEYEVENT_CHAR); + host.send_key_event(Some(&key_event)); + } + } + ElementState::Released => { + key_event.type_ = KeyEventType::from(cef_key_event_type_t::KEYEVENT_KEYUP); + host.send_key_event(Some(&key_event)); + } + }; } } - e => return Some(e), + _ => {} } - None } #[derive(Default, Clone)] @@ -227,6 +216,15 @@ impl From<&InputState> for MouseEvent { } } } +impl From<&mut InputState> for MouseEvent { + fn from(val: &mut InputState) -> Self { + MouseEvent { + x: val.mouse_position.x as i32, + y: val.mouse_position.y as i32, + modifiers: val.cef_modifiers_mouse_event().raw(), + } + } +} struct CefModifiers(u32); diff --git a/desktop/src/cef/internal.rs b/desktop/src/cef/internal.rs index 03a520e669..5157852458 100644 --- a/desktop/src/cef/internal.rs +++ b/desktop/src/cef/internal.rs @@ -2,11 +2,13 @@ mod browser_process_app; mod browser_process_client; mod browser_process_handler; mod browser_process_life_span_handler; -mod render_handler; mod render_process_app; mod render_process_handler; mod render_process_v8_handler; +pub(super) mod render_handler; +pub(super) mod task; + pub(super) use browser_process_app::BrowserProcessAppImpl; pub(super) use browser_process_client::BrowserProcessClientImpl; pub(super) use render_handler::RenderHandlerImpl; diff --git a/desktop/src/cef/internal/browser_process_app.rs b/desktop/src/cef/internal/browser_process_app.rs index 82999c5872..165d27ce4c 100644 --- a/desktop/src/cef/internal/browser_process_app.rs +++ b/desktop/src/cef/internal/browser_process_app.rs @@ -1,3 +1,4 @@ +#[cfg(target_os = "linux")] use std::env; use cef::rc::{Rc, RcImpl}; @@ -34,12 +35,28 @@ impl ImplApp for BrowserProcessAppImpl { fn on_before_command_line_processing(&self, _process_type: Option<&cef::CefString>, command_line: Option<&mut cef::CommandLine>) { if let Some(cmd) = command_line { - // Disable GPU acceleration, because it is not supported for Offscreen Rendering and can cause crashes. - cmd.append_switch(Some(&CefString::from("disable-gpu"))); - cmd.append_switch(Some(&CefString::from("disable-gpu-compositing"))); + #[cfg(not(feature = "accelerated_paint"))] + { + // Disable GPU acceleration when accelerated_paint feature is not enabled + cmd.append_switch(Some(&CefString::from("disable-gpu"))); + cmd.append_switch(Some(&CefString::from("disable-gpu-compositing"))); + } + + #[cfg(feature = "accelerated_paint")] + { + // Enable GPU acceleration switches for better performance + cmd.append_switch(Some(&CefString::from("enable-gpu-rasterization"))); + cmd.append_switch(Some(&CefString::from("enable-accelerated-2d-canvas"))); + } + + #[cfg(all(feature = "accelerated_paint", target_os = "linux"))] + { + // Use Vulkan for accelerated painting + cmd.append_switch_with_value(Some(&CefString::from("use-angle")), Some(&CefString::from("vulkan"))); + } // Tell CEF to use Wayland if available - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] { let use_wayland = env::var("WAYLAND_DISPLAY") .ok() diff --git a/desktop/src/cef/internal/browser_process_handler.rs b/desktop/src/cef/internal/browser_process_handler.rs index 4edacc5c1a..cd80ce6084 100644 --- a/desktop/src/cef/internal/browser_process_handler.rs +++ b/desktop/src/cef/internal/browser_process_handler.rs @@ -5,7 +5,8 @@ use cef::sys::{_cef_browser_process_handler_t, cef_base_ref_counted_t, cef_brows use cef::{CefString, ImplBrowserProcessHandler, SchemeHandlerFactory, WrapBrowserProcessHandler}; use crate::cef::CefEventHandler; -use crate::cef::scheme_handler::{GRAPHITE_SCHEME, GraphiteSchemeHandlerFactory}; +use crate::cef::consts::GRAPHITE_SCHEME; +use crate::cef::scheme_handler::GraphiteSchemeHandlerFactory; pub(crate) struct BrowserProcessHandlerImpl { object: *mut RcImpl, diff --git a/desktop/src/cef/internal/render_handler.rs b/desktop/src/cef/internal/render_handler.rs index 21d818a091..c430630dac 100644 --- a/desktop/src/cef/internal/render_handler.rs +++ b/desktop/src/cef/internal/render_handler.rs @@ -9,6 +9,7 @@ pub(crate) struct RenderHandlerImpl { object: *mut RcImpl<_cef_render_handler_t, Self>, event_handler: H, } + impl RenderHandlerImpl { pub(crate) fn new(event_handler: H) -> Self { Self { @@ -47,6 +48,23 @@ impl ImplRenderHandler for RenderHandlerImpl { self.event_handler.draw(frame_buffer) } + #[cfg(feature = "accelerated_paint")] + fn on_accelerated_paint(&self, _browser: Option<&mut Browser>, type_: PaintElementType, _dirty_rect_count: usize, _dirty_rects: Option<&Rect>, info: Option<&cef::AcceleratedPaintInfo>) { + use crate::cef::texture_import::shared_texture_handle::SharedTextureHandle; + + if type_ != PaintElementType::default() { + return; + } + + let shared_handle = SharedTextureHandle::new(info.unwrap()); + if let SharedTextureHandle::Unsupported = shared_handle { + tracing::error!("Platform does not support accelerated painting"); + return; + } + + self.event_handler.draw_gpu(shared_handle); + } + fn get_raw(&self) -> *mut _cef_render_handler_t { self.object.cast() } diff --git a/desktop/src/cef/internal/task.rs b/desktop/src/cef/internal/task.rs new file mode 100644 index 0000000000..fe29e3de90 --- /dev/null +++ b/desktop/src/cef/internal/task.rs @@ -0,0 +1,61 @@ +use cef::rc::{Rc, RcImpl}; +use cef::sys::{_cef_task_t, cef_base_ref_counted_t}; +use cef::{ImplTask, WrapTask}; +use std::cell::RefCell; + +// Closure-based task wrapper following CEF patterns +pub struct ClosureTask { + pub(crate) object: *mut RcImpl<_cef_task_t, Self>, + pub(crate) closure: RefCell>, +} + +impl ClosureTask { + pub fn new(closure: F) -> Self { + Self { + object: std::ptr::null_mut(), + closure: RefCell::new(Some(closure)), + } + } +} + +impl ImplTask for ClosureTask { + fn execute(&self) { + if let Some(closure) = self.closure.borrow_mut().take() { + closure(); + } + } + + fn get_raw(&self) -> *mut _cef_task_t { + self.object.cast() + } +} + +impl Clone for ClosureTask { + fn clone(&self) -> Self { + unsafe { + if !self.object.is_null() { + let rc_impl = &mut *self.object; + rc_impl.interface.add_ref(); + } + } + Self { + object: self.object, + closure: RefCell::new(None), // Closure can only be executed once + } + } +} + +impl Rc for ClosureTask { + fn as_base(&self) -> &cef_base_ref_counted_t { + unsafe { + let base = &*self.object; + std::mem::transmute(&base.cef_object) + } + } +} + +impl WrapTask for ClosureTask { + fn wrap_rc(&mut self, object: *mut RcImpl<_cef_task_t, Self>) { + self.object = object; + } +} diff --git a/desktop/src/cef/ipc.rs b/desktop/src/cef/ipc.rs index 69b344a464..8d2080fdd0 100644 --- a/desktop/src/cef/ipc.rs +++ b/desktop/src/cef/ipc.rs @@ -1,6 +1,4 @@ -use cef::{CefString, Frame, ImplBinaryValue, ImplBrowser, ImplFrame, ImplListValue, ImplProcessMessage, ImplV8Context, ProcessId, V8Context, sys::cef_process_id_t}; - -use super::{Context, Initialized}; +use cef::{CefString, Frame, ImplBinaryValue, ImplFrame, ImplListValue, ImplProcessMessage, ImplV8Context, ProcessId, V8Context, sys::cef_process_id_t}; pub(crate) enum MessageType { SendToJS, @@ -39,21 +37,6 @@ pub(crate) struct MessageInfo { pub(crate) trait SendMessage { fn send_message(&self, message_type: MessageType, message: &[u8]); } -impl SendMessage for Context { - fn send_message(&self, message_type: MessageType, message: &[u8]) { - let Some(browser) = &self.browser else { - tracing::error!("Browser is not initialized, cannot send message"); - return; - }; - - let Some(frame) = browser.main_frame() else { - tracing::error!("Main frame is not available, cannot send message"); - return; - }; - - frame.send_message(message_type, message); - } -} impl SendMessage for Option { fn send_message(&self, message_type: MessageType, message: &[u8]) { let Some(context) = self else { diff --git a/desktop/src/cef/platform.rs b/desktop/src/cef/platform.rs new file mode 100644 index 0000000000..7df5d5f982 --- /dev/null +++ b/desktop/src/cef/platform.rs @@ -0,0 +1,59 @@ +#[cfg(feature = "accelerated_paint")] +pub fn should_enable_hardware_acceleration() -> bool { + #[cfg(target_os = "linux")] + { + // Check if running on Wayland or X11 + let has_wayland = std::env::var("WAYLAND_DISPLAY") + .ok() + .filter(|var| !var.is_empty()) + .or_else(|| std::env::var("WAYLAND_SOCKET").ok()) + .filter(|var| !var.is_empty()) + .is_some(); + + let has_x11 = std::env::var("DISPLAY").ok().filter(|var| !var.is_empty()).is_some(); + + if !has_wayland && !has_x11 { + tracing::warn!("No display server detected, disabling hardware acceleration"); + return false; + } + + // Check for NVIDIA proprietary driver (known to have issues) + if let Ok(driver_info) = std::fs::read_to_string("/proc/driver/nvidia/version") { + if driver_info.contains("NVIDIA") { + tracing::warn!("NVIDIA proprietary driver detected, hardware acceleration may be unstable"); + // Still return true but with warning + } + } + + // Check for basic GPU capabilities + if has_wayland { + tracing::info!("Wayland detected, enabling hardware acceleration"); + true + } else if has_x11 { + tracing::info!("X11 detected, enabling hardware acceleration"); + true + } else { + false + } + } + + #[cfg(target_os = "windows")] + { + // Windows generally has good D3D11 support + tracing::info!("Windows detected, enabling hardware acceleration"); + true + } + + #[cfg(target_os = "macos")] + { + // macOS has good Metal/IOSurface support + tracing::info!("macOS detected, enabling hardware acceleration"); + true + } + + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] + { + tracing::warn!("Unsupported platform for hardware acceleration"); + false + } +} diff --git a/desktop/src/cef/scheme_handler.rs b/desktop/src/cef/scheme_handler.rs index 0cbb8bb541..a3b5075459 100644 --- a/desktop/src/cef/scheme_handler.rs +++ b/desktop/src/cef/scheme_handler.rs @@ -11,8 +11,7 @@ use cef::{ }; use include_dir::{Dir, include_dir}; -pub(crate) const GRAPHITE_SCHEME: &str = "graphite-static"; -pub(crate) const FRONTEND_DOMAIN: &str = "frontend"; +use super::consts::{FRONTEND_DOMAIN, GRAPHITE_SCHEME}; pub(crate) struct GraphiteSchemeHandlerFactory { object: *mut RcImpl<_cef_scheme_handler_factory_t, Self>, diff --git a/desktop/src/cef/texture_import/common.rs b/desktop/src/cef/texture_import/common.rs new file mode 100644 index 0000000000..ec5951f46d --- /dev/null +++ b/desktop/src/cef/texture_import/common.rs @@ -0,0 +1,99 @@ +//! Common utilities and traits for texture import across platforms + +use crate::cef::texture_import::*; +use ash::vk; +use cef::sys::cef_color_type_t; +use wgpu::Device; + +/// Common format conversion utilities +pub mod format { + use super::*; + + /// Convert CEF color type to wgpu texture format + pub fn cef_to_wgpu(format: cef_color_type_t) -> Result { + match format { + cef_color_type_t::CEF_COLOR_TYPE_BGRA_8888 => Ok(wgpu::TextureFormat::Bgra8UnormSrgb), + cef_color_type_t::CEF_COLOR_TYPE_RGBA_8888 => Ok(wgpu::TextureFormat::Rgba8UnormSrgb), + _ => Err(TextureImportError::UnsupportedFormat { format }), + } + } + + #[cfg(not(target_os = "macos"))] + /// Convert CEF color type to Vulkan format + pub fn cef_to_vulkan(format: cef_color_type_t) -> Result { + match format { + cef_color_type_t::CEF_COLOR_TYPE_BGRA_8888 => Ok(vk::Format::B8G8R8A8_UNORM), + cef_color_type_t::CEF_COLOR_TYPE_RGBA_8888 => Ok(vk::Format::R8G8B8A8_UNORM), + _ => Err(TextureImportError::UnsupportedFormat { format }), + } + } +} + +/// Common texture creation utilities +pub mod texture { + use super::*; + + /// Create a fallback CPU texture with the given dimensions and format + pub fn create_fallback(device: &Device, width: u32, height: u32, format: cef_color_type_t, label: &str) -> TextureImportResult { + let wgpu_format = format::cef_to_wgpu(format)?; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu_format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + tracing::warn!( + "Using fallback CPU texture for CEF rendering ({}x{}, {:?}) - hardware acceleration failed or unavailable. Consider checking GPU driver support.", + width, + height, + format + ); + Ok(texture) + } +} + +/// Common Vulkan utilities +pub mod vulkan { + use super::*; + + /// Find a suitable memory type index for Vulkan allocation + pub fn find_memory_type_index(type_filter: u32, properties: vk::MemoryPropertyFlags, mem_properties: &vk::PhysicalDeviceMemoryProperties) -> Option { + (0..mem_properties.memory_type_count).find(|&i| (type_filter & (1 << i)) != 0 && mem_properties.memory_types[i as usize].property_flags.contains(properties)) + } + + /// Check if the wgpu device is using Vulkan backend + #[cfg(not(target_os = "macos"))] + pub fn is_vulkan_backend(device: &Device) -> bool { + use wgpu::hal::api; + let mut is_vulkan = false; + unsafe { + device.as_hal::(|device| { + is_vulkan = device.is_some(); + }); + } + is_vulkan + } + + /// Check if the wgpu device is using D3D12 backend + #[cfg(target_os = "windows")] + pub fn is_d3d12_backend(device: &Device) -> bool { + use wgpu::hal::api; + let mut is_d3d12 = false; + unsafe { + device.as_hal::(|device| { + is_d3d12 = device.is_some(); + }); + } + is_d3d12 + } +} diff --git a/desktop/src/cef/texture_import/d3d11.rs b/desktop/src/cef/texture_import/d3d11.rs new file mode 100644 index 0000000000..f769d06b3b --- /dev/null +++ b/desktop/src/cef/texture_import/d3d11.rs @@ -0,0 +1,290 @@ +//! Windows D3D11 shared texture import implementation + +use super::common::{format, texture, vulkan}; +use super::{TextureImportError, TextureImportResult, TextureImporter}; +use ash::vk; +use cef::{AcceleratedPaintInfo, sys::cef_color_type_t}; +use std::os::raw::c_void; +use wgpu::hal::api; + +pub struct D3D11Importer { + pub handle: *mut c_void, + pub format: cef_color_type_t, + pub width: u32, + pub height: u32, +} + +impl TextureImporter for D3D11Importer { + fn new(info: &AcceleratedPaintInfo) -> Self { + Self { + handle: info.shared_texture_handle, + format: *info.format.as_ref(), + width: info.extra.coded_size.width as u32, + height: info.extra.coded_size.height as u32, + } + } + + fn import_to_wgpu(&self, device: &wgpu::Device) -> TextureImportResult { + // Try hardware acceleration first + if self.supports_hardware_acceleration(device) { + // Try D3D12 first (most efficient on Windows) + if vulkan::is_d3d12_backend(device) { + match self.import_via_d3d12(device) { + Ok(texture) => { + tracing::info!("Successfully imported D3D11 shared texture via D3D12"); + return Ok(texture); + } + Err(e) => { + tracing::warn!("Failed to import D3D11 via D3D12: {}, trying Vulkan fallback", e); + } + } + } + + // Try Vulkan as fallback + if vulkan::is_vulkan_backend(device) { + match self.import_via_vulkan(device) { + Ok(texture) => { + tracing::info!("Successfully imported D3D11 shared texture via Vulkan"); + return Ok(texture); + } + Err(e) => { + tracing::warn!("Failed to import D3D11 via Vulkan: {}, falling back to CPU texture", e); + } + } + } + } + + // Fallback to CPU texture + texture::create_fallback(device, self.width, self.height, self.format, "CEF D3D11 Texture (fallback)") + } + + fn supports_hardware_acceleration(&self, device: &wgpu::Device) -> bool { + // Check if handle is valid + if self.handle.is_null() { + return false; + } + + // Check if wgpu is using D3D12 or Vulkan backend + vulkan::is_d3d12_backend(device) || vulkan::is_vulkan_backend(device) + } +} + +impl D3D11Importer { + fn import_via_d3d12(&self, device: &wgpu::Device) -> TextureImportResult { + // Get wgpu's D3D12 device + use wgpu::hal::api; + let hal_texture = unsafe { + device.as_hal::(|device| { + let Some(device) = device else { + return Err(TextureImportError::HardwareUnavailable { + reason: "Device is not using D3D12 backend".to_string(), + }); + }; + + // Import D3D11 shared handle directly into D3D12 resource + let d3d12_resource = self.import_d3d11_handle_to_d3d12(device)?; + + // Wrap D3D12 resource in wgpu-hal texture + let hal_texture = ::Device::texture_from_raw( + d3d12_resource, + format::cef_to_wgpu(self.format)?, + wgpu::TextureDimension::D2, + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + 1, // mip_level_count + 1, // sample_count + ); + + Ok(hal_texture) + }) + }?; + + // Import hal texture into wgpu + let texture = unsafe { + device.create_texture_from_hal::( + hal_texture, + &wgpu::TextureDescriptor { + label: Some("CEF D3D11→D3D12 Shared Texture"), + size: wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: format::cef_to_wgpu(self.format)?, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ) + }; + + Ok(texture) + } + + fn import_via_vulkan(&self, device: &wgpu::Device) -> TextureImportResult { + // Get wgpu's Vulkan instance and device + use wgpu::{TextureUses, wgc::api::Vulkan}; + let hal_texture = unsafe { + device.as_hal::(|device| { + let Some(device) = device else { + return Err(TextureImportError::HardwareUnavailable { + reason: "Device is not using Vulkan backend".to_string(), + }); + }; + + // Import D3D11 shared handle into Vulkan + let vk_image = self.import_d3d11_handle_to_vulkan(device)?; + + // Wrap VkImage in wgpu-hal texture + let hal_texture = ::Device::texture_from_raw( + vk_image, + &wgpu::hal::TextureDescriptor { + label: Some("CEF D3D11 Shared Texture"), + size: wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: format::cef_to_wgpu(self.format)?, + usage: TextureUses::COPY_DST | TextureUses::RESOURCE, + memory_flags: wgpu::hal::MemoryFlags::empty(), + view_formats: vec![], + }, + None, // drop_callback + ); + + Ok(hal_texture) + }) + }?; + + // Import hal texture into wgpu + let texture = unsafe { + device.create_texture_from_hal::( + hal_texture, + &wgpu::TextureDescriptor { + label: Some("CEF D3D11 Shared Texture"), + size: wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: format::cef_to_wgpu(self.format)?, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ) + }; + + Ok(texture) + } + + fn import_d3d11_handle_to_vulkan(&self, hal_device: &::Device) -> Result { + // Get raw Vulkan handles + let device = hal_device.raw_device(); + let _instance = hal_device.shared_instance().raw_instance(); + + // Validate dimensions + if self.width == 0 || self.height == 0 { + return Err(TextureImportError::InvalidHandle("Invalid D3D11 texture dimensions".to_string())); + } + + // Create external memory image info + let mut external_memory_info = vk::ExternalMemoryImageCreateInfo::default().handle_types(vk::ExternalMemoryHandleTypeFlags::D3D11_TEXTURE); + + // Create image create info + let image_create_info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(format::cef_to_vulkan(self.format)?) + .extent(vk::Extent3D { + width: self.width, + height: self.height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::COLOR_ATTACHMENT) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .push_next(&mut external_memory_info); + + // Create the image + let image = unsafe { + device.create_image(&image_create_info, None).map_err(|e| TextureImportError::VulkanError { + operation: format!("Failed to create Vulkan image: {:?}", e), + })? + }; + + // Get memory requirements + let memory_requirements = unsafe { device.get_image_memory_requirements(image) }; + + // Import D3D11 handle + let mut import_memory_win32 = vk::ImportMemoryWin32HandleInfoKHR::default() + .handle_type(vk::ExternalMemoryHandleTypeFlags::D3D11_TEXTURE) + .handle(self.handle as isize); + + // Find a suitable memory type + let memory_properties = unsafe { hal_device.shared_instance().raw_instance().get_physical_device_memory_properties(hal_device.raw_physical_device()) }; + + let memory_type_index = + vulkan::find_memory_type_index(memory_requirements.memory_type_bits, vk::MemoryPropertyFlags::empty(), &memory_properties).ok_or_else(|| TextureImportError::VulkanError { + operation: "Failed to find suitable memory type for D3D11 texture".to_string(), + })?; + + let allocate_info = vk::MemoryAllocateInfo::default() + .allocation_size(memory_requirements.size) + .memory_type_index(memory_type_index) + .push_next(&mut import_memory_win32); + + let device_memory = unsafe { + device.allocate_memory(&allocate_info, None).map_err(|e| TextureImportError::VulkanError { + operation: format!("Failed to allocate memory for D3D11 texture: {:?}", e), + })? + }; + + // Bind memory to image + unsafe { + device.bind_image_memory(image, device_memory, 0).map_err(|e| TextureImportError::VulkanError { + operation: format!("Failed to bind memory to image: {:?}", e), + })?; + } + + Ok(image) + } + + fn import_d3d11_handle_to_d3d12(&self, hal_device: &::Device) -> Result { + use windows::Win32::Graphics::Direct3D12::*; + use windows::core::*; + + // Get D3D12 device from wgpu-hal + let d3d12_device = hal_device.raw_device(); + + // Validate dimensions + if self.width == 0 || self.height == 0 { + return Err(TextureImportError::InvalidHandle("Invalid D3D11 texture dimensions".to_string())); + } + + // Open D3D11 shared handle on D3D12 device + unsafe { + let mut shared_resource: Option = None; + d3d12_device + .OpenSharedHandle(windows::Win32::Foundation::HANDLE(self.handle), &mut shared_resource) + .map_err(|e| TextureImportError::PlatformError { + message: format!("Failed to open D3D11 shared handle on D3D12: {:?}", e), + })?; + + shared_resource.ok_or_else(|| TextureImportError::InvalidHandle("Failed to get D3D12 resource from shared handle".to_string())) + } + } +} diff --git a/desktop/src/cef/texture_import/dmabuf.rs b/desktop/src/cef/texture_import/dmabuf.rs new file mode 100644 index 0000000000..a72e07ccd4 --- /dev/null +++ b/desktop/src/cef/texture_import/dmabuf.rs @@ -0,0 +1,273 @@ +//! Linux DMA-BUF texture import implementation + +use super::common::{format, texture, vulkan}; +use super::{TextureImportError, TextureImportResult, TextureImporter}; +use ash::vk; +use cef::{AcceleratedPaintInfo, sys::cef_color_type_t}; +use wgpu::hal::api; + +pub(crate) struct DmaBufImporter { + fds: Vec, + format: cef_color_type_t, + modifier: u64, + width: u32, + height: u32, + strides: Vec, + offsets: Vec, +} + +impl TextureImporter for DmaBufImporter { + fn new(info: &AcceleratedPaintInfo) -> Self { + Self { + fds: extract_fds_from_info(info), + format: *info.format.as_ref(), + modifier: info.modifier, + width: info.extra.coded_size.width as u32, + height: info.extra.coded_size.height as u32, + strides: extract_strides_from_info(info), + offsets: extract_offsets_from_info(info), + } + } + + fn import_to_wgpu(&self, device: &wgpu::Device) -> TextureImportResult { + // Try hardware acceleration first + if self.supports_hardware_acceleration(device) { + match self.import_via_vulkan(device) { + Ok(texture) => { + tracing::info!("Successfully imported DMA-BUF texture via Vulkan"); + return Ok(texture); + } + Err(e) => { + tracing::warn!("Failed to import DMA-BUF via Vulkan: {}, falling back to CPU texture", e); + } + } + } + + // Fallback to CPU texture + texture::create_fallback(device, self.width, self.height, self.format, "CEF DMA-BUF Texture (fallback)") + } + + fn supports_hardware_acceleration(&self, device: &wgpu::Device) -> bool { + // Check if we have valid file descriptors + if self.fds.is_empty() { + return false; + } + + for &fd in &self.fds { + if fd < 0 { + return false; + } + // Check if file descriptor is valid + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags == -1 { + return false; + } + } + + // Check if wgpu is using Vulkan backend + vulkan::is_vulkan_backend(device) + } +} + +impl DmaBufImporter { + fn import_via_vulkan(&self, device: &wgpu::Device) -> TextureImportResult { + // Get wgpu's Vulkan instance and device + use wgpu::{TextureUses, wgc::api::Vulkan}; + let hal_texture = unsafe { + device.as_hal::(|device| { + let Some(device) = device else { + return Err(TextureImportError::HardwareUnavailable { + reason: "Device is not using Vulkan backend".to_string(), + }); + }; + + // Create VkImage from DMA-BUF using external memory + let vk_image = self.create_vulkan_image_from_dmabuf(device)?; + + // Wrap VkImage in wgpu-hal texture + let hal_texture = ::Device::texture_from_raw( + vk_image, + &wgpu::hal::TextureDescriptor { + label: Some("CEF DMA-BUF Texture"), + size: wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: format::cef_to_wgpu(self.format)?, + usage: TextureUses::COPY_DST | TextureUses::RESOURCE, + memory_flags: wgpu::hal::MemoryFlags::empty(), + view_formats: vec![], + }, + None, // drop_callback + ); + + Ok(hal_texture) + }) + }?; + + // Import hal texture into wgpu + let texture = unsafe { + device.create_texture_from_hal::( + hal_texture, + &wgpu::TextureDescriptor { + label: Some("CEF DMA-BUF Texture"), + size: wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: format::cef_to_wgpu(self.format)?, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ) + }; + + Ok(texture) + } + + fn create_vulkan_image_from_dmabuf(&self, hal_device: &::Device) -> Result { + // Get raw Vulkan handles + let device = hal_device.raw_device(); + let _instance = hal_device.shared_instance().raw_instance(); + + // Validate dimensions + if self.width == 0 || self.height == 0 { + return Err(TextureImportError::InvalidHandle("Invalid DMA-BUF dimensions".to_string())); + } + + // Create external memory image + let image_create_info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(format::cef_to_vulkan(self.format)?) + .extent(vk::Extent3D { + width: self.width, + height: self.height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT) + .usage(vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::COLOR_ATTACHMENT) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + + // Set up DRM format modifier + let plane_layouts = self.create_subresource_layouts()?; + let mut drm_format_modifier = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default() + .drm_format_modifier(self.modifier) + .plane_layouts(&plane_layouts); + + let image_create_info = image_create_info.push_next(&mut drm_format_modifier); + + // Create the image + let image = unsafe { + device.create_image(&image_create_info, None).map_err(|e| TextureImportError::VulkanError { + operation: format!("Failed to create Vulkan image: {e:?}"), + })? + }; + + // Import memory from DMA-BUF + let memory_requirements = unsafe { device.get_image_memory_requirements(image) }; + + // Duplicate the file descriptor to avoid ownership issues + let dup_fd = unsafe { libc::dup(self.fds[0]) }; + if dup_fd == -1 { + return Err(TextureImportError::PlatformError { + message: "Failed to duplicate DMA-BUF file descriptor".to_string(), + }); + } + + let mut import_memory_fd = vk::ImportMemoryFdInfoKHR::default().handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT).fd(dup_fd); + + // Find a suitable memory type + let memory_properties = unsafe { hal_device.shared_instance().raw_instance().get_physical_device_memory_properties(hal_device.raw_physical_device()) }; + + let memory_type_index = + vulkan::find_memory_type_index(memory_requirements.memory_type_bits, vk::MemoryPropertyFlags::empty(), &memory_properties).ok_or_else(|| TextureImportError::VulkanError { + operation: "Failed to find suitable memory type for DMA-BUF".to_string(), + })?; + + let allocate_info = vk::MemoryAllocateInfo::default() + .allocation_size(memory_requirements.size) + .memory_type_index(memory_type_index) + .push_next(&mut import_memory_fd); + + let device_memory = unsafe { + device.allocate_memory(&allocate_info, None).map_err(|e| TextureImportError::VulkanError { + operation: format!("Failed to allocate memory for DMA-BUF: {e:?}"), + })? + }; + + // Bind memory to image + unsafe { + device.bind_image_memory(image, device_memory, 0).map_err(|e| TextureImportError::VulkanError { + operation: format!("Failed to bind memory to image: {e:?}"), + })?; + } + + Ok(image) + } + + fn create_subresource_layouts(&self) -> Result, TextureImportError> { + let mut layouts = Vec::new(); + + for i in 0..self.fds.len() { + layouts.push(vk::SubresourceLayout { + offset: self.offsets.get(i).copied().unwrap_or(0) as u64, + size: 0, // Will be calculated by driver + row_pitch: self.strides.get(i).copied().unwrap_or(0) as u64, + array_pitch: 0, + depth_pitch: 0, + }); + } + + Ok(layouts) + } +} + +fn extract_fds_from_info(info: &cef::AcceleratedPaintInfo) -> Vec { + let plane_count = info.plane_count as usize; + let mut fds = Vec::with_capacity(plane_count); + + for i in 0..plane_count { + if let Some(plane) = info.planes.get(i) { + fds.push(plane.fd); + } + } + + fds +} + +fn extract_strides_from_info(info: &cef::AcceleratedPaintInfo) -> Vec { + let plane_count = info.plane_count as usize; + let mut strides = Vec::with_capacity(plane_count); + + for i in 0..plane_count { + if let Some(plane) = info.planes.get(i) { + strides.push(plane.stride); + } + } + + strides +} + +fn extract_offsets_from_info(info: &cef::AcceleratedPaintInfo) -> Vec { + let plane_count = info.plane_count as usize; + let mut offsets = Vec::with_capacity(plane_count); + + for i in 0..plane_count { + if let Some(plane) = info.planes.get(i) { + offsets.push(plane.offset as u32); + } + } + + offsets +} diff --git a/desktop/src/cef/texture_import/iosurface.rs b/desktop/src/cef/texture_import/iosurface.rs new file mode 100644 index 0000000000..65b4fdcc2a --- /dev/null +++ b/desktop/src/cef/texture_import/iosurface.rs @@ -0,0 +1,182 @@ +//! macOS IOSurface texture import implementation + +use super::common::{format, texture}; +use super::{TextureImportError, TextureImportResult, TextureImporter}; +use cef::{AcceleratedPaintInfo, sys::cef_color_type_t}; +use core_foundation::base::{CFType, TCFType}; +use objc2_io_surface::{IOSurface, IOSurfaceRef}; +use objc2_metal::{MTLDevice, MTLPixelFormat, MTLTexture, MTLTextureDescriptor, MTLTextureType, MTLTextureUsage}; +use std::os::raw::c_void; +use wgpu::hal::api; + +pub struct IOSurfaceImporter { + pub handle: *mut c_void, + pub format: cef_color_type_t, + pub width: u32, + pub height: u32, +} + +impl TextureImporter for IOSurfaceImporter { + fn new(info: &AcceleratedPaintInfo) -> Self { + Self { + handle: info.shared_texture_handle, + format: *info.format.as_ref(), + width: info.extra.coded_size.width as u32, + height: info.extra.coded_size.height as u32, + } + } + + fn import_to_wgpu(&self, device: &wgpu::Device) -> TextureImportResult { + // Try hardware acceleration first + if self.supports_hardware_acceleration(device) { + match self.import_via_metal(device) { + Ok(texture) => { + tracing::trace!("Successfully imported IOSurface texture via Metal"); + return Ok(texture); + } + Err(e) => { + tracing::warn!("Failed to import IOSurface via Metal: {}, falling back to CPU texture", e); + } + } + } + + // Fallback to CPU texture + texture::create_fallback(device, self.width, self.height, self.format, "CEF IOSurface Texture (fallback)") + } + + fn supports_hardware_acceleration(&self, device: &wgpu::Device) -> bool { + // Check if handle is valid + if self.handle.is_null() { + return false; + } + + // Check if wgpu is using Metal backend + self.is_metal_backend(device) + } +} + +impl IOSurfaceImporter { + fn import_via_metal(&self, device: &wgpu::Device) -> TextureImportResult { + // Get wgpu's Metal device + use wgpu::{hal::Api, wgc::api::Metal}; + let hal_texture = unsafe { + device.as_hal::(|device| { + let Some(device) = device else { + return Err(TextureImportError::HardwareUnavailable { + reason: "Device is not using Metal backend".to_string(), + }); + }; + + // Import IOSurface handle into Metal texture + let metal_texture = self.import_iosurface_to_metal(device)?; + + // Wrap Metal texture in wgpu-hal texture + let hal_texture = ::Device::texture_from_raw( + metal_texture, + &wgpu::hal::TextureDescriptor { + label: Some("CEF IOSurface Texture"), + size: wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: format::cef_to_wgpu(self.format)?, + usage: wgpu::hal::TextureUses::RESOURCE, + memory_flags: wgpu::hal::MemoryFlags::empty(), + view_formats: vec![], + }, + None, // drop_callback + ); + + Ok(hal_texture) + }) + }?; + + // Import hal texture into wgpu + let texture = unsafe { + device.create_texture_from_hal::( + hal_texture, + &wgpu::TextureDescriptor { + label: Some("CEF IOSurface Texture"), + size: wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: format::cef_to_wgpu(self.format)?, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ) + }; + + Ok(texture) + } + + fn import_iosurface_to_metal(&self, hal_device: &::Device) -> Result<::Texture, TextureImportError> { + // Validate dimensions + if self.width == 0 || self.height == 0 { + return Err(TextureImportError::InvalidHandle("Invalid IOSurface texture dimensions".to_string())); + } + + // Convert handle to IOSurface + let iosurface = unsafe { + let cf_type = CFType::wrap_under_get_rule(self.handle as IOSurfaceRef); + IOSurface::from(cf_type) + }; + + // Get the Metal device from wgpu-hal + let metal_device = hal_device.raw_device(); + + // Convert CEF format to Metal pixel format + let metal_format = self.cef_to_metal_format(self.format)?; + + // Create Metal texture descriptor + let texture_descriptor = MTLTextureDescriptor::new(); + texture_descriptor.setTextureType(MTLTextureType::Type2D); + texture_descriptor.setPixelFormat(metal_format); + texture_descriptor.setWidth(self.width as usize); + texture_descriptor.setHeight(self.height as usize); + texture_descriptor.setDepth(1); + texture_descriptor.setMipmapLevelCount(1); + texture_descriptor.setSampleCount(1); + texture_descriptor.setUsage(MTLTextureUsage::ShaderRead); + + // Create Metal texture from IOSurface + let metal_texture = unsafe { metal_device.newTextureWithDescriptor_iosurface_plane(&texture_descriptor, &iosurface, 0) }; + + let Some(metal_texture) = metal_texture else { + return Err(TextureImportError::PlatformError { + message: "Failed to create Metal texture from IOSurface".to_string(), + }); + }; + + tracing::trace!("Successfully created Metal texture from IOSurface"); + Ok(metal_texture) + } + + fn cef_to_metal_format(&self, format: cef_color_type_t) -> Result { + match format { + cef_color_type_t::CEF_COLOR_TYPE_BGRA_8888 => Ok(MTLPixelFormat::BGRA8Unorm_sRGB), + cef_color_type_t::CEF_COLOR_TYPE_RGBA_8888 => Ok(MTLPixelFormat::RGBA8Unorm_sRGB), + _ => Err(TextureImportError::UnsupportedFormat { format }), + } + } + + fn is_metal_backend(&self, device: &wgpu::Device) -> bool { + use wgpu::hal::api; + let mut is_metal = false; + unsafe { + device.as_hal::(|device| { + is_metal = device.is_some(); + }); + } + is_metal + } +} diff --git a/desktop/src/cef/texture_import/mod.rs b/desktop/src/cef/texture_import/mod.rs new file mode 100644 index 0000000000..485acf4d11 --- /dev/null +++ b/desktop/src/cef/texture_import/mod.rs @@ -0,0 +1,75 @@ +//! Unified texture import system for CEF hardware acceleration +//! +//! This module provides a platform-agnostic interface for importing shared textures +//! from CEF into wgpu, with automatic fallback to CPU textures when hardware +//! acceleration is not available. +//! +//! # Supported Platforms +//! +//! - **Linux**: DMA-BUF via Vulkan external memory +//! - **Windows**: D3D11 shared textures via Vulkan interop +//! - **macOS**: IOSurface via Metal native API +//! +//! # Usage +//! +//! ```no_run +//! // Import texture with automatic platform detection +//! let texture = shared_handle.import_texture(&device)?; +//! ``` +//! +//! # Features +//! +//! - `accelerated_paint` - Base feature for texture import +//! - `accelerated_paint_dmabuf` - Linux DMA-BUF support +//! - `accelerated_paint_d3d11` - Windows D3D11 support +//! - `accelerated_paint_iosurface` - macOS IOSurface support + +pub(crate) mod common; + +pub(crate) mod shared_texture_handle; +pub(crate) use shared_texture_handle::SharedTextureHandle; + +#[cfg(target_os = "linux")] +pub(crate) mod dmabuf; + +#[cfg(target_os = "windows")] +pub(crate) mod d3d11; + +#[cfg(target_os = "macos")] +pub(crate) mod iosurface; + +/// Result type for texture import operations +pub type TextureImportResult = Result; + +/// Errors that can occur during texture import +#[derive(Debug, thiserror::Error)] +pub enum TextureImportError { + #[error("Invalid texture handle: {0}")] + InvalidHandle(String), + + #[error("Unsupported texture format: {format:?}")] + UnsupportedFormat { format: cef::sys::cef_color_type_t }, + + #[error("Hardware acceleration not available: {reason}")] + HardwareUnavailable { reason: String }, + + #[error("Vulkan operation failed: {operation}")] + VulkanError { operation: String }, + + #[error("Platform-specific error: {message}")] + PlatformError { message: String }, + + #[error("Unsupported platform for texture import")] + UnsupportedPlatform, +} + +/// Trait for platform-specific texture importers +pub trait TextureImporter { + fn new(info: &cef::AcceleratedPaintInfo) -> Self; + + /// Import the texture into wgpu, with automatic fallback to CPU texture + fn import_to_wgpu(&self, device: &wgpu::Device) -> TextureImportResult; + + /// Check if hardware acceleration is available for this texture + fn supports_hardware_acceleration(&self, device: &wgpu::Device) -> bool; +} diff --git a/desktop/src/cef/texture_import/shared_texture_handle.rs b/desktop/src/cef/texture_import/shared_texture_handle.rs new file mode 100644 index 0000000000..b19ee7f6f8 --- /dev/null +++ b/desktop/src/cef/texture_import/shared_texture_handle.rs @@ -0,0 +1,45 @@ +use cef::AcceleratedPaintInfo; + +use super::{TextureImportError, TextureImportResult, TextureImporter}; + +pub(crate) enum SharedTextureHandle { + #[cfg(target_os = "linux")] + DmaBuf(super::dmabuf::DmaBufImporter), + #[cfg(target_os = "windows")] + D3D11(super::d3d11::D3D11Importer), + #[cfg(target_os = "macos")] + IOSurface(super::iosurface::IOSurfaceImporter), + Unsupported, +} + +impl SharedTextureHandle { + pub(crate) fn new(info: &AcceleratedPaintInfo) -> Self { + // Extract DMA-BUF information + #[cfg(target_os = "linux")] + return Self::DmaBuf(super::dmabuf::DmaBufImporter::new(info)); + + // Extract D3D11 shared handle with texture metadata + #[cfg(target_os = "windows")] + return Self::D3D11(super::d3d11::D3D11Importer::new(info)); + + // Extract IOSurface handle with texture metadata + #[cfg(target_os = "macos")] + return Self::IOSurface(super::iosurface::IOSurfaceImporter::new(info)); + + #[allow(unreachable_code)] + Self::Unsupported + } + + /// Import a texture using the appropriate platform-specific importer + pub(crate) fn import_texture(self, device: &wgpu::Device) -> TextureImportResult { + match self { + #[cfg(target_os = "linux")] + SharedTextureHandle::DmaBuf(importer) => importer.import_to_wgpu(device), + #[cfg(target_os = "windows")] + SharedTextureHandle::D3D11(importer) => importer.import_to_wgpu(device), + #[cfg(target_os = "macos")] + SharedTextureHandle::IOSurface(importer) => importer.import_to_wgpu(device), + SharedTextureHandle::Unsupported => Err(TextureImportError::UnsupportedPlatform), + } + } +} diff --git a/desktop/src/consts.rs b/desktop/src/consts.rs index 49543588fb..3babf73019 100644 --- a/desktop/src/consts.rs +++ b/desktop/src/consts.rs @@ -1,3 +1,7 @@ pub(crate) static APP_NAME: &str = "Graphite"; pub(crate) static APP_ID: &str = "rs.graphite.GraphiteEditor"; pub(crate) static APP_DIRECTORY_NAME: &str = "graphite-editor"; + +// CEF configuration constants +pub(crate) const CEF_WINDOWLESS_FRAME_RATE: i32 = 60; +pub(crate) const CEF_MESSAGE_LOOP_MAX_ITERATIONS: usize = 10; diff --git a/desktop/src/main.rs b/desktop/src/main.rs index afcff648e3..bc86f1d89f 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1,12 +1,12 @@ use std::process::exit; -use std::time::{Duration, Instant}; +use std::time::Instant; + use tracing_subscriber::EnvFilter; use winit::event_loop::EventLoop; pub(crate) mod consts; mod cef; -use cef::Setup; mod render; @@ -16,7 +16,7 @@ use app::WinitApp; mod dirs; use graphite_desktop_wrapper::messages::DesktopWrapperMessage; -use graphite_desktop_wrapper::{DesktopWrapper, NodeGraphExecutionResult, WgpuContext}; +use graphite_desktop_wrapper::{NodeGraphExecutionResult, WgpuContext}; pub(crate) enum CustomEvent { UiUpdate(wgpu::Texture), @@ -28,21 +28,24 @@ pub(crate) enum CustomEvent { fn main() { tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init(); - let cef_context = match cef::Context::::new() { - Ok(c) => c, - Err(cef::SetupError::Subprocess) => exit(0), - Err(cef::SetupError::SubprocessFailed(t)) => { - tracing::error!("Subprocess of type {t} failed"); - exit(1); - } - }; + let cef_context_builder = cef::CefContextBuilder::new(); + + if cef_context_builder.is_sub_process() { + // We are in a CEF subprocess + // This will block until the CEF subprocess quits + let error = cef_context_builder.execute_sub_process(); + tracing::error!("Cef subprocess failed with error: {error}"); + return; + } + + let wgpu_context = futures::executor::block_on(WgpuContext::new()).unwrap(); let event_loop = EventLoop::::with_user_event().build().unwrap(); let (window_size_sender, window_size_receiver) = std::sync::mpsc::channel(); - let wgpu_context = futures::executor::block_on(WgpuContext::new()).unwrap(); - let cef_context = match cef_context.init(cef::CefHandler::new(window_size_receiver, event_loop.create_proxy(), wgpu_context.clone())) { + let cef_handler = cef::CefHandler::new(window_size_receiver, event_loop.create_proxy(), wgpu_context.clone()); + let cef_context = match cef_context_builder.initialize(cef_handler) { Ok(c) => c, Err(cef::InitError::AlreadyRunning) => { tracing::error!("Another instance is already running, Exiting."); @@ -52,26 +55,15 @@ fn main() { tracing::error!("Cef initialization failed with code: {code}"); exit(1); } + Err(cef::InitError::BrowserCreationFailed) => { + tracing::error!("Failed to create CEF browser"); + exit(1); + } }; - tracing::info!("Cef initialized successfully"); + tracing::info!("CEF initialized successfully"); - let rendering_loop_proxy = event_loop.create_proxy(); - let target_fps = 60; - std::thread::spawn(move || { - loop { - let last_render = Instant::now(); - - let result = futures::executor::block_on(DesktopWrapper::execute_node_graph()); - let _ = rendering_loop_proxy.send_event(CustomEvent::NodeGraphExecutionResult(result)); - - let frame_time = Duration::from_secs_f32((target_fps as f32).recip()); - let sleep = last_render + frame_time - Instant::now(); - std::thread::sleep(sleep); - } - }); - - let mut winit_app = WinitApp::new(cef_context, window_size_sender, wgpu_context, event_loop.create_proxy()); + let mut winit_app = WinitApp::new(Box::new(cef_context), window_size_sender, wgpu_context, event_loop.create_proxy()); event_loop.run_app(&mut winit_app).unwrap(); } diff --git a/desktop/src/render/graphics_state.rs b/desktop/src/render/graphics_state.rs index b93ec7a829..fdec45a609 100644 --- a/desktop/src/render/graphics_state.rs +++ b/desktop/src/render/graphics_state.rs @@ -232,7 +232,7 @@ impl GraphicsState { self.bind_overlays_texture(texture); } - pub(crate) fn render(&mut self) -> Result<(), wgpu::SurfaceError> { + pub(crate) fn render(&mut self, window: &Window) -> Result<(), wgpu::SurfaceError> { if let Some(scene) = self.overlays_scene.take() { self.render_overlays(scene); } @@ -275,6 +275,7 @@ impl GraphicsState { } } self.context.queue.submit(std::iter::once(encoder.finish())); + window.pre_present_notify(); output.present(); Ok(()) diff --git a/editor/src/messages/dialog/dialog_message.rs b/editor/src/messages/dialog/dialog_message.rs index baee1e6582..435b83d819 100644 --- a/editor/src/messages/dialog/dialog_message.rs +++ b/editor/src/messages/dialog/dialog_message.rs @@ -33,6 +33,9 @@ pub enum DialogMessage { RequestLicensesDialogWithLocalizedCommitDate { localized_commit_year: String, }, + RequestLicensesThirdPartyDialogWithLicenseText { + license_text: String, + }, RequestNewDocumentDialog, RequestPreferencesDialog, } diff --git a/editor/src/messages/dialog/dialog_message_handler.rs b/editor/src/messages/dialog/dialog_message_handler.rs index 03e1f12e92..42f17a22e2 100644 --- a/editor/src/messages/dialog/dialog_message_handler.rs +++ b/editor/src/messages/dialog/dialog_message_handler.rs @@ -1,5 +1,6 @@ use super::new_document_dialog::NewDocumentDialogMessageContext; use super::simple_dialogs::{self, AboutGraphiteDialog, ComingSoonDialog, DemoArtworkDialog, LicensesDialog}; +use crate::messages::dialog::simple_dialogs::LicensesThirdPartyDialog; use crate::messages::input_mapper::utility_types::input_mouse::ViewportBounds; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::prelude::*; @@ -103,6 +104,10 @@ impl MessageHandler> for DialogMessageHa dialog.send_dialog_to_frontend(responses); } + DialogMessage::RequestLicensesThirdPartyDialogWithLicenseText { license_text } => { + let dialog = LicensesThirdPartyDialog { license_text }; + dialog.send_dialog_to_frontend(responses); + } DialogMessage::RequestNewDocumentDialog => { self.new_document_dialog = NewDocumentDialogMessageHandler { name: portfolio.generate_new_document_name(), diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs index 4753279c8b..7f549999a4 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs @@ -92,13 +92,13 @@ impl LayoutHolder for ExportDialogMessageHandler { .collect(); let export_type = vec![ - TextLabel::new("File Type").table_align(true).min_width(100).widget_holder(), + TextLabel::new("File Type").table_align(true).min_width("100px").widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), RadioInput::new(entries).selected_index(Some(self.file_type as u32)).widget_holder(), ]; let resolution = vec![ - TextLabel::new("Scale Factor").table_align(true).min_width(100).widget_holder(), + TextLabel::new("Scale Factor").table_align(true).min_width("100px").widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), NumberInput::new(Some(self.scale_factor)) .unit("") @@ -144,14 +144,14 @@ impl LayoutHolder for ExportDialogMessageHandler { } let export_area = vec![ - TextLabel::new("Bounds").table_align(true).min_width(100).widget_holder(), + TextLabel::new("Bounds").table_align(true).min_width("100px").widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), DropdownInput::new(entries).selected_index(Some(index as u32)).widget_holder(), ]; let checkbox_id = CheckboxId::new(); let transparent_background = vec![ - TextLabel::new("Transparency").table_align(true).min_width(100).for_checkbox(checkbox_id).widget_holder(), + TextLabel::new("Transparency").table_align(true).min_width("100px").for_checkbox(checkbox_id).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), CheckboxInput::new(self.transparent_background) .disabled(self.file_type == FileType::Jpg) diff --git a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs index a913302499..5d3ae881f5 100644 --- a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs +++ b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs @@ -79,7 +79,7 @@ impl DialogLayoutHolder for NewDocumentDialogMessageHandler { impl LayoutHolder for NewDocumentDialogMessageHandler { fn layout(&self) -> Layout { let name = vec![ - TextLabel::new("Name").table_align(true).min_width(90).widget_holder(), + TextLabel::new("Name").table_align(true).min_width("90px").widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), TextInput::new(&self.name) .on_update(|text_input: &TextInput| NewDocumentDialogMessage::Name { name: text_input.value.clone() }.into()) @@ -89,7 +89,7 @@ impl LayoutHolder for NewDocumentDialogMessageHandler { let checkbox_id = CheckboxId::new(); let infinite = vec![ - TextLabel::new("Infinite Canvas").table_align(true).min_width(90).for_checkbox(checkbox_id).widget_holder(), + TextLabel::new("Infinite Canvas").table_align(true).min_width("90px").for_checkbox(checkbox_id).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), CheckboxInput::new(self.infinite) .on_update(|checkbox_input: &CheckboxInput| NewDocumentDialogMessage::Infinite { infinite: checkbox_input.checked }.into()) @@ -98,7 +98,7 @@ impl LayoutHolder for NewDocumentDialogMessageHandler { ]; let scale = vec![ - TextLabel::new("Dimensions").table_align(true).min_width(90).widget_holder(), + TextLabel::new("Dimensions").table_align(true).min_width("90px").widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), NumberInput::new(Some(self.dimensions.x as f64)) .label("W") diff --git a/editor/src/messages/dialog/simple_dialogs/licenses_dialog.rs b/editor/src/messages/dialog/simple_dialogs/licenses_dialog.rs index 694a9986d5..cebb43e9f3 100644 --- a/editor/src/messages/dialog/simple_dialogs/licenses_dialog.rs +++ b/editor/src/messages/dialog/simple_dialogs/licenses_dialog.rs @@ -16,22 +16,31 @@ impl DialogLayoutHolder for LicensesDialog { } fn layout_column_2(&self) -> Layout { - let icons_license_link = "https://raw.githubusercontent.com/GraphiteEditor/Graphite/master/frontend/assets/LICENSE.md"; - let links = [ - ("GraphiteLogo", "Graphite Logo", "https://graphite.rs/logo/"), - ("IconsGrid", "Graphite Icons", icons_license_link), - ("License", "Graphite License", "https://graphite.rs/license/"), - ("License", "Other Licenses", "/third-party-licenses.txt"), + #[allow(clippy::type_complexity)] + let button_definitions: &[(&str, &str, fn() -> Message)] = &[ + ("GraphiteLogo", "Graphite Logo", || { + FrontendMessage::TriggerVisitLink { + url: "https://graphite.rs/logo/".into(), + } + .into() + }), + ("IconsGrid", "Graphite Icons", || { + FrontendMessage::TriggerVisitLink { + url: "https://raw.githubusercontent.com/GraphiteEditor/Graphite/master/frontend/assets/LICENSE.md".into(), + } + .into() + }), + ("License", "Graphite License", || { + FrontendMessage::TriggerVisitLink { + url: "https://graphite.rs/license/".into(), + } + .into() + }), + ("License", "Other Licenses", || FrontendMessage::TriggerDisplayThirdPartyLicensesDialog.into()), ]; - let widgets = links - .into_iter() - .map(|(icon, label, url)| { - TextButton::new(label) - .icon(Some(icon.into())) - .flush(true) - .on_update(|_| FrontendMessage::TriggerVisitLink { url: url.into() }.into()) - .widget_holder() - }) + let widgets = button_definitions + .iter() + .map(|&(icon, label, message_factory)| TextButton::new(label).icon(Some((icon).into())).flush(true).on_update(move |_| message_factory()).widget_holder()) .collect(); Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Column { widgets }])) diff --git a/editor/src/messages/dialog/simple_dialogs/licenses_third_party_dialog.rs b/editor/src/messages/dialog/simple_dialogs/licenses_third_party_dialog.rs new file mode 100644 index 0000000000..4078f7a5cb --- /dev/null +++ b/editor/src/messages/dialog/simple_dialogs/licenses_third_party_dialog.rs @@ -0,0 +1,44 @@ +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; + +pub struct LicensesThirdPartyDialog { + pub license_text: String, +} + +impl DialogLayoutHolder for LicensesThirdPartyDialog { + const ICON: &'static str = "License12px"; + const TITLE: &'static str = "Third-Party Software License Notices"; + + fn layout_buttons(&self) -> Layout { + let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DisplayDialogDismiss.into()).widget_holder()]; + + Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row { widgets }])) + } +} + +impl LayoutHolder for LicensesThirdPartyDialog { + fn layout(&self) -> Layout { + // Remove the header and begin with the line containing the first license section (we otherwise keep the title for standalone viewing of the licenses text file) + let license_text = if let Some(first_underscore_line) = self.license_text.lines().position(|line| line.contains('_')) { + // Find the byte position where the line with underscore starts + let char_position = self.license_text.split('\n').take(first_underscore_line).map(|line| line.len() + '\n'.len_utf8()).sum(); + self.license_text[char_position..].to_string() + } else { + // This shouldn't be encountered, but if no underscore line is found, we use the full text as a safety fallback + self.license_text.clone() + }; + + // Two characters (one before, one after) the sequence of underscore characters, plus one additional column to provide a space between the text and the scrollbar + let non_wrapping_column_width = license_text.split('\n').map(|line| line.chars().filter(|&c| c == '_').count()).max().unwrap_or(0) + 2 + 1; + + Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row { + widgets: vec![ + TextLabel::new(license_text) + .monospace(true) + .multiline(true) + .min_width(format!("{non_wrapping_column_width}ch")) + .widget_holder(), + ], + }])) + } +} diff --git a/editor/src/messages/dialog/simple_dialogs/mod.rs b/editor/src/messages/dialog/simple_dialogs/mod.rs index a330efac20..181b3bf1b3 100644 --- a/editor/src/messages/dialog/simple_dialogs/mod.rs +++ b/editor/src/messages/dialog/simple_dialogs/mod.rs @@ -5,6 +5,7 @@ mod coming_soon_dialog; mod demo_artwork_dialog; mod error_dialog; mod licenses_dialog; +mod licenses_third_party_dialog; pub use about_graphite_dialog::AboutGraphiteDialog; pub use close_all_documents_dialog::CloseAllDocumentsDialog; @@ -14,3 +15,4 @@ pub use demo_artwork_dialog::ARTWORK; pub use demo_artwork_dialog::DemoArtworkDialog; pub use error_dialog::ErrorDialog; pub use licenses_dialog::LicensesDialog; +pub use licenses_third_party_dialog::LicensesThirdPartyDialog; diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 1289fc4dba..50b755363e 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -64,6 +64,7 @@ pub enum FrontendMessage { #[serde(rename = "commitDate")] commit_date: String, }, + TriggerDisplayThirdPartyLicensesDialog, TriggerSaveDocument { document_id: DocumentId, name: String, diff --git a/editor/src/messages/layout/utility_types/widgets/label_widgets.rs b/editor/src/messages/layout/utility_types/widgets/label_widgets.rs index cb569730e4..174bc58691 100644 --- a/editor/src/messages/layout/utility_types/widgets/label_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/label_widgets.rs @@ -44,16 +44,18 @@ pub struct TextLabel { pub italic: bool, + pub monospace: bool, + + pub multiline: bool, + #[serde(rename = "centerAlign")] pub center_align: bool, #[serde(rename = "tableAlign")] pub table_align: bool, - pub multiline: bool, - #[serde(rename = "minWidth")] - pub min_width: u32, + pub min_width: String, pub tooltip: String, diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_vello.rs b/editor/src/messages/portfolio/document/overlays/utility_types_vello.rs index 584c64b088..479f6a3788 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_vello.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_vello.rs @@ -1021,6 +1021,8 @@ impl OverlayContextInternal { }; // Load Source Sans Pro font data + // TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo. + // TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size. const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf"); let font_blob = Some(load_font(FONT_DATA)); @@ -1046,6 +1048,8 @@ impl OverlayContextInternal { }; // Load Source Sans Pro font data + // TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo. + // TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size. const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf"); let font_blob = Some(load_font(FONT_DATA)); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3d12766ca6..2c4ec1dae8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,11 +7,11 @@ "name": "graphite-web-frontend", "license": "Apache-2.0", "dependencies": { - "@fontsource/inconsolata": "^5.2.5", - "@fontsource/source-sans-pro": "^5.2.5", "class-transformer": "^0.5.1", "idb-keyval": "^6.2.1", - "reflect-metadata": "^0.2.2" + "reflect-metadata": "^0.2.2", + "source-code-pro": "github:adobe-fonts/source-code-pro#2.042R-u/1.062R-i/1.026R-vf", + "source-sans": "github:adobe-fonts/source-sans#2.045R-ro%2F1.095R-it" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", @@ -29,7 +29,7 @@ "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", "process": "^0.11.10", - "rollup-plugin-license": "^3.5.3", + "rollup-plugin-license": "^3.6.0", "sass": "1.78.0", "svelte": "^4.2.19", "svelte-preprocess": "^6.0.2", @@ -556,21 +556,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@fontsource/inconsolata": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@fontsource/inconsolata/-/inconsolata-5.2.5.tgz", - "integrity": "sha512-OvzkZY5qYghv/jEV6cfGZzFhdFTvSnU+ExPC7WcZ7w8PdRhtiu/SpcBWOBt+3LXgS0n9qyepgq4zZmxlDTlGGQ==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource/source-sans-pro": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@fontsource/source-sans-pro/-/source-sans-pro-5.2.5.tgz", - "integrity": "sha512-ypendqc4pYUc+EgF7qqPY9iVYEz1t/Qr03VojKxG/2g3dnpHa1B6DOlDxWQjQXDj5QrG6inEqGT0g+edjALZyg==", - "license": "OFL-1.1" - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -4622,14 +4607,14 @@ } }, "node_modules/rollup-plugin-license": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/rollup-plugin-license/-/rollup-plugin-license-3.5.3.tgz", - "integrity": "sha512-r3wImZSo2d6sEk9BRJtlzeI/upjyjnpthy06Fdl0EzqRrlg3ULb9KQR7xHJI0zuayW/8bchEXSF5dO6dha4OyA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-license/-/rollup-plugin-license-3.6.0.tgz", + "integrity": "sha512-1ieLxTCaigI5xokIfszVDRoy6c/Wmlot1fDEnea7Q/WXSR8AqOjYljHDLObAx7nFxHC2mbxT3QnTSPhaic2IYw==", "dev": true, "license": "MIT", "dependencies": { "commenting": "~1.1.0", - "fdir": "6.3.0", + "fdir": "^6.4.3", "lodash": "~4.17.21", "magic-string": "~0.30.0", "moment": "~2.30.1", @@ -4645,11 +4630,14 @@ } }, "node_modules/rollup-plugin-license/node_modules/fdir": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.3.0.tgz", - "integrity": "sha512-QOnuT+BOtivR77wYvCWHfGt9s4Pz1VIMbD463vegT5MLqNXy8rYFT/lPVEqf/bhYeT6qmqrNHhsX+rWwe3rOCQ==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -4660,9 +4648,9 @@ } }, "node_modules/rollup-plugin-license/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "optional": true, @@ -4864,6 +4852,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-code-pro": { + "version": "2.38.0", + "resolved": "git+ssh://git@github.com/adobe-fonts/source-code-pro.git#d3f1a5962cde503f9409c21e58527611d4a19ef1", + "license": "OFL-1.1" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4874,6 +4867,12 @@ "node": ">=0.10.0" } }, + "node_modules/source-sans": { + "name": "source-sans-pro", + "version": "2.40.0", + "resolved": "git+ssh://git@github.com/adobe-fonts/source-sans.git#ce77773581f4d454f0fa985c073bb25c721bfcf5", + "license": "SIL Open Font License 1.1" + }, "node_modules/spdx-compare": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/spdx-compare/-/spdx-compare-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a5424935db..02eb2e41cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,11 +31,11 @@ "wasm:watch-production": "cargo watch --postpone --watch-when-idle --workdir=wasm --shell \"wasm-pack build . --release --target=web -- --color=always\"" }, "dependencies": { - "@fontsource/inconsolata": "^5.2.5", - "@fontsource/source-sans-pro": "^5.2.5", "class-transformer": "^0.5.1", "idb-keyval": "^6.2.1", - "reflect-metadata": "^0.2.2" + "reflect-metadata": "^0.2.2", + "source-code-pro": "github:adobe-fonts/source-code-pro#2.042R-u/1.062R-i/1.026R-vf", + "source-sans": "github:adobe-fonts/source-sans#2.045R-ro%2F1.095R-it" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", @@ -53,7 +53,7 @@ "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", "process": "^0.11.10", - "rollup-plugin-license": "^3.5.3", + "rollup-plugin-license": "^3.6.0", "sass": "1.78.0", "svelte": "^4.2.19", "svelte-preprocess": "^6.0.2", diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index fb334c4a9e..a9b71310c4 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -352,4 +352,44 @@ :not(.optional-input) > .checkbox-input input:focus-visible + label.checked { outline: 1px dashed var(--color-2-mildblack); } + + @font-face { + font-family: "Source Sans Pro"; + font-weight: 400; + font-style: normal; + font-stretch: normal; + src: url("@graphite/../node_modules/source-sans/WOFF2/TTF/SourceSansPro-Regular.ttf.woff2") format("woff2"); + } + + @font-face { + font-family: "Source Sans Pro"; + font-weight: 400; + font-style: italic; + font-stretch: normal; + src: url("@graphite/../node_modules/source-sans/WOFF2/TTF/SourceSansPro-It.ttf.woff2") format("woff2"); + } + + @font-face { + font-family: "Source Sans Pro"; + font-weight: 700; + font-style: normal; + font-stretch: normal; + src: url("@graphite/../node_modules/source-sans/WOFF2/TTF/SourceSansPro-Bold.ttf.woff2") format("woff2"); + } + + @font-face { + font-family: "Source Sans Pro"; + font-weight: 700; + font-style: italic; + font-stretch: normal; + src: url("@graphite/../node_modules/source-sans/WOFF2/TTF/SourceSansPro-BoldIt.ttf.woff2") format("woff2"); + } + + @font-face { + font-family: "Source Code Pro"; + font-weight: 400; + font-style: normal; + font-stretch: normal; + src: url("@graphite/../node_modules/source-code-pro/WOFF2/TTF/SourceCodePro-Regular.ttf.woff2") format("woff2"); + } diff --git a/frontend/src/components/floating-menus/Dialog.svelte b/frontend/src/components/floating-menus/Dialog.svelte index e9d0ba5fab..928d869930 100644 --- a/frontend/src/components/floating-menus/Dialog.svelte +++ b/frontend/src/components/floating-menus/Dialog.svelte @@ -89,6 +89,7 @@ .header-area, .footer-area { background: var(--color-1-nearblack); + flex: 0 0 auto; } .header-area, @@ -113,6 +114,8 @@ .content { margin: -4px 0; + padding-right: calc(24px + 1px * var(--even-integer-subpixel-expansion-x)); + padding-bottom: calc(16px + 1px * var(--even-integer-subpixel-expansion-y)); &.center .row { justify-content: center; @@ -126,18 +129,22 @@ } } - .details.text-label { - -webkit-user-select: text; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release - user-select: text; - white-space: pre-wrap; - max-width: 400px; - height: auto; - } - .radio-input button { flex-grow: 1; } + .text-label.multiline { + -webkit-user-select: text; // Still required by Safari as of 2025 + user-select: text; + } + + // Used by the "Third-Party Software License Notices" dialog + .details:has(.text-label.multiline.monospace) { + max-height: 60vh; + max-width: 80vw; + overflow: auto; + } + // Used by the "Open Demo Artwork" dialog .image-label { border-radius: 2px; diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index e4070cb153..6c18cf5168 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -137,20 +137,23 @@ // This solves antialiasing issues when the content isn't cleanly divisible by 2 and gets translated by (-50%, -50%) causing all its content to be blurry. const floatingMenuContentDiv = floatingMenuContent?.div?.(); if (type === "Dialog" && floatingMenuContentDiv) { - // TODO: Also use https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver to detect any changes which may affect the size of the content. - // TODO: The current method only notices when the dialog size increases but can't detect when it decreases. const resizeObserver = new ResizeObserver((entries) => { entries.forEach((entry) => { - let { width, height } = entry.contentRect; + const existingWidth = Number(floatingMenuContentDiv.style.getPropertyValue("--even-integer-subpixel-expansion-x")); + const existingHeight = Number(floatingMenuContentDiv.style.getPropertyValue("--even-integer-subpixel-expansion-y")); - width = Math.ceil(width); - if (width % 2 === 1) width += 1; - height = Math.ceil(height); - if (height % 2 === 1) height += 1; + let { width, height } = entry.contentRect; + width -= existingWidth; + height -= existingHeight; + + let targetWidth = Math.ceil(width); + if (targetWidth % 2 === 1) targetWidth += 1; + let targetHeight = Math.ceil(height); + if (targetHeight % 2 === 1) targetHeight += 1; // We have to set the style properties directly because attempting to do it through a Svelte bound property results in `afterUpdate()` being triggered - floatingMenuContentDiv.style.setProperty("min-width", width === 0 ? "unset" : `${width}px`); - floatingMenuContentDiv.style.setProperty("min-height", height === 0 ? "unset" : `${height}px`); + floatingMenuContentDiv.style.setProperty("--even-integer-subpixel-expansion-x", `${targetWidth - width}`); + floatingMenuContentDiv.style.setProperty("--even-integer-subpixel-expansion-y", `${targetHeight - height}`); }); }); resizeObserver.observe(floatingMenuContentDiv); @@ -158,14 +161,6 @@ }); afterUpdate(() => { - // Remove the size constraint after the content updates so the resize observer can measure the content and reapply a newly calculated one - const floatingMenuContentDiv = floatingMenuContent?.div?.(); - if (type === "Dialog" && floatingMenuContentDiv) { - // We have to set the style properties directly because attempting to do it through a Svelte bound property results in `afterUpdate()` being triggered - floatingMenuContentDiv.style.setProperty("min-width", "unset"); - floatingMenuContentDiv.style.setProperty("min-height", "unset"); - } - // Gets the client bounds of the elements and apply relevant styles to them. // TODO: Use DOM attribute bindings more whilst not causing recursive updates. Turning measuring on and off both causes the component to change, // TODO: which causes the `afterUpdate()` Svelte event to fire extraneous times (hurting performance and sometimes causing an infinite loop). diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 2f3626797b..2fd350178d 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -770,7 +770,7 @@ width: 100%; &:disabled { - -webkit-user-select: none; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release + -webkit-user-select: none; // Still required by Safari as of 2025 user-select: none; // Workaround for `user-select: none` not working on elements pointer-events: none; diff --git a/frontend/src/components/widgets/labels/TextLabel.svelte b/frontend/src/components/widgets/labels/TextLabel.svelte index 4595c6f2a1..ca845047af 100644 --- a/frontend/src/components/widgets/labels/TextLabel.svelte +++ b/frontend/src/components/widgets/labels/TextLabel.svelte @@ -8,9 +8,10 @@ export let disabled = false; export let bold = false; export let italic = false; + export let monospace = false; export let centerAlign = false; export let tableAlign = false; - export let minWidth = 0; + export let minWidth = ""; export let multiline = false; export let tooltip: string | undefined = undefined; export let forCheckbox: bigint | undefined = undefined; @@ -28,10 +29,11 @@ class:disabled class:bold class:italic + class:monospace class:multiline class:center-align={centerAlign} class:table-align={tableAlign} - style:min-width={minWidth > 0 ? `${minWidth}px` : undefined} + style:min-width={minWidth || undefined} style={`${styleName} ${extraStyles}`.trim() || undefined} title={tooltip} for={forCheckbox !== undefined ? `checkbox-input-${forCheckbox}` : undefined} @@ -58,6 +60,11 @@ font-style: italic; } + &.monospace { + font-family: "Source Code Pro", monospace; + font-size: 12px; + } + &.multiline { white-space: pre-wrap; margin: 4px 0; diff --git a/frontend/src/components/widgets/labels/UserInputLabel.svelte b/frontend/src/components/widgets/labels/UserInputLabel.svelte index f19d99d5e0..863ec33e59 100644 --- a/frontend/src/components/widgets/labels/UserInputLabel.svelte +++ b/frontend/src/components/widgets/labels/UserInputLabel.svelte @@ -183,7 +183,8 @@ display: flex; justify-content: center; align-items: center; - font-family: "Inconsolata", monospace; + font-family: "Source Code Pro", monospace; + font-size: 12px; font-weight: 400; text-align: center; height: 16px; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 8ab407bbee..1e46fa6155 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,12 +1,5 @@ // This file is the browser's entry point for the JS bundle -// Fonts -import "@fontsource/inconsolata"; -import "@fontsource/source-sans-pro/400-italic.css"; -import "@fontsource/source-sans-pro/400.css"; -import "@fontsource/source-sans-pro/700-italic.css"; -import "@fontsource/source-sans-pro/700.css"; - // `reflect-metadata` allows for runtime reflection of types in JavaScript. // It is needed for class-transformer to work and is imported as a side effect. // The library replaces the Reflect API on the window to support more features. diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts index 3a4a256ee2..df4196ce61 100644 --- a/frontend/src/messages.ts +++ b/frontend/src/messages.ts @@ -946,6 +946,8 @@ export class TriggerAboutGraphiteLocalizedCommitDate extends JsMessage { readonly commitDate!: string; } +export class TriggerDisplayThirdPartyLicensesDialog extends JsMessage {} + // WIDGET PROPS export abstract class WidgetProps { @@ -1370,13 +1372,15 @@ export class TextLabel extends WidgetProps { italic!: boolean; + monospace!: boolean; + + multiline!: boolean; + centerAlign!: boolean; tableAlign!: boolean; - minWidth!: number; - - multiline!: boolean; + minWidth!: string; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; @@ -1692,6 +1696,7 @@ export const messageMakers: Record = { DisplayRemoveEditableTextbox, SendUIMetadata, TriggerAboutGraphiteLocalizedCommitDate, + TriggerDisplayThirdPartyLicensesDialog, TriggerSaveDocument, TriggerSaveFile, TriggerExportImage, diff --git a/frontend/src/state-providers/dialog.ts b/frontend/src/state-providers/dialog.ts index 1e2da232a3..57a6c7e50d 100644 --- a/frontend/src/state-providers/dialog.ts +++ b/frontend/src/state-providers/dialog.ts @@ -1,7 +1,16 @@ import { writable } from "svelte/store"; import { type Editor } from "@graphite/editor"; -import { defaultWidgetLayout, DisplayDialog, DisplayDialogDismiss, UpdateDialogButtons, UpdateDialogColumn1, UpdateDialogColumn2, patchWidgetLayout } from "@graphite/messages"; +import { + defaultWidgetLayout, + DisplayDialog, + DisplayDialogDismiss, + UpdateDialogButtons, + UpdateDialogColumn1, + UpdateDialogColumn2, + patchWidgetLayout, + TriggerDisplayThirdPartyLicensesDialog, +} from "@graphite/messages"; import { type IconName } from "@graphite/utility-functions/icons"; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -78,6 +87,17 @@ export function createDialogState(editor: Editor) { }); editor.subscriptions.subscribeJsMessage(DisplayDialogDismiss, dismissDialog); + editor.subscriptions.subscribeJsMessage(TriggerDisplayThirdPartyLicensesDialog, async () => { + const BACKUP_URL = "https://editor.graphite.rs/third-party-licenses.txt"; + let licenseText = `Content was not able to load. Please check your network connection and try again.\n\nOr visit ${BACKUP_URL} for the license notices.`; + if (editor.handle.inDevelopmentMode()) licenseText = `Third-party licenses are not available in development builds.\n\nVisit ${BACKUP_URL} for the license notices.`; + + const response = await fetch("/third-party-licenses.txt"); + if (response.ok && response.headers.get("Content-Type")?.includes("text/plain")) licenseText = await response.text(); + + editor.handle.requestLicensesThirdPartyDialogWithLicenseText(licenseText); + }); + return { subscribe, dismissDialog, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2d76e943fb..bc15f2b019 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,34 +13,19 @@ import { DynamicPublicDirectory as viteMultipleAssets } from "vite-multiple-asse const projectRootDir = path.resolve(__dirname); -// Keep this list in sync with those in `/about.toml` and `/deny.toml`. -const ALLOWED_LICENSES = [ - "Apache-2.0 WITH LLVM-exception", - "Apache-2.0", - "BSD-2-Clause", - "BSD-3-Clause", - "BSL-1.0", - "CC0-1.0", - "CDLA-Permissive-2.0", - "ISC", - "MIT-0", - "MIT", - "MPL-2.0", - "OpenSSL", - "Unicode-3.0", - "Unicode-DFS-2016", - "Zlib", - "NCSA", -]; - // https://vitejs.dev/config/ export default defineConfig({ plugins: [ svelte({ preprocess: [sveltePreprocess()], onwarn(warning, defaultHandler) { - // NOTICE: Keep this list in sync with the list in `.vscode/settings.json` - const suppressed = ["css-unused-selector", "vite-plugin-svelte-css-no-scopable-elements", "a11y-no-static-element-interactions", "a11y-no-noninteractive-element-interactions"]; + const suppressed = [ + "css-unused-selector", // NOTICE: Keep this list in sync with the list in `.vscode/settings.json` + "vite-plugin-svelte-css-no-scopable-elements", // NOTICE: Keep this list in sync with the list in `.vscode/settings.json` + "a11y-no-static-element-interactions", // NOTICE: Keep this list in sync with the list in `.vscode/settings.json` + "a11y-no-noninteractive-element-interactions", // NOTICE: Keep this list in sync with the list in `.vscode/settings.json` + "a11y-click-events-have-key-events", // NOTICE: Keep this list in sync with the list in `.vscode/settings.json` + ]; if (suppressed.includes(warning.code)) return; defaultHandler?.(warning); @@ -66,8 +51,10 @@ export default defineConfig({ plugins: [ rollupPluginLicense({ thirdParty: { + includePrivate: false, + multipleVersions: true, allow: { - test: `(${ALLOWED_LICENSES.join(" OR ")})`, + test: `(${getAcceptedLicenses()})`, failOnUnlicensed: true, failOnViolation: true, }, @@ -103,10 +90,11 @@ type PackageInfo = { function formatThirdPartyLicenses(jsLicenses: Dependency[]): string { // Generate the Rust license information. - let licenses = generateRustLicenses() || []; + const rustLicenses = generateRustLicenses(); + const additionalLicenses = generateAdditionalLicenses(); - // Ensure we have license information to work with before proceeding. - if (licenses.length === 0) { + // Ensure we have the required license information to work with before proceeding. + if (rustLicenses.length === 0) { // This is probably caused by `cargo about` not being installed. console.error("Could not run `cargo about`, which is required to generate license information."); console.error("To install cargo-about on your system, you can run `cargo install cargo-about`."); @@ -121,9 +109,11 @@ function formatThirdPartyLicenses(jsLicenses: Dependency[]): string { process.exit(1); } - // Find then duplicate this license if one of its packages is `path-bool`, adding its notice text. - let foundLicensesIndex; - let foundPackagesIndex; + let licenses = rustLicenses.concat(additionalLicenses); + + // SPECIAL CASE: Find then duplicate this license if one of its packages is `path-bool`, adding its notice text. + let foundLicensesIndex: number | undefined = undefined; + let foundPackagesIndex: number | undefined = undefined; licenses.forEach((license, licenseIndex) => { license.packages.forEach((pkg, pkgIndex) => { if (pkg.name === "path-bool") { @@ -147,7 +137,7 @@ function formatThirdPartyLicenses(jsLicenses: Dependency[]): string { }); } - // Augment the imported Rust license list with the provided JS license list. + // Extend the license list with the provided JS licenses. jsLicenses.forEach((jsLicense) => { const name = jsLicense.name || ""; const version = jsLicense.version || ""; @@ -158,14 +148,12 @@ function formatThirdPartyLicenses(jsLicenses: Dependency[]): string { let repository = jsLicense.repository || ""; if (repository && typeof repository === "object") repository = repository.url; - // Remove the `git+` or `git://` prefix and `.git` suffix. - const repo = repository ? repository.replace(/^.*(github.com\/.*?\/.*?)(?:.git)/, "https://$1") : repository; const matchedLicense = licenses.find( (license) => license.licenseName === licenseName && trimBlankLines(license.licenseText || "") === licenseText && trimBlankLines(license.noticeText || "") === noticeText, ); - const pkg: PackageInfo = { name, version, author, repository: repo }; + const pkg: PackageInfo = { name, version, author, repository }; if (matchedLicense) matchedLicense.packages.push(pkg); else licenses.push({ licenseName, licenseText, noticeText, packages: [pkg] }); }); @@ -232,7 +220,15 @@ function formatThirdPartyLicenses(jsLicenses: Dependency[]): string { licenses.forEach((license) => { let packagesWithSameLicense = license.packages.map((packageInfo) => { const { name, version, author, repository } = packageInfo; - return `${name} ${version}${author ? ` - ${author}` : ""}${repository ? ` - ${repository}` : ""}`; + + // Remove the `git+` or `git://` prefix and `.git` suffix. + let repo = repository; + if (repo.startsWith("git+")) repo = repo.slice("git+".length); + if (repo.startsWith("git://")) repo = repo.slice("git://".length); + if (repo.endsWith(".git")) repo = repo.slice(0, -".git".length); + if (repo.endsWith(".git#release")) repo = repo.slice(0, -".git#release".length); + + return `${name} ${version}${author ? ` - ${author}` : ""}${repo ? ` - ${repo}` : ""}`; }); const multi = packagesWithSameLicense.length !== 1; const saysLicense = license.licenseName.toLowerCase().includes("license"); @@ -249,10 +245,44 @@ function formatThirdPartyLicenses(jsLicenses: Dependency[]): string { formattedLicenseNotice += ` ${"‾".repeat(packagesLineLength + 2)}\n`; formattedLicenseNotice += `${license.licenseText}\n`; }); + + formattedLicenseNotice += "\n"; return formattedLicenseNotice; } -function generateRustLicenses(): LicenseInfo[] | undefined { +// Include additional licenses that aren't automatically generated by `cargo about` or `rollup-plugin-license`. +function generateAdditionalLicenses(): LicenseInfo[] { + const ADDITIONAL_LICENSES = [ + { + licenseName: "SIL Open Font License 1.1", + licenseTextPath: "node_modules/source-sans/LICENSE.txt", + manifestPath: "node_modules/source-sans/package.json", + }, + { + licenseName: "SIL Open Font License 1.1", + licenseTextPath: "node_modules/source-code-pro/LICENSE.md", + manifestPath: "node_modules/source-code-pro/package.json", + }, + ]; + + return ADDITIONAL_LICENSES.map(({ licenseName, licenseTextPath, manifestPath }) => { + const licenseText = (fs.existsSync(licenseTextPath) && fs.readFileSync(licenseTextPath, "utf8")) || ""; + + const manifestJSON = (fs.existsSync(manifestPath) && JSON.parse(fs.readFileSync(manifestPath, "utf8"))) || {}; + const name = manifestJSON.name || ""; + const version = manifestJSON.version || ""; + const author = manifestJSON.author.name || manifestJSON.author || ""; + const repository = manifestJSON.repository?.url || ""; + + return { + licenseName, + licenseText: trimBlankLines(licenseText), + packages: [{ name, version, author, repository }], + }; + }); +} + +function generateRustLicenses(): LicenseInfo[] { // Log the starting status to the build output. console.info("\n\nGenerating license information for Rust code\n"); @@ -272,14 +302,14 @@ function generateRustLicenses(): LicenseInfo[] | undefined { if (status !== 101) { console.error("cargo-about failed", status, stderr); } - return undefined; + return []; } // Make sure the output starts with this expected label, which lets us know the file generated with expected output. // We don't want to eval an error message or something else, so we fail early if that happens. if (!stdout.trim().startsWith("GENERATED_BY_CARGO_ABOUT:")) { console.error("Unexpected output from cargo-about", stdout); - return undefined; + return []; } // Convert the array JS syntax string into an actual JS array in memory. @@ -308,7 +338,7 @@ function generateRustLicenses(): LicenseInfo[] | undefined { return rustLicenses; } catch (_) { - return undefined; + return []; } } @@ -355,3 +385,18 @@ function trimBlankLines(input: string): string { return result; } + +function getAcceptedLicenses() { + const tomlContent = fs.readFileSync(path.resolve(__dirname, "../about.toml"), "utf8"); + + const licensesBlock = tomlContent?.match(/accepted\s*=\s*\[([^\]]*)\]/)?.[1] || ""; + + return licensesBlock + .split("\n") + .map((line) => line.replace(/#.*$/, "")) // Remove comments + .join("\n") + .split(",") + .map((license) => license.trim().replace(/"/g, "")) + .filter((license) => license.length > 0) + .join(" OR "); +} diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 34983bd24a..86dae3a968 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -466,6 +466,12 @@ impl EditorHandle { self.dispatch(message); } + #[wasm_bindgen(js_name = requestLicensesThirdPartyDialogWithLicenseText)] + pub fn request_licenses_third_party_dialog_with_license_text(&self, license_text: String) { + let message = DialogMessage::RequestLicensesThirdPartyDialogWithLicenseText { license_text }; + self.dispatch(message); + } + /// Send new bounds when document panel viewports get resized or moved within the editor /// [left, top, right, bottom]... #[wasm_bindgen(js_name = boundsOfViewports)]