feat: enhance `webots-controller` Windows build support with MinGW and MSYS2 requirements

This commit is contained in:
insleker 2026-05-19 11:49:11 +08:00
parent 52af2e1f21
commit bf33aad15e
5 changed files with 320 additions and 19 deletions

1
.gitignore vendored
View File

@ -150,3 +150,4 @@ Session.vim
!/.vscode !/.vscode
/.env* /.env*
!/.env*.example !/.env*.example
**/test_package/bin

View File

@ -1,9 +1,116 @@
from conan import ConanFile from conan import ConanFile
from conan.errors import ConanInvalidConfiguration from conan.errors import ConanException, ConanInvalidConfiguration
from conan.tools.env import Environment from conan.tools.env import Environment
from conan.tools.files import chdir, copy, get from conan.tools.files import chdir, copy, get
from conan.tools.scm import Git from conan.tools.scm import Git
from pathlib import Path
import os import os
import shutil
import subprocess
import platform
def find_webots_install_path() -> str | None:
"""Locate an existing Webots installation directory.
Windows strategy (in order):
1. Respect WEBOTS_HOME environment variable if it points to an existing path.
2. Query uninstall registry keys (both 64-bit and 32-bit views) for InstallLocation:
HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Webots
3. Look in common default install directories (Program Files, Program Files (x86)).
4. Fallback: derive from a discovered `webots-controller.exe` on PATH.
macOS strategy:
* If a `webots` or `webots-controller` binary is found inside a Webots.app bundle,
return the bundle root (/Webots.app).
Linux / Other:
* Attempt to locate `webots-controller` or `webots` via PATH and return its parent directory.
Returns None if nothing suitable is found.
"""
system = platform.system()
# 1. Explicit env var (all platforms)
env_home = os.getenv("WEBOTS_HOME")
if env_home and os.path.exists(env_home):
return env_home
if system == "Windows":
# 2. Registry (both views)
try: # pragma: no cover (import guarded for non-Windows)
import winreg # type: ignore
except ImportError: # pragma: no cover
winreg = None # type: ignore
if winreg:
reg_paths = [
(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Webots",
),
# 32-bit view on 64-bit systems
(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Webots",
),
]
for hive, subkey in reg_paths:
try:
with winreg.OpenKey(hive, subkey) as key: # type: ignore[attr-defined]
install_path, _ = winreg.QueryValueEx(key, "InstallLocation") # type: ignore[attr-defined]
if install_path and os.path.exists(install_path):
return install_path
except FileNotFoundError:
pass
except OSError:
pass
# 3. Common default directories
program_files = os.environ.get("ProgramFiles", r"C:\\Program Files")
program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\\Program Files (x86)")
candidates = [
os.path.join(program_files, "Webots"),
os.path.join(program_files_x86, "Webots"),
]
for c in candidates:
if os.path.exists(c):
return c
# 4. PATH discovery of controller exe
exe = shutil.which("webots-controller.exe") or shutil.which("webots.exe")
if exe:
parent = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(exe)))
) # heuristic
# Explanation: typical Windows structure:
# <WEBOTS_HOME>\msys64\mingw64\bin\webots-controller.exe
# So we ascend four levels to reach WEBOTS_HOME.
if os.path.exists(parent):
return parent
return None
# Non-Windows
candidate = shutil.which("webots-controller") or shutil.which("webots")
if not candidate:
return None
# Resolve symlinks to get actual installation root. This matters on Linux
# distributions where Webots may be installed under /opt/webots but only a
# symlink placed into /usr/local/bin or similar locations.
resolved = os.path.realpath(candidate)
if system == "Darwin":
token = "Webots.app"
# If the resolved path sits inside an app bundle, return the bundle root.
if token in resolved:
prefix, _sep, _rest = resolved.partition(token)
return os.path.join(prefix, token)
# Fallback: just use the directory of the (possibly resolved) binary.
return os.path.dirname(resolved)
return os.path.dirname(resolved)
class WebotsControllerConan(ConanFile): class WebotsControllerConan(ConanFile):
@ -29,15 +136,48 @@ class WebotsControllerConan(ConanFile):
def _source_subfolder(self): def _source_subfolder(self):
return "source_subfolder" return "source_subfolder"
@property
def _is_system_package(self):
return str(self.version) == "system"
@property
def _system_webots_home(self):
webots_home = find_webots_install_path()
if not webots_home:
raise ConanInvalidConfiguration(
f"{self.ref} could not locate an installed Webots root. "
"Set WEBOTS_HOME or install Webots in a standard location discoverable by find_webots_install_path()."
)
return os.path.abspath(os.path.expanduser(webots_home))
def _validate_system_webots_home(self):
webots_home = self._system_webots_home
required_paths = [
os.path.join(webots_home, "include", "controller", "c", "webots", "robot.h"),
os.path.join(webots_home, "include", "controller", "cpp", "webots", "Robot.hpp"),
os.path.join(webots_home, "lib", "controller"),
]
missing = [path for path in required_paths if not os.path.exists(path)]
if missing:
formatted = ", ".join(f"'{path}'" for path in missing)
raise ConanInvalidConfiguration(
f"{self.ref} could not find the expected Webots controller files under WEBOTS_HOME='{webots_home}': {formatted}"
)
def config_options(self): def config_options(self):
if self.settings.os == "Windows": if self.settings.os == "Windows":
self.options.rm_safe("fPIC") self.options.rm_safe("fPIC")
def validate(self): def validate(self):
if self._is_system_package:
self._validate_system_webots_home()
return
if str(self.settings.compiler) == "msvc": if str(self.settings.compiler) == "msvc":
raise ConanInvalidConfiguration("Webots controller libraries are built with GCC/Clang toolchains, not MSVC") raise ConanInvalidConfiguration("Webots controller libraries are built with GCC/Clang toolchains, not MSVC")
def source(self): def source(self):
if self._is_system_package:
return
source_data = self.conan_data["sources"][self.version] source_data = self.conan_data["sources"][self.version]
if "git_url" in source_data: if "git_url" in source_data:
git = Git(self) git = Git(self)
@ -55,40 +195,154 @@ class WebotsControllerConan(ConanFile):
return return
get(self, **source_data, strip_root=True, destination=self._source_subfolder) get(self, **source_data, strip_root=True, destination=self._source_subfolder)
def requirements(self):
# [Using a MinGW as tool_requires to build with gcc in Windows](https://docs.conan.io/2/examples/dev_flow/tool_requires/mingw.html#examples-dev-flow-tool-requires-mingw)
if not self._is_system_package and self.settings.os == "Windows":
self.tool_requires("msys2/cci.latest")
def package_id(self):
if self._is_system_package:
self.info.clear()
def build(self): def build(self):
build_env = Environment() if self._is_system_package:
return
source_root = os.path.join(self.source_folder, self._source_subfolder) source_root = os.path.join(self.source_folder, self._source_subfolder)
if self.settings.os == "Windows":
# Webots makefiles invoke POSIX tools and shell fragments, so run MSYS make from bash.
bash_exe = None
make_exe = None
for dep in self.dependencies.build.values():
if not dep.ref or not dep.package_folder:
continue
if dep.ref.name == "msys2":
bash_matches = list(Path(dep.package_folder).glob("**/bash.exe"))
make_matches = list(Path(dep.package_folder).glob("**/make.exe"))
if bash_matches and not bash_exe:
bash_exe = str(bash_matches[0])
if make_matches and not make_exe:
make_exe = str(make_matches[0])
if not bash_exe or not make_exe:
raise ConanInvalidConfiguration(
"Windows build requires bash.exe and make.exe from tool_requires 'msys2'."
)
mingw_dep = next((dep for dep in self.dependencies.build.values() if dep.ref and dep.ref.name == "mingw-builds"), None)
if mingw_dep is None:
raise ConanInvalidConfiguration("Windows build requires tool_requires 'mingw-builds'.")
env = os.environ.copy()
env["WEBOTS_HOME"] = source_root
env["PATH"] = os.pathsep.join([
os.path.join(mingw_dep.package_folder, "bin"),
str(Path(make_exe).parent),
env.get("PATH", ""),
])
drive, tail = os.path.splitdrive(make_exe)
msys_make = f"/{drive[0].lower()}{tail.replace('\\', '/')}"
for controller_dir in ("c", "cpp"):
cwd = os.path.join(source_root, "src", "controller", controller_dir)
subprocess.run([bash_exe, "--noprofile", "--norc", "-c", f'"{msys_make}" clean'], cwd=cwd, env=env, check=True)
subprocess.run([bash_exe, "--noprofile", "--norc", "-c", f'"{msys_make}" release'], cwd=cwd, env=env, check=True)
else:
build_env = Environment()
build_env.define("WEBOTS_HOME", source_root) build_env.define("WEBOTS_HOME", source_root)
with build_env.vars(self).apply(): with build_env.vars(self).apply():
with chdir(self, os.path.join(source_root, "src", "controller", "c")): with chdir(self, os.path.join(source_root, "src", "controller", "c")):
self.run("make clean")
self.run("make release") self.run("make release")
with chdir(self, os.path.join(source_root, "src", "controller", "cpp")): with chdir(self, os.path.join(source_root, "src", "controller", "cpp")):
self.run("make clean")
self.run("make release") self.run("make release")
expected_artifacts = {
"Linux": ["libController.so", "libCppController.so"],
"Macos": ["libController.dylib", "libCppController.dylib"],
"Windows": ["Controller.dll", "libController.a", "CppController.dll", "libCppController.a"],
}
lib_dir = os.path.join(source_root, "lib", "controller")
missing = [name for name in expected_artifacts.get(str(self.settings.os), []) if not os.path.exists(os.path.join(lib_dir, name))]
if missing:
raise ConanException(
f"Expected Webots controller artifacts were not produced in '{lib_dir}': {', '.join(missing)}. "
"On Windows, the upstream Webots controller build may be incompatible with the current MinGW/MSYS toolchain setup."
)
def package(self): def package(self):
if self._is_system_package:
return
source_root = os.path.join(self.source_folder, self._source_subfolder) source_root = os.path.join(self.source_folder, self._source_subfolder)
build_root = os.path.join(self.build_folder, self._source_subfolder)
copy(self, "LICENSE", src=source_root, dst=os.path.join(self.package_folder, "licenses")) copy(self, "LICENSE", src=source_root, dst=os.path.join(self.package_folder, "licenses"))
copy(self, "*.h", src=os.path.join(source_root, "include", "controller", "c"), dst=os.path.join(self.package_folder, "include", "controller", "c"), keep_path=True) copy(self, "*.h", src=os.path.join(source_root, "include", "controller", "c"), dst=os.path.join(self.package_folder, "include", "controller", "c"), keep_path=True)
copy(self, "*.hpp", src=os.path.join(source_root, "include", "controller", "cpp"), dst=os.path.join(self.package_folder, "include", "controller", "cpp"), keep_path=True) copy(self, "*.hpp", src=os.path.join(source_root, "include", "controller", "cpp"), dst=os.path.join(self.package_folder, "include", "controller", "cpp"), keep_path=True)
lib_src = os.path.join(source_root, "lib", "controller") lib_dst = os.path.join(self.package_folder, "lib")
copy(self, "*.so*", src=lib_src, dst=os.path.join(self.package_folder, "lib"), keep_path=False) bin_dst = os.path.join(self.package_folder, "bin")
copy(self, "*.dylib*", src=lib_src, dst=os.path.join(self.package_folder, "lib"), keep_path=False) lib_src_candidates = [
copy(self, "*.dll", src=lib_src, dst=os.path.join(self.package_folder, "bin"), keep_path=False) os.path.join(build_root, "lib", "controller"),
copy(self, "*.lib", src=lib_src, dst=os.path.join(self.package_folder, "lib"), keep_path=False) os.path.join(source_root, "lib", "controller"),
]
for lib_src in lib_src_candidates:
copy(self, "*.so*", src=lib_src, dst=lib_dst, keep_path=False)
copy(self, "*.dylib*", src=lib_src, dst=lib_dst, keep_path=False)
copy(self, "*.a", src=lib_src, dst=lib_dst, keep_path=False)
copy(self, "*.dll", src=lib_src, dst=bin_dst, keep_path=False)
copy(self, "*.lib", src=lib_src, dst=lib_dst, keep_path=False)
if self.settings.os == "Linux":
patchelf = shutil.which("patchelf")
if patchelf:
package_lib = os.path.join(self.package_folder, "lib")
for soname in ("libController.so", "libCppController.so"):
lib_path = os.path.join(package_lib, soname)
if os.path.exists(lib_path):
self.run(f'"{patchelf}" --set-soname "{soname}" "{lib_path}"')
def package_info(self): def package_info(self):
self.cpp_info.set_property("cmake_file_name", "webots-controller") self.cpp_info.set_property("cmake_file_name", "webots-controller")
if self._is_system_package:
webots_home = self._system_webots_home
controller_lib_dir = os.path.join(webots_home, "lib", "controller")
controller_include_dir = os.path.join(webots_home, "include", "controller", "c")
cpp_controller_include_dir = os.path.join(webots_home, "include", "controller", "cpp")
extra_libdirs = []
extra_bindirs = []
self.cpp_info.bindirs = []
self.cpp_info.includedirs = []
self.cpp_info.libdirs = []
self.buildenv_info.define_path("WEBOTS_HOME", webots_home)
self.runenv_info.define_path("WEBOTS_HOME", webots_home)
if self.settings.os == "Windows":
mingw_bin_dir = os.path.join(webots_home, "msys64", "mingw64", "bin")
extra_libdirs.append(mingw_bin_dir)
extra_bindirs.append(mingw_bin_dir)
elif self.settings.os == "Linux":
extra_libdirs.append(os.path.join(webots_home, "lib"))
self.cpp_info.components["controller"].includedirs = [controller_include_dir]
self.cpp_info.components["controller"].libdirs = [controller_lib_dir, *extra_libdirs]
if self.settings.os == "Windows":
self.cpp_info.components["controller"].bindirs = [controller_lib_dir, *extra_bindirs]
self.cpp_info.components["cpp_controller"].includedirs = [
cpp_controller_include_dir,
controller_include_dir,
]
self.cpp_info.components["cpp_controller"].libdirs = [controller_lib_dir, *extra_libdirs]
if self.settings.os == "Windows":
self.cpp_info.components["cpp_controller"].bindirs = [controller_lib_dir, *extra_bindirs]
else:
self.cpp_info.components["controller"].includedirs = ["include/controller/c"]
self.cpp_info.components["cpp_controller"].includedirs = ["include/controller/cpp", "include/controller/c"]
self.cpp_info.components["controller"].libs = ["Controller"] self.cpp_info.components["controller"].libs = ["Controller"]
self.cpp_info.components["controller"].set_property("cmake_target_name", "Webots::Controller") self.cpp_info.components["controller"].set_property("cmake_target_name", "Webots::Controller")
self.cpp_info.components["controller"].includedirs = ["include/controller/c"]
self.cpp_info.components["cpp_controller"].libs = ["CppController"] self.cpp_info.components["cpp_controller"].libs = ["CppController"]
self.cpp_info.components["cpp_controller"].set_property("cmake_target_name", "Webots::CppController") self.cpp_info.components["cpp_controller"].set_property("cmake_target_name", "Webots::CppController")
self.cpp_info.components["cpp_controller"].requires = ["controller"] self.cpp_info.components["cpp_controller"].requires = ["controller"]
self.cpp_info.components["cpp_controller"].includedirs = ["include/controller/cpp", "include/controller/c"]
if self.settings.os == "Linux": if self.settings.os == "Linux":
self.cpp_info.components["controller"].system_libs = ["m", "pthread", "dl", "rt"] self.cpp_info.components["controller"].system_libs = ["m", "pthread", "dl", "rt"]

View File

@ -5,3 +5,42 @@ find_package(webots-controller REQUIRED CONFIG)
add_executable(test_package test_package.cpp) add_executable(test_package test_package.cpp)
target_link_libraries(test_package PRIVATE Webots::CppController) target_link_libraries(test_package PRIVATE Webots::CppController)
if(POLICY CMP0207)
cmake_policy(SET CMP0207 NEW)
endif()
# Webots controllers rely on seeing shared deps in the same directory as the executable.
install(TARGETS test_package
RUNTIME_DEPENDENCY_SET test_package_runtime_deps
RUNTIME DESTINATION bin
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib)
if(WIN32)
# for single-config generators.
set(_runtime_dependency_directories)
string(TOUPPER "${CMAKE_BUILD_TYPE}" _conan_build_type)
set(_webots_bin_dirs_var "webots-controller_BIN_DIRS_${_conan_build_type}")
if(DEFINED ${_webots_bin_dirs_var})
list(APPEND _runtime_dependency_directories ${${_webots_bin_dirs_var}})
endif()
if(DEFINED ENV{MINGW_HOME})
list(APPEND _runtime_dependency_directories "$ENV{MINGW_HOME}/bin")
endif()
install(
RUNTIME_DEPENDENCY_SET test_package_runtime_deps
DIRECTORIES ${_runtime_dependency_directories}
PRE_EXCLUDE_REGEXES
"^api-ms-win-.*"
"^ext-ms-.*"
POST_EXCLUDE_REGEXES
".*/system32/.*"
".*/Windows/.*"
".*/SysWOW64/.*"
DESTINATION bin)
else()
install(
RUNTIME_DEPENDENCY_SET test_package_runtime_deps
DESTINATION bin)
endif()

View File

@ -21,6 +21,8 @@ class TestPackageConan(ConanFile):
cmake.build() cmake.build()
def test(self): def test(self):
cmake = CMake(self)
cmake.install()
if can_run(self): if can_run(self):
exe = os.path.join(self.cpp.build.bindirs[0], "test_package") exe = os.path.join(self.source_folder, "bin", "test_package")
self.run(exe, env="conanrun") self.run(exe, env="conanrun")

View File

@ -1,7 +1,12 @@
#include <webots/Robot.hpp> #include <webots/Robot.hpp>
#include <webots/robot.h>
#include <iostream>
int main() { int main() {
webots::Robot *robot = nullptr; auto cpp_symbol = &webots::Robot::getBasicTimeStep;
(void)robot; auto c_symbol = &wb_robot_get_time;
(void)cpp_symbol;
(void)c_symbol;
std::cout << "Success!" << std::endl;
return 0; return 0;
} }