diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fcf104b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6801213 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI +on: + pull_request: + push: + branches: + - "*" + tags: + - "*" +env: + CEF_VERSION: "136.1.6+g1ac1b14+chromium-136.0.7103.114" + +jobs: + test: + name: Test + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + run_on: macos-latest + - target: x86_64-linux-gnu + run_on: ubuntu-latest + - target: x86_64-windows-msvc + run_on: windows-latest + runs-on: ${{ matrix.run_on }} + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: clippy + - name: Install system dependencies + if: ${{ matrix.run_on != 'windows-latest' }} + run: script/bootstrap + - name: Machete + if: ${{ matrix.run_on == 'macos-latest' }} + uses: bnjbvr/cargo-machete@v0.9.1 + - name: Setup | Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: test-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + - name: Cache CEF + id: cache-cef + uses: actions/cache@v4 + with: + path: $HOME/.cef + key: cef-${{ env.CEF_VERSION }}-${{ matrix.target }} + - name: Init CEF + if: ${{ !steps.cache-cef.outputs.cache-hit }} + run: | + cargo run -p cargo-wef -- wef init + - name: Typo check + if: ${{ matrix.run_on == 'macos-latest' }} + run: | + cargo install typos-cli || echo "typos-cli already installed" + typos + - name: Lint + if: ${{ matrix.run_on == 'macos-latest' }} + run: | + cargo clippy -- --deny warnings + - name: Test Linux + if: ${{ matrix.run_on == 'ubuntu-latest' }} + run: | + cargo run -p cargo-wef -- wef add-framework target/debug + cargo test --all + - name: Test Windows + if: ${{ matrix.run_on == 'windows-latest' }} + run: | + cargo run -p cargo-wef -- wef add-framework target/debug + cargo test --all + - name: Test macOS + if: ${{ matrix.run_on == 'macos-latest' }} + run: | + cargo test --all diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e694a17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +/target/ +Cargo.lock + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.swp +*.swo +*~ + +# CEF files (will be downloaded by cargo-wef) +.cef/ +*.pak +*.dat +*.bin +*.dll +*.so +*.dylib +libcef.* +libEGL.* +libGLESv2.* +icudtl.dat +resources.pak +chrome_*.pak +snapshot_blob.bin +v8_context_snapshot.bin +locales/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cd5d06b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Contributing to Wef + +Thank you for your interest in contributing to Wef! This document provides guidelines and information for contributors. + +## Prerequisites + +Before you can build and test Wef, you'll need: + +## Development Setup + +1. **Clone the repository** + + ```bash + git clone https://github.com/longbridge/wef.git + cd wef + ``` + +2. **Install cargo-wef** + + ```bash + cargo install --path cargo-wef + ``` + +3. **Initialize CEF** + + ```bash + cargo wef init + ``` + + This downloads the Chromium Embedded Framework binaries to `~/.cef` + +## Building and Testing + +### Building + +```bash +# Build everything +cargo wef build + +# Build specific components +cargo build -p wef # Core library +cargo build -p cargo-wef # CLI tool +``` + +### Testing + +```bash +# Run all tests +cargo test --all + +# Test specific components +cargo test -p wef +cargo test -p cargo-wef +``` + +### Running Examples + +```bash +cargo wef run -p wef-example +``` + +## Development Tips + +### CEF Troubleshooting + +If you encounter CEF-related build issues: + +1. **Clear CEF cache** + + ```bash + rm -rf ~/.cef + cargo wef init + ``` + +2. **Check CEF version** + The CEF version is defined in `.github/workflows/ci.yml` + +3. **Verify system dependencies** + Ensure all required system packages are installed + +### Debugging + +- Use `cargo wef run -p wef-winit` to test changes quickly +- Enable debug logging with `RUST_LOG=debug` +- Use the debugger-friendly debug profile for development + +## Getting Help + +- **Documentation**: Check the [README](README.md) and [library docs](wef/README.md) +- **Issues**: Search existing [GitHub issues](https://github.com/longbridge/wef/issues) +- **Discussions**: Use [GitHub Discussions](https://github.com/longbridge/wef/discussions) for questions + +## License + +By contributing to Wef, you agree that your contributions will be licensed under the Apache License 2.0. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ba3b6c0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,64 @@ +[workspace] +members = [ + "crates/wef", + "crates/cargo-wef", + "crates/webview", + "examples/wef-winit", + "examples/wef-example", +] + +default-members = ["crates/wef"] +resolver = "2" + +[workspace.dependencies] +wef = { path = "crates/wef" } +gpui-webview = { path = "crates/webview" } +ropey = { version = "=2.0.0-beta.1", features = ["metric_utf16", "metric_lines_lf"] } + +anyhow = "1" +log = "0.4" +serde = { version = "1.0.219", features = ["derive"] } +serde_repr = "0.1" +serde_json = "1" +schemars = "1" +smallvec = "1" +rust-i18n = "3" +raw-window-handle = "0.6.2" +smol = "1" +tracing = "0.1.41" +notify = "7.0.0" +lsp-types = "0.97.0" + +[workspace.dependencies.windows] +features = ["Wdk", "Wdk_System", "Wdk_System_SystemServices"] +version = "0.58.0" + +[workspace.lints.clippy] +almost_complete_range = "allow" +arc_with_non_send_sync = "allow" +borrowed_box = "allow" +dbg_macro = "deny" +let_underscore_future = "allow" +map_entry = "allow" +module_inception = "allow" +non_canonical_partial_ord_impl = "allow" +reversed_empty_ranges = "allow" +single_range_in_vec_init = "allow" +style = { level = "allow", priority = -1 } +todo = "deny" +type_complexity = "allow" +manual_is_multiple_of = "allow" + +[profile.dev] +codegen-units = 16 +debug = "limited" +split-debuginfo = "unpacked" + +[profile.dev.package] +# Optimize image processing libraries in debug builds for better performance +image = { opt-level = 3 } +png = { opt-level = 3 } +reqwest = { opt-level = 3 } + +[workspace.metadata.typos] +files.extend-exclude = ["**/fixtures/*"] \ No newline at end of file diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..b6ef865 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,191 @@ +Copyright 2024 - 2025 Longbridge + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.md b/README.md index 9bfd3e2..5a88b1e 100644 --- a/README.md +++ b/README.md @@ -1 +1,89 @@ -# wef \ No newline at end of file +# Wef + +> Web Embedding Framework + +![CI](https://github.com/longbridge/wef/workflows/CI/badge.svg) +[![Crates.io](https://img.shields.io/crates/v/wef.svg)](https://crates.io/crates/wef) +[![Documentation](https://docs.rs/wef/badge.svg)](https://docs.rs/wef) + +**Wef** (Web Embedding Framework) is a Rust library for embedding WebView functionality using Chromium Embedded Framework (CEF3) with offscreen rendering support. + +> The `Wef` name is an abbreviation of "Web Embedding Framework", and it's also inspired by Wry. + +![Wef Example](https://github.com/user-attachments/assets/f677ecb4-dbff-4e0d-86b9-203f6e1004a4) + +## Features + +- **Cross-Platform**: Support for Windows, macOS, and Linux +- **CEF3 Integration**: Built on top of Chromium Embedded Framework for reliable web rendering +- **Offscreen Rendering**: Advanced rendering capabilities with offscreen support +- **JavaScript Bridge**: Seamless communication between Rust and JavaScript +- **Multi-Process Architecture**: Leverages CEF's multi-process design for stability +- **Cargo Integration**: Complete toolchain with `cargo-wef` for easy development + +## Quick Start + +### Installation + +Add `wef` to your `Cargo.toml`: + +```toml +[dependencies] +wef = "0.6.0" +``` + +### Install cargo-wef + +```bash +cargo install cargo-wef +``` + +### Initialize CEF + +```bash +cargo wef init +``` + +### Basic Usage + +```rust +use wef::Settings; + +fn main() { + let settings = Settings::new(); + wef::launch(settings, || { + // Your application logic here + }); +} +``` + +## Development + +### Building + +```bash +# Build the library +cargo wef build + +# Run tests +cargo test --all + +# Run an example +cargo wef run -p wef-winit +``` + +### Requirements + +- CEF binary distribution (automatically downloaded by `cargo-wef`) +- Platform-specific dependencies: + - **Linux**: `libglib2.0-dev`, `pkg-config` + - **Windows**: Visual Studio Build Tools + - **macOS**: Xcode Command Line Tools + +## Contributing + +We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. + +## License + +Licensed under the Apache License, Version 2.0. See [LICENSE-APACHE](LICENSE-APACHE) for details. diff --git a/crates/cargo-wef/Cargo.toml b/crates/cargo-wef/Cargo.toml new file mode 100644 index 0000000..b66c432 --- /dev/null +++ b/crates/cargo-wef/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cargo-wef" +version = "0.7.0" +edition = "2024" +authors = ["sunli "] +license = "Apache-2.0" +homepage = "https://github.com/longbridge/wef" +repository = "https://github.com/longbridge/wef" +description = "Cargo-wef is a command line tool for wef" +readme = "../../README.md" + +[dependencies] +clap = { version = "4.5.38", features = ["derive", "env"] } +plist = "1.7.1" +tempfile = "3.20.0" +anyhow = "1.0.98" +askama = { version = "0.14.0", features = ["code-in-doc"] } +reqwest = { version = "0.12.15", features = ["blocking", "json"] } +serde = { version = "1.0.219", features = ["derive"] } +fs_extra = "1.3.0" +tar = "0.4.44" +bzip2 = "0.5.2" +indicatif = "0.17.7" +dirs = "6.0.0" +cargo_metadata = "0.20.0" +serde_json = "1.0.140" +image = "0.25.6" +icns = "0.3.1" + +[lints] +workspace = true \ No newline at end of file diff --git a/crates/cargo-wef/src/commands/add_framework.rs b/crates/cargo-wef/src/commands/add_framework.rs new file mode 100644 index 0000000..05c2220 --- /dev/null +++ b/crates/cargo-wef/src/commands/add_framework.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; + +use anyhow::Result; + +#[allow(unused_variables)] +pub(crate) fn add_framework( + app_path: PathBuf, + release: bool, + force: bool, + wef_version: Option, + wef_path: Option, +) -> Result<()> { + let cef_root = crate::internal::find_cef_root(); + crate::internal::add_cef_framework(&cef_root, &app_path, release, force)?; + + #[cfg(target_os = "macos")] + crate::internal::add_helper( + &app_path, + wef_version.as_deref(), + wef_path.as_deref(), + release, + force, + )?; + Ok(()) +} diff --git a/crates/cargo-wef/src/commands/build.rs b/crates/cargo-wef/src/commands/build.rs new file mode 100644 index 0000000..b56d556 --- /dev/null +++ b/crates/cargo-wef/src/commands/build.rs @@ -0,0 +1,713 @@ +use std::{ + ffi::OsStr, + fs::File, + io::BufWriter, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::{Context, Result}; +use askama::Template; +use cargo_metadata::{Metadata, MetadataCommand}; +use icns::IconFamily; +use image::GenericImageView; + +use crate::internal::{InfoPlist, add_cef_framework, add_helper}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BinaryKind { + Bin, + Example, +} + +#[derive(Debug)] +struct BinaryInfo { + metadata: serde_json::Value, + package_name: String, + package_path: PathBuf, + target_name: String, + kind: BinaryKind, + version: String, +} + +fn execute_path( + metadata: &Metadata, + target_dir: &Path, + package: Option<&str>, + bin: Option<&str>, + example: Option<&str>, +) -> Result<(PathBuf, BinaryInfo)> { + let packages = if let Some(package_name) = package { + vec![ + metadata + .workspace_packages() + .into_iter() + .find(|package| package.name.as_str() == package_name) + .ok_or_else(|| { + anyhow::anyhow!("No package `{}` found in the workspace", package_name) + })?, + ] + } else if metadata.workspace_default_members.is_available() { + metadata.workspace_default_packages() + } else { + metadata.workspace_packages() + }; + + let (package, target, binary_kind) = if let Some(bin_name) = bin { + packages + .iter() + .find_map(|package| { + package + .targets + .iter() + .find(|target| target.is_bin() && target.name == bin_name) + .map(|target| (*package, target, BinaryKind::Bin)) + }) + .ok_or_else(|| anyhow::anyhow!("no bin target named `{}`", bin_name))? + } else if let Some(example_name) = example { + packages + .iter() + .find_map(|package| { + package + .targets + .iter() + .find(|target| target.is_example() && target.name == example_name) + .map(|target| (*package, target, BinaryKind::Example)) + }) + .ok_or_else(|| anyhow::anyhow!("no example target named `{}`", example_name))? + } else { + let mut bin_targets = packages + .iter() + .flat_map(|package| { + package + .targets + .iter() + .filter_map(|target| target.is_bin().then_some((*package, target))) + }) + .collect::>(); + anyhow::ensure!(!bin_targets.is_empty(), "a bin target must be available"); + anyhow::ensure!(bin_targets.len() == 1, "could not determine which binary"); + let (package, target) = bin_targets.remove(0); + (package, target, BinaryKind::Bin) + }; + + let exec_path = match std::env::consts::OS { + "macos" => Ok(target_dir.join(&target.name)), + "windows" => Ok(target_dir.join(&target.name).with_extension("exe")), + "linux" => Ok(target_dir.join(&target.name)), + _ => Err(anyhow::anyhow!( + "Unsupported platform: {}", + std::env::consts::OS + )), + }; + Ok(( + exec_path?, + BinaryInfo { + metadata: package.metadata.clone(), + package_name: package.name.to_string(), + package_path: package + .manifest_path + .parent() + .unwrap() + .to_path_buf() + .into_std_path_buf(), + target_name: target.name.clone(), + kind: binary_kind, + version: package.version.to_string(), + }, + )) +} + +fn find_bundle_settings( + binary_info: &BinaryInfo, + bundle_type: Option<&str>, +) -> Result> { + let config = match binary_info.kind { + BinaryKind::Bin => { + let mut config = binary_info + .metadata + .pointer(&format!("/bundle/bin/{}", binary_info.target_name)); + if config.is_none() && binary_info.target_name == binary_info.package_name { + config = binary_info.metadata.pointer("/bundle"); + } + config + } + BinaryKind::Example => binary_info + .metadata + .pointer(&format!("/bundle/example/{}", binary_info.target_name)), + }; + + let config = if let Some(bundle_type) = bundle_type { + Some(config.and_then(|c| c.get(bundle_type)).ok_or_else(|| { + anyhow::anyhow!("No bundle settings found for type `{}`", bundle_type) + })?) + } else { + config + }; + + config + .map(|config| { + let mut config: InfoPlist = serde_json::from_value(config.clone())?; + if config.bundle_short_version.is_none() { + config.bundle_short_version = Some(binary_info.version.clone()); + } + Ok::<_, anyhow::Error>(config) + }) + .transpose() + .context("parse bundle settings") +} + +fn create_plist(binary_info: &BinaryInfo, bundle_type: Option<&str>) -> Result { + let plist = find_bundle_settings(binary_info, bundle_type)?.unwrap_or_else(|| { + println!("Bundle settings is not found, fallback to default settings"); + let mut plist = InfoPlist::new( + &binary_info.target_name, + format!("io.github.wef.{}", &binary_info.target_name), + ); + plist.bundle_short_version = Some(binary_info.version.clone()); + plist + }); + Ok(plist) +} + +fn create_icns_file( + package_path: &Path, + resources_dir: &Path, + plist: &mut InfoPlist, +) -> Result<()> { + if plist.icons.is_empty() { + return Ok(()); + } + + for icon_path in &plist.icons { + let icon_path = package_path.join(icon_path); + if icon_path.extension() == Some(OsStr::new("icns")) { + std::fs::create_dir(resources_dir)?; + + let target_path = resources_dir.join(&plist.name).with_extension("icns"); + std::fs::copy(&icon_path, &target_path).with_context(|| { + format!("copy {} to {}", icon_path.display(), target_path.display()) + })?; + plist.icon = Some(format!("{}.icns", plist.name)); + return Ok(()); + } + } + + let mut family = IconFamily::new(); + + fn make_icns_image(img: image::DynamicImage) -> Result { + let pixel_format = match img.color() { + image::ColorType::Rgba8 => icns::PixelFormat::RGBA, + image::ColorType::Rgb8 => icns::PixelFormat::RGB, + image::ColorType::La8 => icns::PixelFormat::GrayAlpha, + image::ColorType::L8 => icns::PixelFormat::Gray, + _ => { + anyhow::bail!("Unsupported image color type: {:?}", img.color()); + } + }; + Ok(icns::Image::from_data( + pixel_format, + img.width(), + img.height(), + img.into_bytes(), + )?) + } + + fn add_icon_to_family( + icon: image::DynamicImage, + density: u32, + family: &mut icns::IconFamily, + ) -> Result<()> { + // Try to add this image to the icon family. Ignore images whose sizes + // don't map to any ICNS icon type; print warnings and skip images that + // fail to encode. + match icns::IconType::from_pixel_size_and_density(icon.width(), icon.height(), density) { + Some(icon_type) => { + if !family.has_icon_with_type(icon_type) { + let icon = make_icns_image(icon)?; + family.add_icon_with_type(&icon, icon_type)?; + } + Ok(()) + } + None => anyhow::bail!("No matching IconType"), + } + } + + fn is_retina(path: &Path) -> bool { + path.file_stem() + .and_then(OsStr::to_str) + .map(|stem| stem.ends_with("@2x")) + .unwrap_or(false) + } + + let mut images_to_resize: Vec<(image::DynamicImage, u32, u32)> = vec![]; + for icon_path in &plist.icons { + let icon_path = package_path.join(icon_path); + let icon = image::open(&icon_path) + .with_context(|| format!("load image {}", icon_path.display()))?; + let density = if is_retina(&icon_path) { 2 } else { 1 }; + let (w, h) = icon.dimensions(); + let orig_size = w.min(h); + let next_size_down = 2f32.powf((orig_size as f32).log2().floor()) as u32; + if orig_size > next_size_down { + images_to_resize.push((icon, next_size_down, density)); + } else { + add_icon_to_family(icon, density, &mut family)?; + } + } + + for (icon, next_size_down, density) in images_to_resize { + let icon = icon.resize_exact(next_size_down, next_size_down, image::imageops::Lanczos3); + add_icon_to_family(icon, density, &mut family)?; + } + + if !family.is_empty() { + std::fs::create_dir_all(resources_dir)?; + let icns_path = resources_dir.join(&plist.name).with_extension("icns"); + let icns_file = BufWriter::new(File::create(&icns_path)?); + family + .write(icns_file) + .with_context(|| format!("write icns file {}", icns_path.display()))?; + plist.icon = Some(format!("{}.icns", plist.name)); + return Ok(()); + } + + anyhow::bail!("No usable icon files found.") +} + +fn bundle_macos_app( + exec_path: &Path, + binary_info: BinaryInfo, + cef_root: &Path, + release: bool, + wef_version: Option<&str>, + wef_path: Option<&Path>, + bundle_type: Option<&str>, +) -> Result { + let filename = exec_path.file_name().unwrap(); + let app_path = exec_path + .parent() + .unwrap() + .join(format!("{}.app", filename.to_string_lossy())); + + let macos_path = app_path.join("Contents").join("MacOS"); + std::fs::create_dir_all(&macos_path).context("create app directory")?; + + std::fs::copy(exec_path, macos_path.join(filename)).context("copy binary to app bundle")?; + + let plist_path = app_path.join("Contents").join("Info.plist"); + let mut plist = create_plist(&binary_info, bundle_type)?; + + println!("Create ICNS file..."); + let resources_path = app_path.join("Contents").join("Resources"); + create_icns_file(&binary_info.package_path, &resources_path, &mut plist) + .context("create ICNS file")?; + + plist + .write_into(&mut File::create(&plist_path)?) + .with_context(|| format!("create file at {}", plist_path.display()))?; + + println!("Add CEF Framework..."); + add_cef_framework(cef_root, &app_path, release, false)?; + println!("Add Helper Processes..."); + add_helper(&app_path, wef_version, wef_path, release, false)?; + Ok(macos_path.join(filename)) +} + +pub(crate) fn build( + package: Option, + bin: Option, + example: Option, + release: bool, + wef_version: Option<&str>, + wef_path: Option<&Path>, + bundle_type: Option<&str>, +) -> Result { + let cef_root = crate::internal::find_cef_root(); + println!("Using CEF_ROOT: {}", cef_root.display()); + + let metadata = MetadataCommand::new() + .current_dir(std::env::current_dir().unwrap()) + .exec()?; + + let mut command = Command::new("cargo"); + + command.arg("build"); + + if let Some(package) = &package { + command.arg("--package").arg(package); + } + + if let Some(bin) = &bin { + command.arg("--bin").arg(bin); + } + + if let Some(example) = &example { + command.arg("--example").arg(example); + } + + if release { + command.arg("--release"); + } + + anyhow::ensure!(command.status()?.success(), "failed to build the project"); + + let target_dir = metadata + .target_directory + .join(if release { "release" } else { "debug" }); + + match std::env::consts::OS { + "macos" => { + let (exec_path, binary_info) = execute_path( + &metadata, + target_dir.as_std_path(), + package.as_deref(), + bin.as_deref(), + example.as_deref(), + )?; + bundle_macos_app( + &exec_path, + binary_info, + &cef_root, + release, + wef_version, + wef_path, + bundle_type, + ) + } + "windows" | "linux" => { + anyhow::ensure!( + bundle_type.is_none(), + "bundle-type argument is used only on macOS" + ); + + add_cef_framework(&cef_root, target_dir.as_std_path(), release, false)?; + execute_path( + &metadata, + target_dir.as_std_path(), + package.as_deref(), + bin.as_deref(), + example.as_deref(), + ) + .map(|(path, _)| path) + } + _ => { + anyhow::bail!("Unsupported platform: {}", std::env::consts::OS); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_bin() { + let metadata = MetadataCommand::new() + .current_dir("tests/package_bin") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + None, + None, + None, + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Bin); + assert_eq!(binary_info.target_name, "package-bin"); + assert_eq!(binary_info.version, "0.3.0"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.3.0".to_string()), + ..InfoPlist::new( + "test-package-bin", + "io.github.longbridge.wef.tests.package-bin" + ) + }) + ); + } + + #[test] + fn bin() { + let metadata = MetadataCommand::new() + .current_dir("tests/bin") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + None, + Some("bin1"), + None, + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Bin); + assert_eq!(binary_info.target_name, "bin1"); + assert_eq!(binary_info.version, "0.5.0"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.5.0".to_string()), + ..InfoPlist::new("test-bin", "io.github.longbridge.wef.tests.bin") + }) + ); + } + + #[test] + fn example() { + let metadata = MetadataCommand::new() + .current_dir("tests/example") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + None, + None, + Some("example1"), + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Example); + assert_eq!(binary_info.target_name, "example1"); + assert_eq!(binary_info.version, "0.3.2"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.3.2".to_string()), + ..InfoPlist::new("test-example", "io.github.longbridge.wef.tests.example") + }) + ); + } + + #[test] + fn workspace_package_bin() { + let metadata = MetadataCommand::new() + .current_dir("tests/workspace") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + Some("package-bin"), + None, + None, + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Bin); + assert_eq!(binary_info.target_name, "package-bin"); + assert_eq!(binary_info.version, "0.3.0"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.3.0".to_string()), + ..InfoPlist::new( + "test-package-bin", + "io.github.longbridge.wef.tests.package-bin" + ) + }) + ); + } + + #[test] + fn workspace_bin() { + let metadata = MetadataCommand::new() + .current_dir("tests/workspace") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + Some("pkg-bin"), + Some("bin1"), + None, + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Bin); + assert_eq!(binary_info.target_name, "bin1"); + assert_eq!(binary_info.version, "0.5.0"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.5.0".to_string()), + ..InfoPlist::new("test-bin", "io.github.longbridge.wef.tests.bin") + }) + ); + } + + #[test] + fn workspace_bin_without_package() { + let metadata = MetadataCommand::new() + .current_dir("tests/workspace") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + None, + Some("bin1"), + None, + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Bin); + assert_eq!(binary_info.target_name, "bin1"); + assert_eq!(binary_info.version, "0.5.0"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.5.0".to_string()), + ..InfoPlist::new("test-bin", "io.github.longbridge.wef.tests.bin") + }) + ); + } + + #[test] + fn workspace_example() { + let metadata = MetadataCommand::new() + .current_dir("tests/workspace") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + Some("pkg-example"), + None, + Some("example1"), + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Example); + assert_eq!(binary_info.target_name, "example1"); + assert_eq!(binary_info.version, "0.3.2"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.3.2".to_string()), + ..InfoPlist::new("test-example", "io.github.longbridge.wef.tests.example") + }) + ); + } + + #[test] + fn workspace_example_without_package() { + let metadata = MetadataCommand::new() + .current_dir("tests/workspace") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + None, + None, + Some("example1"), + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Example); + assert_eq!(binary_info.target_name, "example1"); + assert_eq!(binary_info.version, "0.3.2"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.3.2".to_string()), + ..InfoPlist::new("test-example", "io.github.longbridge.wef.tests.example") + }) + ); + } + + #[test] + fn workspace_default_members() { + let metadata = MetadataCommand::new() + .current_dir("tests/default_members") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + None, + Some("bin2"), + None, + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Bin); + assert_eq!(binary_info.package_name, "bin2"); + assert_eq!(binary_info.target_name, "bin2"); + assert_eq!(binary_info.version, "0.5.1"); + + assert_eq!( + find_bundle_settings(&binary_info, None).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.5.1".to_string()), + ..InfoPlist::new("test-bin2", "io.github.longbridge.wef.tests.bin2") + }) + ); + + let err = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + None, + Some("bin1"), + None, + ) + .unwrap_err(); + assert_eq!(err.to_string(), "no bin target named `bin1`"); + } + + #[test] + fn bundle_type() { + let metadata = MetadataCommand::new() + .current_dir("tests/package_bin") + .exec() + .unwrap(); + + let (_, binary_info) = execute_path( + &metadata, + metadata.target_directory.as_std_path(), + None, + None, + None, + ) + .unwrap(); + assert_eq!(binary_info.kind, BinaryKind::Bin); + assert_eq!(binary_info.target_name, "package-bin"); + assert_eq!(binary_info.version, "0.3.0"); + + assert_eq!( + find_bundle_settings(&binary_info, Some("preview")).unwrap(), + Some(InfoPlist { + category: Some("Utility".to_string()), + bundle_short_version: Some("0.3.0".to_string()), + ..InfoPlist::new( + "test-package-bin-preview", + "io.github.longbridge.wef.tests.package-bin.preview" + ) + }) + ); + } +} diff --git a/crates/cargo-wef/src/commands/init.rs b/crates/cargo-wef/src/commands/init.rs new file mode 100644 index 0000000..7d3f7ed --- /dev/null +++ b/crates/cargo-wef/src/commands/init.rs @@ -0,0 +1,85 @@ +use std::path::PathBuf; + +use anyhow::Result; +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::internal::{CefBuildsPlatform, DownloadCefCallback}; + +#[derive(Debug, Default)] +struct CmdDownloadCallback { + download_progress: Option, + extract_progress: Option, +} + +pub(crate) fn init( + path: Option, + version: String, + platform: CefBuildsPlatform, + force: bool, +) -> Result<()> { + let path = path.unwrap_or_else(|| dirs::home_dir().expect("get home directory").join(".cef")); + crate::internal::download_cef( + &path, + &version, + platform, + force, + CmdDownloadCallback::default(), + )?; + println!("Set environment variable CEF_ROOT={}", path.display()); + Ok(()) +} + +fn create_download_progress_bar() -> ProgressBar { + ProgressBar::new_spinner().with_style( + ProgressStyle::default_spinner() + .progress_chars("#>-" ) + .template("Downloading CEF {spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}) {eta}").unwrap() + ) +} + +fn create_extract_progress_bar() -> ProgressBar { + ProgressBar::new_spinner().with_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .unwrap(), + ) +} + +impl DownloadCefCallback for CmdDownloadCallback { + fn download_start(&mut self, total_size: u64) { + self.download_progress = Some(create_download_progress_bar()); + if let Some(pb) = &self.download_progress { + pb.set_length(total_size); + } + } + + fn download_progress(&mut self, downloaded: u64) { + if let Some(pb) = &self.download_progress { + pb.set_position(downloaded); + } + } + + fn download_end(&mut self) { + if let Some(pb) = self.download_progress.take() { + pb.finish_and_clear(); + println!("Download complete"); + } + } + + fn extract_start(&mut self) { + self.extract_progress = Some(create_extract_progress_bar()); + } + + fn extract_file(&mut self, path: &str) { + if let Some(pb) = &self.extract_progress { + pb.set_message(format!("Extracting {}", path)); + } + } + + fn extract_end(&mut self) { + if let Some(pb) = self.extract_progress.take() { + pb.finish_and_clear(); + println!("Extraction complete"); + } + } +} diff --git a/crates/cargo-wef/src/commands/mod.rs b/crates/cargo-wef/src/commands/mod.rs new file mode 100644 index 0000000..3316bab --- /dev/null +++ b/crates/cargo-wef/src/commands/mod.rs @@ -0,0 +1,9 @@ +mod add_framework; +mod build; +mod init; +mod run; + +pub(crate) use add_framework::add_framework; +pub(crate) use build::build; +pub(crate) use init::init; +pub(crate) use run::run; diff --git a/crates/cargo-wef/src/commands/run.rs b/crates/cargo-wef/src/commands/run.rs new file mode 100644 index 0000000..a916cf5 --- /dev/null +++ b/crates/cargo-wef/src/commands/run.rs @@ -0,0 +1,18 @@ +use std::{path::Path, process::Command}; + +use anyhow::Result; + +pub(crate) fn run( + package: Option, + bin: Option, + example: Option, + release: bool, + wef_version: Option<&str>, + wef_path: Option<&Path>, + args: Vec, +) -> Result<()> { + let exec_path = + crate::commands::build(package, bin, example, release, wef_version, wef_path, None)?; + Command::new(&exec_path).args(args).status()?; + Ok(()) +} diff --git a/crates/cargo-wef/src/internal/add_cef_framework.rs b/crates/cargo-wef/src/internal/add_cef_framework.rs new file mode 100644 index 0000000..641c273 --- /dev/null +++ b/crates/cargo-wef/src/internal/add_cef_framework.rs @@ -0,0 +1,214 @@ +use std::path::Path; + +use anyhow::{Context, Result}; + +pub(crate) fn add_cef_framework( + cef_root: &Path, + app_path: &Path, + release: bool, + force: bool, +) -> Result<()> { + match std::env::consts::OS { + "macos" => add_cef_framework_macos(cef_root, app_path, release, force), + "windows" => add_cef_framework_windows(cef_root, app_path, release, force), + "linux" => add_cef_framework_linux(cef_root, app_path, release, force), + _ => { + anyhow::bail!("Unsupported platform: {}", std::env::consts::OS); + } + } +} + +fn add_cef_framework_macos( + cef_root: &Path, + app_path: &Path, + release: bool, + force: bool, +) -> Result<()> { + let contents_path = app_path.join("Contents"); + anyhow::ensure!( + contents_path.exists(), + "{} is not a valid MacOS app.", + app_path.display() + ); + + // create frameworks directory + let frameworks_path = contents_path.join("Frameworks"); + std::fs::create_dir_all(&frameworks_path).with_context(|| { + format!( + "create frameworks directory at {}", + frameworks_path.display() + ) + })?; + + // copy CEF framework + let cef_framework_path = cef_root + .join(if !release { "Debug" } else { "Release" }) + .join("Chromium Embedded Framework.framework"); + + if !force + && frameworks_path + .join("Chromium Embedded Framework.framework") + .exists() + { + return Ok(()); + } + + fs_extra::dir::copy( + &cef_framework_path, + &frameworks_path, + &fs_extra::dir::CopyOptions { + overwrite: true, + skip_exist: false, + copy_inside: false, + content_only: false, + ..Default::default() + }, + ) + .with_context(|| { + format!( + "copy CEF framework from {} to {}", + cef_framework_path.display(), + frameworks_path.display() + ) + })?; + + Ok(()) +} + +fn add_cef_framework_windows( + cef_root: &Path, + app_path: &Path, + release: bool, + force: bool, +) -> Result<()> { + let files = [ + "chrome_elf.dll", + "d3dcompiler_47.dll", + "dxcompiler.dll", + "dxil.dll", + "libcef.dll", + "libEGL.dll", + "libGLESv2.dll", + "v8_context_snapshot.bin", + "vk_swiftshader.dll", + "vk_swiftshader_icd.json", + "vulkan-1.dll", + ]; + + let resources = [ + "chrome_100_percent.pak", + "chrome_200_percent.pak", + "icudtl.dat", + "resources.pak", + "locales", + ]; + + if !force + && files + .iter() + .all(|filename| app_path.join(filename).exists()) + && resources + .iter() + .all(|filename| app_path.join(filename).exists()) + { + return Ok(()); + } + + for filename in files { + let src_path = cef_root + .join(if !release { "Debug" } else { "Release" }) + .join(filename); + let dst_path = app_path.join(filename); + std::fs::copy(src_path, dst_path) + .with_context(|| format!("copy {} to {}", filename, app_path.display()))?; + } + + let resources_src_path = cef_root.join("Resources"); + fs_extra::dir::copy( + &resources_src_path, + app_path, + &fs_extra::dir::CopyOptions { + overwrite: true, + skip_exist: false, + copy_inside: false, + content_only: true, + ..Default::default() + }, + ) + .with_context(|| { + format!( + "copy CEF Resources from {} to {}", + resources_src_path.display(), + app_path.display() + ) + })?; + + Ok(()) +} + +fn add_cef_framework_linux( + cef_root: &Path, + app_path: &Path, + release: bool, + force: bool, +) -> Result<()> { + let files = [ + "libcef.so", + "libEGL.so", + "libGLESv2.so", + "libvk_swiftshader.so", + "libvulkan.so.1", + "v8_context_snapshot.bin", + "vk_swiftshader_icd.json", + ]; + + let resources = [ + "chrome_100_percent.pak", + "chrome_200_percent.pak", + "icudtl.dat", + "resources.pak", + "locales", + ]; + + if !force + && files + .iter() + .all(|filename| app_path.join(filename).exists()) + && resources + .iter() + .all(|filename| app_path.join(filename).exists()) + { + return Ok(()); + } + + for filename in files { + let src_path = cef_root + .join(if !release { "Debug" } else { "Release" }) + .join(filename); + let dst_path = app_path.join(filename); + std::fs::copy(src_path, dst_path) + .with_context(|| format!("copy {} to {}", filename, app_path.display()))?; + } + + let resources_src_path = cef_root.join("Resources"); + fs_extra::dir::copy( + &resources_src_path, + app_path, + &fs_extra::dir::CopyOptions { + overwrite: true, + skip_exist: false, + copy_inside: false, + content_only: true, + ..Default::default() + }, + ) + .with_context(|| { + format!( + "copy CEF Resources from {} to {}", + resources_src_path.display(), + app_path.display() + ) + })?; + + Ok(()) +} diff --git a/crates/cargo-wef/src/internal/add_helper.rs b/crates/cargo-wef/src/internal/add_helper.rs new file mode 100644 index 0000000..d3019b0 --- /dev/null +++ b/crates/cargo-wef/src/internal/add_helper.rs @@ -0,0 +1,308 @@ +use std::{fs::File, path::Path, process::Command}; + +use anyhow::{Context, Result}; +use askama::Template; +use serde::Deserialize; + +use crate::internal::InfoPlist; + +/// ```askama +/// [package] +/// name = "helper" +/// version = "0.1.0" +/// edition = "2024" +/// +/// [dependencies] +/// {% if let Some(wef_version) = wef_version %} +/// wef = "{{ wef_version }}" +/// {% endif %} +/// {% if let Some(wef_path) = wef_path %} +/// wef = { path = "{{ wef_path }}" } +/// {% endif %} +/// ``` +#[derive(Template)] +#[template(ext = "txt", in_doc = true)] +struct TemplateCargoToml { + wef_version: Option, + wef_path: Option, +} + +const MAIN_RS: &str = r#" +fn main() -> Result<(), Box> { + let _ = wef::SandboxContext::new(); + let _ = wef::FrameworkLoader::load_in_helper()?; + wef::exec_process()?; + Ok(()) +} +"#; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HelperKind { + Main, + Alerts, + Gpu, + Plugin, + Renderer, +} + +impl HelperKind { + const ALL: &[HelperKind] = &[ + HelperKind::Main, + HelperKind::Alerts, + HelperKind::Gpu, + HelperKind::Plugin, + HelperKind::Renderer, + ]; + + fn bundle_name(&self, bundle_name: &str) -> String { + let helper_name = match self { + HelperKind::Main => "Helper", + HelperKind::Alerts => "Helper (Alerts)", + HelperKind::Gpu => "Helper (GPU)", + HelperKind::Plugin => "Helper (Plugin)", + HelperKind::Renderer => "Helper (Renderer)", + }; + format!("{} {}", bundle_name, helper_name) + } + + fn bundle_identifier(&self, bundle_identifier: &str) -> String { + match self { + HelperKind::Main => format!("{}.helper", bundle_identifier), + HelperKind::Alerts => format!("{}.helper.alerts", bundle_identifier), + HelperKind::Gpu => format!("{}.helper.gpu", bundle_identifier), + HelperKind::Plugin => format!("{}.helper.plugin", bundle_identifier), + HelperKind::Renderer => format!("{}.helper.renderer", bundle_identifier), + } + } +} + +fn query_wef_max_stable_version() -> Result { + #[derive(Debug, Deserialize)] + struct CrateInfo { + max_stable_version: String, + } + + #[derive(Debug, Deserialize)] + struct Response { + #[serde(rename = "crate")] + crate_: CrateInfo, + } + + let client = reqwest::blocking::Client::new(); + Ok(client + .get("https://crates.io/api/v1/crates/wef") + .header("user-agent", "curl/8.7.1") + .send()? + .error_for_status()? + .json::()? + .crate_ + .max_stable_version) +} + +fn create_helper_bin( + wef_version: Option<&str>, + wef_path: Option<&Path>, + release: bool, + callback: F, +) -> Result +where + F: FnOnce(&Path) -> Result, +{ + let proj_dir = dirs::home_dir() + .expect("home directory not found") + .join(".wef-tool") + .join("helper"); + std::fs::create_dir_all(proj_dir.as_path()) + .with_context(|| format!("create project directory at {}", proj_dir.display()))?; + + // query wef version + let (wef_version, wef_path) = if let Some(wef_path) = &wef_path { + (None, Some(wef_path.display().to_string())) + } else { + match wef_version { + Some(version) => (Some(version.to_string()), None), + None => ( + Some( + query_wef_max_stable_version() + .with_context(|| "query latest stable version of Wef from crates.io")?, + ), + None, + ), + } + }; + + // create Cargo.toml + let cargo_toml_path = proj_dir.join("Cargo.toml"); + let mut cargo_toml_file = File::create(&cargo_toml_path) + .with_context(|| format!("create Cargo.toml at {}", cargo_toml_path.display()))?; + + TemplateCargoToml { + wef_version, + wef_path, + } + .write_into(&mut cargo_toml_file) + .with_context(|| format!("create file at {}", cargo_toml_path.display()))?; + + // create src/main.rs + let src_path = proj_dir.join("src"); + std::fs::create_dir_all(&src_path) + .with_context(|| format!("create directory at {}", src_path.display()))?; + + let main_rs_path = proj_dir.join("src").join("main.rs"); + std::fs::write(&main_rs_path, MAIN_RS) + .with_context(|| format!("create file at {}", main_rs_path.display()))?; + + // build + let mut command = Command::new("cargo"); + + command + .arg("build") + .arg("--target-dir") + .arg(proj_dir.join("target")); + + if release { + command.arg("--release"); + } + + let output = command + .current_dir(&proj_dir) + .output() + .with_context(|| format!("run cargo build in {}", proj_dir.display()))?; + + anyhow::ensure!( + output.status.success(), + "failed to compile helper binary:\n{}\n", + String::from_utf8_lossy(&output.stderr) + ); + + let target_path = proj_dir + .join("target") + .join(if !release { "debug" } else { "release" }) + .join("helper"); + callback(&target_path) +} + +#[derive(Debug, Deserialize)] +struct BundleInfo { + #[serde(rename = "CFBundleName")] + bundle_name: String, + #[serde(rename = "CFBundleIdentifier")] + bundle_identifier: String, + #[serde(rename = "CFBundleExecutable")] + executable_name: Option, +} + +fn read_bundle_info(path: &Path) -> Result { + plist::from_file(path).map_err(Into::into) +} + +fn create_helper_app( + app_path: &Path, + kind: HelperKind, + bundle_info: &BundleInfo, + bin_path: &Path, +) -> Result<()> { + let frameworks_path = app_path.join("Contents").join("Frameworks"); + + // create frameworks directory + std::fs::create_dir_all(&frameworks_path).with_context(|| { + format!( + "create frameworks directory at {}", + frameworks_path.display() + ) + })?; + + let helper_app_path = frameworks_path.join(format!( + "{}.app", + kind.bundle_name(&bundle_info.bundle_name) + )); + + // create app directory + std::fs::create_dir_all(&helper_app_path).with_context(|| { + format!( + "create helper app directory at {}", + helper_app_path.display() + ) + })?; + + // create Contents directory + let contents_path = helper_app_path.join("Contents"); + std::fs::create_dir_all(&contents_path) + .with_context(|| format!("create directory at {}", contents_path.display()))?; + + // create plist + let plist_path = contents_path.join("Info.plist"); + let mut plist = InfoPlist::new( + kind.bundle_name(&bundle_info.bundle_name), + kind.bundle_identifier(&bundle_info.bundle_identifier), + ); + plist.executable_name = Some( + bundle_info + .executable_name + .as_ref() + .map(|executable_name| kind.bundle_name(executable_name)) + .unwrap_or_else(|| kind.bundle_name(&bundle_info.bundle_name)), + ); + plist.agent_app = true; + + plist + .write_into(&mut File::create(&plist_path)?) + .with_context(|| format!("create file at {}", plist_path.display()))?; + + // create MacOS directory + let macos_path = contents_path.join("MacOS"); + std::fs::create_dir_all(&macos_path) + .with_context(|| format!("create directory at {}", macos_path.display()))?; + + // copy binary + let target_path = macos_path.join(kind.bundle_name(&bundle_info.bundle_name)); + std::fs::copy(bin_path, &target_path).with_context(|| { + format!( + "copy binary from {} to {}", + bin_path.display(), + target_path.display() + ) + })?; + + Ok(()) +} + +pub(crate) fn add_helper( + app_path: &Path, + wef_version: Option<&str>, + wef_path: Option<&Path>, + release: bool, + force: bool, +) -> Result<()> { + let info_path = app_path.join("Contents").join("Info.plist"); + + anyhow::ensure!( + info_path.exists(), + "{} is not a valid Macos app.", + app_path.display() + ); + + let bundle_info = read_bundle_info(&info_path) + .with_context(|| format!("read bundle info from {}", info_path.display()))?; + + if !force + && HelperKind::ALL.iter().all(|kind| { + let helper_path = app_path.join("Contents").join("Frameworks").join(format!( + "{}.app", + kind.bundle_name(&bundle_info.bundle_name) + )); + helper_path.exists() + }) + { + return Ok(()); + } + + create_helper_bin(wef_version, wef_path, release, |path| { + for kind in HelperKind::ALL { + create_helper_app(app_path, *kind, &bundle_info, path)?; + } + Ok(()) + })?; + + Ok(()) +} diff --git a/crates/cargo-wef/src/internal/cef_platform.rs b/crates/cargo-wef/src/internal/cef_platform.rs new file mode 100644 index 0000000..d700331 --- /dev/null +++ b/crates/cargo-wef/src/internal/cef_platform.rs @@ -0,0 +1,47 @@ +use clap::ValueEnum; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[allow(non_camel_case_types)] +pub enum CefBuildsPlatform { + Auto, + Windows_x64, + Macos_x64, + Macos_arm64, + Linux_x64, +} + +pub(crate) const DEFAULT_CEF_VERSION: &str = "137.0.10+g7e14fe1+chromium-137.0.7151.69"; + +impl CefBuildsPlatform { + fn arch(&self) -> Option<&'static str> { + match self { + CefBuildsPlatform::Auto => match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => CefBuildsPlatform::Windows_x64.arch(), + ("macos", "x86_64") => CefBuildsPlatform::Macos_x64.arch(), + ("macos", "aarch64") => CefBuildsPlatform::Macos_arm64.arch(), + ("linux", "x86_64") => CefBuildsPlatform::Linux_x64.arch(), + _ => None, + }, + CefBuildsPlatform::Windows_x64 => Some("windows64"), + CefBuildsPlatform::Macos_x64 => Some("macosx64"), + CefBuildsPlatform::Macos_arm64 => Some("macosarm64"), + CefBuildsPlatform::Linux_x64 => Some("linux64"), + } + } + + pub(crate) fn download_url(&self, version: &str) -> Option { + Some(format!( + "https://cef-builds.spotifycdn.com/cef_binary_{version}_{arch}.tar.bz2", + version = version, + arch = self.arch()? + )) + } + + pub(crate) fn root_dir_name(&self, version: &str) -> Option { + Some(format!( + "cef_binary_{version}_{arch}", + version = version, + arch = self.arch()? + )) + } +} diff --git a/crates/cargo-wef/src/internal/download_cef.rs b/crates/cargo-wef/src/internal/download_cef.rs new file mode 100644 index 0000000..c6ed6cb --- /dev/null +++ b/crates/cargo-wef/src/internal/download_cef.rs @@ -0,0 +1,138 @@ +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::Path, +}; + +use anyhow::{Context, Result}; +use reqwest::blocking::Client; +use tar::EntryType; + +use crate::internal::CefBuildsPlatform; + +pub(crate) fn download_cef( + path: &Path, + version: &str, + platform: CefBuildsPlatform, + force: bool, + mut callback: impl DownloadCefCallback, +) -> Result<()> { + if !force && path.exists() { + return Ok(()); + } + + let url = platform + .download_url(version) + .ok_or_else(|| anyhow::anyhow!("unsupported platform: {:?}", platform))?; + + // Download with progress + let client = Client::new(); + let response = client.get(&url).send().context("download CEF")?; + + let total_size = response.content_length().unwrap_or(0); + + let tmpdir_path = tempfile::tempdir().context("create temporary directory")?; + let archive_path = tmpdir_path.path().join("cef.tar.bz2"); + + callback.download_start(total_size); + download_file(&url, &archive_path, &mut callback)?; + callback.download_end(); + + // Create the target directory if it doesn't exist + fs::create_dir_all(path).context("create target directory")?; + + // Extract with progress + callback.extract_start(); + extract_archive( + &archive_path, + path, + &platform.root_dir_name(version).unwrap(), + &mut callback, + ) + .context("extract CEF archive")?; + callback.extract_end(); + + Ok(()) +} + +fn download_file(url: &str, path: &Path, callback: &mut impl DownloadCefCallback) -> Result<()> { + let client = Client::new(); + + let mut response = client + .get(url) + .send() + .and_then(|resp| resp.error_for_status()) + .with_context(|| format!("download CEF from {}", url))?; + + let mut downloaded: u64 = 0; + let mut buffer = [0; 8192]; + + let mut file = File::create(path).with_context(|| format!("create file {}", path.display()))?; + + while let Ok(bytes_read) = response.read(&mut buffer) { + if bytes_read == 0 { + break; + } + file.write_all(&buffer[..bytes_read]) + .with_context(|| format!("write to file {}", path.display()))?; + downloaded += bytes_read as u64; + callback.download_progress(downloaded); + } + + Ok(()) +} + +fn extract_archive( + archive_path: &Path, + target_dir: &Path, + root_dir_name: &str, + callback: &mut impl DownloadCefCallback, +) -> Result<()> { + let tar_bz2 = File::open(archive_path) + .with_context(|| format!("open archive file {}", archive_path.display()))?; + + let bz2 = bzip2::read::BzDecoder::new(tar_bz2); + let mut archive = tar::Archive::new(bz2); + + let entries = archive.entries().context("read entries from archive")?; + + for res in entries { + let mut entry = res.context("get entry from archive")?; + + if entry.header().entry_type() != EntryType::Regular { + continue; + } + + let entry_path = entry.path().unwrap().to_path_buf(); + let filepath = target_dir.join(entry_path.strip_prefix(root_dir_name).unwrap()); + let parent_path = filepath.parent().unwrap(); + std::fs::create_dir_all(parent_path) + .with_context(|| format!("create directory for entry {}", parent_path.display()))?; + + entry.unpack(&filepath).with_context(|| { + format!( + "unpack entry {} to {}", + entry_path.display(), + filepath.display() + ) + })?; + callback.extract_file(&entry_path.display().to_string()); + } + + Ok(()) +} + +#[allow(unused_variables)] +pub(crate) trait DownloadCefCallback { + fn download_start(&mut self, total: u64) {} + + fn download_progress(&mut self, downloaded: u64) {} + + fn download_end(&mut self) {} + + fn extract_start(&mut self) {} + + fn extract_file(&mut self, path: &str) {} + + fn extract_end(&mut self) {} +} diff --git a/crates/cargo-wef/src/internal/find_cef_root.rs b/crates/cargo-wef/src/internal/find_cef_root.rs new file mode 100644 index 0000000..36da777 --- /dev/null +++ b/crates/cargo-wef/src/internal/find_cef_root.rs @@ -0,0 +1,9 @@ +use std::path::PathBuf; + +pub(crate) fn find_cef_root() -> PathBuf { + if let Ok(cef_root) = std::env::var("CEF_ROOT") { + cef_root.into() + } else { + dirs::home_dir().expect("get home directory").join(".cef") + } +} diff --git a/crates/cargo-wef/src/internal/mod.rs b/crates/cargo-wef/src/internal/mod.rs new file mode 100644 index 0000000..636db43 --- /dev/null +++ b/crates/cargo-wef/src/internal/mod.rs @@ -0,0 +1,13 @@ +mod add_cef_framework; +mod add_helper; +mod cef_platform; +mod download_cef; +mod find_cef_root; +mod plist; + +pub(crate) use add_cef_framework::add_cef_framework; +pub(crate) use add_helper::add_helper; +pub(crate) use cef_platform::{CefBuildsPlatform, DEFAULT_CEF_VERSION}; +pub(crate) use download_cef::{DownloadCefCallback, download_cef}; +pub(crate) use find_cef_root::find_cef_root; +pub(crate) use plist::InfoPlist; diff --git a/crates/cargo-wef/src/internal/plist.rs b/crates/cargo-wef/src/internal/plist.rs new file mode 100644 index 0000000..16c65aa --- /dev/null +++ b/crates/cargo-wef/src/internal/plist.rs @@ -0,0 +1,122 @@ +use askama::Template; +use serde::Deserialize; + +/// ```askama +/// +/// +/// +/// +/// CFBundleDevelopmentRegion +/// {% if let Some(region) = region %} +/// {{ region }} +/// {% else %} +/// en +/// {% endif %} +/// CFBundleDisplayName +/// {% if let Some(display_name) = display_name %} +/// {{ display_name | e }} +/// {% else %} +/// {{ name | e }} +/// {% endif %} +/// CFBundleExecutable +/// {% if let Some(executable_name) = executable_name %} +/// {{ executable_name | e }} +/// {% else %} +/// {{ name | e }} +/// {% endif %} +/// CFBundleIdentifier +/// {{ identifier | e }} +/// CFBundleInfoDictionaryVersion +/// 6.0 +/// CFBundleName +/// {{ name | e }} +/// CFBundlePackageType +/// APPL +/// CFBundleVersion +/// {% if let Some(bundle_version) = bundle_version %} +/// {{ bundle_version | e }} +/// {% else %} +/// +/// {% endif %} +/// CFBundleShortVersionString +/// {% if let Some(bundle_short_version) = bundle_short_version %} +/// {{ bundle_short_version | e }} +/// {% else %} +/// +/// {% endif %} +/// {% if let Some(category) = category %} +/// LSApplicationCategoryType +/// {{ category | e }} +/// {% endif %} +/// {% if let Some(icon) = icon %} +/// CFBundleIconFile +/// {{ icon | e }} +/// {% endif %} +/// {% if agent_app %} +/// LSUIElement +/// 1 +/// {% endif %} +/// {% if let Some(minimum_system_version) = minimum_system_version %} +/// LSMinimumSystemVersion +/// {{ minimum_system_version | e }} +/// {% endif %} +/// {% if url_schemes.len() > 0 %} +/// CFBundleURLTypes +/// +/// +/// CFBundleURLName +/// {{ name | e }} +/// CFBundleTypeRole +/// Viewer +/// CFBundleURLSchemes +/// +/// {% for scheme in url_schemes %} +/// {{ scheme | e }} +/// {% endfor %} +/// +/// +/// +/// {% endif %} +/// +/// +/// ``` +#[derive(Debug, Template, Deserialize, PartialEq, Eq)] +#[template(ext = "xml", in_doc = true)] +pub(crate) struct InfoPlist { + pub(crate) name: String, + pub(crate) identifier: String, + pub(crate) display_name: Option, + pub(crate) executable_name: Option, + pub(crate) region: Option, + pub(crate) bundle_version: Option, + pub(crate) bundle_short_version: Option, + #[serde(default)] + pub(crate) icons: Vec, + pub(crate) icon: Option, + pub(crate) category: Option, + pub(crate) minimum_system_version: Option, + #[serde(default)] + pub(crate) url_schemes: Vec, + #[serde(default)] + pub(crate) agent_app: bool, +} + +impl InfoPlist { + pub(crate) fn new(name: impl Into, identifier: impl Into) -> Self { + Self { + name: name.into(), + identifier: identifier.into(), + display_name: None, + executable_name: None, + region: None, + bundle_version: None, + bundle_short_version: None, + icons: vec![], + icon: None, + category: None, + minimum_system_version: None, + url_schemes: vec![], + agent_app: false, + } + } +} diff --git a/crates/cargo-wef/src/main.rs b/crates/cargo-wef/src/main.rs new file mode 100644 index 0000000..1dd55ef --- /dev/null +++ b/crates/cargo-wef/src/main.rs @@ -0,0 +1,188 @@ +mod commands; +mod internal; + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +use crate::internal::{CefBuildsPlatform, DEFAULT_CEF_VERSION}; + +#[derive(Subcommand)] +enum Commands { + /// Download CEF framework + Init { + /// Target path + path: Option, + /// CEF version + #[clap(long, default_value = DEFAULT_CEF_VERSION)] + version: String, + /// Platform + #[clap(long, default_value = "auto")] + platform: CefBuildsPlatform, + /// Force download even if the file already exists + #[clap(long, short, default_value_t = false)] + force: bool, + }, + /// Compile a local package and all of its dependencies + Build { + /// Package to build (see `cargo help pkgid`) + #[clap(long, short, value_name = "SPEC")] + package: Option, + /// Build only the specified binary + #[clap(long, value_name = "NAME")] + bin: Option, + /// Build only the specified example + #[clap(long, value_name = "NAME")] + example: Option, + /// Build artifacts in release mode, with optimizations + #[clap(long, short)] + release: bool, + /// Use the specified Wef version + /// + /// If not specified, use the latest version + #[clap(long)] + wef_version: Option, + /// Specify the source code path of the local Wef library instead of the + /// published version + #[clap(long)] + wef_path: Option, + /// Specify the bundle type for the MacOS application + bundle_type: Option, + }, + /// Run a binary or example of the local package + Run { + /// Package to build (see `cargo help pkgid`) + #[clap(long, short, value_name = "SPEC")] + package: Option, + /// Build only the specified binary + #[clap(long, value_name = "NAME")] + bin: Option, + /// Build only the specified example + #[clap(long, value_name = "NAME")] + example: Option, + /// Build artifacts in release mode, with optimizations + #[clap(long, short)] + release: bool, + /// Use the specified Wef version + /// + /// If not specified, use the latest version + #[clap(long)] + wef_version: Option, + /// Specify the source code path of the local Wef library instead of the + /// published version + #[clap(long)] + wef_path: Option, + #[arg(last = true)] + args: Vec, + }, + /// Add CEF framework to the application + AddFramework { + /// Target app path + app_path: PathBuf, + /// Build artifacts in release mode, with optimizations + #[clap(long, short)] + release: bool, + /// Force adding the framework even if it already exists + #[clap(long, short, default_value_t = false)] + force: bool, + /// Use the specified Wef version + /// + /// If not specified, use the latest version + #[clap(long)] + wef_version: Option, + /// Specify the source code path of the local Wef library instead of the + /// published version + #[clap(long)] + wef_path: Option, + }, +} + +/// Wef CLI tool +#[derive(Parser)] +#[command(name = "cargo")] +#[command(bin_name = "cargo")] +#[clap(version, about)] +struct Cli { + #[command(subcommand)] + wef: WefCommands, +} + +#[derive(Subcommand)] +enum WefCommands { + Wef { + #[command(subcommand)] + commands: Commands, + }, +} + +fn main() { + let cli = Cli::parse(); + + let res = match cli.wef { + WefCommands::Wef { + commands: + Commands::Init { + path, + version, + platform, + force, + }, + } => commands::init(path, version, platform, force), + WefCommands::Wef { + commands: + Commands::Build { + package, + bin, + example, + release, + wef_version, + wef_path, + bundle_type, + }, + } => commands::build( + package, + bin, + example, + release, + wef_version.as_deref(), + wef_path.as_deref(), + bundle_type.as_deref(), + ) + .map(|_| ()), + WefCommands::Wef { + commands: + Commands::Run { + package, + bin, + example, + release, + wef_version, + wef_path, + args, + }, + } => commands::run( + package, + bin, + example, + release, + wef_version.as_deref(), + wef_path.as_deref(), + args, + ), + WefCommands::Wef { + commands: + Commands::AddFramework { + app_path, + release, + force, + wef_version, + wef_path, + }, + } => commands::add_framework(app_path, release, force, wef_version, wef_path), + }; + + if let Err(err) = res { + eprintln!("{:?}", err); + std::process::exit(-1); + } +} diff --git a/crates/cargo-wef/tests/.gitignore b/crates/cargo-wef/tests/.gitignore new file mode 100644 index 0000000..2a6fee4 --- /dev/null +++ b/crates/cargo-wef/tests/.gitignore @@ -0,0 +1,2 @@ +**/target +**/Cargo.lock \ No newline at end of file diff --git a/crates/cargo-wef/tests/bin/Cargo.toml b/crates/cargo-wef/tests/bin/Cargo.toml new file mode 100644 index 0000000..2c12c4e --- /dev/null +++ b/crates/cargo-wef/tests/bin/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] + +[package] +name = "pkg-bin" +version = "0.5.0" +edition = "2024" + +[package.metadata.bundle.bin.bin1] +name = "test-bin" +identifier = "io.github.longbridge.wef.tests.bin" +category = "Utility" diff --git a/crates/cargo-wef/tests/bin/src/bin/bin1.rs b/crates/cargo-wef/tests/bin/src/bin/bin1.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/cargo-wef/tests/bin/src/bin/bin1.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/cargo-wef/tests/bin/src/main.rs b/crates/cargo-wef/tests/bin/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/crates/cargo-wef/tests/bin/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/crates/cargo-wef/tests/default_members/.gitignore b/crates/cargo-wef/tests/default_members/.gitignore new file mode 100644 index 0000000..c41cc9e --- /dev/null +++ b/crates/cargo-wef/tests/default_members/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/crates/cargo-wef/tests/default_members/Cargo.toml b/crates/cargo-wef/tests/default_members/Cargo.toml new file mode 100644 index 0000000..9554049 --- /dev/null +++ b/crates/cargo-wef/tests/default_members/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +resolver = "2" +default-members = ["bin2", "bin3"] +members = ["bin1", "bin2", "bin3"] diff --git a/crates/cargo-wef/tests/default_members/bin1/Cargo.toml b/crates/cargo-wef/tests/default_members/bin1/Cargo.toml new file mode 100644 index 0000000..2f74cce --- /dev/null +++ b/crates/cargo-wef/tests/default_members/bin1/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "bin1" +version = "0.5.0" +edition = "2024" + +[package.metadata.bundle.bin.bin1] +name = "test-bin1" +identifier = "io.github.longbridge.wef.tests.bin1" +category = "Utility" diff --git a/crates/cargo-wef/tests/default_members/bin1/src/main.rs b/crates/cargo-wef/tests/default_members/bin1/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/crates/cargo-wef/tests/default_members/bin1/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/crates/cargo-wef/tests/default_members/bin2/Cargo.toml b/crates/cargo-wef/tests/default_members/bin2/Cargo.toml new file mode 100644 index 0000000..f271923 --- /dev/null +++ b/crates/cargo-wef/tests/default_members/bin2/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "bin2" +version = "0.5.1" +edition = "2024" + +[package.metadata.bundle.bin.bin2] +name = "test-bin2" +identifier = "io.github.longbridge.wef.tests.bin2" +category = "Utility" diff --git a/crates/cargo-wef/tests/default_members/bin2/src/main.rs b/crates/cargo-wef/tests/default_members/bin2/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/crates/cargo-wef/tests/default_members/bin2/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/crates/cargo-wef/tests/default_members/bin3/Cargo.toml b/crates/cargo-wef/tests/default_members/bin3/Cargo.toml new file mode 100644 index 0000000..cfcde24 --- /dev/null +++ b/crates/cargo-wef/tests/default_members/bin3/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "bin3" +version = "0.5.2" +edition = "2024" + +[package.metadata.bundle.bin.bin3] +name = "test-bin2" +identifier = "io.github.longbridge.wef.tests.bin3" +category = "Utility" diff --git a/crates/cargo-wef/tests/default_members/bin3/src/main.rs b/crates/cargo-wef/tests/default_members/bin3/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/crates/cargo-wef/tests/default_members/bin3/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/crates/cargo-wef/tests/example/Cargo.toml b/crates/cargo-wef/tests/example/Cargo.toml new file mode 100644 index 0000000..4abfd8b --- /dev/null +++ b/crates/cargo-wef/tests/example/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] + +[package] +name = "pkg-example" +version = "0.3.2" +edition = "2024" + +[package.metadata.bundle.example.example1] +name = "test-example" +identifier = "io.github.longbridge.wef.tests.example" +category = "Utility" diff --git a/crates/cargo-wef/tests/example/examples/example1.rs b/crates/cargo-wef/tests/example/examples/example1.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/cargo-wef/tests/example/examples/example1.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/cargo-wef/tests/example/src/lib.rs b/crates/cargo-wef/tests/example/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/cargo-wef/tests/example/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/cargo-wef/tests/package_bin/Cargo.toml b/crates/cargo-wef/tests/package_bin/Cargo.toml new file mode 100644 index 0000000..5895680 --- /dev/null +++ b/crates/cargo-wef/tests/package_bin/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] + +[package] +name = "package-bin" +version = "0.3.0" +edition = "2024" + +[package.metadata.bundle] +name = "test-package-bin" +identifier = "io.github.longbridge.wef.tests.package-bin" +category = "Utility" + +[package.metadata.bundle.preview] +name = "test-package-bin-preview" +identifier = "io.github.longbridge.wef.tests.package-bin.preview" +category = "Utility" diff --git a/crates/cargo-wef/tests/package_bin/src/main.rs b/crates/cargo-wef/tests/package_bin/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/crates/cargo-wef/tests/package_bin/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/crates/cargo-wef/tests/workspace/Cargo.toml b/crates/cargo-wef/tests/workspace/Cargo.toml new file mode 100644 index 0000000..8fd623b --- /dev/null +++ b/crates/cargo-wef/tests/workspace/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["example", "bin", "package_bin"] diff --git a/crates/cargo-wef/tests/workspace/bin/Cargo.toml b/crates/cargo-wef/tests/workspace/bin/Cargo.toml new file mode 100644 index 0000000..ca5916d --- /dev/null +++ b/crates/cargo-wef/tests/workspace/bin/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "pkg-bin" +version = "0.5.0" +edition = "2024" + +[package.metadata.bundle.bin.bin1] +name = "test-bin" +identifier = "io.github.longbridge.wef.tests.bin" +category = "Utility" diff --git a/crates/cargo-wef/tests/workspace/bin/src/bin/bin1.rs b/crates/cargo-wef/tests/workspace/bin/src/bin/bin1.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/cargo-wef/tests/workspace/bin/src/bin/bin1.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/cargo-wef/tests/workspace/bin/src/bin/bin2.rs b/crates/cargo-wef/tests/workspace/bin/src/bin/bin2.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/cargo-wef/tests/workspace/bin/src/bin/bin2.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/cargo-wef/tests/workspace/bin/src/lib.rs b/crates/cargo-wef/tests/workspace/bin/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/cargo-wef/tests/workspace/bin/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/cargo-wef/tests/workspace/example/Cargo.toml b/crates/cargo-wef/tests/workspace/example/Cargo.toml new file mode 100644 index 0000000..12401d7 --- /dev/null +++ b/crates/cargo-wef/tests/workspace/example/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "pkg-example" +version = "0.3.2" +edition = "2024" + +[package.metadata.bundle.example.example1] +name = "test-example" +identifier = "io.github.longbridge.wef.tests.example" +category = "Utility" diff --git a/crates/cargo-wef/tests/workspace/example/examples/example1.rs b/crates/cargo-wef/tests/workspace/example/examples/example1.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/cargo-wef/tests/workspace/example/examples/example1.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/cargo-wef/tests/workspace/example/src/lib.rs b/crates/cargo-wef/tests/workspace/example/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/cargo-wef/tests/workspace/example/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/cargo-wef/tests/workspace/package_bin/Cargo.toml b/crates/cargo-wef/tests/workspace/package_bin/Cargo.toml new file mode 100644 index 0000000..6dcc58b --- /dev/null +++ b/crates/cargo-wef/tests/workspace/package_bin/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "package-bin" +version = "0.3.0" +edition = "2024" + +[package.metadata.bundle] +name = "test-package-bin" +identifier = "io.github.longbridge.wef.tests.package-bin" +category = "Utility" diff --git a/crates/cargo-wef/tests/workspace/package_bin/src/main.rs b/crates/cargo-wef/tests/workspace/package_bin/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/crates/cargo-wef/tests/workspace/package_bin/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/crates/webview/.rustfmt.toml b/crates/webview/.rustfmt.toml new file mode 100644 index 0000000..f87905e --- /dev/null +++ b/crates/webview/.rustfmt.toml @@ -0,0 +1,12 @@ +edition = "2021" +newline_style = "unix" +# comments +normalize_comments = true +wrap_comments = true +format_code_in_doc_comments = true +# imports +imports_granularity = "Crate" +group_imports = "StdExternalCrate" +# report +#report_fixme="Unnumbered" +#report_todo="Unnumbered" diff --git a/crates/webview/Cargo.toml b/crates/webview/Cargo.toml new file mode 100644 index 0000000..1e84371 --- /dev/null +++ b/crates/webview/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gpui-webview" +version = "0.1.0" +edition = "2024" +authors = ["sunli "] +license = "Apache-2.0" +homepage = "https://github.com/longbridge/wef" +repository = "https://github.com/longbridge/wef" + +[dependencies] +gpui-component = { git = "https://github.com/longbridge/gpui-component.git" } +gpui = { git = "https://github.com/zed-industries/zed.git" } +wef = { path = "../wef" } +schemars = "1" +serde = { version = "1.0.219", features = ["derive"] } +raw-window-handle = "0.6.2" +rust-i18n = "3" + +image = { version = "0.25.6", default-features = false } + +[lints] +workspace = true diff --git a/crates/webview/LICENSE-APACHE b/crates/webview/LICENSE-APACHE new file mode 100644 index 0000000..e5ee4a2 --- /dev/null +++ b/crates/webview/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE diff --git a/crates/webview/README.md b/crates/webview/README.md new file mode 100644 index 0000000..8fda0a0 --- /dev/null +++ b/crates/webview/README.md @@ -0,0 +1,16 @@ +# WebView support for GPUI + +## Bootstrap + +You should init the Wef first. + +```bash +cargo install cargo-wef +cargo wef init +``` + +## Run example + +```bash +cargo wef run -p wef-example +``` diff --git a/crates/webview/src/browser_handler.rs b/crates/webview/src/browser_handler.rs new file mode 100644 index 0000000..be69234 --- /dev/null +++ b/crates/webview/src/browser_handler.rs @@ -0,0 +1,339 @@ +use std::{rc::Rc, sync::Arc}; + +use gpui::{AnyWindowHandle, AppContext, AsyncApp, ParentElement, RenderImage, Styled, WeakEntity}; +use gpui_component::{ + ContextModal, + input::{InputState, TextInput}, + v_flex, +}; +use wef::{ + BrowserHandler, ContextMenuParams, CursorInfo, CursorType, DirtyRects, Frame, ImageBuffer, + JsDialogCallback, JsDialogType, LogSeverity, LogicalUnit, PaintElementType, Point, Rect, +}; + +use crate::{ + WebView, + context_menu::{ContextMenuInfo, build_context_menu}, + events::*, + frame_view::FrameView, + utils::from_wef_cursor_type, +}; + +/// A Handler implementation for the WebView. +pub(crate) struct WebViewHandler { + window_handle: AnyWindowHandle, + entity: WeakEntity, + cx: AsyncApp, +} + +impl WebViewHandler { + pub(crate) fn new( + window_handle: AnyWindowHandle, + entity: WeakEntity, + cx: AsyncApp, + ) -> Self { + Self { + window_handle, + entity, + cx, + } + } +} + +impl BrowserHandler for WebViewHandler { + fn on_popup_show(&mut self, show: bool) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |webview, _cx| { + webview.popup = show.then(FrameView::default); + }); + } + } + + fn on_popup_position(&mut self, rect: Rect>) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |webview, _cx| { + webview.popup_rect = Some(rect); + }); + } + } + + fn on_created(&mut self) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_webview, cx| { + cx.emit(CreatedEvent); + }); + } + } + + fn on_paint( + &mut self, + type_: PaintElementType, + _dirty_rects: &DirtyRects, + image_buffer: ImageBuffer, + ) { + let image = Arc::new(RenderImage::new([image::Frame::new( + image::ImageBuffer::from_vec( + image_buffer.width(), + image_buffer.height(), + image_buffer.to_vec(), + ) + .unwrap(), + )])); + + _ = self.entity.update(&mut self.cx, |webview, cx| { + match type_ { + PaintElementType::View => webview.main.update(image), + PaintElementType::Popup => { + if let Some(popup_frame) = &mut webview.popup { + popup_frame.update(image); + } + } + } + cx.notify(); + }); + } + + fn on_address_changed(&mut self, frame: Frame, url: &str) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(AddressChangedEvent { + frame, + url: url.to_string(), + }); + }); + } + } + + fn on_title_changed(&mut self, title: &str) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(TitleChangedEvent { + title: title.to_string(), + }); + }); + } + } + + fn on_tooltip(&mut self, text: &str) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(TooltipEvent { + text: text.to_string(), + }); + }); + } + } + + fn on_status_message(&mut self, text: &str) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(StatusMessageEvent { + text: text.to_string(), + }); + }); + } + } + + fn on_console_message( + &mut self, + message: &str, + level: LogSeverity, + source: &str, + line_number: i32, + ) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(ConsoleMessageEvent { + message: message.to_string(), + level, + source: source.to_string(), + line_number, + }); + }); + } + } + + fn on_before_popup(&mut self, url: &str) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(BeforePopupEvent { + url: url.to_string(), + }); + }); + } + } + + fn on_loading_progress_changed(&mut self, progress: f32) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(LoadingProgressChangedEvent { progress }); + }); + } + } + + fn on_loading_state_changed( + &mut self, + is_loading: bool, + can_go_back: bool, + can_go_forward: bool, + ) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(LoadingStateChangedEvent { + is_loading, + can_go_back, + can_go_forward, + }); + }); + } + } + + fn on_load_start(&mut self, frame: Frame) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(LoadStartEvent { frame }); + }); + } + } + + fn on_load_end(&mut self, frame: Frame) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(LoadEndEvent { frame }); + }); + } + } + + fn on_load_error(&mut self, frame: Frame, error_text: &str, failed_url: &str) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |_, cx| { + cx.emit(LoadErrorEvent { + frame, + error_text: error_text.to_string(), + failed_url: failed_url.to_string(), + }); + }); + } + } + + fn on_cursor_changed( + &mut self, + cursor_type: CursorType, + _cursor_info: Option, + ) -> bool { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_entity(&entity, |webview, cx| { + webview.cursor = from_wef_cursor_type(cursor_type); + cx.notify(); + }); + } + true + } + + fn on_context_menu(&mut self, frame: Frame, params: ContextMenuParams) { + if let Some(entity) = self.entity.upgrade() { + _ = self.cx.update_window(self.window_handle, |_, window, cx| { + cx.update_entity(&entity, |webview, cx| { + webview.context_menu = Some(ContextMenuInfo { + crood: Point::new( + LogicalUnit(params.crood.x.0 + webview.bounds.origin.x.0 as i32), + LogicalUnit(params.crood.y.0 + webview.bounds.origin.y.0 as i32), + ), + frame, + menu: build_context_menu(webview, ¶ms, window, cx), + link_url: params.link_url.map(ToString::to_string), + }); + cx.notify(); + }) + }); + } + } + + fn on_js_dialog( + &mut self, + type_: JsDialogType, + message_text: &str, + callback: JsDialogCallback, + ) -> bool { + _ = self.cx.update_window(self.window_handle, |_, window, cx| { + let message_text = message_text.to_string(); + let callback = Rc::new(callback); + + match type_ { + JsDialogType::Alert => { + window.open_modal(cx, move |modal, _, _| { + modal + .footer(|ok, _, window, cx| vec![ok(window, cx)]) + .child(message_text.clone()) + .on_ok({ + let callback = callback.clone(); + move |_, _, _| { + callback.continue_(true, None); + true + } + }) + }); + } + JsDialogType::Confirm => { + window.open_modal(cx, move |modal, _, _| { + modal + .footer(|ok, cancel, window, cx| { + vec![ok(window, cx), cancel(window, cx)] + }) + .child(message_text.clone()) + .on_ok({ + let callback = callback.clone(); + move |_, _, _| { + callback.continue_(true, None); + true + } + }) + .on_cancel({ + let callback = callback.clone(); + move |_, _, _| { + callback.continue_(false, None); + true + } + }) + }); + } + JsDialogType::Prompt { + default_prompt_text, + } => { + let default_prompt_text = default_prompt_text.to_string(); + let input_state = + cx.new(|cx| InputState::new(window, cx).default_value(default_prompt_text)); + window.open_modal(cx, move |modal, _, _| { + modal + .footer(move |ok, cancel, window, cx| { + vec![ok(window, cx), cancel(window, cx)] + }) + .child( + v_flex() + .gap_3() + .child(message_text.clone()) + .child(TextInput::new(&input_state)), + ) + .on_ok({ + let callback = callback.clone(); + let input_state = input_state.clone(); + move |_, _, cx| { + callback.continue_(true, Some(&input_state.read(cx).value())); + true + } + }) + .on_cancel({ + let callback = callback.clone(); + move |_, _, _| { + callback.continue_(false, None); + true + } + }) + }); + } + } + }); + + true + } +} diff --git a/crates/webview/src/context_menu.rs b/crates/webview/src/context_menu.rs new file mode 100644 index 0000000..cbac8d6 --- /dev/null +++ b/crates/webview/src/context_menu.rs @@ -0,0 +1,117 @@ +use gpui::{Action, App, Entity, Window}; +use gpui_component::popup_menu::PopupMenu; +use rust_i18n::t; +use schemars::JsonSchema; +use serde::Deserialize; +use wef::{ContextMenuParams, Frame, LogicalUnit, Point}; + +use crate::WebView; + +#[derive(Action, Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] +#[action(namespace = webview)] +pub(crate) enum ContextMenuAction { + CopyLinkAddress, + Undo, + Redo, + Cut, + Copy, + Paste, + ParseAsPlainText, + SelectAll, + GoBack, + GoForward, + Reload, +} + +pub(crate) struct ContextMenuInfo { + pub(crate) crood: Point>, + pub(crate) frame: Frame, + pub(crate) menu: Entity, + pub(crate) link_url: Option, +} + +pub(crate) fn build_context_menu( + webview: &WebView, + params: &ContextMenuParams, + window: &mut Window, + cx: &mut App, +) -> Entity { + use wef::{ContextMenuEditStateFlags as EditStateFlags, ContextMenuTypeFlags as TypeFlags}; + + PopupMenu::build(window, cx, |mut popmenu, _window, cx| { + if params.type_.contains(TypeFlags::SELECTION) { + popmenu = popmenu.menu( + t!("WebView.ContextMenu.Copy"), + Box::new(ContextMenuAction::Copy), + ); + } + + if params.type_.contains(TypeFlags::LINK) { + popmenu = popmenu.menu( + t!("WebView.ContextMenu.CopyLinkAddress"), + Box::new(ContextMenuAction::CopyLinkAddress), + ); + } else if params.type_.contains(TypeFlags::EDITABLE) { + popmenu = popmenu + .menu_with_disabled( + t!("WebView.ContextMenu.Undo"), + Box::new(ContextMenuAction::Undo), + !params.edit_state_flags.contains(EditStateFlags::CAN_UNDO), + ) + .menu_with_disabled( + t!("WebView.ContextMenu.Redo"), + Box::new(ContextMenuAction::Redo), + !params.edit_state_flags.contains(EditStateFlags::CAN_REDO), + ) + .separator() + .menu_with_disabled( + t!("WebView.ContextMenu.Cut"), + Box::new(ContextMenuAction::Cut), + !params.edit_state_flags.contains(EditStateFlags::CAN_CUT), + ) + .menu_with_disabled( + t!("WebView.ContextMenu.Copy"), + Box::new(ContextMenuAction::Copy), + !params.edit_state_flags.contains(EditStateFlags::CAN_COPY), + ) + .menu_with_disabled( + t!("WebView.ContextMenu.Paste"), + Box::new(ContextMenuAction::Paste), + !params.edit_state_flags.contains(EditStateFlags::CAN_PASTE), + ) + .menu_with_disabled( + t!("WebView.ContextMenu.ParseAsPlainText"), + Box::new(ContextMenuAction::ParseAsPlainText), + !params + .edit_state_flags + .contains(EditStateFlags::CAN_EDIT_RICHLY), + ) + .menu_with_disabled( + t!("WebView.ContextMenu.SelectAll"), + Box::new(ContextMenuAction::SelectAll), + !params + .edit_state_flags + .contains(EditStateFlags::CAN_SELECT_ALL), + ); + } else if params.type_.contains(TypeFlags::PAGE) { + popmenu = popmenu + .menu_with_disabled( + t!("WebView.ContextMenu.Back"), + Box::new(ContextMenuAction::GoBack), + !webview.browser().can_back(), + ) + .menu_with_disabled( + t!("WebView.ContextMenu.Forward"), + Box::new(ContextMenuAction::GoForward), + !webview.browser().can_forward(), + ) + .menu( + t!("WebView.ContextMenu.Reload"), + Box::new(ContextMenuAction::Reload), + ) + } + + cx.notify(); + popmenu + }) +} diff --git a/crates/webview/src/element.rs b/crates/webview/src/element.rs new file mode 100644 index 0000000..6ff4ad7 --- /dev/null +++ b/crates/webview/src/element.rs @@ -0,0 +1,246 @@ +use std::{panic::Location, rc::Rc, sync::Arc}; + +use gpui::{ + App, Bounds, BoxShadow, Corners, DispatchPhase, Element, ElementInputHandler, Entity, + FocusHandle, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, + IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, RenderImage, Size, + StyleRefinement, Styled, Window, hsla, point, px, size, +}; +use wef::{Browser, LogicalUnit, Rect}; + +use crate::{WebView, utils::*}; + +pub(crate) struct WebViewElement { + webview: Entity, + focus_handle: FocusHandle, + browser: Rc, + interactivity: Interactivity, + view_image: Arc, + popup_image: Option<(Rect>, Arc)>, +} + +impl WebViewElement { + pub(crate) fn new( + webview: Entity, + focus_handle: FocusHandle, + browser: Rc, + view_image: Arc, + popup_image: Option<(Rect>, Arc)>, + ) -> Self { + Self { + webview, + focus_handle, + browser, + interactivity: Interactivity::default(), + view_image, + popup_image, + } + } +} + +impl IntoElement for WebViewElement { + type Element = WebViewElement; + + #[inline] + fn into_element(self) -> Self::Element { + self + } +} + +impl Styled for WebViewElement { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.interactivity.base_style + } +} + +impl InteractiveElement for WebViewElement { + fn interactivity(&mut self) -> &mut Interactivity { + &mut self.interactivity + } +} + +impl Element for WebViewElement { + type RequestLayoutState = (); + type PrepaintState = Option; + + fn id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn source_location(&self) -> Option<&'static Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let layout_id = self.interactivity.request_layout( + global_id, + inspector_id, + window, + cx, + |style, window, cx| window.request_layout(style, None, cx), + ); + (layout_id, ()) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.webview.update(cx, |webview, _| { + webview.bounds = bounds; + }); + + self.interactivity.prepaint( + global_id, + inspector_id, + bounds, + bounds.size, + window, + cx, + |_, _, hit_box, _, _| hit_box, + ) + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + window.handle_input( + &self.focus_handle, + ElementInputHandler::new(bounds, self.webview.clone()), + cx, + ); + + self.interactivity.paint( + global_id, + inspector_id, + bounds, + hitbox.as_ref(), + window, + cx, + |_, window, _cx| { + let scale_factor = window.scale_factor(); + self.browser.resize(wef::Size::new( + wef::PhysicalUnit((bounds.size.width.0 * scale_factor) as i32), + wef::PhysicalUnit((bounds.size.height.0 * scale_factor) as i32), + )); + + let image_size = self.view_image.size(0); + _ = window.paint_image( + Bounds::new( + bounds.origin, + Size::new( + px(image_size.width.0 as f32 / scale_factor), + px(image_size.height.0 as f32 / scale_factor), + ), + ), + Corners::all(px(0.0)), + self.view_image.clone(), + 0, + false, + ); + + if let Some((rect, image)) = &self.popup_image { + let bounds = Bounds::new( + point(px(rect.x.0 as f32), px(rect.y.0 as f32)), + size(px(rect.width.0 as f32), px(rect.height.0 as f32)), + ) + bounds.origin; + + let shadows = &[ + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(10.)), + blur_radius: px(15.), + spread_radius: px(-3.), + }, + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(4.)), + blur_radius: px(6.), + spread_radius: px(-4.), + }, + ]; + window.paint_shadows(bounds, Corners::all(px(0.0)), shadows); + + _ = window.paint_image(bounds, Corners::all(px(0.0)), image.clone(), 0, false); + } + }, + ); + + let cursor_style = self.webview.read(cx).cursor; + window.set_cursor_style(cursor_style, hitbox.as_ref().unwrap()); + + window.on_mouse_event({ + let entity = self.webview.clone(); + move |event: &MouseMoveEvent, phase, _, cx| { + let webview = entity.read(cx); + if phase == DispatchPhase::Bubble + && (event.dragging() || bounds.contains(&event.position)) + { + let position = event.position - bounds.origin; + webview.browser().send_mouse_move_event( + wef::Point::new( + wef::LogicalUnit(position.x.0 as i32), + wef::LogicalUnit(position.y.0 as i32), + ), + to_wef_key_modifiers(&event.modifiers), + ); + } + } + }); + + window.on_mouse_event({ + let entity = self.webview.clone(); + move |event: &MouseDownEvent, phase, _, cx| { + let webview = entity.read(cx); + if phase == DispatchPhase::Bubble && bounds.contains(&event.position) { + if let Some(mouse_button) = to_wef_mouse_button(event.button) { + let modifiers = to_wef_key_modifiers(&event.modifiers); + webview.browser().send_mouse_click_event( + mouse_button, + false, + event.click_count, + modifiers, + ); + webview.browser().set_focus(true); + } + } + } + }); + + window.on_mouse_event({ + let entity = self.webview.clone(); + move |event: &MouseUpEvent, phase, _, cx| { + let webview = entity.read(cx); + if phase == DispatchPhase::Bubble && bounds.contains(&event.position) { + if let Some(mouse_button) = to_wef_mouse_button(event.button) { + let modifiers = to_wef_key_modifiers(&event.modifiers); + webview.browser().send_mouse_click_event( + mouse_button, + true, + event.click_count, + modifiers, + ); + } + } + } + }); + } +} diff --git a/crates/webview/src/events.rs b/crates/webview/src/events.rs new file mode 100644 index 0000000..de0a979 --- /dev/null +++ b/crates/webview/src/events.rs @@ -0,0 +1,100 @@ +//! Events for the WebView. + +use wef::{Frame, LogSeverity}; + +/// Emitted when the browser is created. +#[derive(Debug)] +pub struct CreatedEvent; + +/// Emitted when the address of the frame changes. +#[derive(Debug)] +pub struct AddressChangedEvent { + /// The frame object. + pub frame: Frame, + /// The new URL. + pub url: String, +} + +/// Emitted when the title changes. +#[derive(Debug)] +pub struct TitleChangedEvent { + /// The new title. + pub title: String, +} + +/// Emitted when the browser is about to display a tooltip. +#[derive(Debug)] +pub struct TooltipEvent { + /// The tooltip text. + pub text: String, +} + +/// Emitted when the browser receives a status message. +#[derive(Debug)] +pub struct StatusMessageEvent { + /// The status message text. + pub text: String, +} + +/// Emitted when the browser receives a console message. +#[derive(Debug)] +pub struct ConsoleMessageEvent { + /// The console message text. + pub message: String, + /// The log level. + pub level: LogSeverity, + /// The source code file where the message is sent. + pub source: String, + /// The line number in the source code file. + pub line_number: i32, +} + +/// Emitted when preparing to open a popup browser window. +#[derive(Debug)] +pub struct BeforePopupEvent { + /// The URL of the popup window. + pub url: String, +} + +/// Emitted when the overall page loading progress changes. +#[derive(Debug)] +pub struct LoadingProgressChangedEvent { + /// Ranges from 0.0 to 1.0. + pub progress: f32, +} + +/// Emitted when the loading state changes. +#[derive(Debug)] +pub struct LoadingStateChangedEvent { + /// Whether the browser is loading a page. + pub is_loading: bool, + /// Whether the browser can go back in history. + pub can_go_back: bool, + /// Whether the browser can go forward in history. + pub can_go_forward: bool, +} + +/// Emitted when the browser starts loading a page. +#[derive(Debug)] +pub struct LoadStartEvent { + /// The frame object. + pub frame: Frame, +} + +/// Emitted when the browser finishes loading a page. +#[derive(Debug)] +pub struct LoadEndEvent { + /// The frame object. + pub frame: Frame, +} + +/// Emitted when the browser fails to load a page. +#[derive(Debug)] +pub struct LoadErrorEvent { + /// The frame object. + pub frame: Frame, + /// The error text. + pub error_text: String, + /// The uRL that failed to load. + pub failed_url: String, +} diff --git a/crates/webview/src/frame_view.rs b/crates/webview/src/frame_view.rs new file mode 100644 index 0000000..3a5f170 --- /dev/null +++ b/crates/webview/src/frame_view.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use gpui::{RenderImage, Window}; + +#[derive(Debug, Default)] +pub(crate) struct FrameView { + id: usize, + rendered_frame: Option<(usize, Arc)>, + frame: Option<(usize, Arc)>, +} + +impl FrameView { + pub(crate) fn render(&mut self, window: &mut Window) -> Option> { + let (current_frame_id, image) = self.frame.clone()?; + + if let Some((rendered_frame_id, rendered_image)) = self.rendered_frame.take() { + if rendered_frame_id != current_frame_id { + _ = window.drop_image(rendered_image); + } + self.rendered_frame = None; + } + + self.rendered_frame = self.frame.clone(); + Some(image) + } + + pub(crate) fn update(&mut self, image: Arc) { + self.frame = Some((self.id, image)); + self.id += 1; + } + + pub(crate) fn clear(&mut self, window: &mut Window) { + if let Some((_, image)) = self.rendered_frame.take() { + _ = window.drop_image(image); + } + } +} diff --git a/crates/webview/src/lib.rs b/crates/webview/src/lib.rs new file mode 100644 index 0000000..62f16d6 --- /dev/null +++ b/crates/webview/src/lib.rs @@ -0,0 +1,15 @@ +#![doc = include_str!("../README.md")] + +mod browser_handler; +mod context_menu; +mod element; +mod frame_view; +mod utils; +mod webview; + +pub mod events; + +pub use webview::WebView; +pub use wef; + +rust_i18n::i18n!("locales", fallback = "en"); diff --git a/crates/webview/src/utils.rs b/crates/webview/src/utils.rs new file mode 100644 index 0000000..e1eebf5 --- /dev/null +++ b/crates/webview/src/utils.rs @@ -0,0 +1,77 @@ +use gpui::{CursorStyle, Modifiers, MouseButton}; + +pub(crate) fn to_wef_mouse_button(button: MouseButton) -> Option { + Some(match button { + MouseButton::Left => wef::MouseButton::Left, + MouseButton::Middle => wef::MouseButton::Middle, + MouseButton::Right => wef::MouseButton::Right, + _ => return None, + }) +} + +pub(crate) fn to_wef_key_modifiers(modifiers: &Modifiers) -> wef::KeyModifier { + let mut wef_modifiers = wef::KeyModifier::empty(); + if modifiers.shift { + wef_modifiers |= wef::KeyModifier::SHIFT; + } + if modifiers.control { + wef_modifiers |= wef::KeyModifier::CONTROL; + } + if modifiers.alt { + wef_modifiers |= wef::KeyModifier::ALT; + } + wef_modifiers +} + +pub(crate) fn to_wef_key_code(key_code: &str) -> Option { + Some(match key_code { + "backspace" => wef::KeyCode::Backspace, + "delete" => wef::KeyCode::Delete, + "tab" => wef::KeyCode::Tab, + "enter" => wef::KeyCode::Enter, + "pageup" => wef::KeyCode::PageUp, + "pagedown" => wef::KeyCode::PageDown, + "end" => wef::KeyCode::End, + "home" => wef::KeyCode::Home, + "left" => wef::KeyCode::ArrowLeft, + "up" => wef::KeyCode::ArrowUp, + "right" => wef::KeyCode::ArrowRight, + "down" => wef::KeyCode::ArrowDown, + _ => return None, + }) +} + +pub(crate) fn from_wef_cursor_type(cursor: wef::CursorType) -> CursorStyle { + use wef::CursorType::*; + + match cursor { + Cross => CursorStyle::Crosshair, + Hand => CursorStyle::PointingHand, + IBeam => CursorStyle::IBeam, + EastResize => CursorStyle::ResizeRight, + NorthResize => CursorStyle::ResizeUp, + NorthEastResize => CursorStyle::ResizeUpRightDownLeft, + NorthWestResize => CursorStyle::ResizeUpLeftDownRight, + SouthResize => CursorStyle::ResizeDown, + SouthEastResize => CursorStyle::ResizeUpRightDownLeft, + SouthWestResize => CursorStyle::ResizeUpLeftDownRight, + WestResize => CursorStyle::ResizeLeft, + NorthSouthResize => CursorStyle::ResizeUpDown, + EastWestResize => CursorStyle::ResizeLeftRight, + NorthEastSouthWestResize => CursorStyle::ResizeUpRightDownLeft, + NorthWestSouthEastResize => CursorStyle::ResizeUpLeftDownRight, + ColumnResize => CursorStyle::ResizeColumn, + RowResize => CursorStyle::ResizeRow, + ContextMenu => CursorStyle::ContextualMenu, + NoDrop => CursorStyle::OperationNotAllowed, + Copy => CursorStyle::DragCopy, + None => CursorStyle::None, + NotAllowed => CursorStyle::OperationNotAllowed, + Grab => CursorStyle::OpenHand, + Grabbing => CursorStyle::ClosedHand, + DndNone => CursorStyle::None, + DndCopy => CursorStyle::DragCopy, + DndLink => CursorStyle::DragLink, + _ => CursorStyle::Arrow, + } +} diff --git a/crates/webview/src/webview.rs b/crates/webview/src/webview.rs new file mode 100644 index 0000000..95401a6 --- /dev/null +++ b/crates/webview/src/webview.rs @@ -0,0 +1,350 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + App, Bounds, ClipboardItem, CursorStyle, Empty, Entity, EntityInputHandler, EventEmitter, + FocusHandle, Focusable, KeyDownEvent, KeyUpEvent, MouseDownEvent, Pixels, ScrollWheelEvent, + Subscription, UTF16Selection, WeakEntity, Window, anchored, deferred, div, point, prelude::*, + px, +}; +use wef::{Browser, FuncRegistry, LogicalUnit, Point, Rect}; + +use crate::{ + browser_handler::WebViewHandler, + context_menu::{ContextMenuAction, ContextMenuInfo}, + element::WebViewElement, + events::*, + frame_view::FrameView, + utils::*, +}; + +/// A web view based on the Chromium Embedded Framework (CEF). +pub struct WebView { + pub(crate) main: FrameView, + pub(crate) popup: Option, + pub(crate) popup_rect: Option>>, + pub(crate) cursor: CursorStyle, + pub(crate) context_menu: Option, + pub(crate) bounds: Bounds, + focus_handle: FocusHandle, + browser: Rc, + _subscriptions: Vec, +} + +impl WebView { + /// Creates a new `WebView` instance with the given URL. + pub fn new(url: &str, window: &mut Window, cx: &mut App) -> Entity { + Self::with_func_registry(url, FuncRegistry::default(), window, cx) + } + + /// Creates a new `WebView` instance with the given URL and function + /// registry. + pub fn with_func_registry( + url: &str, + function_registry: FuncRegistry, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let window_handle = window.window_handle(); + let entity = cx.new(|cx| { + let entity = cx.entity(); + + let browser = Rc::new( + Browser::builder() + .parent( + raw_window_handle::HasWindowHandle::window_handle(window) + .ok() + .map(|handle| handle.as_raw()), + ) + .device_scale_factor(window.scale_factor()) + .url(url) + .handler(WebViewHandler::new( + window_handle, + entity.downgrade(), + cx.to_async(), + )) + .func_registry(function_registry) + .build(), + ); + + let focus_handle = cx.focus_handle(); + + let _subscriptions = vec![ + cx.on_focus(&focus_handle, window, Self::on_focus), + cx.on_blur(&focus_handle, window, Self::on_blur), + ]; + + Self { + focus_handle, + main: FrameView::default(), + popup: None, + popup_rect: None, + browser, + cursor: CursorStyle::Arrow, + context_menu: None, + bounds: Bounds::default(), + _subscriptions, + } + }); + + cx.observe_release(&entity, |webview, cx| { + for window in cx.windows() { + _ = cx.update_window(window, |_, window, _cx| { + webview.main.clear(window); + if let Some(popup_frame) = &mut webview.popup { + popup_frame.clear(window); + } + }); + } + }) + .detach(); + + entity + } + + /// Returns the browser instance. + #[inline] + pub fn browser(&self) -> &Rc { + &self.browser + } + + fn scroll_wheel_handler( + &mut self, + event: &ScrollWheelEvent, + _window: &mut Window, + _cx: &mut Context, + ) { + let (delta_x, delta_y) = match event.delta { + gpui::ScrollDelta::Pixels(point) => (point.x.0, point.y.0), + gpui::ScrollDelta::Lines(point) => (point.x * 20.0, point.y * 20.0), + }; + self.browser().send_mouse_wheel_event(Point::new( + LogicalUnit(delta_x as i32), + LogicalUnit(delta_y as i32), + )); + } + + fn keydown_handler( + &mut self, + event: &KeyDownEvent, + _window: &mut Window, + _cx: &mut Context, + ) { + let modifiers = to_wef_key_modifiers(&event.keystroke.modifiers); + if let Some(key_code) = to_wef_key_code(&event.keystroke.key) { + self.browser().send_key_event(true, key_code, modifiers); + }; + } + + fn keyup_handler(&mut self, event: &KeyUpEvent, _window: &mut Window, _cx: &mut Context) { + let modifiers = to_wef_key_modifiers(&event.keystroke.modifiers); + let Some(key_code) = to_wef_key_code(&event.keystroke.key) else { + return; + }; + self.browser().send_key_event(false, key_code, modifiers); + } + + fn on_focus(&mut self, _window: &mut Window, _cx: &mut Context) { + self.browser().set_focus(true); + } + + fn on_blur(&mut self, _window: &mut Window, _cx: &mut Context) { + self.browser().set_focus(false); + } + + fn on_context_menu_action( + &mut self, + action: &ContextMenuAction, + _window: &mut Window, + cx: &mut Context, + ) { + use ContextMenuAction::*; + + if let Some(info) = self.context_menu.take() { + let action = *action; + + cx.spawn(async move |webview: WeakEntity, cx| { + let Ok(browser) = webview.read_with(cx, |webview, _cx| webview.browser().clone()) + else { + return; + }; + match action { + CopyLinkAddress => { + if let Some(link_url) = &info.link_url { + _ = cx.update(|cx| { + cx.write_to_clipboard(ClipboardItem::new_string(link_url.clone())) + }); + } + } + Undo => info.frame.undo(), + Redo => info.frame.redo(), + Cut => info.frame.cut(), + Copy => info.frame.copy(), + Paste => info.frame.paste(), + ParseAsPlainText => info.frame.paste_and_match_style(), + SelectAll => info.frame.select_all(), + GoBack => browser.back(), + GoForward => browser.forward(), + Reload => browser.reload(), + } + }) + .detach(); + } + } + + fn context_menu_mousedown_out_handler( + &mut self, + _event: &MouseDownEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.context_menu = None; + cx.notify(); + } +} + +impl Focusable for WebView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for WebView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(image) = self.main.render(window) else { + return Empty.into_any_element(); + }; + + let mut root = div().size_full().child( + WebViewElement::new( + cx.entity(), + self.focus_handle.clone(), + self.browser.clone(), + image.clone(), + self.popup_rect + .zip(self.popup.as_mut().and_then(|f| f.render(window))), + ) + .size_full() + .track_focus(&self.focus_handle) + .on_scroll_wheel(cx.listener(Self::scroll_wheel_handler)) + .on_key_down(cx.listener(Self::keydown_handler)) + .on_key_up(cx.listener(Self::keyup_handler)), + ); + + if let Some(info) = &self.context_menu { + root = root.child(deferred( + anchored() + .position(point(px(info.crood.x.0 as f32), px(info.crood.y.0 as f32))) + .child( + div() + .child(info.menu.clone()) + .shadow_2xl() + .on_mouse_down_out( + cx.listener(Self::context_menu_mousedown_out_handler), + ), + ), + )); + } + + root.on_action(cx.listener(Self::on_context_menu_action)) + .into_any_element() + } +} + +impl EntityInputHandler for WebView { + fn text_for_range( + &mut self, + _range: Range, + _adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn replace_text_in_range( + &mut self, + _range: Option>, + text: &str, + _window: &mut Window, + _cx: &mut Context, + ) { + self.browser().ime_commit(text); + } + + fn replace_and_mark_text_in_range( + &mut self, + _range: Option>, + new_text: &str, + new_selected_range: Option>, + _window: &mut Window, + _cx: &mut Context, + ) { + let new_selected_range = new_selected_range.unwrap_or_default(); + self.browser().ime_set_composition( + new_text, + new_selected_range.start, + new_selected_range.end, + ); + } + + fn bounds_for_range( + &mut self, + _range_utf16: Range, + _element_bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } +} + +macro_rules! impl_emiter { + ($($ty:ty),*) => { + $( + impl EventEmitter<$ty> for WebView {} + )* + }; +} + +impl_emiter!( + LoadingProgressChangedEvent, + CreatedEvent, + AddressChangedEvent, + TitleChangedEvent, + TooltipEvent, + StatusMessageEvent, + ConsoleMessageEvent, + BeforePopupEvent, + LoadingStateChangedEvent, + LoadStartEvent, + LoadEndEvent, + LoadErrorEvent +); diff --git a/crates/webview/tests/index.html b/crates/webview/tests/index.html new file mode 100644 index 0000000..6bd541c --- /dev/null +++ b/crates/webview/tests/index.html @@ -0,0 +1,48 @@ + + + + + + Test Cases + + + +

Test Cases

+ + + diff --git a/crates/webview/tests/input.html b/crates/webview/tests/input.html new file mode 100644 index 0000000..8419be7 --- /dev/null +++ b/crates/webview/tests/input.html @@ -0,0 +1,83 @@ + + + + + + Text Input + + + +

Text Input

+
+ + + + + + + +
+

+ + + + diff --git a/crates/webview/tests/jsbridge.html b/crates/webview/tests/jsbridge.html new file mode 100644 index 0000000..642916a --- /dev/null +++ b/crates/webview/tests/jsbridge.html @@ -0,0 +1,103 @@ + + + + + + JSBridge + + + +

JSBridge

+
+

Call Native Functions

+ + + + + + +
+

+ + + + diff --git a/crates/webview/tests/jsdialog.html b/crates/webview/tests/jsdialog.html new file mode 100644 index 0000000..ce931cf --- /dev/null +++ b/crates/webview/tests/jsdialog.html @@ -0,0 +1,33 @@ + + + + + + JS Dialog + + + +

JavaScript Dialog

+ + + + + diff --git a/crates/webview/tests/upload.html b/crates/webview/tests/upload.html new file mode 100644 index 0000000..503b032 --- /dev/null +++ b/crates/webview/tests/upload.html @@ -0,0 +1,69 @@ + + + + + + File Upload + + + +

File Upload

+
+
+
+ +
+

+ + + + diff --git a/crates/wef/.rustfmt.toml b/crates/wef/.rustfmt.toml new file mode 100644 index 0000000..eec724b --- /dev/null +++ b/crates/wef/.rustfmt.toml @@ -0,0 +1,12 @@ +edition = "2021" +newline_style = "unix" +# comments +normalize_comments = true +wrap_comments = true +format_code_in_doc_comments = true +# imports +imports_granularity = "Crate" +group_imports = "StdExternalCrate" +# report +#report_fixme="Unnumbered" +#report_todo="Unnumbered" \ No newline at end of file diff --git a/crates/wef/Cargo.toml b/crates/wef/Cargo.toml new file mode 100644 index 0000000..c9c7120 --- /dev/null +++ b/crates/wef/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "wef" +version = "0.7.0" +edition = "2024" +authors = ["sunli "] +license = "Apache-2.0" +homepage = "https://github.com/longbridge/wef" +repository = "https://github.com/longbridge/wef" +description = "Wef is a Rust library for embedding WebView functionality using Chromium Embedded Framework (CEF3) with offscreen rendering support." + +[build-dependencies] +cc = { version = "1.2.18", features = ["parallel"] } +pkg-config = "0.3.32" +dirs = "6.0.0" + +[dependencies] +image = { version = "0.25.6", default-features = false } +bitflags = "2.9.0" +thiserror = "2.0.12" +num_enum = "0.7.3" +mime = "0.3.17" +raw-window-handle = "0.6.2" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +tuple_len = "3.0.0" +futures-util = "0.3.31" + +[lints] +workspace = true \ No newline at end of file diff --git a/crates/wef/LICENSE-APACHE b/crates/wef/LICENSE-APACHE new file mode 100644 index 0000000..e5ee4a2 --- /dev/null +++ b/crates/wef/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE diff --git a/crates/wef/README.md b/crates/wef/README.md new file mode 100644 index 0000000..ae42a26 --- /dev/null +++ b/crates/wef/README.md @@ -0,0 +1 @@ +../../README.md diff --git a/crates/wef/build.rs b/crates/wef/build.rs new file mode 100644 index 0000000..1c41574 --- /dev/null +++ b/crates/wef/build.rs @@ -0,0 +1,305 @@ +use std::path::{Path, PathBuf}; + +use cc::Build; + +/// Return the CEF_ROOT env or default path: `$HOME/.cef` +fn cef_root() -> PathBuf { + if let Ok(path) = std::env::var("CEF_ROOT") { + return Path::new(&path).to_path_buf(); + } + + dirs::home_dir().expect("get home_dir").join(".cef") +} + +fn main() { + let cef_root = cef_root(); + println!("cargo::rerun-if-changed={}", cef_root.display()); + + let profile = match std::env::var("DEBUG") { + Ok(s) if s != "false" => "Debug", + _ => "Release", + }; + + let cef_link_search_path = cef_root.join(profile); + println!( + "cargo:rustc-link-search=native={}", + cef_link_search_path.display() + ); + + if cfg!(target_os = "windows") { + println!("cargo:rustc-link-lib=libcef"); + } else if cfg!(target_os = "linux") { + println!("cargo:rustc-link-lib=cef"); + } else if cfg!(target_os = "macos") { + println!("cargo:rustc-link-lib=framework=AppKit"); + println!("cargo:rustc-link-lib=sandbox"); + + // FIXME: Failed to link to `cef_sandbox.a` on macOS/ + // + // Workaround: copy `cef_sandbox.a` to the output directory and link it as + // `libcef_sandbox.a` + // + // https://github.com/rust-lang/rust/issues/132264 + let outdir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + std::fs::copy( + cef_link_search_path.join("cef_sandbox.a"), + outdir.join("libcef_sandbox.a"), + ) + .expect("copy cef_sandbox.a"); + println!("cargo:rustc-link-search=native={}", outdir.display()); + println!("cargo:rustc-link-lib=static=cef_sandbox"); + } + + build_dll_wrapper(&cef_root); + build_wef_sys(&cef_root); +} + +fn build_dll_wrapper(cef_root: &Path) { + #[allow(unused_mut)] + let mut sources = vec![ + "shutdown_checker.cc", + "transfer_util.cc", + "base/cef_atomic_flag.cc", + "base/cef_callback_helpers.cc", + "base/cef_callback_internal.cc", + "base/cef_dump_without_crashing.cc", + "base/cef_lock.cc", + "base/cef_lock_impl.cc", + "base/cef_logging.cc", + "base/cef_ref_counted.cc", + "base/cef_thread_checker_impl.cc", + "base/cef_weak_ptr.cc", + "cpptoc/accessibility_handler_cpptoc.cc", + "cpptoc/app_cpptoc.cc", + "cpptoc/audio_handler_cpptoc.cc", + "cpptoc/base_ref_counted_cpptoc.cc", + "cpptoc/base_scoped_cpptoc.cc", + "cpptoc/browser_process_handler_cpptoc.cc", + "cpptoc/client_cpptoc.cc", + "cpptoc/command_handler_cpptoc.cc", + "cpptoc/completion_callback_cpptoc.cc", + "cpptoc/context_menu_handler_cpptoc.cc", + "cpptoc/cookie_access_filter_cpptoc.cc", + "cpptoc/cookie_visitor_cpptoc.cc", + "cpptoc/delete_cookies_callback_cpptoc.cc", + "cpptoc/dev_tools_message_observer_cpptoc.cc", + "cpptoc/dialog_handler_cpptoc.cc", + "cpptoc/display_handler_cpptoc.cc", + "cpptoc/domvisitor_cpptoc.cc", + "cpptoc/download_handler_cpptoc.cc", + "cpptoc/download_image_callback_cpptoc.cc", + "cpptoc/drag_handler_cpptoc.cc", + "cpptoc/end_tracing_callback_cpptoc.cc", + "cpptoc/find_handler_cpptoc.cc", + "cpptoc/focus_handler_cpptoc.cc", + "cpptoc/frame_handler_cpptoc.cc", + "cpptoc/jsdialog_handler_cpptoc.cc", + "cpptoc/keyboard_handler_cpptoc.cc", + "cpptoc/life_span_handler_cpptoc.cc", + "cpptoc/load_handler_cpptoc.cc", + "cpptoc/media_observer_cpptoc.cc", + "cpptoc/media_route_create_callback_cpptoc.cc", + "cpptoc/media_sink_device_info_callback_cpptoc.cc", + "cpptoc/menu_model_delegate_cpptoc.cc", + "cpptoc/navigation_entry_visitor_cpptoc.cc", + "cpptoc/pdf_print_callback_cpptoc.cc", + "cpptoc/permission_handler_cpptoc.cc", + "cpptoc/preference_observer_cpptoc.cc", + "cpptoc/print_handler_cpptoc.cc", + "cpptoc/read_handler_cpptoc.cc", + "cpptoc/render_handler_cpptoc.cc", + "cpptoc/render_process_handler_cpptoc.cc", + "cpptoc/request_context_handler_cpptoc.cc", + "cpptoc/request_handler_cpptoc.cc", + "cpptoc/resolve_callback_cpptoc.cc", + "cpptoc/resource_bundle_handler_cpptoc.cc", + "cpptoc/resource_handler_cpptoc.cc", + "cpptoc/resource_request_handler_cpptoc.cc", + "cpptoc/response_filter_cpptoc.cc", + "cpptoc/run_file_dialog_callback_cpptoc.cc", + "cpptoc/scheme_handler_factory_cpptoc.cc", + "cpptoc/server_handler_cpptoc.cc", + "cpptoc/set_cookie_callback_cpptoc.cc", + "cpptoc/setting_observer_cpptoc.cc", + "cpptoc/string_visitor_cpptoc.cc", + "cpptoc/task_cpptoc.cc", + "cpptoc/urlrequest_client_cpptoc.cc", + "cpptoc/v8_accessor_cpptoc.cc", + "cpptoc/v8_array_buffer_release_callback_cpptoc.cc", + "cpptoc/v8_handler_cpptoc.cc", + "cpptoc/v8_interceptor_cpptoc.cc", + "cpptoc/write_handler_cpptoc.cc", + "cpptoc/views/browser_view_delegate_cpptoc.cc", + "cpptoc/views/button_delegate_cpptoc.cc", + "cpptoc/views/menu_button_delegate_cpptoc.cc", + "cpptoc/views/panel_delegate_cpptoc.cc", + "cpptoc/views/textfield_delegate_cpptoc.cc", + "cpptoc/views/view_delegate_cpptoc.cc", + "cpptoc/views/window_delegate_cpptoc.cc", + "ctocpp/auth_callback_ctocpp.cc", + "ctocpp/before_download_callback_ctocpp.cc", + "ctocpp/binary_value_ctocpp.cc", + "ctocpp/browser_ctocpp.cc", + "ctocpp/browser_host_ctocpp.cc", + "ctocpp/callback_ctocpp.cc", + "ctocpp/command_line_ctocpp.cc", + "ctocpp/context_menu_params_ctocpp.cc", + "ctocpp/cookie_manager_ctocpp.cc", + "ctocpp/dictionary_value_ctocpp.cc", + "ctocpp/domdocument_ctocpp.cc", + "ctocpp/domnode_ctocpp.cc", + "ctocpp/download_item_callback_ctocpp.cc", + "ctocpp/download_item_ctocpp.cc", + "ctocpp/drag_data_ctocpp.cc", + "ctocpp/file_dialog_callback_ctocpp.cc", + "ctocpp/frame_ctocpp.cc", + "ctocpp/image_ctocpp.cc", + "ctocpp/jsdialog_callback_ctocpp.cc", + "ctocpp/list_value_ctocpp.cc", + "ctocpp/media_access_callback_ctocpp.cc", + "ctocpp/media_route_ctocpp.cc", + "ctocpp/media_router_ctocpp.cc", + "ctocpp/media_sink_ctocpp.cc", + "ctocpp/media_source_ctocpp.cc", + "ctocpp/menu_model_ctocpp.cc", + "ctocpp/navigation_entry_ctocpp.cc", + "ctocpp/permission_prompt_callback_ctocpp.cc", + "ctocpp/post_data_ctocpp.cc", + "ctocpp/post_data_element_ctocpp.cc", + "ctocpp/preference_manager_ctocpp.cc", + "ctocpp/preference_registrar_ctocpp.cc", + "ctocpp/print_dialog_callback_ctocpp.cc", + "ctocpp/print_job_callback_ctocpp.cc", + "ctocpp/print_settings_ctocpp.cc", + "ctocpp/process_message_ctocpp.cc", + "ctocpp/registration_ctocpp.cc", + "ctocpp/request_context_ctocpp.cc", + "ctocpp/request_ctocpp.cc", + "ctocpp/resource_bundle_ctocpp.cc", + "ctocpp/resource_read_callback_ctocpp.cc", + "ctocpp/resource_skip_callback_ctocpp.cc", + "ctocpp/response_ctocpp.cc", + "ctocpp/run_context_menu_callback_ctocpp.cc", + "ctocpp/run_quick_menu_callback_ctocpp.cc", + "ctocpp/scheme_registrar_ctocpp.cc", + "ctocpp/select_client_certificate_callback_ctocpp.cc", + "ctocpp/server_ctocpp.cc", + "ctocpp/shared_memory_region_ctocpp.cc", + "ctocpp/shared_process_message_builder_ctocpp.cc", + "ctocpp/sslinfo_ctocpp.cc", + "ctocpp/sslstatus_ctocpp.cc", + "ctocpp/stream_reader_ctocpp.cc", + "ctocpp/stream_writer_ctocpp.cc", + "ctocpp/task_manager_ctocpp.cc", + "ctocpp/task_runner_ctocpp.cc", + "ctocpp/thread_ctocpp.cc", + "ctocpp/unresponsive_process_callback_ctocpp.cc", + "ctocpp/urlrequest_ctocpp.cc", + "ctocpp/v8_context_ctocpp.cc", + "ctocpp/v8_exception_ctocpp.cc", + "ctocpp/v8_stack_frame_ctocpp.cc", + "ctocpp/v8_stack_trace_ctocpp.cc", + "ctocpp/v8_value_ctocpp.cc", + "ctocpp/value_ctocpp.cc", + "ctocpp/waitable_event_ctocpp.cc", + "ctocpp/x509_cert_principal_ctocpp.cc", + "ctocpp/x509_certificate_ctocpp.cc", + "ctocpp/xml_reader_ctocpp.cc", + "ctocpp/zip_reader_ctocpp.cc", + "ctocpp/views/box_layout_ctocpp.cc", + "ctocpp/views/browser_view_ctocpp.cc", + "ctocpp/views/button_ctocpp.cc", + "ctocpp/views/display_ctocpp.cc", + "ctocpp/views/fill_layout_ctocpp.cc", + "ctocpp/views/label_button_ctocpp.cc", + "ctocpp/views/layout_ctocpp.cc", + "ctocpp/views/menu_button_ctocpp.cc", + "ctocpp/views/menu_button_pressed_lock_ctocpp.cc", + "ctocpp/views/overlay_controller_ctocpp.cc", + "ctocpp/views/panel_ctocpp.cc", + "ctocpp/views/scroll_view_ctocpp.cc", + "ctocpp/views/textfield_ctocpp.cc", + "ctocpp/views/view_ctocpp.cc", + "ctocpp/views/window_ctocpp.cc", + "wrapper/cef_byte_read_handler.cc", + "wrapper/cef_closure_task.cc", + "wrapper/cef_message_router.cc", + "wrapper/cef_message_router_utils.cc", + "wrapper/cef_resource_manager.cc", + "wrapper/cef_scoped_temp_dir.cc", + "wrapper/cef_stream_resource_handler.cc", + "wrapper/cef_xml_object.cc", + "wrapper/cef_zip_archive.cc", + "wrapper/libcef_dll_wrapper.cc", + "wrapper/libcef_dll_wrapper2.cc", + ]; + + if cfg!(target_os = "macos") { + sources.extend([ + "wrapper/cef_library_loader_mac.mm", + "wrapper/libcef_dll_dylib.cc", + ]); + } + + let sources = sources + .into_iter() + .map(|path| cef_root.join("libcef_dll").join(path)); + + Build::new() + .cpp(true) + .std("c++17") + .cargo_warnings(false) + .include(cef_root) + .define("NOMINMAX", None) + .define("WRAPPING_CEF_SHARED", None) + .files(sources) + .compile("cef-dll-wrapper"); +} + +fn build_wef_sys(cef_root: &Path) { + println!("cargo::rerun-if-changed=cpp"); + + #[allow(unused_mut)] + let mut sources = vec![ + "cpp/wef.cpp", + "cpp/client.cpp", + "cpp/dirty_rect.cpp", + "cpp/frame.cpp", + "cpp/file_dialog.cpp", + "cpp/cursor.cpp", + "cpp/js_dialog.cpp", + "cpp/query.cpp", + "cpp/external_pump.cpp", + ]; + + if cfg!(target_os = "windows") { + sources.extend(["cpp/external_pump_win.cpp"]); + } else if cfg!(target_os = "macos") { + sources.extend([ + "cpp/load_library.cpp", + "cpp/sandbox_context.cpp", + "cpp/external_pump_mac.mm", + ]); + } else if cfg!(target_os = "linux") { + sources.extend(["cpp/external_pump_linux.cpp"]); + } + + let mut build = Build::new(); + + if cfg!(target_os = "linux") { + build.includes( + pkg_config::probe_library("glib-2.0") + .unwrap_or_else(|err| panic!("failed to find glib-2.0: {}", err)) + .include_paths, + ); + } + + build + .cpp(true) + .std("c++17") + .cargo_warnings(false) + .files(sources) + .include(cef_root) + .define("NOMINMAX", None) + .compile("wef-sys"); +} \ No newline at end of file diff --git a/crates/wef/cpp/app.h b/crates/wef/cpp/app.h new file mode 100644 index 0000000..18060e8 --- /dev/null +++ b/crates/wef/cpp/app.h @@ -0,0 +1,80 @@ +#pragma once + +#include + +#include "app_callbacks.h" +#include "external_pump.h" +#include "include/cef_app.h" +#include "include/wrapper/cef_message_router.h" +#include "utils.h" + +const int64_t MAX_TIMER_DELAY = 1000 / 60; + +class WefApp : public CefApp, public CefBrowserProcessHandler { + IMPLEMENT_REFCOUNTING(WefApp); + + private: + std::optional> external_pump_; + AppCallbacks callbacks_; + void* userdata_; + DestroyFn destroy_userdata_; + + public: + WefApp(AppCallbacks callbacks, void* userdata, DestroyFn destroy_userdata) + : callbacks_(callbacks), + userdata_(userdata), + destroy_userdata_(destroy_userdata), + external_pump_(std::make_optional(ExternalPump::Create())) {} + + virtual ~WefApp() { + if (destroy_userdata_) { + destroy_userdata_(userdata_); + userdata_ = nullptr; + } + } + + ///////////////////////////////////////////////////////////////// + // CefApp methods + ///////////////////////////////////////////////////////////////// + virtual void OnBeforeCommandLineProcessing( + const CefString& process_type, + CefRefPtr command_line) override { + if (process_type.empty()) { + // Use software rendering and compositing (disable GPU) for increased FPS + // and decreased CPU usage. This will also disable WebGL so remove these + // switches if you need that capability. + // See https://github.com/chromiumembedded/cef/issues/1257 for details. + // + // NOTE: If GPU rendering is not disabled, sometimes there will be issues + // with incorrect dimensions when changing the window size. + command_line->AppendSwitch("disable-gpu"); + command_line->AppendSwitch("disable-gpu-compositing"); + } + +#ifdef __APPLE__ + command_line->AppendSwitch("use-mock-keychain"); +#endif + } + + CefRefPtr GetBrowserProcessHandler() override { + return this; + } + + ///////////////////////////////////////////////////////////////// + // CefBrowserProcessHandler methods + ///////////////////////////////////////////////////////////////// + bool OnAlreadyRunningAppRelaunch( + CefRefPtr command_line, + const CefString& current_directory) override { + return true; + } + + void OnScheduleMessagePumpWork(int64_t delay_ms) override { + if (external_pump_) { + (*external_pump_)->OnScheduleMessagePumpWork(delay_ms); + } + + callbacks_.on_schedule_message_pump_work( + userdata_, static_cast(std::min(delay_ms, MAX_TIMER_DELAY))); + } +}; diff --git a/crates/wef/cpp/app_callbacks.h b/crates/wef/cpp/app_callbacks.h new file mode 100644 index 0000000..3fb4716 --- /dev/null +++ b/crates/wef/cpp/app_callbacks.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +struct AppCallbacks { + void (*on_schedule_message_pump_work)(void* userdata, int delay_ms); +}; diff --git a/crates/wef/cpp/app_render_process.h b/crates/wef/cpp/app_render_process.h new file mode 100644 index 0000000..3d3c243 --- /dev/null +++ b/crates/wef/cpp/app_render_process.h @@ -0,0 +1,74 @@ +#pragma once + +#include + +#include "include/cef_app.h" +#include "include/wrapper/cef_message_router.h" + +class WefRenderProcessHandler : public CefRenderProcessHandler { + IMPLEMENT_REFCOUNTING(WefRenderProcessHandler); + + private: + CefRefPtr message_router_; + std::map inject_javascript_map_; + + public: + WefRenderProcessHandler() { + CefMessageRouterConfig config; + message_router_ = CefMessageRouterRendererSide::Create(config); + } + + void OnBrowserCreated(CefRefPtr browser, + CefRefPtr extra_info) override { + auto inject_javascript = extra_info->GetString("__wef_inject_javascript"); + inject_javascript_map_[browser->GetIdentifier()] = inject_javascript; + } + + void OnBrowserDestroyed(CefRefPtr browser) override { + inject_javascript_map_.erase(browser->GetIdentifier()); + } + + void OnContextCreated(CefRefPtr browser, + CefRefPtr frame, + CefRefPtr context) override { + if (frame->IsMain()) { + auto it = inject_javascript_map_.find(browser->GetIdentifier()); + if (it != inject_javascript_map_.end()) { + auto inject_javascript = it->second; + if (!inject_javascript.empty()) { + frame->ExecuteJavaScript(inject_javascript, frame->GetURL(), 0); + } + } + } + message_router_->OnContextCreated(browser, frame, context); + } + + void OnContextReleased(CefRefPtr browser, + CefRefPtr frame, + CefRefPtr context) override { + message_router_->OnContextReleased(browser, frame, context); + } + + bool OnProcessMessageReceived(CefRefPtr browser, + CefRefPtr frame, + CefProcessId source_process, + CefRefPtr message) override { + return message_router_->OnProcessMessageReceived(browser, frame, + source_process, message); + } +}; + +class WefRenderProcessApp : public CefApp, public CefRenderProcessHandler { + IMPLEMENT_REFCOUNTING(WefRenderProcessApp); + + private: + CefRefPtr render_process_handler_; + + public: + WefRenderProcessApp() + : render_process_handler_(new WefRenderProcessHandler()) {} + + CefRefPtr GetRenderProcessHandler() { + return render_process_handler_; + } +}; \ No newline at end of file diff --git a/crates/wef/cpp/browser_callbacks.h b/crates/wef/cpp/browser_callbacks.h new file mode 100644 index 0000000..c0f3d66 --- /dev/null +++ b/crates/wef/cpp/browser_callbacks.h @@ -0,0 +1,67 @@ +#pragma once + +#include "include/cef_client.h" +#include "include/cef_frame.h" +#include "include/wrapper/cef_message_router.h" + +struct _ContextMenuParams { + int x_crood; + int y_crood; + int type_flags; + const char* link_url; + const char* unfiltered_link_url; + const char* source_url; + bool has_image_contents; + const char* title_text; + const char* page_url; + const char* frame_url; + int media_type; + int media_state_flags; + const char* selection_text; + bool is_editable; + int edit_state_flags; +}; + +struct BrowserCallbacks { + void (*on_created)(void* userdata); + void (*on_closed)(void* userdata); + void (*on_popup_show)(void* userdata, bool show); + void (*on_popup_position)(void* userdata, const CefRect* rect); + void (*on_paint)(void* userdata, int type, const void* dirty_rects, + const void* buffer, unsigned int width, unsigned int height); + void (*on_address_changed)(void* userdata, void* frame, const char* url); + void (*on_title_changed)(void* userdata, const char* title); + void (*on_favicon_url_change)(void* userdata, const char** urls, int); + void (*on_tooltip)(void* userdata, const char* text); + void (*on_status_message)(void* userdata, const char* text); + void (*on_console_message)(void* userdata, const char* message, int level, + const char* source, int line); + bool (*on_cursor_changed)(void* userdata, int cursor_type, + const void* custom_cursor_info); + void (*on_before_popup)(void* userdata, const char* url); + void (*on_loading_progress_changed)(void* userdata, float progress); + void (*on_loading_state_changed)(void* userdata, bool is_loading, + bool can_go_back, bool can_go_forward); + void (*on_load_start)(void* userdata, void* frame); + void (*on_load_end)(void* userdata, void* frame); + void (*on_load_error)(void* userdata, void* frame, const char* error_text, + const char* failed_url); + void (*on_ime_composition_range_changed)(void* userdata, const CefRect* rect); + bool (*on_file_dialog)(void* userdata, int mode, const char* title, + const char* default_file_path, + const char* accept_filters, + const char* accept_extensions, + const char* accept_descriptions, + CefRefPtr* callback); + void (*on_context_menu)(void* userdata, void* frame, + const _ContextMenuParams* params); + void (*on_find_result)(void* userdata, int identifier, int count, + const CefRect* selection_rect, + int active_match_ordinal, bool final_update); + bool (*on_js_dialog)(void* userdata, int type, const char* message_text, + const char* default_prompt_text, + CefRefPtr* callback); + void (*on_query)( + void* userdata, void* frame, const char* payload, + CefRefPtr* callback); +}; \ No newline at end of file diff --git a/crates/wef/cpp/client.cpp b/crates/wef/cpp/client.cpp new file mode 100644 index 0000000..48f02bf --- /dev/null +++ b/crates/wef/cpp/client.cpp @@ -0,0 +1,487 @@ +#include "client.h" + +#include + +#include "include/base/cef_bind.h" +#include "include/base/cef_callback.h" +#include "include/cef_browser.h" +#include "include/cef_task.h" +#include "include/wrapper/cef_closure_task.h" + +WefClient::WefClient(std::shared_ptr state) + : state_(state) {} + +WefClient::~WefClient() { + if (state_->browser) { + (*state_->browser)->GetHost()->CloseBrowser(true); + } +} + +///////////////////////////////////////////////////////////////// +// CefRenderHandler methods +///////////////////////////////////////////////////////////////// +void WefClient::OnPopupShow(CefRefPtr browser, bool show) { + DCHECK(CefCurrentlyOn(TID_UI)); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_popup_show(userdata, show); + }); +} + +void WefClient::OnPopupSize(CefRefPtr browser, + const CefRect& rect) { + DCHECK(CefCurrentlyOn(TID_UI)); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_popup_position(userdata, &rect); + }); +} + +void WefClient::OnPaint(CefRefPtr browser, PaintElementType type, + const RectList& dirtyRects, const void* buffer, + int width, int height) { + DCHECK(CefCurrentlyOn(TID_UI)); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_paint(userdata, static_cast(type), &dirtyRects, + buffer, static_cast(width), + static_cast(height)); + }); +} + +void WefClient::OnImeCompositionRangeChanged(CefRefPtr browser, + const CefRange& selected_range, + const RectList& character_bounds) { + DCHECK(CefCurrentlyOn(TID_UI)); + + int xmin = std::numeric_limits::max(); + int ymin = std::numeric_limits::max(); + int xmax = std::numeric_limits::min(); + int ymax = std::numeric_limits::min(); + + for (const auto& r : character_bounds) { + if (r.x < xmin) { + xmin = r.x; + } + if (r.y < ymin) { + ymin = r.y; + } + if (r.x + r.width > xmax) { + xmax = r.x + r.width; + } + if (r.y + r.height > ymax) { + ymax = r.y + r.height; + } + } + + CefRect rect{int(float(xmin)), int(float(ymin)), int(float(xmax - xmin)), + int(float(ymax - ymin))}; + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_ime_composition_range_changed(userdata, &rect); + }); +} + +bool WefClient::OnCursorChange(CefRefPtr browser, + CefCursorHandle cursor, cef_cursor_type_t type, + const CefCursorInfo& custom_cursor_info) { + DCHECK(CefCurrentlyOn(TID_UI)); + + bool result = false; + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + result = callbacks.on_cursor_changed( + userdata, static_cast(type), + type == CT_CUSTOM ? &custom_cursor_info : nullptr); + }); + return result; +} + +///////////////////////////////////////////////////////////////// +// CefDisplayHandler methods +///////////////////////////////////////////////////////////////// +void WefClient::OnAddressChange(CefRefPtr browser, + CefRefPtr frame, + const CefString& url) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto url_str = url.ToString(); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_address_changed(userdata, new WefFrame{frame}, + url_str.c_str()); + }); +} + +void WefClient::OnTitleChange(CefRefPtr browser, + const CefString& title) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto title_str = title.ToString(); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_title_changed(userdata, title_str.c_str()); + }); +} + +void WefClient::OnFaviconURLChange(CefRefPtr browser, + const std::vector& icon_urls) { + DCHECK(CefCurrentlyOn(TID_UI)); + + std::vector str_urls; + std::transform(icon_urls.begin(), icon_urls.end(), + std::back_inserter(str_urls), + [](const CefString& url) { return url.ToString(); }); + + std::vector cstr_urls; + std::transform(str_urls.begin(), str_urls.end(), + std::back_inserter(cstr_urls), + [](const std::string& url) { return url.c_str(); }); + + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_favicon_url_change(userdata, cstr_urls.data(), + static_cast(cstr_urls.size())); + }); +} + +bool WefClient::OnTooltip(CefRefPtr browser, CefString& text) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto text_str = text.ToString(); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_tooltip(userdata, text_str.c_str()); + }); + return true; +} + +void WefClient::OnStatusMessage(CefRefPtr browser, + const CefString& value) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto text_str = value.ToString(); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_status_message(userdata, text_str.c_str()); + }); +} + +bool WefClient::OnConsoleMessage(CefRefPtr browser, + cef_log_severity_t level, + const CefString& message, + const CefString& source, int line) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto message_str = message.ToString(); + auto source_str = source.ToString(); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_console_message(userdata, message_str.c_str(), + static_cast(level), + source_str.c_str(), line); + }); + return false; +} + +void WefClient::OnLoadingProgressChange(CefRefPtr browser, + double progress) { + DCHECK(CefCurrentlyOn(TID_UI)); + + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_loading_progress_changed(userdata, + static_cast(progress)); + }); +} + +///////////////////////////////////////////////////////////////// +// CefLifeSpanHandler methods +///////////////////////////////////////////////////////////////// +void WefClient::OnAfterCreated(CefRefPtr browser) { + CefMessageRouterConfig config; + message_router_ = CefMessageRouterBrowserSide::Create(config); + message_router_->AddHandler(this, false); + + state_->browser = browser; + state_->browser_state = BrowserState::Created; + + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_created(userdata); + }); + + if (state_->browser_state == BrowserState::Closed) { + CefPostTask(TID_UI, base::BindOnce(&CefBrowserHost::CloseBrowser, + browser->GetHost(), false)); + } +} + +bool WefClient::OnBeforePopup( + CefRefPtr browser, CefRefPtr frame, int popup_id, + const CefString& target_url, const CefString& target_frame_name, + CefLifeSpanHandler::WindowOpenDisposition target_disposition, + bool user_gesture, const CefPopupFeatures& popupFeatures, + CefWindowInfo& windowInfo, CefRefPtr& client, + CefBrowserSettings& settings, CefRefPtr& extra_info, + bool* no_javascript_access) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto target_url_str = target_url.ToString(); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_before_popup(userdata, target_url_str.c_str()); + }); + return true; +} + +bool WefClient::DoClose(CefRefPtr browser) { return false; } + +void WefClient::OnBeforeClose(CefRefPtr browser) { + DCHECK(CefCurrentlyOn(TID_UI)); + + message_router_->OnBeforeClose(browser); + + state_->browser_state = BrowserState::Closed; + state_->browser = std::nullopt; + + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_closed(userdata); + }); +} + +///////////////////////////////////////////////////////////////// +// CefLoadHandler methods +///////////////////////////////////////////////////////////////// +void WefClient::OnLoadingStateChange(CefRefPtr browser, + bool isLoading, bool canGoBack, + bool canGoForward) { + DCHECK(CefCurrentlyOn(TID_UI)); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_loading_state_changed(userdata, isLoading, canGoBack, + canGoForward); + }); +} + +void WefClient::OnLoadStart(CefRefPtr browser, + CefRefPtr frame, + TransitionType transition_type) { + DCHECK(CefCurrentlyOn(TID_UI)); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_load_start(userdata, new WefFrame{frame}); + }); +} + +void WefClient::OnLoadEnd(CefRefPtr browser, + CefRefPtr frame, int httpStatusCode) { + DCHECK(CefCurrentlyOn(TID_UI)); + + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_load_end(userdata, new WefFrame{frame}); + }); + + if (state_->browser) { + (*state_->browser)->GetHost()->SetFocus(state_->focus); + } +} + +void WefClient::OnLoadError(CefRefPtr browser, + CefRefPtr frame, ErrorCode errorCode, + const CefString& errorText, + const CefString& failedUrl) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto error_text_str = errorText.ToString(); + auto failed_url_str = failedUrl.ToString(); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_load_error(userdata, new WefFrame{frame}, + error_text_str.c_str(), failed_url_str.c_str()); + }); +} + +///////////////////////////////////////////////////////////////// +// CefDialogHandler methods +///////////////////////////////////////////////////////////////// +bool WefClient::OnFileDialog(CefRefPtr browser, FileDialogMode mode, + const CefString& title, + const CefString& default_file_path, + const std::vector& accept_filters, + const std::vector& accept_extensions, + const std::vector& accept_descriptions, + CefRefPtr callback) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto title_str = title.ToString(); + auto default_file_path_str = default_file_path.ToString(); + auto accept_filters_str = join_strings(accept_filters, "@@@"); + auto accept_extensions_str = join_strings(accept_extensions, "@@@"); + auto accept_descriptions_str = join_strings(accept_descriptions, "@@@"); + CefRefPtr* callback_ptr = + new CefRefPtr(callback); + bool result = false; + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + result = callbacks.on_file_dialog( + userdata, static_cast(mode), title_str.c_str(), + default_file_path_str.c_str(), accept_filters_str.c_str(), + accept_extensions_str.c_str(), accept_descriptions_str.c_str(), + callback_ptr); + }); + return result; +} + +///////////////////////////////////////////////////////////////// +// CefContextMenuHandler methods +///////////////////////////////////////////////////////////////// +bool WefClient::RunContextMenu(CefRefPtr browser, + CefRefPtr frame, + CefRefPtr params, + CefRefPtr model, + CefRefPtr callback) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto link_url_str = params->GetLinkUrl().ToString(); + auto unfiltered_link_url_str = params->GetUnfilteredLinkUrl().ToString(); + auto source_url_str = params->GetSourceUrl().ToString(); + auto title_text_str = params->GetTitleText().ToString(); + auto page_url_str = params->GetPageUrl().ToString(); + auto frame_url_str = params->GetFrameUrl().ToString(); + auto selection_text_str = params->GetSelectionText().ToString(); + + _ContextMenuParams params_{ + params->GetXCoord(), + params->GetYCoord(), + static_cast(params->GetTypeFlags()), + !link_url_str.empty() ? link_url_str.c_str() : nullptr, + !unfiltered_link_url_str.empty() ? unfiltered_link_url_str.c_str() + : nullptr, + !source_url_str.empty() ? source_url_str.c_str() : nullptr, + params->HasImageContents(), + !title_text_str.empty() ? title_text_str.c_str() : nullptr, + page_url_str.c_str(), + frame_url_str.c_str(), + static_cast(params->GetMediaType()), + static_cast(params->GetMediaStateFlags()), + selection_text_str.c_str(), + params->IsEditable(), + static_cast(params->GetEditStateFlags()), + }; + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_context_menu(userdata, new WefFrame{frame}, ¶ms_); + }); + return true; +} + +///////////////////////////////////////////////////////////////// +// CefFindHandler methods +///////////////////////////////////////////////////////////////// +void WefClient::OnFindResult(CefRefPtr browser, int identifier, + int count, const CefRect& selectionRect, + int activeMatchOrdinal, bool finalUpdate) { + DCHECK(CefCurrentlyOn(TID_UI)); + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + callbacks.on_find_result(userdata, identifier, count, &selectionRect, + activeMatchOrdinal, finalUpdate); + }); +} + +///////////////////////////////////////////////////////////////// +// CefJSDialogHandler methods +///////////////////////////////////////////////////////////////// +bool WefClient::OnJSDialog(CefRefPtr browser, + const CefString& origin_url, + JSDialogType dialog_type, + const CefString& message_text, + const CefString& default_prompt_text, + CefRefPtr callback, + bool& suppress_message) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto message_text_str = message_text.ToString(); + auto default_prompt_text_str = default_prompt_text.ToString(); + CefRefPtr* callback_ptr = + new CefRefPtr(callback); + + bool result = false; + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + result = callbacks.on_js_dialog( + userdata, static_cast(dialog_type), message_text_str.c_str(), + default_prompt_text_str.c_str(), callback_ptr); + }); + return result; +} + +bool WefClient::OnBeforeUnloadDialog(CefRefPtr browser, + const CefString& message_text, + bool is_reload, + CefRefPtr callback) { + callback->Continue(true, ""); + return true; +} + +///////////////////////////////////////////////////////////////// +// CefRequestHandler methods +///////////////////////////////////////////////////////////////// +void WefClient::OnRenderProcessTerminated(CefRefPtr browser, + TerminationStatus status, + int error_code, + const CefString& error_string) { + message_router_->OnRenderProcessTerminated(browser); +} + +bool WefClient::OnBeforeBrowse(CefRefPtr browser, + CefRefPtr frame, + CefRefPtr request, bool user_gesture, + bool is_redirect) { + message_router_->OnBeforeBrowse(browser, frame); + return false; +} + +///////////////////////////////////////////////////////////////// +// CefFocusHandler methods +///////////////////////////////////////////////////////////////// +void WefClient::OnTakeFocus(CefRefPtr browser, bool next) {} + +bool WefClient::OnSetFocus(CefRefPtr browser, FocusSource source) { + return false; +} + +///////////////////////////////////////////////////////////////// +// CefPermissionHandler methods +///////////////////////////////////////////////////////////////// +bool WefClient::OnRequestMediaAccessPermission( + CefRefPtr browser, CefRefPtr frame, + const CefString& requesting_origin, uint32_t requested_permissions, + CefRefPtr callback) { + callback->Continue(CEF_MEDIA_PERMISSION_NONE); + return true; +} + +///////////////////////////////////////////////////////////////// +// CefMessageRouterBrowserSide::Handler methods +///////////////////////////////////////////////////////////////// +bool WefClient::OnQuery( + CefRefPtr browser, CefRefPtr frame, int64_t query_id, + const CefString& request, bool persistent, + CefRefPtr callback) { + DCHECK(CefCurrentlyOn(TID_UI)); + + auto request_str = request.ToString(); + CefRefPtr* callback_ptr = + new CefRefPtr(callback); + + state_->callbacks_target.call( + [&](const BrowserCallbacks& callbacks, void* userdata) { + return callbacks.on_query(userdata, new WefFrame{frame}, + request_str.c_str(), callback_ptr); + }); + return true; +} diff --git a/crates/wef/cpp/client.h b/crates/wef/cpp/client.h new file mode 100644 index 0000000..6bef086 --- /dev/null +++ b/crates/wef/cpp/client.h @@ -0,0 +1,291 @@ +#pragma once + +#if defined(_WIN32) || defined(_WIN64) +#define NOMINMAX +#endif + +#include +#include +#include +#include + +#include "browser_callbacks.h" +#include "frame.h" +#include "include/cef_browser.h" +#include "include/cef_client.h" +#include "include/wrapper/cef_message_router.h" +#include "utils.h" + +struct WefBrowser; + +enum class BrowserState { + Creating, + Created, + Closing, + Closed, +}; + +class BrowserCallbacksTarget { + private: + bool disable_; + BrowserCallbacks callbacks_; + void* userdata_; + DestroyFn destroy_userdata_; + + public: + BrowserCallbacksTarget(BrowserCallbacks callbacks, void* userdata, + DestroyFn destroy_userdata) + : disable_(false), + callbacks_(callbacks), + userdata_(userdata), + destroy_userdata_(destroy_userdata) {} + + BrowserCallbacksTarget(const BrowserCallbacksTarget& other) = delete; + BrowserCallbacksTarget& operator=(const BrowserCallbacksTarget& other) = + delete; + + BrowserCallbacksTarget(BrowserCallbacksTarget&& other) + : disable_(other.disable_), + callbacks_(std::move(other.callbacks_)), + userdata_(other.userdata_), + destroy_userdata_(other.destroy_userdata_) { + other.disable_ = true; + other.userdata_ = nullptr; + other.destroy_userdata_ = nullptr; + } + + ~BrowserCallbacksTarget() { + if (destroy_userdata_) { + destroy_userdata_(userdata_); + } + } + + void disable() { disable_ = true; } + + template + void call(Callable func) const { + if (disable_) { + return; + } + func(callbacks_, userdata_); + } +}; + +struct BrowserSharedState { + bool focus; + int cursorX, cursorY; + BrowserState browser_state; + std::optional> browser; + int width, height; + float device_scale_factor; + BrowserCallbacksTarget callbacks_target; + + BrowserSharedState(BrowserCallbacksTarget&& other) + : focus(false), + cursorX(0), + cursorY(0), + browser_state(BrowserState::Creating), + width(800), + height(600), + device_scale_factor(1.0f), + callbacks_target(std::move(other)) {} + + BrowserSharedState(const BrowserSharedState& other) = delete; + BrowserSharedState& operator=(const BrowserSharedState& other) = delete; +}; + +class WefClient : public CefClient, + public CefRenderHandler, + public CefDisplayHandler, + public CefLifeSpanHandler, + public CefLoadHandler, + public CefDialogHandler, + public CefFindHandler, + public CefContextMenuHandler, + public CefRequestHandler, + public CefJSDialogHandler, + public CefFocusHandler, + public CefPermissionHandler, + public CefMessageRouterBrowserSide::Handler { + IMPLEMENT_REFCOUNTING(WefClient); + + private: + std::shared_ptr state_; + CefRefPtr message_router_; + + public: + WefClient(std::shared_ptr state); + + virtual ~WefClient(); + + ///////////////////////////////////////////////////////////////// + // CefClient methods + ///////////////////////////////////////////////////////////////// + + bool GetScreenInfo(CefRefPtr browser, + CefScreenInfo& screen_info) override { + screen_info.device_scale_factor = state_->device_scale_factor; + return true; + } + + void GetViewRect(CefRefPtr browser, CefRect& rect) override { + rect.Set(0, 0, + static_cast(static_cast(state_->width) / + state_->device_scale_factor), + static_cast(static_cast(state_->height) / + state_->device_scale_factor)); + } + + CefRefPtr GetRenderHandler() override { return this; } + CefRefPtr GetDisplayHandler() override { return this; } + CefRefPtr GetLifeSpanHandler() override { return this; } + CefRefPtr GetLoadHandler() override { return this; } + CefRefPtr GetDialogHandler() override { return this; } + CefRefPtr GetContextMenuHandler() override { + return this; + } + CefRefPtr GetFindHandler() override { return this; } + CefRefPtr GetJSDialogHandler() override { return this; } + CefRefPtr GetFocusHandler() override { return this; } + CefRefPtr GetPermissionHandler() override { + return this; + } + + bool OnProcessMessageReceived(CefRefPtr browser, + CefRefPtr frame, + CefProcessId source_process, + CefRefPtr message) override { + return message_router_->OnProcessMessageReceived(browser, frame, + source_process, message); + } + + ///////////////////////////////////////////////////////////////// + // CefRenderHandler methods + ///////////////////////////////////////////////////////////////// + void OnPopupShow(CefRefPtr browser, bool show) override; + void OnPopupSize(CefRefPtr browser, const CefRect& rect) override; + void OnPaint(CefRefPtr browser, PaintElementType type, + const RectList& dirtyRects, const void* buffer, int width, + int height) override; + void OnImeCompositionRangeChanged(CefRefPtr browser, + const CefRange& selected_range, + const RectList& character_bounds) override; + bool OnCursorChange(CefRefPtr browser, CefCursorHandle cursor, + cef_cursor_type_t type, + const CefCursorInfo& custom_cursor_info) override; + + ///////////////////////////////////////////////////////////////// + // CefDisplayHandler methods + ///////////////////////////////////////////////////////////////// + void OnAddressChange(CefRefPtr browser, CefRefPtr frame, + const CefString& url) override; + void OnTitleChange(CefRefPtr browser, + const CefString& title) override; + void OnFaviconURLChange(CefRefPtr browser, + const std::vector& icon_urls) override; + bool OnTooltip(CefRefPtr browser, CefString& text) override; + void OnStatusMessage(CefRefPtr browser, + const CefString& value) override; + bool OnConsoleMessage(CefRefPtr browser, cef_log_severity_t level, + const CefString& message, const CefString& source, + int line) override; + void OnLoadingProgressChange(CefRefPtr browser, + double progress) override; + + ///////////////////////////////////////////////////////////////// + // CefLifeSpanHandler methods + ///////////////////////////////////////////////////////////////// + void OnAfterCreated(CefRefPtr browser) override; + bool OnBeforePopup( + CefRefPtr browser, CefRefPtr frame, int popup_id, + const CefString& target_url, const CefString& target_frame_name, + CefLifeSpanHandler::WindowOpenDisposition target_disposition, + bool user_gesture, const CefPopupFeatures& popupFeatures, + CefWindowInfo& windowInfo, CefRefPtr& client, + CefBrowserSettings& settings, CefRefPtr& extra_info, + bool* no_javascript_access) override; + bool DoClose(CefRefPtr browser) override; + void OnBeforeClose(CefRefPtr browser) override; + + ///////////////////////////////////////////////////////////////// + // CefLoadHandler methods + ///////////////////////////////////////////////////////////////// + void OnLoadingStateChange(CefRefPtr browser, bool isLoading, + bool canGoBack, bool canGoForward) override; + void OnLoadStart(CefRefPtr browser, CefRefPtr frame, + TransitionType transition_type) override; + void OnLoadEnd(CefRefPtr browser, CefRefPtr frame, + int httpStatusCode) override; + void OnLoadError(CefRefPtr browser, CefRefPtr frame, + ErrorCode errorCode, const CefString& errorText, + const CefString& failedUrl) override; + + ///////////////////////////////////////////////////////////////// + // CefDialogHandler methods + ///////////////////////////////////////////////////////////////// + bool OnFileDialog(CefRefPtr browser, FileDialogMode mode, + const CefString& title, const CefString& default_file_path, + const std::vector& accept_filters, + const std::vector& accept_extensions, + const std::vector& accept_descriptions, + CefRefPtr callback) override; + + ///////////////////////////////////////////////////////////////// + // CefContextMenuHandler methods + ///////////////////////////////////////////////////////////////// + bool RunContextMenu(CefRefPtr browser, CefRefPtr frame, + CefRefPtr params, + CefRefPtr model, + CefRefPtr callback) override; + + ///////////////////////////////////////////////////////////////// + // CefFindHandler methods + ///////////////////////////////////////////////////////////////// + void OnFindResult(CefRefPtr browser, int identifier, int count, + const CefRect& selectionRect, int activeMatchOrdinal, + bool finalUpdate) override; + + ///////////////////////////////////////////////////////////////// + // CefJSDialogHandler methods + ///////////////////////////////////////////////////////////////// + bool OnJSDialog(CefRefPtr browser, const CefString& origin_url, + JSDialogType dialog_type, const CefString& message_text, + const CefString& default_prompt_text, + CefRefPtr callback, + bool& suppress_message) override; + bool OnBeforeUnloadDialog(CefRefPtr browser, + const CefString& message_text, bool is_reload, + CefRefPtr callback) override; + + ///////////////////////////////////////////////////////////////// + // CefRequestHandler methods + ///////////////////////////////////////////////////////////////// + void OnRenderProcessTerminated(CefRefPtr browser, + TerminationStatus status, int error_code, + const CefString& error_string) override; + bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame, + CefRefPtr request, bool user_gesture, + bool is_redirect) override; + + ///////////////////////////////////////////////////////////////// + // CefFocusHandler methods + ///////////////////////////////////////////////////////////////// + void OnTakeFocus(CefRefPtr browser, bool next) override; + bool OnSetFocus(CefRefPtr browser, FocusSource source) override; + + ///////////////////////////////////////////////////////////////// + // CefPermissionHandler methods + ///////////////////////////////////////////////////////////////// + bool OnRequestMediaAccessPermission( + CefRefPtr browser, CefRefPtr frame, + const CefString& requesting_origin, uint32_t requested_permissions, + CefRefPtr callback) override; + + ///////////////////////////////////////////////////////////////// + // CefMessageRouterBrowserSide::Handler methods + ///////////////////////////////////////////////////////////////// + bool OnQuery(CefRefPtr browser, CefRefPtr frame, + int64_t query_id, const CefString& request, bool persistent, + CefRefPtr + callback) override; +}; diff --git a/crates/wef/cpp/cursor.cpp b/crates/wef/cpp/cursor.cpp new file mode 100644 index 0000000..63a172d --- /dev/null +++ b/crates/wef/cpp/cursor.cpp @@ -0,0 +1,21 @@ +#include "include/cef_render_handler.h" + +extern "C" { + +void wef_cursor_info_hotspot(const CefCursorInfo* info, cef_point_t* point) { + *point = info->hotspot; +} + +float wef_cursor_info_image_scale_factor(const CefCursorInfo* info) { + return info->image_scale_factor; +} + +const void* wef_cursor_info_buffer(const CefCursorInfo* info) { + return info->buffer; +} + +void wef_cursor_info_size(const CefCursorInfo* info, cef_size_t* size) { + *size = info->size; +} + +} // extern "C" \ No newline at end of file diff --git a/crates/wef/cpp/dirty_rect.cpp b/crates/wef/cpp/dirty_rect.cpp new file mode 100644 index 0000000..5337b52 --- /dev/null +++ b/crates/wef/cpp/dirty_rect.cpp @@ -0,0 +1,14 @@ +#include "include/cef_render_handler.h" + +extern "C" { + +int wef_dirty_rects_len(const CefRenderHandler::RectList* dirtyRects) { + return static_cast(dirtyRects->size()); +} + +void wef_dirty_rects_get(const CefRenderHandler::RectList* dirtyRects, int i, + CefRect* rect) { + *rect = dirtyRects->at(i); +} + +} // extern "C" \ No newline at end of file diff --git a/crates/wef/cpp/external_pump.cpp b/crates/wef/cpp/external_pump.cpp new file mode 100644 index 0000000..ee4ee67 --- /dev/null +++ b/crates/wef/cpp/external_pump.cpp @@ -0,0 +1,74 @@ +#include "external_pump.h" + +#include + +#include "include/cef_app.h" + +// Special timer delay placeholder value. Intentionally 32-bit for Windows and +// OS X platform API compatibility. +const int32_t TIMER_DELAY_PLACE_HOLDER = INT_MAX; + +// The maximum number of milliseconds we're willing to wait between calls to +// DoWork(). +const int64_t MAX_TIMER_DELAY = 1000 / 60; // 60fps + +void ExternalPump::OnScheduleWork(int64_t delay_ms) { + if (delay_ms == TIMER_DELAY_PLACE_HOLDER && IsTimerPending()) { + // Don't set the maximum timer requested from DoWork() if a timer event is + // currently pending. + return; + } + + KillTimer(); + + if (delay_ms <= 0) { + // Execute the work immediately. + DoWork(); + } else { + // Never wait longer than the maximum allowed time. + if (delay_ms > MAX_TIMER_DELAY) { + delay_ms = MAX_TIMER_DELAY; + } + + // Results in call to OnTimerTimeout() after the specified delay. + SetTimer(delay_ms); + } +} + +void ExternalPump::OnTimerTimeout() { + KillTimer(); + DoWork(); +} + +void ExternalPump::DoWork() { + const bool was_reentrant = PerformMessageLoopWork(); + if (was_reentrant) { + // Execute the remaining work as soon as possible. + OnScheduleMessagePumpWork(0); + } else if (!IsTimerPending()) { + // Schedule a timer event at the maximum allowed time. This may be dropped + // in OnScheduleWork() if another timer event is already in-flight. + OnScheduleMessagePumpWork(TIMER_DELAY_PLACE_HOLDER); + } +} + +bool ExternalPump::PerformMessageLoopWork() { + if (is_active_) { + // When CefDoMessageLoopWork() is called there may be various callbacks + // (such as paint and IPC messages) that result in additional calls to this + // method. If re-entrancy is detected we must repost a request again to the + // owner thread to ensure that the discarded call is executed in the future. + reentrancy_detected_ = true; + return false; + } + + reentrancy_detected_ = false; + + is_active_ = true; + CefDoMessageLoopWork(); + is_active_ = false; + + // |reentrancy_detected_| may have changed due to re-entrant calls to this + // method. + return reentrancy_detected_; +} \ No newline at end of file diff --git a/crates/wef/cpp/external_pump.h b/crates/wef/cpp/external_pump.h new file mode 100644 index 0000000..4efb838 --- /dev/null +++ b/crates/wef/cpp/external_pump.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include + +class ExternalPump { + public: + ExternalPump() {} + virtual ~ExternalPump() {} + ExternalPump(const ExternalPump&) = delete; + ExternalPump& operator=(const ExternalPump&) = delete; + static std::unique_ptr Create(); + virtual void OnScheduleMessagePumpWork(int64_t delay_ms) = 0; + + protected: + void OnScheduleWork(int64_t delay_ms); + void OnTimerTimeout(); + + virtual void SetTimer(int64_t delay_ms) = 0; + virtual void KillTimer() = 0; + virtual bool IsTimerPending() = 0; + + private: + void DoWork(); + bool PerformMessageLoopWork(); + + bool is_active_ = false; + bool reentrancy_detected_ = false; +}; diff --git a/crates/wef/cpp/external_pump_linux.cpp b/crates/wef/cpp/external_pump_linux.cpp new file mode 100644 index 0000000..dbcc29d --- /dev/null +++ b/crates/wef/cpp/external_pump_linux.cpp @@ -0,0 +1,212 @@ +#include +#include +#include +#include + +#include + +#include "external_pump.h" +#include "include/base/cef_logging.h" +#include "include/cef_app.h" + +#if !defined(HANDLE_EINTR) +#if !DCHECK_IS_ON() + +#define HANDLE_EINTR(x) \ + ({ \ + decltype(x) eintr_wrapper_result; \ + do { \ + eintr_wrapper_result = (x); \ + } while (eintr_wrapper_result == -1 && errno == EINTR); \ + eintr_wrapper_result; \ + }) + +#else + +#define HANDLE_EINTR(x) \ + ({ \ + int eintr_wrapper_counter = 0; \ + decltype(x) eintr_wrapper_result; \ + do { \ + eintr_wrapper_result = (x); \ + } while (eintr_wrapper_result == -1 && errno == EINTR && \ + eintr_wrapper_counter++ < 100); \ + eintr_wrapper_result; \ + }) + +#endif // !DCHECK_IS_ON() +#endif // !defined(HANDLE_EINTR) + +class ExternalPumpLinux : public ExternalPump { + public: + ExternalPumpLinux(); + ~ExternalPumpLinux(); + + // MainMessageLoopExternalPump methods: + void OnScheduleMessagePumpWork(int64_t delay_ms) override; + + int HandlePrepare(); + bool HandleCheck(); + void HandleDispatch(); + + protected: + void SetTimer(int64_t delay_ms) override; + void KillTimer() override; + bool IsTimerPending() override; + + private: + GMainContext* context_; + GSource* work_source_; + CefTime delayed_work_time_; + int wakeup_pipe_read_; + int wakeup_pipe_write_; + std::unique_ptr wakeup_gpollfd_; +}; + +// Return a timeout suitable for the glib loop, -1 to block forever, +// 0 to return right away, or a timeout in milliseconds from now. +int GetTimeIntervalMilliseconds(const CefTime& from) { + if (from.GetDoubleT() == 0.0) { + return -1; + } + + CefTime now; + now.Now(); + + // Be careful here. CefTime has a precision of microseconds, but we want a + // value in milliseconds. If there are 5.5ms left, should the delay be 5 or + // 6? It should be 6 to avoid executing delayed work too early. + int delay = + static_cast(ceil((from.GetDoubleT() - now.GetDoubleT()) * 1000.0)); + + // If this value is negative, then we need to run delayed work soon. + return delay < 0 ? 0 : delay; +} + +struct WorkSource : public GSource { + ExternalPumpLinux* pump; +}; + +gboolean WorkSourcePrepare(GSource* source, gint* timeout_ms) { + *timeout_ms = static_cast(source)->pump->HandlePrepare(); + // We always return FALSE, so that our timeout is honored. If we were + // to return TRUE, the timeout would be considered to be 0 and the poll + // would never block. Once the poll is finished, Check will be called. + return FALSE; +} + +gboolean WorkSourceCheck(GSource* source) { + // Only return TRUE if Dispatch should be called. + return static_cast(source)->pump->HandleCheck(); +} + +gboolean WorkSourceDispatch(GSource* source, GSourceFunc unused_func, + gpointer unused_data) { + static_cast(source)->pump->HandleDispatch(); + // Always return TRUE so our source stays registered. + return TRUE; +} + +// I wish these could be const, but g_source_new wants non-const. +GSourceFuncs WorkSourceFuncs = {WorkSourcePrepare, WorkSourceCheck, + WorkSourceDispatch, nullptr}; + +ExternalPumpLinux::ExternalPumpLinux() + : context_(g_main_context_default()), wakeup_gpollfd_(new GPollFD) { + // Create our wakeup pipe, which is used to flag when work was scheduled. + int fds[2]; + int ret = pipe(fds); + DCHECK_EQ(ret, 0); + (void)ret; // Prevent warning in release mode. + + wakeup_pipe_read_ = fds[0]; + wakeup_pipe_write_ = fds[1]; + wakeup_gpollfd_->fd = wakeup_pipe_read_; + wakeup_gpollfd_->events = G_IO_IN; + + work_source_ = g_source_new(&WorkSourceFuncs, sizeof(WorkSource)); + static_cast(work_source_)->pump = this; + g_source_add_poll(work_source_, wakeup_gpollfd_.get()); + // Use a low priority so that we let other events in the queue go first. + g_source_set_priority(work_source_, G_PRIORITY_DEFAULT_IDLE); + // This is needed to allow Run calls inside Dispatch. + g_source_set_can_recurse(work_source_, TRUE); + g_source_attach(work_source_, context_); +} + +ExternalPumpLinux::~ExternalPumpLinux() { + g_source_destroy(work_source_); + g_source_unref(work_source_); + close(wakeup_pipe_read_); + close(wakeup_pipe_write_); +} + +void ExternalPumpLinux::OnScheduleMessagePumpWork(int64_t delay_ms) { + // This can be called on any thread, so we don't want to touch any state + // variables as we would then need locks all over. This ensures that if we + // are sleeping in a poll that we will wake up. + if (HANDLE_EINTR(write(wakeup_pipe_write_, &delay_ms, sizeof(int64_t))) != + sizeof(int64_t)) { + NOTREACHED() << "Could not write to the UI message loop wakeup pipe!"; + } +} + +// Return the timeout we want passed to poll. +int ExternalPumpLinux::HandlePrepare() { + // We don't think we have work to do, but make sure not to block longer than + // the next time we need to run delayed work. + return GetTimeIntervalMilliseconds(delayed_work_time_); +} + +bool ExternalPumpLinux::HandleCheck() { + // We usually have a single message on the wakeup pipe, since we are only + // signaled when the queue went from empty to non-empty, but there can be + // two messages if a task posted a task, hence we read at most two bytes. + // The glib poll will tell us whether there was data, so this read shouldn't + // block. + if (wakeup_gpollfd_->revents & G_IO_IN) { + int64_t delay_ms[2]; + const size_t num_bytes = + HANDLE_EINTR(read(wakeup_pipe_read_, delay_ms, sizeof(int64_t) * 2)); + if (num_bytes < sizeof(int64_t)) { + NOTREACHED() << "Error reading from the wakeup pipe."; + } + if (num_bytes == sizeof(int64_t)) { + OnScheduleWork(delay_ms[0]); + } + if (num_bytes == sizeof(int64_t) * 2) { + OnScheduleWork(delay_ms[1]); + } + } + + if (GetTimeIntervalMilliseconds(delayed_work_time_) == 0) { + // The timer has expired. That condition will stay true until we process + // that delayed work, so we don't need to record this differently. + return true; + } + + return false; +} + +void ExternalPumpLinux::HandleDispatch() { OnTimerTimeout(); } + +void ExternalPumpLinux::SetTimer(int64_t delay_ms) { + DCHECK_GT(delay_ms, 0); + + CefTime now; + now.Now(); + + delayed_work_time_ = + CefTime(now.GetDoubleT() + static_cast(delay_ms) / 1000.0); +} + +void ExternalPumpLinux::KillTimer() { delayed_work_time_ = CefTime(); } + +bool ExternalPumpLinux::IsTimerPending() { + return GetTimeIntervalMilliseconds(delayed_work_time_) > 0; +} + +// static +std::unique_ptr ExternalPump::Create() { + return std::make_unique(); +} diff --git a/crates/wef/cpp/external_pump_mac.mm b/crates/wef/cpp/external_pump_mac.mm new file mode 100644 index 0000000..9e68cef --- /dev/null +++ b/crates/wef/cpp/external_pump_mac.mm @@ -0,0 +1,123 @@ +#import +#import + +#include "external_pump.h" + +@class EventHandler; + +class ExternalPumpMac : public ExternalPump { + public: + ExternalPumpMac(); + ~ExternalPumpMac(); + + void OnScheduleMessagePumpWork(int64_t delay_ms) override; + void HandleScheduleWork(int64_t delay_ms); + void HandleTimerTimeout(); + + protected: + void SetTimer(int64_t delay_ms) override; + void KillTimer() override; + bool IsTimerPending() override { return timer_ != nil; } + + private: + NSThread* owner_thread_; + NSTimer* timer_; + EventHandler* event_handler_; +}; + +// Object that handles event callbacks on the owner thread. +@interface EventHandler : NSObject { + @private + ExternalPumpMac* pump_; +} + +- (id)initWithPump:(ExternalPumpMac*)pump; +- (void)scheduleWork:(NSNumber*)delay_ms; +- (void)timerTimeout:(id)obj; +@end + +@implementation EventHandler + +- (id)initWithPump:(ExternalPumpMac*)pump { + if (self = [super init]) { + pump_ = pump; + } + return self; +} + +- (void)scheduleWork:(NSNumber*)delay_ms { + pump_->HandleScheduleWork([delay_ms integerValue]); +} + +- (void)timerTimeout:(id)obj { + pump_->HandleTimerTimeout(); +} + +@end + +ExternalPumpMac::ExternalPumpMac() + : owner_thread_([NSThread currentThread]), timer_(nil) { +#if !__has_feature(objc_arc) + [owner_thread_ retain]; +#endif // !__has_feature(objc_arc) + event_handler_ = [[EventHandler alloc] initWithPump:this]; +} + +ExternalPumpMac::~ExternalPumpMac() { + KillTimer(); +#if !__has_feature(objc_arc) + [owner_thread_ release]; + [event_handler_ release]; +#endif // !__has_feature(objc_arc) + owner_thread_ = nil; + event_handler_ = nil; +} + +void ExternalPumpMac::OnScheduleMessagePumpWork( + int64_t delay_ms) { + // This method may be called on any thread. + NSNumber* number = [NSNumber numberWithInt:static_cast(delay_ms)]; + [event_handler_ performSelector:@selector(scheduleWork:) + onThread:owner_thread_ + withObject:number + waitUntilDone:NO]; +} + +void ExternalPumpMac::HandleScheduleWork(int64_t delay_ms) { + OnScheduleWork(delay_ms); +} + +void ExternalPumpMac::HandleTimerTimeout() { + OnTimerTimeout(); +} + +void ExternalPumpMac::SetTimer(int64_t delay_ms) { + const double delay_s = static_cast(delay_ms) / 1000.0; + timer_ = [NSTimer timerWithTimeInterval:delay_s + target:event_handler_ + selector:@selector(timerTimeout:) + userInfo:nil + repeats:NO]; +#if !__has_feature(objc_arc) + [timer_ retain]; +#endif // !__has_feature(objc_arc) + + // Add the timer to default and tracking runloop modes. + NSRunLoop* owner_runloop = [NSRunLoop currentRunLoop]; + [owner_runloop addTimer:timer_ forMode:NSRunLoopCommonModes]; + [owner_runloop addTimer:timer_ forMode:NSEventTrackingRunLoopMode]; +} + +void ExternalPumpMac::KillTimer() { + if (timer_ != nil) { + [timer_ invalidate]; +#if !__has_feature(objc_arc) + [timer_ release]; +#endif // !__has_feature(objc_arc) + timer_ = nil; + } +} + +std::unique_ptr ExternalPump::Create() { + return std::make_unique(); +} diff --git a/crates/wef/cpp/external_pump_win.cpp b/crates/wef/cpp/external_pump_win.cpp new file mode 100644 index 0000000..48da5bb --- /dev/null +++ b/crates/wef/cpp/external_pump_win.cpp @@ -0,0 +1,81 @@ +#include + +#include "external_pump.h" + +static const int MSG_HAVE_WORK = WM_USER + 1; + +class ExternalPumpWin : public ExternalPump { + public: + ExternalPumpWin() { + HINSTANCE hInstance = GetModuleHandle(nullptr); + const char* const className = "CEFMainTargetHWND"; + + WNDCLASSEX wcex = {}; + wcex.cbSize = sizeof(WNDCLASSEX); + wcex.lpfnWndProc = WndProc; + wcex.hInstance = hInstance; + wcex.lpszClassName = className; + RegisterClassEx(&wcex); + + main_thread_target_ = + CreateWindow(className, nullptr, WS_OVERLAPPEDWINDOW, 0, 0, 0, 0, + HWND_MESSAGE, nullptr, hInstance, nullptr); + + SetWindowLongPtr(main_thread_target_, GWLP_USERDATA, + reinterpret_cast(this)); + } + + ~ExternalPumpWin() override { + KillTimer(); + if (main_thread_target_) { + DestroyWindow(main_thread_target_); + } + } + + void OnScheduleMessagePumpWork(int64_t delay_ms) override { + // This method may be called on any thread. + PostMessage(main_thread_target_, MSG_HAVE_WORK, 0, + static_cast(delay_ms)); + } + + protected: + void SetTimer(int64_t delay_ms) override { + timer_pending_ = true; + ::SetTimer(main_thread_target_, 1, static_cast(delay_ms), nullptr); + } + + void KillTimer() override { + if (timer_pending_) { + ::KillTimer(main_thread_target_, 1); + timer_pending_ = false; + } + } + + bool IsTimerPending() override { return timer_pending_; } + + private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wparam, + LPARAM lparam) { + if (msg == WM_TIMER || msg == MSG_HAVE_WORK) { + ExternalPumpWin* message_loop = reinterpret_cast( + GetWindowLongPtr(hwnd, GWLP_USERDATA)); + if (msg == MSG_HAVE_WORK) { + const int64_t delay_ms = static_cast(lparam); + message_loop->OnScheduleWork(delay_ms); + } else { + message_loop->OnTimerTimeout(); + } + } + return DefWindowProc(hwnd, msg, wparam, lparam); + } + + // True if a timer event is currently pending. + bool timer_pending_ = false; + + // HWND owned by the thread that CefDoMessageLoopWork should be invoked on. + HWND main_thread_target_ = nullptr; +}; + +std::unique_ptr ExternalPump::Create() { + return std::make_unique(); +} diff --git a/crates/wef/cpp/file_dialog.cpp b/crates/wef/cpp/file_dialog.cpp new file mode 100644 index 0000000..a27c48f --- /dev/null +++ b/crates/wef/cpp/file_dialog.cpp @@ -0,0 +1,21 @@ +#include "include/cef_dialog_handler.h" +#include "utils.h" + +extern "C" { + +void wef_file_dialog_callback_continue( + CefRefPtr* callback, const char* file_paths) { + (*callback)->Continue(split_string(file_paths, ";")); +} + +void wef_file_dialog_callback_cancel( + CefRefPtr* callback) { + (*callback)->Cancel(); +} + +void wef_file_dialog_callback_destroy( + CefRefPtr* callback) { + delete callback; +} + +} // extern "C" \ No newline at end of file diff --git a/crates/wef/cpp/frame.cpp b/crates/wef/cpp/frame.cpp new file mode 100644 index 0000000..9a36303 --- /dev/null +++ b/crates/wef/cpp/frame.cpp @@ -0,0 +1,65 @@ +#include "frame.h" + +#include + +#include "include/cef_frame.h" + +extern "C" { +void wef_frame_destroy(WefFrame* frame) { delete frame; } + +bool wef_frame_is_valid(WefFrame* frame) { return frame->frame->IsValid(); } + +bool wef_frame_is_main(WefFrame* frame) { return frame->frame->IsMain(); } + +void wef_frame_name(WefFrame* frame, void* userdata, + void (*callback)(void*, const char*)) { + auto name = frame->frame->GetName().ToString(); + callback(userdata, name.c_str()); +} + +void wef_frame_identifier(WefFrame* frame, void* userdata, + void (*callback)(void*, const char*)) { + auto id = frame->frame->GetIdentifier().ToString(); + callback(userdata, id.c_str()); +} + +void wef_frame_get_url(WefFrame* frame, void* userdata, + void (*callback)(void*, const char*)) { + auto url = frame->frame->GetURL().ToString(); + callback(userdata, url.c_str()); +} + +void wef_frame_load_url(WefFrame* frame, const char* url) { + if (strlen(url) > 0) { + frame->frame->LoadURL(url); + } +} + +WefFrame* wef_frame_parent(WefFrame* frame) { + auto parent = frame->frame->GetParent(); + return parent ? new WefFrame{parent} : nullptr; +} + +void wef_frame_undo(WefFrame* frame) { frame->frame->Undo(); } + +void wef_frame_redo(WefFrame* frame) { frame->frame->Redo(); } + +void wef_frame_cut(WefFrame* frame) { frame->frame->Cut(); } + +void wef_frame_copy(WefFrame* frame) { frame->frame->Copy(); } + +void wef_frame_paste(WefFrame* frame) { frame->frame->Paste(); } + +void wef_frame_paste_and_match_style(WefFrame* frame) { + frame->frame->PasteAndMatchStyle(); +} + +void wef_frame_delete(WefFrame* frame) { frame->frame->Delete(); } + +void wef_frame_select_all(WefFrame* frame) { frame->frame->SelectAll(); } + +void wef_frame_execute_javascript(WefFrame* frame, const char* code) { + frame->frame->ExecuteJavaScript(code, frame->frame->GetURL(), 0); +} + +} // extern "C" diff --git a/crates/wef/cpp/frame.h b/crates/wef/cpp/frame.h new file mode 100644 index 0000000..e1147a2 --- /dev/null +++ b/crates/wef/cpp/frame.h @@ -0,0 +1,7 @@ +#pragma once + +#include "include/cef_frame.h" + +struct WefFrame { + CefRefPtr frame; +}; diff --git a/crates/wef/cpp/js_dialog.cpp b/crates/wef/cpp/js_dialog.cpp new file mode 100644 index 0000000..0bf03b0 --- /dev/null +++ b/crates/wef/cpp/js_dialog.cpp @@ -0,0 +1,15 @@ +#include "include/cef_jsdialog_handler.h" +#include "utils.h" + +extern "C" { + +void wef_js_dialog_callback_continue(CefRefPtr* callback, + bool success, const char* user_input) { + (*callback)->Continue(success, user_input); +} + +void wef_js_dialog_callback_destroy(CefRefPtr* callback) { + delete callback; +} + +} // extern "C" \ No newline at end of file diff --git a/crates/wef/cpp/load_library.cpp b/crates/wef/cpp/load_library.cpp new file mode 100644 index 0000000..86acefb --- /dev/null +++ b/crates/wef/cpp/load_library.cpp @@ -0,0 +1,27 @@ +#include "include/wrapper/cef_library_loader.h" + +extern "C" { + +void* wef_load_library(bool helper) { + CefScopedLibraryLoader* library_loader = new CefScopedLibraryLoader(); + if (!helper) { + if (!library_loader->LoadInMain()) { + delete library_loader; + return nullptr; + } + } else { + if (!library_loader->LoadInHelper()) { + delete library_loader; + return nullptr; + } + } + return library_loader; +} + +void wef_unload_library(void* loader) { + CefScopedLibraryLoader* library_loader = + static_cast(loader); + delete library_loader; +} + +} // extern "C" \ No newline at end of file diff --git a/crates/wef/cpp/query.cpp b/crates/wef/cpp/query.cpp new file mode 100644 index 0000000..7e49539 --- /dev/null +++ b/crates/wef/cpp/query.cpp @@ -0,0 +1,25 @@ +#include + +#include "include/wrapper/cef_message_router.h" +#include "utils.h" + +extern "C" { + +void wef_query_callback_success( + CefRefPtr* callback, + const char* response) { + (*callback)->Success(response); +} + +void wef_query_callback_failure( + CefRefPtr* callback, + const char* error) { + (*callback)->Failure(-1, error); +} + +void wef_query_callback_destroy( + CefRefPtr* callback) { + delete callback; +} + +} // extern "C" \ No newline at end of file diff --git a/crates/wef/cpp/sandbox_context.cpp b/crates/wef/cpp/sandbox_context.cpp new file mode 100644 index 0000000..d7f3d14 --- /dev/null +++ b/crates/wef/cpp/sandbox_context.cpp @@ -0,0 +1,19 @@ +#include "include/cef_sandbox_mac.h" + +extern "C" { + +void* wef_sandbox_context_create(char* argv[], int argc) { + CefScopedSandboxContext* ctx = new CefScopedSandboxContext(); + if (!ctx->Initialize(argc, argv)) { + delete ctx; + return nullptr; + } + return ctx; +} + +void wef_sandbox_context_destroy(void* p) { + CefScopedSandboxContext* ctx = static_cast(p); + delete ctx; +} + +} // extern "C" \ No newline at end of file diff --git a/crates/wef/cpp/utils.h b/crates/wef/cpp/utils.h new file mode 100644 index 0000000..94171f2 --- /dev/null +++ b/crates/wef/cpp/utils.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include "include/internal/cef_string.h" + +typedef void (*DestroyFn)(void*); + +inline std::string join_strings(const std::vector& strings, + const std::string& delimiter) { + return std::accumulate(strings.begin(), strings.end(), std::string(), + [&](const std::string& a, const CefString& b) { + auto b_ = b.ToString(); + return a.empty() ? b_ : a + delimiter + b_; + }); +} + +inline std::vector split_string(const std::string& str, + const std::string& delimiter) { + std::vector result; + std::string::size_type start = 0; + std::string::size_type end; + + while ((end = str.find(delimiter, start)) != std::string::npos) { + result.emplace_back(CefString(str.substr(start, end - start))); + start = end + delimiter.length(); + } + auto last = str.substr(start); + if (!last.empty()) { + result.emplace_back(CefString(last)); + } + return result; +} \ No newline at end of file diff --git a/crates/wef/cpp/wef.cpp b/crates/wef/cpp/wef.cpp new file mode 100644 index 0000000..8df6c86 --- /dev/null +++ b/crates/wef/cpp/wef.cpp @@ -0,0 +1,417 @@ +#if defined(_WIN32) || defined(_WIN64) +#define NOMINMAX +#endif + +#include +#include +#include +#include + +#include "app.h" +#include "app_render_process.h" +#include "browser_callbacks.h" +#include "client.h" +#include "frame.h" +#include "include/base/cef_bind.h" +#include "include/base/cef_callback.h" +#include "include/cef_app.h" +#include "include/cef_command_line.h" +#include "include/cef_render_handler.h" +#include "include/cef_task.h" +#include "include/wrapper/cef_closure_task.h" + +const uint32_t ALL_MOUSE_BUTTONS = EVENTFLAG_LEFT_MOUSE_BUTTON; + +struct WefSettings { + const char* locale; + const char* cache_path; + const char* root_cache_path; + const char* browser_subprocess_path; + AppCallbacks callbacks; + void* userdata; + DestroyFn destroy_userdata; +}; + +struct WefBrowserSettings { + void* parent; + float device_scale_factor; + int width; + int height; + int frame_rate; + const char* url; + const char* inject_javascript; + BrowserCallbacks callbacks; + void* userdata; + DestroyFn destroy_userdata; +}; + +struct WefBrowser { + std::shared_ptr state; +}; + +inline void apply_key_modifiers(uint32_t& m, int modifiers) { + if (modifiers & 0x1) { + m |= EVENTFLAG_SHIFT_DOWN; + } + + if (modifiers & 0x2) { + m |= EVENTFLAG_CONTROL_DOWN; + } + + if (modifiers & 0x4) { + m |= EVENTFLAG_ALT_DOWN; + } +} + +extern "C" { + +bool wef_init(const WefSettings* wef_settings) { + CefSettings settings; + settings.windowless_rendering_enabled = true; + settings.external_message_pump = true; + +#ifdef __APPLE__ + settings.no_sandbox = false; +#else + settings.no_sandbox = true; +#endif + + if (wef_settings->locale) { + CefString(&settings.locale) = wef_settings->locale; + } + + if (wef_settings->cache_path) { + CefString(&settings.cache_path) = wef_settings->cache_path; + } + + if (wef_settings->root_cache_path) { + CefString(&settings.root_cache_path) = wef_settings->root_cache_path; + } + + if (wef_settings->browser_subprocess_path) { + CefString(&settings.browser_subprocess_path) = + wef_settings->browser_subprocess_path; + } + + CefRefPtr app(new WefApp(wef_settings->callbacks, + wef_settings->userdata, + wef_settings->destroy_userdata)); + return CefInitialize(CefMainArgs(), settings, app, nullptr); +} + +bool wef_exec_process(char* argv[], int argc) { +#ifdef WIN32 + CefMainArgs args(GetModuleHandle(NULL)); +#else + CefMainArgs args(argc, argv); +#endif + + CefRefPtr app(new WefRenderProcessApp()); + return CefExecuteProcess(args, app, nullptr) >= 0; +} + +void wef_shutdown() { CefShutdown(); } + +void wef_do_message_work() { CefDoMessageLoopWork(); } + +WefBrowser* wef_browser_create(const WefBrowserSettings* settings) { + CefWindowInfo window_info; + window_info.SetAsWindowless( + reinterpret_cast(settings->parent)); + window_info.runtime_style = CEF_RUNTIME_STYLE_ALLOY; + + CefBrowserSettings browser_settings; + browser_settings.windowless_frame_rate = settings->frame_rate; + browser_settings.background_color = CefColorSetARGB(255, 255, 255, 255); + + WefBrowser* wef_browser = new WefBrowser; + CefRefPtr extra_info = CefDictionaryValue::Create(); + extra_info->SetString("__wef_inject_javascript", settings->inject_javascript); + + wef_browser->state = + std::make_shared(BrowserCallbacksTarget{ + settings->callbacks, settings->userdata, settings->destroy_userdata}); + wef_browser->state->width = settings->width; + wef_browser->state->height = settings->height; + wef_browser->state->device_scale_factor = settings->device_scale_factor; + + CefRefPtr client(new WefClient(wef_browser->state)); + CefBrowserHost::CreateBrowser(window_info, client, settings->url, + browser_settings, extra_info, nullptr); + return wef_browser; +} + +void wef_browser_close(WefBrowser* browser) { + if (browser->state->browser_state == BrowserState::Creating) { + browser->state->browser_state = BrowserState::Closed; + } else if (browser->state->browser_state == BrowserState::Created) { + browser->state->browser_state = BrowserState::Closing; + (*browser->state->browser)->GetHost()->CloseBrowser(false); + } +} + +void wef_browser_destroy(WefBrowser* browser) { + if (browser->state->browser_state == BrowserState::Creating) { + browser->state->browser_state = BrowserState::Closed; + } else if (browser->state->browser_state == BrowserState::Created) { + browser->state->browser_state = BrowserState::Closed; + (*browser->state->browser)->GetHost()->CloseBrowser(true); + } + browser->state->callbacks_target.disable(); + delete browser; +} + +bool wef_browser_is_created(WefBrowser* browser) { + return browser->state->browser_state == BrowserState::Created; +} + +void wef_browser_set_size(WefBrowser* browser, int width, int height) { + browser->state->width = width; + browser->state->height = height; + if (browser->state->browser) { + (*browser->state->browser)->GetHost()->WasResized(); + } +} + +void wef_browser_load_url(WefBrowser* browser, const char* url) { + if (strlen(url) == 0) { + return; + } + + if (!browser->state->browser) { + return; + } + + CefPostTask(TID_UI, base::BindOnce(&CefFrame::LoadURL, + (*browser->state->browser)->GetMainFrame(), + CefString(url))); +} + +bool wef_browser_can_go_forward(WefBrowser* browser) { + if (!browser->state->browser) { + return false; + } + return (*browser->state->browser)->CanGoForward(); +} + +bool wef_browser_can_go_back(WefBrowser* browser) { + if (!browser->state->browser) { + return false; + } + return (*browser->state->browser)->CanGoBack(); +} + +void wef_browser_go_forward(WefBrowser* browser) { + if (!browser->state->browser) { + return; + } + (*browser->state->browser)->GoForward(); +} + +void wef_browser_go_back(WefBrowser* browser) { + if (!browser->state->browser) { + return; + } + (*browser->state->browser)->GoBack(); +} + +void wef_browser_reload(WefBrowser* browser) { + if (!browser->state->browser) { + return; + } + (*browser->state->browser)->Reload(); +} + +void wef_browser_reload_ignore_cache(WefBrowser* browser) { + if (!browser->state->browser) { + return; + } + (*browser->state->browser)->ReloadIgnoreCache(); +} + +void wef_browser_send_mouse_click_event(WefBrowser* browser, + int mouse_button_type, bool mouse_up, + int click_count, int modifiers) { + if (!browser->state->browser) { + return; + } + + CefMouseEvent mouse_event; + mouse_event.x = browser->state->cursorX; + mouse_event.y = browser->state->cursorY; + mouse_event.modifiers = EVENTFLAG_NONE; + + CefBrowserHost::MouseButtonType btn_type; + switch (mouse_button_type) { + case 1: + btn_type = MBT_MIDDLE; + mouse_event.modifiers |= EVENTFLAG_MIDDLE_MOUSE_BUTTON; + break; + case 2: + btn_type = MBT_RIGHT; + mouse_event.modifiers |= EVENTFLAG_RIGHT_MOUSE_BUTTON; + break; + default: + btn_type = MBT_LEFT; + mouse_event.modifiers |= EVENTFLAG_LEFT_MOUSE_BUTTON; + } + + apply_key_modifiers(mouse_event.modifiers, modifiers); + + (*browser->state->browser) + ->GetHost() + ->SendMouseClickEvent(mouse_event, btn_type, mouse_up, + std::max(click_count, 3)); +} + +void wef_browser_send_mouse_move_event(WefBrowser* browser, int x, int y, + int modifiers) { + if (!browser->state->browser) { + return; + } + + CefMouseEvent mouse_event; + mouse_event.x = x; + mouse_event.y = y; + mouse_event.modifiers = ALL_MOUSE_BUTTONS; + apply_key_modifiers(mouse_event.modifiers, modifiers); + (*browser->state->browser)->GetHost()->SendMouseMoveEvent(mouse_event, false); + + browser->state->cursorX = mouse_event.x; + browser->state->cursorY = mouse_event.y; +} + +void wef_browser_send_mouse_wheel_event(WefBrowser* browser, int delta_x, + int delta_y) { + if (!browser->state->browser) { + return; + } + + CefMouseEvent mouse_event; + mouse_event.x = browser->state->cursorX; + mouse_event.y = browser->state->cursorY; + mouse_event.modifiers = ALL_MOUSE_BUTTONS; + (*browser->state->browser) + ->GetHost() + ->SendMouseWheelEvent(mouse_event, delta_x, delta_y); +} + +void wef_browser_send_key_event(WefBrowser* browser, bool is_down, int key_code, + int modifiers) { + if (!browser->state->browser) { + return; + } + + CefKeyEvent key_event; + key_event.type = is_down ? KEYEVENT_KEYDOWN : KEYEVENT_KEYUP; + key_event.modifiers = EVENTFLAG_NONE; + key_event.focus_on_editable_field = false; + key_event.is_system_key = false; + key_event.windows_key_code = key_code; + key_event.native_key_code = key_code; + key_event.modifiers = 0; + apply_key_modifiers(key_event.modifiers, modifiers); + (*browser->state->browser)->GetHost()->SendKeyEvent(key_event); +} + +void wef_browser_send_char_event(WefBrowser* browser, char16_t ch) { + if (!browser->state->browser) { + return; + } + + CefKeyEvent key_event; + key_event.type = KEYEVENT_CHAR; + key_event.modifiers = EVENTFLAG_NONE; + key_event.windows_key_code = static_cast(ch); + key_event.native_key_code = static_cast(ch); + key_event.character = static_cast(ch); + (*browser->state->browser)->GetHost()->SendKeyEvent(key_event); +} + +void wef_browser_ime_set_composition(WefBrowser* browser, const char* text, + uint32_t cursor_begin, + uint32_t cursor_end) { + if (!browser->state->browser) { + return; + } + (*browser->state->browser) + ->GetHost() + ->ImeSetComposition(text, {}, CefRange::InvalidRange(), + CefRange(cursor_begin, cursor_end)); +} + +void wef_browser_ime_commit(WefBrowser* browser, const char* text) { + if (!browser->state->browser) { + return; + } + (*browser->state->browser) + ->GetHost() + ->ImeCommitText(text, CefRange::InvalidRange(), 0); +} + +WefFrame* wef_browser_get_main_frame(WefBrowser* browser) { + if (!browser->state->browser) { + return nullptr; + } + auto main_frame = (*browser->state->browser)->GetMainFrame(); + return main_frame ? new WefFrame{main_frame} : nullptr; +} + +WefFrame* wef_browser_get_focused_frame(WefBrowser* browser) { + if (!browser->state->browser) { + return nullptr; + } + auto frame = (*browser->state->browser)->GetFocusedFrame(); + return frame ? new WefFrame{frame} : nullptr; +} + +WefFrame* wef_browser_get_frame_by_name(WefBrowser* browser, const char* name) { + if (!browser->state->browser) { + return nullptr; + } + auto frame = (*browser->state->browser)->GetFrameByName(name); + return frame ? new WefFrame{frame} : nullptr; +} + +WefFrame* wef_browser_get_frame_by_identifier(WefBrowser* browser, + const char* id) { + if (!browser->state->browser) { + return nullptr; + } + auto frame = (*browser->state->browser)->GetFrameByIdentifier(id); + return frame ? new WefFrame{frame} : nullptr; +} + +bool wef_browser_is_audio_muted(WefBrowser* browser, bool mute) { + if (browser->state->browser) { + return false; + } + return (*browser->state->browser)->GetHost()->IsAudioMuted(); +} + +void wef_browser_set_audio_mute(WefBrowser* browser, bool mute) { + if (browser->state->browser) { + return; + } + (*browser->state->browser)->GetHost()->SetAudioMuted(mute); +} + +void wef_browser_find(WefBrowser* browser, const char* search_text, + bool forward, bool match_case, bool find_next) { + if (!browser->state->browser) { + return; + } + (*browser->state->browser) + ->GetHost() + ->Find(search_text, forward, match_case, find_next); +} + +void wef_browser_set_focus(WefBrowser* browser, bool focus) { + if (!browser->state->browser) { + browser->state->focus = true; + return; + } + (*browser->state->browser)->GetHost()->SetFocus(focus); +} + +} // extern "C" \ No newline at end of file diff --git a/crates/wef/src/app_handler.rs b/crates/wef/src/app_handler.rs new file mode 100644 index 0000000..60e485d --- /dev/null +++ b/crates/wef/src/app_handler.rs @@ -0,0 +1,37 @@ +use std::ffi::c_void; + +/// Represents a handler for application events. +#[allow(unused_variables)] +pub trait ApplicationHandler: Send + Sync { + /// Called from any thread when work has been scheduled for the browser + /// process main (UI) thread. + /// + /// This callback is used in combination with [`crate::do_message_work`] in + /// cases where the CEF message loop must be integrated into an existing + /// application message loop. + /// + /// This callback should schedule a [`crate::do_message_work`] call to + /// happen on the main (UI) thread. + /// + /// `delay_ms` is the requested delay in milliseconds. If `delay_ms` is <= 0 + /// then the call should happen reasonably soon. If `delay_ms` is > 0 + /// then the call should be scheduled to happen after the specified + /// delay and any currently pending scheduled call should be cancelled. + fn on_schedule_message_pump_work(&mut self, delay_ms: i32) {} +} + +impl ApplicationHandler for () {} + +pub(crate) struct ApplicationState { + pub(crate) handler: T, +} + +pub(crate) extern "C" fn on_schedule_message_pump_work( + userdata: *mut c_void, + delay_ms: i32, +) { + unsafe { + let state = &mut *(userdata as *mut ApplicationState); + state.handler.on_schedule_message_pump_work(delay_ms); + } +} diff --git a/crates/wef/src/browser.rs b/crates/wef/src/browser.rs new file mode 100644 index 0000000..f84e922 --- /dev/null +++ b/crates/wef/src/browser.rs @@ -0,0 +1,224 @@ +use std::{ffi::CString, fmt}; + +use crate::{ + BrowserBuilder, Frame, KeyCode, KeyModifier, LogicalUnit, MouseButton, PhysicalUnit, Point, + Size, ffi::*, +}; + +/// A browser instance. +pub struct Browser { + pub(crate) wef_browser: *mut wef_browser_t, +} + +impl fmt::Debug for Browser { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Browser").finish() + } +} + +impl Drop for Browser { + fn drop(&mut self) { + unsafe { wef_browser_destroy(self.wef_browser) }; + } +} + +impl Browser { + /// Creates a new [`BrowserBuilder`] instance. + pub fn builder() -> BrowserBuilder<()> { + BrowserBuilder::new() + } + + /// Closes the browser. + /// + /// When the browser has been closed, the callback + /// [`crate::BrowserHandler::on_closed`] will be called. + /// + /// If the browser is not created or in creating state, this method does + /// nothing. + pub fn close(&self) { + unsafe { wef_browser_close(self.wef_browser) } + } + + /// Returns `true` if the browser is created. + pub fn is_created(&self) -> bool { + unsafe { wef_browser_is_created(self.wef_browser) } + } + + /// Sets the size of the render target. + pub fn resize(&self, sz: Size>) { + unsafe { wef_browser_set_size(self.wef_browser, sz.width.0.max(1), sz.height.0.max(1)) }; + } + + /// Loads a URL. + pub fn load_url(&self, url: &str) { + let c_url = CString::new(url).unwrap(); + unsafe { wef_browser_load_url(self.wef_browser, c_url.as_ptr()) }; + } + + /// Returns `true`` if the browser can navigate forwards. + pub fn can_forward(&self) -> bool { + unsafe { wef_browser_can_go_forward(self.wef_browser) } + } + + /// Returns `true`` if the browser can navigate backwards. + pub fn can_back(&self) -> bool { + unsafe { wef_browser_can_go_back(self.wef_browser) } + } + + /// Navigates forward in history. + pub fn forward(&self) { + unsafe { wef_browser_go_forward(self.wef_browser) }; + } + + /// Navigates back in history. + pub fn back(&self) { + unsafe { wef_browser_go_back(self.wef_browser) }; + } + + /// Reloads the current page. + pub fn reload(&self) { + unsafe { wef_browser_reload(self.wef_browser) }; + } + + /// Reloads the current page ignoring any cached data. + pub fn reload_ignore_cache(&self) { + unsafe { wef_browser_reload_ignore_cache(self.wef_browser) }; + } + + /// Sends a mouse click event. + pub fn send_mouse_click_event( + &self, + mouse_button_type: MouseButton, + mouse_up: bool, + click_count: usize, + modifiers: KeyModifier, + ) { + unsafe { + wef_browser_send_mouse_click_event( + self.wef_browser, + mouse_button_type as i32, + mouse_up, + click_count as i32, + modifiers.bits(), + ) + }; + } + + /// Sends a mouse move event. + pub fn send_mouse_move_event(&self, pt: Point>, modifiers: KeyModifier) { + unsafe { + wef_browser_send_mouse_move_event(self.wef_browser, pt.x.0, pt.y.0, modifiers.bits()) + }; + } + + /// Sends a mouse wheel event. + pub fn send_mouse_wheel_event(&self, delta: Point>) { + unsafe { wef_browser_send_mouse_wheel_event(self.wef_browser, delta.x.0, delta.y.0) }; + } + + /// Sends a key event. + pub fn send_key_event(&self, is_down: bool, key_code: KeyCode, modifiers: KeyModifier) { + unsafe { + wef_browser_send_key_event( + self.wef_browser, + is_down, + key_code as i32, + modifiers.bits(), + ); + if let Some(ch) = key_code.as_char() { + wef_browser_send_char_event(self.wef_browser, ch); + } + } + } + + /// Sends a character event. + pub fn send_char_event(&self, ch: u16) { + unsafe { wef_browser_send_char_event(self.wef_browser, ch) }; + } + + /// Sets the composition text for the IME (Input Method Editor). + pub fn ime_set_composition(&self, text: &str, cursor_begin: usize, cursor_end: usize) { + let c_text = CString::new(text).unwrap(); + unsafe { + wef_browser_ime_set_composition( + self.wef_browser, + c_text.as_ptr(), + cursor_begin as u32, + cursor_end as u32, + ) + }; + } + + /// Commits the composition text for the IME (Input Method Editor). + pub fn ime_commit(&self, text: &str) { + let c_text = CString::new(text).unwrap(); + unsafe { wef_browser_ime_commit(self.wef_browser, c_text.as_ptr()) }; + } + + /// Returns the main (top-level) frame for the browser. + pub fn main_frame(&self) -> Option { + let frame = unsafe { wef_browser_get_main_frame(self.wef_browser) }; + (!frame.is_null()).then_some(Frame(frame)) + } + + /// Returns the focused frame for the browser. + pub fn focused_frame(&self) -> Option { + let frame = unsafe { wef_browser_get_focused_frame(self.wef_browser) }; + (!frame.is_null()).then_some(Frame(frame)) + } + + /// Returns a frame by its name. + pub fn frame_by_name(&self, name: &str) -> Option { + let c_name = CString::new(name).unwrap(); + let frame = unsafe { wef_browser_get_frame_by_name(self.wef_browser, c_name.as_ptr()) }; + (!frame.is_null()).then_some(Frame(frame)) + } + + /// Returns a frame by its identifier. + pub fn frame_by_identifier(&self, id: &str) -> Option { + let c_id = CString::new(id).unwrap(); + let frame = unsafe { wef_browser_get_frame_by_identifier(self.wef_browser, c_id.as_ptr()) }; + (!frame.is_null()).then_some(Frame(frame)) + } + + /// Returns `true` if the browser's audio is muted. + pub fn is_audio_muted(&self) -> bool { + unsafe { wef_browser_is_audio_muted(self.wef_browser) } + } + + /// Set whether the browser's audio is muted. + pub fn set_audio_mute(&self, mute: bool) { + unsafe { wef_browser_set_audio_mute(self.wef_browser, mute) }; + } + + /// Search for `searchText``. + /// + /// `forward`` indicates whether to search forward + /// or backward within the page. + /// `matchCase`` indicates whether the search should be case-sensitive. + /// `findNext`` indicates whether this is the first request or a + /// follow-up. + /// + /// The search will be restarted if `searchText` or `matchCase` change. The + /// search will be stopped if `searchText` is empty. + /// + /// The find results will be reported via the + /// [`crate::BrowserHandler::on_find_result`]. + pub fn find(&self, search_text: &str, forward: bool, match_case: bool, find_next: bool) { + unsafe { + let c_search_text = CString::new(search_text).unwrap(); + wef_browser_find( + self.wef_browser, + c_search_text.as_ptr(), + forward, + match_case, + find_next, + ) + }; + } + + /// Set whether the browser is focused. + pub fn set_focus(&self, focus: bool) { + unsafe { wef_browser_set_focus(self.wef_browser, focus) }; + } +} diff --git a/crates/wef/src/browser_handler.rs b/crates/wef/src/browser_handler.rs new file mode 100644 index 0000000..32cee22 --- /dev/null +++ b/crates/wef/src/browser_handler.rs @@ -0,0 +1,667 @@ +use std::{ + ffi::{CStr, c_char, c_void}, + mem::MaybeUninit, +}; + +use num_enum::TryFromPrimitive; +use serde::Deserialize; +use serde_json::Value; + +use crate::{ + Accept, ContextMenuEditStateFlags, ContextMenuMediaStateFlags, ContextMenuMediaType, + ContextMenuParams, ContextMenuTypeFlags, CursorType, DirtyRects, FileDialogCallback, + FileDialogMode, Frame, JsDialogCallback, JsDialogType, LogicalUnit, Point, Rect, Size, + builder::BrowserState, cursor::CursorInfo, ffi::*, file_dialog::AcceptFilter, + query::QueryCallback, +}; + +/// A type alias for the image buffer. +pub type ImageBuffer<'a> = image::ImageBuffer, &'a [u8]>; + +/// Paint element types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] +#[allow(missing_docs)] +#[repr(i32)] +pub enum PaintElementType { + View = 0, + Popup = 1, +} + +/// Log severity levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] +#[repr(i32)] +pub enum LogSeverity { + /// Default logging (currently INFO logging). + Default = 0, + /// DEBUG logging. + Debug = 1, + /// INFO logging. + Info = 2, + /// WARNING logging. + Warning = 3, + /// ERROR logging. + Error = 4, + /// FATAL logging. + Fatal = 5, + /// Disable logging to file for all messages, and to stderr for messages + /// with severity less than FATAL. + Disable = 99, +} + +/// Represents a handler for browser events. +/// +/// **All the functions are called on the main thread.** +#[allow(unused_variables)] +pub trait BrowserHandler { + /// Called when the browser is created. + fn on_created(&mut self) {} + + /// Called when the browser is closed. + fn on_closed(&mut self) {} + + /// Called when the browser wants to show or hide the popup widget. + fn on_popup_show(&mut self, show: bool) {} + + /// Called when the browser wants to move or resize the popup widget. + fn on_popup_position(&mut self, rect: Rect>) {} + + /// Called when an element should be painted. + /// + /// Pixel values passed to this method are scaled relative to view + /// coordinates based on the value of + /// [`crate::BrowserBuilder::device_scale_factor`]. + /// + /// `type` indicates whether the element is the view or the popup widget. + /// + /// `image_buffer` contains the pixel data for the whole image. + /// + /// `dirty_rects` contains the set of rectangles in pixel coordinates that + /// need to be repainted. + fn on_paint( + &mut self, + type_: PaintElementType, + dirty_rects: &DirtyRects, + image_buffer: ImageBuffer, + ) { + } + + /// Called when the address of the frame changes. + fn on_address_changed(&mut self, frame: Frame, url: &str) {} + + /// Called when the title changes. + fn on_title_changed(&mut self, title: &str) {} + + /// Called when the page icon changes. + fn on_favicon_url_changed(&mut self, urls: &[&str]) {} + + /// Called when the browser is about to display a tooltip. + fn on_tooltip(&mut self, text: &str) {} + + /// Called when the browser receives a status message. + fn on_status_message(&mut self, text: &str) {} + + /// Called to display a console message. + fn on_console_message( + &mut self, + message: &str, + level: LogSeverity, + source: &str, + line_number: i32, + ) { + } + + /// Called when the cursor changes. + /// + /// Return `true` if the cursor change was handled or false for default + /// handling. + fn on_cursor_changed( + &mut self, + cursor_type: CursorType, + cursor_info: Option, + ) -> bool { + false + } + + /// Called when preparing to open a popup browser window. + fn on_before_popup(&mut self, url: &str) {} + + /// Called when the overall page loading progress changes. + /// + /// `progress` ranges from 0.0 to 1.0. + fn on_loading_progress_changed(&mut self, progress: f32) {} + + /// Called when the loading state changes. + /// + /// This callback will be executed twice -- once when loading is initiated + /// either programmatically or by user action, and once when loading is + /// terminated due to completion, cancellation of failure. + /// + /// It will be called before any calls to `on_load_start` and after all + /// calls to `on_load_error` and/or `on_load_end`. + fn on_loading_state_changed( + &mut self, + is_loading: bool, + can_go_back: bool, + can_go_forward: bool, + ) { + } + + /// Called after a navigation has been committed and before the browser + /// begins loading contents in the frame. + fn on_load_start(&mut self, frame: Frame) {} + + /// Called when the browser is done loading a frame. + fn on_load_end(&mut self, frame: Frame) {} + + /// Called when a navigation fails or is canceled. + /// + /// This method may be called by itself if before commit or in combination + /// with `on_load_start`/`on_load_end` if after commit. + fn on_load_error(&mut self, frame: Frame, error_text: &str, failed_url: &str) {} + + /// Called when the IME composition range changes. + fn on_ime_composition_range_changed(&mut self, bounds: Rect>) {} + + /// Called when a file dialog is requested. + /// + /// To display the default dialog return `false`. + fn on_file_dialog( + &mut self, + mode: FileDialogMode, + title: Option<&str>, + default_file_path: Option<&str>, + accepts: &[Accept], + callback: FileDialogCallback, + ) -> bool { + false + } + + /// Called when before displaying a context menu. + fn on_context_menu(&mut self, frame: Frame, params: ContextMenuParams) {} + + /// Called to report find results returned by [`crate::Browser::find`]. + /// + /// `identifier`` is a unique incremental identifier for the currently + /// active search. + /// `count`` is the number of matches currently identified. + /// `selection_rect` is the location of where the match was found (in window + /// coordinates). + /// `active_match_ordinal` is the current position in the search results. + /// `final_update` is `true` if this is the last find notification. + fn on_find_result( + &mut self, + identifier: i32, + count: i32, + selection_rect: Rect>, + active_match_ordinal: i32, + final_update: bool, + ) { + } + + /// Called to run a JavaScript dialog. + /// + /// The `default_prompt_text` value will be specified for prompt dialogs + /// only. + /// + /// Return `true` if the application will use a custom + /// dialog or if the callback has been executed immediately. Custom dialogs + /// may be either modal or modeless. + /// + /// If a custom dialog is used the application must execute `callback` once + /// the custom dialog is dismissed. + fn on_js_dialog( + &mut self, + type_: JsDialogType, + message_text: &str, + callback: JsDialogCallback, + ) -> bool { + false + } +} + +impl BrowserHandler for () {} + +pub(crate) extern "C" fn on_created(userdata: *mut c_void) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + state.handler.on_created(); + } +} + +pub(crate) extern "C" fn on_closed(userdata: *mut c_void) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + state.handler.on_closed(); + } +} + +pub(crate) extern "C" fn on_popup_show(userdata: *mut c_void, show: bool) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + state.handler.on_popup_show(show); + } +} + +pub(crate) extern "C" fn on_popup_position( + userdata: *mut c_void, + rect: *const Rect, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + state.handler.on_popup_position((*rect).map(LogicalUnit)); + } +} + +pub(crate) extern "C" fn on_paint( + userdata: *mut c_void, + type_: i32, + dirty_rects: *const c_void, + image_buffer: *const c_void, + width: u32, + height: u32, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let type_ = PaintElementType::try_from(type_).expect("BUG: invalid paint element type"); + let dirty_rects = DirtyRects::new(dirty_rects); + let image_buffer = + std::slice::from_raw_parts(image_buffer as *const u8, (width * height * 4) as usize); + state.handler.on_paint( + type_, + &dirty_rects, + ImageBuffer::from_raw(width, height, image_buffer).unwrap(), + ); + } +} + +pub(crate) extern "C" fn on_address_changed( + userdata: *mut c_void, + frame: *mut wef_frame_t, + url: *const c_char, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let frame = Frame(frame); + let url = CStr::from_ptr(url).to_string_lossy(); + state.handler.on_address_changed(frame, &url); + } +} + +pub(crate) extern "C" fn on_title_changed( + userdata: *mut c_void, + title: *const c_char, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let title = CStr::from_ptr(title).to_string_lossy(); + state.handler.on_title_changed(&title); + } +} + +pub(crate) extern "C" fn on_favicon_url_changed( + userdata: *mut c_void, + urls: *const *const c_char, + num_urls: i32, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let urls = std::slice::from_raw_parts(urls, num_urls as usize) + .iter() + .filter_map(|url| CStr::from_ptr(*url).to_str().ok()) + .collect::>(); + state.handler.on_favicon_url_changed(&urls); + } +} + +pub(crate) extern "C" fn on_tooltip(userdata: *mut c_void, text: *const c_char) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let text = CStr::from_ptr(text).to_string_lossy(); + state.handler.on_tooltip(&text); + } +} + +pub(crate) extern "C" fn on_status_message( + userdata: *mut c_void, + text: *const c_char, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let text = CStr::from_ptr(text).to_string_lossy(); + state.handler.on_status_message(&text); + } +} + +pub(crate) extern "C" fn on_console_message( + userdata: *mut c_void, + message: *const c_char, + level: i32, + source: *const c_char, + line: i32, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let message = CStr::from_ptr(message).to_string_lossy(); + let source = CStr::from_ptr(source).to_string_lossy(); + let level = LogSeverity::try_from(level).expect("BUG: invalid log severity"); + state + .handler + .on_console_message(&message, level, &source, line); + } +} + +pub(crate) extern "C" fn on_cursor_changed( + userdata: *mut c_void, + cursor_type: i32, + custom_cursor_info: *const c_void, +) -> bool { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let cursor_type = CursorType::try_from(cursor_type).expect("BUG: invalid file dialog mode"); + let cursor_info = if !custom_cursor_info.is_null() { + let mut hotspot: MaybeUninit> = MaybeUninit::uninit(); + let mut size: MaybeUninit> = MaybeUninit::uninit(); + + wef_cursor_info_hotspot(custom_cursor_info, hotspot.as_mut_ptr()); + wef_cursor_info_size(custom_cursor_info, size.as_mut_ptr()); + + let hotspot = hotspot.assume_init(); + let size = size.assume_init(); + + let image_buffer = std::slice::from_raw_parts( + wef_cursor_info_buffer(custom_cursor_info) as *const u8, + (size.width * size.height * 4) as usize, + ); + + Some(CursorInfo { + hotspot, + scale_factor: wef_cursor_info_image_scale_factor(custom_cursor_info), + image: ImageBuffer::from_raw(size.width as u32, size.height as u32, image_buffer) + .expect("BUG: invalid image buffer size"), + }) + } else { + None + }; + state.handler.on_cursor_changed(cursor_type, cursor_info) + } +} + +pub(crate) extern "C" fn on_before_popup( + userdata: *mut c_void, + url: *const c_char, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let url = CStr::from_ptr(url).to_string_lossy(); + state.handler.on_before_popup(&url); + } +} + +pub(crate) extern "C" fn on_loading_progress_changed( + userdata: *mut c_void, + progress: f32, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + state.handler.on_loading_progress_changed(progress); + } +} + +pub(crate) extern "C" fn on_loading_state_changed( + userdata: *mut c_void, + is_loading: bool, + can_go_back: bool, + can_go_forward: bool, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + state + .handler + .on_loading_state_changed(is_loading, can_go_back, can_go_forward); + } +} + +pub(crate) extern "C" fn on_load_start( + userdata: *mut c_void, + frame: *mut wef_frame_t, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let frame = Frame(frame); + state.handler.on_load_start(frame); + } +} + +pub(crate) extern "C" fn on_load_end( + userdata: *mut c_void, + frame: *mut wef_frame_t, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let frame = Frame(frame); + state.handler.on_load_end(frame); + } +} + +pub(crate) extern "C" fn on_load_error( + userdata: *mut c_void, + frame: *mut wef_frame_t, + error_text: *const c_char, + failed_url: *const c_char, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let frame = Frame(frame); + let error_text = CStr::from_ptr(error_text).to_string_lossy(); + let failed_url = CStr::from_ptr(failed_url).to_string_lossy(); + state.handler.on_load_error(frame, &error_text, &failed_url); + } +} + +pub(crate) extern "C" fn on_ime_composition_range_changed( + userdata: *mut c_void, + bounds: *const Rect, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + state + .handler + .on_ime_composition_range_changed((*bounds).map(LogicalUnit)); + } +} + +pub(crate) extern "C" fn on_file_dialog( + userdata: *mut c_void, + mode: i32, + title: *const c_char, + default_file_path: *const c_char, + accept_filters: *const c_char, + accept_extensions: *const c_char, + accept_descriptions: *const c_char, + callback: *mut c_void, +) -> bool { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let mode = FileDialogMode::try_from(mode).expect("BUG: invalid file dialog mode"); + let title = CStr::from_ptr(title).to_string_lossy(); + let default_file_path = CStr::from_ptr(default_file_path).to_string_lossy(); + let accept_filters = CStr::from_ptr(accept_filters).to_string_lossy(); + let accept_extensions = CStr::from_ptr(accept_extensions).to_string_lossy(); + let accept_descriptions = CStr::from_ptr(accept_descriptions).to_string_lossy(); + let mut extensions_vec = vec![]; + let mut accepts = vec![]; + const DELIMITER: &str = "@@@"; + + for ((filter, extensions), description) in accept_filters + .split(DELIMITER) + .zip(accept_extensions.split(DELIMITER)) + .zip(accept_descriptions.split(DELIMITER)) + { + let filter = if filter.starts_with('.') { + AcceptFilter::Extension(filter) + } else { + let Ok(mime) = filter.parse() else { + continue; + }; + AcceptFilter::Mime(mime) + }; + + let extensions = (!extensions.is_empty()).then(|| { + extensions_vec.push(extensions.split(';').collect::>()); + extensions_vec.len() - 1 + }); + + let description = (!description.is_empty()).then_some(description); + accepts.push((filter, extensions, description)); + } + + let accepts = accepts + .into_iter() + .map(|(filter, extensions, description)| Accept { + filters: filter, + extensions: extensions.map(|idx| &*extensions_vec[idx]), + description, + }) + .collect::>(); + + let title = (!title.is_empty()).then_some(&*title); + let default_file_path = (!default_file_path.is_empty()).then_some(&*default_file_path); + + state.handler.on_file_dialog( + mode, + title, + default_file_path, + &accepts, + FileDialogCallback::new(callback), + ) + } +} + +pub(crate) extern "C" fn on_context_menu( + userdata: *mut c_void, + frame: *mut wef_frame_t, + params: *const CContextMenuParams, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let frame = Frame(frame); + let link_url = (!(*params).link_url.is_null()) + .then(|| CStr::from_ptr((*params).link_url).to_string_lossy()); + let unfiltered_link_url = (!(*params).unfiltered_link_url.is_null()) + .then(|| CStr::from_ptr((*params).unfiltered_link_url).to_string_lossy()); + let source_url = (!(*params).source_url.is_null()) + .then(|| CStr::from_ptr((*params).source_url).to_string_lossy()); + let title_text = (!(*params).title_text.is_null()) + .then(|| CStr::from_ptr((*params).title_text).to_string_lossy()); + let page_url = CStr::from_ptr((*params).page_url).to_string_lossy(); + let frame_url = CStr::from_ptr((*params).frame_url).to_string_lossy(); + let selection_text = (!(*params).selection_text.is_null()) + .then(|| CStr::from_ptr((*params).selection_text).to_string_lossy()); + + state.handler.on_context_menu( + frame, + ContextMenuParams { + crood: Point::new( + LogicalUnit((*params).x_crood), + LogicalUnit((*params).y_crood), + ), + type_: ContextMenuTypeFlags::from_bits_truncate((*params).type_flags), + link_url: link_url.as_deref(), + unfiltered_link_url: unfiltered_link_url.as_deref(), + source_url: source_url.as_deref(), + has_image_contents: (*params).has_image_contents, + title_text: title_text.as_deref(), + page_url: &page_url, + frame_url: &frame_url, + media_type: ContextMenuMediaType::try_from((*params).media_type) + .unwrap_or_default(), + media_state_flags: ContextMenuMediaStateFlags::from_bits_truncate( + (*params).media_state_flags, + ), + selection_text: selection_text.as_deref(), + is_editable: (*params).is_editable, + edit_state_flags: ContextMenuEditStateFlags::from_bits_truncate( + (*params).edit_state_flags, + ), + }, + ); + } +} + +pub(crate) extern "C" fn on_find_result( + userdata: *mut c_void, + identifier: i32, + count: i32, + selection_rect: *const Rect, + active_match_ordinal: i32, + final_update: bool, +) { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + state.handler.on_find_result( + identifier, + count, + (*selection_rect).map(LogicalUnit), + active_match_ordinal, + final_update, + ); + } +} + +pub(crate) extern "C" fn on_js_dialog( + userdata: *mut c_void, + type_: i32, + message_text: *const c_char, + default_prompt_text: *const c_char, + callback: *mut c_void, +) -> bool { + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let default_prompt_text = CStr::from_ptr(default_prompt_text).to_string_lossy(); + let type_ = match type_ { + 0 => JsDialogType::Alert, + 1 => JsDialogType::Confirm, + 2 => JsDialogType::Prompt { + default_prompt_text: &default_prompt_text, + }, + _ => panic!("BUG: invalid js dialog type"), + }; + let message_text = CStr::from_ptr(message_text).to_string_lossy(); + state + .handler + .on_js_dialog(type_, &message_text, JsDialogCallback::new(callback)) + } +} + +pub(crate) extern "C" fn on_query( + userdata: *mut c_void, + frame: *mut wef_frame_t, + query: *const c_char, + callback: *mut wef_query_callback_t, +) { + #[derive(Debug, Deserialize)] + struct Request { + method: String, + args: Vec, + } + + let frame = Frame(frame); + + unsafe { + let state = &mut *(userdata as *mut BrowserState); + let Some(request) = CStr::from_ptr(query) + .to_str() + .ok() + .and_then(|value| serde_json::from_str::(value).ok()) + else { + return; + }; + + state.func_registry.call( + frame, + &request.method, + request.args, + QueryCallback::new(callback), + ) + } +} diff --git a/crates/wef/src/builder.rs b/crates/wef/src/builder.rs new file mode 100644 index 0000000..025ad22 --- /dev/null +++ b/crates/wef/src/builder.rs @@ -0,0 +1,186 @@ +use std::ffi::{CString, c_void}; + +use raw_window_handle::RawWindowHandle; + +use crate::{Browser, BrowserHandler, FuncRegistry, ffi::*}; + +/// A builder for creating a browser instance. +pub struct BrowserBuilder { + parent: Option, + width: u32, + height: u32, + device_scale_factor: f32, + frame_rate: u32, + url: String, + handler: T, + func_registry: FuncRegistry, +} + +impl BrowserBuilder<()> { + pub(crate) fn new() -> BrowserBuilder<()> { + BrowserBuilder { + parent: None, + width: 100, + height: 100, + device_scale_factor: 1.0, + frame_rate: 60, + url: "about:blank".to_string(), + handler: (), + func_registry: Default::default(), + } + } +} + +pub(crate) struct BrowserState { + pub(crate) handler: T, + pub(crate) func_registry: FuncRegistry, +} + +impl BrowserBuilder +where + T: BrowserHandler, +{ + /// Sets the parent window handle. + /// + /// Default is `None`. + pub fn parent(self, parent: impl Into>) -> Self { + Self { + parent: parent.into(), + ..self + } + } + + /// Sets the size of the render target. + pub fn size(self, width: u32, height: u32) -> Self { + Self { + width, + height, + ..self + } + } + + /// Sets the device scale factor. + /// + /// Default is `1.0`. + #[inline] + pub fn device_scale_factor(self, device_scale_factor: f32) -> Self { + Self { + device_scale_factor, + ..self + } + } + + /// Sets the frame rate. + /// + /// Default is `60`. + pub fn frame_rate(self, frame_rate: u32) -> Self { + Self { frame_rate, ..self } + } + + /// Sets the URL to load. + /// + /// Default is `about:blank`. + pub fn url(self, url: impl Into) -> Self { + Self { + url: url.into(), + ..self + } + } + + /// Sets the event handler. + #[inline] + pub fn handler(self, handler: Q) -> BrowserBuilder + where + Q: BrowserHandler, + { + BrowserBuilder { + parent: self.parent, + width: self.width, + height: self.height, + device_scale_factor: self.device_scale_factor, + frame_rate: self.frame_rate, + url: self.url, + handler, + func_registry: self.func_registry, + } + } + + /// Sets the function registry. + /// + /// See also [`FuncRegistry`] for more details. + pub fn func_registry(self, func_registry: FuncRegistry) -> Self { + Self { + func_registry, + ..self + } + } + + /// Cosumes the builder and creates a [`Browser`] instance. + /// + /// The creation of the browser is asynchronous, and the + /// [`BrowserHandler::on_created`] method will be called upon completion. + pub fn build(self) -> Browser { + let callbacks = CBrowserCallbacks { + on_created: crate::browser_handler::on_created::, + on_closed: crate::browser_handler::on_closed::, + on_popup_show: crate::browser_handler::on_popup_show::, + on_popup_position: crate::browser_handler::on_popup_position::, + on_paint: crate::browser_handler::on_paint::, + on_address_changed: crate::browser_handler::on_address_changed::, + on_title_changed: crate::browser_handler::on_title_changed::, + on_favicon_url_changed: crate::browser_handler::on_favicon_url_changed::, + on_tooltip: crate::browser_handler::on_tooltip::, + on_status_message: crate::browser_handler::on_status_message::, + on_console_message: crate::browser_handler::on_console_message::, + on_cursor_changed: crate::browser_handler::on_cursor_changed::, + on_before_popup: crate::browser_handler::on_before_popup::, + on_loading_progress_changed: crate::browser_handler::on_loading_progress_changed::, + on_loading_state_changed: crate::browser_handler::on_loading_state_changed::, + on_load_start: crate::browser_handler::on_load_start::, + on_load_end: crate::browser_handler::on_load_end::, + on_load_error: crate::browser_handler::on_load_error::, + on_ime_composition_range_changed: + crate::browser_handler::on_ime_composition_range_changed::, + on_file_dialog: crate::browser_handler::on_file_dialog::, + on_context_menu: crate::browser_handler::on_context_menu::, + on_find_result: crate::browser_handler::on_find_result::, + on_js_dialog: crate::browser_handler::on_js_dialog::, + on_query: crate::browser_handler::on_query::, + }; + let handler = Box::into_raw(Box::new(BrowserState { + handler: self.handler, + func_registry: self.func_registry.clone(), + })); + let parent_window_handle: *const c_void = match self.parent { + Some(RawWindowHandle::Win32(handle)) => handle.hwnd.get() as *const c_void, + Some(RawWindowHandle::AppKit(handle)) => handle.ns_view.as_ptr(), + Some(RawWindowHandle::Xcb(handle)) => handle.window.get() as *const c_void, + _ => std::ptr::null(), + }; + + extern "C" fn destroy_handler(user_data: *mut c_void) { + unsafe { _ = Box::from_raw(user_data as *mut T) } + } + + let url_cstr = CString::new(self.url).unwrap(); + let inject_javascript = CString::new(self.func_registry.javascript()).unwrap(); + let settings = CBrowserSettings { + parent: parent_window_handle, + device_scale_factor: self.device_scale_factor, + width: self.width as i32, + height: self.height as i32, + frame_rate: self.frame_rate as i32, + url: url_cstr.as_ptr(), + inject_javascript: inject_javascript.as_ptr(), + callbacks, + userdata: handler as *mut c_void, + destroy_userdata: destroy_handler::, + }; + + unsafe { + Browser { + wef_browser: wef_browser_create(&settings), + } + } + } +} diff --git a/crates/wef/src/context_menu.rs b/crates/wef/src/context_menu.rs new file mode 100644 index 0000000..9ce6b67 --- /dev/null +++ b/crates/wef/src/context_menu.rs @@ -0,0 +1,147 @@ +use num_enum::TryFromPrimitive; + +use crate::{LogicalUnit, Point}; + +bitflags::bitflags! { + /// The type of node that is selected. + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub struct ContextMenuTypeFlags: i32 { + /// No node is selected. + const NONE = 0; + /// The top page is selected. + const PAGE = 1 << 0; + /// A subframe page is selected. + const FRAME = 1 << 1; + /// A link is selected. + const LINK = 1 << 2; + /// A media node is selected. + const MEDIA = 1 << 3; + /// There is a textual or mixed selection that is selected. + const SELECTION = 1 << 4; + /// An editable element is selected. + const EDITABLE = 1 << 5; + } +} + +/// Supported context menu media types. +#[derive(Copy, Clone, Debug, PartialEq, Eq, TryFromPrimitive, Default)] +#[repr(i32)] +pub enum ContextMenuMediaType { + /// No special node is in context. + #[default] + None = 0, + /// An image node is selected. + Image = 1, + /// A video node is selected. + Video = 2, + /// An audio node is selected. + Audio = 3, + /// A canvas node is selected. + Canvas = 4, + /// A file node is selected. + File = 5, + /// A plugin node is selected. + Plugin = 6, +} + +bitflags::bitflags! { + /// Supported context menu media state bit flags. + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub struct ContextMenuMediaStateFlags: i32 { + /// No special state is in context. + const NONE = 0; + /// The media is in error. + const IN_ERROR = 1 << 0; + /// The media is paused. + const PAUSED = 1 << 1; + /// The media is muted. + const MUTED = 1 << 2; + /// The media is set to loop. + const LOOP = 1 << 3; + /// The media can be saved. + const CAN_SAVE = 1 << 4; + /// The media has audio. + const HAS_AUDIO = 1 << 5; + /// The media can toggle controls. + const CAN_TOGGLE_CONTROLS = 1 << 6; + /// The media has controls enabled. + const CONTROLS = 1 << 7; + /// The media can be printed. + const CAN_PRINT = 1 << 8; + /// The media can be rotated. + const CAN_ROTATE = 1 << 9; + /// The media can be displayed in picture-in-picture mode. + const CAN_PICTURE_IN_PICTURE = 1 << 10; + /// The media is in picture-in-picture mode. + const PICTURE_IN_PICTURE = 1 << 11; + /// The media can be looped. + const CAN_LOOP = 1 << 12; + } +} + +bitflags::bitflags! { + /// Supported context menu type bit flags. + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub struct ContextMenuEditStateFlags: i32 { + /// No special state is in context. + const NONE = 0; + /// The edit control can be undone. + const CAN_UNDO = 1 << 0; + /// The edit control can be redone. + const CAN_REDO = 1 << 1; + /// The edit control can be cut. + const CAN_CUT = 1 << 2; + /// The edit control can be copied. + const CAN_COPY = 1 << 3; + /// The edit control can be pasted. + const CAN_PASTE = 1 << 4; + /// The edit control can be deleted. + const CAN_DELETE = 1 << 5; + /// The edit control can select all text. + const CAN_SELECT_ALL = 1 << 6; + /// The edit control can be translated. + const CAN_TRANSLATE = 1 << 7; + /// The edit control can be edited richly. + const CAN_EDIT_RICHLY = 1 << 8; + } +} + +/// Provides information about the context menu state. +#[derive(Debug)] +pub struct ContextMenuParams<'a> { + /// The X coordinate of the mouse where the context menu was invoked. + /// + /// Coords are relative to the associated RenderView's origin. + pub crood: Point>, + /// The flags representing the type of node that the context menu was + /// invoked on. + pub type_: ContextMenuTypeFlags, + /// The URL of the link + pub link_url: Option<&'a str>, + /// The link URL to be used ONLY for "copy link address". + pub unfiltered_link_url: Option<&'a str>, + /// The source URL for the element that the context menu was invoked on. + /// + /// Example of elements with source URLs are img, audio, and video. + pub source_url: Option<&'a str>, + /// Whether the element that the context menu was invoked on has image + /// contents. + pub has_image_contents: bool, + /// The title text or the alt text if the context menu was invoked on + /// an image. + pub title_text: Option<&'a str>, + /// The URL of the top level page that the context menu was invoked on. + pub page_url: &'a str, + /// The URL of the subframe that the context menu was invoked on. + pub frame_url: &'a str, + /// The type of context node that the context menu was invoked on. + pub media_type: ContextMenuMediaType, + /// The flags representing the actions supported by the media element. + pub media_state_flags: ContextMenuMediaStateFlags, + /// The text of the selection, if any, that the context menu was invoked on. + pub selection_text: Option<&'a str>, + /// Whether the context menu was invoked on an editable element. + pub is_editable: bool, + /// The flags representing the actions supported by the editable node. + pub edit_state_flags: ContextMenuEditStateFlags, +} diff --git a/crates/wef/src/cursor.rs b/crates/wef/src/cursor.rs new file mode 100644 index 0000000..0806997 --- /dev/null +++ b/crates/wef/src/cursor.rs @@ -0,0 +1,72 @@ +use num_enum::TryFromPrimitive; + +use crate::{ImageBuffer, Point}; + +/// Cursor type values. +#[derive(Debug, Clone, Copy, TryFromPrimitive)] +#[repr(i32)] +#[allow(missing_docs)] +pub enum CursorType { + Pointer = 0, + Cross = 1, + Hand = 2, + IBeam = 3, + Wait = 4, + Help = 5, + EastResize = 6, + NorthResize = 7, + NorthEastResize = 8, + NorthWestResize = 9, + SouthResize = 10, + SouthEastResize = 11, + SouthWestResize = 12, + WestResize = 13, + NorthSouthResize = 14, + EastWestResize = 15, + NorthEastSouthWestResize = 16, + NorthWestSouthEastResize = 17, + ColumnResize = 18, + RowResize = 19, + MiddlePanning = 20, + EastPanning = 21, + NorthPanning = 22, + NorthEastPanning = 23, + NorthWestPanning = 24, + SouthPanning = 25, + SouthEastPanning = 26, + SouthWestPanning = 27, + WestPanning = 28, + Move = 29, + VerticalText = 30, + Cell = 31, + ContextMenu = 32, + Alias = 33, + Progress = 34, + NoDrop = 35, + Copy = 36, + None = 37, + NotAllowed = 38, + ZoomIn = 39, + ZoomOut = 40, + Grab = 41, + Grabbing = 42, + MiddlePanningVertical = 43, + MiddlePanningHorizontal = 44, + Custom = 45, + DndNone = 46, + DndMove = 47, + DndCopy = 48, + DndLink = 49, +} + +/// Representing cursor information. +#[derive(Debug)] +pub struct CursorInfo<'a> { + /// The hotspot of the cursor, which is the point in the image that will be + /// used as the actual cursor position. + pub hotspot: Point, + /// The scale factor of the cursor. + pub scale_factor: f32, + /// The image buffer of the cursor. + pub image: ImageBuffer<'a>, +} diff --git a/crates/wef/src/dirty_rects.rs b/crates/wef/src/dirty_rects.rs new file mode 100644 index 0000000..9af92c2 --- /dev/null +++ b/crates/wef/src/dirty_rects.rs @@ -0,0 +1,95 @@ +use std::{ffi::c_void, fmt, marker::PhantomData, mem::MaybeUninit}; + +use crate::{PhysicalUnit, Rect, ffi::*}; + +/// Dirty rectangles for CEF. +pub struct DirtyRects<'a> { + ptr: *const c_void, + _mark: PhantomData<&'a ()>, +} + +impl fmt::Debug for DirtyRects<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut ls = f.debug_list(); + for rect in self.iter() { + ls.entry(&rect); + } + ls.finish() + } +} + +impl<'a> DirtyRects<'a> { + #[inline] + pub(crate) fn new(dirty_rects: *const c_void) -> Self { + DirtyRects { + ptr: dirty_rects, + _mark: PhantomData, + } + } + + /// Returns the number of dirty rectangles. + #[inline] + pub fn len(&self) -> usize { + unsafe { wef_dirty_rects_len(self.ptr) as usize } + } + + /// Returns `true` if there are no dirty rectangles. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Returns the dirty rectangle at the specified index. + #[inline] + pub fn get(&self, i: usize) -> Rect> { + unsafe { + debug_assert!(i < self.len(), "index out of bounds"); + let mut rect: MaybeUninit> = MaybeUninit::uninit(); + wef_dirty_rects_get(self.ptr, i as i32, rect.as_mut_ptr()); + rect.assume_init().map(PhysicalUnit) + } + } + + /// Returns an iterator over the dirty rectangles. + #[inline] + pub fn iter(&self) -> DirtyRectsIter<'a> { + DirtyRectsIter { + ptr: self.ptr, + index: 0, + _mark: PhantomData, + } + } +} + +impl<'a> IntoIterator for &'a DirtyRects<'a> { + type Item = Rect>; + type IntoIter = DirtyRectsIter<'a>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// An iterator over the dirty rectangles. +pub struct DirtyRectsIter<'a> { + ptr: *const c_void, + index: usize, + _mark: PhantomData<&'a ()>, +} + +impl Iterator for DirtyRectsIter<'_> { + type Item = Rect>; + + fn next(&mut self) -> Option { + unsafe { + if self.index < wef_dirty_rects_len(self.ptr) as usize { + let mut rect: MaybeUninit> = MaybeUninit::uninit(); + wef_dirty_rects_get(self.ptr, self.index as i32, rect.as_mut_ptr()); + self.index += 1; + Some(rect.assume_init().map(PhysicalUnit)) + } else { + None + } + } + } +} diff --git a/crates/wef/src/dpi.rs b/crates/wef/src/dpi.rs new file mode 100644 index 0000000..170197e --- /dev/null +++ b/crates/wef/src/dpi.rs @@ -0,0 +1,43 @@ +/// A physical pixel unit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PhysicalUnit(pub T); + +macro_rules! impl_to_logical { + ($($unit:ty),*) => { + $( + impl PhysicalUnit<$unit> { + /// Converts the physical unit to a logical unit using the device scale + /// factor. + #[inline] + pub fn to_logical(&self, device_scale_factor: f32) -> LogicalUnit<$unit> { + LogicalUnit((self.0 as f32 / device_scale_factor) as $unit) + } + } + )* + }; + () => {}; +} + +impl_to_logical!(i32, u32, i64, u64, f32, f64); + +/// A logical pixel unit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LogicalUnit(pub T); + +macro_rules! impl_to_physical { + ($($unit:ty),*) => { + $( + impl LogicalUnit<$unit> { + /// Converts the logical unit to a physical unit using the device scale + /// factor. + #[inline] + pub fn to_physical(&self, device_scale_factor: f32) -> PhysicalUnit<$unit> { + PhysicalUnit((self.0 as f32 * device_scale_factor) as $unit) + } + } + )* + }; + () => {}; +} + +impl_to_physical!(i32, u32, i64, u64, f32, f64); diff --git a/crates/wef/src/error.rs b/crates/wef/src/error.rs new file mode 100644 index 0000000..2c0c035 --- /dev/null +++ b/crates/wef/src/error.rs @@ -0,0 +1,15 @@ +/// Error type. +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + /// Failed to load the CEF framework. + #[cfg(target_os = "macos")] + #[error("failed to load CEF library")] + LoadLibrary, + /// Failed to create the sandbox context. + #[cfg(target_os = "macos")] + #[error("failed to create the sandbox context")] + SandboxContextCreate, + /// Failed to initialize the CEF browser process. + #[error("failed to initialize the CEF browser process")] + InitializeBrowserProcess, +} diff --git a/crates/wef/src/ffi.rs b/crates/wef/src/ffi.rs new file mode 100644 index 0000000..033337e --- /dev/null +++ b/crates/wef/src/ffi.rs @@ -0,0 +1,337 @@ +#![allow(non_camel_case_types)] + +use std::ffi::{CStr, c_char, c_void}; + +use crate::{Point, Rect, Size}; + +pub(crate) type wef_browser_t = c_void; +pub(crate) type wef_frame_t = c_void; +pub(crate) type wef_cursor_info_t = c_void; +pub(crate) type wef_file_dialog_callback_t = c_void; +pub(crate) type wef_js_dialog_callback_t = c_void; +pub(crate) type wef_query_callback_t = c_void; + +type DestroyFn = extern "C" fn(*mut c_void); + +#[repr(C)] +pub(crate) struct CAppCallbacks { + pub(crate) on_schedule_message_pump_work: extern "C" fn(*mut c_void, i32), +} + +#[repr(C)] +pub(crate) struct CSettings { + pub(crate) locale: *const c_char, + pub(crate) cache_path: *const c_char, + pub(crate) root_cache_path: *const c_char, + pub(crate) browser_subprocess_path: *const c_char, + pub(crate) callbacks: CAppCallbacks, + pub(crate) userdata: *mut c_void, + pub(crate) destroy_userdata: DestroyFn, +} + +#[repr(C)] +pub(crate) struct CBrowserSettings { + pub(crate) parent: *const c_void, + pub(crate) device_scale_factor: f32, + pub(crate) width: i32, + pub(crate) height: i32, + pub(crate) frame_rate: i32, + pub(crate) url: *const c_char, + pub(crate) inject_javascript: *const c_char, + pub(crate) callbacks: CBrowserCallbacks, + pub(crate) userdata: *mut c_void, + pub(crate) destroy_userdata: DestroyFn, +} + +#[repr(C)] +pub(crate) struct CContextMenuParams { + pub(crate) x_crood: i32, + pub(crate) y_crood: i32, + pub(crate) type_flags: i32, + pub(crate) link_url: *const c_char, + pub(crate) unfiltered_link_url: *const c_char, + pub(crate) source_url: *const c_char, + pub(crate) has_image_contents: bool, + pub(crate) title_text: *const c_char, + pub(crate) page_url: *const c_char, + pub(crate) frame_url: *const c_char, + pub(crate) media_type: i32, + pub(crate) media_state_flags: i32, + pub(crate) selection_text: *const c_char, + pub(crate) is_editable: bool, + pub(crate) edit_state_flags: i32, +} + +#[repr(C)] +pub(crate) struct CBrowserCallbacks { + pub(crate) on_created: extern "C" fn(*mut c_void), + pub(crate) on_closed: extern "C" fn(*mut c_void), + pub(crate) on_popup_show: extern "C" fn(*mut c_void, bool), + pub(crate) on_popup_position: extern "C" fn(*mut c_void, *const Rect), + pub(crate) on_paint: extern "C" fn(*mut c_void, i32, *const c_void, *const c_void, u32, u32), + pub(crate) on_address_changed: extern "C" fn(*mut c_void, *mut wef_frame_t, *const c_char), + pub(crate) on_title_changed: extern "C" fn(*mut c_void, *const c_char), + pub(crate) on_favicon_url_changed: extern "C" fn(*mut c_void, *const *const c_char, i32), + pub(crate) on_tooltip: extern "C" fn(*mut c_void, *const c_char), + pub(crate) on_status_message: extern "C" fn(*mut c_void, *const c_char), + pub(crate) on_console_message: + extern "C" fn(*mut c_void, *const c_char, i32, *const c_char, i32), + pub(crate) on_cursor_changed: extern "C" fn( + *mut c_void, + cursor_type: i32, + custom_cursor_info: *const wef_cursor_info_t, + ) -> bool, + pub(crate) on_before_popup: extern "C" fn(*mut c_void, *const c_char), + pub(crate) on_loading_progress_changed: extern "C" fn(*mut c_void, f32), + pub(crate) on_loading_state_changed: extern "C" fn(*mut c_void, bool, bool, bool), + pub(crate) on_load_start: extern "C" fn(*mut c_void, *mut wef_frame_t), + pub(crate) on_load_end: extern "C" fn(*mut c_void, *mut wef_frame_t), + pub(crate) on_load_error: + extern "C" fn(*mut c_void, *mut wef_frame_t, *const c_char, *const c_char), + pub(crate) on_ime_composition_range_changed: extern "C" fn(*mut c_void, *const Rect), + pub(crate) on_file_dialog: extern "C" fn( + *mut c_void, + i32, + *const c_char, + *const c_char, + *const c_char, + *const c_char, + *const c_char, + *mut wef_file_dialog_callback_t, + ) -> bool, + pub(crate) on_context_menu: + extern "C" fn(*mut c_void, *mut wef_frame_t, *const CContextMenuParams), + pub(crate) on_find_result: extern "C" fn(*mut c_void, i32, i32, *const Rect, i32, bool), + pub(crate) on_js_dialog: extern "C" fn( + *mut c_void, + i32, + *const c_char, + *const c_char, + *mut wef_js_dialog_callback_t, + ) -> bool, + pub(crate) on_query: + extern "C" fn(*mut c_void, *mut wef_frame_t, *const c_char, *mut wef_query_callback_t), +} + +#[inline] +pub(crate) fn to_cstr_ptr_opt(s: Option<&CStr>) -> *const c_char { + s.map(|s| s.as_ptr()).unwrap_or(std::ptr::null()) +} + +unsafe extern "C" { + #[cfg(target_os = "macos")] + pub(crate) unsafe fn wef_load_library(helper: bool) -> *mut c_void; + + #[cfg(target_os = "macos")] + pub(crate) unsafe fn wef_unload_library(loader: *mut c_void); + + #[cfg(target_os = "macos")] + pub(crate) unsafe fn wef_sandbox_context_create( + args: *const *const c_char, + count: i32, + ) -> *mut c_void; + + #[cfg(target_os = "macos")] + pub(crate) unsafe fn wef_sandbox_context_destroy(loader: *mut c_void); + + pub(crate) unsafe fn wef_init(settings: *const CSettings) -> bool; + + pub(crate) unsafe fn wef_exec_process(args: *const *const c_char, count: i32) -> bool; + + pub(crate) unsafe fn wef_shutdown(); + + pub(crate) unsafe fn wef_do_message_work(); + + pub(crate) unsafe fn wef_browser_create( + settings: *const CBrowserSettings, + ) -> *mut wef_browser_t; + + pub(crate) unsafe fn wef_browser_close(browser: *mut wef_browser_t); + + pub(crate) unsafe fn wef_browser_destroy(browser: *mut wef_browser_t); + + pub(crate) unsafe fn wef_browser_is_created(browser: *mut wef_browser_t) -> bool; + + pub(crate) unsafe fn wef_browser_set_size(browser: *mut wef_browser_t, width: i32, height: i32); + + pub(crate) unsafe fn wef_browser_load_url(cebrowserf: *mut wef_browser_t, url: *const c_char); + + pub(crate) unsafe fn wef_browser_can_go_forward(browser: *const wef_browser_t) -> bool; + + pub(crate) unsafe fn wef_browser_can_go_back(browser: *const wef_browser_t) -> bool; + + pub(crate) unsafe fn wef_browser_go_forward(browser: *mut wef_browser_t); + + pub(crate) unsafe fn wef_browser_go_back(browser: *mut wef_browser_t); + + pub(crate) unsafe fn wef_browser_reload(browser: *mut wef_browser_t); + + pub(crate) unsafe fn wef_browser_reload_ignore_cache(browser: *mut wef_browser_t); + + pub(crate) unsafe fn wef_browser_send_mouse_click_event( + browser: *mut wef_browser_t, + mouse_button_type: i32, + mouse_up: bool, + click_count: i32, + modifiers: i32, + ); + + pub(crate) unsafe fn wef_browser_send_mouse_move_event( + browser: *mut wef_browser_t, + x: i32, + y: i32, + modifiers: i32, + ); + + pub(crate) unsafe fn wef_browser_send_mouse_wheel_event( + browser: *mut wef_browser_t, + delta_x: i32, + delta_y: i32, + ); + + pub(crate) unsafe fn wef_browser_send_key_event( + browser: *mut wef_browser_t, + is_press: bool, + key_code: i32, + modifiers: i32, + ); + + pub(crate) unsafe fn wef_browser_send_char_event(browser: *mut wef_browser_t, ch: u16); + + pub(crate) unsafe fn wef_browser_ime_set_composition( + browser: *mut wef_browser_t, + text: *const c_char, + cursor_begin: u32, + cursor_end: u32, + ); + + pub(crate) unsafe fn wef_browser_ime_commit(browser: *mut wef_browser_t, text: *const c_char); + + pub(crate) unsafe fn wef_browser_get_main_frame( + browser: *mut wef_browser_t, + ) -> *mut wef_frame_t; + + pub(crate) unsafe fn wef_browser_get_focused_frame( + browser: *mut wef_browser_t, + ) -> *mut wef_frame_t; + + pub(crate) unsafe fn wef_browser_get_frame_by_name( + browser: *mut wef_browser_t, + name: *const c_char, + ) -> *mut wef_frame_t; + + pub(crate) unsafe fn wef_browser_get_frame_by_identifier( + browser: *mut wef_browser_t, + id: *const c_char, + ) -> *mut wef_frame_t; + + pub(crate) unsafe fn wef_browser_is_audio_muted(browser: *mut wef_browser_t) -> bool; + + pub(crate) unsafe fn wef_browser_set_audio_mute(browser: *mut wef_browser_t, mute: bool); + + pub(crate) unsafe fn wef_browser_find( + browser: *mut wef_browser_t, + search_text: *const c_char, + forward: bool, + match_case: bool, + find_next: bool, + ); + + pub(crate) unsafe fn wef_browser_set_focus(browser: *mut wef_browser_t, focus: bool); + + pub(crate) unsafe fn wef_dirty_rects_len(dirty_rects: *const c_void) -> i32; + + pub(crate) unsafe fn wef_dirty_rects_get( + dirty_rects: *const c_void, + i: i32, + rect: *mut Rect, + ); + + pub(crate) unsafe fn wef_frame_destroy(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_is_valid(frame: *mut wef_frame_t) -> bool; + + pub(crate) unsafe fn wef_frame_is_main(frame: *mut wef_frame_t) -> bool; + + pub(crate) unsafe fn wef_frame_name( + frame: *mut wef_frame_t, + userdata: *mut c_void, + callback: extern "C" fn(*mut c_void, *const c_char), + ) -> i32; + + pub(crate) unsafe fn wef_frame_identifier( + frame: *mut wef_frame_t, + userdata: *mut c_void, + callback: extern "C" fn(*mut c_void, *const c_char), + ) -> i32; + + pub(crate) unsafe fn wef_frame_get_url( + frame: *mut wef_frame_t, + userdata: *mut c_void, + callback: extern "C" fn(*mut c_void, *const c_char), + ) -> i32; + + pub(crate) unsafe fn wef_frame_load_url(frame: *mut wef_frame_t, url: *const c_char); + + pub(crate) unsafe fn wef_frame_parent(frame: *mut wef_frame_t) -> *mut c_void; + + pub(crate) unsafe fn wef_frame_undo(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_redo(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_cut(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_copy(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_paste(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_paste_and_match_style(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_delete(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_select_all(frame: *mut wef_frame_t); + + pub(crate) unsafe fn wef_frame_execute_javascript(frame: *mut wef_frame_t, code: *const c_char); + + pub(crate) unsafe fn wef_file_dialog_callback_continue( + callback: *mut wef_file_dialog_callback_t, + file_paths: *const c_char, + ); + + pub(crate) unsafe fn wef_file_dialog_callback_cancel(callback: *mut wef_file_dialog_callback_t); + + pub(crate) unsafe fn wef_file_dialog_callback_destroy( + callback: *mut wef_file_dialog_callback_t, + ); + + pub(crate) unsafe fn wef_cursor_info_hotspot( + info: *const wef_cursor_info_t, + point: *mut Point, + ); + + pub(crate) unsafe fn wef_cursor_info_image_scale_factor(info: *const wef_cursor_info_t) -> f32; + + pub(crate) unsafe fn wef_cursor_info_buffer(info: *const wef_cursor_info_t) -> *const c_void; + + pub(crate) unsafe fn wef_cursor_info_size(info: *const wef_cursor_info_t, size: *mut Size); + + pub(crate) unsafe fn wef_js_dialog_callback_continue( + callback: *mut wef_js_dialog_callback_t, + success: bool, + user_input: *const c_char, + ); + + pub(crate) unsafe fn wef_js_dialog_callback_destroy(callback: *mut wef_js_dialog_callback_t); + + pub(crate) unsafe fn wef_query_callback_success( + callback: *mut wef_query_callback_t, + response: *const c_char, + ); + + pub(crate) unsafe fn wef_query_callback_failure( + callback: *mut wef_query_callback_t, + error: *const c_char, + ); + + pub(crate) unsafe fn wef_query_callback_destroy(callback: *mut wef_query_callback_t); +} diff --git a/crates/wef/src/file_dialog.rs b/crates/wef/src/file_dialog.rs new file mode 100644 index 0000000..33fda8f --- /dev/null +++ b/crates/wef/src/file_dialog.rs @@ -0,0 +1,73 @@ +use std::ffi::CString; + +use mime::Mime; +use num_enum::TryFromPrimitive; + +use crate::ffi::*; + +/// Supported file dialog modes. +#[derive(Debug, Clone, Copy, TryFromPrimitive)] +#[repr(i32)] +pub enum FileDialogMode { + /// Requires that the file exists before allowing the user to pick it. + Open = 0, + /// Like Open, but allows picking multiple files to open. + OpenMultiple = 1, + /// Like Open, but selects a folder to open. + OpenFolder = 2, + /// Allows picking a nonexistent file, and prompts to overwrite if the file + /// already exists. + Save = 3, +} + +#[derive(Debug)] +pub enum AcceptFilter<'a> { + Mime(Mime), + Extension(&'a str), +} + +/// Accept filter for file dialogs. +#[derive(Debug)] +pub struct Accept<'a> { + /// Used to restrict the selectable file types. + /// + /// May be any combination of valid lower-cased MIME types (e.g. "text/*" or + /// "image/*") and individual file extensions (e.g. ".txt" or ".png"). + pub filters: AcceptFilter<'a>, + /// Provides the expansion of MIME types to file extensions. + pub extensions: Option<&'a [&'a str]>, + /// Provides the descriptions for MIME types + /// + /// For example, the "image/*" mime type might have extensions + /// [".png", ".jpg", ".bmp", ...] and description "Image Files". + pub description: Option<&'a str>, +} + +/// File dialog callback. +pub struct FileDialogCallback(*mut wef_file_dialog_callback_t); + +unsafe impl Send for FileDialogCallback {} +unsafe impl Sync for FileDialogCallback {} + +impl Drop for FileDialogCallback { + fn drop(&mut self) { + unsafe { wef_file_dialog_callback_destroy(self.0) }; + } +} + +impl FileDialogCallback { + pub(crate) fn new(callback: *mut wef_file_dialog_callback_t) -> Self { + FileDialogCallback(callback) + } + + /// Continue the file dialog with the selected file paths. + pub fn continue_(&self, file_paths: &[&str]) { + let paths = CString::new(file_paths.join(";")).unwrap(); + unsafe { wef_file_dialog_callback_continue(self.0, paths.as_ptr()) }; + } + + /// Cancel the file dialog. + pub fn cancel(&self) { + unsafe { wef_file_dialog_callback_cancel(self.0) }; + } +} diff --git a/crates/wef/src/frame.rs b/crates/wef/src/frame.rs new file mode 100644 index 0000000..a2f7291 --- /dev/null +++ b/crates/wef/src/frame.rs @@ -0,0 +1,153 @@ +use std::{ + ffi::{CStr, c_char, c_void}, + fmt, +}; + +use serde::Serialize; + +use crate::ffi::*; + +/// A frame in the browser window. +pub struct Frame(pub(crate) *mut wef_frame_t); + +unsafe impl Send for Frame {} +unsafe impl Sync for Frame {} + +impl fmt::Debug for Frame { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Frame") + .field("name", &self.name()) + .field("identifier", &self.identifier()) + .field("url", &self.url()) + .finish() + } +} + +impl Drop for Frame { + fn drop(&mut self) { + unsafe { wef_frame_destroy(self.0) }; + } +} + +impl Frame { + /// Returns `true` if this object is currently attached to a valid frame. + pub fn is_valid(&self) -> bool { + unsafe { wef_frame_is_valid(self.0) } + } + + /// Returns `true` if this is the main (top-level) frame. + pub fn is_main(&self) -> bool { + unsafe { wef_frame_is_main(self.0) } + } + + /// Returns the name for this frame. + /// + /// If the frame has an assigned name (for example, set via the iframe + /// "name" attribute) then that value will be returned. Otherwise a + /// unique name will be constructed based on the frame parent hierarchy. + /// + /// Returns `None` if the frame is main (top-level). + pub fn name(&self) -> Option { + let mut name = String::new(); + unsafe { wef_frame_name(self.0, &mut name as *mut _ as _, get_string_callback) }; + (!name.is_empty()).then_some(name) + } + + /// Returns the globally unique identifier for this frame or `None` if the + /// underlying frame does not yet exist. + pub fn identifier(&self) -> Option { + let mut id = String::new(); + unsafe { wef_frame_identifier(self.0, &mut id as *mut _ as _, get_string_callback) }; + (!id.is_empty()).then_some(id) + } + + /// Returns the URL currently loaded in this frame. + pub fn url(&self) -> String { + let mut url = String::new(); + unsafe { wef_frame_get_url(self.0, &mut url as *mut _ as _, get_string_callback) }; + url + } + + /// Loads the specified URL in this frame. + pub fn load_url(&self, url: &str) { + let c_url = std::ffi::CString::new(url).unwrap(); + unsafe { wef_frame_load_url(self.0, c_url.as_ptr()) }; + } + + /// Returns the parent of this frame or `None` if this is the main + /// (top-level) frame. + pub fn parent(&self) -> Option { + let frame = unsafe { wef_frame_parent(self.0) }; + if !frame.is_null() { + Some(Frame(frame)) + } else { + None + } + } + + /// Execute undo in this frame. + pub fn undo(&self) { + unsafe { wef_frame_undo(self.0) }; + } + + /// Execute redo in this frame. + pub fn redo(&self) { + unsafe { wef_frame_redo(self.0) }; + } + + /// Execute cut in this frame. + pub fn cut(&self) { + unsafe { wef_frame_cut(self.0) }; + } + + /// Execute copy in this frame. + pub fn copy(&self) { + unsafe { wef_frame_copy(self.0) }; + } + + /// Execute paste in this frame. + pub fn paste(&self) { + unsafe { wef_frame_paste(self.0) }; + } + + /// Execute paste and match style in this frame. + pub fn paste_and_match_style(&self) { + unsafe { wef_frame_paste_and_match_style(self.0) }; + } + + /// Execute delete in this frame. + pub fn delete(&self) { + unsafe { wef_frame_delete(self.0) }; + } + + /// Execute select all in this frame. + pub fn select_all(&self) { + unsafe { wef_frame_select_all(self.0) }; + } + + /// Execute javascript in this frame. + pub fn execute_javascript(&self, script: &str) { + if script.is_empty() { + return; + } + let c_script = std::ffi::CString::new(script).unwrap(); + unsafe { wef_frame_execute_javascript(self.0, c_script.as_ptr()) }; + } + + /// Emits a message to the JavaScript side. + pub fn emit(&self, message: impl Serialize) { + let Ok(message) = serde_json::to_string(&message) else { + return; + }; + self.execute_javascript(&format!("window.jsBridge.__internal.emit({})", message)); + } +} + +extern "C" fn get_string_callback(output: *mut c_void, value: *const c_char) { + unsafe { + *(output as *mut String) = CStr::from_ptr(value) + .to_str() + .unwrap_or_default() + .to_string(); + } +} diff --git a/crates/wef/src/framework_loader.rs b/crates/wef/src/framework_loader.rs new file mode 100644 index 0000000..1f98f94 --- /dev/null +++ b/crates/wef/src/framework_loader.rs @@ -0,0 +1,38 @@ +use std::os::raw::c_void; + +use crate::{Error, ffi::*}; + +/// Scoped helper for loading and unloading the CEF framework library at +/// runtime from the expected location in the app bundle. +#[derive(Debug)] +pub struct FrameworkLoader(*mut c_void); + +impl Drop for FrameworkLoader { + fn drop(&mut self) { + unsafe { wef_unload_library(self.0) }; + } +} + +impl FrameworkLoader { + fn load(helper: bool) -> Result { + unsafe { + let loader = wef_load_library(helper); + if loader.is_null() { + return Err(Error::LoadLibrary); + } + Ok(Self(loader)) + } + } + + /// Load the CEF framework in the main process from the expected app + /// bundle location relative to the executable. + pub fn load_in_main() -> Result { + Self::load(false) + } + + /// Load the CEF framework in the helper process from the expected app + /// bundle location relative to the executable. + pub fn load_in_helper() -> Result { + Self::load(true) + } +} diff --git a/crates/wef/src/func_registry/async_function_type.rs b/crates/wef/src/func_registry/async_function_type.rs new file mode 100644 index 0000000..4f234cb --- /dev/null +++ b/crates/wef/src/func_registry/async_function_type.rs @@ -0,0 +1,120 @@ +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::{ + Frame, + func_registry::{CallFunctionError, into_result::IntoFunctionResult}, +}; + +/// Represents a async function type that can be called from JavaScript. +pub trait AsyncFunctionType { + /// Number of arguments. + const NUM_ARGUMENTS: usize; + + /// Calls the function with the given arguments. + fn call( + &self, + frame: Frame, + args: Vec, + ) -> impl Future> + Send + 'static; +} + +macro_rules! impl_async_function_types { + ($($name:ident),*) => { + impl AsyncFunctionType<($($name,)*), R> for F + where + F: Fn($($name),*) -> Fut + Clone + Send + Sync + 'static, + Fut: Future + Send + 'static, + Ret: IntoFunctionResult + Send, + $($name: DeserializeOwned + Send,)* + { + const NUM_ARGUMENTS: usize = tuple_len::tuple_len!(($($name,)*)); + + fn call(&self, _frame: Frame, args: Vec) -> impl Future> + Send + 'static { + let f = self.clone(); + async move { + let expected_args = tuple_len::tuple_len!(($($name,)*)); + + if args.len() != expected_args { + return Err(CallFunctionError::InvalidNumberOfArguments { + expected: expected_args, + actual: args.len(), + }); + } + + let mut args = args; + args.reverse(); + + $( + #[allow(non_snake_case)] + let $name: $name = serde_json::from_value(args.pop().unwrap()).map_err(|e| { + CallFunctionError::InvalidArgument { + arg_name: "A1".to_string(), + error: e.to_string(), + } + })?; + )* + + + match (f($($name),*).await).into_function_result() { + Ok(value) => Ok(serde_json::to_value(value).unwrap()), + Err(e) => Err(CallFunctionError::Other(e.to_string())), + } + } + } + } + + impl AsyncFunctionType<(Frame, $($name,)*), R> for F + where + F: Fn(Frame, $($name),*) -> Fut + Clone + Send + Sync + 'static, + Fut: Future + Send + 'static, + Ret: IntoFunctionResult + Send, + $($name: DeserializeOwned + Send,)* + { + const NUM_ARGUMENTS: usize = tuple_len::tuple_len!(($($name,)*)); + + fn call(&self, frame: Frame, args: Vec) -> impl Future> + Send + 'static { + let f = self.clone(); + async move { + let expected_args = tuple_len::tuple_len!(($($name,)*)); + + if args.len() != expected_args { + return Err(CallFunctionError::InvalidNumberOfArguments { + expected: expected_args, + actual: args.len(), + }); + } + + let mut args = args; + args.reverse(); + + $( + #[allow(non_snake_case)] + let $name: $name = serde_json::from_value(args.pop().unwrap()).map_err(|e| { + CallFunctionError::InvalidArgument { + arg_name: "A1".to_string(), + error: e.to_string(), + } + })?; + )* + + + match (f(frame, $($name),*).await).into_function_result() { + Ok(value) => Ok(serde_json::to_value(value).unwrap()), + Err(e) => Err(CallFunctionError::Other(e.to_string())), + } + } + } + } + }; +} + +impl_async_function_types!(); +impl_async_function_types!(A1); +impl_async_function_types!(A1, A2); +impl_async_function_types!(A1, A2, A3); +impl_async_function_types!(A1, A2, A3, A4); +impl_async_function_types!(A1, A2, A3, A4, A5); +impl_async_function_types!(A1, A2, A3, A4, A5, A6); +impl_async_function_types!(A1, A2, A3, A4, A5, A6, A7); +impl_async_function_types!(A1, A2, A3, A4, A5, A6, A7, A8); diff --git a/crates/wef/src/func_registry/builder.rs b/crates/wef/src/func_registry/builder.rs new file mode 100644 index 0000000..755b4c0 --- /dev/null +++ b/crates/wef/src/func_registry/builder.rs @@ -0,0 +1,91 @@ +use std::{collections::HashMap, sync::Arc}; + +use futures_util::future::BoxFuture; + +use crate::{ + AsyncFunctionType, FuncRegistry, FunctionType, + func_registry::dyn_wrapper::{DynAsyncFunctionWrapper, DynFunctionType, DynFunctionWrapper}, +}; + +/// A builder for creating a function registry. +#[derive(Default)] +pub struct FuncRegistryBuilder { + functions: HashMap>, +} + +impl FuncRegistryBuilder { + /// Registers a function with the given name. + pub fn register(mut self, name: &str, func: F) -> Self + where + F: FunctionType + Send + Sync + 'static, + S: Send + Sync + 'static, + R: Send + Sync + 'static, + { + self.functions + .insert(name.to_string(), Box::new(DynFunctionWrapper::new(func))); + self + } + + /// Consumes the builder and returns a new [`AsyncFuncRegistryBuilder`]. + pub fn with_spawner(self, spawner: S) -> AsyncFuncRegistryBuilder + where + S: Fn(BoxFuture<'static, ()>) -> R + Send + Sync + 'static, + { + AsyncFuncRegistryBuilder { + functions: self.functions, + spawner: Arc::new(move |fut| { + spawner(fut); + }), + } + } + + /// Builds the [`FuncRegistry`]. + pub fn build(self) -> FuncRegistry { + FuncRegistry { + functions: Arc::new(self.functions), + spawner: None, + } + } +} + +/// A builder for creating an function registry with async functions. +pub struct AsyncFuncRegistryBuilder { + functions: HashMap>, + spawner: Arc) + Send + Sync>, +} + +impl AsyncFuncRegistryBuilder { + /// Registers a function with the given name. + pub fn register(mut self, name: &str, func: F) -> Self + where + F: FunctionType + Send + Sync + 'static, + S: Send + Sync + 'static, + R: Send + Sync + 'static, + { + self.functions + .insert(name.to_string(), Box::new(DynFunctionWrapper::new(func))); + self + } + + /// Registers a async function with the given name. + pub fn register_async(mut self, name: &str, func: F) -> Self + where + F: AsyncFunctionType + Send + Sync + 'static, + S: Send + Sync + 'static, + R: Send + Sync + 'static, + { + self.functions.insert( + name.to_string(), + Box::new(DynAsyncFunctionWrapper::new(func)), + ); + self + } + + /// Builds the [`FuncRegistry`]. + pub fn build(self) -> FuncRegistry { + FuncRegistry { + functions: Arc::new(self.functions), + spawner: Some(self.spawner), + } + } +} diff --git a/crates/wef/src/func_registry/dyn_wrapper.rs b/crates/wef/src/func_registry/dyn_wrapper.rs new file mode 100644 index 0000000..36d1c12 --- /dev/null +++ b/crates/wef/src/func_registry/dyn_wrapper.rs @@ -0,0 +1,96 @@ +use std::marker::PhantomData; + +use futures_util::future::BoxFuture; +use serde_json::Value; + +use crate::{AsyncFunctionType, Frame, FunctionType, query::QueryCallback}; + +pub(crate) trait DynFunctionType: Send + Sync { + fn num_arguments(&self) -> usize; + + fn call( + &self, + spawner: Option<&(dyn Fn(BoxFuture<'static, ()>) + Send + Sync)>, + frame: Frame, + args: Vec, + callback: QueryCallback, + ); +} + +pub(crate) struct DynFunctionWrapper { + func: F, + _mark: PhantomData<(S, R)>, +} + +impl DynFunctionWrapper { + #[inline] + pub(crate) fn new(func: F) -> Self { + Self { + func, + _mark: PhantomData, + } + } +} + +impl DynFunctionType for DynFunctionWrapper +where + F: FunctionType + Send + Sync, + S: Send + Sync, + R: Send + Sync, +{ + #[inline] + fn num_arguments(&self) -> usize { + F::NUM_ARGUMENTS + } + + #[inline] + fn call( + &self, + _spawner: Option<&(dyn Fn(BoxFuture<'static, ()>) + Send + Sync)>, + frame: Frame, + args: Vec, + callback: QueryCallback, + ) { + callback.result(self.func.call(frame, args)) + } +} + +pub(crate) struct DynAsyncFunctionWrapper { + func: F, + _mark: PhantomData<(S, R)>, +} + +impl DynAsyncFunctionWrapper { + #[inline] + pub(crate) fn new(func: F) -> Self { + Self { + func, + _mark: PhantomData, + } + } +} + +impl DynFunctionType for DynAsyncFunctionWrapper +where + F: AsyncFunctionType + Send + Sync, + S: Send + Sync, + R: Send + Sync, +{ + #[inline] + fn num_arguments(&self) -> usize { + F::NUM_ARGUMENTS + } + + #[inline] + fn call( + &self, + spawner: Option<&(dyn Fn(BoxFuture<'static, ()>) + Send + Sync)>, + frame: Frame, + args: Vec, + callback: QueryCallback, + ) { + let spawner = spawner.expect("BUG: spawner is None"); + let fut = self.func.call(frame, args); + spawner(Box::pin(async move { callback.result(fut.await) })); + } +} diff --git a/crates/wef/src/func_registry/error.rs b/crates/wef/src/func_registry/error.rs new file mode 100644 index 0000000..b63f35d --- /dev/null +++ b/crates/wef/src/func_registry/error.rs @@ -0,0 +1,26 @@ +/// Error type for function calls. +#[derive(Debug, thiserror::Error)] +pub enum CallFunctionError { + /// Invalid number of arguments. + #[error("Invalid number of arguments, expected {expected}, got {actual}")] + InvalidNumberOfArguments { + /// Expected number of arguments. + expected: usize, + /// Actual number of arguments. + actual: usize, + }, + /// Invalid argument type. + #[error("Invalid argument {arg_name}: {error}")] + InvalidArgument { + /// Argument name. + arg_name: String, + /// Error message. + error: String, + }, + /// Function not found. + #[error("Function not found: {0}")] + NotFound(String), + /// Other errors. + #[error("{0}")] + Other(String), +} diff --git a/crates/wef/src/func_registry/function_type.rs b/crates/wef/src/func_registry/function_type.rs new file mode 100644 index 0000000..ab9b1b4 --- /dev/null +++ b/crates/wef/src/func_registry/function_type.rs @@ -0,0 +1,110 @@ +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::{ + Frame, + func_registry::{CallFunctionError, into_result::IntoFunctionResult}, +}; + +/// Represents a function type that can be called from JavaScript. +pub trait FunctionType { + /// Number of arguments. + const NUM_ARGUMENTS: usize; + + /// Calls the function with the given arguments. + fn call(&self, frame: Frame, args: Vec) -> Result; +} + +macro_rules! impl_function_types { + ($($name:ident),*) => { + impl FunctionType<($($name,)*), R> for F + where + F: Fn($($name),*) -> Ret, + Ret: IntoFunctionResult, + $($name: DeserializeOwned,)* + { + const NUM_ARGUMENTS: usize = tuple_len::tuple_len!(($($name,)*)); + + fn call(&self, _frame: Frame, args: Vec) -> Result { + let expected_args = tuple_len::tuple_len!(($($name,)*)); + + if args.len() != expected_args { + return Err(CallFunctionError::InvalidNumberOfArguments { + expected: expected_args, + actual: args.len(), + }); + } + + let mut args = args; + args.reverse(); + + $( + #[allow(non_snake_case)] + let $name: $name = serde_json::from_value(args.pop().unwrap()).map_err(|e| { + CallFunctionError::InvalidArgument { + arg_name: "A1".to_string(), + error: e.to_string(), + } + })?; + )* + + + let result = IntoFunctionResult::into_function_result(self($($name),*)); + match result { + Ok(value) => Ok(serde_json::to_value(value).unwrap()), + Err(e) => Err(CallFunctionError::Other(e.to_string())), + } + } + } + + impl FunctionType<(Frame, $($name,)*), R> for F + where + F: Fn(Frame, $($name),*) -> Ret, + Ret: IntoFunctionResult, + $($name: DeserializeOwned,)* + { + const NUM_ARGUMENTS: usize = tuple_len::tuple_len!(($($name,)*)); + + fn call(&self, frame: Frame, args: Vec) -> Result { + let expected_args = tuple_len::tuple_len!(($($name,)*)); + + if args.len() != expected_args { + return Err(CallFunctionError::InvalidNumberOfArguments { + expected: expected_args, + actual: args.len(), + }); + } + + let mut args = args; + args.reverse(); + + $( + #[allow(non_snake_case)] + let $name: $name = serde_json::from_value(args.pop().unwrap()).map_err(|e| { + CallFunctionError::InvalidArgument { + arg_name: "A1".to_string(), + error: e.to_string(), + } + })?; + )* + + + let result = IntoFunctionResult::into_function_result(self(frame, $($name),*)); + match result { + Ok(value) => Ok(serde_json::to_value(value).unwrap()), + Err(e) => Err(CallFunctionError::Other(e.to_string())), + } + } + } + }; +} + +impl_function_types!(); +impl_function_types!(A1); +impl_function_types!(A1, A2); +impl_function_types!(A1, A2, A3); +impl_function_types!(A1, A2, A3, A4); +impl_function_types!(A1, A2, A3, A4, A5); +impl_function_types!(A1, A2, A3, A4, A5, A6); +impl_function_types!(A1, A2, A3, A4, A5, A6, A7); +impl_function_types!(A1, A2, A3, A4, A5, A6, A7, A8); diff --git a/crates/wef/src/func_registry/inject.js b/crates/wef/src/func_registry/inject.js new file mode 100644 index 0000000..bd201f5 --- /dev/null +++ b/crates/wef/src/func_registry/inject.js @@ -0,0 +1,35 @@ +window.jsBridge = { + __internal: { + call(method, args) { + var request = { + method: method, + args, + }; + return new Promise((resolve, reject) => { + window.cefQuery({ + request: JSON.stringify(request), + persistent: false, + onSuccess: function (response) { + resolve(JSON.parse(response)); + }, + onFailure: (error_code, error_message) => reject(error_message), + }); + }); + }, + nextEventListenerId: 0, + eventListeners: {}, + emit(message) { + for (const id in this.eventListeners) { + this.eventListeners[id](message); + } + }, + }, + addEventListener(callback) { + const id = this.__internal.nextEventListenerId++; + this.__internal.eventListeners[id] = callback; + return id; + }, + removeEventListener(id) { + delete this.__internal.eventListeners[id]; + }, +}; diff --git a/crates/wef/src/func_registry/into_result.rs b/crates/wef/src/func_registry/into_result.rs new file mode 100644 index 0000000..905c432 --- /dev/null +++ b/crates/wef/src/func_registry/into_result.rs @@ -0,0 +1,30 @@ +use std::error::Error; + +use serde::Serialize; +use serde_json::Value; + +use crate::func_registry::CallFunctionError; + +pub(crate) trait IntoFunctionResult { + fn into_function_result(self) -> Result; +} + +impl IntoFunctionResult for Result +where + T: Serialize, + E: Error, +{ + fn into_function_result(self) -> Result { + self.map(|value| serde_json::to_value(value).unwrap()) + .map_err(|err| CallFunctionError::Other(err.to_string())) + } +} + +impl IntoFunctionResult for T +where + T: Serialize, +{ + fn into_function_result(self) -> Result { + Ok(serde_json::to_value(self).unwrap()) + } +} diff --git a/crates/wef/src/func_registry/mod.rs b/crates/wef/src/func_registry/mod.rs new file mode 100644 index 0000000..b226c03 --- /dev/null +++ b/crates/wef/src/func_registry/mod.rs @@ -0,0 +1,13 @@ +mod async_function_type; +mod builder; +mod dyn_wrapper; +mod error; +mod function_type; +mod into_result; +mod registry; + +pub use async_function_type::AsyncFunctionType; +pub use builder::{AsyncFuncRegistryBuilder, FuncRegistryBuilder}; +pub use error::CallFunctionError; +pub use function_type::FunctionType; +pub use registry::FuncRegistry; diff --git a/crates/wef/src/func_registry/registry.rs b/crates/wef/src/func_registry/registry.rs new file mode 100644 index 0000000..791da1c --- /dev/null +++ b/crates/wef/src/func_registry/registry.rs @@ -0,0 +1,105 @@ +use std::{collections::HashMap, sync::Arc}; + +use futures_util::future::BoxFuture; +use serde_json::Value; + +use crate::{ + Frame, FuncRegistryBuilder, + func_registry::{CallFunctionError, dyn_wrapper::DynFunctionType}, + query::QueryCallback, +}; + +/// A registry for functions that can be called from JavaScript. +/// +/// To create a new `FuncRegistry`, use the [`FuncRegistry::builder`] method to +/// create a `FuncRegistryBuilder`, register your functions, and then call +/// [`FuncRegistryBuilder::build`] to create the `FuncRegistry`. +/// +/// ```rust, no_run +/// use wef::{Browser, FuncRegistry}; +/// +/// let registry = FuncRegistry::builder() +/// .register("add", |a: i32, b: i32| a + b) +/// .register("sub", |a: i32, b: i32| a - b) +/// .build(); +/// +/// let browser = Browser::builder() +/// .func_registry(registry) // Register the functions with the browser +/// .build(); +/// ``` +/// +/// The functions can be synchronous or asynchronous, and they can accept any +/// number of arguments. The arguments must implement the `serde::Serialize` and +/// `serde::Deserialize` traits. +/// +/// Call the functions from JavaScript: +/// +/// ```javascript +/// jsBridge.add(1, 2); // Returns 3 +/// jsBridge.sub(5, 3); // Returns 2 +/// ``` +/// +/// # Asynchronous Functions +/// +/// You can also register asynchronous functions. Call +/// `FuncRegistryBuilder::with_spawner` to create an `AsyncFuncRegistryBuilder` +/// that allows you to register async functions. +/// +/// ```rust, ignore +/// use wef::{Browser, FuncRegistry}; +/// +/// let registry = FuncRegistry::builder() +/// .with_spawner(tokio::spawn) +/// .register_async("sleep", |millis: u64| async move { +/// tokio::time::sleep(std::time::Duration::from_millis(millis)).await; +/// "done" +/// }) +/// .build(); +/// +/// let browser = Browser::builder() +/// .func_registry(registry) // Register the functions with the browser +/// .build(); +/// ``` +/// +/// _You can clone the `FuncRegistry` and use it in multiple browsers._ +#[derive(Default, Clone)] +pub struct FuncRegistry { + pub(crate) functions: Arc>>, + pub(crate) spawner: Option) + Send + Sync>>, +} + +impl FuncRegistry { + /// Creates a new `FuncRegistryBuilder`. + #[inline] + pub fn builder() -> FuncRegistryBuilder { + FuncRegistryBuilder::default() + } + + pub(crate) fn call(&self, frame: Frame, name: &str, args: Vec, callback: QueryCallback) { + let Some(func) = self.functions.get(name) else { + callback.result(Err(CallFunctionError::NotFound(name.to_string()))); + return; + }; + func.call(self.spawner.as_deref(), frame, args, callback) + } + + pub(crate) fn javascript(&self) -> String { + let mut code = include_str!("inject.js").to_string(); + + for (name, func) in &*self.functions { + let args = (0..func.num_arguments()) + .map(|i| format!("arg{}", i)) + .collect::>() + .join(","); + code += &format!( + r#"window.jsBridge.{name} = function({args}) {{ + return window.jsBridge.__internal.call("{name}", [{args}]); + }};"#, + name = name, + args = args + ); + } + + code + } +} diff --git a/crates/wef/src/geom.rs b/crates/wef/src/geom.rs new file mode 100644 index 0000000..189cef6 --- /dev/null +++ b/crates/wef/src/geom.rs @@ -0,0 +1,117 @@ +/// A rectangle structure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(C)] +pub struct Rect { + /// X coordinate of the rectangle. + pub x: T, + /// Y coordinate of the rectangle. + pub y: T, + /// Width of the rectangle. + pub width: T, + /// Height of the rectangle. + pub height: T, +} + +impl Rect { + /// Creates a new rectangle with the specified coordinates and size. + #[inline] + pub fn new(x: T, y: T, width: T, height: T) -> Self { + Rect { + x, + y, + width, + height, + } + } + + /// Returns the origin point of the rectangle. + #[inline] + pub fn origin(&self) -> Point + where + T: Copy, + { + Point::new(self.x, self.y) + } + + /// Returns the size of the rectangle. + pub fn size(&self) -> Size + where + T: Copy, + { + Size::new(self.width, self.height) + } + + /// Maps the rectangle to a new type using the provided function. + pub fn map(&self, f: F) -> Rect + where + T: Copy, + F: Fn(T) -> U, + { + Rect { + x: f(self.x), + y: f(self.y), + width: f(self.width), + height: f(self.height), + } + } +} + +/// A point structure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(C)] +pub struct Point { + /// X coordinate of the point. + pub x: T, + /// Y coordinate of the point. + pub y: T, +} + +impl Point { + /// Creates a new point with the specified coordinates. + #[inline] + pub fn new(x: T, y: T) -> Self { + Point { x, y } + } + + /// Maps the point to a new type using the provided function. + pub fn map(&self, f: F) -> Point + where + T: Copy, + F: Fn(T) -> U, + { + Point { + x: f(self.x), + y: f(self.y), + } + } +} + +/// A size structure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(C)] +pub struct Size { + /// Width of the size. + pub width: T, + /// Height of the size. + pub height: T, +} + +impl Size { + /// Creates a new size with the specified width and height. + #[inline] + pub fn new(width: T, height: T) -> Self { + Size { width, height } + } + + /// Maps the size to a new type using the provided function. + pub fn map(&self, f: F) -> Size + where + T: Copy, + F: Fn(T) -> U, + { + Size { + width: f(self.width), + height: f(self.height), + } + } +} diff --git a/crates/wef/src/input.rs b/crates/wef/src/input.rs new file mode 100644 index 0000000..499ab2c --- /dev/null +++ b/crates/wef/src/input.rs @@ -0,0 +1,66 @@ +/// Mouse button +#[derive(Debug, Clone, Copy)] +#[repr(i32)] +pub enum MouseButton { + /// Left + Left = 0, + /// Middle + Middle = 1, + /// Right + Right = 2, +} + +/// Key codes +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum KeyCode { + /// Backspace + Backspace = 0x08, + /// Delete + Delete = 0x2E, + /// Tab + Tab = 0x09, + /// Enter + Enter = 0x0D, + /// PageUp + PageUp = 0x21, + /// PageDown + PageDown = 0x22, + /// End + End = 0x23, + /// Home + Home = 0x24, + /// Arrow left + ArrowLeft = 0x25, + /// Arrow up + ArrowUp = 0x26, + /// Arrow right + ArrowRight = 0x27, + /// Arrow down + ArrowDown = 0x28, +} + +impl KeyCode { + pub(crate) fn as_char(&self) -> Option { + use KeyCode::*; + + match self { + Enter => Some(0x0D), + _ => None, + } + } +} + +bitflags::bitflags! { + /// Key modifiers for keyboard events. + #[repr(transparent)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct KeyModifier: i32 { + /// Shift key + const SHIFT = 0x1; + /// Control key + const CONTROL = 0x2; + /// Alt key + const ALT = 0x4; + } +} diff --git a/crates/wef/src/js_dialog.rs b/crates/wef/src/js_dialog.rs new file mode 100644 index 0000000..c12e8a1 --- /dev/null +++ b/crates/wef/src/js_dialog.rs @@ -0,0 +1,43 @@ +use std::ffi::CString; + +use crate::ffi::*; + +/// Supported JS dialog types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(missing_docs)] +pub enum JsDialogType<'a> { + Alert, + Confirm, + Prompt { default_prompt_text: &'a str }, +} + +/// Js dialog callback. +pub struct JsDialogCallback(*mut wef_js_dialog_callback_t); + +unsafe impl Send for JsDialogCallback {} +unsafe impl Sync for JsDialogCallback {} + +impl Drop for JsDialogCallback { + fn drop(&mut self) { + unsafe { wef_js_dialog_callback_destroy(self.0) } + } +} + +impl JsDialogCallback { + pub(crate) fn new(callback: *mut wef_js_dialog_callback_t) -> Self { + JsDialogCallback(callback) + } + + /// Continue the JS dialog request. + /// + /// Set `success` to true if the OK button was pressed. + /// The `user_input` value should be specified for prompt dialogs. + pub fn continue_(&self, success: bool, user_input: Option<&str>) { + unsafe { + let user_input_cstr = user_input + .and_then(|user_input| CString::new(user_input).ok()) + .unwrap_or_default(); + wef_js_dialog_callback_continue(self.0, success, user_input_cstr.as_ptr()); + } + } +} diff --git a/crates/wef/src/lib.rs b/crates/wef/src/lib.rs new file mode 100644 index 0000000..0cfdff9 --- /dev/null +++ b/crates/wef/src/lib.rs @@ -0,0 +1,54 @@ +#![doc = include_str!("../README.md")] + +mod app_handler; +mod browser; +mod browser_handler; +mod builder; +mod context_menu; +mod cursor; +mod dirty_rects; +mod dpi; +mod error; +mod ffi; +mod file_dialog; +mod frame; +#[cfg(target_os = "macos")] +mod framework_loader; +mod func_registry; +mod geom; +mod input; +mod js_dialog; +mod query; +#[cfg(target_os = "macos")] +mod sandbox_context; +mod settings; +mod wef; + +pub use app_handler::ApplicationHandler; +pub use browser::Browser; +pub use browser_handler::{BrowserHandler, ImageBuffer, LogSeverity, PaintElementType}; +pub use builder::BrowserBuilder; +pub use context_menu::{ + ContextMenuEditStateFlags, ContextMenuMediaStateFlags, ContextMenuMediaType, ContextMenuParams, + ContextMenuTypeFlags, +}; +pub use cursor::{CursorInfo, CursorType}; +pub use dirty_rects::{DirtyRects, DirtyRectsIter}; +pub use dpi::{LogicalUnit, PhysicalUnit}; +pub use error::Error; +pub use file_dialog::{Accept, FileDialogCallback, FileDialogMode}; +pub use frame::Frame; +#[cfg(target_os = "macos")] +pub use framework_loader::FrameworkLoader; +pub use func_registry::{ + AsyncFuncRegistryBuilder, AsyncFunctionType, CallFunctionError, FuncRegistry, + FuncRegistryBuilder, FunctionType, +}; +pub use geom::{Point, Rect, Size}; +pub use input::{KeyCode, KeyModifier, MouseButton}; +pub use js_dialog::{JsDialogCallback, JsDialogType}; +#[cfg(target_os = "macos")] +pub use sandbox_context::SandboxContext; +pub use serde_json::Value; +pub use settings::Settings; +pub use wef::*; diff --git a/crates/wef/src/query.rs b/crates/wef/src/query.rs new file mode 100644 index 0000000..5ee90ba --- /dev/null +++ b/crates/wef/src/query.rs @@ -0,0 +1,39 @@ +use std::ffi::CString; + +use serde_json::Value; + +use crate::{ffi::*, func_registry::CallFunctionError}; + +pub(crate) struct QueryCallback(*mut wef_query_callback_t); + +unsafe impl Send for QueryCallback {} +unsafe impl Sync for QueryCallback {} + +impl Drop for QueryCallback { + fn drop(&mut self) { + unsafe { wef_query_callback_destroy(self.0) }; + } +} + +impl QueryCallback { + pub(crate) fn new(callback: *mut wef_query_callback_t) -> Self { + Self(callback) + } + + pub(crate) fn result(self, res: Result) { + match res { + Ok(resp) => self.success(resp), + Err(err) => self.failure(err), + } + } + + fn success(self, resp: Value) { + let resp = CString::new(serde_json::to_string(&resp).unwrap_or_default()).unwrap(); + unsafe { wef_query_callback_success(self.0, resp.as_ptr()) } + } + + fn failure(self, err: CallFunctionError) { + let err = CString::new(err.to_string()).unwrap(); + unsafe { wef_query_callback_failure(self.0, err.as_ptr()) } + } +} diff --git a/crates/wef/src/sandbox_context.rs b/crates/wef/src/sandbox_context.rs new file mode 100644 index 0000000..419fe21 --- /dev/null +++ b/crates/wef/src/sandbox_context.rs @@ -0,0 +1,37 @@ +use std::{ + ffi::{CString, c_char}, + os::raw::c_void, +}; + +use crate::{Error, ffi::*}; + +/// The sandbox is used to restrict sub-processes (renderer, GPU, etc) from +/// directly accessing system resources. +/// +/// This helps to protect the user from untrusted and potentially malicious Web +/// content. +#[derive(Debug)] +pub struct SandboxContext(*mut c_void); + +impl SandboxContext { + pub fn new() -> Result { + unsafe { + let args: Vec = std::env::args() + .filter_map(|arg| CString::new(arg).ok()) + .collect(); + let c_args: Vec<*const c_char> = args.iter().map(|arg| arg.as_ptr()).collect(); + + let context = wef_sandbox_context_create(c_args.as_ptr(), args.len() as i32); + if context.is_null() { + return Err(Error::SandboxContextCreate); + } + Ok(SandboxContext(context)) + } + } +} + +impl Drop for SandboxContext { + fn drop(&mut self) { + unsafe { wef_sandbox_context_destroy(self.0) }; + } +} diff --git a/crates/wef/src/settings.rs b/crates/wef/src/settings.rs new file mode 100644 index 0000000..afc5bd3 --- /dev/null +++ b/crates/wef/src/settings.rs @@ -0,0 +1,117 @@ +use std::ffi::CString; + +use crate::ApplicationHandler; + +/// Application settings. +#[derive(Debug)] +pub struct Settings { + pub(crate) locale: Option, + pub(crate) cache_path: Option, + pub(crate) root_cache_path: Option, + pub(crate) browser_subprocess_path: Option, + pub(crate) external_message_pump: bool, + pub(crate) handler: T, +} + +impl Settings<()> { + /// Creates a new [`Settings`] instance with default values. + #[inline] + pub fn new() -> Self { + Self { + locale: None, + cache_path: None, + root_cache_path: None, + browser_subprocess_path: None, + external_message_pump: false, + handler: (), + } + } +} + +impl Settings { + /// The locale string that will be passed to CEF. + /// + /// If `None` the default locale of "en-US" will be used. + pub fn locale(mut self, locale: impl Into>) -> Self { + self.locale = Some(CString::new(locale).expect("invalid locale string")); + self + } + + /// The directory where data for the global browser cache will be stored on + /// disk. + /// + /// If this value is non-empty then it must be an absolute path that is + /// either equal to or a child directory of `root_cache_path`. If + /// this value is empty then browsers will be created in "incognito mode" + /// where in-memory caches are used for storage and no profile-specific data + /// is persisted to disk (installation-specific data will still be persisted + /// in root_cache_path). HTML5 databases such as localStorage will only + /// persist across sessions if a cache path is specified. + pub fn cache_path(mut self, path: impl Into>) -> Self { + self.cache_path = Some(CString::new(path).expect("invalid cache path")); + self + } + + /// The root directory for installation-specific data and the parent + /// directory for profile-specific data. + /// + /// If this value is `None` then the default platform-specific directory + /// will be used ("~/.config/cef_user_data" directory on Linux, + /// "~/Library/Application Support/CEF/User Data" directory on MacOS, + /// "AppData\Local\CEF\User Data" directory under the user profile + /// directory on Windows). Use of the default directory is not + /// recommended in production applications (see below). + /// + /// NOTE: Multiple application instances writing to the same root_cache_path + /// directory could result in data corruption. + pub fn root_cache_path(mut self, path: impl Into>) -> Self { + self.root_cache_path = Some(CString::new(path).expect("invalid root cache path")); + self + } + + /// The path to a separate executable that will be launched for + /// sub-processes. + /// + /// If this value is not set on Windows or Linux then the + /// main process executable will be used. If this value is not set on + /// macOS then a helper executable must exist at + /// `Contents/Frameworks/ Helper.app/Contents/MacOS/ Helper` + /// in the top-level app bundle. See the comments on CefExecuteProcess() + /// for details. + /// + /// If this value is set then it must be an absolute path. + pub fn browser_subprocess_path(mut self, path: impl Into>) -> Self { + self.browser_subprocess_path = + Some(CString::new(path).expect("invalid browser subprocess path")); + self + } + + /// Enable to control browser process main (UI) thread message pump + /// scheduling via the + /// [`crate::ApplicationHandler::on_schedule_message_pump_work`] + /// callback. + /// + /// This option is recommended for use in combination with the + /// [`crate::do_message_work`] function in cases where the CEF message loop + /// must be integrated into an existing application message. + pub fn external_message_pump(mut self, enable: bool) -> Self { + self.external_message_pump = enable; + self + } + + /// Sets the event handler. + #[inline] + pub fn handler(self, handler: Q) -> Settings + where + Q: ApplicationHandler, + { + Settings { + locale: self.locale, + cache_path: self.cache_path, + root_cache_path: self.root_cache_path, + browser_subprocess_path: self.browser_subprocess_path, + external_message_pump: self.external_message_pump, + handler, + } + } +} diff --git a/crates/wef/src/wef.rs b/crates/wef/src/wef.rs new file mode 100644 index 0000000..a6d29db --- /dev/null +++ b/crates/wef/src/wef.rs @@ -0,0 +1,146 @@ +use std::ffi::{CString, c_char, c_void}; + +use crate::{ApplicationHandler, Error, app_handler::ApplicationState, ffi::*, settings::Settings}; + +/// Initialize the CEF browser process. +/// +/// This function should be called on the main application thread to +/// initialize the CEF browser process. +pub fn init(settings: Settings) -> Result<(), Error> +where + T: ApplicationHandler, +{ + unsafe { + let callbacks = CAppCallbacks { + on_schedule_message_pump_work: crate::app_handler::on_schedule_message_pump_work::, + }; + + let handler = Box::into_raw(Box::new(ApplicationState { + handler: settings.handler, + })); + + extern "C" fn destroy_handler(user_data: *mut c_void) { + unsafe { _ = Box::from_raw(user_data as *mut T) } + } + + let c_settings = CSettings { + locale: to_cstr_ptr_opt(settings.locale.as_deref()), + cache_path: to_cstr_ptr_opt(settings.cache_path.as_deref()), + root_cache_path: to_cstr_ptr_opt(settings.root_cache_path.as_deref()), + browser_subprocess_path: to_cstr_ptr_opt(settings.browser_subprocess_path.as_deref()), + callbacks, + userdata: handler as *mut c_void, + destroy_userdata: destroy_handler::, + }; + + if !wef_init(&c_settings) { + return Err(Error::InitializeBrowserProcess); + } + } + + Ok(()) +} + +/// Executes the CEF subprocess. +/// +/// This function should be called from the application entry point function +/// to execute a secondary process. It can be used to run secondary +/// processes from the browser client executable. +/// +/// If called for the browser process (identified by no "type" command-line +/// value) it will return immediately with a value of `false`. +/// +/// If called for a recognized secondary process it will block until the +/// process should exit and then return `true`. +/// +/// # Examples +/// +/// ```rust, no_run +/// use wef::Settings; +/// +/// fn main() -> Result<(), Box> { +/// if wef::exec_process()? { +/// return Ok(()); +/// } +/// +/// wef::init(Settings::new()); +/// // ... event loop +/// wef::shutdown(); +/// Ok(()) +/// } +/// ``` +pub fn exec_process() -> Result { + let args: Vec = std::env::args() + .filter_map(|arg| CString::new(arg).ok()) + .collect(); + let c_args: Vec<*const c_char> = args.iter().map(|arg| arg.as_ptr()).collect(); + Ok(unsafe { wef_exec_process(c_args.as_ptr(), args.len() as i32) }) +} + +/// Perform a single iteration of CEF message loop processing. +/// +/// This function is provided for cases where the CEF message loop must be +/// integrated into an existing application message loop. +pub fn do_message_work() { + unsafe { wef_do_message_work() }; +} + +/// Shuts down the CEF library. +/// +/// # Panics +/// +/// This function **MUST NOT** be called while any `CefBrowser` instances are +/// still alive. If there are any `CefBrowser` objects that have not been +/// dropped properly at the time of calling this function, it will likely lead +/// to a crash or undefined behavior. +pub fn shutdown() { + unsafe { wef_shutdown() }; +} + +/// Launch the Wef application. +/// +/// This function initializes the CEF library and runs the main process. +/// It is a convenience function that combines the [`init`] and [`shutdown`] +/// functions. +/// +/// On macOS, it also loads the CEF framework using the +/// `crate::FrameworkLoader`. +/// +/// # Panics +/// +/// This function panics if the CEF library fails to initialize or if the +/// CEF framework fails to load on macOS. +/// +/// # Examples +/// +/// ```rust, no_run +/// use wef::Settings; +/// +/// fn main() { +/// let settings = Settings::new(); +/// wef::launch(settings, || { +/// // do something in the main process +/// }); +/// } +/// ``` +pub fn launch(settings: Settings, f: F) -> R +where + T: ApplicationHandler, + F: FnOnce() -> R, +{ + if cfg!(not(target_os = "macos")) { + if exec_process().expect("failed to execute process") { + // Is helper process, exit immediately + std::process::exit(0); + } + } + + #[cfg(target_os = "macos")] + let _ = crate::FrameworkLoader::load_in_main().expect("failed to load CEF framework"); + + // Run the main process + init(settings).expect("failed to initialize CEF"); + let res = f(); + shutdown(); + res +} diff --git a/examples/wef-example/Cargo.toml b/examples/wef-example/Cargo.toml new file mode 100644 index 0000000..60de6c2 --- /dev/null +++ b/examples/wef-example/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wef-example" +version = "0.1.0" +edition = "2024" + +[build-dependencies] +embed-manifest = "1.4.0" + +[dependencies] +wef = { path = "../../crates/wef" } +gpui-webview = { path = "../../crates/webview" } +gpui-component = { git = "https://github.com/longbridge/gpui-component.git" } +gpui = { git = "https://github.com/zed-industries/zed.git" } +futures-util = "0.3.31" +serde = { version = "1.0.219", features = ["derive"] } +flume = "0.11.1" diff --git a/examples/wef-example/build.rs b/examples/wef-example/build.rs new file mode 100644 index 0000000..c2bce4f --- /dev/null +++ b/examples/wef-example/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/examples/wef-example/src/main.rs b/examples/wef-example/src/main.rs new file mode 100644 index 0000000..4f0ce04 --- /dev/null +++ b/examples/wef-example/src/main.rs @@ -0,0 +1,140 @@ +use std::time::Duration; + +use futures_util::StreamExt; +use gpui::{ + App, AppContext, Application, Bounds, Context, Entity, IntoElement, ParentElement, Render, + Styled, Timer, Window, WindowBounds, WindowOptions, div, px, size, +}; +use gpui_component::{ + Root, + input::{InputEvent, InputState, TextInput}, +}; +use gpui_webview::{ + WebView, + events::TitleChangedEvent, + wef::{self, Frame, FuncRegistry, Settings}, +}; +use serde::Serialize; + +struct Main { + address_state: Entity, + webview: Entity, +} + +impl Main { + fn new(window: &mut Window, cx: &mut App) -> Entity { + let background_executor = cx.background_executor().clone(); + + let func_registry = FuncRegistry::builder() + .with_spawner(move |fut| { + background_executor.spawn(fut).detach(); + }) + .register("toUppercase", |value: String| value.to_uppercase()) + .register("addInt", |a: i32, b: i32| a + b) + .register("parseInt", |value: String| value.parse::()) + .register_async("sleep", |millis: u64| async move { + Timer::after(Duration::from_millis(millis)).await; + "ok" + }) + .register("emit", |frame: Frame| { + #[derive(Debug, Serialize)] + struct Message { + event: String, + data: String, + } + + frame.emit(Message { + event: "custom".to_string(), + data: "ok".to_string(), + }); + }) + .build(); + + cx.new(|cx| { + let url = "https://www.google.com"; + + // create webview + let webview = WebView::with_func_registry(url, func_registry.clone(), window, cx); + + window + .subscribe(&webview, cx, |_, event: &TitleChangedEvent, window, _| { + window.set_window_title(&event.title); + }) + .detach(); + + // create address input + let address_state = cx.new(|cx| InputState::new(window, cx).default_value(url)); + + window + .subscribe(&address_state, cx, { + let webview = webview.clone(); + move |state, event: &InputEvent, _, cx| { + if let InputEvent::PressEnter { .. } = event { + let url = state.read(cx).value(); + webview.read(cx).browser().load_url(&url); + } + } + }) + .detach(); + + Self { + address_state, + webview, + } + }) + } +} + +impl Render for Main { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .child(TextInput::new(&self.address_state)) + .child(self.webview.clone()) + .children(Root::render_modal_layer(window, cx)) + } +} + +fn run() { + Application::new().run(|cx: &mut App| { + if cfg!(target_os = "linux") { + cx.spawn(async move |cx| { + let (tx, rx) = flume::unbounded(); + + cx.background_spawn(async move { + let mut timer = Timer::interval(Duration::from_millis(1000 / 60)); + while timer.next().await.is_some() { + _ = tx.send_async(()).await; + } + }) + .detach(); + + while rx.recv_async().await.is_ok() { + wef::do_message_work(); + } + }) + .detach(); + } + + gpui_component::init(cx); + + let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| { + let main = Main::new(window, cx); + cx.new(|cx| Root::new(main.into(), window, cx)) + }, + ) + .unwrap(); + cx.activate(true); + }); +} + +fn main() -> Result<(), Box> { + wef::launch(Settings::new(), run); + Ok(()) +} diff --git a/examples/wef-winit/Cargo.toml b/examples/wef-winit/Cargo.toml new file mode 100644 index 0000000..e01de2f --- /dev/null +++ b/examples/wef-winit/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "wef-winit" +version = "0.1.0" +edition = "2024" + +[build-dependencies] +embed-manifest = "1.4.0" + +[dependencies] +wef = { path = "../../crates/wef" } + +image = "0.25.6" +winit = "0.30.9" +softbuffer = "0.4.6" + +[package.metadata.bundle] +name = "wef-winit" +identifier = "io.github.longbridge.wef.examples.winit" +category = "Utility" +icons = ["icons/icon32x32.png", "icons/icon128x128.png"] diff --git a/examples/wef-winit/README.md b/examples/wef-winit/README.md new file mode 100644 index 0000000..348cdd8 --- /dev/null +++ b/examples/wef-winit/README.md @@ -0,0 +1,21 @@ +# Wef example for Winit + +## How to run + +1. Install `cargo-wef` + + ```bash + cargo install cargo-wef + ``` + +2. Download CEF framework + + ```bash + cargo wef init + ``` + +2. Run the example + + ```bash + cargo wef run -p wef-winit + ``` diff --git a/examples/wef-winit/build.rs b/examples/wef-winit/build.rs new file mode 100644 index 0000000..e628f61 --- /dev/null +++ b/examples/wef-winit/build.rs @@ -0,0 +1,13 @@ +use embed_manifest::{embed_manifest, new_manifest}; + +fn main() { + if std::env::var_os("CARGO_CFG_WINDOWS").is_some() { + embed_manifest(new_manifest("Wef.Example.Winit")).expect("unable to embed manifest file"); + } + + if cfg!(target_os = "linux") { + println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN"); + } + + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/examples/wef-winit/icons/icon128x128.png b/examples/wef-winit/icons/icon128x128.png new file mode 100644 index 0000000..213de63 Binary files /dev/null and b/examples/wef-winit/icons/icon128x128.png differ diff --git a/examples/wef-winit/icons/icon32x32.png b/examples/wef-winit/icons/icon32x32.png new file mode 100644 index 0000000..f170e80 Binary files /dev/null and b/examples/wef-winit/icons/icon32x32.png differ diff --git a/examples/wef-winit/src/main.rs b/examples/wef-winit/src/main.rs new file mode 100644 index 0000000..d05f1b5 --- /dev/null +++ b/examples/wef-winit/src/main.rs @@ -0,0 +1,364 @@ +use std::{rc::Rc, time::Duration}; + +use image::{GenericImage, RgbaImage, buffer::ConvertBuffer}; +use softbuffer::Surface; +use wef::{ + Browser, BrowserHandler, DirtyRects, ImageBuffer, KeyCode, KeyModifier, LogicalUnit, + MouseButton, PaintElementType, Rect, Settings, +}; +use winit::{ + application::ApplicationHandler, + dpi::LogicalSize, + event::{Ime, Modifiers, MouseScrollDelta, WindowEvent}, + event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}, + keyboard::{ModifiersState, NamedKey}, + window::{Window, WindowId}, +}; + +type BoxError = Box; + +enum UserEvent { + WefMessagePump, + Exit, +} + +struct State { + scale_factor: f32, + browser: Browser, +} + +impl State { + fn new(event_loop_proxy: EventLoopProxy, window: Window) -> Result { + let window = Rc::new(window); + let inner_size = window.inner_size(); + let context = softbuffer::Context::new(window.clone())?; + let mut surface = Surface::new(&context, window.clone())?; + + surface.resize( + inner_size.width.try_into().expect("valid surface width"), + inner_size.height.try_into().expect("valid surface height"), + )?; + + let scale_factor = window.scale_factor() as f32; + + // Create the browser instance + let browser = Browser::builder() + .size(inner_size.width, inner_size.height) + .device_scale_factor(scale_factor) + .url("https://www.google.com") + .handler(MyHandler { + scale_factor, + surface, + window, + view_image: None, + popup_rect: Rect::default(), + popup_image: None, + event_loop_proxy, + }) + .build(); + browser.set_focus(true); + + Ok(Self { + scale_factor, + browser, + }) + } +} + +struct App { + event_loop_proxy: EventLoopProxy, + state: Option, + key_modifiers: KeyModifier, +} + +impl App { + fn new(event_loop_proxy: EventLoopProxy) -> Self { + Self { + event_loop_proxy, + state: None, + key_modifiers: KeyModifier::empty(), + } + } + + #[inline] + fn browser(&self) -> Option<&Browser> { + self.state.as_ref().map(|state| &state.browser) + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let window = event_loop + .create_window( + Window::default_attributes().with_inner_size(LogicalSize::new(1024, 768)), + ) + .unwrap(); + window.set_ime_allowed(true); + + self.state = + Some(State::new(self.event_loop_proxy.clone(), window).expect("create window")); + } + + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + match event { + UserEvent::WefMessagePump => wef::do_message_work(), + UserEvent::Exit => event_loop.exit(), + } + } + + fn window_event( + &mut self, + _event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: winit::event::WindowEvent, + ) { + match event { + WindowEvent::CloseRequested => { + if let Some(browser) = self.browser() { + browser.close(); + } + } + WindowEvent::Resized(size) => { + if let Some(state) = &mut self.state { + state.browser.resize(wef::Size::new( + wef::PhysicalUnit(size.width as i32), + wef::PhysicalUnit(size.height as i32), + )); + } + } + WindowEvent::CursorMoved { position, .. } => { + let scale_factor = self.state.as_ref().unwrap().scale_factor; + let position = position.to_logical::(scale_factor as f64); + if let Some(browser) = self.browser() { + browser.send_mouse_move_event( + wef::Point::new( + wef::LogicalUnit(position.x as i32), + wef::LogicalUnit(position.y as i32), + ), + self.key_modifiers, + ); + } + } + WindowEvent::MouseInput { state, button, .. } => { + let button = match button { + winit::event::MouseButton::Left => MouseButton::Left, + winit::event::MouseButton::Middle => MouseButton::Middle, + winit::event::MouseButton::Right => MouseButton::Right, + _ => return, + }; + let pressed = state.is_pressed(); + if let Some(browser) = self.browser() { + browser.send_mouse_click_event(button, !pressed, 1, self.key_modifiers); + } + } + WindowEvent::MouseWheel { delta, .. } => { + let (delta_x, delta_y) = match delta { + MouseScrollDelta::LineDelta(x, y) => (50 * x as i32, 50 * y as i32), + MouseScrollDelta::PixelDelta(delta) => (delta.x as _, delta.y as _), + }; + if let Some(browser) = self.browser() { + browser.send_mouse_wheel_event(wef::Point::new( + wef::LogicalUnit(delta_x), + wef::LogicalUnit(delta_y), + )); + } + } + WindowEvent::ModifiersChanged(modifiers) => { + self.key_modifiers = convert_key_modifiers(modifiers); + } + WindowEvent::KeyboardInput { event, .. } => match event.logical_key.as_ref() { + winit::keyboard::Key::Named(named_key) => { + if let Some(key_code) = convert_key_code(named_key) { + if let Some(browser) = self.browser() { + browser.send_key_event( + event.state.is_pressed(), + key_code, + self.key_modifiers, + ); + } + } + } + winit::keyboard::Key::Character(s) if event.state.is_pressed() => { + if let Some(browser) = self.browser() { + for ch in s.chars() { + browser.send_char_event(ch as u16); + } + } + } + _ => {} + }, + WindowEvent::Ime(ime) => match ime { + Ime::Preedit(text, range) => { + let (start, end) = range.unwrap_or_default(); + if let Some(browser) = self.browser() { + browser.ime_set_composition(&text, start, end); + } + } + Ime::Commit(text) => { + if let Some(browser) = self.browser() { + browser.ime_commit(&text) + } + } + _ => {} + }, + WindowEvent::Focused(focused) => { + if let Some(browser) = self.browser() { + browser.set_focus(focused); + } + } + _ => (), + } + } +} + +struct MyHandler { + scale_factor: f32, + surface: Surface, Rc>, + window: Rc, + view_image: Option, + popup_rect: Rect>, + popup_image: Option, + event_loop_proxy: EventLoopProxy, +} + +impl BrowserHandler for MyHandler { + fn on_closed(&mut self) { + _ = self.event_loop_proxy.send_event(UserEvent::Exit); + } + + fn on_paint( + &mut self, + type_: PaintElementType, + _dirty_rects: &DirtyRects, + image_buffer: ImageBuffer, + ) { + match type_ { + PaintElementType::View => match &mut self.view_image { + Some(view_image) + if view_image.width() == image_buffer.width() + && view_image.height() == image_buffer.height() => + { + view_image.copy_from_slice(&image_buffer); + } + _ => self.view_image = Some(image_buffer.convert()), + }, + PaintElementType::Popup => { + self.popup_image = Some(image_buffer.convert()); + } + } + + if let Some(view_image) = &self.view_image { + self.surface + .resize( + view_image.width().try_into().expect("valid surface width"), + view_image + .height() + .try_into() + .expect("valid surface height"), + ) + .expect("resize surface"); + let mut buffer = self.surface.buffer_mut().unwrap(); + + let mut dest = image::ImageBuffer::, &mut [u8]>::from_raw( + view_image.width(), + view_image.height(), + unsafe { + std::slice::from_raw_parts_mut( + buffer.as_mut_ptr() as *mut u8, + (view_image.width() * view_image.height() * 4) as usize, + ) + }, + ) + .unwrap(); + dest.copy_from_slice(view_image); + + if let Some(popup_image) = &self.popup_image { + let origin = self + .popup_rect + .origin() + .map(|x| x.to_physical(self.scale_factor)); + dest.copy_from(popup_image, origin.x.0 as u32, origin.y.0 as u32) + .unwrap(); + } + + buffer.present().unwrap(); + } + } + + fn on_title_changed(&mut self, title: &str) { + self.window.set_title(title); + } + + fn on_popup_show(&mut self, show: bool) { + if !show { + self.popup_image = None; + } + } + + fn on_popup_position(&mut self, rect: Rect>) { + self.popup_rect = rect; + } +} + +fn convert_key_code(key: NamedKey) -> Option { + match key { + NamedKey::Backspace => Some(KeyCode::Backspace), + NamedKey::Delete => Some(KeyCode::Delete), + NamedKey::Tab => Some(KeyCode::Tab), + NamedKey::Enter => Some(KeyCode::Enter), + NamedKey::PageUp => Some(KeyCode::PageUp), + NamedKey::PageDown => Some(KeyCode::PageDown), + NamedKey::End => Some(KeyCode::End), + NamedKey::Home => Some(KeyCode::Home), + NamedKey::ArrowLeft => Some(KeyCode::ArrowLeft), + NamedKey::ArrowUp => Some(KeyCode::ArrowUp), + NamedKey::ArrowRight => Some(KeyCode::ArrowRight), + NamedKey::ArrowDown => Some(KeyCode::ArrowDown), + _ => None, + } +} + +fn convert_key_modifiers(modifiers: Modifiers) -> KeyModifier { + let mut key_modifiers = KeyModifier::empty(); + if modifiers.state().contains(ModifiersState::SHIFT) { + key_modifiers |= KeyModifier::SHIFT; + } + if modifiers.state().contains(ModifiersState::CONTROL) { + key_modifiers |= KeyModifier::CONTROL; + } + if modifiers.state().contains(ModifiersState::ALT) { + key_modifiers |= KeyModifier::ALT; + } + key_modifiers +} + +fn run() -> Result<(), Box> { + let event_loop = EventLoop::::with_user_event().build()?; + let event_loop_proxy = event_loop.create_proxy(); + + if cfg!(target_os = "linux") { + std::thread::spawn({ + let event_loop_proxy = event_loop_proxy.clone(); + move || { + loop { + std::thread::sleep(Duration::from_millis(1000 / 60)); + if event_loop_proxy + .send_event(UserEvent::WefMessagePump) + .is_err() + { + break; + } + } + } + }); + } + + let mut app = App::new(event_loop_proxy); + event_loop.run_app(&mut app)?; + Ok(()) +} + +fn main() -> Result<(), Box> { + wef::launch(Settings::new(), run)?; + Ok(()) +} diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..e8d5ea4 --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo "Install Linux dependencies..." + script/install-linux-deps +else + echo "Install macOS dependencies..." +fi diff --git a/script/install-linux-deps b/script/install-linux-deps new file mode 100755 index 0000000..e8a2cde --- /dev/null +++ b/script/install-linux-deps @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +sudo apt update +# Test on Ubuntu 24.04 +sudo apt install -y \ + gcc g++ clang libfontconfig-dev libwayland-dev \ + libwebkit2gtk-4.1-dev libxkbcommon-x11-dev libx11-xcb-dev \ + libssl-dev libzstd-dev \ + vulkan-validationlayers libvulkan1