本文へ移動
cccskills
無料GitHub で公開

ios

iOS platform development for Pulp — iPhone/iPad AUv3 app extensions, iOS Simulator builds, UIKit window host, CoreAudio IO audio, touch & Apple Pencil input, XcodeBuildMCP automation. Covers configure, build, deploy to simulator/device, and the gotchas discovered during iOS bringup.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md69.9 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

iOS Skill

Build, deploy, and debug Pulp on iOS — both standalone AUv3 host apps and AUv3 app extensions. This skill captures iOS-specific architecture, simulator workflows, AUv3 packaging, and GPU host gotchas.

Architecture Overview

Swift / UIKit (iOS UI)        C++ (Pulp core)
├── HostApp (Xcode target)    ├── libpulp-format.a
│   └── AVAudioEngine         │   └── PulpAudioUnit (AUv3)
├── AUv3 App Extension        │
│   ├── Info.plist/NSExtension│   ├── core/view/platform/ios/
│   └── MetalView (optional)  │   │   ├── window_host_ios.mm (UIView)
└── CoreAudio IO              │   │   ├── plugin_view_host_ios.mm
                              │   │   └── accessibility_ios.mm
                              │   └── core/platform (UIKit stubs)

Key Files

FilePurpose
core/view/platform/ios/window_host_ios.mmUIView root, touch/pencil dispatch → View::on_mouse_*
core/view/platform/ios/plugin_view_host_ios.mmAUv3 editor UIView
core/view/platform/ios/accessibility_ios.mmUIAccessibility bridge
core/format/src/au_view_controller_ios.mmAUv3 NSExtensionPrincipalClass
core/platform/CMakeLists.txt (IOS branch)UIKit link, omit Cocoa/fork-exec
tools/cmake/PulpUtils.cmake (PULP_IOS blocks)AUv3 bundle, Info.plist generation, pulp_add_ios_auv3() helper
templates/ios-auv3/Info.plist.inAUv3 extension manifest template

Configure & Build

iOS Simulator (arm64)

# Xcode generator is required — the bundled tools/cmake/ios.toolchain.cmake
# has a recursive enable_language bug with Makefile/Ninja generators.

cmake -S . -B build-ios-sim -G Xcode \
  -DCMAKE_SYSTEM_NAME=iOS \
  -DCMAKE_OSX_SYSROOT=iphonesimulator \
  -DCMAKE_OSX_ARCHITECTURES=arm64 \
  -DCMAKE_OSX_DEPLOYMENT_TARGET=16.4 \
  -DPULP_ENABLE_GPU=OFF \
  -DPULP_BUILD_TESTS=OFF \
  -DPULP_BUILD_EXAMPLES=OFF

# Build a target
xcodebuild -project build-ios-sim/Pulp.xcodeproj \
  -target pulp-format -configuration Debug \
  -sdk iphonesimulator -arch arm64 \
  IPHONEOS_DEPLOYMENT_TARGET=16.4 build

iOS Device (arm64)

cmake -S . -B build-ios \
  -G Xcode \
  -DCMAKE_SYSTEM_NAME=iOS \
  -DCMAKE_OSX_SYSROOT=iphoneos \
  -DCMAKE_OSX_ARCHITECTURES=arm64 \
  -DCMAKE_OSX_DEPLOYMENT_TARGET=16.4 \
  -DCMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY="Apple Development" \
  -DCMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM=<TEAM_ID>

Deploying to the Simulator

Pulp standardizes on XcodeBuildMCP when available (fast, structured output). Falls back to raw xcodebuild + xcrun simctl.

With XcodeBuildMCP (preferred)

Check availability: if the mcp__XcodeBuildMCP__* tools are exposed in the session, use them.

session_show_defaults         # inspect current project/scheme/sim
list_sims                     # enumerate booted/shutdown simulators
build_sim                     # build for selected simulator
install_app_sim               # install .app
launch_app_sim                # launch + stream logs
screenshot                    # capture for visual regression

Preferred simulator: iPhone 17 Pro on iOS-26 runtime for most testing (fastest on Apple Silicon). Use iPad Pro 13-inch for layout validation.

Without XcodeBuildMCP (fallback)

# List simulators
xcrun simctl list devices available

# Boot + install + launch
xcrun simctl boot "iPhone 17 Pro"
xcrun simctl install booted path/to/MyApp.app
xcrun simctl launch --console booted com.example.MyApp

Policy: assume XcodeBuildMCP when the user has it configured; do not hard-require or auto-install it. Leave install to user preference.

Audio etiquette for Sim launches: simctl launch --console … opens a virtual coreaudio device that routes through the host Mac's coreaudiod — any non-muted audio path the app exercises plays out the host's speakers. Per CLAUDE.md → Working with AI Tools → Local-dev audio etiquette, announce before launching ("heads up — about to launch the Sim, audio may be active for ~30s"), cap the verify duration, and simctl terminate + simctl shutdown when done. Tracked as issue #3173.

AUv3 App Extension

macOS AU v3 is architecturally different — see the auv3 skill. The macOS path (Phase 3.5) uses framework + stub .appex + container .app; iOS stays monolithic. The threading hard guard (XPC queue → dispatch_async(main) before any UIKit access) applies to BOTH platforms, and is implemented in core/format/src/au_view_controller_ios.mm for iOS. The lifecycle fix (createAudioUnitWithComponentDescription: must actually instantiate PulpAudioUnit, then rebuildEditorIfReady shared between viewDidLoad and setAudioUnit:) also applies here. Full recipe + diagnostics live in .agents/skills/auv3/SKILL.md → "macOS AU v3 packaging".

An AUv3 plugin on iOS ships as an App Extension bundled inside a host app (App Store requires a host container). Both targets must be in the same Xcode project.

Bundle-id containment is enforced at configure time. The AUv3 extension's bundle id (pulp_add_ios_auv3(... BUNDLE_ID ...)) must be the host app's bundle id (pulp_add_ios_host_app(... BUNDLE_ID ...)) plus at least one extra dot-component — e.g. host com.example.host, extension com.example.host.MySynth. A sibling id, or an id equal to the host's, builds fine but fails late at xcrun simctl install with IXErrorDomain code=2 / Mismatched bundle IDs. pulp_add_ios_host_app now FATAL_ERRORs at configure (PulpIosHostApp.cmake, "must be nested under") so the mistake surfaces immediately instead of after a multi-minute iOS build. Regression test: test/cmake/test_ios_hostapp_bundle_guard.sh.

Minimal structure:

examples/ios-auv3-synth/
├── HostApp/
│   ├── HostApp.entitlements
│   ├── Info.plist
│   ├── ContentView.swift          # simple SwiftUI AUv3 host
│   └── Assets.xcassets
├── AUv3Extension/
│   ├── Info.plist                 # NSExtension / audiocomponents
│   ├── AUv3Extension.entitlements
│   └── AudioUnitViewController.mm # wraps PulpAudioUnit
└── CMakeLists.txt                 # uses pulp_add_ios_auv3()

Required Info.plist keys (extension)

<key>NSExtension</key>
<dict>
  <key>NSExtensionAttributes</key>
  <dict>
    <key>AudioComponents</key>
    <array>
      <dict>
        <key>description</key><string>MySynth</string>
        <key>manufacturer</key><string>EXMP</string>
        <key>name</key><string>Example: MySynth</string>
        <key>sandboxSafe</key><true/>
        <key>subtype</key><string>mySy</string>
        <key>tags</key><array><string>Synth</string></array>
        <key>type</key><string>aumu</string>
        <key>version</key><integer>0x00010000</integer>
      </dict>
    </array>
  </dict>
  <key>NSExtensionPointIdentifier</key>
  <string>com.apple.AudioUnit-UI</string>
  <key>NSExtensionPrincipalClass</key>
  <string>AudioUnitViewController</string>
</dict>

Required HostApp entitlement: inter-app-audio

On iOS 11+ real device, AVAudioUnitComponentManager.components(matching:) returns an empty list when the host app lacks inter-app-audio — your AUv3 appears invisible even though pkd indexed it correctly. The iOS Simulator does NOT enforce this, so the gap is silent until you test on hardware.

The fix lives in two places:

  1. One-time portal action (covers every future Pulp example forever): Apple Developer portal → Identifiers → the com.<you>.pulpdev.* wildcard App ID → Edit → tick Inter-App Audio → Save. IAA is one of the few capabilities Apple allows on a wildcard. Xcode auto-fetches the regenerated profile on next build. See docs/guides/ios-dev-signing.md.
  2. HostApp entitlements file (already wired in templates/ios-auv3/HostApp/Entitlements.plist.in):
    <key>inter-app-audio</key>
    <true/>
    
    pulp_add_ios_host_app() configures this into each HostApp's ${target}.entitlements and sets CODE_SIGN_ENTITLEMENTS.

Verify after build:

codesign -d --entitlements :- path/to/HostApp.app | plutil -p -
# Expected: "inter-app-audio" => 1

Apple deprecated IAA in iOS 13 (no new IAA-only plug-ins on the Store), but the entitlement still gates AUv3 host scanning. Do not strip it from host apps.

Validation

# On device (AUv3 must be installed from App Store or sideload)
auval -v aumu mySy EXMP        # type / subtype / manufacturer

# Simulator sanity (compile + load, no audio render)
xcodebuild test -project ... -scheme AUv3Tests -sdk iphonesimulator

Gotchas (hard-won)

When the iOS compile gate runs

  • Per PR and merge group, only for iOS-only surfaces. ios_surface in tools/scripts/classify_changes.py selects it: apple/**, examples/ios-*, templates/ios-*, the AUv3 templates, */platform/ios/** and *_ios.* sources, any source containing TARGET_OS_IPHONE / TARGET_OS_IOS / TARGET_OS_SIMULATOR / PULP_IOS / UIKit, every non-test CMake file, the dependency pins, and the gate's own wiring. A change to shared core code does not run it, because the macOS job compiles that code for macOS.
  • So an iOS break in shared-looking code can land. The full gate runs nightly on main (ios-compile-gate-nightly.yml, one tracking issue titled "Nightly iOS compile gate is broken on main") and on every release tag (release-cli.yml ios-compile-gate, required for publish). If you add iOS-only code to a file, guard it with TARGET_OS_IPHONE (or put it under platform/ios/) so the per-PR selector sees it.
  • Run it yourself before landing iOS-affecting work: bash test/cmake/test_ios_compile_gate.sh "$PWD" "$PWD/build-ios", or ghapp workflow run ios-compile-gate-nightly.yml --ref <branch>.

Platform

  • Deployment target floor is 16.3; recipes use 16.4 — iPhoneSimulator SDK 26.x marks std::to_chars (<format>/<charconv> floating-point) as available only on iOS 16.3+. Anything lower will fail with cryptic errors inside __format/formatter_floating_point.h.
  • No Cocoa.h — mac platform files (clipboard_mac.mm, file_dialog_mac.mm, popup_menu_mac.mm) must be excluded via APPLE AND NOT IOS. Link UIKit instead of Cocoa on iOS.
  • No posix_spawn_file_actions_addchdir_np — wrap the call in #if !(TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_WATCH). TargetConditionals.h only exists on Apple, so include it under #ifdef __APPLE__.
  • No CoreAudio/AudioToolbox device APIs — iOS uses AVAudioSession for the device-level work that AudioHardwarePropertyDefaultOutputDevice and friends do on macOS. Gate with PULP_HAS_COREAUDIO_DEVICE.
  • No FSEvents — choc_FileWatcher.h pulls FSEventStreamRef which does not exist on iOS. hot_reload.hpp is gated so the iOS path gets a no-op HotReloader. The iOS editor still builds its bridge from ViewBridge::Options::hosted_editor() like every other adapter, so the flag is accepted and file-watch reload is simply inert; HotReloader::kWatchesFiles is false there and ScriptedUiSession logs once so the degradation is visible. DSP-swap-driven editor rebuilds (ViewBridge::poll_editor_reload()) are watcher-free and DO work on device.

Frame timing (CADisplayLink)

  • Never hand the animation system a hardcoded 1.0f/60.0f. Every iOS host drives frames from a CADisplayLink, and on a 120 Hz ProMotion iPad the link fires ~120×/s. A constant 1/60 per tick made animation time run at double speed on those devices (and slow whenever ticks were dropped or coalesced). Both iOS hosts now route through the shared pump in core/view/include/pulp/view/host_frame_pump.hpp: begin_host_frame(root, clock, pump, frame_time, needs_repaint) measures the dt, pumps the FrameClock's activity probes with it, and reports whether the frame composites; advance_host_frame(root, clock, dt) then advances the FrameClock, widget animations, and CSS timelines by that ONE dt.
  • Take the timestamp from the link, not from a wall-clock read. Use link.targetTimestamp (when the frame will actually be presented) — it stays correct when the callback is delivered late. Seed the pump's nominal interval from link.duration (1/120 on ProMotion) via set_nominal_frame_dt, so the first frame and any wake-from-idle frame advance by one real frame of that display.
  • Suspend the pump whenever the link stops. HostFramePump::suspend() on stop_display_link() / start_display_link(): a UI that idled at 0 fps for 30 s must not resume with a 30-second dt — that would teleport any animation that starts on wake straight to its end state.
  • window_host_ios.mm used to own no FrameClock at all (only plugin_view_host_ios.mm did), so a standalone iOS app advanced nothing but gesture recognizers — no FrameClock subscribers, no widget animations, no CSS timelines. If you add a third iOS host, bind a FrameClock + HostFramePump and go through the shared pump; do not re-invent the tick.

Toolchain

  • Use the Xcode generator, not Unix Makefiles/Ninja with tools/cmake/ios.toolchain.cmake — the vendored toolchain has a recursive enable_language(C) bug with non-Xcode generators.
  • CLI and MCP targets are desktop-only — gate with NOT IOS AND NOT ANDROID (they depend on process spawning and MACOSX_BUNDLE install rules).
  • pulp::inspect is desktop-only — it needs Dawn/Skia GPU. Gate the link in core/format/CMakeLists.txt.
  • clang -fsyntax-only iOS check — two flags matter or you get false errors. When syntax-checking an ObjC++ host file (e.g. window_host_ios.mm) against the iPhoneOS SDK, (1) target iOS 16.3 or later (-target arm64-apple-ios17.0): runtime::log.hpp pulls in std::format, whose float path calls std::to_chars, marked unavailable before iOS 16.3 — a lower target fails deep in libc++ with an error that looks like your code but isn't; and (2) do NOT pass -fobjc-arc — the iOS host files use manual retain/release ([super dealloc]), so ARC turns pre-existing, correct code into "ARC forbids explicit dealloc" errors. Pull the real include dirs from the build's OBJCXX_INCLUDES line in build/core/view/CMakeFiles/pulp-view-core.dir/flags.make (note: the .mm uses OBJCXX_INCLUDES, not CXX_INCLUDES).

Window host (GPU) — dpi_scale + design viewport

  • IOSGpuWindowHost implements dpi_scale() + the design viewport, mirroring the macOS GPU host: dpi_scale() returns window_.screen.scale (the base default 1.0 was a live Retina coordinate bug on every 2x/3x device); set_design_viewport / design_viewport_transform pin the root to design size and letterbox it in render_frame via the shared, x-platform-tested WindowHost::compute_design_viewport_transform. set_fixed_aspect_ratio is deliberately left to the base (self-diagnosing) no-op — iOS has no user-resizable window to aspect-lock. NOTE: the standalone GPU host's PulpMetalWindowView currently processes no touches, so the paint-side design viewport has no touch coordinate to misalign yet; touch inverse-mapping is the follow-up when that view gains touch handling (contrast with IOSGpuPluginViewHost below, which does own the pointTransform touch map).

Input

  • Touch events — UIKit's touchesBegan/Moved/Ended/Cancelled route through window_host_ios.mm. The handlers build a root-space MouseEvent with a stable pointer id, PointerType::touch or PointerType::pen, and an explicit MousePhase, then call View::dispatch_gesture_pointer_event(...) before the legacy widget path. If the arbiter consumes the event, do not also dispatch View::on_mouse_{down,up,drag}(Point) or JS pointer fallback events. For touchesCancelled, set is_cancelled = true and use the release phase so recognizers cancel/fail consistently.
  • Apple Pencil — UITouch.type == UITouchTypePencil sets MouseEvent::pointer_type = PointerType::pen plus altitude/azimuth.
  • Multi-touch — each UITouch* gets a stable pointer_id via stableIdForTouch: so widgets that use set_pointer_capture() work correctly.
  • GPU AUv3 editor touch → JS pointer events — the standalone CPU paths call only the position-based View::on_mouse_{down,up,drag}(Point) virtuals, which native widgets consume but which do NOT dispatch JS pointer events. The GPU AUv3 editor view (PulpMetalPluginView in plugin_view_host_ios.mm) originally had NO touch handlers at all, so a scripted/Three.js editor was inert to touch. It now hit_tests rootView, captures a per-pointer drag target, and fires View::on_mouse_event (→ JS pointerdown/up) plus the new View::on_pointer_move (→ JS pointermove carrying real pointerId+pointerType:'touch', which on_drag collapses to pointerId:0/'mouse'), mirroring the mac pulp_plugin_mouse_* dispatch. Multi-pointer identity is what lets Three.js OrbitControls pinch-zoom track two fingers. Gotcha: dispatching pointer events to a canvas widget id is NOT enough — code that listens on ownerDocument (OrbitControls moves its move/up listeners there after pointerdown) needs the bridge's document fan-out in __dispatch__ (widget_bridge.cpp); the element bubble walk never reaches the document object. view_is_in_tree-style validation of a captured drag target MUST walk root→down (compare pointer identity), never target→parent — the captured View* can be freed by an editor rebuild between touch events, so walking its parent() is a UAF.
  • Touch injection on the Simulator is not scriptable here — simctl has no touch/gesture command, the Simulator exposes no AX window, and cliclick/pyobjc-Quartz aren't installed, so a live finger-drag can't be auto-injected. To prove a touch→JS chain, drive synthetic events through the real __dispatch__(<canvas._id>, 'pointermove', {...}) entrypoint (the same one the native handlers call) from a hot-patched scene.js and assert camera state / document-level listener counts in the dev.pulp.runtime log.
  • GPU AUv3 editor fills the pane (responsive), no forced design viewport — IOSGpuPluginViewHost CAN scale a fixed design viewport (it overrides set_design_viewport/set_fixed_aspect_ratio/set_design_viewport_top_align/ window_to_root_point, and render_frame applies an aspect-correct translate+scale that the Three.js cube composites through coherently). BUT the iOS AU view controller (au_view_controller_ios.mm) deliberately does NOT force a design viewport: aspect-locked scaling letterboxed the pane (dark bars on the sides) and pushed header text to the edge. It instead lays the root out at the ACTUAL pane bounds (resizeEditorToViewBounds → set_size + bridge->resize) so a responsive flex scene fills edge-to-edge. The scene must be responsive to benefit: examples/ios-auv3-jsc-threejs/js/scene.js flex-fills the body/shell at width:100% and syncCanvasSize() resizes the Three.js drawing buffer + camera aspect to the canvas's measured size each frame (a fixed-size canvas would just sit small in a wide pane). To fill the pane you ALSO need ContentView.swift to give the editor .frame(maxWidth:.infinity, maxHeight:.infinity). A genuinely fixed-aspect editor that wants letterboxing can call set_design_viewport itself.
  • Editor-initiated AUv3 resize is advisory — Processor::request_editor_resize should validate the requested size through ViewBridge and publish an accepted size via preferredContentSize on the main thread. Keep laying the editor out at the host pane's actual bounds; do not turn the request into a fixed design viewport. Install the handler only after bridge/host attachment, and clear it before changing audio units, rebuilding or tearing down the editor, and in dealloc, so a late request cannot reach stale controller or host state.
  • Host-app AUParameter slider must mirror value in @State — binding a SwiftUI Slider straight to AUParameter.value (get/set) does NOT update: SwiftUI doesn't observe AUParameter, so dragging writes the param but never re-renders, leaving the thumb + readout stuck at 0.00. The ParameterRow view in templates/ios-auv3/HostApp/ContentView.swift mirrors the value in @State (slider + readout bind to that, so they move live), writes through in onChange via setValue(_, originator: token), and installs an AUParameterObserverToken to reflect host/automation changes back — passing the token as originator so the observer doesn't echo the gesture.

Plugin Editor Child Views

  • PluginViewHost now mirrors WindowHost for native child views — plugin_view_host_ios.mm supports attach_native_child_view(...), set_native_child_view_bounds(...), set_native_child_view_clip(...), and detach_native_child_view(...) for UIView children embedded inside AUv3/plugin editors.
  • Native-child clipping uses a CALayer mask, and UIKit layers are already top-left — set_native_child_view_clip(...) (used by the NativeViewHost widget so an embedded WebView/text-field/video layer clips to its scroll ancestor) sets child.layer.mask to a CALayer sized to the visible sub-rect expressed in the child's own [0,0,w,h] box. Unlike AppKit (where a non-flipped NSView layer is bottom-left and the helper flips the Y), a UIView's layer is top-left origin, so the local clip maps directly with no Y-flip — do not copy the mac flip math into the iOS helper. has_clip=false removes the mask. Masking clips WITHOUT resizing the child, so a WKWebView does not reflow when it scrolls past a viewport edge.
  • Subview trees inherit plugin_view_host() recursively before Processor::on_view_opened(...) fires — if a View creates a WebViewPanel or other native-backed child in a plugin editor, wire it through View::plugin_view_host(), not a standalone WindowHost.
  • Detach on close — plugin-editor child views must be explicitly detached in on_view_closed()/destructors just like standalone window-hosted child views; the host clears propagated references when subtrees are removed.
  • Gate notify_attached() on is_attached(), never assume attach succeeded — PluginViewHost exposes is_attached() const noexcept and [[nodiscard]] try_attach_to_parent(...). The iOS hosts override is_attached() with the truthful native check (view_.superview != nil, and metal_view_.superview != nil for the GPU host). A foreign or AUv3 embedder must call try_attach_to_parent() (or check is_attached() after attach_to_parent()) before firing ViewBridge::notify_attached() — firing it when the parent rejected the view leaves the editor open/close lifecycle unbalanced. The base default is conservative (false), so a host that has not opted in never reports a phantom attach. Query on the UI thread.
  • Standalone WindowHost now reports live content bounds on iOS — window_host_ios.mm exposes WindowHost::get_content_size() and set_resize_callback(...) on both the CPU and Metal hosts, driven from layoutSubviews. For native child embeds, size from the host's reported content bounds instead of hard-coding UIScreen.mainScreen.bounds.
  • UIViewControllerRepresentable observer registrations must be paired with dismantleUIViewController removal — the HostApp template's PulpAUv3EditorView mounts the AUv3 editor by registering a closure on the @StateObject PulpAUv3Host that captures the container UIViewController. SwiftUI rebuilds the representable on orientation change, scene reset, and iPad split-view shuffles, calling makeUIViewController each time. If the observer list is append-only, every rebuild leaks one container VC for the lifetime of the host (which is the lifetime of the SwiftUI app). Use the install-token / remove-by-token pattern in templates/ios-auv3/HostApp/ContentView.swift: store the token in a Coordinator, call removeEditorObserver(token) from the static dismantleUIViewController(_:coordinator:) hook, and capture the container VC weakly inside the closure as a second layer of safety.

Native drag-and-drop (UIDrop/UIDrag)

  • iOS drag-and-drop is interaction-based, not the AppKit NSDraggingSession model. plugin_view_host_ios.mm installs a PulpIOSDragDrop coordinator (conforms to UIDropInteractionDelegate + UIDragInteractionDelegate) on BOTH host views (PulpPluginUIView CPU + PulpMetalPluginView GPU), bridging to the same cross-platform dispatch core (dispatch_drag_* / dispatch_drop) the mac/win/linux hosts use.
  • start_file_drag() ARMS, it does not start. UIKit begins a drag only from the system long-press lift, which calls dragInteraction:itemsForBeginningSession:. So PluginViewHost::start_file_drag on iOS stages the FileDragRequest.file_paths and returns true; the next lift consumes them (itemsForBeginningSession returns @[] when nothing is armed, so no stray drags). This differs from AppKit, where begin_file_drag starts a drag synchronously inside the mouse handler. A Pulp widget that wants outbound drag must call start_file_drag during its own long-press handling.
  • Drop coordinate transform differs by host. The drop point ([session locationInView:hostView]) is root-space directly on the CPU host (identity transform) but must go through the Metal view's live pointTransform (the inverse design-viewport map touches use) on the GPU host. plugin_view_host_ios.mm currently compiles without ARC in the generated iOS host-app CMake/Xcode path, so copy the transform block into the coordinator ivar and release it in dealloc; storing the caller's stack block directly can dangle after install.
  • performDrop completion runs on the main queue (UIKit guarantee), so it touches the view tree safely — but keep the coordinator alive with explicit MRC retain/release while the async loadObjectsOfClass: completion is outstanding and re-check the root pointer inside the block. An async completion can outlive the host; the coordinator's invalidate (called from both host dtors) nils the root so a late completion is a no-op.
  • No headless test — UIKit drag/drop is gesture-driven and can't be exercised by the macOS Catch2 suite; validated by local Simulator compile (clang -fsyntax-only -target arm64-apple-ios…-simulator) + manual gesture. Same gesture-untestable category as touch input.

Accessibility

  • UIAccessibility is the iOS equivalent of NSAccessibility. accessibility_ios.mm bridges AccessibilityNode → UIAccessibilityElement. Unlike macOS, iOS accessibility is opt-in per view: isAccessibilityElement = YES.
  • UIKit has no role STRING — only traits. View::AccessRole is a much richer vocabulary than UIAccessibilityTraits, so several roles legitimately collapse onto UIAccessibilityTraitButton (checkbox, radio, combo box, menu item, tab); VoiceOver then announces "button" and reads the element's accessibilityValue for state. That is UIKit's own idiom (UISwitch does the same) — do not "fix" it by inventing a trait.
  • AccessRole::text_field carries NO trait, and that is a real gap. VoiceOver derives "text field" from the element conforming to UITextInput, not from a trait. Pulp exposes plain UIAccessibilityElements, so a TextEditor announces as untyped text. Closing this means implementing UITextInput on the accessibility element, not adding a case to access_role_to_traits.
  • access_role_to_traits() deliberately has no default: case — adding an AccessRole must be a compile error in accessibility_ios.mm, not a silent fall-through to UIAccessibilityTraitNone. The iOS lane is not built by the required macOS gate, so this is the only thing that catches an unmapped role.

Sandboxing

  • AUv3 extensions run in a sandbox with no filesystem access beyond the container. Presets must be bundled in the app's resources or fetched via NSFileCoordinator from the host app's group container.
  • No fork/exec, no NSTask — all child-process code must be NOT IOS.

Permissions (pulp::platform::Permissions)

core/platform/platform/ios/permissions_ios.mm is the iOS backend for the cross-platform pulp::platform::Permissions API. Three rules:

  1. Always hop to the main queue before firing the user RequestCallback. AVFoundation/UserNotifications completion blocks don't promise a specific queue, and iOS callers invariably touch UIKit from the callback. The backend owns a RequestCallback* on the heap, dispatches to dispatch_get_main_queue(), and deletes after invocation.
  2. CBManager.authorization is iOS 13.1+ only — anything older has no discrete authorization surface. Wrap in @available and fall back to PermissionState::Granted; the system will prompt on first CBCentralManager instantiation provided NSBluetoothAlwaysUsageDescription is set in Info.plist.
  3. Don't forget the Info.plist keys. The iOS backend only surfaces the request; the prompt itself is gated on NSMicrophoneUsageDescription, NSCameraUsageDescription, NSBluetoothAlwaysUsageDescription, and NSLocalNetworkUsageDescription being present. No key → prompt never fires and the callback delivers Denied.

The required frameworks (AVFoundation, CoreBluetooth, UserNotifications) are linked in the IOS branch of core/platform/CMakeLists.txt.

pulp_add_ios_auv3() helper

pulp_add_ios_auv3(
    NAME               PulpSineSynth
    BUNDLE_ID          com.pulp.examples.sinesynth.host.PulpSineSynth
    MANUFACTURER       Pulp
    MANUFACTURER_CODE  Pulp        # exactly 4 characters
    SUBTYPE_CODE       PsSn        # exactly 4 characters
    AU_TYPE            aumu        # aumu | aufx | aumi
    VERSION            0.1.0
    SOURCES            src/sine_synth.cpp src/sine_synth.hpp
)

Builds the .appex only. iOS App-Store policy requires the .appex to ship inside a containing HostApp .app — pair the call above with pulp_add_ios_host_app(...) below to get an installable Simulator / device bundle.

pulp_add_ios_host_app() helper (Phase iOS-B)

pulp_add_ios_host_app(...) (in tools/cmake/PulpIosHostApp.cmake) builds a SwiftUI HostApp .app and embeds the AUv3 .appex into ${target}.app/PlugIns/.

pulp_add_ios_host_app(PulpSineSynth_HostApp
    AUV3_EXTENSION    PulpSineSynth_AUv3        # must exist; pulp_add_ios_auv3 sets up
    BUNDLE_ID         com.pulp.examples.sinesynth.host
    NAME              "PulpSineSynth"           # display name (defaults to target)
    VERSION           0.1.0                       # defaults to "1.0.0"
    DEPLOYMENT_TARGET 16.4                        # defaults to 16.3
    # SOURCES ...                                 # optional override; default is the
                                                  # shipped HostApp/ SwiftUI template
)

How the helper works

  1. Reads the AudioComponentDescription off the .appex target. _pulp_add_auv3_ios(...) stashes PULP_AUV3_MANUFACTURER_CODE / _SUBTYPE_CODE / _AU_TYPE / _VERSION_INT / _PLUGIN_NAME / _MANUFACTURER_NAME as target properties when it creates the .appex target. The HostApp helper reads them back so the HostApp's Info.plist AudioComponents entry matches the extension exactly. Descriptor drift between the HostApp and the extension silently breaks AVAudioUnitComponentManager.components(matching:) — the helper enforces parity by reading from one source of truth.
  2. Generates the HostApp Info.plist from templates/ios-auv3/HostApp/Info.plist.in — declares LSRequiresIPhoneOS, the audio background mode, supported orientations, NSMicrophoneUsageDescription, and the mirrored AudioComponents block.
  3. Embeds via a sentinel target. ${target}_Embed is an ALL-dep custom target whose sentinel depends on the .appex bundle output, so a .appex-only rebuild forces a re-embed even when the HostApp itself didn't relink. The macOS framework + appex container does the same thing for the same reason (see PulpAuv3.cmake comments).

Deploy recipe

See docs/getting-started/ios-deployment.md for the full end-to-end: Simulator install + launch via xcrun simctl install / launch, physical-iPad install via xcrun devicectl device install app (Xcode 15+), GarageBand iOS validation checklist for Phase iOS-C.

Quick reference — exact commands

# Simulator
cmake -S . -B build-ios-sim -G Xcode \
  -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphonesimulator \
  -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=16.4 \
  -DPULP_ENABLE_GPU=OFF -DPULP_BUILD_TESTS=OFF
cmake --build build-ios-sim --target PulpSineSynth_HostApp --config Release -- -sdk iphonesimulator
xcrun simctl boot "iPad Pro 13-inch (M5)"
xcrun simctl install booted build-ios-sim/AUv3/Release-iphonesimulator/PulpSineSynth.app
xcrun simctl launch --console booted com.pulp.examples.sinesynth.host

# Physical iPad (signed)
# Prefer the validated sourceable helper — it errors clearly on missing
# keys / unfilled placeholders. See docs/guides/ios-dev-signing.md for
# the full reusable dev-signing stub (schema template + one-time setup).
. tools/scripts/source_dev_creds.sh
cmake -S . -B build-ios-device -G Xcode \
  -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphoneos \
  -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=16.4 \
  -DCMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY="Apple Development" \
  -DCMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM="${PULP_TEAM_ID}" \
  -DCMAKE_XCODE_ATTRIBUTE_CODE_SIGN_STYLE="Automatic"
cmake --build build-ios-device --target PulpSineSynth_HostApp --config Release -- -sdk iphoneos
xcrun devicectl device install app --device <DEVICE_UDID> \
  build-ios-device/AUv3/Release-iphoneos/PulpSineSynth.app

Smoke test coverage

test/cmake/test_ios_auv3_configure.sh exercises both helpers:

  • Default behavior (since 2026-05-27): configures + builds both the AUv3 .appex and the HostApp .app, asserts the .appex is embedded under PlugIns/, and validates the HostApp Info.plist carries the matching AudioComponents.subtype. This catches link-time regressions that configure-only smoke cannot — for example a missing pulp::audio PUBLIC link in core/view or an unguarded <pulp/host/*> include in a view header (both real iPad-walkthrough regressions).
  • Override with PULP_IOS_AUV3_SMOKE_BUILD=0 to fall back to configure-only when iterating locally on a slow machine.

test/cmake/test_ios_hostapp_links.sh is the companion link-time regression test — it always builds the HostApp and asserts the produced bundle has a real Info.plist (lint-clean, non-empty CFBundleIdentifier), a real Mach-O executable at CFBundleExecutable, and an embedded .appex under PlugIns/. Use this script directly when triaging a "configure smoke is green but the user's iPad has no plugin" report.

Follow-ups / Known Gaps

  • Full pulp-view iOS build requires gating hot_reload.hpp. Done 2026-05-27: choc::file::Watcher (FSEventStream-based) is now #if !TARGET_OS_IPHONE-gated and HotReloader ships a no-op iOS stub.
  • pulp_add_ios_auv3() ships the .appex; host-app target generation Done in Phase iOS-B via pulp_add_ios_host_app().
  • Device-target code signing is documented but not scripted.
  • Visual regression for iOS UIs is not wired into CI yet; use simulator/device screenshots for visual validation.

Platform Gotchas

Cross-subsystem deps in pulp-view-core must hold on iOS

pulp::host is intentionally not added on iOS (App Store policy plus std::format / long double to_chars libc++ availability on the iPhoneSimulator SDK). That guard lives in the root CMakeLists.txt and in core/view/CMakeLists.txt's pulp-view-core link list. Two follow-on contracts must hold for pulp-view-core to actually link on iOS:

  1. Every pulp::* library that a view source #includes must be in the PUBLIC link list. The iOS lane catches this only if the smoke actually builds the HostApp. The typical failure mode is a view source including a subsystem header, such as <pulp/audio/audio_thumbnail.hpp>, without linking that subsystem.
  2. Any view header that includes <pulp/host/...> must wrap that include in #if defined(__has_include) && __has_include(<pulp/host/...>) so transitive consumers on iOS get a clean "feature absent" macro rather than a "file not found" error. Affected headers today: core/view/include/pulp/view/widgets/graph_editor_view.hpp, core/view/include/pulp/view/plugin_manager_panel.hpp, core/view/include/pulp/view/hosted_editor_attachment.hpp. Each defines a PULP_VIEW_HAS_* companion macro downstream code can key off.

If you add a new view source or header that pulls from pulp::audio, pulp::host, or any other subsystem that may be conditionally absent, the iOS HostApp link smoke (test/cmake/test_ios_hostapp_links.sh) is the canonical local reproducer.

add_compile_options(-Wall ...) must be gated to C/C++/ObjC languages

The Swift driver rejects clang's -Wall / -Wextra / -Wpedantic flags with Driver threw unknown argument: '-Wall' without emitting errors. The root CMakeLists.txt adds these via add_compile_options, which (without a language genex) attaches them to every language in the build — including any Swift target like the iOS HostApp's PulpHostApp.swift. Always wrap with $<$<COMPILE_LANGUAGE:C,CXX,OBJC,OBJCXX>:-Wall> etc. so Swift sees only the flags it understands.

iOS deployment target must be ≥ 16.3 for std::format

The iPhoneSimulator26.x SDK's libc++ marks std::to_chars for floating- point types as introduced in iOS 16.3 simulator. std::format unconditionally instantiates to_chars for float / double / long double as part of its formatter type list, so any TU that includes <pulp/runtime/log.hpp> (which uses std::format) fails to compile if the active IPHONEOS_DEPLOYMENT_TARGET is below 16.3 — even when the caller passes only strings. pulp_add_ios_auv3() and pulp_add_ios_host_app() both default to the user-supplied CMAKE_OSX_DEPLOYMENT_TARGET when present, otherwise pin to 16.3. Don't lower either floor below 16.3 unless std::format is removed from core/runtime/log.hpp first.

The same SDK availability constraint can surface through direct std::from_chars use for floating-point parsing in view code. Prefer integer or manual decimal parsing for small numeric-token paths that must build for the current iOS deployment floor; only use floating from_chars when the active SDK and deployment target both prove it is available.

iOS link line: don't link CoreAudioTypes standalone

On macOS, CoreAudioTypes ships as /System/Library/Frameworks/CoreAudioTypes.framework. On iOS it does not exist as a top-level framework — the same headers ship inside AudioToolbox.framework. Linking it standalone fails with framework 'CoreAudioTypes' not found on iphonesimulator26.x. Link AudioToolbox only; the AudioComponentDescription / AVAudioUnitComponent types resolve through that.

FileDialog / PopupMenu stubs must compile on iOS

core/platform/src/file_dialog_stub.cpp defines FileDialog::open_file / save_file / choose_folder under #if !defined(__APPLE__) and core/platform/src/popup_menu_stub.cpp defines PopupMenu::show / show_at_view under the same guard. macOS has native impls in file_dialog_mac.mm / popup_menu_mac.mm; iOS has neither (UIDocumentPicker + UIMenu wiring are follow-ups). Without an explicit iOS branch the link step fails on Undefined symbols for any iOS bundle that pulls pulp-view-core (WidgetBridge wires both into the JS bridge). The fix is to widen the #if to also include iOS: #if !defined(__APPLE__) || (defined(TARGET_OS_IPHONE) && TARGET_OS_IPHONE). Callers see nullopt / empty results — honest "unsupported" signaling — until the native UIDocumentPicker impl lands.

PulpAUViewController::dealloc — never call _bridge->close() explicitly

Touched here because core/format/src/au_view_controller_ios.mm is dual-owned by ios + auv3 + view-bridge. The view controller's ivars are declared _bridge, _fallbackView, _viewHost (the GPU-plugin-view-host work reordered them — see below) and destroyed in REVERSE declaration order, so _viewHost is destroyed FIRST. That makes root_.set_plugin_view_host(nullptr) / set_frame_clock(nullptr) in ~PluginViewHost safe on BOTH paths (the bridge's view and the _fallbackView are still alive). Calling _bridge->close() explicitly in dealloc reverses that order, frees the View first, and the host's destructor then dereferences a dangling reference — crashes AUv3 editor close. Full rationale lives in the auv3 skill under "PulpAUViewController::dealloc — never call _bridge->close() explicitly". Teardown must also run on the MAIN thread: the GPU host's CVDisplayLink idle pump is dispatched to the main queue and dereferences the bridge, so dealloc resets _viewHost via dispatch_sync(main) when off-main (an AUv3 controller can be released on the XPC queue) before the reverse-order ivar destruction — otherwise a queued idle block races the free (SIGSEGV in display_link_callback). See the view-bridge skill, "AU v3 teardown must ALSO run on the main thread."

Ivar order is load-bearing (2026-05 fallback-UAF fix): the old order _bridge, _viewHost, _fallbackView destroyed _fallbackView BEFORE _viewHost. On the no-audioUnit preview path _fallbackView is the View _viewHost->root_ references, so the host then cleared a back-pointer into a freed View. Declaring _viewHost LAST (so it destroys first) fixes both paths. Don't reorder back.

AUv3 headless guard must not create a fallback host

If PULP_DISABLE_PLUGIN_EDITOR, PULP_HEADLESS, PULP_TEST_MODE, or CI is set, PulpAUViewController should return without constructing a ViewBridge, PluginViewHost, or fallback empty view. That guard exists to prevent native editor windows during validation and agent runs; the fallback view remains only for preview/no-audioUnit cases.

Mounting an AUv3 editor inside a SwiftUI HostApp on iOS (iOS-D.2)

On macOS, AU v3 hosts open the editor inside their own window and the developer never has to wire SwiftUI to a UIViewController. On iOS the HostApp container ships with the .appex and has to mount the editor itself if the dev wants the in-app smoke to surface the plug-in UI (GarageBand iOS / AUM do their own mounting in production).

The contract (proven end-to-end on iPad Pro 13-inch M5 in iOS-D.2):

  1. After AVAudioUnit.instantiate(with:options:.loadOutOfProcess) succeeds, call node.auAudioUnit.requestViewController { vc in … } on the main queue. The XPC view-controller fetch can take 1–2 render-loop ticks — render a placeholder SwiftUI label in the meantime, not an empty container.
  2. Wrap the returned UIViewController in a UIViewControllerRepresentable whose container UIViewController adopts the editor as a child VC (addChild, pinned constraints, didMove(toParent:)). Do NOT try to mount the AUv3's own view directly — the editor lifecycle expects the parent VC to be present.
  3. Re-mount on every refresh: the AU host can swap the editor (e.g. after a preset reset), so keep the setEditor(_:) pathway live instead of mounting once in makeUIViewController.

See templates/ios-auv3/HostApp/ContentView.swift for the canonical SwiftUI host implementation that ships in the template; the PulpAUv3EditorView struct is the reference pattern.

iOS GPU host must run idle_callback inside the CADisplayLink tick

IOSGpuWindowHost::tick() (in core/view/platform/ios/window_host_ios.mm) is the per-vsync entry point and MUST invoke idle_callback_() BEFORE the needs_repaint check. Without this, JS requestAnimationFrame / setTimeout / async-result queues never fire on iOS GPU because set_idle_callback can otherwise behave like a no-op relative to the CADisplayLink loop.

The fix is two pieces:

  1. Override set_idle_callback to store the callback in a non-atomic field (CADisplayLink fires on mainRunLoop so the read-and-invoke happens on main only — no atomic guard needed unlike macOS GPU's CV thread).
  2. In tick(), run idle_callback_() first; the callback can request_repaint, which arms needs_repaint_ and triggers render_frame() in the same tick.

CPU iOS host (IOSWindowHost) has the same gap but no display link; repaint() just calls [root_view_ setNeedsDisplay] and UIKit drives drawRect: only on user interaction. A separate CADisplayLink-on-CPU-path fix is owed; not blocking AUv3 use cases since AUv3 host apps go through the GPU path.

iOS window hosts register the main-thread dispatcher while visible

window_host_ios.mm registers pulp::events::MainThreadDispatcher backends from both standalone window hosts so worker code can marshal work to UIKit without depending on platform APIs. Keep these invariants:

  • Register after UIKit objects are ready, before any CADisplayLink tick can run. The GPU host registers immediately after makeKeyAndVisible and before start_display_link() so the first idle/rAF callback sees a live dispatcher.
  • Unregister before clearing UIKit callbacks. Destructors set the host liveness token false, unregister the dispatcher token, then nil onResize, display-link targets, root views, and window/controller references. That order prevents queued resize/display callbacks from reaching freed host state.
  • Do not treat run_event_loop() like macOS. On iOS it returns after handing ownership to UIKit, so the dispatcher token must stay on the host and be released in teardown instead of on method exit.

Embeddable iOS GPU plugin host (IOSGpuPluginViewHost) — distinct from the window host

The iOS AU v3 controller calls ViewBridge::prepare_first_frame(*_viewHost) once its host is attached, so the document mounts before the first frame (content-first, view-bridge "Editor open"). The iOS host does not override PluginViewHost::present_first_frame(): it only marks itself dirty, and its display link's first frame is the mounted document. Presenting with the Core Animation transaction, as the macOS GPU host does, is not implemented here.

core/view/platform/ios/plugin_view_host_ios.mm is the AUv3 plugin embed host (vs IOSGpuWindowHost for standalone). Two things to know (2026-05 GPU-plugin-view-host work):

  • It was dead code on a stale render API. The prior version did std::make_unique<render::GpuSurface>() (abstract!) and SkiaSurface::initialize with dawn_device/dawn_queue Config fields that no longer exist. It compiled never because no ios-gpu Skia libs are fetched, so PULP_HAS_SKIA is OFF for iOS and the block is #ifdef'd out. It now uses the current API (GpuSurface::create_dawn() + SkiaSurface::create()). This path is only validated where iOS Skia is fetched and linked; the examples/ios-auv3-jsc-threejs path below is the current simulator proof. Verify other AUv3 GPU-host changes on a device/simulator.
  • Embeddability matches mac: display link starts on -didMoveToWindow (not attach_to_parent); -layoutSubviews re-syncs size/scale; tick() pumps idle_callback_ first (the idle-pump-in-tick contract — keep it), then frame clock
    • CSS animations; liveness token; GpuSurface resized PHYSICAL, SkiaSurface LOGICAL+scale; CPU fallback in the factory when is_gpu_backed() is false.

examples/ios-auv3-jsc-threejs — Three.js cube through the AUv3 GPU host

The PulpThreeJsDemo example runs real three.webgpu.js in JSC inside an AUv3 .appex, painting through PulpMetalPluginView → Dawn → Skia Graphite → CAMetalLayer. The rotating cube renders on the iOS Simulator when iOS Skia libs are present at external/skia-build/build/ios-gpu/.../libskia.a, which the GPU build requires. Build with -DPULP_ENABLE_GPU=ON -DPULP_REQUIRE_GPU_FOR_SDK=ON for iphonesimulator arm64; the host app auto-presents the editor.

The deep WebGPU-bridge gotchas (geometry uploaded as zero via getMappedRange, JS-guessed bind-group layout vs layout:"auto", the load-bearing Sim skip_validation toggle, capturing the out-of-process appex's logs via the dev.pulp.runtime os_log subsystem, and the $<PLATFORM_ID:Darwin,iOS,...> host-classification link gate) live in the threejs-bridge skill — read it before touching the WebGPU shim or the iOS GPU draw path.

Sim audio loop validation — recordVideo does NOT capture audio

xcrun simctl io booted recordVideo produces a video-only .mov — the Simulator routes audio natively through the Mac's output device but never into the recording. This caught the 2026-05-27 iOS AUv3 audio-path bringup loop: HostApp launched cleanly, all PULP_ triage prints showed INSTANTIATE_OK/NOTE: ON/engine.start-succeeded, but the recorded video file was silent. Don't waste cycles trying to get audio out of recordVideo.

Options for audio-path validation on iOS:

  1. Trust the wiring proof. When the HostApp prints PULP_INSTANTIATE_OK, PULP_MIDI_BLOCK: ready, PULP_NOTE: ON, and engine.start() returns without throwing, the audio path is wired. The Sim renders through CoreAudio → Mac output device → host speakers; an empty audio buffer would manifest as engine.start failing or INSTANTIATE_ERROR. For most regressions this proof is sufficient.
  2. Mac audio routing tool. BlackHole or Loopback can capture the Mac's output device into an audio file; combine with recordVideo for an A/V record. Worth the setup time only for true audio QA.
  3. Real device + headphone jack / mic. Authoritative — installs the same .appex PluginKit registers on real iOS, plays through the device's own audio hardware. Required for any audio-quality judgement (the Sim's mainMixerNode resamples; not a faithful reproduction of the device).

When debugging "did the plug-in actually produce sound?" prefer option 1 + option 3 over chasing a Sim audio capture. See .agents/skills/auv3/SKILL.md "iOS AUv3 diagnostic recipe" for the chain of PULP_ prints to grep.

iOS GPU (Skia/Dawn) header-namespace gotcha — Phase iOS-D.1

When the iOS configure first turns on Skia (i.e. PULP_ENABLE_GPU=ON + PULP_HAS_SKIA is set), two pre-existing bugs in core/view/platform/ios/{window_host,plugin_view_host}_ios.mm surfaced — they were inert because no prior iOS build had ever defined PULP_HAS_SKIA:

  1. Skia / Dawn / Metal headers #included inside an open namespace pulp::view {. Symptom is a wall of compile errors from Apple's Metal headers — "Objective-C declarations may only appear in global scope" on every @protocol. Cause: the open namespace nests every header symbol under pulp::view, and @protocol outside the global scope is a hard syntax error. Fix: close the namespace before the #include block, reopen it after.

  2. std::make_unique<render::GpuSurface>() / <render::SkiaSurface>(). Both classes are abstract — use the factories render::GpuSurface::create_dawn() and render::SkiaSurface::create(GpuSurface&, Config). The Config struct exposes only width / height / scale_factor; Dawn device/queue/instance are queried from the GpuSurface internally.

  3. pulp::render was not linked into pulp-view-core on iOS. The mac branch in core/view/CMakeLists.txt linked pulp::render under if(PULP_HAS_SKIA); the iOS branch did not. Phase iOS-D.1 adds the same gate.

iOS GPU AUv3 acceptance log markers — Phase iOS-D.1

A GPU-backed AUv3 view that's actually using Skia/Dawn on iOS emits a specific log sequence (see planning/2026-05-28-ios-d-gpu-auv3-crosscheck.md):

[plugin-gpu-host] adapter mode=custom use_gpu=true ... requires_gpu_host=true
GpuSurface: created Metal surface from CAMetalLayer
GpuSurface: Dawn initialized (surface: presentable)
GpuSurface: backend_type=Metal     ← added by Phase iOS-D.1
AU iOS: view controller loaded, ... mode=custom, gpu=true

Capture with:

xcrun simctl spawn booted log stream \
  --predicate 'process == "<AppName>" OR eventMessage CONTAINS "GpuSurface" OR eventMessage CONTAINS "[plugin-gpu-host]" OR eventMessage CONTAINS "AU iOS"' \
  --level debug

Missing backend_type=Metal (or backend_type=Null / OpenGL) means GPU init silently fell back to a non-Metal adapter — treat that as a P0 since the AUv3 is then rendering through software. The first frame may also log a benign WebGPU error (2): First instance (1) must be zero — that's an upstream Skia/Dawn first-frame issue, not a Pulp regression.

iOS GPU configure hard-fail — PULP_REQUIRE_GPU_FOR_SDK

The release-lane guard makes the contradiction PULP_REQUIRE_GPU_FOR_SDK=ON + PULP_ENABLE_GPU=OFF hard-fail at configure time. Test: bash test/cmake/test_require_gpu_for_sdk.sh <pulp-src> — three cases (REQUIRE+ENABLE+missing-Skia fail; REQUIRE off succeeds; REQUIRE on + ENABLE off fails). Add a case here whenever a new flag contradicts an existing one.

The GPU (PULP_HAS_SKIA) half of plugin_view_host_ios.mm only compiles in a GPU-ON configure

Both long-standing iOS gates configure PULP_ENABLE_GPU=OFF, so the #ifdef PULP_HAS_SKIA sections of the iOS plugin view host were compiled by nothing and silently drifted (missing pointer_dispatch.hpp include; a __weak capture in a manual-refcounted file — this repo's ObjC++ is MRC everywhere, so use the mac hosts' __block non-retaining capture instead; MRC blocks do not retain __block object variables). The iOS compile gate now carries an iphonesimulator GPU leg (-DPULP_ENABLE_GPU=ON -DPULP_REQUIRE_GPU_FOR_SDK=ON, manifest-pinned ios-simulator-arm64-x86_64 Skia slice fetched into the build tree) that builds PulpGpuSmoke_AUv3 + PulpGpuSmoke_HostApp_Embed, so breakage there reds the required macos check when the change runs the per-PR iOS gate (see "When the iOS compile gate runs"), and the nightly otherwise. When editing the GPU half of the iOS host, that leg is the compile proof — a CG-only green run says nothing about it.

IOSGpuPluginViewHost::gpu_surface() exposes the host's wgpu::Surface (Phase iOS-D.3b Slice 1)

PluginViewHost now has a virtual render::GpuSurface* gpu_surface() mirroring WindowHost::gpu_surface(). IOSGpuPluginViewHost overrides it to return gpu_surface_.get(); the CPU IOSPluginViewHost inherits the nullptr default.

The AUv3 iOS view controller calls bridge->scripted_ui()->attach_gpu_surface(_viewHost->gpu_surface()) right after PluginViewHost::create() succeeds, so the JS-side navigator.gpu / canvas.getContext('webgpu') shim talks to Pulp's real Dawn instance. Without it the JS GPU bridge falls through to mocks and any embedded WebGPU content (Three.js, raw WebGPU) renders black.

The iOS GPU host fills each frame (and its letterbox bars) with Options::background_rgb, so an iOS AUv3 opens on the plug-in's declared background like the desktop hosts. The CPU IOSPluginViewHost fallback still paints the framework default and keeps a black UIView background — a gap, not a contract; nothing on iOS compiles this in CI, so check it on a simulator build when touching it. Build the host's PluginViewHost::Options with editor_host_options(bridge, gpu, size) (gpu_host_select.hpp), never field by field: it carries the plug-in's declared background (ViewBridge::editor_background_rgb()), which the host paints on its backing layer and under the empty tree until the view-first document mounts. A hand-built Options silently drops it and this format opens on the framework navy while the others open on the plug-in's colour (view-bridge, "The first frame must already look like the plug-in").

See the view-bridge skill's "GpuSurface plumbing into WidgetBridge" section for the cross-platform contract and planning/2026-05-29-ios-d3b-threejs-webgpu-program.md § Slice 1 for the full rationale.

CoreMIDI device and UMP backend

iOS builds the same production CoreMIDI device and UMP translation units as macOS: core/midi/platform/mac/{coremidi_device,ump_session_coremidi}.mm. create_midi_system() therefore enumerates native CoreMIDI sources as Pulp inputs and destinations as Pulp outputs on both platforms. Ports share the process-wide client declared by coremidi_shared_client.h; never dispose that client from an individual port or test.

The CoreMIDI event-list/UMP API is runtime-guarded at iOS 14. Pulp's supported iOS floor is newer, but retaining the guard prevents an installed-SDK consumer from weak-linking and calling the API on an older runtime. When unavailable, UmpSession stays virtual-endpoint-only.

In the current Simulator gate, omitting UIBackgroundModes=audio from the harness bundle made virtual endpoint creation return kMIDINotPermitted, while the same oracle passed with the declaration. The harness therefore declares that mode in its CMake-generated Info.plist. Do not generalize this fixture requirement into production guidance: only claim background modes justified by the app's real behavior and Apple's current policy.

That plist spells its executable, bundle identifier and name as literals, not Xcode build settings like $(PRODUCT_BUNDLE_IDENTIFIER). The compile gate's SDK legs build with Ninja, which copies MACOSX_BUNDLE_INFO_PLIST verbatim: a $(...) placeholder then ships unexpanded, and simctl install / launch by bundle id fails even though the build is green. The Xcode generator expands them, so an Xcode-only check never notices. Keep any hand-written iOS plist generator-neutral, or configure it with @VAR@ substitution.

The authoritative iOS proof is test/cmake/test_ios_compile_gate.sh. It builds pulp-midi, the shared-client compile contract, and an installable harness for both the Simulator and device SDKs. On the Simulator it creates uniquely named virtual CoreMIDI endpoints through the shared client, enumerates via production create_midi_system(), verifies id/name/direction, requires an active production UmpSession, opens its CoreMIDI endpoints, proves event-list input and output, exercises canonical UMP packet walking, disposes the endpoints while their production handles are open, and requires stale-handle failure plus disappearance. Its topology contract pairs only unambiguous one-source / one- destination entities and preserves every endpoint in a multi-endpoint entity. A source-syntax check or in-process VirtualUmpEndpoint does not replace this proof.

See Also

  • android skill — parallel structure for Android NDK.
  • view-bridge skill — au_v2_cocoa_view.mm + au_view_controller_ios.mm linkage.
  • ci skill — how iOS builds integrate into PR validation (when added).
  • threejs-bridge skill — iOS WebGPU and Three.js runtime contracts.

Three.js inside AUv3 on iOS

Pulp ships a Three.js-on-iPad path inside AUv3 extensions via JSC + an esbuild-bundled IIFE wrapper for three.webgpu.js. The non-obvious bits worth remembering:

JSC's ESM module-loader API is NOT public on iOS

iPhoneOS.sdk/JavaScriptCore.framework/Headers/ ships no JSScript.h, no JSModuleLoaderDelegate, no setModuleLoaderDelegate:. Shipping any code path that uses those in an .appex risks App Store rejection at submission time. The supported path is:

  1. Bundle three.webgpu.js (ESM) at build time into a self-contained IIFE wrapper via tools/scripts/bundle_threejs_for_jsc.mjs — the script delegates ESM resolution and IIFE generation to esbuild, strips dev-only http: imports through an esbuild plugin, then wraps the namespace onto globalThis.THREE.
  2. Load the resulting three.iife.js at runtime via pulp::view::threejs_iife_source() (core/view/include/pulp/view/threejs_resources.hpp + core/view/src/threejs_resources_apple.mm). The NSBundle loader walks up from PulpAUViewController to the .appex and reads threejs/three.iife.js.
  3. JSC evaluate() the source — THREE lands on globalThis. No resolver needed.

If you reach for setModuleLoaderDelegate: because it's mentioned in WebKit internal docs, stop. Use the IIFE path. Future contributors who don't see this note will spend a day discovering this on their own.

Bundle invocation in CMake

tools/cmake/PulpAuv3.cmake _pulp_add_auv3_ios() adds a POST_BUILD step that runs node tools/scripts/bundle_threejs_for_jsc.mjs --input <threejs.webgpu.js> --output <appex>/threejs/three.iife.js. The step is gated on find_program(node) — if Node.js is missing on the build host, the embed is skipped with a STATUS message and pulp::view::threejs_iife_source() returns std::nullopt at runtime (clean failure, not a build break).

Log marker chain (grep these on iPad device walks)

[plugin-gpu-host] adapter mode=custom use_gpu=true ... requires_gpu_host=true
GpuSurface: backend_type=Metal
PULP_WEBGPU_BRIDGE: canvas.getContext('webgpu') ok (presentable=true)
PULP_WEBGPU_BRIDGE: context.configure ok (format=bgra8unorm, size=WxH)
PULP_WEBGPU_BRIDGE: queue.submit ok (canvas=X, commands=N)
PULP_THREEJS: bundle loaded (N bytes)
PULP_THREEJS: globalThis.THREE available
PULP_THREE_SHIM: ready
PULP_THREE_SHIM: webgpu-renderer-present

The presentable=true|false boolean is the program's most load-bearing signal — false means JS draws went to an offscreen texture, not the visible swapchain. If the iPad demo shows a black editor pane, grep for presentable=false first.

Buffered-draw probe

globalThis.__phase13BufferedSkips is the canonical "bridge gave up" probe. After a Three.js render call, the array should be empty. If it's non-empty, each entry is a JSON.stringify(...) of the skipped draw — that's where a missing native-bridge function call surfaces. The macOS V8 lane uses the same probe; the JSC lane behaves identically by design (widget_bridge.cpp registers each __gpu*Impl engine-agnostically via engine_.register_function(...)).

Memory probe

The .appex peak resident-set after first frame painted is the program's memory exit gate. Phase iOS-D.3b deliberately defers adding Increased Memory Limit (com.apple.developer.kernel.increased-memory-limit) and Extended Virtual Addressing (com.apple.developer.kernel.extended-virtual-addressing) entitlements — instrument the number first, escalate only if real-device testing shows pressure.

Putting the IIFE in front of a plugin's scripted-UI script (iOS-D.3c, 2026-05-29)

The iOS-D.3b plumbing landed the IIFE bundler and the NSBundle loader, but it did NOT add a "register native function before script runs" hook on WidgetBridge / ScriptedUiSession. A Pulp plugin that wants globalThis.THREE populated before its scene code runs has exactly two practical options today:

  1. Concatenate at runtime (recommended for plugin examples). In Processor::create_view(), read pulp::view::threejs_iife_source(), read the bundled scene script via [NSBundle pathForResource:ofType:inDirectory:@"threejs"], concatenate IIFE + shim + scene, write to NSTemporaryDirectory(), and hand THAT path to ScriptedUiSession::ScriptedUiOptions::script_path. The session's load() eval-orders everything correctly because it all lives in one file. Cleanup happens in Processor::on_view_closed(). This is what examples/ios-auv3-jsc-threejs/ does — keep the per-instance filename unique (encoding this works) so split-view / multi-insert hosts don't collide on the tempfile.

  2. Use the framework's default editor path (PULP_UI_SCRIPT_PATH via pulp_add_plugin). The IIFE has to live INSIDE that script file (you'd configure_file it in at build time). Bigger build hammer, but means no runtime concat. Suitable for plugin authors who already manage their script via Pulp's existing tooling.

Don't reach for "register a native function on the bridge that returns the IIFE source then call eval(...) from JS first thing." There is no public API on ScriptedUiSession / WidgetBridge to register native functions BEFORE load_script runs, and registering AFTER means the JS evaluate has already failed on new THREE.WebGPURenderer(...). Concatenation is the smaller change.

iOS bundle layout reminder

<appex>/threejs/three.iife.js (flat). NOT <appex>/Resources/threejs/three.iife.js (that's the macOS layout; iOS bundles are flat). The runtime loader (core/view/src/threejs_resources_apple.mm) queries the flat path. If you see "three.iife.js missing from bundle" from a successful build, double-check the POST_BUILD wrote under <appex>/threejs/, not <appex>/Resources/threejs/.

Per-example POST_BUILD copies

When an example bundles its OWN sibling resources (e.g. examples/ios-auv3-jsc-threejs/js/scene.js), use a add_custom_command(TARGET <name>_AUv3 POST_BUILD ...) that writes under the same $<TARGET_BUNDLE_DIR:...>/threejs/ directory so the NSBundle lookup finds it via the same inDirectory:@"threejs" query as Three.js. Don't put example assets under a sibling folder unless you also update the loader to search that folder.

Arming PULP_HAS_THREEJS on the iOS target

The root tools/cmake/PulpDependencies.cmake gate that fetches Three.js is if(PULP_BUILD_TESTS AND PULP_ENABLE_GPU). iOS forces PULP_BUILD_TESTS=OFF (the root CMakeLists.txt does this at top), so the FetchContent doesn't fire automatically. An example that needs the bundler step running on iOS must do its own subdirectory-local FetchContent before calling pulp_add_ios_auv3():

if(NOT PULP_HAS_THREEJS OR NOT DEFINED threejs_SOURCE_DIR)
    include(FetchContent)
    pulp_register_fetchcontent_source(threejs REF <same-pin-as-root>)
    FetchContent_Declare(threejs GIT_REPOSITORY ... GIT_TAG ...)
    FetchContent_MakeAvailable(threejs)
    set(PULP_HAS_THREEJS TRUE)
endif()

The variables are subdir-scoped so OTHER iOS examples in the same configure (synth, chainer, gpu-smoke) don't accidentally pick up the FetchContent. The _pulp_add_auv3_ios() helper reads both variables in-function and arms the bundler POST_BUILD step.

First-install discovery race

After a fresh devicectl install on an iPad (or first launch after deleting + reinstalling the HostApp), iOS may not have finished scanning the embedded .appex when the SwiftUI HostApp's .onAppear fires. AVAudioUnitComponentManager.shared().components(matching:) then returns 0 matches, the HostApp prints PULP_DISCOVER: no matching Pulp AUv3 found, and the user is stuck on that state until they manually kill + relaunch the app multiple times.

The HostApp template in templates/ios-auv3/HostApp/ContentView.swift now wires two recovery paths:

  1. Notification observer: subscribes to AVAudioUnitComponentManagerRegistrationsChangedNotification (Apple's documented signal that the AU component registry changed) and re-runs discover() when iOS finishes its scan.
  2. Polling fallback: 60 ticks at 1s each, guards the case where the notification doesn't fire on the first foreground after install. Each tick is a separate Task { @MainActor in ... } hop so Swift 6 strict-concurrency is happy.

Idempotency is mandatory. Without it, the polling fallback re-calls AVAudioUnit.instantiate(...) every tick — each call creates a fresh AudioUnit instance, which tears down the AUv3's editor view and crashes pulp::format::ViewBridge::~ViewBridge() with EXC_BAD_ACCESS because the editor is mid-render when the AU is replaced. The fix is the instantiationInFlight: Bool field on PulpAUv3Host:

func discover() {
    if audioUnit != nil { return }
    if instantiationInFlight { return }
    instantiationInFlight = true
    // ... scan + instantiate ...
}

Cleared in both the success path (inside the Task { @MainActor in ... } after AVAudioUnit.instantiate succeeds) AND the bail paths (no matching component found, instantiate callback returns error != nil). Forgetting either bail path leaks the in-flight flag and the discovery never retries.

Grep PULP_DISCOVER + PULP_INSTANTIATE in the launch log to trace the discovery cycle; the new code reuses these existing log markers and adds no new noise.

The GPU surface reaches the scripted UI by subscription (WAH-1)

au_view_controller_ios.mm no longer reads _viewHost->gpu_surface() once. It holds a _gpuSurfaceBinding from pulp::format::bind_gpu_surface(...), which follows the host's surface lifecycle and forwards BOTH creation and teardown into the scripted UI session.

Ivar order is load-bearing, same as _viewHost: declare _gpuSurfaceBinding AFTER _viewHost so reverse-order destruction drops the subscription before the host it observes, and reset it explicitly in the -dealloc main-thread teardown block alongside _viewHost.reset().

Why it changed: the read was correct on iOS (this host builds its surface in the constructor) but wrong on Windows, and one shared code path beats six per-format copies with one silently-broken member.

window_to_root_point is shared now, not per-host (WAH-10)

plugin_view_host_ios.mm no longer carries its own inverse letterbox transform. It calls WindowHost::design_viewport_window_to_root(), the same one the macOS and Windows plug-in hosts use.

Why it matters here specifically: the iOS host maps TOUCH points through this, and the transform must stay identical to the one paint applies — including design_top_align, which AU v3 sets. When these were four separate copies, a change to the paint-side transform could leave one host's INPUT mapping behind, and the symptom is touches landing on the wrong control rather than anything that looks like a coordinate bug.

If you need to change the mapping, change it in window_host.hpp beside the forward transform it inverts, and let pulp-test-design-viewport-inverse (round-trip: the point paint places at X is the point input recovers from a touch at X) tell you whether the pair still agree.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

aax

無料

Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

android

無料

Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

ara

無料

Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Generous-Corp のスキルをすべて見る

このスキルの問題を報告する