diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ea269d3..9b2b248 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -68,3 +68,45 @@ jobs: - name: Run tests run: cargo test --verbose + + cross-compile-windows: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Install dependencies + run: | + # The MSVC STL headers require Clang 19 or newer, ubuntu-latest ships 18. + wget -qO llvm.sh https://apt.llvm.org/llvm.sh + sudo bash llvm.sh 20 + sudo apt-get install -yq llvm-20 ninja-build + echo "/usr/lib/llvm-20/bin" >> "$GITHUB_PATH" + rustup target add x86_64-pc-windows-msvc + pipx install cargo-xwin + echo "CARGO_XWIN_VERSION=$(cargo xwin --version | awk '{print $NF}')" >> "$GITHUB_ENV" + + - name: Cache CEF + id: cache-cef + uses: actions/cache@v6.1.0 + with: + path: ~/.local/share/cef + key: cef-cross-windows-${{ hashFiles('Cargo.toml') }} + + # Like the native jobs, export the distribution ahead of the build so the cache + # holds only the extracted files and not the downloaded archive as well. + - name: Export CEF + if: steps.cache-cef.outputs.cache-hit != 'true' + run: cargo run -p export-cef-dir -- --target x86_64-pc-windows-msvc "$HOME/.local/share/cef" + + # Only the SDK itself: cargo-xwin symlinks `clang-cl` to the `clang` found on PATH + # next to it, and that must be recreated for the toolchain installed above. The + # SDK and CRT versions come from the cargo-xwin release, so key on it. + - name: Cache Windows SDK + uses: actions/cache@v6.1.0 + with: + path: ~/.cache/cargo-xwin/xwin + key: cargo-xwin-sdk-${{ runner.os }}-${{ env.CARGO_XWIN_VERSION }} + + - name: Build + run: CEF_PATH="$HOME/.local/share/cef" cargo xwin build -p cefsimple --target x86_64-pc-windows-msvc --verbose diff --git a/Cargo.toml b/Cargo.toml index 6e68128..844e2d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ anyhow = "1" ash = "0.38" bindgen = "0.73" cargo_metadata = "0.23.1" +cc = "1.2.8" clap = { version = "4", features = ["derive"] } cmake = "0.1.52" convert_case = "0.12" diff --git a/README.md b/README.md index 2f05c10..f7f5c91 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,18 @@ cargo run --bin bundle-cef-app -- cefsimple -o ./target/bundle ./target/bundle/cefsimple.exe ``` +### Cross-compiling to Windows + +The `cef-dll-sys` crate can be cross-compiled to `x86_64-pc-windows-msvc` from Linux with [cargo-xwin](https://github.com/rust-cross/cargo-xwin), which downloads the Windows SDK and sets up a `clang-cl` toolchain for both Rust and CMake. Install `clang`, `lld`, `llvm` and `ninja` from your package manager (the MSVC STL headers require Clang 19 or newer; on Ubuntu 24.04 use [apt.llvm.org](https://apt.llvm.org/)), then: + +```sh +rustup target add x86_64-pc-windows-msvc +cargo install cargo-xwin +cargo xwin build --target x86_64-pc-windows-msvc +``` + +The `libcef_dll_wrapper` static library is built with `clang-cl` and the CEF runtime files are copied next to the output binaries, exactly like a native Windows build. The `CEF_PATH` environment variable works the same way as well: the Windows CEF binaries are downloaded into their own `cef_windows_x86_64` directory next to the host ones. + ## Contributing Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. diff --git a/sys/Cargo.toml b/sys/Cargo.toml index eaf21a0..734d0c1 100644 --- a/sys/Cargo.toml +++ b/sys/Cargo.toml @@ -21,6 +21,7 @@ features = ["dox"] [build-dependencies] anyhow.workspace = true +cc.workspace = true cmake.workspace = true download-cef.workspace = true serde_json.workspace = true diff --git a/sys/build.rs b/sys/build.rs index 59ab83d..c13ea75 100644 --- a/sys/build.rs +++ b/sys/build.rs @@ -175,33 +175,38 @@ fn main() -> anyhow::Result<()> { // On macOS it's more complicated so we'll leave it to tools like tauri-cli for now. copy_cef_runtime_files(&cef_dir, target_dir)?; + // Windows SDK import libraries used by the wrapper. These used to be merged into + // libcef_dll_wrapper.lib through CMAKE_STATIC_LINKER_FLAGS, but llvm-lib (used when + // cross-compiling with cargo-xwin) can't read the XFG hash map member of current SDK + // import libraries, so pass them to the final link through cargo instead. let sdk_libs = [ - "comctl32.lib", - "delayimp.lib", - "mincore.lib", - "powrprof.lib", - "propsys.lib", - "runtimeobject.lib", - "setupapi.lib", - "shcore.lib", - "shell32.lib", - "shlwapi.lib", - "user32.lib", - "version.lib", - "wbemuuid.lib", - "winmm.lib", - ] - .join(" "); + "comctl32", + "delayimp", + "mincore", + "powrprof", + "propsys", + "runtimeobject", + "setupapi", + "shcore", + "shell32", + "shlwapi", + "user32", + "version", + "wbemuuid", + "winmm", + ]; + for lib in sdk_libs { + println!("cargo::rustc-link-lib=dylib={lib}"); + } - let build_dir = cef_dll_wrapper + cef_dll_wrapper .define("CMAKE_MSVC_RUNTIME_LIBRARY", "MultiThreaded") .define("CMAKE_OBJECT_PATH_MAX", "500") - .define("CMAKE_STATIC_LINKER_FLAGS", &sdk_libs) .define("PROJECT_ARCH", project_arch) - .define("USE_SANDBOX", sandbox) - .build() - .to_string_lossy() - .into_owned(); + .define("USE_SANDBOX", sandbox); + configure_clang_cl(&mut cef_dll_wrapper, &cef_dir, &out_dir)?; + + let build_dir = cef_dll_wrapper.build().to_string_lossy().into_owned(); println!("cargo::rustc-link-search=native={build_dir}/build/libcef_dll_wrapper"); println!("cargo::rustc-link-lib=static=libcef_dll_wrapper"); @@ -227,6 +232,153 @@ fn main() -> anyhow::Result<()> { Ok(()) } +/// Cross-compiling to Windows from another host (e.g. with `cargo xwin`) uses `clang-cl`, which +/// needs a few adjustments to CEF's MSVC-oriented CMake configuration. This is a no-op when the +/// C++ compiler for the target isn't `clang-cl`. +#[cfg(not(feature = "dox"))] +fn configure_clang_cl( + config: &mut cmake::Config, + cef_dir: &std::path::Path, + out_dir: &std::path::Path, +) -> anyhow::Result<()> { + use std::{fs, path::PathBuf}; + + // Only the flags from the CXXFLAGS environment (e.g. cargo-xwin's `--target` and `/imsvc` + // include paths) are needed here; the cmake crate takes care of everything else. + let compiler = cc::Build::new() + .cpp(true) + .no_default_flags(true) + .try_get_compiler()?; + if !compiler.is_like_clang_cl() { + return Ok(()); + } + + // CMake de-duplicates compile options, which would drop the repeated `/imsvc` of options that + // take their value as a separate argument (`/imsvc `), so join those into one token. + let mut flags: Vec = Vec::new(); + let mut args = compiler + .args() + .iter() + .map(|arg| arg.to_string_lossy().into_owned()); + while let Some(arg) = args.next() { + let flag = match arg.as_str() { + "/imsvc" | "-imsvc" | "/I" | "-I" | "-isystem" | "/external:I" => match args.next() { + Some(value) => format!("{arg}{value}"), + None => arg, + }, + _ => arg, + }; + flags.push(flag); + } + + // clang-cl reports diagnostics that MSVC doesn't (`/MP` is unsupported, `/W4` enables extra + // warnings), which CEF's `/WX` would turn into errors. + for flag in [ + "-Wno-unused-command-line-argument", + "-Wno-missing-field-initializers", + "-Wno-undefined-var-template", + "/WX-", + ] { + if !flags.iter().any(|existing| existing == flag) { + flags.push(flag.to_owned()); + } + } + + // The Windows SDK relies on case-insensitive file names, which doesn't hold on other hosts: + // e.g. the wrapper includes `Softpub.h` while the SDK ships `SoftPub.h`. Provide copies with + // the expected casing in an extra include directory. + let include_dirs: Vec = flags + .iter() + .filter_map(|flag| { + flag.strip_prefix("/imsvc") + .or_else(|| flag.strip_prefix("-imsvc")) + }) + .map(PathBuf::from) + .collect(); + let shim_dir = out_dir.join("include-shim"); + fs::create_dir_all(&shim_dir)?; + for header in system_includes(cef_dir)? { + if include_dirs.iter().any(|dir| dir.join(&header).is_file()) { + continue; + } + if let Some(found) = include_dirs + .iter() + .find_map(|dir| find_case_insensitive(dir, &header)) + { + fs::copy(found, shim_dir.join(&header))?; + } + } + flags.push(format!("/imsvc{}", shim_dir.display())); + + // CEF clears CMAKE_CXX_FLAGS when using the Ninja generator (see cef_variables.cmake), which + // would drop all of the above, so pass them through CEF's own flag lists instead. Those are + // appended after CEF's flags, so they can also override `/WX`. + let flags = flags.join(";"); + config + .define("CEF_C_COMPILER_FLAGS", &flags) + .define("CEF_CXX_COMPILER_FLAGS", &flags); + + Ok(()) +} + +/// Collects the file names used in `#include <...>` directives (without directory components) by +/// the CEF headers and wrapper sources. +#[cfg(not(feature = "dox"))] +fn system_includes( + cef_dir: &std::path::Path, +) -> Result, std::io::Error> { + fn visit( + dir: &std::path::Path, + includes: &mut std::collections::BTreeSet, + ) -> Result<(), std::io::Error> { + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + visit(&path, includes)?; + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + for line in source.lines() { + let Some(directive) = line.trim_start().strip_prefix("#include") else { + continue; + }; + let Some((name, _)) = directive + .trim_start() + .strip_prefix('<') + .and_then(|rest| rest.split_once('>')) + else { + continue; + }; + if !name.contains('/') { + includes.insert(name.to_owned()); + } + } + } + Ok(()) + } + + let mut includes = std::collections::BTreeSet::new(); + for dir in ["include", "libcef_dll"] { + visit(&cef_dir.join(dir), &mut includes)?; + } + Ok(includes) +} + +#[cfg(not(feature = "dox"))] +fn find_case_insensitive(dir: &std::path::Path, name: &str) -> Option { + std::fs::read_dir(dir) + .ok()? + .flatten() + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.eq_ignore_ascii_case(name)) + }) +} + /// `CEF_API_VERSION_LAST` from the distribution's generated /// `include/cef_api_versions.h`: the newest versioned (non-experimental) API /// it supports, written there as `#define CEF_API_VERSION_LAST