diff --git a/Cargo.lock b/Cargo.lock index 16c73db0c8..b2b15f946f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12988,8 +12988,6 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1730ab21a962e7ff44df17d20804572beee66d998afc7fcbc3f846a3a12dbda7" dependencies = [ "base64 0.22.1", "block2 0.6.2", diff --git a/Cargo.toml b/Cargo.toml index 06c27a332f..023815ee21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ exclude = [ "OpenBitFun-Installer/src-tauri", "third_party/eioc", "third_party/tao", + "third_party/wry", ] resolver = "2" @@ -308,6 +309,9 @@ eioc = { path = "third_party/eioc" } # Restore macOS traffic-light geometry after the queued native style reset. # See third_party/tao/OPENBITFUN-PATCH.md for the upstream source and removal condition. tao = { path = "third_party/tao" } +# Keep macOS HTML drags in WebKit while preserving native file-drop paths. +# See third_party/wry/OPENBITFUN-PATCH.md for source and removal conditions. +wry = { path = "third_party/wry" } # Tauri 2.11 loses keyboard focus for child webviews after Windows reactivates # an `unstable` multi-webview window. Use the upstream fix until released; # this revision also adopts tao 0.36, which includes the Windows input deadlock fix. diff --git a/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts b/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts index 47eb2b51ed..176a610dde 100644 --- a/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts +++ b/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts @@ -102,6 +102,9 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { "done": "Done", "failed": "Failed", "cancelled": "Cancelled" + }, + "sessions": { + "new": "New Session" } }, "zh-CN": { @@ -155,6 +158,9 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { "done": "已完成", "failed": "失败", "cancelled": "已取消" + }, + "sessions": { + "new": "新会话" } }, "zh-TW": { @@ -208,6 +214,9 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { "done": "已完成", "failed": "失敗", "cancelled": "已取消" + }, + "sessions": { + "new": "新會話" } } } as const satisfies Record; diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 75f418dbfc..4c8aa058a8 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -34,6 +34,18 @@ dismissal. Floating menus and dialogs within either pane continue to use the document overlay coordinator. Owners control visibility with `mode` and persist split and overlay sizes independently when those preferences differ. +For movable tab hosts, pass a flat keyed deck through `surfaces` and render each +view inside `SplitViewSurface pane="primary" | "secondary"`. Changing `pane` keeps +the same React/DOM host. Inactive surfaces stay mounted but are inert and hidden +from accessibility; F6 navigates visible surfaces. Do not reparent portals between +pane containers to simulate a move. + +Use the `header` render function for chrome aligned with the divider. Its +`columnClassName` supplies layout-owned columns tied to live resizing and clamping, +not a second stored width. Apply it to a three-column header (primary, separator, +secondary); keep padding inside those columns. Header layout works without CSS +subgrid. The primitive remains independent of product tabs and stores. + Place `SplitViewPrimaryDock` inside the primary content's positioned container for an absolute input or toolbar that must avoid the secondary overlay. Its width and placement follow the displayed pane size, including pointer drags and @@ -401,7 +413,7 @@ focus ring. Use it for toolbar, dialog, and row utilities. `fill` and `primary` keep an opaque backing surface for persistent emphasis. Disabled quiet actions remain transparent and do not show hover or pressed feedback. -`IconButton variant="glass"` is a translucent, blurred action over existing +`Button variant="glass"` and `IconButton variant="glass"` share a translucent, blurred action over existing content. It uses a low-opacity tint of the shared raised surface, a softened subtle border and subtle blur, without an outer shadow. Hover and pressed states retain translucency. Unsupported blur, increased contrast and reduced transparency @@ -488,7 +500,10 @@ and ref interface, using the same Lucide MessageCircle glyph. ## Advanced selection and menus Use `Select` for simple options; its hidden native control preserves form -participation. `Combobox` adds searchable single +participation. `width="content"` fits the collapsed control to the selected label; +`popupWidth="content"` independently fits the expanded surface to its options, +within the viewport. The defaults remain a full-width control and a popup that +matches the trigger. `Combobox` adds searchable single selection, grouped options, explicit custom-value creation and async loading states. `MultiSelect` owns multiple selection, removable tags and select-all. Controlled values are authoritative; option discovery remains host-owned. diff --git a/design-system/packages/ui/src/components/Button/Button.meta.ts b/design-system/packages/ui/src/components/Button/Button.meta.ts index 0bfdd108be..cf5b62719a 100644 --- a/design-system/packages/ui/src/components/Button/Button.meta.ts +++ b/design-system/packages/ui/src/components/Button/Button.meta.ts @@ -2,11 +2,11 @@ import type { ComponentMeta } from "../../registry.types"; export const buttonMeta = { category: "action", - description: "An action with transparent outline and text variants, neutral fill, secondary, and primary presentation.", + description: "An action with transparent outline and text variants, floating glass, neutral fill, secondary, and primary presentation.", maturity: "stable", name: "Button", props: [ - { defaultValue: "outline", name: "variant", type: "outline | fill | secondary | primary | text" }, + { defaultValue: "outline", name: "variant", type: "outline | glass | fill | secondary | primary | text" }, { defaultValue: "md", name: "size", type: "xs | sm | md | lg" }, { defaultValue: "neutral", name: "tone", type: "neutral | danger" }, { defaultValue: "false", name: "loading", type: "boolean" }, @@ -16,6 +16,9 @@ export const buttonMeta = { ], states: ["default", "hover", "active", "disabled"], tokens: [ + "color.surface.raised", + "color.border.subtle", + "effect.blur.subtle", "opacity.iconArtwork", "component.button.content", "component.button.outlineBorder", diff --git a/design-system/packages/ui/src/components/Button/Button.module.css b/design-system/packages/ui/src/components/Button/Button.module.css index eabc7de84f..c3a6a71a5b 100644 --- a/design-system/packages/ui/src/components/Button/Button.module.css +++ b/design-system/packages/ui/src/components/Button/Button.module.css @@ -90,6 +90,17 @@ --_button-border-active: transparent; } + /* GlassAction owns the material, including contrast/transparency fallbacks. */ + .button[data-openbitfun-variant="glass"] { + --_button-border: transparent; + --_button-border-hover: transparent; + --_button-border-active: transparent; + } + + .button[data-openbitfun-variant="glass"]::before { + content: none; + } + .button[data-openbitfun-variant="primary"] { --_button-background: var(--openbitfun-component-button-primary-background); --_button-background-hover: var(--openbitfun-component-button-primary-background-hover); diff --git a/design-system/packages/ui/src/components/Button/Button.tsx b/design-system/packages/ui/src/components/Button/Button.tsx index 9797230245..a0b544a09e 100644 --- a/design-system/packages/ui/src/components/Button/Button.tsx +++ b/design-system/packages/ui/src/components/Button/Button.tsx @@ -5,6 +5,7 @@ import { } from "react"; import { classNames } from "../../internal/classNames"; import { OverflowText } from "../../primitives/OverflowText"; +import glassStyles from "../../internal/GlassAction.module.css"; import styles from "./Button.module.css"; export interface ButtonProps extends ButtonHTMLAttributes { @@ -16,7 +17,7 @@ export interface ButtonProps extends ButtonHTMLAttributes { size?: "xs" | "sm" | "md" | "lg"; tone?: "danger" | "neutral"; trailingIcon?: ReactNode; - variant?: "fill" | "outline" | "primary" | "secondary" | "text"; + variant?: "fill" | "glass" | "outline" | "primary" | "secondary" | "text"; } export const Button = forwardRef(function Button({ @@ -37,7 +38,7 @@ export const Button = forwardRef(function Button + )} + > + setDraftValue(draft.id, 'path', event.target.value)} + disabled={saving} + size="sm" + autoFocus + /> + + + +

+ Link + + + + diff --git a/third_party/wry/examples/custom_protocol/script.js b/third_party/wry/examples/custom_protocol/script.js new file mode 100644 index 0000000000..f6f257dcb3 --- /dev/null +++ b/third_party/wry/examples/custom_protocol/script.js @@ -0,0 +1,22 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT +if (window.location.pathname.startsWith("/page2")) { + console.log("hello from javascript in page2"); +} else { + console.log("hello from javascript in page1"); + + if (typeof WebAssembly.instantiateStreaming !== "undefined") { + WebAssembly.instantiateStreaming(fetch("/wasm.wasm")).then((wasm) => { + console.log(wasm.instance.exports.main()); // should log 42 + }); + } else { + // Older WKWebView may not support `WebAssembly.instantiateStreaming` yet. + fetch("/wasm.wasm") + .then((response) => response.arrayBuffer()) + .then((bytes) => WebAssembly.instantiate(bytes)) + .then((wasm) => { + console.log(wasm.instance.exports.main()); // should log 42 + }); + } +} diff --git a/third_party/wry/examples/custom_protocol/subpage.html b/third_party/wry/examples/custom_protocol/subpage.html new file mode 100644 index 0000000000..db373d554a --- /dev/null +++ b/third_party/wry/examples/custom_protocol/subpage.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + +

Page 2

+ Back home + + + + \ No newline at end of file diff --git a/third_party/wry/examples/custom_protocol/wasm.wasm b/third_party/wry/examples/custom_protocol/wasm.wasm new file mode 100644 index 0000000000..8ffac94b9f Binary files /dev/null and b/third_party/wry/examples/custom_protocol/wasm.wasm differ diff --git a/third_party/wry/examples/custom_titlebar.rs b/third_party/wry/examples/custom_titlebar.rs new file mode 100644 index 0000000000..226207ac84 --- /dev/null +++ b/third_party/wry/examples/custom_titlebar.rs @@ -0,0 +1,304 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tao::{ + dpi::PhysicalSize, + event::{Event, StartCause, WindowEvent}, + event_loop::{ControlFlow, EventLoopBuilder}, + window::{CursorIcon, ResizeDirection, Window, WindowBuilder}, +}; +use wry::{http::Request, WebViewBuilder}; + +#[derive(Debug)] +enum HitTestResult { + Client, + Left, + Right, + Top, + Bottom, + TopLeft, + TopRight, + BottomLeft, + BottomRight, + NoWhere, +} + +impl HitTestResult { + fn drag_resize_window(&self, window: &Window) { + let _ = window.drag_resize_window(match self { + HitTestResult::Left => ResizeDirection::West, + HitTestResult::Right => ResizeDirection::East, + HitTestResult::Top => ResizeDirection::North, + HitTestResult::Bottom => ResizeDirection::South, + HitTestResult::TopLeft => ResizeDirection::NorthWest, + HitTestResult::TopRight => ResizeDirection::NorthEast, + HitTestResult::BottomLeft => ResizeDirection::SouthWest, + HitTestResult::BottomRight => ResizeDirection::SouthEast, + _ => unreachable!(), + }); + } + + fn change_cursor(&self, window: &Window) { + window.set_cursor_icon(match self { + HitTestResult::Left => CursorIcon::WResize, + HitTestResult::Right => CursorIcon::EResize, + HitTestResult::Top => CursorIcon::NResize, + HitTestResult::Bottom => CursorIcon::SResize, + HitTestResult::TopLeft => CursorIcon::NwResize, + HitTestResult::TopRight => CursorIcon::NeResize, + HitTestResult::BottomLeft => CursorIcon::SwResize, + HitTestResult::BottomRight => CursorIcon::SeResize, + _ => CursorIcon::Default, + }); + } +} + +fn hit_test(window_size: PhysicalSize, x: i32, y: i32, scale: f64) -> HitTestResult { + const BORDERLESS_RESIZE_INSET: f64 = 5.0; + + const CLIENT: isize = 0b0000; + const LEFT: isize = 0b0001; + const RIGHT: isize = 0b0010; + const TOP: isize = 0b0100; + const BOTTOM: isize = 0b1000; + const TOPLEFT: isize = TOP | LEFT; + const TOPRIGHT: isize = TOP | RIGHT; + const BOTTOMLEFT: isize = BOTTOM | LEFT; + const BOTTOMRIGHT: isize = BOTTOM | RIGHT; + + let top = 0; + let left = 0; + let bottom = top + window_size.height as i32; + let right = left + window_size.width as i32; + + let inset = (BORDERLESS_RESIZE_INSET * scale) as i32; + + #[rustfmt::skip] + let result = + (LEFT * (if x < (left + inset) { 1 } else { 0 })) + | (RIGHT * (if x >= (right - inset) { 1 } else { 0 })) + | (TOP * (if y < (top + inset) { 1 } else { 0 })) + | (BOTTOM * (if y >= (bottom - inset) { 1 } else { 0 })); + + match result { + CLIENT => HitTestResult::Client, + LEFT => HitTestResult::Left, + RIGHT => HitTestResult::Right, + TOP => HitTestResult::Top, + BOTTOM => HitTestResult::Bottom, + TOPLEFT => HitTestResult::TopLeft, + TOPRIGHT => HitTestResult::TopRight, + BOTTOMLEFT => HitTestResult::BottomLeft, + BOTTOMRIGHT => HitTestResult::BottomRight, + _ => HitTestResult::NoWhere, + } +} + +enum UserEvent { + Minimize, + Maximize, + DragWindow, + CloseWindow, + MouseDown(i32, i32), + MouseMove(i32, i32), +} + +fn main() -> wry::Result<()> { + let event_loop = EventLoopBuilder::::with_user_event().build(); + let window = WindowBuilder::new() + .with_decorations(false) + .build(&event_loop) + .unwrap(); + + const HTML: &str = r#" + + + + + + + +
+
Custom Titlebar
+
+
+ +
+
+ +
+
+ +
+
+
+
+

WRYYYYYYYYYYYYYYYYYYYYYY!

+
+ + + + +"#; + + let proxy = event_loop.create_proxy(); + let handler = move |req: Request| { + let body = req.body(); + let mut req = body.split([':', ',']); + match req.next().unwrap() { + "minimize" => { + let _ = proxy.send_event(UserEvent::Minimize); + } + "maximize" => { + let _ = proxy.send_event(UserEvent::Maximize); + } + "drag_window" => { + let _ = proxy.send_event(UserEvent::DragWindow); + } + "close" => { + let _ = proxy.send_event(UserEvent::CloseWindow); + } + "mousedown" => { + let x = req.next().unwrap().parse().unwrap(); + let y = req.next().unwrap().parse().unwrap(); + let _ = proxy.send_event(UserEvent::MouseDown(x, y)); + } + "mousemove" => { + let x = req.next().unwrap().parse().unwrap(); + let y = req.next().unwrap().parse().unwrap(); + let _ = proxy.send_event(UserEvent::MouseMove(x, y)); + } + _ => {} + } + }; + + let builder = WebViewBuilder::new() + .with_html(HTML) + .with_ipc_handler(handler) + .with_accept_first_mouse(true); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let webview = builder.build(&window)?; + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let webview = { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox)? + }; + + let mut webview = Some(webview); + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::NewEvents(StartCause::Init) => println!("Wry application started!"), + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } + | Event::UserEvent(UserEvent::CloseWindow) => { + let _ = webview.take(); + *control_flow = ControlFlow::Exit + } + + Event::UserEvent(e) => match e { + UserEvent::Minimize => window.set_minimized(true), + UserEvent::Maximize => window.set_maximized(!window.is_maximized()), + UserEvent::DragWindow => window.drag_window().unwrap(), + UserEvent::MouseDown(x, y) => { + let res = hit_test(window.inner_size(), x, y, window.scale_factor()); + match res { + HitTestResult::Client | HitTestResult::NoWhere => {} + _ => res.drag_resize_window(&window), + } + } + UserEvent::MouseMove(x, y) => { + hit_test(window.inner_size(), x, y, window.scale_factor()).change_cursor(&window); + } + UserEvent::CloseWindow => { /* handled above */ } + }, + _ => (), + } + }); +} diff --git a/third_party/wry/examples/gtk_multiwebview.rs b/third_party/wry/examples/gtk_multiwebview.rs new file mode 100644 index 0000000000..3e47efd4ea --- /dev/null +++ b/third_party/wry/examples/gtk_multiwebview.rs @@ -0,0 +1,133 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, +}; +use wry::{ + dpi::{LogicalPosition, LogicalSize}, + Rect, WebViewBuilder, +}; + +fn main() -> wry::Result<()> { + let event_loop = EventLoop::new(); + let window = WindowBuilder::new().build(&event_loop).unwrap(); + + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let fixed = { + use gtk::prelude::*; + use tao::platform::unix::WindowExtUnix; + let fixed = gtk::Fixed::new(); + let vbox = window.default_vbox().unwrap(); + vbox.pack_start(&fixed, true, true, 0); + fixed.show_all(); + fixed + }; + + let build_webview = |builder: WebViewBuilder<'_>| -> wry::Result { + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let webview = builder.build(&window)?; + + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let webview = { + use wry::WebViewBuilderExtUnix; + builder.build_gtk(&fixed)? + }; + + Ok(webview) + }; + + let size = window.inner_size().to_logical::(window.scale_factor()); + + let builder = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(0, 0).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .with_url("https://tauri.app"); + let webview = build_webview(builder)?; + + let builder2 = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(size.width / 2, 0).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .with_url("https://github.com/tauri-apps/wry"); + let webview2 = build_webview(builder2)?; + + let builder3 = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(0, size.height / 2).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .with_url("https://twitter.com/TauriApps"); + let webview3 = build_webview(builder3)?; + + let builder4 = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(size.width / 2, size.height / 2).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .with_url("https://google.com"); + let webview4 = build_webview(builder4)?; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { + event: WindowEvent::Resized(size), + .. + } => { + let size = size.to_logical::(window.scale_factor()); + webview + .set_bounds(Rect { + position: LogicalPosition::new(0, 0).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .unwrap(); + webview2 + .set_bounds(Rect { + position: LogicalPosition::new(size.width / 2, 0).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .unwrap(); + webview3 + .set_bounds(Rect { + position: LogicalPosition::new(0, size.height / 2).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .unwrap(); + webview4 + .set_bounds(Rect { + position: LogicalPosition::new(size.width / 2, size.height / 2).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .unwrap(); + } + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => *control_flow = ControlFlow::Exit, + _ => {} + } + }); +} diff --git a/third_party/wry/examples/gtk_opengl.rs b/third_party/wry/examples/gtk_opengl.rs new file mode 100644 index 0000000000..248361433a --- /dev/null +++ b/third_party/wry/examples/gtk_opengl.rs @@ -0,0 +1,240 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, +}; +use wry::{ + dpi::{LogicalPosition, LogicalSize}, + Rect, WebViewBuilder, +}; + +fn main() -> wry::Result<()> { + let event_loop = EventLoop::new(); + let window = WindowBuilder::new() + .with_title("GTK OpenGL with Webview") + .with_inner_size(LogicalSize::new(800, 600)) + .build(&event_loop) + .unwrap(); + + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let (fixed, _) = { + use gtk::prelude::*; + use std::{cell::RefCell, rc::Rc}; + use tao::platform::unix::WindowExtUnix; + + let overlay = gtk::Overlay::new(); + let vbox = window.default_vbox().unwrap(); + vbox.pack_start(&overlay, true, true, 0); + + let gl_area = gtk::GLArea::new(); + gl_area.set_has_alpha(true); + gl_area.set_auto_render(true); + + struct AppState { + gl: glow::Context, + program: glow::Program, + vertex_array: glow::VertexArray, + } + + let state: Rc>> = Rc::new(RefCell::new(None)); + let state_realize = state.clone(); + + gl_area.connect_realize(move |gl_area| { + gl_area.make_current(); + if gl_area.error().is_some() { + println!("Error creating GLArea context"); + return; + } + + let gl = unsafe { + glow::Context::from_loader_function(|s| { + let mut ptr = std::ptr::null(); + let name = std::ffi::CString::new(s).unwrap(); + + if let Ok(lib) = libloading::Library::new("libGL.so.1") { + if let Ok(sym) = lib.get:: *const std::ffi::c_void>( + b"glXGetProcAddress\0", + ) { + ptr = sym(name.as_ptr()); + } + } + if ptr.is_null() { + if let Ok(lib) = libloading::Library::new("libEGL.so.1") { + if let Ok(sym) = lib + .get:: *const std::ffi::c_void>( + b"eglGetProcAddress\0", + ) + { + ptr = sym(name.as_ptr()); + } + } + } + ptr + }) + }; + + unsafe { + use glow::HasContext as _; + + let vertex_array = gl.create_vertex_array().unwrap(); + gl.bind_vertex_array(Some(vertex_array)); + + let program = gl.create_program().expect("Cannot create program"); + + let vertex_shader_source = r#" + #version 330 core + void main() { + if (gl_VertexID == 0) gl_Position = vec4(-0.5, -0.5, 0.0, 1.0); + else if (gl_VertexID == 1) gl_Position = vec4(0.5, -0.5, 0.0, 1.0); + else gl_Position = vec4(0.0, 0.5, 0.0, 1.0); + } + "#; + + let fragment_shader_source = r#" + #version 330 core + out vec4 FragColor; + void main() { + FragColor = vec4(1.0, 0.5, 0.2, 1.0); + } + "#; + + let vs = gl.create_shader(glow::VERTEX_SHADER).unwrap(); + gl.shader_source(vs, vertex_shader_source); + gl.compile_shader(vs); + if !gl.get_shader_compile_status(vs) { + panic!("{}", gl.get_shader_info_log(vs)); + } + + let fs = gl.create_shader(glow::FRAGMENT_SHADER).unwrap(); + gl.shader_source(fs, fragment_shader_source); + gl.compile_shader(fs); + if !gl.get_shader_compile_status(fs) { + panic!("{}", gl.get_shader_info_log(fs)); + } + + gl.attach_shader(program, vs); + gl.attach_shader(program, fs); + gl.link_program(program); + if !gl.get_program_link_status(program) { + panic!("{}", gl.get_program_info_log(program)); + } + + gl.detach_shader(program, vs); + gl.delete_shader(vs); + gl.detach_shader(program, fs); + gl.delete_shader(fs); + + *state_realize.borrow_mut() = Some(AppState { + gl, + program, + vertex_array, + }); + } + }); + + let state_render = state.clone(); + gl_area.connect_render(move |_gl_area, _gl_context| { + if let Some(state) = state_render.borrow().as_ref() { + unsafe { + use glow::HasContext as _; + state.gl.clear_color(0.1, 0.2, 0.3, 1.0); + state.gl.clear(glow::COLOR_BUFFER_BIT); + + state.gl.use_program(Some(state.program)); + state.gl.bind_vertex_array(Some(state.vertex_array)); + state.gl.draw_arrays(glow::TRIANGLES, 0, 3); + } + } + gtk::glib::Propagation::Proceed + }); + + gl_area.connect_unrealize(move |gl_area| { + gl_area.make_current(); + if let Some(state) = state.borrow_mut().take() { + unsafe { + use glow::HasContext as _; + state.gl.delete_program(state.program); + state.gl.delete_vertex_array(state.vertex_array); + } + } + }); + + overlay.add(&gl_area); + + let fixed = gtk::Fixed::new(); + overlay.add_overlay(&fixed); + + overlay.show_all(); + (fixed, gl_area) + }; + + let builder = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(100, 100).into(), + size: LogicalSize::new(400, 300).into(), + }) + .with_transparent(true) + .with_html( + r#" + +

Hello World!

+ + "#, + ); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let webview = builder.build(&window)?; + + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let webview = { + use wry::WebViewBuilderExtUnix; + builder.build_gtk(&fixed)? + }; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { + event: WindowEvent::Resized(size), + .. + } => { + let size = size.to_logical::(window.scale_factor()); + webview + .set_bounds(Rect { + position: LogicalPosition::new(100, 100).into(), + size: LogicalSize::new( + size.width.saturating_sub(200).max(100), + size.height.saturating_sub(200).max(100), + ) + .into(), + }) + .unwrap(); + } + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => *control_flow = ControlFlow::Exit, + _ => {} + } + }); +} diff --git a/third_party/wry/examples/multiwebview.rs b/third_party/wry/examples/multiwebview.rs new file mode 100644 index 0000000000..6540c15418 --- /dev/null +++ b/third_party/wry/examples/multiwebview.rs @@ -0,0 +1,168 @@ +use winit::{ + application::ApplicationHandler, + event::WindowEvent, + event_loop::{ActiveEventLoop, EventLoop}, + window::{Window, WindowId}, +}; +use wry::{ + dpi::{LogicalPosition, LogicalSize}, + Rect, WebViewBuilder, +}; + +#[derive(Default)] +struct State { + window: Option, + webview1: Option, + webview2: Option, + webview3: Option, + webview4: Option, +} + +impl ApplicationHandler for State { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let mut attributes = Window::default_attributes(); + attributes.inner_size = Some(LogicalSize::new(800, 800).into()); + let window = event_loop.create_window(attributes).unwrap(); + + let size = window.inner_size().to_logical::(window.scale_factor()); + + let webview1 = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(0, 0).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .with_url("https://tauri.app") + .build_as_child(&window) + .unwrap(); + + let webview2 = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(size.width / 2, 0).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .with_url("https://github.com/tauri-apps/wry") + .build_as_child(&window) + .unwrap(); + + let webview3 = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(0, size.height / 2).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .with_url("https://twitter.com/TauriApps") + .build_as_child(&window) + .unwrap(); + + let webview4 = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(size.width / 2, size.height / 2).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .with_url("https://google.com") + .build_as_child(&window) + .unwrap(); + + self.window = Some(window); + self.webview1 = Some(webview1); + self.webview2 = Some(webview2); + self.webview3 = Some(webview3); + self.webview4 = Some(webview4); + } + + fn window_event( + &mut self, + _event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + match event { + WindowEvent::Resized(size) => { + if let (Some(window), Some(webview1), Some(webview2), Some(webview3), Some(webview4)) = ( + &self.window, + &self.webview1, + &self.webview2, + &self.webview3, + &self.webview4, + ) { + let size = size.to_logical::(window.scale_factor()); + + webview1 + .set_bounds(Rect { + position: LogicalPosition::new(0, 0).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .unwrap(); + + webview2 + .set_bounds(Rect { + position: LogicalPosition::new(size.width / 2, 0).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .unwrap(); + + webview3 + .set_bounds(Rect { + position: LogicalPosition::new(0, size.height / 2).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .unwrap(); + + webview4 + .set_bounds(Rect { + position: LogicalPosition::new(size.width / 2, size.height / 2).into(), + size: LogicalSize::new(size.width / 2, size.height / 2).into(), + }) + .unwrap(); + } + } + WindowEvent::CloseRequested => { + std::process::exit(0); + } + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + ))] + { + while gtk::events_pending() { + gtk::main_iteration_do(false); + } + } + } +} + +fn main() -> wry::Result<()> { + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + ))] + { + use gtk::prelude::DisplayExtManual; + + gtk::init()?; + if gtk::gdk::Display::default().unwrap().backend().is_wayland() { + panic!("This example doesn't support wayland!"); + } + + winit::platform::x11::register_xlib_error_hook(Box::new(|_display, error| { + let error = error as *mut x11_dl::xlib::XErrorEvent; + (unsafe { (*error).error_code }) == 170 + })); + } + + let event_loop = EventLoop::new().unwrap(); + let mut state = State::default(); + event_loop.run_app(&mut state).unwrap(); + + Ok(()) +} diff --git a/third_party/wry/examples/multiwindow.rs b/third_party/wry/examples/multiwindow.rs new file mode 100644 index 0000000000..0514f99e86 --- /dev/null +++ b/third_party/wry/examples/multiwindow.rs @@ -0,0 +1,125 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::collections::HashMap; +use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy, EventLoopWindowTarget}, + window::{Window, WindowBuilder, WindowId}, +}; +use wry::{http::Request, WebView, WebViewBuilder}; + +enum UserEvent { + CloseWindow(WindowId), + NewTitle(WindowId, String), + NewWindow, +} + +fn main() -> wry::Result<()> { + let event_loop = EventLoopBuilder::::with_user_event().build(); + let mut webviews = HashMap::new(); + let proxy = event_loop.create_proxy(); + + let (window, webview) = create_new_window( + format!("Window {}", webviews.len() + 1), + &event_loop, + proxy.clone(), + ); + webviews.insert(window.id(), (window, webview)); + + event_loop.run(move |event, event_loop, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + window_id, + .. + } => { + webviews.remove(&window_id); + if webviews.is_empty() { + *control_flow = ControlFlow::Exit + } + } + Event::UserEvent(UserEvent::NewWindow) => { + let (window, webview) = create_new_window( + format!("Window {}", webviews.len() + 1), + event_loop, + proxy.clone(), + ); + webviews.insert(window.id(), (window, webview)); + } + Event::UserEvent(UserEvent::CloseWindow(id)) => { + webviews.remove(&id); + if webviews.is_empty() { + *control_flow = ControlFlow::Exit + } + } + + Event::UserEvent(UserEvent::NewTitle(id, title)) => { + webviews.get(&id).unwrap().0.set_title(&title); + } + _ => (), + } + }); +} + +fn create_new_window( + title: String, + event_loop: &EventLoopWindowTarget, + proxy: EventLoopProxy, +) -> (Window, WebView) { + let window = WindowBuilder::new() + .with_title(title) + .build(event_loop) + .unwrap(); + let window_id = window.id(); + let handler = move |req: Request| { + let body = req.body(); + match body.as_str() { + "new-window" => { + let _ = proxy.send_event(UserEvent::NewWindow); + } + "close" => { + let _ = proxy.send_event(UserEvent::CloseWindow(window_id)); + } + _ if body.starts_with("change-title") => { + let title = body.replace("change-title:", ""); + let _ = proxy.send_event(UserEvent::NewTitle(window_id, title)); + } + _ => {} + } + }; + + let builder = WebViewBuilder::new() + .with_html( + r#" + + + + "#, + ) + .with_ipc_handler(handler); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let webview = builder.build(&window).unwrap(); + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let webview = { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox).unwrap() + }; + (window, webview) +} diff --git a/third_party/wry/examples/permission_handler.rs b/third_party/wry/examples/permission_handler.rs new file mode 100644 index 0000000000..08e5f8a05d --- /dev/null +++ b/third_party/wry/examples/permission_handler.rs @@ -0,0 +1,66 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Example demonstrating the permission handler API. +//! +//! Run: cargo run --example permission_handler +//! Then click the buttons and watch the terminal output. + +fn main() -> wry::Result<()> { + use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, + }; + use wry::{PermissionKind, PermissionResponse, WebViewBuilder}; + + let event_loop = EventLoop::new(); + let window = WindowBuilder::new() + .with_title("Permission Handler Example") + .with_inner_size(tao::dpi::LogicalSize::new(800, 600)) + .build(&event_loop) + .unwrap(); + + let builder = WebViewBuilder::new() + .with_url("https://permission.site/") + .with_permission_handler(|kind| { + let response = match kind { + PermissionKind::Geolocation => PermissionResponse::Default, + _ => PermissionResponse::Allow, + }; + println!("[permission] {kind} → {response}"); + response + }); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let _webview = builder.build(&window)?; + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let _webview = { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox)? + }; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + if let Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } = event + { + *control_flow = ControlFlow::Exit; + } + }); +} diff --git a/third_party/wry/examples/reparent.rs b/third_party/wry/examples/reparent.rs new file mode 100644 index 0000000000..1b5476508d --- /dev/null +++ b/third_party/wry/examples/reparent.rs @@ -0,0 +1,111 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tao::{ + event::{ElementState, Event, KeyEvent, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + keyboard::Key, + window::WindowBuilder, +}; +use wry::WebViewBuilder; + +#[cfg(target_os = "macos")] +use {objc2_app_kit::NSWindow, tao::platform::macos::WindowExtMacOS, wry::WebViewExtMacOS}; +#[cfg(target_os = "windows")] +use {tao::platform::windows::WindowExtWindows, wry::WebViewExtWindows}; + +#[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" +)))] +#[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" +)))] +use { + tao::platform::unix::WindowExtUnix, + wry::{WebViewBuilderExtUnix, WebViewExtUnix}, +}; + +fn main() -> wry::Result<()> { + let event_loop = EventLoop::new(); + let window = WindowBuilder::new().build(&event_loop).unwrap(); + let window2 = WindowBuilder::new().build(&event_loop).unwrap(); + + let builder = WebViewBuilder::new().with_url("https://tauri.app"); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let webview = builder.build(&window)?; + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let webview = { + use tao::platform::unix::WindowExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox)? + }; + + let mut webview_container = window.id(); + + event_loop.run(move |event, _event_loop, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => *control_flow = ControlFlow::Exit, + + Event::WindowEvent { + event: + WindowEvent::KeyboardInput { + event: + KeyEvent { + logical_key: Key::Character("x"), + state: ElementState::Pressed, + .. + }, + .. + }, + .. + } => { + let new_parent = if webview_container == window.id() { + &window2 + } else { + &window + }; + webview_container = new_parent.id(); + + #[cfg(target_os = "macos")] + webview + .reparent(new_parent.ns_window() as *mut NSWindow) + .unwrap(); + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + webview + .reparent(new_parent.default_vbox().unwrap()) + .unwrap(); + #[cfg(target_os = "windows")] + webview.reparent(new_parent.hwnd()).unwrap(); + } + _ => {} + } + }); +} diff --git a/third_party/wry/examples/simple.rs b/third_party/wry/examples/simple.rs new file mode 100644 index 0000000000..e9c53569f3 --- /dev/null +++ b/third_party/wry/examples/simple.rs @@ -0,0 +1,70 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, +}; +use wry::WebViewBuilder; + +fn main() -> wry::Result<()> { + let event_loop = EventLoop::new(); + let window = WindowBuilder::new().build(&event_loop).unwrap(); + + let builder = WebViewBuilder::new() + .with_url("http://tauri.app") + .with_new_window_req_handler(|url, features| { + println!("new window req: {url} {features:?}"); + wry::NewWindowResponse::Allow + }); + + let builder = builder.with_drag_drop_handler(|e| { + match e { + wry::DragDropEvent::Enter { paths, position } => { + println!("DragEnter: {position:?} {paths:?} ") + } + wry::DragDropEvent::Over { position } => println!("DragOver: {position:?} "), + wry::DragDropEvent::Drop { paths, position } => { + println!("DragDrop: {position:?} {paths:?} ") + } + wry::DragDropEvent::Leave => println!("DragLeave"), + _ => {} + } + + true + }); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let _webview = builder.build(&window)?; + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let _webview = { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox)? + }; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + if let Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } = event + { + *control_flow = ControlFlow::Exit; + } + }); +} diff --git a/third_party/wry/examples/streaming.rs b/third_party/wry/examples/streaming.rs new file mode 100644 index 0000000000..9472d78c68 --- /dev/null +++ b/third_party/wry/examples/streaming.rs @@ -0,0 +1,262 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + io::{Read, Seek, SeekFrom, Write}, + path::PathBuf, +}; + +use http::{header, StatusCode}; +use http_range::HttpRange; +use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, +}; +use wry::{ + http::{header::*, Request, Response}, + WebViewBuilder, +}; + +pub fn main() -> wry::Result<()> { + let event_loop = EventLoop::new(); + let window = WindowBuilder::new().build(&event_loop).unwrap(); + + let builder = WebViewBuilder::new() + .with_custom_protocol( + "wry".into(), + move |_webview_id, request| match wry_protocol(request) { + Ok(r) => r.map(Into::into), + Err(e) => http::Response::builder() + .header(CONTENT_TYPE, "text/plain") + .status(500) + .body(e.to_string().as_bytes().to_vec()) + .unwrap() + .map(Into::into), + }, + ) + .with_custom_protocol( + "stream".into(), + move |_webview_id, request| match stream_protocol(request) { + Ok(r) => r.map(Into::into), + Err(e) => http::Response::builder() + .header(CONTENT_TYPE, "text/plain") + .status(500) + .body(e.to_string().as_bytes().to_vec()) + .unwrap() + .map(Into::into), + }, + ) + // tell the webview to load the custom protocol + .with_url("wry://localhost"); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let _webview = builder.build(&window)?; + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let _webview = { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox)? + }; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + if let Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } = event + { + *control_flow = ControlFlow::Exit + } + }); +} + +fn wry_protocol( + request: Request>, +) -> Result>, Box> { + let path = request.uri().path(); + // Read the file content from file path + let root = PathBuf::from("examples/streaming"); + let path = if path == "/" { + "index.html" + } else { + // removing leading slash + &path[1..] + }; + let content = std::fs::read(std::fs::canonicalize(root.join(path))?)?; + + // Return asset contents and mime types based on file extentions + // If you don't want to do this manually, there are some crates for you. + // Such as `infer` and `mime_guess`. + let mimetype = if path.ends_with(".html") || path == "/" { + "text/html" + } else if path.ends_with(".js") { + "text/javascript" + } else { + unimplemented!(); + }; + + Response::builder() + .header(CONTENT_TYPE, mimetype) + .body(content) + .map_err(Into::into) +} + +fn stream_protocol( + request: http::Request>, +) -> Result>, Box> { + // skip leading `/` + let path = percent_encoding::percent_decode(&request.uri().path().as_bytes()[1..]) + .decode_utf8_lossy() + .to_string(); + + let mut file = std::fs::File::open(path)?; + + // get file length + let len = { + let old_pos = file.stream_position()?; + let len = file.seek(SeekFrom::End(0))?; + file.seek(SeekFrom::Start(old_pos))?; + len + }; + + let mut resp = Response::builder().header(CONTENT_TYPE, "video/mp4"); + + // if the webview sent a range header, we need to send a 206 in return + // Actually only macOS and Windows are supported. Linux will ALWAYS return empty headers. + let http_response = if let Some(range_header) = request.headers().get("range") { + let not_satisfiable = || { + Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{len}")) + .body(vec![]) + }; + + // parse range header + let ranges = if let Ok(ranges) = HttpRange::parse(range_header.to_str()?, len) { + ranges + .iter() + // map the output back to spec range , example: 0-499 + .map(|r| (r.start, r.start + r.length - 1)) + .collect::>() + } else { + return Ok(not_satisfiable()?); + }; + + /// The Maximum bytes we send in one range + const MAX_LEN: u64 = 1000 * 1024; + + if ranges.len() == 1 { + let &(start, mut end) = ranges.first().unwrap(); + + // check if a range is not satisfiable + // + // this should be already taken care of by HttpRange::parse + // but checking here again for extra assurance + if start >= len || end >= len || end < start { + return Ok(not_satisfiable()?); + } + + // adjust end byte for MAX_LEN + end = start + (end - start).min(len - start).min(MAX_LEN - 1); + + // calculate number of bytes needed to be read + let bytes_to_read = end + 1 - start; + + // allocate a buf with a suitable capacity + let mut buf = Vec::with_capacity(bytes_to_read as usize); + // seek the file to the starting byte + file.seek(SeekFrom::Start(start))?; + // read the needed bytes + file.take(bytes_to_read).read_to_end(&mut buf)?; + + resp = resp.header(CONTENT_RANGE, format!("bytes {start}-{end}/{len}")); + resp = resp.header(CONTENT_LENGTH, end + 1 - start); + resp = resp.status(StatusCode::PARTIAL_CONTENT); + resp.body(buf) + } else { + let mut buf = Vec::new(); + let ranges = ranges + .iter() + .filter_map(|&(start, mut end)| { + // filter out unsatisfiable ranges + // + // this should be already taken care of by HttpRange::parse + // but checking here again for extra assurance + if start >= len || end >= len || end < start { + None + } else { + // adjust end byte for MAX_LEN + end = start + (end - start).min(len - start).min(MAX_LEN - 1); + Some((start, end)) + } + }) + .collect::>(); + + let boundary = random_boundary(); + let boundary_sep = format!("\r\n--{boundary}\r\n"); + let boundary_closer = format!("\r\n--{boundary}\r\n"); + + resp = resp.header( + CONTENT_TYPE, + format!("multipart/byteranges; boundary={boundary}"), + ); + + for (end, start) in ranges { + // a new range is being written, write the range boundary + buf.write_all(boundary_sep.as_bytes())?; + + // write the needed headers `Content-Type` and `Content-Range` + buf.write_all(format!("{CONTENT_TYPE}: video/mp4\r\n").as_bytes())?; + buf.write_all(format!("{CONTENT_RANGE}: bytes {start}-{end}/{len}\r\n").as_bytes())?; + + // write the separator to indicate the start of the range body + buf.write_all("\r\n".as_bytes())?; + + // calculate number of bytes needed to be read + let bytes_to_read = end + 1 - start; + + let mut local_buf = vec![0_u8; bytes_to_read as usize]; + file.seek(SeekFrom::Start(start))?; + file.read_exact(&mut local_buf)?; + buf.extend_from_slice(&local_buf); + } + // all ranges have been written, write the closing boundary + buf.write_all(boundary_closer.as_bytes())?; + + resp.body(buf) + } + } else { + resp = resp.header(CONTENT_LENGTH, len); + let mut buf = Vec::with_capacity(len as usize); + file.read_to_end(&mut buf)?; + resp.body(buf) + }; + + http_response.map_err(Into::into) +} + +fn random_boundary() -> String { + let mut x = [0_u8; 30]; + getrandom::fill(&mut x).expect("failed to get random bytes"); + (x[..]) + .iter() + .map(|&x| format!("{x:x}")) + .fold(String::new(), |mut a, x| { + a.push_str(x.as_str()); + a + }) +} diff --git a/third_party/wry/examples/streaming/index.html b/third_party/wry/examples/streaming/index.html new file mode 100644 index 0000000000..547cef0d82 --- /dev/null +++ b/third_party/wry/examples/streaming/index.html @@ -0,0 +1,38 @@ + + + + + + Document + + +

Enter a path to a video to play, then hit Enter or click Start

+
+ + +
+ + + + + diff --git a/third_party/wry/examples/transparent.rs b/third_party/wry/examples/transparent.rs new file mode 100644 index 0000000000..6f78282ec3 --- /dev/null +++ b/third_party/wry/examples/transparent.rs @@ -0,0 +1,79 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, +}; +use wry::WebViewBuilder; + +fn main() -> wry::Result<()> { + let event_loop = EventLoop::new(); + #[allow(unused_mut)] + let mut builder = WindowBuilder::new() + .with_decorations(false) + // There are actually three layer of background color when creating webview window. + // The first is window background... + .with_transparent(true); + #[cfg(target_os = "windows")] + { + use tao::platform::windows::WindowBuilderExtWindows; + builder = builder.with_undecorated_shadow(false); + } + let window = builder.build(&event_loop).unwrap(); + + #[cfg(target_os = "windows")] + { + use tao::platform::windows::WindowExtWindows; + window.set_undecorated_shadow(true); + } + + let builder = WebViewBuilder::new() + // The second is on webview... + .with_transparent(true) + // And the last is in html. + .with_html( + r#" + + + "#, + ); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let _webview = builder.build(&window)?; + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let _webview = { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox)? + }; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + if let Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } = event + { + *control_flow = ControlFlow::Exit + } + }); +} diff --git a/third_party/wry/examples/wgpu.rs b/third_party/wry/examples/wgpu.rs new file mode 100644 index 0000000000..2b9ce1ea3f --- /dev/null +++ b/third_party/wry/examples/wgpu.rs @@ -0,0 +1,273 @@ +use std::{borrow::Cow, sync::Arc}; +use winit::{ + application::ApplicationHandler, + event::WindowEvent, + event_loop::{ActiveEventLoop, EventLoop}, + window::{Window, WindowId}, +}; +use wry::{ + dpi::{LogicalPosition, LogicalSize}, + Rect, WebViewBuilder, +}; + +#[derive(Default)] +struct State { + window: Option>, + webview: Option, + gfx_state: Option, +} + +struct GfxState { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + config: wgpu::SurfaceConfiguration, + render_pipeline: wgpu::RenderPipeline, +} + +impl GfxState { + fn new(window: Arc) -> Self { + let instance = wgpu::Instance::default(); + let window_size = window.inner_size(); + let surface = instance.create_surface(window).unwrap(); + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::default(), + force_fallback_adapter: false, + compatible_surface: Some(&surface), + })) + .expect("Failed to find an appropriate adapter"); + + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: None, + required_features: wgpu::Features::empty(), + required_limits: + wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()), + memory_hints: wgpu::MemoryHints::Performance, + }, + None, + )) + .expect("Failed to create device"); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Wgsl(Cow::Borrowed( + r#" + @vertex + fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4 { + let x = f32(i32(in_vertex_index) - 1); + let y = f32(i32(in_vertex_index & 1u) * 2 - 1); + return vec4(x, y, 0.0, 1.0); + } + + @fragment + fn fs_main() -> @location(0) vec4 { + return vec4(1.0, 0.0, 0.0, 1.0); + } + "#, + )), + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: None, + bind_group_layouts: &[], + push_constant_ranges: &[], + }); + + let swapchain_capabilities = surface.get_capabilities(&adapter); + let swapchain_format = swapchain_capabilities.formats[0]; + + let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(swapchain_format.into())], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: swapchain_format, + width: window_size.width, + height: window_size.height, + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode: swapchain_capabilities.alpha_modes[0], + view_formats: vec![], + }; + + surface.configure(&device, &config); + + Self { + surface, + device, + queue, + config, + render_pipeline, + } + } + + fn render(&mut self) { + let frame = self + .surface + .get_current_texture() + .expect("Failed to acquire next swap chain texture"); + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + render_pass.set_pipeline(&self.render_pipeline); + render_pass.draw(0..3, 0..1); + } + + self.queue.submit(Some(encoder.finish())); + frame.present(); + } +} + +impl ApplicationHandler for State { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let mut attributes = Window::default_attributes(); + attributes.transparent = true; + #[cfg(windows)] + { + use winit::platform::windows::WindowAttributesExtWindows; + attributes = attributes.with_clip_children(false); + } + + let window = Arc::new(event_loop.create_window(attributes).unwrap()); + + let gfx_state = GfxState::new(Arc::clone(&window)); + + let webview = WebViewBuilder::new() + .with_bounds(Rect { + position: LogicalPosition::new(100, 100).into(), + size: LogicalSize::new(200, 200).into(), + }) + .with_transparent(true) + .with_html( + r#" + + + "#, + ) + .build_as_child(&window) + .unwrap(); + + window.request_redraw(); + + self.window = Some(window); + self.webview = Some(webview); + self.gfx_state = Some(gfx_state); + } + + fn window_event( + &mut self, + _event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + match event { + WindowEvent::Resized(size) => { + if let Some(gfx_state) = &mut self.gfx_state { + gfx_state.config.width = size.width; + gfx_state.config.height = size.height; + gfx_state + .surface + .configure(&gfx_state.device, &gfx_state.config); + + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } + WindowEvent::RedrawRequested => { + if let Some(gfx_state) = &mut self.gfx_state { + gfx_state.render(); + } + } + WindowEvent::CloseRequested => { + std::process::exit(0); + } + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + ))] + { + while gtk::events_pending() { + gtk::main_iteration_do(false); + } + } + } +} + +fn main() { + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + ))] + { + use gtk::prelude::DisplayExtManual; + + gtk::init().unwrap(); + if gtk::gdk::Display::default().unwrap().backend().is_wayland() { + panic!("This example doesn't support wayland!"); + } + + winit::platform::x11::register_xlib_error_hook(Box::new(|_display, error| { + let error = error as *mut x11_dl::xlib::XErrorEvent; + (unsafe { (*error).error_code }) == 170 + })); + } + + let event_loop = EventLoop::new().unwrap(); + let mut state = State::default(); + event_loop.run_app(&mut state).unwrap(); +} diff --git a/third_party/wry/examples/window_border.rs b/third_party/wry/examples/window_border.rs new file mode 100644 index 0000000000..2520caf4e2 --- /dev/null +++ b/third_party/wry/examples/window_border.rs @@ -0,0 +1,122 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use dpi::LogicalSize; +use tao::{ + event::{Event, StartCause, WindowEvent}, + event_loop::{ControlFlow, EventLoopBuilder}, + window::WindowBuilder, +}; +use wry::{http::Request, WebViewBuilder}; + +#[derive(Debug)] +enum UserEvent { + TogglShadows, +} + +fn main() -> wry::Result<()> { + let event_loop = EventLoopBuilder::::with_user_event().build(); + let window = WindowBuilder::new() + .with_inner_size(LogicalSize::new(500, 500)) + .with_decorations(false) + .build(&event_loop) + .unwrap(); + + const HTML: &str = r#" + + + + + + + +

+ Click the window to toggle shadows. +

+ + + + + +"#; + + let proxy = event_loop.create_proxy(); + let handler = move |req: Request| { + if req.body().as_str() == "toggleShadows" { + proxy.send_event(UserEvent::TogglShadows).unwrap(); + } + }; + + let builder = WebViewBuilder::new() + .with_html(HTML) + .with_ipc_handler(handler) + .with_accept_first_mouse(true); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let webview = builder.build(&window)?; + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let webview = { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox)? + }; + + let mut webview = Some(webview); + + let mut shadow = true; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::NewEvents(StartCause::Init) => println!("Wry application started!"), + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => { + let _ = webview.take(); + *control_flow = ControlFlow::Exit + } + + Event::UserEvent(e) => match e { + UserEvent::TogglShadows => { + shadow = !shadow; + #[cfg(windows)] + { + use tao::platform::windows::WindowExtWindows; + window.set_undecorated_shadow(shadow); + } + } + }, + _ => (), + } + }); +} diff --git a/third_party/wry/examples/winit.rs b/third_party/wry/examples/winit.rs new file mode 100644 index 0000000000..7299487009 --- /dev/null +++ b/third_party/wry/examples/winit.rs @@ -0,0 +1,100 @@ +use dpi::{LogicalPosition, LogicalSize}; +use winit::{ + application::ApplicationHandler, + event::WindowEvent, + event_loop::{ActiveEventLoop, EventLoop}, + window::{Window, WindowId}, +}; +use wry::{Rect, WebViewBuilder}; + +#[derive(Default)] +struct State { + window: Option, + webview: Option, +} + +impl ApplicationHandler for State { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let mut attributes = Window::default_attributes(); + attributes.inner_size = Some(LogicalSize::new(800, 800).into()); + let window = event_loop.create_window(attributes).unwrap(); + + let webview = WebViewBuilder::new() + .with_url("https://tauri.app") + .build_as_child(&window) + .unwrap(); + + self.window = Some(window); + self.webview = Some(webview); + } + + fn window_event( + &mut self, + _event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + match event { + WindowEvent::Resized(size) => { + let window = self.window.as_ref().unwrap(); + let webview = self.webview.as_ref().unwrap(); + + let size = size.to_logical::(window.scale_factor()); + webview + .set_bounds(Rect { + position: LogicalPosition::new(0, 0).into(), + size: LogicalSize::new(size.width, size.height).into(), + }) + .unwrap(); + } + WindowEvent::CloseRequested => { + std::process::exit(0); + } + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + ))] + { + while gtk::events_pending() { + gtk::main_iteration_do(false); + } + } + } +} + +fn main() -> wry::Result<()> { + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + ))] + { + use gtk::prelude::DisplayExtManual; + + gtk::init().unwrap(); + if gtk::gdk::Display::default().unwrap().backend().is_wayland() { + panic!("This example doesn't support wayland!"); + } + + winit::platform::x11::register_xlib_error_hook(Box::new(|_display, error| { + let error = error as *mut x11_dl::xlib::XErrorEvent; + (unsafe { (*error).error_code }) == 170 + })); + } + + let event_loop = EventLoop::new().unwrap(); + let mut state = State::default(); + event_loop.run_app(&mut state).unwrap(); + + Ok(()) +} diff --git a/third_party/wry/renovate.json b/third_party/wry/renovate.json new file mode 100644 index 0000000000..e0493a29a7 --- /dev/null +++ b/third_party/wry/renovate.json @@ -0,0 +1,10 @@ +{ + "extends": ["config:recommended"], + "rangeStrategy": "replace", + "packageRules": [ + { + "semanticCommitType": "chore", + "matchPackageNames": ["*"] + } + ] +} diff --git a/third_party/wry/rustfmt.toml b/third_party/wry/rustfmt.toml new file mode 100644 index 0000000000..a90653d108 --- /dev/null +++ b/third_party/wry/rustfmt.toml @@ -0,0 +1,15 @@ +max_width = 100 +hard_tabs = false +tab_spaces = 2 +newline_style = "Unix" +use_small_heuristics = "Default" +reorder_imports = true +reorder_modules = true +remove_nested_parens = true +edition = "2018" +merge_derives = true +use_try_shorthand = false +use_field_init_shorthand = false +force_explicit_abi = true +imports_granularity = "Crate" +#license_template_path = ".license_template" diff --git a/third_party/wry/src/android/binding.rs b/third_party/wry/src/android/binding.rs new file mode 100644 index 0000000000..552b1b6d17 --- /dev/null +++ b/third_party/wry/src/android/binding.rs @@ -0,0 +1,574 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use http::{ + header::{HeaderName, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}, + Request, +}; +use jni::errors::Result as JniResult; +pub use jni::{ + self, + objects::{GlobalRef, JClass, JMap, JObject, JString}, + sys::{jboolean, jint, jobject, jstring}, + JNIEnv, +}; +pub use ndk; +use ndk::looper::{FdEvent, ThreadLooper}; +use std::os::fd::{AsFd, AsRawFd}; + +use super::{ + main_pipe::{MainPipe, MAIN_PIPE}, + ASSET_LOADER_DOMAIN, EVAL_CALLBACKS, IPC, ON_LOAD_HANDLER, PERMISSION_HANDLER, REQUEST_HANDLER, + TITLE_CHANGE_HANDLER, URL_LOADING_OVERRIDE, WITH_ASSET_LOADER, +}; + +use crate::{PageLoadEvent, PermissionKind, PermissionResponse}; + +#[macro_export] +macro_rules! android_binding { + ($domain:ident, $package:ident) => { + ::wry::android_binding!($domain, $package, ::wry) + }; + // use imported `android_setup` just to force the import path to use `wry::{}` + // as the macro breaks without braces + ($domain:ident, $package:ident, $wry:path) => {{ + use $wry::{android_setup as _, prelude::*}; + + android_fn!($domain, $package, Rust, onFirstActivityCreateWry, []); + android_fn!( + $domain, + $package, + Rust, + onWebviewDestroy, + [JObject, JString] + ); + + android_fn!( + $domain, + $package, + Rust, + handleRequest, + [JString, JObject, jboolean], + jobject + ); + android_fn!( + $domain, + $package, + Rust, + withAssetLoader, + [JString], + jboolean + ); + android_fn!( + $domain, + $package, + Rust, + assetLoaderDomain, + [JString], + jstring + ); + android_fn!( + $domain, + $package, + Rust, + shouldOverride, + [JString, JString], + jboolean + ); + android_fn!($domain, $package, Rust, onEval, [JString, jint, JString]); + android_fn!($domain, $package, Rust, onPageLoading, [JString, JString]); + android_fn!($domain, $package, Rust, onPageLoaded, [JString, JString]); + android_fn!($domain, $package, Rust, ipc, [JString, JString, JString]); + android_fn!( + $domain, + $package, + Rust, + handleReceivedTitle, + [JString, JString], + ); + android_fn!( + $domain, + $package, + RustWebChromeClient, + onPermissionRequestNative, + [JString, JString], + jint + ); + android_fn!( + $domain, + $package, + RustWebChromeClient, + onGeolocationPermissionRequestNative, + [JString, JString], + jboolean + ); + }}; +} + +fn handle_request( + env: &mut JNIEnv, + webview_id: JString, + request: JObject, + is_document_start_script_enabled: jboolean, +) -> JniResult { + let webview_id = env.get_string(&webview_id)?; + let webview_id = webview_id.to_str().unwrap_or_default(); + + let Some(handler) = REQUEST_HANDLER + .lock() + .unwrap() + .get(webview_id) + .map(|handler| handler.handler.clone()) + else { + return Ok(*JObject::null()); + }; + + #[cfg(feature = "tracing")] + let span = + tracing::info_span!(parent: None, "wry::custom_protocol::handle", uri = tracing::field::Empty) + .entered(); + + let mut request_builder = Request::builder(); + + let uri = env + .call_method(&request, "getUrl", "()Landroid/net/Uri;", &[])? + .l()?; + let url: JString = env + .call_method(&uri, "toString", "()Ljava/lang/String;", &[])? + .l()? + .into(); + let url = env.get_string(&url)?.to_string_lossy().to_string(); + + #[cfg(feature = "tracing")] + span.record("uri", &url); + + request_builder = request_builder.uri(&url); + + let method = env + .call_method(&request, "getMethod", "()Ljava/lang/String;", &[])? + .l() + .map(JString::from)?; + request_builder = request_builder.method( + env + .get_string(&method)? + .to_string_lossy() + .to_string() + .as_str(), + ); + + let request_headers = env + .call_method(request, "getRequestHeaders", "()Ljava/util/Map;", &[])? + .l()?; + let request_headers = JMap::from_env(env, &request_headers)?; + let mut iter = request_headers.iter(env)?; + while let Some((header, value)) = iter.next(env)? { + let header = JString::from(header); + let value = JString::from(value); + let header = env.get_string(&header)?; + let value = env.get_string(&value)?; + if let (Ok(header), Ok(value)) = ( + HeaderName::from_bytes(header.to_bytes()), + HeaderValue::from_bytes(value.to_bytes()), + ) { + request_builder = request_builder.header(header, value); + } + } + + let final_request = match request_builder.body(Vec::new()) { + Ok(req) => req, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to build response: {_e}"); + return Ok(*JObject::null()); + } + }; + + let response = { + #[cfg(feature = "tracing")] + let _span = tracing::info_span!("wry::custom_protocol::call_handler").entered(); + handler( + webview_id, + final_request, + is_document_start_script_enabled != 0, + ) + }; + let Some(response) = response else { + return Ok(*JObject::null()); + }; + let status = response.status(); + let status_code = status.as_u16() as i32; + let status_err = if status_code < 100 { + Some("Status code can't be less than 100") + } else if status_code > 599 { + Some("statusCode can't be greater than 599.") + } else if status_code > 299 && status_code < 400 { + Some("statusCode can't be in the [300, 399] range.") + } else { + None + }; + if let Some(_err) = status_err { + #[cfg(feature = "tracing")] + tracing::warn!("{_err}"); + return Ok(*JObject::null()); + } + + let reason_phrase = status.canonical_reason().unwrap_or("OK"); + let (mime_type, encoding) = if let Some(content_type) = response.headers().get(CONTENT_TYPE) { + let content_type = content_type.to_str().unwrap().trim(); + let mut s = content_type.split(';'); + let mime_type = s.next().unwrap().trim(); + let mut encoding = None; + for token in s { + let token = token.trim(); + if token.starts_with("charset=") { + encoding.replace(token.split('=').nth(1).unwrap()); + break; + } + } + ( + env.new_string(mime_type)?, + if let Some(encoding) = encoding { + env.new_string(encoding)? + } else { + JString::default() + }, + ) + } else { + (JString::default(), JString::default()) + }; + + let headers = response.headers(); + let obj = env.new_object("java/util/HashMap", "()V", &[])?; + let response_headers = { + let headers_map = JMap::from_env(env, &obj)?; + for (name, value) in headers.iter() { + // WebResourceResponse will automatically generate Content-Type and + // Content-Length headers so we should skip them to avoid duplication. + if name == CONTENT_TYPE || name == CONTENT_LENGTH { + continue; + } + let key = env.new_string(name)?; + let value = env.new_string(value.to_str().unwrap_or_default())?; + headers_map.put(env, &key, &value)?; + } + headers_map + }; + + let bytes = response.body(); + + let byte_array_input_stream = env.find_class("java/io/ByteArrayInputStream")?; + let byte_array = env.byte_array_from_slice(bytes)?; + let stream = env.new_object(byte_array_input_stream, "([B)V", &[(&byte_array).into()])?; + + let reason_phrase = env.new_string(reason_phrase)?; + + let web_resource_response_class = env.find_class("android/webkit/WebResourceResponse")?; + let web_resource_response = env.new_object( + web_resource_response_class, + "(Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/util/Map;Ljava/io/InputStream;)V", + &[(&mime_type).into(), (&encoding).into(), status_code.into(), (&reason_phrase).into(), (&response_headers).into(), (&stream).into()], + )?; + + Ok(*web_resource_response) +} + +#[allow(non_snake_case)] +pub unsafe fn onFirstActivityCreateWry(env: JNIEnv, _: JClass) { + let mut main_pipe = MainPipe { env }; + + let looper = ThreadLooper::for_thread().unwrap(); + + looper + .add_fd_with_callback(MAIN_PIPE[0].as_fd(), FdEvent::INPUT, move |fd, _event| { + let mut buf = [0u8]; + if libc::read(fd.as_raw_fd(), buf.as_mut_ptr() as *mut _, buf.len()) + == buf.len() as libc::ssize_t + { + // unregister itself on errors + main_pipe.recv().is_ok() + } else { + // unregister itself + false + } + }) + .unwrap(); +} + +#[allow(non_snake_case)] +pub unsafe fn onWebviewDestroy(mut env: JNIEnv, _: JClass, activity: JObject, webview_id: JString) { + let activity_id = env + .call_method(&activity, "getId", "()I", &[]) + .unwrap() + .i() + .unwrap(); + + let webview_id = env + .get_string(&webview_id) + .unwrap() + .to_string_lossy() + .to_string(); + + let is_changing_configurations = env + .call_method(&activity, "isChangingConfigurations", "()Z", &[]) + .unwrap() + .z() + .unwrap(); + + super::MainPipe::send( + activity_id, + super::WebViewMessage::OnDestroy { + activity_id, + webview_id, + is_changing_configurations, + }, + ); +} + +#[allow(non_snake_case)] +pub unsafe fn handleRequest( + mut env: JNIEnv, + _: JClass, + webview_id: JString, + request: JObject, + is_document_start_script_enabled: jboolean, +) -> jobject { + match handle_request( + &mut env, + webview_id, + request, + is_document_start_script_enabled, + ) { + Ok(response) => response, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to handle request: {_e}"); + JObject::null().as_raw() + } + } +} + +#[allow(non_snake_case)] +pub unsafe fn shouldOverride( + mut env: JNIEnv, + _: JClass, + webview_id: JString, + url: JString, +) -> jboolean { + match env.get_string(&url) { + Ok(url) => { + let url = url.to_string_lossy().to_string(); + + let Ok(webview_id) = env.get_string(&webview_id) else { + return false.into(); + }; + let webview_id = webview_id.to_str().unwrap_or_default(); + + URL_LOADING_OVERRIDE + .lock() + .unwrap() + .get(webview_id) + // We negate the result of the function because the logic for the android + // client is different from how the navigation_handler is defined. + // + // https://developer.android.com/reference/android/webkit/WebViewClient#shouldOverrideUrlLoading(android.webkit.WebView,%20android.webkit.WebResourceRequest) + .map(|f| !(f.handler)(url)) + .unwrap_or_default() + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to parse JString: {_e}"); + false + } + } + .into() +} + +#[allow(non_snake_case)] +pub unsafe fn onEval(mut env: JNIEnv, _: JClass, _webview_id: JString, id: jint, result: JString) { + match env.get_string(&result) { + Ok(result) => { + if let Some(cb) = EVAL_CALLBACKS + .get_or_init(Default::default) + .lock() + .unwrap() + .get(&id) + { + cb(result.into()); + } + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to parse JString: {_e}"); + } + } +} + +pub unsafe fn ipc(mut env: JNIEnv, _: JClass, webview_id: JString, url: JString, body: JString) { + match ( + env.get_string(&url), + env.get_string(&body), + env.get_string(&webview_id), + ) { + (Ok(url), Ok(body), Ok(webview_id)) => { + #[cfg(feature = "tracing")] + let _span = tracing::info_span!(parent: None, "wry::ipc::handle").entered(); + + let url = url.to_string_lossy().to_string(); + let body = body.to_string_lossy().to_string(); + let webview_id = webview_id.to_string_lossy().to_string(); + if let Some(ipc) = IPC.lock().unwrap().get(&webview_id) { + (ipc.handler)(Request::builder().uri(url).body(body).unwrap()) + } + } + (Err(_e), _, _) | (_, Err(_e), _) | (_, _, Err(_e)) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to parse JString: {_e}") + } + } +} + +#[allow(non_snake_case)] +pub unsafe fn handleReceivedTitle(mut env: JNIEnv, _: JClass, webview_id: JString, title: JString) { + match (env.get_string(&title), env.get_string(&webview_id)) { + (Ok(title), Ok(webview_id)) => { + let title = title.to_string_lossy().to_string(); + let webview_id = webview_id.to_string_lossy().to_string(); + if let Some(title_handler) = TITLE_CHANGE_HANDLER.lock().unwrap().get(&webview_id) { + (title_handler.handler)(title) + } + } + (Err(_e), _) | (_, Err(_e)) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to parse JString: {_e}") + } + } +} + +#[allow(non_snake_case)] +pub unsafe fn withAssetLoader(mut env: JNIEnv, _: JClass, webview_id: JString) -> jboolean { + let Ok(webview_id) = env.get_string(&webview_id) else { + return false.into(); + }; + let webview_id = webview_id.to_str().unwrap_or_default(); + (*WITH_ASSET_LOADER + .lock() + .unwrap() + .get(webview_id) + .unwrap_or(&false)) + .into() +} + +#[allow(non_snake_case)] +pub unsafe fn assetLoaderDomain(mut env: JNIEnv, _: JClass, webview_id: JString) -> jstring { + let Ok(webview_id) = env.get_string(&webview_id) else { + return env.new_string("wry.assets").unwrap().as_raw(); + }; + let webview_id = webview_id.to_str().unwrap_or_default(); + if let Some(domain) = ASSET_LOADER_DOMAIN.lock().unwrap().get(webview_id) { + env.new_string(domain).unwrap().as_raw() + } else { + env.new_string("wry.assets").unwrap().as_raw() + } +} + +#[allow(non_snake_case)] +pub unsafe fn onPageLoading(mut env: JNIEnv, _: JClass, webview_id: JString, url: JString) { + match (env.get_string(&url), env.get_string(&webview_id)) { + (Ok(url), Ok(webview_id)) => { + let url = url.to_string_lossy().to_string(); + let webview_id = webview_id.to_string_lossy().to_string(); + if let Some(on_load) = ON_LOAD_HANDLER.lock().unwrap().get(&webview_id) { + (on_load.handler)(PageLoadEvent::Started, url) + } + } + (Err(_e), _) | (_, Err(_e)) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to parse JString: {_e}") + } + } +} + +#[allow(non_snake_case)] +pub unsafe fn onPageLoaded(mut env: JNIEnv, _: JClass, webview_id: JString, url: JString) { + match (env.get_string(&url), env.get_string(&webview_id)) { + (Ok(url), Ok(webview_id)) => { + let url = url.to_string_lossy().to_string(); + let webview_id = webview_id.to_string_lossy().to_string(); + if let Some(on_load) = ON_LOAD_HANDLER.lock().unwrap().get(&webview_id) { + (on_load.handler)(PageLoadEvent::Finished, url) + } + } + (Err(_e), _) | (_, Err(_e)) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to parse JString: {_e}") + } + } +} + +const ANDROID_PERMISSION_REQUEST_DEFAULT: jint = 0; +const ANDROID_PERMISSION_REQUEST_ALLOW: jint = 1; +const ANDROID_PERMISSION_REQUEST_DENY: jint = 2; + +/// Returns `ANDROID_PERMISSION_REQUEST_DEFAULT | ANDROID_PERMISSION_REQUEST_ALLOW | ANDROID_PERMISSION_REQUEST_DENY` +#[allow(non_snake_case)] +pub unsafe fn onPermissionRequestNative( + mut env: JNIEnv, + _: JClass, + webview_id: JString, + resource: JString, +) -> jint { + let Ok(webview_id) = env.get_string(&webview_id) else { + return ANDROID_PERMISSION_REQUEST_DEFAULT; + }; + let webview_id = webview_id.to_str().ok().unwrap_or_default(); + let permission_handlers = PERMISSION_HANDLER.lock().unwrap(); + let Some(handler) = permission_handlers.get(webview_id) else { + return ANDROID_PERMISSION_REQUEST_DEFAULT; + }; + + let Ok(resource_str) = env.get_string(&resource) else { + return ANDROID_PERMISSION_REQUEST_DEFAULT; + }; + let resource_str = resource_str.to_string_lossy(); + + let kind = match resource_str.as_ref() { + "android.webkit.resource.AUDIO_CAPTURE" => PermissionKind::Microphone, + "android.webkit.resource.VIDEO_CAPTURE" => PermissionKind::Camera, + "android.webkit.resource.PROTECTED_MEDIA_ID" => PermissionKind::MediaKeySystemAccess, + "android.webkit.resource.MIDI_SYSEX" => PermissionKind::Midi, + _ => PermissionKind::Other, + }; + + match (handler.handler)(kind) { + PermissionResponse::Default => ANDROID_PERMISSION_REQUEST_DEFAULT, + PermissionResponse::Allow => ANDROID_PERMISSION_REQUEST_ALLOW, + PermissionResponse::Deny => ANDROID_PERMISSION_REQUEST_DENY, + } +} + +/// Returns true to deny geolocation. +/// +/// Returns false to let Kotlin continue with Android's normal runtime permission flow. +#[allow(non_snake_case)] +pub unsafe fn onGeolocationPermissionRequestNative( + mut env: JNIEnv, + _: JClass, + webview_id: JString, + _origin: JString, +) -> jboolean { + let Ok(webview_id) = env.get_string(&webview_id) else { + return false.into(); + }; + let webview_id = webview_id.to_str().ok().unwrap_or_default(); + let permission_handlers = PERMISSION_HANDLER.lock().unwrap(); + let Some(handler) = permission_handlers.get(webview_id) else { + return false.into(); + }; + + matches!( + (handler.handler)(PermissionKind::Geolocation), + PermissionResponse::Deny + ) + .into() +} diff --git a/third_party/wry/src/android/kotlin/Ipc.kt b/third_party/wry/src/android/kotlin/Ipc.kt new file mode 100644 index 0000000000..c760375064 --- /dev/null +++ b/third_party/wry/src/android/kotlin/Ipc.kt @@ -0,0 +1,23 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("unused") + +package {{package}} + +import android.webkit.* + +class Ipc(val webView: RustWebView, val webViewClient: RustWebViewClient) { + @JavascriptInterface + fun postMessage(message: String?) { + message?.let {m -> + // we're not using WebView::getUrl() here because it needs to be executed on the main thread + // and it would slow down the Ipc + // so instead we track the current URL on the webview client + Rust.ipc(webView.id, webViewClient.currentUrl, m) + } + } + + {{class-extension}} +} diff --git a/third_party/wry/src/android/kotlin/Logger.kt b/third_party/wry/src/android/kotlin/Logger.kt new file mode 100644 index 0000000000..3666ad2aac --- /dev/null +++ b/third_party/wry/src/android/kotlin/Logger.kt @@ -0,0 +1,87 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("unused", "MemberVisibilityCanBePrivate") + +package {{package}} + +// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/Logger.java + +import android.text.TextUtils +import android.util.Log + +class Logger { + companion object { + private const val LOG_TAG_CORE = "Tauri" + + fun tags(vararg subtags: String): String { + return if (subtags.isNotEmpty()) { + LOG_TAG_CORE + "/" + TextUtils.join("/", subtags) + } else LOG_TAG_CORE + } + + fun verbose(message: String) { + verbose(LOG_TAG_CORE, message) + } + + private fun verbose(tag: String, message: String) { + if (!shouldLog()) { + return + } + Log.v(tag, message) + } + + fun debug(message: String) { + debug(LOG_TAG_CORE, message) + } + + fun debug(tag: String, message: String) { + if (!shouldLog()) { + return + } + Log.d(tag, message) + } + + fun info(message: String) { + info(LOG_TAG_CORE, message) + } + + fun info(tag: String, message: String) { + if (!shouldLog()) { + return + } + Log.i(tag, message) + } + + fun warn(message: String) { + warn(LOG_TAG_CORE, message) + } + + fun warn(tag: String, message: String) { + if (!shouldLog()) { + return + } + Log.w(tag, message) + } + + fun error(message: String) { + error(LOG_TAG_CORE, message, null) + } + + fun error(message: String, e: Throwable?) { + error(LOG_TAG_CORE, message, e) + } + + fun error(tag: String, message: String, e: Throwable?) { + if (!shouldLog()) { + return + } + Log.e(tag, message, e) + } + + private fun shouldLog(): Boolean { + return BuildConfig.DEBUG + } + } +} diff --git a/third_party/wry/src/android/kotlin/PermissionHelper.kt b/third_party/wry/src/android/kotlin/PermissionHelper.kt new file mode 100644 index 0000000000..b2471e515e --- /dev/null +++ b/third_party/wry/src/android/kotlin/PermissionHelper.kt @@ -0,0 +1,115 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package {{package}} + +// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/PermissionHelper.java + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import java.util.ArrayList + +object PermissionHelper { + /** + * Checks if a list of given permissions are all granted by the user + * + * @param permissions Permissions to check. + * @return True if all permissions are granted, false if at least one is not. + */ + fun hasPermissions(context: Context?, permissions: Array): Boolean { + for (perm in permissions) { + if (ActivityCompat.checkSelfPermission( + context!!, + perm + ) != PackageManager.PERMISSION_GRANTED + ) { + return false + } + } + return true + } + + /** + * Check whether the given permission has been defined in the AndroidManifest.xml + * + * @param permission A permission to check. + * @return True if the permission has been defined in the Manifest, false if not. + */ + fun hasDefinedPermission(context: Context, permission: String): Boolean { + var hasPermission = false + val requestedPermissions = getManifestPermissions(context) + if (!requestedPermissions.isNullOrEmpty()) { + val requestedPermissionsList = listOf(*requestedPermissions) + val requestedPermissionsArrayList = ArrayList(requestedPermissionsList) + if (requestedPermissionsArrayList.contains(permission)) { + hasPermission = true + } + } + return hasPermission + } + + /** + * Check whether all of the given permissions have been defined in the AndroidManifest.xml + * @param context the app context + * @param permissions a list of permissions + * @return true only if all permissions are defined in the AndroidManifest.xml + */ + fun hasDefinedPermissions(context: Context, permissions: Array): Boolean { + for (permission in permissions) { + if (!hasDefinedPermission(context, permission)) { + return false + } + } + return true + } + + /** + * Get the permissions defined in AndroidManifest.xml + * + * @return The permissions defined in AndroidManifest.xml + */ + private fun getManifestPermissions(context: Context): Array? { + var requestedPermissions: Array? = null + try { + val pm = context.packageManager + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + } + if (packageInfo != null) { + requestedPermissions = packageInfo.requestedPermissions + } + } catch (_: Exception) { + } + return requestedPermissions + } + + /** + * Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml + * + * @param neededPermissions The permissions needed. + * @return The permissions not present in AndroidManifest.xml + */ + fun getUndefinedPermissions(context: Context, neededPermissions: Array): Array { + val undefinedPermissions = ArrayList() + val requestedPermissions = getManifestPermissions(context) + if (!requestedPermissions.isNullOrEmpty()) { + val requestedPermissionsList = listOf(*requestedPermissions) + val requestedPermissionsArrayList = ArrayList(requestedPermissionsList) + for (permission in neededPermissions) { + if (!requestedPermissionsArrayList.contains(permission)) { + undefinedPermissions.add(permission) + } + } + var undefinedPermissionArray = arrayOfNulls(undefinedPermissions.size) + undefinedPermissionArray = undefinedPermissions.toArray(undefinedPermissionArray) + return undefinedPermissionArray + } + return neededPermissions + } +} diff --git a/third_party/wry/src/android/kotlin/Rust.kt b/third_party/wry/src/android/kotlin/Rust.kt new file mode 100644 index 0000000000..f05ec7dbe5 --- /dev/null +++ b/third_party/wry/src/android/kotlin/Rust.kt @@ -0,0 +1,45 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("unused") + +package {{package}} + +import android.content.Intent +import android.webkit.WebView +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse + +object Rust { + init { + System.loadLibrary("{{library}}") + } + + // Tao bindings + @JvmStatic external fun onFirstActivityCreate() + @JvmStatic external fun onCreate(activity: WryActivity) + @JvmStatic external fun onStart(activity: WryActivity) + @JvmStatic external fun onResume(activity: WryActivity) + @JvmStatic external fun onPause(activity: WryActivity) + @JvmStatic external fun onStop(activity: WryActivity) + @JvmStatic external fun onDestroy(activity: WryActivity) + @JvmStatic external fun onWindowFocusChanged(activity: WryActivity, focus: Boolean) + @JvmStatic external fun onLowMemory() + @JvmStatic external fun onNewIntent(intent: Intent) + + @JvmStatic external fun onFirstActivityCreateWry() + @JvmStatic external fun onWebviewDestroy(activity: WryActivity, webviewId: String) + + @JvmStatic external fun ipc(webviewId: String, url: String, message: String) + + @JvmStatic external fun assetLoaderDomain(webviewId: String): String + @JvmStatic external fun withAssetLoader(webviewId: String): Boolean + @JvmStatic external fun handleRequest(webviewId: String, request: WebResourceRequest, isDocumentStartScriptEnabled: Boolean): WebResourceResponse? + @JvmStatic external fun shouldOverride(webviewId: String, url: String): Boolean + @JvmStatic external fun onPageLoading(webviewId: String, url: String) + @JvmStatic external fun onPageLoaded(webviewId: String, url: String) + @JvmStatic external fun onEval(webviewId: String, id: Int, result: String) + + @JvmStatic external fun handleReceivedTitle(webviewId: String, title: String) +} \ No newline at end of file diff --git a/third_party/wry/src/android/kotlin/RustWebChromeClient.kt b/third_party/wry/src/android/kotlin/RustWebChromeClient.kt new file mode 100644 index 0000000000..70c97a7946 --- /dev/null +++ b/third_party/wry/src/android/kotlin/RustWebChromeClient.kt @@ -0,0 +1,509 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("ObsoleteSdkInt", "RedundantOverride", "QueryPermissionsNeeded", "SimpleDateFormat") + +package {{package}} + +// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java + +import android.Manifest +import android.app.Activity +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.view.View +import android.webkit.* +import android.widget.EditText +import androidx.core.content.FileProvider +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* + +class RustWebChromeClient(private val activity: WryActivity, private val webViewId: String) : WebChromeClient() { + private companion object { + const val PERMISSION_REQUEST_DEFAULT = 0 + const val PERMISSION_REQUEST_ALLOW = 1 + const val PERMISSION_REQUEST_DENY = 2 + } + + /** + * Render web content in `view`. + * + * Both this method and [.onHideCustomView] are required for + * rendering web content in full screen. + * + * @see [](https://developer.android.com/reference/android/webkit/WebChromeClient.onShowCustomView + ) */ + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + callback.onCustomViewHidden() + super.onShowCustomView(view, callback) + } + + /** + * Render web content in the original Web View again. + * + * Do not remove this method--@see #onShowCustomView(View, CustomViewCallback). + */ + override fun onHideCustomView() { + super.onHideCustomView() + } + + override fun onPermissionRequest(request: PermissionRequest) { + val requestedResources = request.resources + if (requestedResources.isEmpty()) { + request.deny() + return + } + + val allowedResources = ArrayList() + val defaultResources = ArrayList() + + for (resource in requestedResources) { + when (onPermissionRequestNative(webViewId, resource)) { + PERMISSION_REQUEST_DENY -> {} + PERMISSION_REQUEST_ALLOW -> allowedResources.add(resource) + PERMISSION_REQUEST_DEFAULT -> defaultResources.add(resource) + } + } + + val resources = + allowedResources.plus(filterKnownPermissions(defaultResources)).toList().toTypedArray() + grantPermissionRequest(request, resources) + } + + private fun grantPermissionRequest(request: PermissionRequest, resources: Array) { + if (resources.isEmpty()) { + request.deny() + return + } + + val isRequestPermissionRequired = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + val permissionList = androidPermissionsForResources(resources) + if (permissionList.isNotEmpty() && isRequestPermissionRequired) { + val permissions = permissionList.toTypedArray() + activity.requestPermissions(permissions) { isGranted -> + if (isGranted == true) { + request.grant(resources) + } else { + request.deny() + } + } + } else { + request.grant(resources) + } + } + + private fun androidPermissionsForResources(resources: Array): MutableList { + val permissionList: MutableList = ArrayList() + if (resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) { + permissionList.add(Manifest.permission.CAMERA) + } + if (resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) { + permissionList.add(Manifest.permission.MODIFY_AUDIO_SETTINGS) + permissionList.add(Manifest.permission.RECORD_AUDIO) + } + return permissionList + } + + /** + * @return one of the PERMISSION_REQUEST_* constants. + */ + private external fun onPermissionRequestNative(webviewId: String, resource: String): Int + /** + * @return true when Rust denies geolocation; false continues the normal Android permission flow. + */ + private external fun onGeolocationPermissionRequestNative(webviewId: String, origin: String): Boolean + + /** + * Show the browser alert modal + * @param view + * @param url + * @param message + * @param result + * @return + */ + override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean { + if (activity.isFinishing) { + return true + } + val builder = AlertDialog.Builder(view.context) + builder + .setMessage(message) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + result.confirm() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + val dialog = builder.create() + dialog.show() + return true + } + + /** + * Show the browser confirm modal + * @param view + * @param url + * @param message + * @param result + * @return + */ + override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): Boolean { + if (activity.isFinishing) { + return true + } + val builder = AlertDialog.Builder(view.context) + builder + .setMessage(message) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + result.confirm() + } + .setNegativeButton( + "Cancel" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + result.cancel() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + val dialog = builder.create() + dialog.show() + return true + } + + /** + * Show the browser prompt modal + * @param view + * @param url + * @param message + * @param defaultValue + * @param result + * @return + */ + override fun onJsPrompt( + view: WebView, + url: String, + message: String, + defaultValue: String, + result: JsPromptResult + ): Boolean { + if (activity.isFinishing) { + return true + } + val builder = AlertDialog.Builder(view.context) + val input = EditText(view.context) + builder + .setMessage(message) + .setView(input) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + val inputText1 = input.text.toString().trim { it <= ' ' } + result.confirm(inputText1) + } + .setNegativeButton( + "Cancel" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + result.cancel() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + val dialog = builder.create() + dialog.show() + return true + } + + /** + * Handle the browser geolocation permission prompt + * @param origin + * @param callback + */ + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback + ) { + super.onGeolocationPermissionsShowPrompt(origin, callback) + if (onGeolocationPermissionRequestNative(webViewId, origin)) { + callback.invoke(origin, false, false) + return + } + + Logger.debug("onGeolocationPermissionsShowPrompt: DOING IT HERE FOR ORIGIN: $origin") + val geoPermissions = definedGeolocationPermissions() + if (geoPermissions.isEmpty()) { + callback.invoke(origin, false, false) + return + } + + if (!PermissionHelper.hasPermissions(activity, geoPermissions)) { + activity.requestPermissions(geoPermissions) { isGranted -> + if (isGranted == true) { + callback.invoke(origin, true, false) + } else { + val coarsePermission = + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + PermissionHelper.hasPermissions(activity, coarsePermission) + ) { + callback.invoke(origin, true, false) + } else { + callback.invoke(origin, false, false) + } + } + } + } else { + // permission is already granted + callback.invoke(origin, true, false) + Logger.debug("onGeolocationPermissionsShowPrompt: has required permission") + } + } + + private fun filterKnownPermissions(resources: List): Array { + return resources.filter { + it == PermissionRequest.RESOURCE_AUDIO_CAPTURE || + it == PermissionRequest.RESOURCE_VIDEO_CAPTURE || + it == PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID || + it == PermissionRequest.RESOURCE_MIDI_SYSEX + }.toTypedArray() + } + + private fun definedGeolocationPermissions(): Array { + val permissions = ArrayList() + if (PermissionHelper.hasDefinedPermission(activity, Manifest.permission.ACCESS_COARSE_LOCATION)) { + permissions.add(Manifest.permission.ACCESS_COARSE_LOCATION) + } + if (PermissionHelper.hasDefinedPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION)) { + permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) + } + return permissions.toTypedArray() + } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback?>, + fileChooserParams: FileChooserParams + ): Boolean { + val acceptTypes = listOf(*fileChooserParams.acceptTypes) + val captureEnabled = fileChooserParams.isCaptureEnabled + val capturePhoto = captureEnabled && acceptTypes.contains("image/*") + val captureVideo = captureEnabled && acceptTypes.contains("video/*") + if (capturePhoto || captureVideo) { + if (isMediaCaptureSupported) { + showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo) + } else { + val camPermission = arrayOf(Manifest.permission.CAMERA) + activity.requestPermissions(camPermission) { isGranted -> + if (isGranted == true) { + showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo) + } else { + Logger.warn(Logger.tags("FileChooser"), "Camera permission not granted") + filePathCallback.onReceiveValue(null) + } + } + } + } else { + showFilePicker(filePathCallback, fileChooserParams) + } + return true + } + + private val isMediaCaptureSupported: Boolean + get() { + val permissions = arrayOf(Manifest.permission.CAMERA) + return PermissionHelper.hasPermissions(activity, permissions) || + !PermissionHelper.hasDefinedPermission(activity, Manifest.permission.CAMERA) + } + + private fun showMediaCaptureOrFilePicker( + filePathCallback: ValueCallback?>, + fileChooserParams: FileChooserParams, + isVideo: Boolean + ) { + val isVideoCaptureSupported = true + val shown = if (isVideo && isVideoCaptureSupported) { + showVideoCapturePicker(filePathCallback) + } else { + showImageCapturePicker(filePathCallback) + } + if (!shown) { + Logger.warn( + Logger.tags("FileChooser"), + "Media capture intent could not be launched. Falling back to default file picker." + ) + showFilePicker(filePathCallback, fileChooserParams) + } + } + + private fun showImageCapturePicker(filePathCallback: ValueCallback?>): Boolean { + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + if (takePictureIntent.resolveActivity(activity.packageManager) == null) { + return false + } + val imageFileUri: Uri = try { + createImageFileUri() + } catch (ex: Exception) { + Logger.error("Unable to create temporary media capture file: " + ex.message) + return false + } + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri) + activity.launchActivityForResult(takePictureIntent) { result -> + var res: Array? = null + if (result?.resultCode == Activity.RESULT_OK) { + res = arrayOf(imageFileUri) + } + filePathCallback.onReceiveValue(res) + } + return true + } + + private fun showVideoCapturePicker(filePathCallback: ValueCallback?>): Boolean { + val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE) + if (takeVideoIntent.resolveActivity(activity.packageManager) == null) { + return false + } + activity.launchActivityForResult(takeVideoIntent) { result -> + var res: Array? = null + if (result?.resultCode == Activity.RESULT_OK) { + res = arrayOf(result.data!!.data) + } + filePathCallback.onReceiveValue(res) + } + return true + } + + private fun showFilePicker( + filePathCallback: ValueCallback?>, + fileChooserParams: FileChooserParams + ) { + val intent = fileChooserParams.createIntent() + if (fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + if (fileChooserParams.acceptTypes.size > 1 || intent.type!!.startsWith(".")) { + val validTypes = getValidTypes(fileChooserParams.acceptTypes) + intent.putExtra(Intent.EXTRA_MIME_TYPES, validTypes) + if (intent.type!!.startsWith(".")) { + intent.type = validTypes[0] + } + } + try { + activity.launchActivityForResult(intent) { result -> + val res: Array? + val resultIntent = result?.data + if (result?.resultCode == Activity.RESULT_OK && resultIntent!!.clipData != null) { + val numFiles = resultIntent.clipData!!.itemCount + res = arrayOfNulls(numFiles) + for (i in 0 until numFiles) { + res[i] = resultIntent.clipData!!.getItemAt(i).uri + } + } else { + res = FileChooserParams.parseResult( + result?.resultCode ?: 0, + resultIntent + ) + } + filePathCallback.onReceiveValue(res) + } + } catch (e: ActivityNotFoundException) { + filePathCallback.onReceiveValue(null) + } + } + + private fun getValidTypes(currentTypes: Array): Array { + val validTypes: MutableList = ArrayList() + val mtm = MimeTypeMap.getSingleton() + for (mime in currentTypes) { + if (mime.startsWith(".")) { + val extension = mime.substring(1) + val extensionMime = mtm.getMimeTypeFromExtension(extension) + if (extensionMime != null && !validTypes.contains(extensionMime)) { + validTypes.add(extensionMime) + } + } else if (!validTypes.contains(mime)) { + validTypes.add(mime) + } + } + val validObj: Array = validTypes.toTypedArray() + return Arrays.copyOf( + validObj, validObj.size, + Array::class.java + ) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val tag: String = Logger.tags("Console") + if (consoleMessage.message() != null && isValidMsg(consoleMessage.message())) { + val msg = String.format( + "File: %s - Line %d - Msg: %s", + consoleMessage.sourceId(), + consoleMessage.lineNumber(), + consoleMessage.message() + ) + val level = consoleMessage.messageLevel().name + if ("ERROR".equals(level, ignoreCase = true)) { + Logger.error(tag, msg, null) + } else if ("WARNING".equals(level, ignoreCase = true)) { + Logger.warn(tag, msg) + } else if ("TIP".equals(level, ignoreCase = true)) { + Logger.debug(tag, msg) + } else { + Logger.info(tag, msg) + } + } + return true + } + + private fun isValidMsg(msg: String): Boolean { + return !(msg.contains("%cresult %c") || + msg.contains("%cnative %c") || + msg.equals("[object Object]", ignoreCase = true) || + msg.equals("console.groupEnd", ignoreCase = true)) + } + + @Throws(IOException::class) + private fun createImageFileUri(): Uri { + val photoFile = createImageFile(activity) + return FileProvider.getUriForFile( + activity, + activity.packageName.toString() + ".fileprovider", + photoFile + ) + } + + @Throws(IOException::class) + private fun createImageFile(activity: Activity): File { + // Create an image file name + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date()) + val imageFileName = "JPEG_" + timeStamp + "_" + val storageDir = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File.createTempFile(imageFileName, ".jpg", storageDir) + } + + override fun onReceivedTitle( + view: WebView, + title: String + ) { + Rust.handleReceivedTitle((view as RustWebView).id, title) + } +} diff --git a/third_party/wry/src/android/kotlin/RustWebView.kt b/third_party/wry/src/android/kotlin/RustWebView.kt new file mode 100644 index 0000000000..31356c37a7 --- /dev/null +++ b/third_party/wry/src/android/kotlin/RustWebView.kt @@ -0,0 +1,96 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("unused", "SetJavaScriptEnabled") + +package {{package}} + +import android.annotation.SuppressLint +import android.webkit.* +import android.content.Context +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import kotlin.collections.Map + +@SuppressLint("RestrictedApi") +class RustWebView(context: Context, val initScripts: Array, val id: String): WebView(context) { + val isDocumentStartScriptEnabled: Boolean + + init { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.setGeolocationEnabled(true) + settings.databaseEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.javaScriptCanOpenWindowsAutomatically = true + + if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + isDocumentStartScriptEnabled = true + for (script in initScripts) { + WebViewCompat.addDocumentStartJavaScript(this, script, setOf("*")); + } + } else { + isDocumentStartScriptEnabled = false + } + + {{class-init}} + } + + fun loadUrlMainThread(url: String) { + post { + loadUrl(url) + } + } + + fun loadUrlMainThread(url: String, additionalHttpHeaders: Map) { + post { + loadUrl(url, additionalHttpHeaders) + } + } + + override fun loadUrl(url: String) { + if (!Rust.shouldOverride(id, url)) { + super.loadUrl(url); + } + } + + override fun loadUrl(url: String, additionalHttpHeaders: Map) { + if (!Rust.shouldOverride(id, url)) { + super.loadUrl(url, additionalHttpHeaders); + } + } + + fun loadHTMLMainThread(html: String) { + post { + super.loadData(html, "text/html", null) + } + } + + fun evalScript(id: Int, script: String) { + post { + super.evaluateJavascript(script) { result -> + Rust.onEval(this.id, id, result) + } + } + } + + fun clearAllBrowsingData() { + try { + super.getContext().deleteDatabase("webviewCache.db") + super.getContext().deleteDatabase("webview.db") + super.clearCache(true) + super.clearHistory() + super.clearFormData() + } catch (ex: Exception) { + Logger.error("Unable to create temporary media capture file: " + ex.message) + } + } + + fun getCookies(url: String): String { + val cookieManager = CookieManager.getInstance() + return cookieManager.getCookie(url) + } + + {{class-extension}} +} diff --git a/third_party/wry/src/android/kotlin/RustWebViewClient.kt b/third_party/wry/src/android/kotlin/RustWebViewClient.kt new file mode 100644 index 0000000000..3dae460897 --- /dev/null +++ b/third_party/wry/src/android/kotlin/RustWebViewClient.kt @@ -0,0 +1,98 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package {{package}} + +import android.net.Uri +import android.webkit.* +import android.content.Context +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import androidx.webkit.WebViewAssetLoader + +class RustWebViewClient(webView: RustWebView, context: Context): WebViewClient() { + private val interceptedState = mutableMapOf() + var currentUrl: String = "about:blank" + private var lastInterceptedUrl: Uri? = null + private var pendingUrlRedirect: String? = null + + private val assetLoader = WebViewAssetLoader.Builder() + .setDomain(Rust.assetLoaderDomain(webView.id)) + .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(context)) + .build() + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + pendingUrlRedirect?.let { + Handler(Looper.getMainLooper()).post { + view.loadUrl(it) + } + pendingUrlRedirect = null + return null + } + + lastInterceptedUrl = request.url + return if (Rust.withAssetLoader((view as RustWebView).id)) { + assetLoader.shouldInterceptRequest(request.url) + } else { + val response = Rust.handleRequest(view.id, request, view.isDocumentStartScriptEnabled) + if (response != null) { + if (response.responseHeaders != null) { + response.responseHeaders["Cache-Control"] = "no-store" + } else { + response.responseHeaders = mapOf("Cache-Control" to "no-store") + } + } + interceptedState[request.url.toString()] = response != null + return response + } + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + return Rust.shouldOverride((view as RustWebView).id, request.url.toString()) + } + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + currentUrl = url + if (interceptedState[url] == false) { + val webView = view as RustWebView + for (script in webView.initScripts) { + view.evaluateJavascript(script, null) + } + } + return Rust.onPageLoading((view as RustWebView).id, url) + } + + override fun onPageFinished(view: WebView, url: String) { + Rust.onPageLoaded((view as RustWebView).id, url) + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + // we get a net::ERR_CONNECTION_REFUSED when an external URL redirects to a custom protocol + // e.g. oauth flow, because shouldInterceptRequest is not called on redirects + // so we must force retry here with loadUrl() to get a chance of the custom protocol to kick in + if (error.errorCode == ERROR_CONNECT && request.isForMainFrame && request.url != lastInterceptedUrl) { + // prevent the default error page from showing + view.stopLoading() + // without this initial loadUrl the app is stuck + view.loadUrl(request.url.toString()) + // ensure the URL is actually loaded - for some reason there's a race condition and we need to call loadUrl() again later + pendingUrlRedirect = request.url.toString() + } else { + super.onReceivedError(view, request, error) + } + } + + {{class-extension}} +} diff --git a/third_party/wry/src/android/kotlin/WryActivity.kt b/third_party/wry/src/android/kotlin/WryActivity.kt new file mode 100644 index 0000000000..06e872d286 --- /dev/null +++ b/third_party/wry/src/android/kotlin/WryActivity.kt @@ -0,0 +1,197 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package {{package}} + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.webkit.WebView +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner + +private const val ACTIVITY_ID_KEY = "__wryActivityId" + +object WryLifecycleObserver : DefaultLifecycleObserver { + // This only runs once: https://developer.android.com/reference/androidx/lifecycle/ProcessLifecycleOwner + override fun onCreate(owner: LifecycleOwner) { + super.onCreate(owner) + Rust.onFirstActivityCreate() + Rust.onFirstActivityCreateWry() + } +} + +abstract class WryActivity : AppCompatActivity() { + private lateinit var mWebView: RustWebView + private lateinit var permissionLauncher: ActivityResultLauncher> + private lateinit var activityLauncher: ActivityResultLauncher + private var permissionListener: ((Boolean?) -> Unit)? = null + private var activityListener: ((ActivityResult?) -> Unit)? = null + var id: Int = 0 + open val handleBackNavigation: Boolean = true + + open fun onWebViewCreate(webView: WebView) { } + + fun requestPermissions(permissions: Array, listener: (Boolean?) -> Unit) { + permissionListener = listener + permissionLauncher.launch(permissions) + } + + fun launchActivityForResult(intent: Intent, listener: (ActivityResult?) -> Unit) { + activityListener = listener + activityLauncher.launch(intent) + } + + fun setWebView(webView: RustWebView) { + mWebView = webView + + if (handleBackNavigation) { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (this@WryActivity::mWebView.isInitialized) { + if (this@WryActivity.mWebView.canGoBack()) { + this@WryActivity.mWebView.goBack() + } else { + this.isEnabled = false + this@WryActivity.onBackPressed() + this.isEnabled = true + } + } + } + } + onBackPressedDispatcher.addCallback(this, callback) + } + + onWebViewCreate(webView) + } + + val version: String + @SuppressLint("WebViewApiAvailability", "ObsoleteSdkInt") + get() { + // Check getCurrentWebViewPackage() directly if above Android 8 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return WebView.getCurrentWebViewPackage()?.versionName ?: "" + } + + // Otherwise manually check WebView versions + var webViewPackage = "com.google.android.webview" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + webViewPackage = "com.android.chrome" + } + try { + @Suppress("DEPRECATION") + val info = packageManager.getPackageInfo(webViewPackage, 0) + return info.versionName.toString() + } catch (ex: Exception) { + Logger.warn("Unable to get package info for '$webViewPackage'$ex") + } + + try { + @Suppress("DEPRECATION") + val info = packageManager.getPackageInfo("com.android.webview", 0) + return info.versionName.toString() + } catch (ex: Exception) { + Logger.warn("Unable to get package info for 'com.android.webview'$ex") + } + + // Could not detect any webview, return empty string + return "" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + id = savedInstanceState?.getInt(ACTIVITY_ID_KEY) ?: intent.extras?.getInt(ACTIVITY_ID_KEY) ?: hashCode() + + permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { isGranted -> + permissionListener?.let { listener -> + val allGranted = isGranted.values.all { it } + listener(allGranted) + } + } + activityLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + activityListener?.invoke(result) + } + + Rust.onCreate(this) + ProcessLifecycleOwner.get().lifecycle.addObserver(WryLifecycleObserver) + } + + override fun onStart() { + super.onStart() + Rust.onStart(this) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + Rust.onWindowFocusChanged(this, hasFocus) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(ACTIVITY_ID_KEY, id) + } + + override fun onPause() { + super.onPause() + Rust.onPause(this) + if (::mWebView.isInitialized) { + mWebView.onPause() + } + } + + override fun onResume() { + super.onResume() + Rust.onResume(this) + if (::mWebView.isInitialized) { + mWebView.onResume() + } + } + + override fun onStop() { + super.onStop() + Rust.onStop(this) + } + + override fun onDestroy() { + super.onDestroy() + Rust.onDestroy(this) + Rust.onWebviewDestroy(this, if (::mWebView.isInitialized) { mWebView.id } else { "" }) + } + + override fun onLowMemory() { + super.onLowMemory() + Rust.onLowMemory() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + Rust.onNewIntent(intent) + } + + fun getAppClass(name: String): Class<*> { + return Class.forName(name) + } + + // Called by tao through JNI + fun startActivity(cls: Class<*>): Int { + val intent = Intent(this, cls) + val id = kotlin.random.Random.nextInt() + intent.putExtra(ACTIVITY_ID_KEY, id) + startActivity(intent) + return id + } + + {{class-extension}} +} diff --git a/third_party/wry/src/android/kotlin/proguard-wry.pro b/third_party/wry/src/android/kotlin/proguard-wry.pro new file mode 100644 index 0000000000..a0f19c65ab --- /dev/null +++ b/third_party/wry/src/android/kotlin/proguard-wry.pro @@ -0,0 +1,35 @@ +# Copyright 2020-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +-keep class {{package-unescaped}}.* { + native ; +} + +-keep class {{package-unescaped}}.WryActivity { + public (...); + + void setWebView({{package-unescaped}}.RustWebView); + java.lang.Class getAppClass(...); + int getId(); + java.lang.String getVersion(); + int startActivity(...); +} + +-keep class {{package-unescaped}}.Ipc { + public (...); + + @android.webkit.JavascriptInterface public ; +} + +-keep class {{package-unescaped}}.RustWebView { + public (...); + + void loadUrlMainThread(...); + void loadHTMLMainThread(...); + void evalScript(...); +} + +-keep class {{package-unescaped}}.RustWebChromeClient,{{package-unescaped}}.RustWebViewClient { + public (...); +} diff --git a/third_party/wry/src/android/main_pipe.rs b/third_party/wry/src/android/main_pipe.rs new file mode 100644 index 0000000000..ac15cc42fa --- /dev/null +++ b/third_party/wry/src/android/main_pipe.rs @@ -0,0 +1,654 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use crate::{Error, InitializationScript, RGBA}; +use crossbeam_channel::*; +use jni::{ + errors::Result as JniResult, + objects::{GlobalRef, JMap, JObject, JString}, + JNIEnv, JavaVM, +}; +use once_cell::sync::Lazy; +use std::{ + collections::BTreeMap, + ffi::c_void, + os::unix::prelude::*, + sync::{Arc, Mutex}, +}; + +use super::{find_class, EvalCallback, WebviewId, EVAL_CALLBACKS, EVAL_ID_GENERATOR}; + +pub type ActivityId = i32; + +static CHANNEL: Lazy<( + Sender<(ActivityId, WebViewMessage)>, + Receiver<(ActivityId, WebViewMessage)>, +)> = Lazy::new(|| bounded(8)); +pub static MAIN_PIPE: Lazy<[OwnedFd; 2]> = Lazy::new(|| { + let mut pipe: [RawFd; 2] = Default::default(); + unsafe { libc::pipe(pipe.as_mut_ptr()) }; + unsafe { pipe.map(|fd| OwnedFd::from_raw_fd(fd)) } +}); + +#[derive(Clone)] +pub struct ActivityProxy { + pub activity: GlobalRef, + pub window_manager: GlobalRef, + pub webview: Option, + pub java_vm: *mut c_void, +} + +unsafe impl Send for ActivityProxy {} + +impl ActivityProxy { + pub fn new(vm: JavaVM, activity: GlobalRef, window_manager: GlobalRef) -> Self { + Self { + activity, + window_manager, + webview: None, + java_vm: vm.get_java_vm_pointer() as *mut _, + } + } +} + +static ACTIVITY_PROXY: once_cell::sync::Lazy>> = + Lazy::new(|| Mutex::new(BTreeMap::new())); + +pub fn activity_proxy(id: ActivityId) -> Option { + ACTIVITY_PROXY.lock().unwrap().get(&id).cloned() +} + +fn remove_activity_proxy(id: ActivityId) { + ACTIVITY_PROXY.lock().unwrap().remove(&id); +} + +pub fn register_activity_proxy( + vm: JavaVM, + id: ActivityId, + activity: GlobalRef, + window_manager: GlobalRef, +) { + let mut activity_proxy = ACTIVITY_PROXY.lock().unwrap(); + if let Some(proxy) = activity_proxy.get_mut(&id) { + proxy.activity = activity; + proxy.window_manager = window_manager; + proxy.java_vm = vm.get_java_vm_pointer() as *mut _; + } else { + let proxy = ActivityProxy::new(vm, activity, window_manager); + activity_proxy.insert(id, proxy); + } +} + +pub fn activity_id_for_window_manager(window_manager: JObject) -> Option { + for (activity_id, proxy) in ACTIVITY_PROXY.lock().unwrap().iter() { + let vm = unsafe { JavaVM::from_raw(proxy.java_vm.cast()) }.unwrap(); + let mut env = vm.attach_current_thread_as_daemon().unwrap(); + let equals = env + .call_method( + proxy.window_manager.as_obj(), + "equals", + "(Ljava/lang/Object;)Z", + &[(&window_manager).into()], + ) + .and_then(|v| v.z()) + .unwrap_or_default(); + if equals { + return Some(*activity_id); + } + } + None +} + +pub fn first_activity_id() -> Option { + ACTIVITY_PROXY.lock().unwrap().keys().next().cloned() +} + +pub fn get_webview(activity_id: ActivityId) -> Option { + ACTIVITY_PROXY + .lock() + .unwrap() + .get(&activity_id) + .unwrap() + .webview + .as_ref() + .cloned() +} + +pub struct MainPipe<'a> { + pub env: JNIEnv<'a>, +} + +impl<'a> MainPipe<'a> { + pub(crate) fn send(activity_id: ActivityId, message: WebViewMessage) { + const BYTE: [u8; 1] = [0]; + if CHANNEL.0.send((activity_id, message)).is_ok() { + unsafe { + libc::write( + MAIN_PIPE[1].as_raw_fd(), + BYTE.as_ptr() as *const _, + BYTE.len(), + ) + }; + } + } + + pub fn recv(&mut self) -> JniResult<()> { + let package = super::PACKAGE.get().unwrap(); + while let Ok((activity_id, message)) = CHANNEL.1.try_recv() { + self.handle_message(package, activity_id, message)?; + } + Ok(()) + } + + fn handle_message( + &mut self, + package: &str, + activity_id: ActivityId, + message: WebViewMessage, + ) -> JniResult<()> { + match message { + WebViewMessage::CreateWebView(attrs) => { + let Some(ActivityProxy { activity, .. }) = activity_proxy(activity_id) else { + #[cfg(debug_assertions)] + eprintln!("no activity found for activity id: {}", activity_id); + return Ok(()); + }; + let CreateWebViewAttributes { + url, + html, + #[cfg(any(debug_assertions, feature = "devtools"))] + devtools, + transparent, + background_color, + headers, + on_webview_created, + autoplay, + user_agent, + initialization_scripts, + id, + javascript_disabled, + .. + } = attrs; + + let string_class = self.env.find_class("java/lang/String")?; + let initialization_scripts_array = self.env.new_object_array( + initialization_scripts.len() as i32, + string_class, + self.env.new_string("")?, + )?; + for (i, init_script) in initialization_scripts.into_iter().enumerate() { + self.env.set_object_array_element( + &initialization_scripts_array, + i as i32, + self.env.new_string(init_script.script)?, + )?; + } + let id = self.env.new_string(id)?; + // Create webview + let rust_webview_class = + find_class(&mut self.env, &activity, format!("{package}/RustWebView"))?; + let webview = self.env.new_object( + &rust_webview_class, + "(Landroid/content/Context;[Ljava/lang/String;Ljava/lang/String;)V", + &[ + (&activity).into(), + (&initialization_scripts_array).into(), + (&id).into(), + ], + )?; + // get settings + let web_settings = self + .env + .call_method( + &webview, + "getSettings", + "()Landroid/webkit/WebSettings;", + &[], + )? + .l()?; + // set media autoplay + self.env.call_method( + &web_settings, + "setMediaPlaybackRequiresUserGesture", + "(Z)V", + &[(!autoplay).into()], + )?; + // set user-agent + if let Some(user_agent) = user_agent { + let user_agent = self.env.new_string(user_agent)?; + self.env.call_method( + &web_settings, + "setUserAgentString", + "(Ljava/lang/String;)V", + &[(&user_agent).into()], + )?; + } + + // disable javascript + if javascript_disabled { + self.env.call_method( + &web_settings, + "setJavaScriptEnabled", + "(Z)V", + &[false.into()], + )?; + } + + let webview_class_name = format!("{package}/RustWebView"); + self.env.call_method( + &activity, + "setWebView", + format!("(L{webview_class_name};)V"), + &[(&webview).into()], + )?; + // Navigation + if let Some(u) = url { + if let Ok(url) = self.env.new_string(u) { + load_url(&mut self.env, &webview, &url, headers, true)?; + } + } else if let Some(h) = html { + if let Ok(html) = self.env.new_string(h) { + load_html(&mut self.env, &webview, &html)?; + } + } + // Enable devtools + #[cfg(any(debug_assertions, feature = "devtools"))] + self.env.call_static_method( + &rust_webview_class, + "setWebContentsDebuggingEnabled", + "(Z)V", + &[devtools.into()], + )?; + if transparent { + set_background_color(&mut self.env, &webview, (0, 0, 0, 0))?; + } else if let Some(color) = background_color { + set_background_color(&mut self.env, &webview, color)?; + } + // Create and set webview client + let client_class_name = format!("{package}/RustWebViewClient"); + let rust_webview_client_class = + find_class(&mut self.env, &activity, client_class_name.clone())?; + let webview_client = self.env.new_object( + &rust_webview_client_class, + format!("(L{webview_class_name};Landroid/content/Context;)V"), + &[(&webview).into(), (&activity).into()], + )?; + self.env.call_method( + &webview, + "setWebViewClient", + "(Landroid/webkit/WebViewClient;)V", + &[(&webview_client).into()], + )?; + // Create and set webchrome client + let rust_webchrome_client_class = find_class( + &mut self.env, + &activity, + format!("{package}/RustWebChromeClient"), + )?; + let web_chrome_client = self.env.new_object( + &rust_webchrome_client_class, + format!("(L{package}/WryActivity;Ljava/lang/String;)V"), + &[(&activity).into(), (&id).into()], + )?; + self.env.call_method( + &webview, + "setWebChromeClient", + "(Landroid/webkit/WebChromeClient;)V", + &[(&web_chrome_client).into()], + )?; + + // Add javascript interface (IPC) + let ipc_class = find_class(&mut self.env, &activity, format!("{package}/Ipc"))?; + let ipc = self.env.new_object( + ipc_class, + format!("(L{webview_class_name};L{client_class_name};)V"), + &[(&webview).into(), (&webview_client).into()], + )?; + let ipc_str = self.env.new_string("ipc")?; + self.env.call_method( + &webview, + "addJavascriptInterface", + "(Ljava/lang/Object;Ljava/lang/String;)V", + &[(&ipc).into(), (&ipc_str).into()], + )?; + + // Set content view + self.env.call_method( + &activity, + "setContentView", + "(Landroid/view/View;)V", + &[(&webview).into()], + )?; + + if let Some(on_webview_created) = on_webview_created { + if let Err(_e) = on_webview_created(super::Context { + env: &mut self.env, + activity: &activity, + webview: &webview, + }) { + #[cfg(feature = "tracing")] + tracing::warn!("failed to run webview created hook: {_e}"); + } + } + + let webview = self.env.new_global_ref(webview)?; + + ACTIVITY_PROXY + .lock() + .unwrap() + .get_mut(&activity_id) + .unwrap() + .webview + .replace(webview); + } + WebViewMessage::Eval(script, callback) => { + if let Some(webview) = get_webview(activity_id) { + let id = EVAL_ID_GENERATOR.next() as i32; + + #[cfg(feature = "tracing")] + let span = std::sync::Mutex::new(Some(SendEnteredSpan( + tracing::debug_span!("wry::eval").entered(), + ))); + + EVAL_CALLBACKS + .get_or_init(Default::default) + .lock() + .unwrap() + .insert( + id, + Box::new(move |result| { + #[cfg(feature = "tracing")] + span.lock().unwrap().take(); + + if let Some(callback) = &callback { + callback(result); + } + }), + ); + + let s = self.env.new_string(script)?; + self.env.call_method( + webview.as_obj(), + "evalScript", + "(ILjava/lang/String;)V", + &[id.into(), (&s).into()], + )?; + } + } + WebViewMessage::SetBackgroundColor(background_color) => { + if let Some(webview) = get_webview(activity_id) { + set_background_color(&mut self.env, webview.as_obj(), background_color)?; + } + } + WebViewMessage::GetWebViewVersion(tx) => { + if let Some(activity) = activity_proxy(activity_id).map(|p| p.activity) { + match self + .env + .call_method(activity, "getVersion", "()Ljava/lang/String;", &[]) + .and_then(|v| v.l()) + .and_then(|s| { + let s = JString::from(s); + self + .env + .get_string(&s) + .map(|v| v.to_string_lossy().to_string()) + }) { + Ok(version) => { + tx.send(Ok(version)).unwrap(); + } + Err(e) => tx.send(Err(e.into())).unwrap(), + } + } else { + tx.send(Err(Error::ActivityNotFound)).unwrap(); + } + } + WebViewMessage::GetUrl(tx) => { + if let Some(webview) = get_webview(activity_id) { + let url = self + .env + .call_method(webview.as_obj(), "getUrl", "()Ljava/lang/String;", &[]) + .and_then(|v| v.l()) + .and_then(|s| { + let s = JString::from(s); + self + .env + .get_string(&s) + .map(|v| v.to_string_lossy().to_string()) + }) + .unwrap_or_default(); + + tx.send(url).unwrap() + } + } + WebViewMessage::Jni(f) => { + match activity_proxy(activity_id).map(|p| (p.activity.clone(), p.webview)) { + Some((activity, Some(webview))) => { + f(&mut self.env, &activity, webview.as_obj()); + } + Some((activity, None)) => { + f(&mut self.env, &activity, &JObject::null()); + } + _ => { + f(&mut self.env, &JObject::null(), &JObject::null()); + } + } + } + WebViewMessage::LoadUrl(url, headers) => { + if let Some(webview) = get_webview(activity_id) { + let url = self.env.new_string(url)?; + load_url(&mut self.env, webview.as_obj(), &url, headers, false)?; + } + } + WebViewMessage::ClearAllBrowsingData => { + if let Some(webview) = get_webview(activity_id) { + self + .env + .call_method(webview, "clearAllBrowsingData", "()V", &[])?; + } + } + WebViewMessage::LoadHtml(html) => { + if let Some(webview) = get_webview(activity_id) { + let html = self.env.new_string(html)?; + load_html(&mut self.env, webview.as_obj(), &html)?; + } + } + WebViewMessage::Reload => { + if let Some(webview) = get_webview(activity_id) { + reload(&mut self.env, webview.as_obj())?; + } + } + WebViewMessage::GoForward => { + if let Some(webview) = get_webview(activity_id) { + go_forward(&mut self.env, webview.as_obj())?; + } + } + WebViewMessage::GoBack => { + if let Some(webview) = get_webview(activity_id) { + go_back(&mut self.env, webview.as_obj())?; + } + } + WebViewMessage::CanGoForward(tx) => { + if let Some(webview) = get_webview(activity_id) { + tx.send(can_go_forward(&mut self.env, webview.as_obj())?) + .unwrap(); + } + } + WebViewMessage::CanGoBack(tx) => { + if let Some(webview) = get_webview(activity_id) { + tx.send(can_go_back(&mut self.env, webview.as_obj())?) + .unwrap(); + } + } + WebViewMessage::GetCookies(tx, url) => { + if let Some(webview) = get_webview(activity_id) { + let url = self.env.new_string(url)?; + let cookies = self + .env + .call_method( + webview, + "getCookies", + "(Ljava/lang/String;)Ljava/lang/String;", + &[(&url).into()], + ) + .and_then(|v| v.l()) + .and_then(|s| { + let s = JString::from(s); + self + .env + .get_string(&s) + .map(|v| v.to_string_lossy().to_string()) + }) + .unwrap_or_default(); + + tx.send( + cookies + .split("; ") + .flat_map(|c| cookie::Cookie::parse(c.to_string())) + .collect(), + ) + .unwrap(); + } + } + WebViewMessage::OnDestroy { + activity_id, + webview_id, + is_changing_configurations, + } => { + // keep our webview references (callbacks etc) alive if the activity is going to be recreated due to configuration changes + // e.g. rotation, multi-window mode change, etc + if !is_changing_configurations { + super::destroy_webview(activity_id, &webview_id); + remove_activity_proxy(activity_id); + } + } + } + Ok(()) + } +} + +fn load_url<'a>( + env: &mut JNIEnv<'a>, + webview: &JObject<'a>, + url: &JString<'a>, + headers: Option, + main_thread: bool, +) -> JniResult<()> { + let function = if main_thread { + "loadUrlMainThread" + } else { + "loadUrl" + }; + if let Some(headers) = headers { + let obj = env.new_object("java/util/HashMap", "()V", &[])?; + let headers_map = { + let headers_map = JMap::from_env(env, &obj)?; + for (name, value) in headers.iter() { + let key = env.new_string(name)?; + let value = env.new_string(value.to_str().unwrap_or_default())?; + headers_map.put(env, &key, &value)?; + } + headers_map + }; + env.call_method( + webview, + function, + "(Ljava/lang/String;Ljava/util/Map;)V", + &[url.into(), (&headers_map).into()], + )?; + } else { + env.call_method(webview, function, "(Ljava/lang/String;)V", &[url.into()])?; + } + Ok(()) +} + +fn load_html<'a>(env: &mut JNIEnv<'a>, webview: &JObject<'a>, html: &JString<'a>) -> JniResult<()> { + env.call_method( + webview, + "loadHTMLMainThread", + "(Ljava/lang/String;)V", + &[html.into()], + )?; + Ok(()) +} + +fn reload<'a>(env: &mut JNIEnv<'a>, webview: &JObject<'a>) -> JniResult<()> { + env.call_method(webview, "reload", "()V", &[])?; + Ok(()) +} + +fn go_forward<'a>(env: &mut JNIEnv<'a>, webview: &JObject<'a>) -> JniResult<()> { + env.call_method(webview, "goForward", "()V", &[])?; + Ok(()) +} + +fn go_back<'a>(env: &mut JNIEnv<'a>, webview: &JObject<'a>) -> JniResult<()> { + env.call_method(webview, "goBack", "()V", &[])?; + Ok(()) +} + +fn can_go_forward<'a>(env: &mut JNIEnv<'a>, webview: &JObject<'a>) -> JniResult { + Ok(env.call_method(webview, "canGoForward", "()Z", &[])?.z()?) +} + +fn can_go_back<'a>(env: &mut JNIEnv<'a>, webview: &JObject<'a>) -> JniResult { + Ok(env.call_method(webview, "canGoBack", "()Z", &[])?.z()?) +} + +fn set_background_color<'a>( + env: &mut JNIEnv<'a>, + webview: &JObject<'a>, + (r, g, b, a): RGBA, +) -> JniResult<()> { + let color = (a as i32) << 24 | (r as i32) << 16 | (g as i32) << 8 | (b as i32); + env.call_method(webview, "setBackgroundColor", "(I)V", &[color.into()])?; + Ok(()) +} + +pub(crate) enum WebViewMessage { + CreateWebView(CreateWebViewAttributes), + Eval(String, Option), + SetBackgroundColor(RGBA), + GetWebViewVersion(Sender>), + GetUrl(Sender), + GetCookies(Sender>>, String), + Jni(Box), + LoadUrl(String, Option), + LoadHtml(String), + Reload, + GoForward, + GoBack, + CanGoForward(Sender), + CanGoBack(Sender), + ClearAllBrowsingData, + OnDestroy { + activity_id: ActivityId, + webview_id: WebviewId, + is_changing_configurations: bool, + }, +} + +#[derive(Clone)] +pub(crate) struct CreateWebViewAttributes { + pub id: String, + pub url: Option, + pub html: Option, + #[cfg(any(debug_assertions, feature = "devtools"))] + pub devtools: bool, + pub transparent: bool, + pub background_color: Option, + pub headers: Option, + pub autoplay: bool, + pub on_webview_created: + Option JniResult<()> + Send + Sync + 'static>>, + pub user_agent: Option, + pub initialization_scripts: Vec, + pub javascript_disabled: bool, +} + +// SAFETY: only use this when you are sure the span will be dropped on the same thread it was entered +#[cfg(feature = "tracing")] +struct SendEnteredSpan(tracing::span::EnteredSpan); + +#[cfg(feature = "tracing")] +unsafe impl Send for SendEnteredSpan {} diff --git a/third_party/wry/src/android/mod.rs b/third_party/wry/src/android/mod.rs new file mode 100644 index 0000000000..00cd1e6abe --- /dev/null +++ b/third_party/wry/src/android/mod.rs @@ -0,0 +1,550 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use super::{PageLoadEvent, WebViewAttributes, RGBA}; +use crate::{ + custom_protocol_workaround, inject_initialization_scripts::inject_scripts_into_html, Error, + PermissionKind, PermissionResponse, RequestAsyncResponder, Result, +}; +use crossbeam_channel::*; + +use http::{Request, Response as HttpResponse}; +use jni::{ + errors::Result as JniResult, + objects::{GlobalRef, JClass, JObject}, + JNIEnv, +}; +use ndk::looper::ThreadLooper; +use once_cell::sync::{Lazy, OnceCell}; +use raw_window_handle::HasWindowHandle; +use std::{ + borrow::Cow, + collections::HashMap, + sync::{mpsc::channel, Arc, Mutex}, + time::Duration, +}; + +pub(crate) mod binding; +mod main_pipe; +use main_pipe::{ + activity_id_for_window_manager, first_activity_id, register_activity_proxy, ActivityId, + CreateWebViewAttributes, MainPipe, WebViewMessage, +}; + +use crate::util::Counter; + +static COUNTER: Counter = Counter::new(); +const MAIN_PIPE_TIMEOUT: Duration = Duration::from_secs(10); + +pub struct Context<'a, 'b> { + pub env: &'a mut JNIEnv<'b>, + pub activity: &'a JObject<'b>, + pub webview: &'a JObject<'b>, +} + +type WebviewId = String; + +macro_rules! define_static_handlers { + ($($key: ident, $var:ident = $type_name:ident);+ $(;)?) => { + $(static $var: Lazy>> = Lazy::new(||Mutex::new(HashMap::new()));)* + }; + + ($($var:ident = $type_name:ident { $($fields:ident:$types:ty),+ $(,)? });+ $(;)?) => { + $( + static $var: Lazy>> = Lazy::new(||Mutex::new(HashMap::new())); + struct $type_name { + $($fields: $types,)* + } + impl $type_name { + fn new($($fields: $types,)*) -> Self { + Self { + $($fields,)* + } + } + } + unsafe impl Send for $type_name {} + unsafe impl Sync for $type_name {})* + }; +} + +define_static_handlers! { + IPC = UnsafeIpc { handler: Box)> }; + REQUEST_HANDLER = UnsafeRequestHandler { handler: Arc>, bool) -> Option>> + Send + Sync> }; + TITLE_CHANGE_HANDLER = UnsafeTitleHandler { handler: Box }; + URL_LOADING_OVERRIDE = UnsafeUrlLoadingOverride { handler: Box bool> }; + ON_LOAD_HANDLER = UnsafeOnPageLoadHandler { handler: Box }; + PERMISSION_HANDLER = UnsafePermissionHandler { handler: Box PermissionResponse> }; +} +define_static_handlers! { + WebviewId, WITH_ASSET_LOADER = bool; + WebviewId, ASSET_LOADER_DOMAIN = String; + ActivityId, WEBVIEW_ATTRIBUTES = CreateWebViewAttributes; +} + +static PACKAGE: OnceCell = OnceCell::new(); + +type EvalCallback = Box; + +static EVAL_ID_GENERATOR: Counter = Counter::new(); +static EVAL_CALLBACKS: OnceCell>> = OnceCell::new(); + +pub fn destroy_webview(activity_id: ActivityId, webview_id: &WebviewId) { + WEBVIEW_ATTRIBUTES.lock().unwrap().remove(&activity_id); + IPC.lock().unwrap().remove(webview_id); + REQUEST_HANDLER.lock().unwrap().remove(webview_id); + TITLE_CHANGE_HANDLER.lock().unwrap().remove(webview_id); + URL_LOADING_OVERRIDE.lock().unwrap().remove(webview_id); + ON_LOAD_HANDLER.lock().unwrap().remove(webview_id); + PERMISSION_HANDLER.lock().unwrap().remove(webview_id); + WITH_ASSET_LOADER.lock().unwrap().remove(webview_id); + ASSET_LOADER_DOMAIN.lock().unwrap().remove(webview_id); +} + +/// Sets up the necessary logic for wry to be able to create the webviews later. +/// +/// This function must be run on the thread where the [`JNIEnv`] is registered and the looper is local, +/// hence the requirement for a [`ThreadLooper`]. +/// +/// When used with tao, this is usually passed in like +/// `tao::android_binding!($domain, $app_name, $activity, android_setup, $main)` +/// to fill in `on_activity_create` which is run at the end of `onCreate` of an activiy +pub unsafe fn android_setup( + package: &str, + mut env: JNIEnv, + _looper: &ThreadLooper, + activity: GlobalRef, +) { + PACKAGE.get_or_init(|| package.to_string()); + + let vm = env.get_java_vm().unwrap(); + + let activity_id = env + .call_method(activity.as_obj(), "getId", "()I", &[]) + .unwrap() + .i() + .unwrap(); + + let window_manager = env + .call_method( + &activity, + "getWindowManager", + "()Landroid/view/WindowManager;", + &[], + ) + .unwrap() + .l() + .unwrap(); + let window_manager = env.new_global_ref(window_manager).unwrap(); + + register_activity_proxy(vm, activity_id, activity, window_manager); + + if let Some(webview_attributes) = WEBVIEW_ATTRIBUTES.lock().unwrap().get(&activity_id) { + MainPipe::send( + activity_id, + WebViewMessage::CreateWebView(webview_attributes.clone()), + ); + } +} + +pub(crate) struct InnerWebView { + id: String, + pub activity_id: ActivityId, +} + +impl InnerWebView { + pub fn new_as_child( + window: &impl HasWindowHandle, + attributes: WebViewAttributes, + pl_attrs: super::PlatformSpecificWebViewAttributes, + ) -> Result { + Self::new(window, attributes, pl_attrs) + } + + pub fn new( + window: &impl HasWindowHandle, + attributes: WebViewAttributes, + pl_attrs: super::PlatformSpecificWebViewAttributes, + ) -> Result { + let window_manager = match window.window_handle()?.as_raw() { + raw_window_handle::RawWindowHandle::AndroidNdk(window_manager) => { + window_manager.a_native_window + } + _ => return Err(Error::UnsupportedWindowHandle), + }; + let window_manager = unsafe { JObject::from_raw(window_manager.as_ptr().cast()) }; + let activity_id = + activity_id_for_window_manager(window_manager).expect("no available activity"); + let WebViewAttributes { + url, + html, + initialization_scripts, + ipc_handler, + #[cfg(any(debug_assertions, feature = "devtools"))] + devtools, + custom_protocols, + background_color, + transparent, + headers, + autoplay, + user_agent, + javascript_disabled, + .. + } = attributes; + + let super::PlatformSpecificWebViewAttributes { + on_webview_created, + with_asset_loader, + asset_loader_domain, + https_scheme, + } = pl_attrs; + + let http_or_https = if https_scheme { "https" } else { "http" }; + + let url = if let Some(mut url) = url { + if let Some((protocol, _)) = url.split_once("://") { + if custom_protocols.contains_key(protocol) { + url = custom_protocol_workaround::apply_uri_work_around(&url, http_or_https, protocol) + } + } + + Some(url) + } else { + None + }; + + let id = attributes + .id + .map(|id| id.to_string()) + .unwrap_or_else(|| COUNTER.next().to_string()); + + WITH_ASSET_LOADER + .lock() + .unwrap() + .insert(id.clone(), with_asset_loader); + if let Some(domain) = asset_loader_domain { + ASSET_LOADER_DOMAIN + .lock() + .unwrap() + .insert(id.clone(), domain); + } + + let initialization_scripts_ = initialization_scripts.clone(); + REQUEST_HANDLER.lock().unwrap().insert( + id.clone(), + UnsafeRequestHandler::new(Arc::new( + move |webview_id, mut request, is_document_start_script_enabled| { + let uri = request.uri().to_string(); + let (custom_protocol, custom_protocol_handler) = + custom_protocols.iter().find(|(protocol, _)| { + custom_protocol_workaround::is_work_around_uri(&uri, http_or_https, protocol) + })?; + + let uri_res = custom_protocol_workaround::revert_uri_work_around( + &uri, + http_or_https, + custom_protocol, + ) + .parse(); + + if let Ok(uri) = uri_res { + *request.uri_mut() = uri; + } + + let (tx, rx) = channel(); + let initialization_scripts = initialization_scripts_.clone(); + let responder: Box>)> = Box::new( + move |mut response| { + if !is_document_start_script_enabled { + #[cfg(feature = "tracing")] + tracing::info!("`addDocumentStartJavaScript` is not supported; injecting initialization scripts via custom protocol handler"); + response = inject_scripts_into_html(response, &initialization_scripts); + } + let _ = tx.send(response); + }, + ); + + (custom_protocol_handler)(webview_id, request, RequestAsyncResponder { responder }); + // 3x the timeout while we monitor https://github.com/tauri-apps/wry/issues/1551 + // TODO: Remove timeout + rx.recv_timeout(MAIN_PIPE_TIMEOUT * 3) + .inspect_err(|e| { + eprintln!("custom protocol timed out: {e}"); + }) + .ok() + }, + )), + ); + + if let Some(i) = ipc_handler { + IPC + .lock() + .unwrap() + .insert(id.clone(), UnsafeIpc::new(Box::new(i))); + } + + if let Some(i) = attributes.document_title_changed_handler { + TITLE_CHANGE_HANDLER + .lock() + .unwrap() + .insert(id.clone(), UnsafeTitleHandler::new(i)); + } + + if let Some(i) = attributes.navigation_handler { + URL_LOADING_OVERRIDE + .lock() + .unwrap() + .insert(id.clone(), UnsafeUrlLoadingOverride::new(i)); + } + + if let Some(h) = attributes.on_page_load_handler { + ON_LOAD_HANDLER + .lock() + .unwrap() + .insert(id.clone(), UnsafeOnPageLoadHandler::new(h)); + } + + if let Some(permission_handler) = attributes.permission_handler { + let permission_handler: Box PermissionResponse> = + permission_handler; + PERMISSION_HANDLER + .lock() + .unwrap() + .insert(id.clone(), UnsafePermissionHandler::new(permission_handler)); + } + + let attributes = CreateWebViewAttributes { + id: id.clone(), + url, + html, + #[cfg(any(debug_assertions, feature = "devtools"))] + devtools, + background_color, + transparent, + headers, + on_webview_created, + autoplay, + user_agent, + initialization_scripts, + javascript_disabled, + }; + + WEBVIEW_ATTRIBUTES + .lock() + .unwrap() + .insert(activity_id, attributes.clone()); + + MainPipe::send(activity_id, WebViewMessage::CreateWebView(attributes)); + + Ok(Self { id, activity_id }) + } + + pub fn print(&self) -> crate::Result<()> { + Ok(()) + } + + pub fn id(&self) -> crate::WebViewId<'_> { + &self.id + } + + pub fn url(&self) -> crate::Result { + let (tx, rx) = bounded(1); + MainPipe::send(self.activity_id, WebViewMessage::GetUrl(tx)); + rx.recv_timeout(MAIN_PIPE_TIMEOUT).map_err(Into::into) + } + + pub fn eval(&self, js: &str, callback: Option) -> Result<()> { + MainPipe::send( + self.activity_id, + WebViewMessage::Eval( + js.into(), + callback.map(|c| Box::new(c) as Box), + ), + ); + Ok(()) + } + + #[cfg(any(debug_assertions, feature = "devtools"))] + pub fn open_devtools(&self) {} + + #[cfg(any(debug_assertions, feature = "devtools"))] + pub fn close_devtools(&self) {} + + #[cfg(any(debug_assertions, feature = "devtools"))] + pub fn is_devtools_open(&self) -> bool { + false + } + + pub fn zoom(&self, _scale_factor: f64) -> Result<()> { + Ok(()) + } + + pub fn set_background_color(&self, background_color: RGBA) -> Result<()> { + MainPipe::send( + self.activity_id, + WebViewMessage::SetBackgroundColor(background_color), + ); + Ok(()) + } + + pub fn load_url(&self, url: &str) -> Result<()> { + MainPipe::send( + self.activity_id, + WebViewMessage::LoadUrl(url.to_string(), None), + ); + Ok(()) + } + + pub fn load_url_with_headers(&self, url: &str, headers: http::HeaderMap) -> Result<()> { + MainPipe::send( + self.activity_id, + WebViewMessage::LoadUrl(url.to_string(), Some(headers)), + ); + Ok(()) + } + + pub fn load_html(&self, html: &str) -> Result<()> { + MainPipe::send(self.activity_id, WebViewMessage::LoadHtml(html.to_string())); + Ok(()) + } + + pub fn reload(&self) -> Result<()> { + MainPipe::send(self.activity_id, WebViewMessage::Reload); + Ok(()) + } + + pub fn go_forward(&self) -> Result<()> { + MainPipe::send(self.activity_id, WebViewMessage::GoForward); + Ok(()) + } + + pub fn go_back(&self) -> Result<()> { + MainPipe::send(self.activity_id, WebViewMessage::GoBack); + Ok(()) + } + + pub fn can_go_forward(&self) -> Result { + let (tx, rx) = bounded(1); + MainPipe::send(self.activity_id, WebViewMessage::CanGoForward(tx)); + rx.recv_timeout(MAIN_PIPE_TIMEOUT).map_err(Into::into) + } + + pub fn can_go_back(&self) -> Result { + let (tx, rx) = bounded(1); + MainPipe::send(self.activity_id, WebViewMessage::CanGoBack(tx)); + rx.recv_timeout(MAIN_PIPE_TIMEOUT).map_err(Into::into) + } + + pub fn clear_all_browsing_data(&self) -> Result<()> { + MainPipe::send(self.activity_id, WebViewMessage::ClearAllBrowsingData); + Ok(()) + } + + pub fn cookies_for_url(&self, url: &str) -> Result>> { + let (tx, rx) = bounded(1); + MainPipe::send( + self.activity_id, + WebViewMessage::GetCookies(tx, url.to_string()), + ); + rx.recv_timeout(MAIN_PIPE_TIMEOUT).map_err(Into::into) + } + + pub fn set_cookie(&self, #[allow(unused)] cookie: &cookie::Cookie<'_>) -> Result<()> { + // Unsupported + Ok(()) + } + + pub fn delete_cookie(&self, #[allow(unused)] cookie: &cookie::Cookie<'_>) -> Result<()> { + // Unsupported + Ok(()) + } + + pub fn cookies(&self) -> Result>> { + Ok(Vec::new()) + } + + pub fn bounds(&self) -> Result { + Ok(crate::Rect::default()) + } + + pub fn set_bounds(&self, _bounds: crate::Rect) -> Result<()> { + // Unsupported + Ok(()) + } + + pub fn set_visible(&self, _visible: bool) -> Result<()> { + // Unsupported + Ok(()) + } + + pub fn focus(&self) -> Result<()> { + // Unsupported + Ok(()) + } + + pub fn focus_parent(&self) -> Result<()> { + // Unsupported + Ok(()) + } +} + +#[derive(Clone, Copy)] +pub struct JniHandle { + pub(crate) activity_id: ActivityId, +} + +impl JniHandle { + /// Execute jni code on the thread of the webview. + /// Provided function will be provided with the jni evironment, Android activity and WebView + pub fn exec(&self, func: F) + where + F: FnOnce(&mut JNIEnv, &JObject, &JObject) + Send + 'static, + { + MainPipe::send(self.activity_id, WebViewMessage::Jni(Box::new(func))); + } +} + +pub fn platform_webview_version() -> Result { + let (tx, rx) = bounded(1); + let activity_id = loop { + match first_activity_id() { + Some(id) => break id, + None => { + std::thread::sleep(Duration::from_millis(100)); + } + } + }; + MainPipe::send(activity_id, WebViewMessage::GetWebViewVersion(tx)); + rx.recv_timeout(MAIN_PIPE_TIMEOUT)? +} + +/// Finds a class in the project scope. +pub fn find_class<'a>( + env: &mut JNIEnv<'a>, + activity: &JObject<'_>, + name: String, +) -> JniResult> { + let class_name = env.new_string(name.replace('/', "."))?; + let my_class = env + .call_method( + activity, + "getAppClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[(&class_name).into()], + )? + .l()?; + Ok(my_class.into()) +} + +/// Dispatch a closure to run on the Android context. +/// +/// The closure takes the JNI env, the Android activity instance and the possibly null webview. +pub fn dispatch(func: F) +where + F: FnOnce(&mut JNIEnv, &JObject, &JObject) + Send + 'static, +{ + MainPipe::send( + first_activity_id().expect("no available activity"), + WebViewMessage::Jni(Box::new(func)), + ); +} diff --git a/third_party/wry/src/custom_protocol_workaround.rs b/third_party/wry/src/custom_protocol_workaround.rs new file mode 100644 index 0000000000..122b1341dd --- /dev/null +++ b/third_party/wry/src/custom_protocol_workaround.rs @@ -0,0 +1,56 @@ +//! - WebView2 supports non-standard protocols only on Windows 10+, so we have to use a workaround. +//! See +//! - On Android, there's no API for registering custom protocols, so this workaround is also used. +//! +//! The process looks like this: +//! +//! 1. Use [`apply_uri_work_around`] to convert the URI we want to navigate to +//! 2. Intercept http(s) requests, test the request URI against [`is_work_around_uri`], +//! if it matches, we apply [`revert_uri_work_around`] to the URI and feed it to the custom protocol handler + +/// If the URI is a work around URI for this protocol which starts with `{http_or_https}://{protocol}.` +pub fn is_work_around_uri(uri: &str, http_or_https: &str, protocol: &str) -> bool { + uri + .strip_prefix(http_or_https) + .and_then(|rest| rest.strip_prefix("://")) + .and_then(|rest| rest.strip_prefix(protocol)) + .and_then(|rest| rest.strip_prefix(".")) + .is_some() +} + +/// Conveting `{protocol}://localhost/abc` to `{http_or_https}://{protocol}.localhost/abc` +pub fn apply_uri_work_around(uri: &str, http_or_https: &str, protocol: &str) -> String { + uri.replace( + &original_uri_prefix(protocol), + &work_around_uri_prefix(http_or_https, protocol), + ) +} + +/// Conveting `{http_or_https}://{protocol}.localhost/abc` back to `{protocol}://localhost/abc` +pub fn revert_uri_work_around(uri: &str, http_or_https: &str, protocol: &str) -> String { + uri.replace( + &work_around_uri_prefix(http_or_https, protocol), + &original_uri_prefix(protocol), + ) +} + +pub fn original_uri_prefix(protocol: &str) -> String { + format!("{protocol}://") +} + +pub fn work_around_uri_prefix(http_or_https: &str, protocol: &str) -> String { + format!("{http_or_https}://{protocol}.") +} + +#[cfg(test)] +mod tests { + use super::is_work_around_uri; + + #[test] + fn checks_if_custom_protocol_uri() { + let scheme = "http"; + let uri = "http://wry.localhost/path/to/page"; + assert!(is_work_around_uri(uri, scheme, "wry")); + assert!(!is_work_around_uri(uri, scheme, "asset")); + } +} diff --git a/third_party/wry/src/error.rs b/third_party/wry/src/error.rs new file mode 100644 index 0000000000..959899311e --- /dev/null +++ b/third_party/wry/src/error.rs @@ -0,0 +1,80 @@ +/// Convenient type alias of Result type for wry. +pub type Result = std::result::Result; + +/// Errors returned by wry. +#[non_exhaustive] +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[cfg(gtk)] + #[error(transparent)] + GlibError(#[from] gtk::glib::Error), + #[cfg(gtk)] + #[error(transparent)] + GlibBoolError(#[from] gtk::glib::BoolError), + #[cfg(gtk)] + #[error("Fail to fetch security manager")] + MissingManager, + #[cfg(gtk)] + #[error("Couldn't find X11 Display")] + X11DisplayNotFound, + #[cfg(all(gtk, feature = "x11"))] + #[error(transparent)] + XlibError(#[from] x11_dl::error::OpenError), + #[error("Failed to initialize the script")] + InitScriptError, + #[error("Bad RPC request: {0} ((1))")] + RpcScriptError(String, String), + #[error(transparent)] + NulError(#[from] std::ffi::NulError), + #[error(transparent)] + ReceiverError(#[from] std::sync::mpsc::RecvError), + #[cfg(target_os = "android")] + #[error(transparent)] + ReceiverTimeoutError(#[from] crossbeam_channel::RecvTimeoutError), + #[error(transparent)] + SenderError(#[from] std::sync::mpsc::SendError), + #[error("Failed to send the message")] + MessageSender, + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[cfg(target_os = "windows")] + #[error("WebView2 error: {0}")] + WebView2Error(webview2_com::Error), + #[error(transparent)] + HttpError(#[from] http::Error), + #[error("Infallible error, something went really wrong: {0}")] + Infallible(#[from] std::convert::Infallible), + #[cfg(target_os = "android")] + #[error(transparent)] + JniError(#[from] jni::errors::Error), + #[error("Failed to create proxy endpoint")] + ProxyEndpointCreationFailed, + #[error(transparent)] + WindowHandleError(#[from] raw_window_handle::HandleError), + #[error("the window handle kind is not supported")] + UnsupportedWindowHandle, + #[error(transparent)] + Utf8Error(#[from] std::str::Utf8Error), + #[cfg(target_os = "android")] + #[error(transparent)] + CrossBeamRecvError(#[from] crossbeam_channel::RecvError), + #[error("not on the main thread")] + NotMainThread, + #[error("Custom protocol task is invalid.")] + CustomProtocolTaskInvalid, + #[error("Failed to register URL scheme: {0}, could be due to invalid URL scheme or the scheme is already registered.")] + UrlSchemeRegisterError(String), + #[error("Duplicate custom protocol '{0}' registered on the WebViewBuilder")] + DuplicateCustomProtocol(String), + #[error("Duplicate custom protocol '{0}' registered on the same web context on Linux")] + ContextDuplicateCustomProtocol(String), + #[error(transparent)] + #[cfg(any(target_os = "macos", target_os = "ios"))] + UrlParse(#[from] url::ParseError), + #[cfg(any(target_os = "macos", target_os = "ios"))] + #[error("data store is currently opened")] + DataStoreInUse, + #[cfg(target_os = "android")] + #[error("Activity not found")] + ActivityNotFound, +} diff --git a/third_party/wry/src/inject_initialization_scripts.rs b/third_party/wry/src/inject_initialization_scripts.rs new file mode 100644 index 0000000000..61636d2043 --- /dev/null +++ b/third_party/wry/src/inject_initialization_scripts.rs @@ -0,0 +1,225 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! This is an internal implementation detail used by the Android backend to inject +//! initialization scripts when `addDocumentStartJavaScript` is not supported. + +use base64::{prelude::BASE64_STANDARD, Engine}; +use dom_query::Document; +use http::{ + header::{HeaderValue, CONTENT_SECURITY_POLICY, CONTENT_TYPE}, + Response as HttpResponse, +}; +use sha2::{Digest, Sha256}; +use std::borrow::Cow; + +use crate::InitializationScript; + +pub fn inject_scripts_into_html( + mut response: HttpResponse>, + scripts: &[InitializationScript], +) -> HttpResponse> { + if scripts.is_empty() { + return response; + } + + let should_inject_scripts = response + .headers() + .get(CONTENT_TYPE) + // Content-Type must begin with the media type, but is case-insensitive. + // It may also be followed by any number of semicolon-delimited key value pairs. + // We don't care about these here. + // source: https://httpwg.org/specs/rfc9110.html#rfc.section.8.3.1 + .and_then(|content_type| content_type.to_str().ok()) + .map(|content_type_str| content_type_str.to_lowercase().starts_with("text/html")) + .unwrap_or_default(); + + if !should_inject_scripts { + return response; + } + + let document = Document::from(String::from_utf8_lossy(response.body()).as_ref()); + let csp = response.headers_mut().get_mut(CONTENT_SECURITY_POLICY); + + // Get or create head element + let head = document.head().unwrap_or_else(|| { + let html = document.html_root(); + let head = document.tree.new_element("head"); + html.prepend_child(&head); + head + }); + + // Iterate in reverse order since we are prepending each script to the head tag + let mut hashes = Vec::new(); + for script in scripts.iter().rev().map(|s| &s.script) { + let script_tag = document.tree.new_element("script"); + script_tag.set_text(script.as_str()); + head.prepend_child(&script_tag); + if csp.is_some() { + hashes.push(hash_script(script)); + } + } + + if let Some(csp) = csp { + let csp_string = csp.to_str().unwrap().to_string(); + let csp_string = if csp_string.contains("script-src") { + csp_string.replace("script-src", &format!("script-src {}", hashes.join(" "))) + } else { + format!("{csp_string} script-src {}", hashes.join(" ")) + }; + *csp = HeaderValue::from_str(&csp_string).unwrap(); + } + + *response.body_mut() = Cow::Owned(document.html().as_bytes().to_vec()); + response +} + +fn hash_script(script: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(script); + let hash = hasher.finalize(); + format!("'sha256-{}'", BASE64_STANDARD.encode(hash)) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::StatusCode; + + #[test] + fn test_no_scripts_returns_original_response() { + let body = "Test"; + + let result = run(body, "text/html", vec![]); + + assert_eq!(result, body); + } + + #[test] + fn test_non_html_response_not_modified() { + let body = r#"{"key": "value"}"#; + let scripts = vec!["console.log('test');".to_string()]; + + let result = run(body, "application/json", scripts); + + assert_eq!(result, body); + } + + #[test] + fn test_inject_single_script() { + let body = "Content"; + let scripts = vec!["console.log('injected');".to_string()]; + + let result = run(body, "text/html", scripts); + + assert_eq!( + result, + "Content" + ); + } + + #[test] + fn test_inject_multiple_scripts() { + let body = "Content"; + let scripts = vec![ + "var first = 1;".to_owned(), + "let second = 2;".to_owned(), + "const third = 3;".to_owned(), + "window.test = () => console.log('test');".to_owned(), + ]; + + let result = run(body, "text/html", scripts); + + assert_eq!( + result, + "Content" + ); + } + + #[test] + fn test_inject_script_creates_head_if_missing() { + let body = "Content"; + let scripts = vec!["console.log('test');".to_string()]; + + let result = run(body, "text/html", scripts); + + assert_eq!( + result, + "Content" + ); + } + + #[test] + fn test_inject_script_creates_html_structure_if_missing() { + let body = "Just some text"; + let scripts = vec!["console.log('test');".to_string()]; + + let result = run(body, "text/html", scripts); + + assert_eq!( + result, + "Just some text" + ); + } + + #[test] + fn test_csp_header_updated_with_script_hashes() { + let body = "Content"; + let mut response = create_response(body, "text/html"); + response.headers_mut().insert( + CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'self'"), + ); + + let script_code = "console.log('test');"; + let scripts = vec![script_code.to_string()]; + + let scripts: Vec = scripts + .into_iter() + .map(|script| InitializationScript { + script, + for_main_frame_only: true, + }) + .collect(); + let result = inject_scripts_into_html(response, &scripts); + let result_body = String::from_utf8_lossy(result.body()).to_string(); + let csp = result.headers().get(CONTENT_SECURITY_POLICY).unwrap(); + let csp_str = csp.to_str().unwrap(); + + assert_eq!( + result_body, + "Content" + ); + assert_eq!( + csp_str, + "default-src 'self' script-src 'sha256-3x8DE279hr8o/Aq0dEdH4WApIwn5rbRKhugPzn6Bofw='" + ); + } + + fn create_response(body: &str, content_type: &'static str) -> HttpResponse> { + let mut response = HttpResponse::builder() + .status(StatusCode::OK) + .body(Cow::Owned(body.as_bytes().to_vec())) + .unwrap(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); + + response + } + + /// Helper function to create a response, inject scripts, and return the body as a string + fn run(body: &str, content_type: &'static str, scripts: Vec) -> String { + let response = create_response(body, content_type); + let scripts: Vec = scripts + .into_iter() + .map(|script| InitializationScript { + script, + for_main_frame_only: true, + }) + .collect(); + let result = inject_scripts_into_html(response, &scripts); + String::from_utf8_lossy(result.body()).to_string() + } +} diff --git a/third_party/wry/src/lib.rs b/third_party/wry/src/lib.rs new file mode 100644 index 0000000000..7995a2c2de --- /dev/null +++ b/third_party/wry/src/lib.rs @@ -0,0 +1,2638 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//!

WRY Webview Rendering library

+//! +//! [![](https://img.shields.io/crates/v/wry?style=flat-square)](https://crates.io/crates/wry) [![](https://img.shields.io/docsrs/wry?style=flat-square)](https://docs.rs/wry/) +//! [![License](https://img.shields.io/badge/License-MIT%20or%20Apache%202-green.svg)](https://opencollective.com/tauri) +//! [![Chat Server](https://img.shields.io/badge/chat-discord-7289da.svg)](https://discord.gg/SpmNs4S) +//! [![website](https://img.shields.io/badge/website-tauri.app-purple.svg)](https://tauri.app) +//! [![https://good-labs.github.io/greater-good-affirmation/assets/images/badge.svg](https://good-labs.github.io/greater-good-affirmation/assets/images/badge.svg)](https://good-labs.github.io/greater-good-affirmation) +//! [![support](https://img.shields.io/badge/sponsor-Open%20Collective-blue.svg)](https://opencollective.com/tauri) +//! +//! Wry is a cross-platform WebView rendering library. +//! +//! The webview requires a running event loop and a window type that implements [`HasWindowHandle`], +//! or a gtk container widget if you need to support X11 and Wayland. +//! You can use a windowing library like [`tao`] or [`winit`]. +//! +//! ## Examples +//! +//! This example leverages the [`HasWindowHandle`] and supports Windows, macOS, iOS, Android and Linux (X11 Only). +//! See the following example using [`winit`]: +//! +//! ```no_run +//! # use wry::{WebViewBuilder, raw_window_handle}; +//! # use winit::{application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, EventLoop}, window::{Window, WindowId}}; +//! #[derive(Default)] +//! struct App { +//! window: Option, +//! webview: Option, +//! } +//! +//! impl ApplicationHandler for App { +//! fn resumed(&mut self, event_loop: &ActiveEventLoop) { +//! let window = event_loop.create_window(Window::default_attributes()).unwrap(); +//! let webview = WebViewBuilder::new() +//! .with_url("https://tauri.app") +//! .build(&window) +//! .unwrap(); +//! +//! self.window = Some(window); +//! self.webview = Some(webview); +//! } +//! +//! fn window_event(&mut self, _event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {} +//! } +//! +//! let event_loop = EventLoop::new().unwrap(); +//! let mut app = App::default(); +//! event_loop.run_app(&mut app).unwrap(); +//! ``` +//! +//! If you also want to support Wayland too, then we recommend you use [`WebViewBuilderExtUnix::build_gtk`] on Linux. +//! See the following example using [`tao`]: +//! +//! ```no_run +//! # use wry::WebViewBuilder; +//! # use tao::{window::WindowBuilder, event_loop::EventLoop}; +//! # #[cfg(target_os = "linux")] +//! # use tao::platform::unix::WindowExtUnix; +//! # #[cfg(target_os = "linux")] +//! # use wry::WebViewBuilderExtUnix; +//! let event_loop = EventLoop::new(); +//! let window = WindowBuilder::new().build(&event_loop).unwrap(); +//! +//! let builder = WebViewBuilder::new().with_url("https://tauri.app"); +//! +//! #[cfg(not(target_os = "linux"))] +//! let webview = builder.build(&window).unwrap(); +//! #[cfg(target_os = "linux")] +//! let webview = builder.build_gtk(window.gtk_window()).unwrap(); +//! ``` +//! +//! ## Child webviews +//! +//! You can use [`WebViewBuilder::build_as_child`] to create the webview as a child inside another window. This is supported on +//! macOS, Windows and Linux (X11 Only). +//! +//! ```no_run +//! # use wry::{WebViewBuilder, raw_window_handle, Rect, dpi::*}; +//! # use winit::{application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, EventLoop}, window::{Window, WindowId}}; +//! #[derive(Default)] +//! struct App { +//! window: Option, +//! webview: Option, +//! } +//! +//! impl ApplicationHandler for App { +//! fn resumed(&mut self, event_loop: &ActiveEventLoop) { +//! let window = event_loop.create_window(Window::default_attributes()).unwrap(); +//! let webview = WebViewBuilder::new() +//! .with_url("https://tauri.app") +//! .with_bounds(Rect { +//! position: LogicalPosition::new(100, 100).into(), +//! size: LogicalSize::new(200, 200).into(), +//! }) +//! .build_as_child(&window) +//! .unwrap(); +//! +//! self.window = Some(window); +//! self.webview = Some(webview); +//! } +//! +//! fn window_event(&mut self, _event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {} +//! } +//! +//! let event_loop = EventLoop::new().unwrap(); +//! let mut app = App::default(); +//! event_loop.run_app(&mut app).unwrap(); +//! ``` +//! +//! If you want to support X11 and Wayland at the same time, we recommend using +//! [`WebViewExtUnix::new_gtk`] or [`WebViewBuilderExtUnix::build_gtk`] with [`gtk::Fixed`]. +//! +//! ```no_run +//! # use wry::{WebViewBuilder, raw_window_handle, Rect, dpi::*}; +//! # use tao::{window::WindowBuilder, event_loop::EventLoop}; +//! # #[cfg(target_os = "linux")] +//! # use wry::WebViewBuilderExtUnix; +//! # #[cfg(target_os = "linux")] +//! # use tao::platform::unix::WindowExtUnix; +//! let event_loop = EventLoop::new(); +//! let window = WindowBuilder::new().build(&event_loop).unwrap(); +//! +//! let builder = WebViewBuilder::new() +//! .with_url("https://tauri.app") +//! .with_bounds(Rect { +//! position: LogicalPosition::new(100, 100).into(), +//! size: LogicalSize::new(200, 200).into(), +//! }); +//! +//! #[cfg(not(target_os = "linux"))] +//! let webview = builder.build_as_child(&window).unwrap(); +//! #[cfg(target_os = "linux")] +//! let webview = { +//! # use gtk::prelude::*; +//! let vbox = window.default_vbox().unwrap(); // tao adds a gtk::Box by default +//! let fixed = gtk::Fixed::new(); +//! fixed.show_all(); +//! vbox.pack_start(&fixed, true, true, 0); +//! builder.build_gtk(&fixed).unwrap() +//! }; +//! ``` +//! +//! ## Platform Considerations +//! +//! Here is the underlying web engine each platform uses, and some dependencies you might need to install. +//! +//! ### Linux +//! +//! [WebKitGTK](https://webkitgtk.org/) is used to provide webviews on Linux which requires GTK, +//! so if the windowing library doesn't support GTK (as in [`winit`]) +//! you'll need to call [`gtk::init`] before creating the webview and then call [`gtk::main_iteration_do`] alongside +//! your windowing library event loop. +//! +//! ```no_run +//! # use wry::{WebView, WebViewBuilder}; +//! # use winit::{application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, EventLoop}, window::{Window, WindowId}}; +//! #[derive(Default)] +//! struct App { +//! webview_window: Option<(Window, WebView)>, +//! } +//! +//! impl ApplicationHandler for App { +//! fn resumed(&mut self, event_loop: &ActiveEventLoop) { +//! let window = event_loop.create_window(Window::default_attributes()).unwrap(); +//! let webview = WebViewBuilder::new() +//! .with_url("https://tauri.app") +//! .build(&window) +//! .unwrap(); +//! +//! self.webview_window = Some((window, webview)); +//! } +//! +//! fn window_event(&mut self, _event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {} +//! +//! // Advance GTK event loop