diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b44b2c0..6343859 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,38 @@ jobs: - name: Build run: cmake --build build --config ${{ matrix.build_type }} + # The macOS plugin loads inside TouchDesigner on users' machines, so nothing + # about this runner may leak into it: no link against a Python that only + # exists here (v0.4.0 shipped linked to Homebrew's 3.14 and failed to load + # on every Mac), a minimum OS TouchDesigner supports, both architectures, + # and a bundle signature codesign accepts. + - name: Verify macOS plugins + if: runner.os == 'macOS' + run: | + status=0 + for plugin in build/bin/AnimationCHOP.plugin build/bin/AnimationViewCHOP.plugin; do + bin="$plugin/Contents/MacOS/$(basename "${plugin%.plugin}")" + echo "== $plugin" + otool -L "$bin" + if otool -L "$bin" | grep -qi python; then + echo "::error::$plugin links a Python library; it must resolve Python from the host process." >&2; status=1 + fi + archs=$(lipo -archs "$bin") + echo "archs: $archs" + case "$archs" in *arm64*x86_64*|*x86_64*arm64*) ;; *) + echo "::error::$plugin is not a universal binary (got: $archs)." >&2; status=1 ;; + esac + minos=$(otool -l "$bin" | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print $2; exit}') + echo "minos: $minos" + if [ "$minos" != "13.3" ]; then + echo "::error::$plugin minimum macOS is $minos, expected 13.3." >&2; status=1 + fi + if ! codesign --verify --deep --strict -vv "$plugin"; then + echo "::error::$plugin has no valid bundle signature." >&2; status=1 + fi + done + exit $status + - name: Test run: ctest --test-dir build --build-config ${{ matrix.build_type }} --output-on-failure diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35b9c18..7e8ea03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,38 @@ jobs: - name: Build run: cmake --build build --config Release + # The macOS plugin loads inside TouchDesigner on users' machines, so nothing + # about this runner may leak into it: no link against a Python that only + # exists here (v0.4.0 shipped linked to Homebrew's 3.14 and failed to load + # on every Mac), a minimum OS TouchDesigner supports, both architectures, + # and a bundle signature codesign accepts. + - name: Verify macOS plugins + if: runner.os == 'macOS' + run: | + status=0 + for plugin in build/bin/AnimationCHOP.plugin build/bin/AnimationViewCHOP.plugin; do + bin="$plugin/Contents/MacOS/$(basename "${plugin%.plugin}")" + echo "== $plugin" + otool -L "$bin" + if otool -L "$bin" | grep -qi python; then + echo "::error::$plugin links a Python library; it must resolve Python from the host process." >&2; status=1 + fi + archs=$(lipo -archs "$bin") + echo "archs: $archs" + case "$archs" in *arm64*x86_64*|*x86_64*arm64*) ;; *) + echo "::error::$plugin is not a universal binary (got: $archs)." >&2; status=1 ;; + esac + minos=$(otool -l "$bin" | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print $2; exit}') + echo "minos: $minos" + if [ "$minos" != "13.3" ]; then + echo "::error::$plugin minimum macOS is $minos, expected 13.3." >&2; status=1 + fi + if ! codesign --verify --deep --strict -vv "$plugin"; then + echo "::error::$plugin has no valid bundle signature." >&2; status=1 + fi + done + exit $status + # Don't publish a release that fails its own tests. - name: Test run: ctest --test-dir build --build-config Release --output-on-failure @@ -66,7 +98,15 @@ jobs: - name: Package shell: bash run: | - version="${GITHUB_REF_NAME:-dev}" + # The archive is named after the tag. A workflow_dispatch dry run from + # a branch has no tag, and a branch name can contain a slash, which + # would turn the stage name into a nested directory the upload glob + # never sees -- so those are named after the commit instead. + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + version="${GITHUB_REF_NAME}" + else + version="dev-${GITHUB_SHA::7}" + fi stage="AnimationCHOP-${version}-${{ matrix.platform }}" mkdir -p "$stage/Plugins" "$stage/modules" diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a3b99..dce0ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the major version is `0`, breaking changes may land in a minor release. +## [0.4.1] - 2026-09-08 + +### Fixed + +- The macOS release did not load: TouchDesigner reported the plugins as + corrupted. They were built on a runner without TouchDesigner, so CMake fell + back to the runner's Homebrew Python 3.14 and linked against a path that + exists on no user's Mac; they also required macOS 26 and Apple silicon, and + carried only the linker's partial ad-hoc signature, which `codesign` rejects + for the bundle. The plugins now link no Python library at all (the symbols + resolve from TouchDesigner's own interpreter at load time, as for any + CPython extension), build universal with a minimum of macOS 13.3, and are + ad-hoc signed as complete bundles. The release workflow verifies all four + properties before it will publish. +- The plugins are not notarized, so macOS still refuses a copy that carries the + browser's quarantine flag, with the same "corrupted" report. The README's + installation steps now include clearing the flag. +- `run_td_tests.sh` is executable. + ## [0.4.0] - 2026-07-26 First release prepared for the public repository. diff --git a/CMakeLists.txt b/CMakeLists.txt index 187a662..347bbbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,17 @@ cmake_minimum_required(VERSION 3.14) + +# macOS: TouchDesigner 2025 runs on macOS 13 and later, on Apple silicon and +# Intel. Without these the plugin inherits the build machine's OS release as its +# minimum and its CPU as its only architecture -- the CI runner is macOS 26 on +# arm64, so v0.4.0 would only ever have loaded on a Mac newer than most users'. +# 13.3 rather than 13.0 because the bindings format floats with std::format, +# which libc++ only provides from 13.3. They must be set before project(), and +# are ignored on other platforms. +set(CMAKE_OSX_DEPLOYMENT_TARGET "13.3" CACHE STRING "Minimum macOS version the plugins load on") +set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64" CACHE STRING "macOS architectures to build the plugins for") + # Stays on 0.x until the API is stable; see CHANGELOG.md. -project(AnimationCHOP VERSION 0.4.0 LANGUAGES CXX) +project(AnimationCHOP VERSION 0.4.1 LANGUAGES CXX) set(VERBOSE_STATUS ON CACHE BOOL "Verbose status messages" FORCE) set(SUPPRESS_NOT_REFERENCED_WARNINGS ON CACHE BOOL "Suppress not referenced warnings" FORCE) @@ -90,45 +101,52 @@ if(WIN32) ) endforeach() elseif(APPLE) - # On macOS, use TouchDesigner's Python specifically - set(TD_PYTHON_EXECUTABLE "/Applications/TouchDesigner.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3.11") - set(TD_PYTHON_INCLUDE_DIR "/Applications/TouchDesigner.app/Contents/Frameworks/Python.framework/Versions/Current/include/python3.11") - set(TD_PYTHON_LIBRARY_DIR "/Applications/TouchDesigner.app/Contents/Frameworks/Python.framework/Versions/Current/lib") - - # Check if TouchDesigner's Python exists, otherwise fall back to system Python - if(EXISTS "${TD_PYTHON_EXECUTABLE}" AND EXISTS "${TD_PYTHON_INCLUDE_DIR}") - message(STATUS "Using TouchDesigner's Python: ${TD_PYTHON_EXECUTABLE}") - - # Force CMake to use TouchDesigner's Python by setting the paths explicitly - set(Python3_EXECUTABLE "${TD_PYTHON_EXECUTABLE}" CACHE FILEPATH "Path to Python3 executable" FORCE) - set(Python3_INCLUDE_DIR "${TD_PYTHON_INCLUDE_DIR}" CACHE PATH "Path to Python3 include directory" FORCE) - set(Python3_LIBRARY_DIRS "${TD_PYTHON_LIBRARY_DIR}" CACHE PATH "Path to Python3 library directory" FORCE) - - # Set the find strategy to prioritize our specified paths - set(Python3_FIND_STRATEGY LOCATION) - set(Python3_FIND_REGISTRY NEVER) - set(Python3_FIND_FRAMEWORK NEVER) - - find_package(Python3 COMPONENTS Interpreter Development REQUIRED) - - foreach(TARGET animation_chop animation_view_chop) - target_link_libraries(${TARGET} PRIVATE - Python3::Python - anim - ) - target_compile_definitions(${TARGET} PRIVATE __cdecl=) - endforeach() + # TouchDesigner embeds CPython 3.11 and loads the plugin into its own + # process, so the plugin must not link a libpython of its own: whatever it + # linked would have to exist, at that exact path, on every user's Mac. + # (v0.4.0 was built on a runner without TouchDesigner, linked the runner's + # Homebrew Python 3.14, and failed to load everywhere.) Instead, as every + # CPython extension module does, leave the Python symbols undefined and let + # dyld resolve them from the host process at load time. All the build needs + # from Python is the 3.11 headers. + # + # TouchDesigner's own copy is preferred, because it is exactly the ABI the + # plugin runs against. Without a TouchDesigner install (CI), any CPython + # 3.11 supplies identical headers. EXACT matters: FindPython otherwise + # returns the newest interpreter it can find, and headers from another + # minor version would silently build against the wrong ABI. + set(ANIMATIONCHOP_TD_APP "/Applications/TouchDesigner.app" + CACHE PATH "TouchDesigner install to take the Python 3.11 headers from") + set(TD_PYTHON_INCLUDE_DIR + "${ANIMATIONCHOP_TD_APP}/Contents/Frameworks/Python.framework/Versions/3.11/include/python3.11") + + if(EXISTS "${TD_PYTHON_INCLUDE_DIR}/Python.h") + message(STATUS "Using TouchDesigner's Python 3.11 headers: ${TD_PYTHON_INCLUDE_DIR}") + set(ANIMATIONCHOP_MACOS_PYTHON_INCLUDE_DIRS "${TD_PYTHON_INCLUDE_DIR}") else() - message(WARNING "TouchDesigner's Python not found at ${TD_PYTHON_EXECUTABLE}, falling back to system Python") - find_package(Python3 COMPONENTS Interpreter Development REQUIRED) - foreach(TARGET animation_chop animation_view_chop) - target_link_libraries(${TARGET} PRIVATE - Python3::Python - anim - ) - target_compile_definitions(${TARGET} PRIVATE __cdecl=) - endforeach() + set(Python3_FIND_FRAMEWORK LAST) + find_package(Python3 3.11 EXACT COMPONENTS Interpreter Development.Module REQUIRED) + message(STATUS "TouchDesigner not found at ${ANIMATIONCHOP_TD_APP}; " + "using Python ${Python3_VERSION} headers: ${Python3_INCLUDE_DIRS}") + set(ANIMATIONCHOP_MACOS_PYTHON_INCLUDE_DIRS ${Python3_INCLUDE_DIRS}) endif() + + foreach(TARGET animation_chop animation_view_chop) + target_include_directories(${TARGET} PRIVATE ${ANIMATIONCHOP_MACOS_PYTHON_INCLUDE_DIRS}) + target_link_libraries(${TARGET} PRIVATE anim) + target_link_options(${TARGET} PRIVATE "LINKER:-undefined,dynamic_lookup") + target_compile_definitions(${TARGET} PRIVATE __cdecl=) + + # The linker's implicit ad-hoc signature covers only the Mach-O, not the + # bundle, so `codesign --verify` rejects the .plugin and Gatekeeper + # reports a downloaded copy as damaged. Sign the whole bundle (still + # ad-hoc: no certificate needed). Runs before the copies below, which + # are added later in this file, so every copy carries the signature. + add_custom_command(TARGET ${TARGET} POST_BUILD + COMMAND codesign --force --sign - "$" + COMMENT "Ad-hoc signing $.plugin" + ) + endforeach() else() # On other platforms, use pkg-config to find Python find_package(PkgConfig REQUIRED) @@ -283,9 +301,14 @@ if(ANIMATIONCHOP_BUILD_TESTS) # Windows builds against the vendored headers/import library, matching # the operator targets above. set(ANIMATIONCHOP_PYTHON_INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/ext/Python/Include) + elseif(APPLE) + # Same headers and undefined-symbol linking as the operators above; the + # importing interpreter supplies the symbols. + set(ANIMATIONCHOP_PYEXT_SUFFIX ".so") + set(ANIMATIONCHOP_PYTHON_INCLUDE_DIRS ${ANIMATIONCHOP_MACOS_PYTHON_INCLUDE_DIRS}) else() set(ANIMATIONCHOP_PYEXT_SUFFIX ".so") - find_package(Python3 3.11 COMPONENTS Development.Module REQUIRED) + find_package(Python3 3.11 EXACT COMPONENTS Development.Module REQUIRED) set(ANIMATIONCHOP_PYTHON_INCLUDE_DIRS ${Python3_INCLUDE_DIRS}) set(ANIMATIONCHOP_PYTHON_LIBRARIES Python3::Module) endif() diff --git a/README.md b/README.md index 40dd9c8..af35592 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ it. operators. That build is where the Custom Operator API gained node data persistence (`saveData`/`loadData`), which is how animations are stored in the `.toe`; the operators target CHOP API version 10 / common API version 2. -- **Windows or macOS.** Windows builds are x64. +- **Windows or macOS.** Windows builds are x64. macOS builds are universal and + need macOS 13.3 or newer. ## Installation @@ -62,9 +63,17 @@ it. TouchDesigner only loads Custom Operators from a `Plugins` folder next to the project file, or from the system-wide plugin folder. -3. Open the project. The first load of a new operator build shows a prompt +3. **macOS only:** clear the quarantine flag the browser put on the download, + or macOS refuses to load the plugins and TouchDesigner reports them as + corrupted. The plugins are not notarized with Apple, so this is a one-time + step per download. In Terminal, from the folder holding `Plugins`: + + ```bash + xattr -dr com.apple.quarantine Plugins + ``` +4. Open the project. The first load of a new operator build shows a prompt asking you to trust it — approve it once. -4. Add an **AnimationCHOP** from the operator palette, or drop in the included +5. Add an **AnimationCHOP** from the operator palette, or drop in the included `Keyframer.tox` for the full editor. The release archive also contains an example project you can open directly. @@ -137,8 +146,12 @@ Either way the built operators are copied into `td/Plugins/` so the example project picks them up. Windows vendors the CPython 3.11 headers and import libraries it needs, so there -is nothing to install. macOS builds against TouchDesigner's own Python framework -and expects TouchDesigner in `/Applications`. +is nothing to install. macOS compiles against the Python 3.11 headers inside +TouchDesigner when it is in `/Applications` (pass +`-DANIMATIONCHOP_TD_APP=` for another location), and otherwise against any +CPython 3.11 it can find; the plugins never link a Python library, taking the +symbols from TouchDesigner's own interpreter at load time. Builds are universal +(Apple silicon and Intel) with a minimum of macOS 13.3. ## Testing diff --git a/run_td_tests.sh b/run_td_tests.sh old mode 100644 new mode 100755 diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index 6f46798..47b41cd 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -44,6 +44,9 @@ else() # Extension modules leave Python's symbols undefined; the host interpreter # supplies them at import. target_link_libraries(animationchop_testext PRIVATE ${ANIMATIONCHOP_PYTHON_LIBRARIES}) + if(APPLE) + target_link_options(animationchop_testext PRIVATE "LINKER:-undefined,dynamic_lookup") + endif() target_compile_definitions(animationchop_testext PRIVATE __cdecl=) target_compile_options(animationchop_testext PRIVATE -Wno-unused-parameter -Wno-unused-variable)