From db9d597e77b5162672f5f9ff70e09c6f12c5e71c Mon Sep 17 00:00:00 2001 From: twlite <46562212+twlite@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:28:56 +0545 Subject: [PATCH] feat: multiple webviews --- examples/multi-webview.mjs | 51 ++++++ index.d.ts | 121 +++++++++---- index.js | 6 +- src/browser_window.rs | 339 ++++++++++++------------------------- src/lib.rs | 119 +++++-------- src/webview.rs | 337 ++++++++++++++++++++++++++++++++++++ 6 files changed, 629 insertions(+), 344 deletions(-) create mode 100644 examples/multi-webview.mjs create mode 100644 src/webview.rs diff --git a/examples/multi-webview.mjs b/examples/multi-webview.mjs new file mode 100644 index 0000000..bf3503c --- /dev/null +++ b/examples/multi-webview.mjs @@ -0,0 +1,51 @@ +import { Application, ProgressBarState } from '../index.js' + +const width = 800; +const height = 600; + +const app = new Application(); + +app.onEvent(console.log) + +const window = app.createBrowserWindow({ + width, + height, + title: 'Multiple Webviews', +}); + +const webview1 = window.createWebview({ + url: 'https://nodejs.org', + child: true, + width: width / 2, + height +}); + +const webview2 = window.createWebview({ + url: 'https://deno.land', + child: true, + width: width / 2, + x: width / 2, + height, +}); + +webview1.onIpcMessage((message) => { + const str = message.body.toString('utf8') + + console.log('Received message from webview 1:', str) +}) + +webview1.evaluateScript(`setTimeout(() => { + window.ipc.postMessage('Hello from webview1') +}, 1000)`) + +webview2.onIpcMessage((message) => { + const str = message.body.toString('utf8') + + console.log('Received message from webview 2:', str) +}) + +webview2.evaluateScript(`setTimeout(() => { + window.ipc.postMessage('Hello from webview2') +}, 1000)`) + +app.run() \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index 0b89230..a51cdfb 100644 --- a/index.d.ts +++ b/index.d.ts @@ -57,6 +57,44 @@ export interface JsProgressBar { /** The progress value. */ progress?: number } +export interface BrowserWindowOptions { + /** Whether the window is resizable. Default is `true`. */ + resizable?: boolean + /** The window title. */ + title?: string + /** The width of the window. */ + width?: number + /** The height of the window. */ + height?: number + /** The x position of the window. */ + x?: number + /** The y position of the window. */ + y?: number + /** Whether or not the window should be created with content protection mode. */ + contentProtection?: boolean + /** Whether or not the window is always on top. */ + alwaysOnTop?: boolean + /** Whether or not the window is always on bottom. */ + alwaysOnBottom?: boolean + /** Whether or not the window is visible. */ + visible?: boolean + /** Whether or not the window decorations are enabled. */ + decorations?: boolean + /** Whether or not the window is visible on all workspaces */ + visibleOnAllWorkspaces?: boolean + /** Whether or not the window is maximized. */ + maximized?: boolean + /** Whether or not the window is maximizable */ + maximizable?: boolean + /** Whether or not the window is minimizable */ + minimizable?: boolean + /** Whether or not the window is focused */ + focused?: boolean + /** Whether or not the window is transparent */ + transparent?: boolean + /** The fullscreen state of the window. */ + fullscreen?: FullscreenType +} /** Represents the theme of the window. */ export const enum Theme { /** The light theme. */ @@ -66,7 +104,7 @@ export const enum Theme { /** The system theme. */ System = 2 } -export interface BrowserWindowOptions { +export interface WebviewOptions { /** The URL to load. */ url?: string /** The HTML content to load. */ @@ -81,20 +119,18 @@ export interface BrowserWindowOptions { y?: number /** Whether to enable devtools. Default is `true`. */ enableDevtools?: boolean - /** Whether the window is resizable. Default is `true`. */ - resizable?: boolean /** Whether the window is incognito. Default is `false`. */ incognito?: boolean - /** Whether the window is transparent. Default is `false`. */ - transparent?: boolean - /** The window title. */ - title?: string /** The default user agent. */ userAgent?: string + /** Whether the webview should be built as a child. */ + child?: boolean + /** The preload script to inject. */ + preload?: string + /** Whether the window is transparent. Default is `false`. */ + transparent?: boolean /** The default theme. */ theme?: Theme - /** The preload script */ - preload?: string /** Whether the window is zoomable via hotkeys or gestures. */ hotkeysZoom?: boolean /** Whether the clipboard access is enabled. */ @@ -104,6 +140,11 @@ export interface BrowserWindowOptions { /** Indicates whether horizontal swipe gestures trigger backward and forward page navigation. */ backForwardNavigationGestures?: boolean } +/** TODO */ +export const enum WebviewApplicationEvent { + /** Window close event. */ + WindowCloseRequested = 0 +} export interface HeaderData { /** The key of the header. */ key: string @@ -111,8 +152,6 @@ export interface HeaderData { value?: string } export interface IpcMessage { - /** The unique identifier of the window that sent the message. */ - windowId: number /** The body of the message. */ body: Buffer /** The HTTP method of the message. */ @@ -144,23 +183,16 @@ export interface ApplicationOptions { /** The exit code of the application. Only applicable if control flow is set to `ExitWithCode`. */ exitCode?: number } +/** Represents an event for the application. */ +export interface ApplicationEvent { + /** The event type. */ + event: WebviewApplicationEvent +} export declare class BrowserWindow { + /** Creates a webview on this window. */ + createWebview(options?: WebviewOptions | undefined | null): JsWebview /** Whether or not the window is a child window. */ get isChild(): boolean - /** The unique identifier of this window. */ - get id(): number - /** Launch a print modal for this window's contents. */ - print(): void - /** Set webview zoom level. */ - zoom(scaleFacotr: number): void - /** Hides or shows the webview. */ - setWebviewVisibility(visible: boolean): void - /** Whether the devtools is opened. */ - isDevtoolsOpen(): boolean - /** Opens the devtools. */ - openDevtools(): void - /** Closes the devtools. */ - closeDevtools(): void /** Whether the window is focused. */ isFocused(): boolean /** Whether the window is visible. */ @@ -179,10 +211,6 @@ export declare class BrowserWindow { isMinimized(): boolean /** Whether the window is resizable. */ isResizable(): boolean - /** Loads the given URL. */ - loadUrl(url: string): void - /** Loads the given HTML content. */ - loadHtml(html: string): void /** Sets the window title. */ setTitle(title: string): void /** Sets the window title. */ @@ -196,11 +224,9 @@ export declare class BrowserWindow { /** Sets resizable. */ setResizable(resizable: boolean): void /** Gets the window theme. */ - get theme(): Theme + get theme(): JsTheme /** Sets the window theme. */ - setTheme(theme: Theme): void - /** Evaluates the given JavaScript code. */ - evaluateScript(js: string): void + setTheme(theme: JsTheme): void /** Sets the window icon. */ setWindowIcon(icon: Array | string, width: number, height: number): void /** Removes the window icon. */ @@ -239,12 +265,37 @@ export declare class BrowserWindow { /** Sets the window to fullscreen or back. */ setFullscreen(fullscreenType?: FullscreenType | undefined | null): void } +export type JsWebview = Webview +export declare class Webview { + constructor() + /** Sets the IPC handler callback. */ + onIpcMessage(handler?: (arg: IpcMessage) => void | undefined | null): void + /** Launch a print modal for this window's contents. */ + print(): void + /** Set webview zoom level. */ + zoom(scaleFacotr: number): void + /** Hides or shows the webview. */ + setWebviewVisibility(visible: boolean): void + /** Whether the devtools is opened. */ + isDevtoolsOpen(): boolean + /** Opens the devtools. */ + openDevtools(): void + /** Closes the devtools. */ + closeDevtools(): void + /** Loads the given URL. */ + loadUrl(url: string): void + /** Loads the given HTML content. */ + loadHtml(html: string): void + /** Evaluates the given JavaScript code. */ + evaluateScript(js: string): void + evaluateScriptWithCallback(js: string, callback: (err: Error | null, arg: string) => any): void +} /** Represents an application. */ export declare class Application { /** Creates a new application. */ constructor(options?: ApplicationOptions | undefined | null) - /** Sets the IPC handler callback. */ - onIpcMessage(handler?: (arg: IpcMessage) => void | undefined | null): void + /** Sets the event handler callback. */ + onEvent(handler?: (arg: ApplicationEvent) => void | undefined | null): void /** Creates a new browser window. */ createBrowserWindow(options?: BrowserWindowOptions | undefined | null): BrowserWindow /** Creates a new browser window as a child window. */ diff --git a/index.js b/index.js index f21a446..be278d9 100644 --- a/index.js +++ b/index.js @@ -310,12 +310,14 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { FullscreenType, ProgressBarState, Theme, BrowserWindow, getWebviewVersion, ControlFlow, Application } = nativeBinding +const { FullscreenType, ProgressBarState, BrowserWindow, Theme, Webview, WebviewApplicationEvent, getWebviewVersion, ControlFlow, Application } = nativeBinding module.exports.FullscreenType = FullscreenType module.exports.ProgressBarState = ProgressBarState -module.exports.Theme = Theme module.exports.BrowserWindow = BrowserWindow +module.exports.Theme = Theme +module.exports.Webview = Webview +module.exports.WebviewApplicationEvent = WebviewApplicationEvent module.exports.getWebviewVersion = getWebviewVersion module.exports.ControlFlow = ControlFlow module.exports.Application = Application diff --git a/src/browser_window.rs b/src/browser_window.rs index 9dfb11d..4c59077 100644 --- a/src/browser_window.rs +++ b/src/browser_window.rs @@ -1,11 +1,12 @@ -use napi::{Either, Result}; +use napi::{Either, Env, Result}; use napi_derive::*; use tao::{ - dpi::{LogicalPosition, LogicalSize, PhysicalSize}, + dpi::{LogicalPosition, PhysicalSize}, event_loop::EventLoop, window::{Fullscreen, Icon, ProgressBarState, Window, WindowBuilder}, }; -use wry::{http::Request, Rect, WebView, WebViewBuilder}; + +use crate::webview::{JsTheme, JsWebview, WebviewOptions}; // #[cfg(target_os = "windows")] // use tao::platform::windows::IconExtWindows; @@ -78,23 +79,12 @@ pub struct JsProgressBar { pub progress: Option, } -#[napi(js_name = "Theme")] -/// Represents the theme of the window. -pub enum JsTheme { - /// The light theme. - Light, - /// The dark theme. - Dark, - /// The system theme. - System, -} - #[napi(object)] pub struct BrowserWindowOptions { - /// The URL to load. - pub url: Option, - /// The HTML content to load. - pub html: Option, + /// Whether the window is resizable. Default is `true`. + pub resizable: Option, + /// The window title. + pub title: Option, /// The width of the window. pub width: Option, /// The height of the window. @@ -103,38 +93,61 @@ pub struct BrowserWindowOptions { pub x: Option, /// The y position of the window. pub y: Option, - /// Whether to enable devtools. Default is `true`. - pub enable_devtools: Option, - /// Whether the window is resizable. Default is `true`. - pub resizable: Option, - /// Whether the window is incognito. Default is `false`. - pub incognito: Option, - /// Whether the window is transparent. Default is `false`. + /// Whether or not the window should be created with content protection mode. + pub content_protection: Option, + /// Whether or not the window is always on top. + pub always_on_top: Option, + /// Whether or not the window is always on bottom. + pub always_on_bottom: Option, + /// Whether or not the window is visible. + pub visible: Option, + /// Whether or not the window decorations are enabled. + pub decorations: Option, + /// Whether or not the window is visible on all workspaces + pub visible_on_all_workspaces: Option, + /// Whether or not the window is maximized. + pub maximized: Option, + /// Whether or not the window is maximizable + pub maximizable: Option, + /// Whether or not the window is minimizable + pub minimizable: Option, + /// Whether or not the window is focused + pub focused: Option, + /// Whether or not the window is transparent pub transparent: Option, - /// The window title. - pub title: Option, - /// The default user agent. - pub user_agent: Option, - /// The default theme. - pub theme: Option, - /// The preload script - pub preload: Option, - /// Whether the window is zoomable via hotkeys or gestures. - pub hotkeys_zoom: Option, - /// Whether the clipboard access is enabled. - pub clipboard: Option, - /// Whether the autoplay policy is enabled. - pub autoplay: Option, - /// Indicates whether horizontal swipe gestures trigger backward and forward page navigation. - pub back_forward_navigation_gestures: Option, + /// The fullscreen state of the window. + pub fullscreen: Option, +} + +impl Default for BrowserWindowOptions { + fn default() -> Self { + Self { + resizable: Some(true), + title: Some("WebviewJS".to_owned()), + width: Some(800.0), + height: Some(600.0), + x: Some(0.0), + y: Some(0.0), + content_protection: Some(false), + always_on_top: Some(false), + always_on_bottom: Some(false), + visible: Some(true), + decorations: Some(true), + visible_on_all_workspaces: Some(false), + maximized: Some(false), + maximizable: Some(true), + minimizable: Some(true), + focused: Some(true), + transparent: Some(false), + fullscreen: None, + } + } } #[napi] pub struct BrowserWindow { - id: u32, is_child_window: bool, window: Window, - webview: WebView, } #[napi] @@ -142,193 +155,100 @@ impl BrowserWindow { pub fn new( event_loop: &EventLoop<()>, options: Option, - id: u32, child: bool, - ipc_handler: impl Fn(Request) + 'static, ) -> Result { - let options = options.unwrap_or(BrowserWindowOptions { - url: None, - html: None, - width: None, - height: None, - x: None, - y: None, - enable_devtools: None, - resizable: None, - incognito: None, - transparent: None, - title: Some("WebviewJS".to_string()), - user_agent: None, - theme: None, - preload: None, - autoplay: None, - back_forward_navigation_gestures: None, - clipboard: None, - hotkeys_zoom: None, - }); - - let mut window = WindowBuilder::new().with_resizable(options.resizable.unwrap_or(true)); + let options = options.unwrap_or(BrowserWindowOptions::default()); - if let Some(title) = options.title { - window = window.with_title(&title); - } + let mut window = WindowBuilder::new(); - let window = window.build(event_loop).map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("Failed to create window: {}", e), - ) - })?; - - let mut webview = if child { - WebViewBuilder::new_as_child(&window) - } else { - WebViewBuilder::new(&window) - }; - - webview = webview - .with_devtools(options.enable_devtools.unwrap_or(true)) - .with_bounds(Rect { - position: LogicalPosition::new(options.x.unwrap_or(0.0), options.y.unwrap_or(0.0)).into(), - size: LogicalSize::new( - options.width.unwrap_or(800.0), - options.height.unwrap_or(600.0), - ) - .into(), - }) - .with_incognito(options.incognito.unwrap_or(false)); + if let Some(resizable) = options.resizable { + window = window.with_resizable(resizable); + } - if let Some(preload) = options.preload { - webview = webview.with_initialization_script(&preload); + if let Some(width) = options.width { + window = window.with_inner_size(PhysicalSize::new(width, options.height.unwrap())); } - if let Some(transparent) = options.transparent { - webview = webview.with_transparent(transparent); + if let Some(x) = options.x { + window = window.with_position(LogicalPosition::new(x, options.y.unwrap())); } - if let Some(autoplay) = options.autoplay { - webview = webview.with_autoplay(autoplay); + if let Some(visible) = options.visible { + window = window.with_visible(visible); } - if let Some(clipboard) = options.clipboard { - webview = webview.with_clipboard(clipboard); + if let Some(decorations) = options.decorations { + window = window.with_decorations(decorations); } - if let Some(back_forward_navigation_gestures) = options.back_forward_navigation_gestures { - webview = webview.with_back_forward_navigation_gestures(back_forward_navigation_gestures); + if let Some(always_on_top) = options.always_on_top { + window = window.with_always_on_top(always_on_top); } - if let Some(hotkeys_zoom) = options.hotkeys_zoom { - webview = webview.with_hotkeys_zoom(hotkeys_zoom); + if let Some(always_on_bottom) = options.always_on_bottom { + window = window.with_always_on_bottom(always_on_bottom); } - #[cfg(target_os = "windows")] - { - use wry::WebViewBuilderExtWindows; + if let Some(visible_on_all_workspaces) = options.visible_on_all_workspaces { + window = window.with_visible_on_all_workspaces(visible_on_all_workspaces); + } - if let Some(theme) = options.theme { - let theme = match theme { - JsTheme::Light => wry::Theme::Light, - JsTheme::Dark => wry::Theme::Dark, - _ => wry::Theme::Auto, - }; + if let Some(maximized) = options.maximized { + window = window.with_maximized(maximized); + } - webview = webview.with_theme(theme) - } + if let Some(maximizable) = options.maximizable { + window = window.with_maximizable(maximizable); } - if let Some(user_agent) = options.user_agent { - webview = webview.with_user_agent(&user_agent); + if let Some(minimizable) = options.minimizable { + window = window.with_minimizable(minimizable); } - if let Some(html) = options.html { - webview = webview.with_html(&html); + if let Some(focused) = options.focused { + window = window.with_focused(focused); } - if let Some(url) = options.url { - webview = webview.with_url(&url); + if let Some(fullscreen) = options.fullscreen { + let fs = match fullscreen { + // Some(FullscreenType::Exclusive) => Some(Fullscreen::Exclusive()), + FullscreenType::Borderless => Some(Fullscreen::Borderless(None)), + _ => None, + }; + + window = window.with_fullscreen(fs); } - webview = webview.with_ipc_handler(ipc_handler); + if let Some(title) = options.title { + window = window.with_title(&title); + } - let webview = webview.build().map_err(|e| { + let window = window.build(event_loop).map_err(|e| { napi::Error::new( napi::Status::GenericFailure, - format!("Failed to create webview: {}", e), + format!("Failed to create window: {}", e), ) })?; Ok(Self { window, - webview, - id, is_child_window: child, }) } + #[napi] + /// Creates a webview on this window. + pub fn create_webview(&mut self, env: Env, options: Option) -> Result { + let webview = JsWebview::create(&env, &self.window, options.unwrap_or(Default::default()))?; + Ok(webview) + } + #[napi(getter)] /// Whether or not the window is a child window. pub fn is_child(&self) -> bool { self.is_child_window } - #[napi(getter)] - /// The unique identifier of this window. - pub fn get_id(&self) -> u32 { - self.id - } - - #[napi] - /// Launch a print modal for this window's contents. - pub fn print(&self) -> Result<()> { - self.webview.print().map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("Failed to print: {}", e), - ) - }) - } - - #[napi] - /// Set webview zoom level. - pub fn zoom(&self, scale_facotr: f64) -> Result<()> { - self.webview.zoom(scale_facotr).map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("Failed to zoom: {}", e), - ) - }) - } - - #[napi] - /// Hides or shows the webview. - pub fn set_webview_visibility(&self, visible: bool) -> Result<()> { - self.webview.set_visible(visible).map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("Failed to set webview visibility: {}", e), - ) - }) - } - - #[napi] - /// Whether the devtools is opened. - pub fn is_devtools_open(&self) -> bool { - self.webview.is_devtools_open() - } - - #[napi] - /// Opens the devtools. - pub fn open_devtools(&self) { - self.webview.open_devtools(); - } - - #[napi] - /// Closes the devtools. - pub fn close_devtools(&self) { - self.webview.close_devtools(); - } - #[napi] /// Whether the window is focused. pub fn is_focused(&self) -> bool { @@ -383,28 +303,6 @@ impl BrowserWindow { self.window.is_resizable() } - #[napi] - /// Loads the given URL. - pub fn load_url(&self, url: String) -> Result<()> { - self.webview.load_url(&url).map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("Failed to load URL: {}", e), - ) - }) - } - - #[napi] - /// Loads the given HTML content. - pub fn load_html(&self, html: String) -> Result<()> { - self.webview.load_html(&html).map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("Failed to load HTML: {}", e), - ) - }) - } - #[napi] /// Sets the window title. pub fn set_title(&self, title: String) { @@ -463,33 +361,6 @@ impl BrowserWindow { self.window.set_theme(theme); } - #[napi] - /// Evaluates the given JavaScript code. - pub fn evaluate_script(&self, js: String) -> Result<()> { - self - .webview - .evaluate_script(&js) - .map_err(|e| napi::Error::new(napi::Status::GenericFailure, format!("{}", e))) - } - - // #[napi] - // /// Evaluates the given JavaScript code with a callback. - // pub fn evaluate_script_with_callback( - // &self, - // js: String, - // callback: Function, - // env: Env, - // ) -> Result<()> { - // let cb_ref = callback.create_ref()?; - - // self - // .webview - // .evaluate_script_with_callback(&js, move |val| { - // let cb = cb_ref.borrow_back(&env); - // }) - // .map_err(|e| napi::Error::new(napi::Status::GenericFailure, format!("{}", e))) - // } - #[napi] /// Sets the window icon. pub fn set_window_icon( diff --git a/src/lib.rs b/src/lib.rs index 785c41a..60743b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,9 @@ #![deny(clippy::all)] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::cell::RefCell; +use std::rc::Rc; + use browser_window::{BrowserWindow, BrowserWindowOptions}; use napi::bindgen_prelude::*; use napi::Result; @@ -9,9 +12,16 @@ use tao::{ event::{Event, WindowEvent}, event_loop::{ControlFlow, EventLoop}, }; -use wry::http::Request; pub mod browser_window; +pub mod webview; + +#[napi] +/// TODO +pub enum WebviewApplicationEvent { + /// Window close event. + WindowCloseRequested, +} #[napi(object)] pub struct HeaderData { @@ -23,8 +33,6 @@ pub struct HeaderData { #[napi(object)] pub struct IpcMessage { - /// The unique identifier of the window that sent the message. - pub window_id: u32, /// The body of the message. pub body: Buffer, /// The HTTP method of the message. @@ -70,6 +78,13 @@ pub struct ApplicationOptions { pub exit_code: Option, } +#[napi(object)] +/// Represents an event for the application. +pub struct ApplicationEvent { + /// The event type. + pub event: WebviewApplicationEvent, +} + #[napi] /// Represents an application. pub struct Application { @@ -77,10 +92,8 @@ pub struct Application { event_loop: Option>, /// The options for creating the application. options: ApplicationOptions, - /// The unique identifier of the webviews created by this application. - id_ref: u32, - /// The ipc handler callback - ipc_handler: Option>, + /// The event handler for the application. + handler: Rc>>>, /// The env env: Env, } @@ -99,58 +112,15 @@ impl Application { wait_time: None, exit_code: None, }), - id_ref: 0, - ipc_handler: None, + handler: Rc::new(RefCell::new(None::>)), env, }) } #[napi] - /// Sets the IPC handler callback. - pub fn on_ipc_message(&mut self, handler: Option>) { - self.ipc_handler = handler; - } - - /// Handles the IPC message. - fn handle_ipc_message(&self, req: Request, id: &u32) { - let func = &self.ipc_handler.as_ref(); - - if func.is_none() { - return; - } - - let on_ipc_msg = func.unwrap().borrow_back(&self.env); - - if on_ipc_msg.is_err() { - return; - } - - let on_ipc_msg = on_ipc_msg.unwrap(); - - let body = req.body().as_bytes().to_vec().into(); - let headers = req - .headers() - .iter() - .map(|(k, v)| HeaderData { - key: k.as_str().to_string(), - value: match v.to_str() { - Ok(v) => Some(v.to_string()), - Err(_) => None, - }, - }) - .collect::>(); - - let msg = IpcMessage { - window_id: id.clone(), - body, - method: req.method().to_string(), - headers, - uri: req.uri().to_string(), - }; - - match on_ipc_msg.call(msg) { - _ => (), - }; + /// Sets the event handler callback. + pub fn on_event(&mut self, handler: Option>) { + *self.handler.borrow_mut() = handler; } #[napi] @@ -168,15 +138,7 @@ impl Application { )); } - self.id_ref += 1; - - let next_id = &self.id_ref; - - let cb = |req: Request| { - self.handle_ipc_message(req, next_id); - }; - - let window = BrowserWindow::new(event_loop.unwrap(), options, self.id_ref, false, cb)?; + let window = BrowserWindow::new(event_loop.unwrap(), options, false)?; Ok(window) } @@ -196,15 +158,7 @@ impl Application { )); } - self.id_ref += 1; - - let next_id = &self.id_ref; - - let cb = |req: Request| { - self.handle_ipc_message(req, next_id); - }; - - let window = BrowserWindow::new(event_loop.unwrap(), options, self.id_ref, true, cb)?; + let window = BrowserWindow::new(event_loop.unwrap(), options, true)?; Ok(window) } @@ -229,6 +183,9 @@ impl Application { }; if let Some(event_loop) = self.event_loop.take() { + let handler = self.handler.clone(); + let env = self.env.clone(); + event_loop.run(move |event, _, control_flow| { *control_flow = ctrl; @@ -236,7 +193,23 @@ impl Application { Event::WindowEvent { event: WindowEvent::CloseRequested, .. - } => *control_flow = ControlFlow::Exit, + } => { + let callback = handler.borrow(); + if callback.is_some() { + let callback = callback.as_ref().unwrap().borrow_back(&env); + + if callback.is_ok() { + let callback = callback.unwrap(); + match callback.call(ApplicationEvent { + event: WebviewApplicationEvent::WindowCloseRequested, + }) { + _ => (), + }; + } + } + + *control_flow = ControlFlow::Exit + } _ => (), } }); diff --git a/src/webview.rs b/src/webview.rs new file mode 100644 index 0000000..b203fc8 --- /dev/null +++ b/src/webview.rs @@ -0,0 +1,337 @@ +use std::{borrow::Borrow, cell::RefCell, rc::Rc}; + +use napi::{ + bindgen_prelude::FunctionRef, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, + Env, Result, +}; +use napi_derive::*; +use tao::dpi::{LogicalPosition, LogicalSize}; +use wry::{http::Request, Rect, WebViewBuilder}; + +use crate::{HeaderData, IpcMessage}; + +/// Represents the theme of the window. +#[napi(js_name = "Theme")] +pub enum JsTheme { + /// The light theme. + Light, + /// The dark theme. + Dark, + /// The system theme. + System, +} + +#[napi(object)] +pub struct WebviewOptions { + /// The URL to load. + pub url: Option, + /// The HTML content to load. + pub html: Option, + /// The width of the window. + pub width: Option, + /// The height of the window. + pub height: Option, + /// The x position of the window. + pub x: Option, + /// The y position of the window. + pub y: Option, + /// Whether to enable devtools. Default is `true`. + pub enable_devtools: Option, + /// Whether the window is incognito. Default is `false`. + pub incognito: Option, + /// The default user agent. + pub user_agent: Option, + /// Whether the webview should be built as a child. + pub child: Option, + /// The preload script to inject. + pub preload: Option, + /// Whether the window is transparent. Default is `false`. + pub transparent: Option, + /// The default theme. + pub theme: Option, + /// Whether the window is zoomable via hotkeys or gestures. + pub hotkeys_zoom: Option, + /// Whether the clipboard access is enabled. + pub clipboard: Option, + /// Whether the autoplay policy is enabled. + pub autoplay: Option, + /// Indicates whether horizontal swipe gestures trigger backward and forward page navigation. + pub back_forward_navigation_gestures: Option, +} + +impl Default for WebviewOptions { + fn default() -> Self { + Self { + url: None, + html: None, + width: None, + height: None, + x: None, + y: None, + enable_devtools: Some(true), + incognito: Some(false), + user_agent: Some("WebviewJS".to_owned()), + child: Some(false), + preload: None, + transparent: Some(false), + theme: None, + hotkeys_zoom: Some(true), + clipboard: Some(true), + autoplay: Some(true), + back_forward_navigation_gestures: Some(true), + } + } +} + +#[napi(js_name = "Webview")] +pub struct JsWebview { + /// The inner webview. + webview_inner: wry::WebView, + /// The ipc handler fn + ipc_state: Rc>>>, +} + +#[napi] +impl JsWebview { + pub fn create(env: &Env, window: &tao::window::Window, options: WebviewOptions) -> Result { + let mut webview = if options.child.unwrap_or(false) { + WebViewBuilder::new_as_child(window) + } else { + WebViewBuilder::new(window) + }; + + if let Some(devtools) = options.enable_devtools { + webview = webview.with_devtools(devtools); + } + + webview = webview.with_bounds(Rect { + position: LogicalPosition::new(options.x.unwrap_or(0.0), options.y.unwrap_or(0.0)).into(), + size: LogicalSize::new( + options.width.unwrap_or(800.0), + options.height.unwrap_or(600.0), + ) + .into(), + }); + + if let Some(incognito) = options.incognito { + webview = webview.with_incognito(incognito); + } + + if let Some(preload) = options.preload { + webview = webview.with_initialization_script(&preload); + } + + if let Some(transparent) = options.transparent { + webview = webview.with_transparent(transparent); + } + + if let Some(autoplay) = options.autoplay { + webview = webview.with_autoplay(autoplay); + } + + if let Some(clipboard) = options.clipboard { + webview = webview.with_clipboard(clipboard); + } + + if let Some(back_forward_navigation_gestures) = options.back_forward_navigation_gestures { + webview = webview.with_back_forward_navigation_gestures(back_forward_navigation_gestures); + } + + if let Some(hotkeys_zoom) = options.hotkeys_zoom { + webview = webview.with_hotkeys_zoom(hotkeys_zoom); + } + + #[cfg(target_os = "windows")] + { + use wry::WebViewBuilderExtWindows; + + if let Some(theme) = options.theme { + let theme = match theme { + JsTheme::Light => wry::Theme::Light, + JsTheme::Dark => wry::Theme::Dark, + _ => wry::Theme::Auto, + }; + + webview = webview.with_theme(theme) + } + } + + if let Some(user_agent) = options.user_agent { + webview = webview.with_user_agent(&user_agent); + } + + if let Some(html) = options.html { + webview = webview.with_html(&html); + } + + if let Some(url) = options.url { + webview = webview.with_url(&url); + } + + let ipc_state = Rc::new(RefCell::new(None::>)); + let ipc_state_clone = ipc_state.clone(); + + let env = env.clone(); + let ipc_handler = move |req: Request| { + let callback: &RefCell>> = ipc_state_clone.borrow(); + let callback = callback.borrow(); + if let Some(func) = callback.as_ref() { + let on_ipc_msg = func.borrow_back(&env); + + if on_ipc_msg.is_err() { + return; + } + + let on_ipc_msg = on_ipc_msg.unwrap(); + + let body = req.body().as_bytes().to_vec().into(); + let headers = req + .headers() + .iter() + .map(|(k, v)| HeaderData { + key: k.as_str().to_string(), + value: v.to_str().ok().map(|s| s.to_string()), + }) + .collect::>(); + + let ipc_message = IpcMessage { + body, + headers, + method: req.method().to_string(), + uri: req.uri().to_string(), + }; + + match on_ipc_msg.call(ipc_message) { + _ => {} + }; + } + }; + + webview = webview.with_ipc_handler(ipc_handler); + + let webview = webview.build().map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("Failed to create webview: {}", e), + ) + })?; + + Ok(Self { + webview_inner: webview, + ipc_state, + }) + } + + #[napi(constructor)] + pub fn new() -> Result { + Err(napi::Error::new( + napi::Status::GenericFailure, + "Webview constructor is not directly supported", + )) + } + + #[napi] + /// Sets the IPC handler callback. + pub fn on_ipc_message(&mut self, handler: Option>) { + *self.ipc_state.borrow_mut() = handler; + } + + #[napi] + /// Launch a print modal for this window's contents. + pub fn print(&self) -> Result<()> { + self.webview_inner.print().map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("Failed to print: {}", e), + ) + }) + } + + #[napi] + /// Set webview zoom level. + pub fn zoom(&self, scale_facotr: f64) -> Result<()> { + self.webview_inner.zoom(scale_facotr).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("Failed to zoom: {}", e), + ) + }) + } + + #[napi] + /// Hides or shows the webview. + pub fn set_webview_visibility(&self, visible: bool) -> Result<()> { + self.webview_inner.set_visible(visible).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("Failed to set webview visibility: {}", e), + ) + }) + } + + #[napi] + /// Whether the devtools is opened. + pub fn is_devtools_open(&self) -> bool { + self.webview_inner.is_devtools_open() + } + + #[napi] + /// Opens the devtools. + pub fn open_devtools(&self) { + self.webview_inner.open_devtools(); + } + + #[napi] + /// Closes the devtools. + pub fn close_devtools(&self) { + self.webview_inner.close_devtools(); + } + + #[napi] + /// Loads the given URL. + pub fn load_url(&self, url: String) -> Result<()> { + self.webview_inner.load_url(&url).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("Failed to load URL: {}", e), + ) + }) + } + + #[napi] + /// Loads the given HTML content. + pub fn load_html(&self, html: String) -> Result<()> { + self.webview_inner.load_html(&html).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("Failed to load HTML: {}", e), + ) + }) + } + + #[napi] + /// Evaluates the given JavaScript code. + pub fn evaluate_script(&self, js: String) -> Result<()> { + self + .webview_inner + .evaluate_script(&js) + .map_err(|e| napi::Error::new(napi::Status::GenericFailure, format!("{}", e))) + } + + #[napi] + pub fn evaluate_script_with_callback( + &self, + js: String, + callback: ThreadsafeFunction, + ) -> Result<()> { + let tsfn = callback.clone(); + + self + .webview_inner + .evaluate_script_with_callback(&js, move |val| { + tsfn.call(Ok(val), ThreadsafeFunctionCallMode::Blocking); + }) + .map_err(|e| napi::Error::new(napi::Status::GenericFailure, format!("{}", e))) + } +}