diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index da958daa5b..89841ccdb4 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -1,140 +1,46 @@ -name: Build - Emscripten +name: Build Browser on: workflow_dispatch: pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: + - "Dockerfile.browser" + - "Dockerfile.browser.sh" + - "browser/**" - "src/**" - ".github/workflows/build-browser.yml" push: + branches: [main] paths: + - "Dockerfile.browser" + - "Dockerfile.browser.sh" + - "browser/**" - "src/**" - ".github/workflows/build-browser.yml" - branches: - - main permissions: contents: read - pull-requests: read - -env: - LUKKA_RUN_VCPKG_SHA: "${{ vars.LUKKA_RUN_VCPKG_SHA != '' && vars.LUKKA_RUN_VCPKG_SHA || 'b3dd708d38df5c856fe1c18dc0d59ab771f93921' }}" + actions: write jobs: - cancel-runs: - if: github.event_name == 'pull_request' && github.ref != 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@main - with: - access_token: ${{ github.token }} - - build: - name: ${{ matrix.buildtype }} + docker-build: runs-on: ubuntu-latest env: - ACTIONS_RUNTIME_TOKEN: ${{ secrets.ACTIONS_RUNTIME_TOKEN }} - ACTIONS_CACHE_URL: ${{ secrets.ACTIONS_CACHE_URL }} - NODE_OPTIONS: "--max-old-space-size=4096" - - concurrency: - group: otclient-emscripten-${{ github.workflow }}-${{ github.ref }}-${{ matrix.buildtype }} - cancel-in-progress: true - - strategy: - fail-fast: false - matrix: - buildtype: [emscripten-debug] - include: - - buildtype: emscripten-debug - cmake_build_type: Debug - + BUILD_LOG: docker-browser-build.log + OUTPUT_DIR: build-emscripten-web steps: - name: Checkout repository uses: actions/checkout@v4 - with: - repository: ${{ github.event_name == 'push' && github.repository || (github.event.pull_request.head.repo.fork && github.repository || github.event.pull_request.head.repo.full_name) }} - ref: ${{ github.event_name == 'push' && github.ref || (github.event.pull_request.head.repo.fork && github.event.pull_request.base.ref || github.event.pull_request.head.ref) }} - fetch-depth: 0 - - name: Setup Emscripten - uses: mymindstorm/setup-emsdk@v14 - with: - version: "3.1.45" - - - name: Setup Node.js for Emscripten - uses: actions/setup-node@v4 - with: - node-version: '18' + - name: Build browser bundle with Docker + run: bash Dockerfile.browser.sh - - name: Get vcpkg commit ID - id: vcpkg-step - run: | - vcpkgCommitId=$(grep '.builtin-baseline' vcpkg.json | awk -F: '{print $2}' | tr -d '," ') - echo "vcpkgGitCommitId=$vcpkgCommitId" >> $GITHUB_OUTPUT - - - name: Cache full vcpkg artifacts - uses: actions/cache@v4 - with: - path: | - ~/.cache/vcpkg/archives - ${{ github.workspace }}/vcpkg/installed - ${{ github.workspace }}/vcpkg/buildtrees - ${{ github.workspace }}/vcpkg/downloads - key: vcpkg-${{ matrix.buildtype }}-${{ steps.vcpkg-step.outputs.vcpkgGitCommitId }} - restore-keys: | - vcpkg-${{ matrix.buildtype }}- - - name: Setup vcpkg - uses: lukka/run-vcpkg@a400452f634fe49e9f18d388aeb1809dcc642136 - with: - vcpkgGitCommitId: ${{ steps.vcpkg-step.outputs.vcpkgGitCommitId }} - - - - name: Validate vcpkg baseline SHA - shell: pwsh - run: | - $SHAS = "${{ steps.vcpkg-step.outputs.vcpkgGitCommitId }}" - if ([string]::IsNullOrEmpty($SHAS)) { - echo "::error title=Missing vcpkg baseline::Provide a full 40-char vcpkgGitCommitId." - exit 1 - } - if ($SHAS -notmatch '^[0-9a-f]{40}$') { - echo "::error title=Invalid vcpkg baseline::vcpkgGitCommitId must be a full 40-char commit SHA." - exit 1 - } - - name: Install CMake and Ninja - uses: lukka/get-cmake@v3.31.6 - - - name: Configure CMake - run: | - cmake -G Ninja -S . -B build-${{ matrix.buildtype }} \ - -DVCPKG_CHAINLOAD_TOOLCHAIN_FILE=$EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake \ - -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake \ - -DVCPKG_TARGET_TRIPLET=wasm32-emscripten \ - -DVCPKG_OVERLAY_PORTS=${{ github.workspace }}/browser/overlay-ports \ - -DVCPKG_BUILD_TYPE=${{ matrix.cmake_build_type }} \ - -DCMAKE_MAKE_PROGRAM=ninja \ - -DOPTIONS_ENABLE_IPO=OFF \ - -DTOGGLE_BIN_FOLDER=ON \ - -DCMAKE_BUILD_TYPE=${{ matrix.cmake_build_type }} \ - -DTOGGLE_BOT_PROTECTION=OFF \ - -DCMAKE_CXX_FLAGS="-fno-lto -std=c++20 -D_LIBCPP_DISABLE_AVAILABILITY -fexperimental-library" \ - -DCMAKE_EXE_LINKER_FLAGS="-s DISABLE_EXCEPTION_CATCHING=0 -s ALLOW_MEMORY_GROWTH=1 -s USE_PTHREADS=0 -s WASM=1 -s NO_EXIT_RUNTIME=1 -s MALLOC=none -fno-lto" \ - -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF \ - -DCMAKE_CXX_FLAGS_RELEASE="-O2 -DNDEBUG -fno-lto" \ - -DCMAKE_CXX_FLAGS_DEBUG="-O0 -g -fno-lto" \ - -DCMAKE_CXX_STANDARD=20 - - - name: Build - run: | - cmake --build build-${{ matrix.buildtype }} --target otclient --verbose - - - name: Create and Upload Artifact - if: ${{ github.event_name != 'pull_request' }} + - name: Upload browser artifact uses: actions/upload-artifact@v4 with: - name: otclient-${{ matrix.buildtype }}-${{ github.sha }} - path: ${{ github.workspace }}/build-${{ matrix.buildtype }}/bin/ - retention-days: 30 + name: otclient-browser-${{ github.sha }} + path: | + ${{ env.BUILD_LOG }} + ${{ env.OUTPUT_DIR }} + retention-days: 14 diff --git a/.gitignore b/.gitignore index 1828222e60..de011b1fca 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ static/ cmake-build-*/ vcpkg_installed/ vc17/ +/build-emscripten-web/ # MSTest test Results [Tt]est[Rr]esult*/ @@ -313,4 +314,4 @@ google-services.json # Android libraries android/app/libs -android/app/src/main/assets \ No newline at end of file +android/app/src/main/assets diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cd5c6c433..41ef451578 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,38 @@ include(MessageColors) include(LoggingHelper) include(GNUInstallDirs) +set(_OTCLIENT_EMSCRIPTEN_COMPILER FALSE) +if(DEFINED CMAKE_CXX_COMPILER) + string(FIND "${CMAKE_CXX_COMPILER}" "em++" _OTCLIENT_EMSCRIPTEN_COMPILER_POS) + if(NOT _OTCLIENT_EMSCRIPTEN_COMPILER_POS EQUAL -1) + set(_OTCLIENT_EMSCRIPTEN_COMPILER TRUE) + endif() +endif() + +if(NOT DEFINED WASM) + if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten" + OR CMAKE_CXX_COMPILER_ID MATCHES "Emscripten" + OR _OTCLIENT_EMSCRIPTEN_COMPILER) + set(WASM ON CACHE BOOL "Build otclient for WebAssembly targets" FORCE) + else() + set(WASM OFF CACHE BOOL "Build otclient for WebAssembly targets" FORCE) + endif() +endif() + +if(WASM) + message(STATUS "WASM: ON") +endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten" + OR CMAKE_CXX_COMPILER_ID MATCHES "Emscripten" + OR _OTCLIENT_EMSCRIPTEN_COMPILER + OR WASM) + set(THREADS_PREFER_PTHREAD_FLAG ON) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -pthread") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pthread") + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -pthread") +endif() + # ***************************************************************************** # Options # ***************************************************************************** diff --git a/Dockerfile.browser b/Dockerfile.browser new file mode 100644 index 0000000000..84a437706e --- /dev/null +++ b/Dockerfile.browser @@ -0,0 +1,72 @@ +# run from the repository root, e.g. +# docker build -t otclient-web -f Dockerfile.browser . +# docker create --name otclient-web-tmp otclient-web:latest +# docker cp otclient-web-tmp:/otclient-web ./build-emscripten-web +# docker rm otclient-web-tmp +FROM ubuntu:24.04 AS builder +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update -y +RUN apt-get -y full-upgrade + +RUN apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + git \ + ninja-build \ + pkg-config \ + python3 \ + python3-pip \ + unzip \ + xz-utils \ + zip \ + && rm -rf /var/lib/apt/lists/* # drop apt caches to save ~100MB in the final image + +SHELL ["/bin/bash", "-lc"] + +WORKDIR /opt +RUN git clone --filter=blob:none --single-branch --depth 1 https://github.com/emscripten-core/emsdk.git emsdk \ + && /opt/emsdk/emsdk install latest \ + && /opt/emsdk/emsdk activate latest + +ENV VCPKG_ROOT=/opt/vcpkg +COPY vcpkg.json /tmp/vcpkg.json +RUN git clone --filter=blob:none https://github.com/microsoft/vcpkg.git ${VCPKG_ROOT} \ + && cd ${VCPKG_ROOT} \ + && git checkout "$(grep '.builtin-baseline' /tmp/vcpkg.json | awk -F: '{print $2}' | tr -d ',\" ')" \ + && ./bootstrap-vcpkg.sh + +WORKDIR /workspace +# NOSONAR: .dockerignore constrains the build context to tracked source assets required for this build stage +COPY . /workspace + +ARG BUILD_TYPE=Release +ENV BUILD_DIR=/workspace/build-emscripten +RUN mkdir -p ${BUILD_DIR} +RUN source /opt/emsdk/emsdk_env.sh \ + && cmake -G Ninja -S /workspace -B ${BUILD_DIR} \ + -DWASM=ON \ + -DVCPKG_CHAINLOAD_TOOLCHAIN_FILE=/opt/emsdk/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake \ + -DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \ + -DVCPKG_TARGET_TRIPLET=wasm32-emscripten \ + -DVCPKG_OVERLAY_PORTS=/workspace/browser/overlay-ports \ + -DVCPKG_BUILD_TYPE=${BUILD_TYPE} \ + -DWASM_USE_WASM_EXCEPTIONS=OFF \ + -DOPTIONS_ENABLE_IPO=OFF \ + -DTOGGLE_BIN_FOLDER=ON \ + -DTOGGLE_BOT_PROTECTION=OFF \ + -DCMAKE_BUILD_TYPE=${BUILD_TYPE} \ + -DCMAKE_CXX_STANDARD=20 \ + -DCMAKE_CXX_FLAGS="-fno-lto -std=c++20 -D_LIBCPP_DISABLE_AVAILABILITY -fexperimental-library" \ + -DCMAKE_EXE_LINKER_FLAGS="-s ALLOW_MEMORY_GROWTH=1 -s USE_PTHREADS=1 -s WASM=1 -s NO_EXIT_RUNTIME=1 -fno-lto" \ + -DCMAKE_CXX_FLAGS_RELEASE="-O2 -DNDEBUG -fno-lto" \ + -DCMAKE_CXX_FLAGS_DEBUG="-O0 -g -fno-lto" \ + -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF \ + && cmake --build ${BUILD_DIR} --target otclient -j"$(nproc)" + +# NOSONAR: minimal BusyBox stage used only to package static artifacts, root default is acceptable +FROM busybox:latest AS web-artifacts +WORKDIR /otclient-web +COPY --from=builder /workspace/build-emscripten/bin/ . +CMD ["sh"] diff --git a/Dockerfile.browser.sh b/Dockerfile.browser.sh new file mode 100644 index 0000000000..663f5560fe --- /dev/null +++ b/Dockerfile.browser.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euxo pipefail + +cd "$(dirname "$0")" +export DOCKER_BUILDKIT=${DOCKER_BUILDKIT:-1} +BUILD_LOG=${BUILD_LOG:-docker-browser-build.log} +OUTPUT_DIR=${OUTPUT_DIR:-build-emscripten-web} +: > "${BUILD_LOG}" +rm -rf "${OUTPUT_DIR}" +docker build --progress=plain -t otclient-web -f Dockerfile.browser . 2>&1 | tee -a "${BUILD_LOG}" +docker rm -f otclient-web-tmp >/dev/null 2>&1 || true +docker create --name otclient-web-tmp otclient-web:latest +docker cp otclient-web-tmp:/otclient-web "${OUTPUT_DIR}" +docker rm otclient-web-tmp diff --git a/browser/shell.html b/browser/shell.html index e12cce9a1f..822ab12e8e 100644 --- a/browser/shell.html +++ b/browser/shell.html @@ -74,6 +74,31 @@ > {{{ SCRIPT }}} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 28cf794177..094e4be88a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,10 +3,6 @@ project(otclient) # ***************************************************************************** # Options # ***************************************************************************** -if(CMAKE_BASE_NAME STREQUAL "em++") - set(WASM ON) - message(STATUS "WASM: ON") -endif() option(TOGGLE_FRAMEWORK_GRAPHICS "Use Graphics " ON) option(TOGGLE_FRAMEWORK_SOUND "Use SOUND " ON) @@ -606,6 +602,17 @@ elseif(ANDROID) ) elseif(WASM) + option(WASM_USE_WASM_EXCEPTIONS "Use native WebAssembly exception handling" ON) + + set(_WASM_LONGJMP_MODE "emscripten") + set(_WASM_EXPORTED_FUNCTIONS "_main,_paste_return") + set(_WASM_CXX_FLAGS "-O3 -ffast-math -frtti -pthread -fexceptions") + if(WASM_USE_WASM_EXCEPTIONS) + set(_WASM_LONGJMP_MODE "wasm") + set(_WASM_EXPORTED_FUNCTIONS "${_WASM_EXPORTED_FUNCTIONS},_emscripten_longjmp") + string(APPEND _WASM_CXX_FLAGS " -fwasm-exceptions") + endif() + target_include_directories(otclient_core PUBLIC ${CMAKE_SOURCE_DIR}/src @@ -662,31 +669,62 @@ elseif(WASM) ) - get_property(linkflags TARGET ${PROJECT_NAME} PROPERTY LINK_FLAGS) - if(CMAKE_BUILD_TYPE STREQUAL "Debug") target_compile_options(otclient_core PRIVATE -Wall -Wextra -Wpedantic ) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g") - set(linkflags "${linkflags} -sASSERTIONS=1") endif() - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -ffast-math -frtti -pthread -fexceptions") - set(linkflags "${linkflags} - -sINVOKE_RUN=0 -sEXIT_RUNTIME=1 -sTEXTDECODER=0 -sCASE_INSENSITIVE_FS=1 - -lopenal -lidbfs.js -lwebsocket.js -sEXPORTED_RUNTIME_METHODS=ccall -sEXPORTED_FUNCTIONS=_main,_paste_return - -sINCOMING_MODULE_JS_API=[locateFile,preRun,postRun,print,printErr,canvas,setStatus,monitorRunDependencies] - -sFORCE_FILESYSTEM -sMALLOC=mimalloc -sWEBSOCKET_SUBPROTOCOL=binary -sOFFSCREENCANVAS_SUPPORT=1 - -sOFFSCREEN_FRAMEBUFFER -sENVIRONMENT=web,worker -sPROXY_TO_PTHREAD -sFULL_ES2=1 - -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 -sUSE_PTHREADS=1 -sFETCH=1 - -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency -sALLOW_MEMORY_GROWTH=1 - --preload-file=../otclientrc.lua@otclientrc.lua --preload-file=../init.lua@init.lua - --preload-file=../data@data --preload-file=../mods@mods --preload-file=../modules@modules - --shell-file=../browser/shell.html --use-preload-cache") - set_target_properties(${PROJECT_NAME} PROPERTIES - LINK_FLAGS ${linkflags}) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${_WASM_CXX_FLAGS}") + + set(WASM_LINK_OPTIONS + -sINVOKE_RUN=0 + -sEXIT_RUNTIME=1 + -sTEXTDECODER=2 + -sCASE_INSENSITIVE_FS=1 + -lopenal + -lidbfs.js + -lwebsocket.js + -sEXPORTED_RUNTIME_METHODS=ccall + "-sEXPORTED_FUNCTIONS=${_WASM_EXPORTED_FUNCTIONS}" + -sINCOMING_MODULE_JS_API=[locateFile,preRun,postRun,print,printErr,canvas,setStatus,monitorRunDependencies] + -sFORCE_FILESYSTEM + -sMALLOC=dlmalloc + "-sSUPPORT_LONGJMP=${_WASM_LONGJMP_MODE}" + -sWEBSOCKET_SUBPROTOCOL=binary + -sOFFSCREENCANVAS_SUPPORT=1 + -sOFFSCREEN_FRAMEBUFFER + -sENVIRONMENT=web,worker + -sPROXY_TO_PTHREAD + -sFULL_ES2=1 + -sMIN_WEBGL_VERSION=2 + -sMAX_WEBGL_VERSION=2 + -sUSE_PTHREADS=1 + -sFETCH=1 + -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency + -sALLOW_MEMORY_GROWTH=1 + --preload-file=../otclientrc.lua@otclientrc.lua + --preload-file=../init.lua@init.lua + --preload-file=../data@data + --preload-file=../mods@mods + --preload-file=../modules@modules + --shell-file=../browser/shell.html + --use-preload-cache + ) + + if(WASM_USE_WASM_EXCEPTIONS) + list(APPEND WASM_LINK_OPTIONS "-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['emscripten_longjmp']") + endif() + + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + list(APPEND WASM_LINK_OPTIONS -sASSERTIONS=1) + endif() + + target_link_options(${PROJECT_NAME} PRIVATE ${WASM_LINK_OPTIONS}) + string(REPLACE ";" " " _WASM_LINK_LOG "${WASM_LINK_OPTIONS}") + message(STATUS "WASM link options: ${_WASM_LINK_LOG}") set(CMAKE_EXECUTABLE_SUFFIX ".html") set(VCPKG_TARGET_TRIPLET "wasm32-emscripten" CACHE STRING "") diff --git a/src/framework/luaengine/luabinder.h b/src/framework/luaengine/luabinder.h index 069cc07d12..fa90242ed8 100644 --- a/src/framework/luaengine/luabinder.h +++ b/src/framework/luaengine/luabinder.h @@ -38,16 +38,44 @@ /// pushes the result to lua. namespace luabinder { + template + struct default_return_value; + template - Ret make_default_return_value() + struct default_return_value>> { - if constexpr (std::is_reference_v) { + static Ret get() + { using ValueType = std::remove_cv_t>; static ValueType value{}; return value; - } else { - return std::decay_t{}; } + }; + + template + struct default_return_value && !std::is_reference_v>> + { + static Ret get() + { + using ValueType = std::remove_cv_t; + return ValueType{}; + } + }; + + template<> + struct default_return_value + { + static void get() {} + }; + + template + Ret make_default_return_value() + { + if constexpr (std::is_void_v) { + default_return_value::get(); + return; + } + return default_return_value::get(); } /// Pack arguments from lua stack into a tuple recursively @@ -184,7 +212,16 @@ namespace luabinder return [=](const std::shared_ptr& obj, const Args&... args) mutable -> Ret { if (!obj) { g_logger.warning("Lua warning: member function call skipped because the passed object is nil"); - return make_default_return_value(); + if constexpr (!std::is_void_v) { + return make_default_return_value(); + } else { + return; + } + } + + if constexpr (std::is_void_v) { + mf(obj.get(), args...); + return; } return mf(obj.get(), args...); }; @@ -207,13 +244,19 @@ namespace luabinder std::function make_mem_func_singleton(Ret(C::* f)(Args...), C* instance) { auto mf = std::mem_fn(f); - return [=](Args... args) mutable -> Ret { return mf(instance, args...); }; + return [mf, instance](Args... args) -> Ret { + if constexpr (std::is_void_v) { + mf(instance, args...); + return; + } + return mf(instance, args...); + }; } template std::function make_mem_func_singleton(void (C::* f)(Args...), C* instance) { auto mf = std::mem_fn(f); - return [=](Args... args) mutable { mf(instance, args...); }; + return [mf, instance](Args... args) { mf(instance, args...); }; } /// Bind member functions @@ -259,10 +302,10 @@ namespace luabinder return [=](const std::shared_ptr& obj, const Args&... args) mutable -> Ret { if (!obj) { g_logger.warning("Lua warning: member function call skipped because the passed object is nil"); - if constexpr (std::is_void_v) { - return; - } else { + if constexpr (!std::is_void_v) { return make_default_return_value(); + } else { + return; } } if constexpr (std::is_void_v) { @@ -302,7 +345,7 @@ namespace luabinder make_mem_func_singleton(Ret (C::*f)(Args...) const, C* instance) { auto mf = std::mem_fn(f); - return [=](Args... args) mutable -> Ret { + return [mf, instance](Args... args) -> Ret { if constexpr (std::is_void_v) { mf(instance, args...); return; @@ -316,7 +359,7 @@ namespace luabinder make_mem_func_singleton(void (C::*f)(Args...) const, C* instance) { auto mf = std::mem_fn(f); - return [=](Args... args) mutable { mf(instance, args...); }; + return [mf, instance](Args... args) { mf(instance, args...); }; } template diff --git a/src/framework/platform/platform.h b/src/framework/platform/platform.h index 978b6b76d3..02f803b85f 100644 --- a/src/framework/platform/platform.h +++ b/src/framework/platform/platform.h @@ -60,7 +60,7 @@ class Platform return m_device.type == Mobile; #else return MAIN_THREAD_EM_ASM_INT({ - return (/ iphone | ipod | ipad | android / i).test(navigator.userAgent); + return (/iphone|ipod|ipad|android/i).test(navigator.userAgent); }) == 1; #endif } diff --git a/src/framework/stdext/string.cpp b/src/framework/stdext/string.cpp index 42f57db458..f8f32b112b 100644 --- a/src/framework/stdext/string.cpp +++ b/src/framework/stdext/string.cpp @@ -24,6 +24,7 @@ #include "exception.h" #include "types.h" +#include #include #include @@ -322,4 +323,4 @@ namespace stdext return out; } -} \ No newline at end of file +} diff --git a/tools/emscripten-web-serve.py b/tools/emscripten-web-serve.py new file mode 100644 index 0000000000..8903547916 --- /dev/null +++ b/tools/emscripten-web-serve.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +Serve the generated WebAssembly bundle with the isolation headers that Emscripten's +pthread builds require (COOP/COEP/CORP). Example: + + python3 tools/emscripten-web-serve.py --root build-emscripten-web --port 8000 +""" + +from __future__ import annotations + +import argparse +import errno +import http.server +import os +import re +import shutil +import socket +import socketserver +import sys +from pathlib import Path +from subprocess import CalledProcessError, check_output + +DEFAULT_HEADERS = { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Resource-Policy": "same-origin", + "Content-Type": "application/wasm", +} + + +class HeaderInjectorHandler(http.server.SimpleHTTPRequestHandler): + extra_headers: dict[str, str] = {} + default_origin: str | None = None + + def _emit_extra_headers(self) -> None: + for key, value in self.extra_headers.items(): + if key.lower() == "content-type": + continue # normal flow decides actual mime type per file + self.send_header(key, value) + allow_origin = self._determine_origin() + if allow_origin: + self.send_header("Access-Control-Allow-Origin", allow_origin) + self.send_header("Vary", "Origin, Host") + + def end_headers(self) -> None: # type: ignore[override] + self._emit_extra_headers() + super().end_headers() + + def guess_type(self, path: str) -> str: + ctype = super().guess_type(path) + if path.endswith(".wasm"): + return DEFAULT_HEADERS["Content-Type"] + return ctype + + def _determine_origin(self) -> str | None: + origin = self.headers.get("Origin") + if origin: + return origin + host_header = self.headers.get("Host") + if host_header: + scheme = "https" if self.server.server_address[1] == 443 else "http" + return f"{scheme}://{host_header}" + return self.default_origin + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Serve the browser build with COOP/COEP/CORP headers." + ) + repo_root = Path(__file__).resolve().parents[1] + default_root = repo_root / "build-emscripten-web" + parser.add_argument( + "--root", + default=str(default_root), + help=f"Directory to serve (default: {default_root})", + ) + parser.add_argument("--port", type=int, default=8080, help="Port to bind (default: 8080)") + parser.add_argument("--bind", default="0.0.0.0", help="Interface/IP to bind (default: 0.0.0.0)") + return parser.parse_args() + + +def identify_port_holders(port: int) -> str | None: + holders: list[str] = [] + lsof_path = shutil.which("lsof") + if lsof_path: + try: + output = check_output( + [lsof_path, "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"], text=True + ).strip().splitlines() + except CalledProcessError: + output = [] + if len(output) > 1: + for line in output[1:]: + parts = line.split() + if len(parts) >= 2: + holders.append(f"pid {parts[1]} ({parts[0]})") + if holders: + return ", ".join(holders) + + ss_path = shutil.which("ss") + if ss_path: + try: + output = check_output([ss_path, "-H", "-ltnp"], text=True) + except CalledProcessError: + output = "" + if output: + port_pattern = re.compile(rf':{port}\b') + proc_pattern = re.compile(r'"([^"]+)",pid=(\d+)') + for line in output.splitlines(): + if not port_pattern.search(line): + continue + match = proc_pattern.search(line) + if match: + holders.append(f"pid {match.group(2)} ({match.group(1)})") + if holders: + # remove duplicates while preserving order + return ", ".join(dict.fromkeys(holders)) + + fuser_path = shutil.which("fuser") + if fuser_path: + try: + output = check_output([fuser_path, "-n", "tcp", str(port)], text=True).strip() + except CalledProcessError as err: + output = err.output.strip() if err.output else "" + if output: + pids = [token for token in output.split() if token.isdigit()] + if pids: + return ", ".join(f"pid {pid}" for pid in pids) + + return None + + +def discover_addresses(port: int) -> list[str]: + addrs: set[str] = {"127.0.0.1"} + + def add_addr(candidate: str | None) -> None: + if candidate and candidate != "0.0.0.0": + addrs.add(candidate) + + hostname_variants = {socket.gethostname(), socket.getfqdn()} + for name in hostname_variants: + try: + host_ips = socket.gethostbyname_ex(name)[2] + for ip in host_ips: + add_addr(ip) + except socket.gaierror: + continue + + try: + for info in socket.getaddrinfo(None, 0, socket.AF_INET, socket.SOCK_STREAM): + add_addr(info[4][0]) + except socket.gaierror: + pass + + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) # NOSONAR: harmless probe to discover outbound interface + add_addr(s.getsockname()[0]) + except OSError: + pass + + try: + output = check_output(["hostname", "-I"], text=True).strip() + for ip in output.split(): + add_addr(ip) + except (CalledProcessError, FileNotFoundError): + pass + + return [ + f"http://{addr}:{port}/otclient.html" # NOSONAR: dev-only HTTP endpoint for local testing + for addr in sorted(addrs) + ] + + +def main() -> None: + args = parse_args() + serve_root = Path(args.root).expanduser().resolve() + if not serve_root.exists(): + print(f"[error] Serve root '{serve_root}' does not exist.", file=sys.stderr) + sys.exit(1) + + host_origin = ( + f"http://{args.bind}:{args.port}" # NOSONAR: local-only origin for debugging server + if args.bind != "0.0.0.0" + else None + ) + + HeaderInjectorHandler.extra_headers = { + key: value for key, value in DEFAULT_HEADERS.items() if key != "Content-Type" + } + HeaderInjectorHandler.default_origin = host_origin + + os.chdir(serve_root) + try: + httpd = socketserver.TCPServer((args.bind, args.port), HeaderInjectorHandler) + except OSError as exc: + if exc.errno == errno.EADDRINUSE: + print(f"[error] Port {args.port} is already in use.", file=sys.stderr) + holder_info = identify_port_holders(args.port) + if holder_info: + print(f"[hint] Held by {holder_info}.", file=sys.stderr) + print("[hint] Stop that process or pick another --port.", file=sys.stderr) + sys.exit(1) + raise + + with httpd: + urls = discover_addresses(args.port) if args.bind == "0.0.0.0" else [ + f"http://{args.bind}:{args.port}/otclient.html" # NOSONAR: debugging server not exposed publicly + ] + print(f"Serving {serve_root} with COOP/COEP headers (Ctrl+C to stop)") + for url in urls: + print(f" {url}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nShutting down...") + + +if __name__ == "__main__": + main()