From ea60690696c4ff94af2909ebdae8fccf3c65fb09 Mon Sep 17 00:00:00 2001 From: keithlostracco Date: Tue, 8 Sep 2026 10:52:02 -0700 Subject: [PATCH 1/2] Fix the macOS release plugins failing to load The v0.4.0 macOS plugins were reported by TouchDesigner as corrupted. The release runner has no TouchDesigner, so CMake fell back to the runner's Homebrew Python 3.14 and linked the plugins against /opt/homebrew/opt/python@3.14/..., a path that exists on no user's Mac and is the wrong ABI for the 3.11 TouchDesigner embeds. The binaries also inherited the runner's macOS 26 as their minimum OS and arm64 as their only architecture, and carried only the linker's partial ad-hoc signature. The plugins now link no Python library at all: symbols resolve from the host process at load time (-undefined dynamic_lookup), as for any CPython extension. Only the 3.11 headers are needed, taken from TouchDesigner when it is installed and from any CPython 3.11 (EXACT) otherwise. Deployment target is 13.3 (std::format on floats needs libc++ from 13.3), architectures are arm64 and x86_64, and the bundle is ad-hoc signed as a whole post-build so codesign accepts it. CI and the release gate now verify all four properties. Gatekeeper still refuses a quarantined copy without notarization, so the README's install steps now include clearing the quarantine flag. Verified locally: both the TouchDesigner-headers build and the no-TouchDesigner fallback produce identical universal plugins that pass the 418-check TouchDesigner integration suite once the flag is cleared. Also makes run_td_tests.sh executable and bumps the version to 0.4.1. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 32 +++++++++++ .github/workflows/release.yml | 32 +++++++++++ CHANGELOG.md | 19 +++++++ CMakeLists.txt | 101 +++++++++++++++++++++------------- README.md | 23 ++++++-- run_td_tests.sh | 0 tests/python/CMakeLists.txt | 3 + 7 files changed, 166 insertions(+), 44 deletions(-) mode change 100644 => 100755 run_td_tests.sh 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..489da08 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 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) From 319dcbbd7b22675f3cedccdeacf0e40be6da5fd3 Mon Sep 17 00:00:00 2001 From: keithlostracco Date: Tue, 8 Sep 2026 11:09:08 -0700 Subject: [PATCH 2/2] release: name dry-run archives after the commit, not the branch A workflow_dispatch run from a branch whose name contains a slash (such as fix/macos-release-plugin-loading) made the stage name a nested path, so the zip landed in a subdirectory the upload glob never matched and both platforms failed at the upload step. Tags are unaffected; branch runs now use dev-. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 489da08..7e8ea03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,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"