feat(sys): support cross-compiling to Windows with cargo-xwin (#462)

* feat(sys): support cross-compiling to Windows with cargo-xwin

Building `cef-dll-sys` for `x86_64-pc-windows-msvc` from a Linux host with
cargo-xwin failed while compiling `libcef_dll_wrapper` with clang-cl, for
four reasons that are now handled in the build script:

- CEF's CMake clears `CMAKE_CXX_FLAGS` for the Ninja generator, dropping
  the toolchain's `--target` and `/imsvc` include paths. They are now passed
  through `CEF_C_COMPILER_FLAGS`/`CEF_CXX_COMPILER_FLAGS` instead, which CEF
  appends after its own flags.
- clang-cl reports diagnostics that MSVC doesn't (`/MP` is unsupported,
  `-Wmissing-field-initializers`, `-Wundefined-var-template`) and CEF's `/WX`
  turned them into errors. Those are silenced and `/WX-` is appended.
- The Windows SDK import libraries were merged into the wrapper archive
  through `CMAKE_STATIC_LINKER_FLAGS`, which llvm-lib can't do because it
  rejects the XFG hash map member of current import libraries. They are
  now emitted as `cargo::rustc-link-lib` directives for the final link.
- The SDK expects case-insensitive header lookups (`Softpub.h` vs
  `SoftPub.h`). Mismatched headers get a correctly-cased copy in an extra
  include directory under `OUT_DIR`.

The `cc` crate is used to detect clang-cl and to read the target CXXFLAGS,
so this is a no-op for native MSVC builds. A CI job cross-compiles the
`cefsimple` example on ubuntu-latest to keep this working.

* fix(sys): join clang-cl options that take a separate argument

cargo-xwin 0.23 exports the SDK include paths as `/imsvc <dir>` (two
tokens) where 0.19 used `/imsvc<dir>`. CMake de-duplicates compile
options, so every repeated `/imsvc` after the first was dropped and the
bare paths were passed to clang-cl as additional source files:

    clang-cl: error: cannot specify '/Fo...' when compiling multiple source files

Join such option/value pairs into a single token before handing the flags
to CMake, and only add the clang-cl specific flags that aren't already
present so de-duplication has nothing left to remove.

* ci: use LLVM 20 for the Windows cross-compile job

The MSVC STL headers that cargo-xwin downloads now refuse compilers older
than Clang 19 (STL1000), and ubuntu-latest ships Clang 18. Install LLVM 20
from apt.llvm.org and put it first on PATH: cargo-xwin symlinks its
`clang-cl` to the `clang` found there, so only the SDK directory is cached.

* cleanup

---------

Co-authored-by: Lucas Nogueira <lucas@crabnebula.dev>
This commit is contained in:
Lucas Fernandes NogueiraandLucas Nogueira authored and GitHub committed 2026-09-14 10:09:51 -03:00
1 parent 207bdd2917
commit a7a4451b19
5 files changed
+230 -22

No files matched your search

+42
View File
@@ -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
+1
View File
@@ -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"
+12
View File
@@ -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.
+1
View File
@@ -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
+174 -22
View File
@@ -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 <dir>`), so join those into one token.
let mut flags: Vec<String> = 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<PathBuf> = 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::collections::BTreeSet<String>, std::io::Error> {
fn visit(
dir: &std::path::Path,
includes: &mut std::collections::BTreeSet<String>,
) -> 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::path::PathBuf> {
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