From ea71e275cff2e95a34e82c216bc23b80d2da3cba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 30 May 2026 12:43:47 -0700 Subject: [PATCH 1/3] swift: split MoqFFI bindings from the Moq wrapper and redesign the wrapper Mirrors the py split (#1551) for Swift, then spends the freedom on a Swift-native wrapper API. Packaging: the Swift integration now ships as two SPM packages. The raw MoqFFI bindings + prebuilt XCFramework mirror to moq-dev/moq-swift-ffi lockstep with the moq-ffi crate on each moq-ffi-v* tag (release-swift-ffi.yml). The ergonomic Moq wrapper versions independently via swift/VERSION and mirrors to moq-dev/moq-swift when that version is new (release-swift.yml, gated on the mirror's tags like release-plz). The wrapper pins MoqFFI at .upToNextMinor, so a crate patch reaches consumers with no wrapper release. Local dev keeps one monolithic swift/Package.swift; the split lives only in the two release templates. Wrapper redesign: every stateful FFI handle is wrapped in a de-prefixed, Sendable class (Client, Session, BroadcastProducer, ...); MoqFFI's Moq-prefixed classes no longer leak. Consumers conform to AsyncSequence, so `for try await x in consumer` works directly. Plain data records/enums are re-exported under de-prefixed names via typealias, so they track the crate automatically. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/release.sh | 30 ++- .github/workflows/release-swift-ffi.yml | 304 ++++++++++++++++++++++++ .github/workflows/release-swift.yml | 284 +++++++--------------- CLAUDE.md | 20 +- doc/lib/index.md | 2 +- doc/lib/swift/index.md | 42 ++-- doc/lib/swift/moq.md | 56 +++-- swift/Package.swift.template | 23 +- swift/README.md | 95 +++++--- swift/Sources/Moq/Aliases.swift | 24 ++ swift/Sources/Moq/AsyncSequences.swift | 166 ------------- swift/Sources/Moq/AsyncStream.swift | 31 +++ swift/Sources/Moq/Audio.swift | 49 ++++ swift/Sources/Moq/Broadcast.swift | 77 ++++++ swift/Sources/Moq/Client.swift | 81 +++++++ swift/Sources/Moq/Errors.swift | 5 +- swift/Sources/Moq/Log.swift | 7 + swift/Sources/Moq/Media.swift | 112 +++++++++ swift/Sources/Moq/Origin.swift | 112 +++++++++ swift/Sources/Moq/Server.swift | 109 +++++++++ swift/Sources/Moq/Session.swift | 9 - swift/Sources/Moq/Track.swift | 156 ++++++++++++ swift/Tests/MoqTests/SmokeTests.swift | 20 +- swift/VERSION | 1 + swift/ffi/Package.swift.template | 25 ++ swift/scripts/package-ffi.sh | 252 ++++++++++++++++++++ swift/scripts/package.sh | 198 +++++---------- swift/scripts/publish-ffi.sh | 116 +++++++++ swift/scripts/publish.sh | 41 ++-- swift/scripts/verify-ffi.sh | 134 +++++++++++ swift/scripts/verify.sh | 40 ++-- 31 files changed, 1969 insertions(+), 652 deletions(-) create mode 100644 .github/workflows/release-swift-ffi.yml create mode 100644 swift/Sources/Moq/Aliases.swift delete mode 100644 swift/Sources/Moq/AsyncSequences.swift create mode 100644 swift/Sources/Moq/AsyncStream.swift create mode 100644 swift/Sources/Moq/Audio.swift create mode 100644 swift/Sources/Moq/Broadcast.swift create mode 100644 swift/Sources/Moq/Client.swift create mode 100644 swift/Sources/Moq/Log.swift create mode 100644 swift/Sources/Moq/Media.swift create mode 100644 swift/Sources/Moq/Origin.swift create mode 100644 swift/Sources/Moq/Server.swift delete mode 100644 swift/Sources/Moq/Session.swift create mode 100644 swift/Sources/Moq/Track.swift create mode 100644 swift/VERSION create mode 100644 swift/ffi/Package.swift.template create mode 100755 swift/scripts/package-ffi.sh create mode 100755 swift/scripts/publish-ffi.sh create mode 100755 swift/scripts/verify-ffi.sh diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 8028f7a10..ab6b5e1b5 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -5,6 +5,7 @@ # release.sh parse-version — extract SemVer from GITHUB_REF given a tag prefix # release.sh prev-tag — find the tag immediately before the current one # release.sh create — create or update a GitHub release with artifacts +# release.sh git-tag-exists — check whether a tag exists on a remote repo # # Environment: # GITHUB_REF — set by GitHub Actions (e.g. refs/tags/moq-relay-v1.2.3) @@ -77,13 +78,40 @@ create_release() { fi } +# Check whether a bare-semver tag already exists on a remote repo. This is the +# release gate for the independently-versioned Swift wrapper (like release-plz +# checking the registry): the mirror's git tag is the source of truth, so a +# version that's already published is a no-op. Writes exists=true|false to +# $GITHUB_OUTPUT. A connection failure is fatal rather than silently +# re-publishing. +git_tag_exists() { + local repo="$1" + local tag="$2" + local url="https://github.com/${repo}" + + local out + out=$(git ls-remote --tags "$url" "refs/tags/${tag}") || { + echo "Failed to query tags on $url" >&2 + exit 1 + } + + local exists + if [[ -n "$out" ]]; then exists=true; else exists=false; fi + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "exists=${exists}" >>"$GITHUB_OUTPUT" + fi + echo "Tag ${repo}@${tag}: exists=${exists}" +} + # Dispatch subcommands case "${1:-}" in parse-version) parse_version "$2" ;; prev-tag) prev_tag "$2" ;; create) create_release "$2" ;; + git-tag-exists) git_tag_exists "$2" "$3" ;; *) - echo "Usage: $0 {parse-version|prev-tag|create} " >&2 + echo "Usage: $0 {parse-version|prev-tag|create|git-tag-exists} " >&2 exit 1 ;; esac diff --git a/.github/workflows/release-swift-ffi.yml b/.github/workflows/release-swift-ffi.yml new file mode 100644 index 000000000..3b932c1b2 --- /dev/null +++ b/.github/workflows/release-swift-ffi.yml @@ -0,0 +1,304 @@ +name: Release Swift FFI + +# Builds and publishes the raw `MoqFFI` Swift package (UniFFI bindings + +# prebuilt XCFramework). Driven by the `moq-ffi-v*` tag that release-plz pushes +# when it bumps rs/moq-ffi/Cargo.toml, so this package stays lockstep with the +# moq-ffi crate. The ergonomic `Moq` wrapper is released separately +# (release-swift.yml) and versions independently. + +on: + push: + tags: + - "moq-ffi-v*" + pull_request: + paths: + - ".github/workflows/release-swift-ffi.yml" + - ".github/scripts/release.sh" + - "swift/ffi/Package.swift.template" + - "swift/scripts/package-ffi.sh" + - "swift/scripts/publish-ffi.sh" + - "swift/scripts/verify-ffi.sh" + - "rs/moq-ffi/build.sh" + +permissions: + contents: read + +concurrency: + group: release-swift-ffi + cancel-in-progress: false + +jobs: + parse-version: + name: Parse version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.parse.outputs.version }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Parse version + id: parse + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [[ "$EVENT_NAME" == "pull_request" ]]; then + CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" + echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION" + else + .github/scripts/release.sh parse-version moq-ffi + fi + + build: + name: Build moq-ffi (${{ matrix.target }}) + needs: [parse-version] + runs-on: macos-latest + + strategy: + fail-fast: false + matrix: + target: + - aarch64-apple-ios + - aarch64-apple-ios-sim + # x86_64-apple-ios is the Intel iOS simulator slice (Apple never + # shipped Intel iOS devices), still needed for Xcode users on + # Intel Macs running the simulator. + - x86_64-apple-ios + - universal-apple-darwin + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + targets: ${{ matrix.target == 'universal-apple-darwin' && 'x86_64-apple-darwin,aarch64-apple-darwin' || matrix.target }} + + - name: Rust cache + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Build + shell: bash + env: + BUILD_TARGET: ${{ matrix.target }} + BUILD_VERSION: ${{ needs.parse-version.outputs.version }} + # Set iOS deployment target to avoid ___chkstk_darwin linker errors. + IPHONEOS_DEPLOYMENT_TARGET: ${{ contains(matrix.target, 'apple-ios') && '16.0' || '' }} + run: | + ./rs/moq-ffi/build.sh \ + --target "$BUILD_TARGET" \ + --version "$BUILD_VERSION" \ + --output dist + + - name: Upload lib + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: swift-lib-${{ matrix.target }} + path: dist/moq-ffi-${{ needs.parse-version.outputs.version }}-${{ matrix.target }}/lib/ + + bindings: + name: Generate bindings + needs: [parse-version] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Rust cache + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Generate bindings + env: + BUILD_VERSION: ${{ needs.parse-version.outputs.version }} + run: | + ./rs/moq-ffi/build.sh \ + --bindings-only \ + --version "$BUILD_VERSION" \ + --output dist + + - name: Upload bindings + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: swift-bindings + path: dist/bindings/swift/ + + package: + name: Package + needs: [parse-version, build, bindings] + runs-on: macos-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Download per-target libs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: libs-raw + pattern: swift-lib-* + + - name: Flatten lib layout + run: | + mkdir -p libs + for dir in libs-raw/swift-lib-*; do + target="${dir#libs-raw/swift-lib-}" + mv "$dir" "libs/$target" + done + ls -la libs + + - name: Download bindings + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: swift-bindings + path: bindings + + - name: Package + env: + BUILD_VERSION: ${{ needs.parse-version.outputs.version }} + run: | + ./swift/scripts/package-ffi.sh \ + --version "$BUILD_VERSION" \ + --lib-dir libs \ + --bindings-dir bindings \ + --output release-out + + - name: Upload Swift package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: swift-ffi-package + path: release-out/* + + release: + # Attach MoqFFI.xcframework.zip (and the staged Swift package tarball) to + # the moq-ffi-v* GitHub release. The mirror's Package.swift binaryTarget URL + # points at this asset, so it must exist before any consumer resolves the + # SPM tag. + name: Attach assets to GitHub release + needs: [package, parse-version] + runs-on: ubuntu-latest + if: github.event_name == 'push' + permissions: + contents: write + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + # No git operations here use the stored credential. gh CLI below picks + # up GH_TOKEN directly, so don't persist a contents-write token. + persist-credentials: false + + - name: Find previous tag + id: prev_tag + run: .github/scripts/release.sh prev-tag moq-ffi + + - name: Download package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: swift-ffi-package + path: artifacts + + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TITLE: "moq-ffi v${{ needs.parse-version.outputs.version }}" + RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} + run: .github/scripts/release.sh create artifacts + + verify: + # Gate the mirror push on actually being able to resolve the staged package + # against the live release asset. Catches manifests that look syntactically + # fine but SPM cannot resolve (e.g. a path-based binaryTarget slipping in + # where a URL+checksum is expected). + name: Verify staged package resolves + needs: [release, parse-version] + runs-on: macos-latest + if: github.event_name == 'push' + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Download staged package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: swift-ffi-package + path: artifacts + + - name: Resolve and build smoke consumer + env: + BUILD_VERSION: ${{ needs.parse-version.outputs.version }} + run: | + ./swift/scripts/verify-ffi.sh --tarball "artifacts/moq-ffi-${BUILD_VERSION}-swift-ffi.tar.gz" + + publish: + name: Publish to Swift Package mirror + needs: [verify, parse-version] + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Generate moq-bot token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + id: token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: moq-dev + repositories: moq-swift-ffi + # Narrow the minted token to only what publish-ffi.sh needs. + permission-contents: write + + - name: Download package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: swift-ffi-package + path: swift-out + + - name: Publish to moq-dev/moq-swift-ffi mirror + env: + BUILD_VERSION: ${{ needs.parse-version.outputs.version }} + # Token is minted fresh per run, expires in 1 hour, never at rest. + SWIFT_MIRROR_TOKEN: ${{ steps.token.outputs.token }} + GIT_AUTHOR_NAME: moq-bot + GIT_AUTHOR_EMAIL: moq-bot[bot]@users.noreply.github.com + run: ./swift/scripts/publish-ffi.sh + + publish-dry-run: + name: Publish dry-run + needs: [package, parse-version] + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Download package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: swift-ffi-package + path: swift-out + + - name: Dry-run publish to mirror + env: + BUILD_VERSION: ${{ needs.parse-version.outputs.version }} + run: ./swift/scripts/publish-ffi.sh --dry-run diff --git a/.github/workflows/release-swift.yml b/.github/workflows/release-swift.yml index a0ede8712..dfb903ec1 100644 --- a/.github/workflows/release-swift.yml +++ b/.github/workflows/release-swift.yml @@ -1,249 +1,142 @@ name: Release Swift +# Publishes the ergonomic `Moq` Swift wrapper to the moq-dev/moq-swift mirror. +# This is the package most callers install; the raw `MoqFFI` bindings it depends +# on are built separately (release-swift-ffi.yml). +# +# Versioned independently of the moq-ffi crate: bump `swift/VERSION` by hand in a +# normal PR. On merge to main/dev this reads that version and publishes only if +# it isn't already tagged on the mirror. That tag check is the release gate (the +# same model release-plz uses for crates), so there's no separate tag to +# remember. The wrapper pins moq-ffi at .upToNextMinor and floats to the latest +# compatible patch, so a moq-ffi patch needs no wrapper re-release. + on: push: - tags: - - "moq-ffi-v*" - pull_request: + branches: + - main + - dev paths: + - "swift/VERSION" + - "swift/Package.swift.template" + - "swift/Sources/Moq/**" + - "swift/Tests/**" + - "swift/scripts/package.sh" + - "swift/scripts/publish.sh" + - "swift/scripts/verify.sh" - ".github/workflows/release-swift.yml" - ".github/scripts/release.sh" + pull_request: + paths: + - "swift/VERSION" - "swift/Package.swift.template" + - "swift/Sources/Moq/**" + - "swift/Tests/**" - "swift/scripts/package.sh" - "swift/scripts/publish.sh" - "swift/scripts/verify.sh" - - "rs/moq-ffi/build.sh" + - ".github/workflows/release-swift.yml" + - ".github/scripts/release.sh" permissions: contents: read +# Separate PR builds from mainline publishes so a PR run can't queue ahead of +# (and delay) a release on main. concurrency: - group: release-swift + group: release-swift-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: - parse-version: - name: Parse version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.parse.outputs.version }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - name: Parse version - id: parse - env: - EVENT_NAME: ${{ github.event_name }} - run: | - if [[ "$EVENT_NAME" == "pull_request" ]]; then - CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') - echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" - echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION" - else - .github/scripts/release.sh parse-version moq-ffi - fi - build: - name: Build moq-ffi (${{ matrix.target }}) - needs: [parse-version] + name: Build wrapper package runs-on: macos-latest - - strategy: - fail-fast: false - matrix: - target: - - aarch64-apple-ios - - aarch64-apple-ios-sim - # x86_64-apple-ios is the Intel iOS simulator slice (Apple never - # shipped Intel iOS devices), still needed for Xcode users on - # Intel Macs running the simulator. - - x86_64-apple-ios - - universal-apple-darwin + if: ${{ github.repository_owner == 'moq-dev' }} + outputs: + version: ${{ steps.version.outputs.version }} + ffi_version: ${{ steps.version.outputs.ffi_version }} + # On PRs we only build (dry-run). On main/dev we publish when the version + # isn't already tagged on the mirror. + publish: ${{ github.event_name == 'push' && steps.gate.outputs.exists == 'false' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - targets: ${{ matrix.target == 'universal-apple-darwin' && 'x86_64-apple-darwin,aarch64-apple-darwin' || matrix.target }} - - - name: Rust cache - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - - name: Build - shell: bash - env: - BUILD_TARGET: ${{ matrix.target }} - BUILD_VERSION: ${{ needs.parse-version.outputs.version }} - # Set iOS deployment target to avoid ___chkstk_darwin linker errors. - IPHONEOS_DEPLOYMENT_TARGET: ${{ contains(matrix.target, 'apple-ios') && '16.0' || '' }} + - name: Read versions + id: version run: | - ./rs/moq-ffi/build.sh \ - --target "$BUILD_TARGET" \ - --version "$BUILD_VERSION" \ - --output dist - - - name: Upload lib - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: swift-lib-${{ matrix.target }} - path: dist/moq-ffi-${{ needs.parse-version.outputs.version }}-${{ matrix.target }}/lib/ - - bindings: - name: Generate bindings - needs: [parse-version] - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - name: Rust cache - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - - name: Generate bindings + VERSION=$(tr -d '[:space:]' < swift/VERSION) + FFI_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "ffi_version=$FFI_VERSION" >> "$GITHUB_OUTPUT" + echo "wrapper $VERSION, moq-ffi pin $FFI_VERSION" + + # The release gate: skip publishing a version already tagged on the mirror. + # Runs on PRs too, as an informational dry-run. + - name: Check mirror tag + id: gate env: - BUILD_VERSION: ${{ needs.parse-version.outputs.version }} - run: | - ./rs/moq-ffi/build.sh \ - --bindings-only \ - --version "$BUILD_VERSION" \ - --output dist - - - name: Upload bindings - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: swift-bindings - path: dist/bindings/swift/ - - package: - name: Package - needs: [parse-version, build, bindings] - runs-on: macos-latest - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - name: Download per-target libs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - path: libs-raw - pattern: swift-lib-* - - - name: Flatten lib layout - run: | - mkdir -p libs - for dir in libs-raw/swift-lib-*; do - target="${dir#libs-raw/swift-lib-}" - mv "$dir" "libs/$target" - done - ls -la libs - - - name: Download bindings - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: swift-bindings - path: bindings + VERSION: ${{ steps.version.outputs.version }} + run: .github/scripts/release.sh git-tag-exists moq-dev/moq-swift "$VERSION" - name: Package env: - BUILD_VERSION: ${{ needs.parse-version.outputs.version }} - run: | - ./swift/scripts/package.sh \ - --version "$BUILD_VERSION" \ - --lib-dir libs \ - --bindings-dir bindings \ - --output release-out + BUILD_VERSION: ${{ steps.version.outputs.version }} + run: ./swift/scripts/package.sh --version "$BUILD_VERSION" --output release-out - name: Upload Swift package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: swift-package + name: swift-wrapper-package path: release-out/* - release: - # Attach MoqFFI.xcframework.zip (and the staged Swift package - # tarball) to the moq-ffi-v* GitHub release. The mirror's - # Package.swift binaryTarget URL points at this asset, so it must - # exist before any consumer resolves the SPM tag. - name: Attach assets to GitHub release - needs: [package, parse-version] - runs-on: ubuntu-latest - if: github.event_name == 'push' - permissions: - contents: write - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 0 - # No git operations here use the stored credential. gh CLI - # below picks up GH_TOKEN directly, so don't persist a - # contents-write token in .git/config. - persist-credentials: false - - - name: Find previous tag - id: prev_tag - run: .github/scripts/release.sh prev-tag moq-ffi - - - name: Download package - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: swift-package - path: artifacts - - - name: Create or update release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} - RELEASE_TITLE: "moq-ffi v${{ needs.parse-version.outputs.version }}" - RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} - run: .github/scripts/release.sh create artifacts - verify: - # Gate the mirror push on actually being able to resolve the staged - # package against the live release asset. Catches manifests that - # look syntactically fine but SPM cannot resolve (e.g. a path-based - # binaryTarget slipping in where a URL+checksum is expected). + # Build a throwaway SPM consumer against the staged wrapper, which pulls the + # published moq-swift-ffi transitively. Proves the moq-ffi pin resolves + # against a real release before the wrapper reaches the mirror. Skipped (not + # failed) when the pinned moq-ffi version isn't mirrored yet, so the first + # bootstrap doesn't deadlock on a not-yet-published FFI package. name: Verify staged package resolves - needs: [release, parse-version] + needs: [build] runs-on: macos-latest - if: github.event_name == 'push' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false + - name: Check FFI mirror tag + id: ffi + env: + FFI_VERSION: ${{ needs.build.outputs.ffi_version }} + run: .github/scripts/release.sh git-tag-exists moq-dev/moq-swift-ffi "$FFI_VERSION" + - name: Download staged package + if: steps.ffi.outputs.exists == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: swift-package + name: swift-wrapper-package path: artifacts - name: Resolve and build smoke consumer + if: steps.ffi.outputs.exists == 'true' env: - BUILD_VERSION: ${{ needs.parse-version.outputs.version }} - run: | - ./swift/scripts/verify.sh --tarball "artifacts/moq-ffi-${BUILD_VERSION}-swift.tar.gz" + BUILD_VERSION: ${{ needs.build.outputs.version }} + run: ./swift/scripts/verify.sh --tarball "artifacts/moq-${BUILD_VERSION}-swift.tar.gz" + + - name: Skip notice + if: steps.ffi.outputs.exists != 'true' + env: + FFI_VERSION: ${{ needs.build.outputs.ffi_version }} + run: echo "::notice::moq-swift-ffi ${FFI_VERSION} not published yet; skipping cross-package resolve." publish: name: Publish to Swift Package mirror - needs: [verify, parse-version] + needs: [build, verify] runs-on: ubuntu-latest - if: github.event_name == 'push' + if: ${{ needs.build.outputs.publish == 'true' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -258,30 +151,27 @@ jobs: private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: moq-dev repositories: moq-swift - # Narrow the minted token to only what publish.sh needs, - # rather than inheriting the full App installation scope. + # Narrow the minted token to only what publish.sh needs. permission-contents: write - name: Download package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: swift-package + name: swift-wrapper-package path: swift-out - name: Publish to moq-dev/moq-swift mirror env: - BUILD_VERSION: ${{ needs.parse-version.outputs.version }} + BUILD_VERSION: ${{ needs.build.outputs.version }} # Token is minted fresh per run, expires in 1 hour, never at rest. SWIFT_MIRROR_TOKEN: ${{ steps.token.outputs.token }} - # Use the moq-bot identity for the commit author so the - # mirror's git log reflects who actually pushed. GIT_AUTHOR_NAME: moq-bot GIT_AUTHOR_EMAIL: moq-bot[bot]@users.noreply.github.com run: ./swift/scripts/publish.sh publish-dry-run: name: Publish dry-run - needs: [package, parse-version] + needs: [build] runs-on: ubuntu-latest if: github.event_name == 'pull_request' @@ -293,10 +183,10 @@ jobs: - name: Download package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: swift-package + name: swift-wrapper-package path: swift-out - name: Dry-run publish to mirror env: - BUILD_VERSION: ${{ needs.parse-version.outputs.version }} + BUILD_VERSION: ${{ needs.build.outputs.version }} run: ./swift/scripts/publish.sh --dry-run diff --git a/CLAUDE.md b/CLAUDE.md index 4e4225115..78308662f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,12 +76,22 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi # libraries can't be split across separately # packaged Python wheels. -/swift/ # Swift wrapper over rs/moq-ffi (SwiftPM) +/swift/ # Swift over rs/moq-ffi (SwiftPM). Split like py: the + # ergonomic `Moq` wrapper (Sources/Moq) versions + # independently via swift/VERSION and mirrors to + # moq-dev/moq-swift on a VERSION bump (release-swift.yml, + # registry-gated like release-plz); the raw `MoqFFI` + # bindings + XCFramework mirror to moq-dev/moq-swift-ffi + # lockstep with the crate on each moq-ffi-v* tag + # (release-swift-ffi.yml). The wrapper pins MoqFFI at + # .upToNextMinor so a crate patch needs no wrapper release. + # Local dev uses one monolithic swift/Package.swift; the + # two-package split exists only in released artifacts + # (Package.swift.template + ffi/Package.swift.template). /kt/ # Kotlin wrapper over rs/moq-ffi (Gradle, KMP) /go/ # Go wrapper over rs/moq-ffi (uniffi-bindgen-go) - # swift/kt/go are in-tree source skeletons. - # CI mirrors them to moq-dev/moq-{swift,kotlin,go} - # on each moq-ffi-v* tag. + # kt/go are in-tree source skeletons. CI mirrors them to + # moq-dev/moq-{kotlin,go} on each moq-ffi-v* tag. /demo/ # Demos and test media boy/ # MoQ Boy demo (ROM hosting, orchestration justfile) @@ -174,6 +184,8 @@ Changes in one area usually need matching updates elsewhere, including docs. If | `rs/moq-gst` | `doc/bin/gstreamer.md` | | `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | +For `swift/`, the wrapper re-exports `moq-ffi` records/enums via typealias, so new catalog/audio *fields* flow through automatically. Only a new FFI *method* (or a renamed/removed one) needs a matching change in the de-prefixed `Sources/Moq` wrapper. + ## Branch Targeting Two long-lived branches: diff --git a/doc/lib/index.md b/doc/lib/index.md index 20b233219..d1c8472bf 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -47,7 +47,7 @@ Coroutines and `Flow` for Android and the JVM. Published as `dev.moq:moq` on Mav ### [Swift](/lib/swift/) -Async sequences and structured concurrency for iOS, iPadOS, and macOS. Distributed via Swift Package Manager. +Async sequences and structured concurrency for iOS, iPadOS, and macOS. Distributed via Swift Package Manager as [`Moq`](https://github.com/moq-dev/moq-swift) (the ergonomic wrapper, versioned independently), atop the raw [`MoqFFI`](https://github.com/moq-dev/moq-swift-ffi) bindings. ### [Go](/lib/go/) diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 537626fc8..d82c33281 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -9,9 +9,13 @@ The Swift bindings expose [Media over QUIC](/) to iOS, iPadOS, macOS, and the iO ## Packages +Two Swift packages, split so the ergonomic API can evolve on its own cadence: + ### Moq -A single Swift Package Manager target that wraps the UniFFI bindings with `AsyncSequence` adapters, structured-concurrency-friendly cancellation, and a session helper. +[moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) + +The package you want. A Swift-native wrapper: de-prefixed types (`Client`, `Session`, `BroadcastProducer`), `AsyncSequence` conformance on every consumer, structured-concurrency cancellation, and `Sendable` handles. The raw `MoqFFI` types stay behind it. **Features:** @@ -19,16 +23,23 @@ A single Swift Package Manager target that wraps the UniFFI bindings with `Async - Universal binary for Apple Silicon and Intel Macs - iOS device + iOS Simulator slices (arm64 and x86_64) - Cancellation through Swift `Task` propagates to native consumers +- Versioned independently of the Rust crates; floats to the latest compatible `MoqFFI` patch [Learn more](/lib/swift/moq) +### MoqFFI + +[moq-dev/moq-swift-ffi](https://github.com/moq-dev/moq-swift-ffi) + +The raw UniFFI bindings (the `Moq`-prefixed classes) plus the prebuilt `MoqFFI.xcframework`, tracking the [`moq-ffi`](https://crates.io/crates/moq-ffi) Rust crate one-to-one. `Moq` pulls this in for you; depend on it directly only if you need the unwrapped API. + ## Installation -The package lives in [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift), a mirror repo that SPM resolves with bare-semver tags. Add it to your `Package.swift`: +Add the `Moq` wrapper; SPM resolves `MoqFFI` (and its XCFramework) transitively: ```swift dependencies: [ - .package(url: "https://github.com/moq-dev/moq-swift", from: "0.2.0"), + .package(url: "https://github.com/moq-dev/moq-swift", from: "0.3.0"), ], targets: [ .target( @@ -42,30 +53,31 @@ targets: [ Or in Xcode: File → Add Package Dependencies → enter the URL. -The package depends on a prebuilt `MoqFFI.xcframework` attached to the matching [`moq-ffi-v*` release](https://github.com/moq-dev/moq/releases) on the source repo. SPM downloads it transparently; no manual asset handling required. +The transitive `MoqFFI.xcframework` is attached to the matching [`moq-ffi-v*` release](https://github.com/moq-dev/moq/releases) on the source repo. SPM downloads it transparently; no manual asset handling required. ## Quickstart ```swift import Moq -let client = MoqClient() -let cs = try await client.connect(url: "https://cdn.moq.dev/anon/demo") +let client = Client() +let session = try await client.connect(to: "https://relay.example.com") -// cs.consumer() and cs.publisher() are always populated: by whatever -// origin you wired via setPublish / setConsume before connect, or by a -// fresh auto-created one for any side you didn't set. -let announced = try cs.consumer().announced(prefix: "demos/") +// session.publisher and session.consumer are always populated: by whatever +// origin you wired via setPublish / setConsume before connect, or by a fresh +// auto-created one. The duplex no-config path (the typical client) shares one +// origin between both sides. +let announced = try session.consumer.announced(prefix: "demos/") for try await announcement in announced { - print("got broadcast \(announcement.path())") + print("got broadcast \(announcement.path)") - let catalog = try announcement.broadcast().subscribeCatalog() - for try await update in catalog.updates { + let catalog = try announcement.broadcast.subscribeCatalog() + for try await update in catalog { print("catalog: \(update)") } } -cs.shutdown() +session.shutdown() ``` Cancelling the surrounding Swift `Task` propagates through to the underlying `cancel()` calls on each consumer. @@ -73,5 +85,5 @@ Cancelling the surrounding Swift `Task` propagates through to the underlying `ca ## Source and issues - Source: [swift/](https://github.com/moq-dev/moq/tree/main/swift) (in the monorepo) -- Mirror (what SPM resolves): [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) +- Mirrors (what SPM resolves): [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) (wrapper), [moq-dev/moq-swift-ffi](https://github.com/moq-dev/moq-swift-ffi) (raw bindings) - README: [swift/README.md](https://github.com/moq-dev/moq/blob/main/swift/README.md) diff --git a/doc/lib/swift/moq.md b/doc/lib/swift/moq.md index cfbcedabc..5870c083d 100644 --- a/doc/lib/swift/moq.md +++ b/doc/lib/swift/moq.md @@ -5,14 +5,14 @@ description: Swift Package Manager target for Media over QUIC # Moq -The Swift Package Manager target for [Media over QUIC](/). +The ergonomic Swift Package Manager target for [Media over QUIC](/). -This is an ergonomic wrapper around the UniFFI-generated `MoqFFI` types, providing `AsyncSequence` adapters and Swift-friendly errors. +A Swift-native wrapper over the UniFFI-generated bindings: de-prefixed types, `AsyncSequence` streams, throwing initializers, `Sendable` handles, and Swift-friendly errors. The raw `MoqFFI` types it wraps stay out of your way (data types like `Frame` and `Catalog` are re-exported under de-prefixed names). ## Install ```swift -.package(url: "https://github.com/moq-dev/moq-swift", from: "0.2.0"), +.package(url: "https://github.com/moq-dev/moq-swift", from: "0.3.0"), ``` Add `Moq` to your target's dependencies: @@ -26,42 +26,46 @@ Add `Moq` to your target's dependencies: ), ``` -Supported platforms: iOS 15+, iPadOS 15+, macOS 12+. The package ships an XCFramework with iOS device (arm64), iOS Simulator (arm64 + x86_64), and macOS universal slices. +The raw `MoqFFI` bindings and the prebuilt XCFramework are pulled in transitively from [moq-dev/moq-swift-ffi](https://github.com/moq-dev/moq-swift-ffi); you only depend on `moq-swift`. + +Supported platforms: iOS 15+, iPadOS 15+, macOS 12+. The XCFramework ships iOS device (arm64), iOS Simulator (arm64 + x86_64), and macOS universal slices. ## Connect ```swift import Moq -let client = MoqClient() -let cs = try await client.connect(url: "https://cdn.moq.dev/anon/demo") +let client = Client() +let session = try await client.connect(to: "https://relay.example.com") ``` -`MoqClient.connect(url:)` returns a `MoqSession`. The accessors `cs.publisher()` and `cs.consumer()` are always populated: by whatever origin you wired via `setPublish` / `setConsume` before connect, or by a fresh auto-created one for any side you didn't set. +`session.publisher` and `session.consumer` are always populated: by whatever origin you wired via `setPublish` / `setConsume` before connecting, or by a fresh auto-created one for any side you left unset. The duplex no-config path (the typical client) shares one origin between both. -For development against a relay with a self-signed certificate, configure the client before connecting: +For development against a relay with a self-signed certificate: ```swift -let client = MoqClient() -client.setTlsDisableVerify(disable: true) -try client.setBind(addr: "127.0.0.1:0") -let cs = try await client.connect(url: "https://localhost:4443") +let client = Client() +client.setTlsVerify(false) +try client.bind("127.0.0.1:0") +let session = try await client.connect(to: "https://localhost:4443") ``` When you're done, signal graceful shutdown to the peer: ```swift -cs.shutdown() // alias for cancel(code: 0) +session.shutdown() // alias for cancel(code: 0) ``` ## Subscribe +Every consumer is an `AsyncSequence`, so iterate directly: + ```swift -let announced = try cs.consumer().announced(prefix: "demos/") +let announced = try session.consumer.announced(prefix: "demos/") for try await announcement in announced { - let catalog = try announcement.broadcast().subscribeCatalog() - for try await update in catalog.updates { + let catalog = try announcement.broadcast.subscribeCatalog() + for try await update in catalog { print("catalog: \(update)") } } @@ -70,20 +74,20 @@ for try await announcement in announced { ## Publish ```swift -let broadcast = try MoqBroadcastProducer() -let audio = try broadcast.publishMedia(format: "opus", init: opusInitBytes) +let broadcast = try BroadcastProducer() +let audio = try broadcast.publishMedia(format: "opus", initData: opusInitBytes) -try cs.publisher().announce(path: "my-stream", broadcast: broadcast) +try session.publisher.announce(path: "my-stream", broadcast: broadcast) -try audio.writeFrame(payload: payload, timestampUs: 0) -try audio.writeFrame(payload: payload, timestampUs: 20_000) +try audio.writeFrame(payload, timestampUs: 0) +try audio.writeFrame(payload, timestampUs: 20_000) try audio.finish() try broadcast.finish() ``` ## Cancellation -All async sequences cooperate with structured concurrency. Cancelling the surrounding `Task` propagates to the underlying `cancel()` call on the consumer: +All async sequences cooperate with structured concurrency. Cancelling the surrounding `Task` propagates to the underlying `cancel()` on the consumer: ```swift let task = Task { @@ -96,6 +100,10 @@ let task = Task { task.cancel() // releases native resources ``` +## A note on enum casing + +`MoqError` keeps Rust's PascalCase variants, each carrying `message: String` (e.g. `MoqError.Closed(message: "...")`); use `error.isShutdown` to fold the graceful `Cancelled` / `Closed` cases. Plain enums round-trip to lowerCamelCase (`AudioFormat.s16`, `AudioCodec.opus`). + ## Local development To run the test suite, build a host-only XCFramework first: @@ -104,10 +112,10 @@ To run the test suite, build a host-only XCFramework first: just check-ffi ``` -This runs `swift/scripts/check.sh`, which builds `moq-ffi` for the host arch, regenerates the UniFFI Swift bindings, drops a single-slice `MoqFFI.xcframework` into `swift/`, and then runs `swift test`. Requires macOS with `xcodebuild`. +This runs `swift/scripts/check.sh`, which builds `moq-ffi` for the host arch, regenerates the UniFFI Swift bindings, drops a single-slice `MoqFFI.xcframework` into `swift/`, and runs `swift test` against the monolithic local-dev `Package.swift`. Requires macOS with `xcodebuild`. ## See also - Source: [swift/Sources/Moq](https://github.com/moq-dev/moq/tree/main/swift/Sources/Moq) -- Mirror repo: [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) +- Mirror repos: [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) (wrapper), [moq-dev/moq-swift-ffi](https://github.com/moq-dev/moq-swift-ffi) (raw bindings) - The Rust crate this wraps: [moq-net](/lib/rs/crate/moq-net) diff --git a/swift/Package.swift.template b/swift/Package.swift.template index 60fa4def6..8bafcd015 100644 --- a/swift/Package.swift.template +++ b/swift/Package.swift.template @@ -1,8 +1,12 @@ // swift-tools-version:5.9 -// Released manifest for the Swift package at moq-dev/moq-swift. The +// Released manifest for the ergonomic wrapper at moq-dev/moq-swift. The // source-of-truth template lives at swift/Package.swift.template in -// moq-dev/moq; swift/scripts/package.sh substitutes the version and -// xcframework SHA-256 at release time. +// moq-dev/moq; swift/scripts/package.sh substitutes the moq-ffi version pin +// (REPLACE_FFI_VERSION) at release time. +// +// The wrapper versions independently of the bindings (see swift/VERSION). The +// dependency floats to the latest compatible moq-ffi patch via .upToNextMinor, +// so a moq-ffi patch release needs no wrapper re-release. import PackageDescription @@ -10,13 +14,14 @@ let package = Package( name: "Moq", platforms: [.iOS(.v15), .macOS(.v12)], products: [.library(name: "Moq", targets: ["Moq"])], + dependencies: [ + .package(url: "https://github.com/moq-dev/moq-swift-ffi", .upToNextMinor(from: "REPLACE_FFI_VERSION")), + ], targets: [ - .target(name: "Moq", dependencies: ["MoqFFI"], path: "Sources/Moq"), - .target(name: "MoqFFI", dependencies: ["MoqFFIBinary"], path: "Sources/MoqFFI"), - .binaryTarget( - name: "MoqFFIBinary", - url: "REPLACE_URL", - checksum: "REPLACE_CHECKSUM" + .target( + name: "Moq", + dependencies: [.product(name: "MoqFFI", package: "moq-swift-ffi")], + path: "Sources/Moq" ), .testTarget(name: "MoqTests", dependencies: ["Moq"], path: "Tests/MoqTests"), ] diff --git a/swift/README.md b/swift/README.md index 979426aaa..cef21ac1f 100644 --- a/swift/README.md +++ b/swift/README.md @@ -1,85 +1,108 @@ # Moq (Swift) -An ergonomic Swift wrapper around the [moq-ffi](../rs/moq-ffi) UniFFI bindings for [Media over QUIC](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/). +A Swift-native wrapper around the [moq-ffi](../rs/moq-ffi) UniFFI bindings for [Media over QUIC](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/): de-prefixed types, `AsyncSequence` streams, throwing initializers, and `Sendable` handles. -## Install +## Two packages + +The Swift integration ships as two SPM packages, each mirrored to its own repo: -Add the package via Swift Package Manager pointing at the [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) mirror repo: +| Package | Mirror | Versioning | +|---|---|---| +| `Moq` (this wrapper) | [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) | independent (`swift/VERSION`) | +| `MoqFFI` (raw bindings + XCFramework) | [moq-dev/moq-swift-ffi](https://github.com/moq-dev/moq-swift-ffi) | lockstep with the `moq-ffi` crate | + +`Moq` depends on `MoqFFI` at `.upToNextMinor`, so a `moq-ffi` patch flows to consumers with no wrapper release. The split mirrors what `py/` does with `moq-rs` (wrapper) and `moq-ffi` (bindings). + +## Install ```swift -.package(url: "https://github.com/moq-dev/moq-swift", from: "0.2.0"), +.package(url: "https://github.com/moq-dev/moq-swift", from: "0.3.0"), ``` -The mirror repo is updated by [`swift/scripts/publish.sh`](scripts/publish.sh) on every `moq-ffi-v*` tag in the main repo. It carries a bare-semver tag (e.g. `0.2.11`) that SPM can resolve, and a `Package.swift` whose `MoqFFI` binary target points at the `MoqFFI.xcframework.zip` attached to the matching GitHub Release here. +SPM resolves `MoqFFI` (and its prebuilt `MoqFFI.xcframework`, attached to the matching `moq-ffi-v*` GitHub Release) transitively. You only depend on `moq-swift`. ## Quick start ```swift import Moq -let client = MoqClient() -let cs = try await client.connect(url: "https://relay.example.com") +let client = Client() +let session = try await client.connect(to: "https://relay.example.com") -// cs.publisher() and cs.consumer() are always populated: by whatever -// origin you wired via setPublish / setConsume before connect, or by a -// fresh auto-created one for any side you didn't set. The duplex no-config -// path (the typical client) shares one origin between both. -let consumer = cs.consumer() -let announced = try consumer.announced(prefix: "demos/") +// session.publisher and session.consumer are always populated: by whatever +// origin you wired via setPublish / setConsume before connect, or by a fresh +// auto-created one. The duplex no-config path (the typical client) shares one +// origin between both sides. +let announced = try session.consumer.announced(prefix: "demos/") for try await announcement in announced { - print("got broadcast \(announcement.path())") + print("got broadcast \(announcement.path)") - let catalog = try announcement.broadcast().subscribeCatalog() - for try await update in catalog.updates { + let catalog = try announcement.broadcast.subscribeCatalog() + for try await update in catalog { print("catalog: \(update)") } } -cs.shutdown() +session.shutdown() ``` -Cancelling the surrounding Swift `Task` propagates through to the underlying `cancel()` calls on each consumer. `cs.shutdown()` is an alias for `cancel(code: 0)` that documents the convention that code 0 means "no error". - -To publish a broadcast through the auto-created origin: +To publish through the auto-created origin: ```swift -let pub = cs.publisher() -let broadcast = try MoqBroadcastProducer() +let broadcast = try BroadcastProducer() // ... configure tracks on broadcast ... -try pub.announce(path: "my-stream", broadcast: broadcast) +try session.publisher.announce(path: "my-stream", broadcast: broadcast) ``` -A note on enum casing: UniFFI keeps Rust's casing for error variants (every `MoqError` case is PascalCase and carries `message: String`, e.g. `MoqError.Closed(message: "...")`), but plain enums round-trip to lowerCamelCase (`MoqAudioFormat.s16`, `MoqAudioCodec.opus`). +Cancelling the surrounding Swift `Task` propagates through to the underlying `cancel()` calls on each consumer. `session.shutdown()` is an alias for `cancel(code: 0)` (code 0 means "no error"). + +A note on enum casing: `MoqError` keeps Rust's PascalCase variants, each carrying `message: String` (e.g. `MoqError.Closed(message: "...")`); plain enums round-trip to lowerCamelCase (`AudioFormat.s16`, `AudioCodec.opus`). + +## API shape + +The wrapper fully wraps every stateful handle (`Client`, `Session`, `BroadcastProducer`, `TrackConsumer`, …) and re-exports the plain data records/enums under de-prefixed names via typealias (`Frame`, `Catalog`, `Audio`, `Container`, …). Because the records are typealiased, new fields on the `moq-ffi` side flow through automatically; only new FFI *methods* need a matching wrapper method. + +Every consumer conforms to `AsyncSequence`, so `for try await x in consumer` works directly. `TrackConsumer` iterates groups in sequence order; use its `groupsAsArrived` property for arrival order. ## Local development `swift/scripts/check.sh` builds `moq-ffi` for the host, regenerates the UniFFI Swift bindings, builds a single-slice `MoqFFI.xcframework`, and runs `swift test`. Requires macOS with `xcodebuild` and `swift` on `$PATH`. Invoked by `just check-ffi`; skips cleanly on non-macOS hosts. -The `release-swift.yml` workflow triggers on the same `moq-ffi-v*` tag as the Kotlin and Python releases, so the Swift package version always echoes moq-ffi's. +Local development uses one **monolithic** `Package.swift` containing both the `Moq` and `MoqFFI` targets plus the path-based XCFramework, so `swift test` and Xcode work against a single package. The split into two packages exists only in the released artifacts, assembled from the two templates below at release time. Because the FFI module is named `MoqFFI` in both layouts, the wrapper sources (`import MoqFFI`) compile identically either way. ## Layout ```text swift/ - Package.swift Local-dev manifest (path-based MoqFFIBinary; used by check.sh + IDEs) - Package.swift.template Released manifest (URL + checksum; substituted by package.sh) + VERSION Wrapper's independent version (bump by hand to release) + Package.swift Monolithic local-dev manifest (both targets, path-based; used by check.sh + IDEs) + Package.swift.template Released WRAPPER manifest (Moq + dep on moq-swift-ffi; REPLACE_FFI_VERSION) + ffi/Package.swift.template Released FFI manifest (MoqFFI + binaryTarget; REPLACE_URL/REPLACE_CHECKSUM) Sources/ - Moq/ Ergonomic shim (Moq.swift, AsyncSequences.swift, Errors.swift, Session.swift) - MoqFFI/ UniFFI-generated swift (populated by check.sh/package.sh, gitignored) - Tests/MoqTests/ Smoke tests - scripts/ check.sh, package.sh, verify.sh, publish.sh + Moq/ Ergonomic wrapper (Client, Server, Origin, Broadcast, Track, Media, Audio, …) + MoqFFI/ UniFFI-generated swift (populated by check.sh/package-ffi.sh, gitignored) + Tests/MoqTests/ Smoke tests + scripts/ check.sh, package{,-ffi}.sh, verify{,-ffi}.sh, publish{,-ffi}.sh ``` -The two manifests are intentionally separate: the in-repo `Package.swift` is what SPM and Xcode see during local development, while `Package.swift.template` is the source-of-truth for what ships to the mirror. Edit the template when changing the released manifest; never copy the dev-mode form into the release path. +Edit the templates when changing a released manifest; never copy the monolithic dev-mode form into the release path. ## Publishing to SPM -Every `moq-ffi-v*` tag attaches `MoqFFI.xcframework.zip` to the GitHub Release here and mirrors a self-contained Swift package to [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) on a bare-semver tag that SPM can resolve. No extra configuration: the `moq-bot` GitHub App (already used by `release-rs.yml`) has `contents: write` on the mirror, and `release-swift.yml` mints a fresh installation token per run. +Two workflows, mirroring the two packages: -Before the push, a `verify` job builds a throwaway SPM consumer against the staged package (via [`scripts/verify.sh`](scripts/verify.sh)) and runs `swift package resolve` + `swift build`. That resolves the binary target against the just-uploaded `MoqFFI.xcframework.zip` and verifies its SHA-256 checksum, so a manifest SPM cannot resolve never reaches the mirror. +- **`release-swift-ffi.yml`** fires on each `moq-ffi-v*` tag (pushed by release-plz). It builds the per-target libs + bindings, assembles the `MoqFFI` package via `package-ffi.sh`, attaches `MoqFFI.xcframework.zip` to the `moq-ffi-v*` GitHub Release, verifies the staged package resolves (`verify-ffi.sh`), and mirrors it to [moq-dev/moq-swift-ffi](https://github.com/moq-dev/moq-swift-ffi) on a bare-semver tag (`publish-ffi.sh`). +- **`release-swift.yml`** fires on push to `main`/`dev` when `swift/VERSION` (or the wrapper sources) change. It reads `swift/VERSION`, checks whether that tag already exists on the mirror (the release gate, the same model release-plz uses for crates), assembles the wrapper via `package.sh` (substituting the `moq-ffi` pin from `rs/moq-ffi/Cargo.toml`), verifies it resolves against the published `MoqFFI` (`verify.sh`), and publishes to [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) only when the version is new (`publish.sh`). -The `publish` job ("Publish to Swift Package mirror") runs `publish.sh`, which clones the mirror, replaces its tree with the staged package, commits, tags with `${VERSION}` (bare semver), and pushes. +Both `verify` jobs build a throwaway SPM consumer against the staged package before any mirror push, so a manifest SPM cannot resolve never reaches consumers. The `moq-bot` GitHub App mints a fresh installation token per run, scoped to the relevant mirror. -To dry-run locally, run `BUILD_VERSION= ./swift/scripts/publish.sh --dry-run` against a staged tarball. Dry-run uses an anonymous clone (so the mirror must be public, or you must export `SWIFT_MIRROR_TOKEN` to authenticate), stages the diff, and skips the commit and push. +To release a new wrapper version: bump `swift/VERSION` in a PR. On merge, `release-swift.yml` publishes it. + +To dry-run a publish locally against a staged tarball: + +```bash +BUILD_VERSION= ./swift/scripts/publish.sh --dry-run # wrapper -> moq-swift +BUILD_VERSION= ./swift/scripts/publish-ffi.sh --dry-run # bindings -> moq-swift-ffi +``` No Apple Developer account or App Store Connect setup needed. diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift new file mode 100644 index 000000000..e09213d86 --- /dev/null +++ b/swift/Sources/Moq/Aliases.swift @@ -0,0 +1,24 @@ +import MoqFFI + +// Plain data types (records + enums) are re-exported under de-prefixed names. +// These carry no behavior, so a typealias keeps them in lockstep with the +// `moq-ffi` crate automatically. The stateful handle types are fully wrapped +// instead (see Client.swift, Broadcast.swift, etc.), so MoqFFI's `Moq`-prefixed +// classes never appear in the public API. + +public typealias Frame = MoqFFI.MoqFrame +public typealias Catalog = MoqFFI.MoqCatalog +public typealias Video = MoqFFI.MoqVideo +public typealias Audio = MoqFFI.MoqAudio +public typealias AudioFrame = MoqFFI.MoqAudioFrame +public typealias Dimensions = MoqFFI.MoqDimensions +public typealias AudioEncoderInput = MoqFFI.MoqAudioEncoderInput +public typealias AudioEncoderOutput = MoqFFI.MoqAudioEncoderOutput +public typealias AudioDecoderOutput = MoqFFI.MoqAudioDecoderOutput +public typealias AudioFormat = MoqFFI.MoqAudioFormat +public typealias AudioCodec = MoqFFI.MoqAudioCodec +public typealias Container = MoqFFI.Container + +/// The error thrown by every throwing call in this package. Already conforms to +/// `Swift.Error` and `LocalizedError`; see `Errors.swift` for conveniences. +public typealias MoqError = MoqFFI.MoqError diff --git a/swift/Sources/Moq/AsyncSequences.swift b/swift/Sources/Moq/AsyncSequences.swift deleted file mode 100644 index ebe7b9b49..000000000 --- a/swift/Sources/Moq/AsyncSequences.swift +++ /dev/null @@ -1,166 +0,0 @@ -import Foundation -@_exported import MoqFFI - -extension MoqCatalogConsumer { - /// Stream of catalog updates. Terminates when the underlying track ends. - public var updates: AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - while let next = try await self.next() { - try Task.checkCancellation() - continuation.yield(next) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { [weak self] _ in - task.cancel() - self?.cancel() - } - } - } -} - -extension MoqMediaConsumer { - /// Stream of decoded media frames in decode order. Terminates when the underlying track ends. - public var frames: AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - while let frame = try await self.next() { - try Task.checkCancellation() - continuation.yield(frame) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { [weak self] _ in - task.cancel() - self?.cancel() - } - } - } -} - -extension MoqAudioConsumer { - /// Stream of decoded audio frames in the layout declared by - /// `MoqAudioDecoderConfig`. Terminates when the underlying track ends. - public var frames: AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - while let frame = try await self.next() { - try Task.checkCancellation() - continuation.yield(frame) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { [weak self] _ in - task.cancel() - self?.cancel() - } - } - } -} - -extension MoqTrackConsumer { - /// Stream of groups in sequence order, skipping forward if the reader falls behind. - public var groups: AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - while let group = try await self.nextGroup() { - try Task.checkCancellation() - continuation.yield(group) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { [weak self] _ in - task.cancel() - self?.cancel() - } - } - } - - /// Stream of groups in arrival order, including out-of-sequence deliveries. - public var groupsAsArrived: AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - while let group = try await self.recvGroup() { - try Task.checkCancellation() - continuation.yield(group) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { [weak self] _ in - task.cancel() - self?.cancel() - } - } - } -} - -extension MoqGroupConsumer { - /// Stream of raw frame payloads in this group. - public var frames: AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - while let frame = try await self.readFrame() { - try Task.checkCancellation() - continuation.yield(frame) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { [weak self] _ in - task.cancel() - self?.cancel() - } - } - } -} - -extension MoqAnnounced: AsyncSequence { - public typealias Element = MoqAnnouncement - - /// Iterate broadcast announcements directly: `for try await a in announced`. - /// The sequence terminates when the origin closes; cancelling the consuming - /// task cancels the underlying subscription. - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { - AsyncThrowingStream { continuation in - let task = Task { - do { - while let announcement = try await self.next() { - try Task.checkCancellation() - continuation.yield(announcement) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { [weak self] _ in - task.cancel() - self?.cancel() - } - }.makeAsyncIterator() - } -} diff --git a/swift/Sources/Moq/AsyncStream.swift b/swift/Sources/Moq/AsyncStream.swift new file mode 100644 index 000000000..6c0873672 --- /dev/null +++ b/swift/Sources/Moq/AsyncStream.swift @@ -0,0 +1,31 @@ +import Foundation +import MoqFFI + +/// Bridge a `next()`-style native consumer into an `AsyncThrowingStream`. +/// +/// Pulls from `next` until it returns nil (the track/origin ended), finishes on +/// error, and calls `cancel` when the consuming task terminates, so a broken +/// `for await` loop or a cancelled parent `Task` releases the native handle and +/// unblocks any in-flight read. +func moqStream( + cancel: @escaping @Sendable () -> Void, + next: @escaping @Sendable () async throws -> Element? +) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + while let item = try await next() { + try Task.checkCancellation() + continuation.yield(item) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() + cancel() + } + } +} diff --git a/swift/Sources/Moq/Audio.swift b/swift/Sources/Moq/Audio.swift new file mode 100644 index 000000000..3ef1c6751 --- /dev/null +++ b/swift/Sources/Moq/Audio.swift @@ -0,0 +1,49 @@ +import MoqFFI + +/// Read side of a raw-audio track. Iterating yields decoded PCM frames in the +/// layout declared by the `AudioDecoderOutput` passed at subscribe time. +public final class AudioConsumer: AsyncSequence, Sendable { + public typealias Element = AudioFrame + + let ffi: MoqAudioConsumer + + init(_ ffi: MoqAudioConsumer) { + self.ffi = ffi + } + + /// The next frame, or `nil` once the track ends or is closed. + public func next() async throws -> AudioFrame? { + try await ffi.next() + } + + /// Cancel all current and future reads. + public func cancel() { + ffi.cancel() + } + + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in + try await ffi.next() + }.makeAsyncIterator() + } +} + +/// Write side of a raw-audio track. PCM written here is encoded (e.g. to Opus) +/// inside the FFI boundary per the `AudioEncoderInput`/`Output` from publish time. +public final class AudioProducer: Sendable { + let ffi: MoqAudioProducer + + init(_ ffi: MoqAudioProducer) { + self.ffi = ffi + } + + /// Encode and write one PCM frame. + public func write(_ frame: AudioFrame) throws { + try ffi.write(frame: frame) + } + + /// Finish the track and finalize encoding. + public func finish() throws { + try ffi.finish() + } +} diff --git a/swift/Sources/Moq/Broadcast.swift b/swift/Sources/Moq/Broadcast.swift new file mode 100644 index 000000000..b9747828f --- /dev/null +++ b/swift/Sources/Moq/Broadcast.swift @@ -0,0 +1,77 @@ +import Foundation +import MoqFFI + +/// Read side of a broadcast: subscribe to its catalog and tracks. +public final class BroadcastConsumer: Sendable { + let ffi: MoqBroadcastConsumer + + init(_ ffi: MoqBroadcastConsumer) { + self.ffi = ffi + } + + /// Subscribe to the broadcast's catalog (the description of its tracks). + public func subscribeCatalog() throws -> CatalogConsumer { + CatalogConsumer(try ffi.subscribeCatalog()) + } + + /// Subscribe to a track by name, delivering raw frame payloads with no codec + /// or container parsing. + public func subscribeTrack(name: String) throws -> TrackConsumer { + TrackConsumer(try ffi.subscribeTrack(name: name)) + } + + /// Subscribe to a media track, delivering frames in decode order. `container` + /// comes from the catalog; `maxLatencyMs` bounds buffering before skipping a GoP. + public func subscribeMedia(name: String, container: Container, maxLatencyMs: UInt64) throws -> MediaConsumer { + MediaConsumer(try ffi.subscribeMedia(name: name, container: container, maxLatencyMs: maxLatencyMs)) + } + + /// Subscribe to a raw-audio track, decoding to PCM in the layout `output` + /// declares. `catalogAudio` is the matching rendition from the catalog. + public func subscribeAudio(name: String, catalogAudio: Audio, output: AudioDecoderOutput) throws -> AudioConsumer { + AudioConsumer(try ffi.subscribeAudio(name: name, catalogAudio: catalogAudio, output: output)) + } +} + +/// Write side of a broadcast: open tracks and publish frames. Does nothing until +/// announced to an origin (see `OriginProducer.announce`). +public final class BroadcastProducer: Sendable { + let ffi: MoqBroadcastProducer + + public init() throws { + ffi = try MoqBroadcastProducer() + } + + /// A read handle for this broadcast's tracks. + public func consume() throws -> BroadcastConsumer { + BroadcastConsumer(try ffi.consume()) + } + + /// Open a media track. `format` controls how `initData` and frame payloads + /// are interpreted (e.g. `"opus"`, `"avc3"`). + public func publishMedia(format: String, initData: Data) throws -> MediaProducer { + MediaProducer(try ffi.publishMedia(format: format, init: initData)) + } + + /// Open a media track fed by a raw byte stream with inferred frame boundaries + /// (e.g. piped Annex-B H.264). Only self-describing formats are supported. + public func publishMediaStream(format: String) throws -> MediaStreamProducer { + MediaStreamProducer(try ffi.publishMediaStream(format: format)) + } + + /// Open a track for arbitrary byte payloads, with no codec or container. + public func publishTrack(name: String) throws -> TrackProducer { + TrackProducer(try ffi.publishTrack(name: name)) + } + + /// Open a raw-audio track. PCM written via `AudioProducer.write` is encoded + /// (e.g. to Opus) inside the FFI boundary per `input`/`output`. + public func publishAudio(name: String, input: AudioEncoderInput, output: AudioEncoderOutput) throws -> AudioProducer { + AudioProducer(try ffi.publishAudio(name: name, input: input, output: output)) + } + + /// Finish the broadcast, finalizing the catalog stream. + public func finish() throws { + try ffi.finish() + } +} diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift new file mode 100644 index 000000000..0fbfa8a1c --- /dev/null +++ b/swift/Sources/Moq/Client.swift @@ -0,0 +1,81 @@ +import MoqFFI + +/// A MoQ client. Configure the optional knobs, then `connect(to:)`. +public final class Client: Sendable { + let ffi: MoqClient + + public init() { + ffi = MoqClient() + } + + /// Toggle TLS certificate verification. Defaults to on; pass `false` only + /// against a relay with a self-signed certificate during development. + public func setTlsVerify(_ verify: Bool) { + ffi.setTlsDisableVerify(disable: !verify) + } + + /// Set the local UDP socket bind address (defaults to `[::]:0`). Throws if + /// the address cannot be parsed. + public func bind(_ addr: String) throws { + try ffi.setBind(addr: addr) + } + + /// Wire the origin whose local broadcasts get advertised to the remote. If + /// left unset, `connect` auto-creates one, reachable via `Session.publisher`. + public func setPublish(_ origin: OriginProducer?) { + ffi.setPublish(origin: origin?.ffi) + } + + /// Wire the origin used to receive the remote's announcements. If left + /// unset, `connect` auto-creates one, reachable via `Session.consumer`. + public func setConsume(_ origin: OriginProducer?) { + ffi.setConsume(origin: origin?.ffi) + } + + /// Connect and wait for the session to be established. Cancellable via `cancel()`. + public func connect(to url: String) async throws -> Session { + Session(try await ffi.connect(url: url)) + } + + /// Cancel all current and future `connect()` calls. + public func cancel() { + ffi.cancel() + } +} + +/// An established MoQ session. +public final class Session: Sendable { + let ffi: MoqSession + + init(_ ffi: MoqSession) { + self.ffi = ffi + } + + /// The publish-side origin: where local broadcasts are advertised to the + /// remote. Either the one wired via `Client.setPublish`, or auto-created. + public var publisher: OriginProducer { + OriginProducer(ffi.publisher()) + } + + /// The subscribe-side origin: a read handle for the remote's announcements. + /// Either derived from `Client.setConsume`, or auto-created. + public var consumer: OriginConsumer { + OriginConsumer(ffi.consumer()) + } + + /// Suspend until the session is closed. + public func closed() async throws { + try await ffi.closed() + } + + /// Close the session with the given error code. Code 0 means "no error"; + /// prefer `shutdown()` for that case. + public func cancel(code: UInt32) { + ffi.cancel(code: code) + } + + /// Graceful shutdown. Alias for `cancel(code: 0)`. + public func shutdown() { + ffi.shutdown() + } +} diff --git a/swift/Sources/Moq/Errors.swift b/swift/Sources/Moq/Errors.swift index 84595ddfc..8ded079ab 100644 --- a/swift/Sources/Moq/Errors.swift +++ b/swift/Sources/Moq/Errors.swift @@ -1,10 +1,9 @@ -import Foundation -@_exported import MoqFFI +import MoqFFI extension MoqError { /// True for `Cancelled` and `Closed`, which arise from graceful shutdown /// rather than actual failures. Useful for swallowing the expected error - /// that an `AsyncSequence` produces when its consumer cancels. + /// an `AsyncSequence` produces when its consuming task is cancelled. public var isShutdown: Bool { switch self { case .Cancelled, .Closed: return true diff --git a/swift/Sources/Moq/Log.swift b/swift/Sources/Moq/Log.swift new file mode 100644 index 000000000..baaf68893 --- /dev/null +++ b/swift/Sources/Moq/Log.swift @@ -0,0 +1,7 @@ +import MoqFFI + +/// Initialize logging with a level string: "error", "warn", "info", "debug", +/// "trace", or "" (defaults to info). Throws if called more than once. +public func logLevel(_ level: String) throws { + try moqLogLevel(level: level) +} diff --git a/swift/Sources/Moq/Media.swift b/swift/Sources/Moq/Media.swift new file mode 100644 index 000000000..a99bcef96 --- /dev/null +++ b/swift/Sources/Moq/Media.swift @@ -0,0 +1,112 @@ +import Foundation +import MoqFFI + +/// Read side of a broadcast's catalog. Iterating yields catalog updates as the +/// set of tracks changes. +public final class CatalogConsumer: AsyncSequence, Sendable { + public typealias Element = Catalog + + let ffi: MoqCatalogConsumer + + init(_ ffi: MoqCatalogConsumer) { + self.ffi = ffi + } + + /// The next catalog update, or `nil` once the track ends or is closed. + public func next() async throws -> Catalog? { + try await ffi.next() + } + + /// Cancel all current and future reads. + public func cancel() { + ffi.cancel() + } + + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in + try await ffi.next() + }.makeAsyncIterator() + } +} + +/// Read side of a media track. Iterating yields decoded frames in decode order. +public final class MediaConsumer: AsyncSequence, Sendable { + public typealias Element = Frame + + let ffi: MoqMediaConsumer + + init(_ ffi: MoqMediaConsumer) { + self.ffi = ffi + } + + /// The next frame, or `nil` once the track ends or is closed. + public func next() async throws -> Frame? { + try await ffi.next() + } + + /// Cancel all current and future reads. + public func cancel() { + ffi.cancel() + } + + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in + try await ffi.next() + }.makeAsyncIterator() + } +} + +/// Write side of a media track fed pre-framed payloads. +public final class MediaProducer: Sendable { + let ffi: MoqMediaProducer + + init(_ ffi: MoqMediaProducer) { + self.ffi = ffi + } + + /// The track's name. + public var name: String { + get throws { try ffi.name() } + } + + /// Suspend until the track has at least one active consumer. + public func used() async throws { + try await ffi.used() + } + + /// Suspend until the track has no active consumers. + public func unused() async throws { + try await ffi.unused() + } + + /// Write a frame with the given presentation timestamp (microseconds). + public func writeFrame(_ payload: Data, timestampUs: UInt64) throws { + try ffi.writeFrame(payload: payload, timestampUs: timestampUs) + } + + /// Finish the track and finalize encoding. + public func finish() throws { + try ffi.finish() + } +} + +/// Write side of a media track fed a raw byte stream with inferred frame boundaries. +public final class MediaStreamProducer: Sendable { + let ffi: MoqMediaStreamProducer + + init(_ ffi: MoqMediaStreamProducer) { + self.ffi = ffi + } + + /// Push raw stream bytes (e.g. Annex-B H.264). The importer frames whole + /// access units and buffers any partial trailing frame for the next call. + public func write(_ payload: Data) throws { + try ffi.write(payload: payload) + } + + /// Finalize the track. A trailing access unit with no following delimiter is + /// not emitted (matches the moq-cli stdin path). + public func finish() throws { + try ffi.finish() + } +} diff --git a/swift/Sources/Moq/Origin.swift b/swift/Sources/Moq/Origin.swift new file mode 100644 index 000000000..2f26107c7 --- /dev/null +++ b/swift/Sources/Moq/Origin.swift @@ -0,0 +1,112 @@ +import MoqFFI + +/// The publish side of an origin: announce local broadcasts so subscribers can +/// discover them. +public final class OriginProducer: Sendable { + let ffi: MoqOriginProducer + + public init() { + ffi = MoqOriginProducer() + } + + init(_ ffi: MoqOriginProducer) { + self.ffi = ffi + } + + /// A read handle for this origin. + public func consume() -> OriginConsumer { + OriginConsumer(ffi.consume()) + } + + /// Announce a broadcast under the given path so subscribers can find it. + public func announce(path: String, broadcast: BroadcastProducer) throws { + try ffi.announce(path: path, broadcast: broadcast.ffi) + } +} + +/// The subscribe side of an origin: discover announced broadcasts. +public final class OriginConsumer: Sendable { + let ffi: MoqOriginConsumer + + init(_ ffi: MoqOriginConsumer) { + self.ffi = ffi + } + + /// Stream every broadcast announced under a prefix. + public func announced(prefix: String) throws -> Announced { + Announced(try ffi.announced(prefix: prefix)) + } + + /// Wait for a single broadcast announced at an exact path. + public func announcedBroadcast(path: String) throws -> AnnouncedBroadcast { + AnnouncedBroadcast(try ffi.announcedBroadcast(path: path)) + } +} + +/// A stream of broadcast announcements. Iterate directly: +/// `for try await announcement in announced { ... }`. The sequence ends when the +/// origin closes; cancelling the consuming task cancels the subscription. +public final class Announced: AsyncSequence, Sendable { + public typealias Element = Announcement + + let ffi: MoqAnnounced + + init(_ ffi: MoqAnnounced) { + self.ffi = ffi + } + + /// The next announcement, or `nil` once the origin closes. + public func next() async throws -> Announcement? { + (try await ffi.next()).map(Announcement.init) + } + + /// Cancel all current and future `next()` calls. + public func cancel() { + ffi.cancel() + } + + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in + (try await ffi.next()).map(Announcement.init) + }.makeAsyncIterator() + } +} + +/// A single broadcast announcement. +public final class Announcement: Sendable { + let ffi: MoqAnnouncement + + init(_ ffi: MoqAnnouncement) { + self.ffi = ffi + } + + /// The path of the announced broadcast. + public var path: String { + ffi.path() + } + + /// A consumer for the announced broadcast. + public var broadcast: BroadcastConsumer { + BroadcastConsumer(ffi.broadcast()) + } +} + +/// A pending wait for a specific broadcast path. +public final class AnnouncedBroadcast: Sendable { + let ffi: MoqAnnouncedBroadcast + + init(_ ffi: MoqAnnouncedBroadcast) { + self.ffi = ffi + } + + /// Suspend until the broadcast is announced. Throws `Closed` if cancelled or + /// the origin closes first. + public func available() async throws -> BroadcastConsumer { + BroadcastConsumer(try await ffi.available()) + } + + /// Cancel the pending `available()` call. + public func cancel() { + ffi.cancel() + } +} diff --git a/swift/Sources/Moq/Server.swift b/swift/Sources/Moq/Server.swift new file mode 100644 index 000000000..9e319a7ae --- /dev/null +++ b/swift/Sources/Moq/Server.swift @@ -0,0 +1,109 @@ +import MoqFFI + +/// A MoQ server that accepts incoming QUIC/WebTransport sessions. +public final class Server: Sendable { + let ffi: MoqServer + + public init() { + ffi = MoqServer() + } + + /// Set the address to bind, e.g. `127.0.0.1:4443`, `[::]:443`, or `localhost:0`. + /// Validated syntactically here; DNS hostnames resolve at `listen()` time. + public func bind(_ addr: String) throws { + try ffi.setBind(addr: addr) + } + + /// Load TLS certificate chains from PEM files on disk. + public func setTlsCert(_ paths: [String]) { + ffi.setTlsCert(paths: paths) + } + + /// Load TLS private keys from PEM files on disk. + public func setTlsKey(_ paths: [String]) { + ffi.setTlsKey(paths: paths) + } + + /// Generate self-signed TLS certificates for the given hostnames. Clients + /// must pin the fingerprint (see `certFingerprints`) or disable verification. + public func generateTls(hostnames: [String]) { + ffi.setTlsGenerate(hostnames: hostnames) + } + + /// Set the origin to publish broadcasts to incoming sessions. + public func setPublish(_ origin: OriginProducer?) { + ffi.setPublish(origin: origin?.ffi) + } + + /// Set the origin to consume broadcasts from incoming sessions. + public func setConsume(_ origin: OriginProducer?) { + ffi.setConsume(origin: origin?.ffi) + } + + /// Bind the listening socket. Returns the bound local address, useful when + /// binding to an ephemeral port (`:0`). + public func listen() async throws -> String { + try await ffi.listen() + } + + /// Accept the next incoming session. Returns `nil` once the server closes. + /// `listen()` must be called first. + public func accept() async throws -> Request? { + (try await ffi.accept()).map(Request.init) + } + + /// SHA-256 fingerprints of the configured TLS certificates, hex-encoded. + /// Useful for pinning a generated self-signed cert in a WebTransport client. + public func certFingerprints() throws -> [String] { + try ffi.certFingerprints() + } + + /// Cancel any in-flight `listen()` or `accept()` call. + public func cancel() { + ffi.cancel() + } +} + +/// An incoming MoQ session that can be accepted or rejected. +public final class Request: Sendable { + let ffi: MoqRequest + + init(_ ffi: MoqRequest) { + self.ffi = ffi + } + + /// The URL provided by the client, if any. + public var url: String? { + ffi.url() + } + + /// The transport type, e.g. `"quic"`, `"iroh"`, or `"websocket"`. + public var transport: String { + ffi.transport() + } + + /// Override the publish origin for this session, falling back to the server's. + public func setPublish(_ origin: OriginProducer?) { + ffi.setPublish(origin: origin?.ffi) + } + + /// Override the consume origin for this session, falling back to the server's. + public func setConsume(_ origin: OriginProducer?) { + ffi.setConsume(origin: origin?.ffi) + } + + /// Complete the handshake and return the established session. + public func accept() async throws -> Session { + Session(try await ffi.ok()) + } + + /// Reject the session with the given HTTP status code. + public func reject(code: UInt16) async throws { + try await ffi.close(code: code) + } + + /// Cancel any in-flight `accept()` or `reject()` call. + public func cancel() { + ffi.cancel() + } +} diff --git a/swift/Sources/Moq/Session.swift b/swift/Sources/Moq/Session.swift deleted file mode 100644 index d89a7e5a7..000000000 --- a/swift/Sources/Moq/Session.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation -@_exported import MoqFFI - -extension MoqSession { - /// Suspend until the session is closed. - public func waitForClose() async throws { - try await closed() - } -} diff --git a/swift/Sources/Moq/Track.swift b/swift/Sources/Moq/Track.swift new file mode 100644 index 000000000..14ba77779 --- /dev/null +++ b/swift/Sources/Moq/Track.swift @@ -0,0 +1,156 @@ +import Foundation +import MoqFFI + +/// Read side of a raw track. Iterating yields groups in sequence order, skipping +/// forward if the reader falls behind: `for try await group in track { ... }`. +public final class TrackConsumer: AsyncSequence, Sendable { + public typealias Element = GroupConsumer + + let ffi: MoqTrackConsumer + + init(_ ffi: MoqTrackConsumer) { + self.ffi = ffi + } + + /// The next group in sequence order, skipping forward on fall-behind. `nil` + /// once the track ends. + public func nextGroup() async throws -> GroupConsumer? { + (try await ffi.nextGroup()).map(GroupConsumer.init) + } + + /// The next group in arrival order, which may be out of sequence. `nil` once + /// the track ends. + public func recvGroup() async throws -> GroupConsumer? { + (try await ffi.recvGroup()).map(GroupConsumer.init) + } + + /// Read the first frame of the next group. Convenience for one-frame-per-group + /// tracks (status/command style). `nil` once the track ends. + public func readFrame() async throws -> Data? { + try await ffi.readFrame() + } + + /// Cancel all current and future reads. + public func cancel() { + ffi.cancel() + } + + /// Groups in arrival order, including out-of-sequence deliveries. The default + /// `AsyncSequence` iteration uses sequence order instead. + public var groupsAsArrived: AsyncThrowingStream { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in + (try await ffi.recvGroup()).map(GroupConsumer.init) + } + } + + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in + (try await ffi.nextGroup()).map(GroupConsumer.init) + }.makeAsyncIterator() + } +} + +/// Read side of a single group. Iterating yields raw frame payloads. +public final class GroupConsumer: AsyncSequence, Sendable { + public typealias Element = Data + + let ffi: MoqGroupConsumer + + init(_ ffi: MoqGroupConsumer) { + self.ffi = ffi + } + + /// The sequence number of this group within the track. + public var sequence: UInt64 { + ffi.sequence() + } + + /// The next frame payload, or `nil` once the group ends. + public func readFrame() async throws -> Data? { + try await ffi.readFrame() + } + + /// Cancel all current and future reads. + public func cancel() { + ffi.cancel() + } + + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in + try await ffi.readFrame() + }.makeAsyncIterator() + } +} + +/// Write side of a raw track. +public final class TrackProducer: Sendable { + let ffi: MoqTrackProducer + + init(_ ffi: MoqTrackProducer) { + self.ffi = ffi + } + + /// The track's name. + public var name: String { + get throws { try ffi.name() } + } + + /// A read handle for this track (local pub/sub, no origin needed). + public func consume() throws -> TrackConsumer { + TrackConsumer(try ffi.consume()) + } + + /// Suspend until the track has at least one active consumer. + public func used() async throws { + try await ffi.used() + } + + /// Suspend until the track has no active consumers. + public func unused() async throws { + try await ffi.unused() + } + + /// Append a new group, returning a producer for its frames. + public func appendGroup() throws -> GroupProducer { + GroupProducer(try ffi.appendGroup()) + } + + /// Write a single-frame group in one call. + public func writeFrame(_ payload: Data) throws { + try ffi.writeFrame(payload: payload) + } + + /// Finish the track. No more groups can be appended. + public func finish() throws { + try ffi.finish() + } +} + +/// Write side of a single group. +public final class GroupProducer: Sendable { + let ffi: MoqGroupProducer + + init(_ ffi: MoqGroupProducer) { + self.ffi = ffi + } + + /// The sequence number of this group within the track. + public var sequence: UInt64 { + ffi.sequence() + } + + /// A read handle for this group's frames. + public func consume() throws -> GroupConsumer { + GroupConsumer(try ffi.consume()) + } + + /// Write a frame into this group. + public func writeFrame(_ payload: Data) throws { + try ffi.writeFrame(payload: payload) + } + + /// Mark the group complete. No more frames can be written. + public func finish() throws { + try ffi.finish() + } +} diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index 880b4ef61..4091bc0c1 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -3,14 +3,14 @@ import XCTest @testable import Moq final class SmokeTests: XCTestCase { - /// Verifies the native lib loads and the wrapper compiles against - /// the generated API. No network needed: we just instantiate a few - /// types and exercise the cancel path. + /// Verifies the native lib loads and the wrapper compiles against the + /// generated API. No network needed: we just instantiate a few types and + /// exercise the cancel path. func testClientConstructsAndCancels() async throws { - let client = MoqClient() + let client = Client() client.cancel() do { - _ = try await client.connect(url: "https://localhost:0/test") + _ = try await client.connect(to: "https://localhost:0/test") XCTFail("expected error from cancelled client") } catch let error as MoqError { XCTAssertTrue( @@ -27,7 +27,15 @@ final class SmokeTests: XCTestCase { } func testOriginProducerIsConstructible() { - let origin = MoqOriginProducer() + let origin = OriginProducer() _ = origin.consume() } + + func testBroadcastProducerOpensTracks() throws { + let broadcast = try BroadcastProducer() + let track = try broadcast.publishTrack(name: "events") + XCTAssertEqual(try track.name, "events") + try track.finish() + try broadcast.finish() + } } diff --git a/swift/VERSION b/swift/VERSION new file mode 100644 index 000000000..0d91a54c7 --- /dev/null +++ b/swift/VERSION @@ -0,0 +1 @@ +0.3.0 diff --git a/swift/ffi/Package.swift.template b/swift/ffi/Package.swift.template new file mode 100644 index 000000000..428c33de4 --- /dev/null +++ b/swift/ffi/Package.swift.template @@ -0,0 +1,25 @@ +// swift-tools-version:5.9 +// Released manifest for the raw UniFFI bindings package at moq-dev/moq-swift-ffi. +// The source-of-truth template lives at swift/ffi/Package.swift.template in +// moq-dev/moq; swift/scripts/package-ffi.sh substitutes the xcframework URL and +// SHA-256 (REPLACE_URL / REPLACE_CHECKSUM) at release time. +// +// Lockstep with the moq-ffi Rust crate: each moq-ffi-v* tag publishes a matching +// bare-semver tag here. Most callers want the ergonomic `Moq` wrapper at +// moq-dev/moq-swift instead, which depends on this package. + +import PackageDescription + +let package = Package( + name: "MoqFFI", + platforms: [.iOS(.v15), .macOS(.v12)], + products: [.library(name: "MoqFFI", targets: ["MoqFFI"])], + targets: [ + .target(name: "MoqFFI", dependencies: ["MoqFFIBinary"], path: "Sources/MoqFFI"), + .binaryTarget( + name: "MoqFFIBinary", + url: "REPLACE_URL", + checksum: "REPLACE_CHECKSUM" + ), + ] +) diff --git a/swift/scripts/package-ffi.sh b/swift/scripts/package-ffi.sh new file mode 100755 index 000000000..a5152a963 --- /dev/null +++ b/swift/scripts/package-ffi.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Assemble the MoqFFI Swift Package (raw UniFFI bindings): bundle per-target +# static libs into an XCFramework, copy the uniffi-generated Swift source, +# rewrite the Package.swift binary URL+checksum from ffi/Package.swift.template, +# and tar the result. This package is published to moq-dev/moq-swift-ffi, +# lockstep with the moq-ffi crate. The ergonomic wrapper (package.sh) depends +# on it. +# +# Designed to run after rs/moq-ffi/build.sh produces per-target outputs. +# Only macOS hosts can run this (xcodebuild is required). +# +# Usage: +# swift/scripts/package-ffi.sh --version 0.0.0-dev --lib-dir dist --output dist +# +# --version Version baked into Package.swift (the moq-ffi crate version). +# --lib-dir Directory containing per-target moq-ffi outputs. +# --output Destination directory for the .tar.gz + xcframework.zip. +# --bindings-dir Directory with uniffi-bindgen swift output (defaults to +# "$LIB_DIR/bindings"). +# --release-url Release URL prefix used as the XCFramework download +# target. Defaults to the upstream GitHub Releases URL; +# override when publishing from a fork. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SWIFT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKSPACE_DIR="$(cd "$SWIFT_DIR/.." && pwd)" + +VERSION="" +LIB_DIR="" +OUTPUT_DIR="" +BINDINGS_DIR="" +RELEASE_URL_BASE="https://github.com/moq-dev/moq/releases/download" + +while [[ $# -gt 0 ]]; do + case $1 in + --version) + VERSION="$2" + shift 2 + ;; + --lib-dir) + LIB_DIR="$2" + shift 2 + ;; + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + --bindings-dir) + BINDINGS_DIR="$2" + shift 2 + ;; + --release-url) + RELEASE_URL_BASE="$2" + shift 2 + ;; + -h | --help) + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +[[ -z "$VERSION" ]] && { + echo "Error: --version is required" >&2 + exit 1 +} +[[ -z "$LIB_DIR" ]] && { + echo "Error: --lib-dir is required" >&2 + exit 1 +} +[[ -z "$OUTPUT_DIR" ]] && OUTPUT_DIR="dist" +[[ -z "$BINDINGS_DIR" ]] && BINDINGS_DIR="$LIB_DIR/bindings" + +[[ "$(uname)" == "Darwin" ]] || { + echo "Error: package-ffi.sh requires macOS (xcodebuild)" >&2 + exit 1 +} +command -v xcodebuild >/dev/null || { + echo "Error: xcodebuild not found" >&2 + exit 1 +} +command -v swift >/dev/null || { + echo "Error: swift not found" >&2 + exit 1 +} + +# `nix develop` on Darwin exports SDKROOT/DEVELOPER_DIR pointing at the +# nixpkgs-bundled apple-sdk, which the Xcode swift/xcodebuild in PATH refuse to +# load. Drop those so they fall back to `xcrun --show-sdk-path`. No-op in CI +# (macOS runners don't set a nix SDKROOT). Mirrors check.sh. +xcode_sdk_env=() +if [[ "${SDKROOT:-}" == /nix/store/* ]]; then + xcode_sdk_env+=(-u SDKROOT) +fi +if [[ "${DEVELOPER_DIR:-}" == /nix/store/* ]]; then + xcode_sdk_env+=(-u DEVELOPER_DIR) +fi + +mkdir -p "$OUTPUT_DIR" +# Normalize to an absolute path: later steps (zip, swift package +# compute-checksum) run from cd'd subshells, so a relative OUTPUT_DIR +# would resolve against the wrong cwd. +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" + +STAGING=$(mktemp -d) +trap 'rm -rf "$STAGING"' EXIT + +# --- Headers (modulemap + .h) shared by all slices --- +HEADERS_DIR="$STAGING/headers" +mkdir -p "$HEADERS_DIR" +[[ -f "$BINDINGS_DIR/moqFFI.h" ]] || { + echo "Error: missing $BINDINGS_DIR/moqFFI.h" >&2 + exit 1 +} +[[ -f "$BINDINGS_DIR/moqFFI.modulemap" ]] || { + echo "Error: missing $BINDINGS_DIR/moqFFI.modulemap" >&2 + exit 1 +} +[[ -f "$BINDINGS_DIR/moq.swift" ]] || { + echo "Error: missing $BINDINGS_DIR/moq.swift" >&2 + exit 1 +} +cp "$BINDINGS_DIR/moqFFI.h" "$HEADERS_DIR/" +cp "$BINDINGS_DIR/moqFFI.modulemap" "$HEADERS_DIR/module.modulemap" + +# --- Per-slice library prep --- +lib_for() { + echo "$LIB_DIR/$1/libmoq_ffi.a" +} + +ensure_lib() { + local path + path=$(lib_for "$1") + [[ -f "$path" ]] || { + echo "Error: missing static lib for $1 at $path" >&2 + exit 1 + } + echo "$path" +} + +IOS_DEVICE_LIB=$(ensure_lib "aarch64-apple-ios") +IOS_SIM_ARM64=$(ensure_lib "aarch64-apple-ios-sim") +IOS_SIM_X86_64=$(ensure_lib "x86_64-apple-ios") +MAC_UNIVERSAL=$(ensure_lib "universal-apple-darwin") + +# Fat lib for iOS simulator (arm64 + x86_64). +IOS_SIM_FAT="$STAGING/libmoq_ffi-iossim.a" +lipo -create "$IOS_SIM_ARM64" "$IOS_SIM_X86_64" -output "$IOS_SIM_FAT" + +# --- Build XCFramework --- +XCF="$STAGING/MoqFFI.xcframework" +env ${xcode_sdk_env[@]+"${xcode_sdk_env[@]}"} xcodebuild -create-xcframework \ + -library "$IOS_DEVICE_LIB" -headers "$HEADERS_DIR" \ + -library "$IOS_SIM_FAT" -headers "$HEADERS_DIR" \ + -library "$MAC_UNIVERSAL" -headers "$HEADERS_DIR" \ + -output "$XCF" + +# --- Zip and checksum the XCFramework --- +XCF_ZIP="$OUTPUT_DIR/MoqFFI.xcframework.zip" +rm -f "$XCF_ZIP" +(cd "$STAGING" && zip -r -q "$XCF_ZIP" "$(basename "$XCF")") + +# Move/copy to absolute path before computing checksum (swift requires +# it to live in a package). +CHECKSUM=$(cd "$SWIFT_DIR" && env ${xcode_sdk_env[@]+"${xcode_sdk_env[@]}"} swift package compute-checksum "$XCF_ZIP") +echo "XCFramework checksum: $CHECKSUM" + +# --- Assemble Swift package staging dir --- +PKG_NAME="moq-ffi-${VERSION}-swift-ffi" +PKG_STAGE="$STAGING/$PKG_NAME" +mkdir -p "$PKG_STAGE/Sources/MoqFFI" + +cp "$BINDINGS_DIR/moq.swift" "$PKG_STAGE/Sources/MoqFFI/Generated.swift" + +# Dual-license files lifted from the workspace root so the mirror isn't +# licenseless. Both files are required by the MIT OR Apache-2.0 grant. +for license in LICENSE-MIT LICENSE-APACHE; do + [[ -f "$WORKSPACE_DIR/$license" ]] || { + echo "Error: missing $WORKSPACE_DIR/$license" >&2 + exit 1 + } + cp "$WORKSPACE_DIR/$license" "$PKG_STAGE/$license" +done + +# Minimal consumer-facing README. The full developer README lives in the +# monorepo; this one just orients a visitor to moq-dev/moq-swift-ffi. +cat >"$PKG_STAGE/README.md" <&2 + exit 1 +} +URL="${RELEASE_URL_BASE}/moq-ffi-v${VERSION}/MoqFFI.xcframework.zip" +# Token-based substitution: the template carries REPLACE_URL / REPLACE_CHECKSUM +# placeholders, so editing the upstream URL in the template (or passing +# --release-url) never goes silently unsubstituted. +sed -e "s|REPLACE_URL|${URL}|g" \ + -e "s|REPLACE_CHECKSUM|${CHECKSUM}|g" \ + "$TEMPLATE" >"$PKG_STAGE/Package.swift" + +# Fail loudly if any placeholder survived (e.g. someone renamed a token +# in the template without updating this script). Catching it here keeps +# a broken manifest from reaching the mirror. +if grep -q 'REPLACE_URL\|REPLACE_CHECKSUM' "$PKG_STAGE/Package.swift"; then + echo "Error: unresolved REPLACE_* tokens in generated Package.swift" >&2 + grep -n 'REPLACE_URL\|REPLACE_CHECKSUM' "$PKG_STAGE/Package.swift" >&2 + exit 1 +fi + +# Cheap manifest sanity check: parse the generated Package.swift via the +# Swift toolchain. This runs even on PR dry-runs (where the live release +# asset doesn't exist yet) and catches syntax / API breakage in the +# template before it can reach the mirror. +(cd "$PKG_STAGE" && env ${xcode_sdk_env[@]+"${xcode_sdk_env[@]}"} swift package dump-package >/dev/null) + +# --- Archive --- +ARCHIVE="$OUTPUT_DIR/${PKG_NAME}.tar.gz" +tar -czf "$ARCHIVE" -C "$STAGING" "$PKG_NAME" +echo "Created: $ARCHIVE" +echo "Created: $XCF_ZIP" diff --git a/swift/scripts/package.sh b/swift/scripts/package.sh index 6ad87cac1..ea3f2c426 100755 --- a/swift/scripts/package.sh +++ b/swift/scripts/package.sh @@ -1,34 +1,32 @@ #!/usr/bin/env bash set -euo pipefail -# Assemble the moq-ffi Swift Package: bundle per-target static libs into -# an XCFramework, copy the uniffi-generated Swift source, rewrite the -# Package.swift binary URL+checksum, and tar the result. +# Assemble the ergonomic `Moq` Swift Package: stage the wrapper sources + tests, +# rewrite Package.swift from Package.swift.template (substituting the moq-ffi +# dependency pin), and tar the result. This package is published to +# moq-dev/moq-swift and versioned independently of the moq-ffi crate +# (see swift/VERSION). # -# Designed to run after rs/moq-ffi/build.sh produces per-target outputs. -# Only macOS hosts can run this (xcodebuild is required). +# The wrapper is pure Swift: no native build, no xcframework. The prebuilt +# bindings come from the moq-ffi Swift package (package-ffi.sh), which this +# package depends on at .upToNextMinor(from: ). # # Usage: -# swift/scripts/package.sh --version 0.0.0-dev --lib-dir dist --output dist +# swift/scripts/package.sh --version 0.3.0 --output dist # -# --version Version baked into Package.swift. -# --lib-dir Directory containing per-target moq-ffi outputs. -# --output Destination directory for the .tar.gz + xcframework.zip. -# --bindings-dir Directory with uniffi-bindgen swift output (defaults to -# "$LIB_DIR/bindings"). -# --release-url Release URL prefix used as the XCFramework download -# target. Defaults to the upstream GitHub Releases URL; -# override when publishing from a fork. +# --version Wrapper version (from swift/VERSION). Names the tarball only; +# the SPM version comes from the mirror's git tag. +# --ffi-version moq-ffi crate version to pin. Defaults to the version in +# rs/moq-ffi/Cargo.toml. +# --output Destination directory for the .tar.gz. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SWIFT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" WORKSPACE_DIR="$(cd "$SWIFT_DIR/.." && pwd)" VERSION="" -LIB_DIR="" +FFI_VERSION="" OUTPUT_DIR="" -BINDINGS_DIR="" -RELEASE_URL_BASE="https://github.com/moq-dev/moq/releases/download" while [[ $# -gt 0 ]]; do case $1 in @@ -36,22 +34,14 @@ while [[ $# -gt 0 ]]; do VERSION="$2" shift 2 ;; - --lib-dir) - LIB_DIR="$2" + --ffi-version) + FFI_VERSION="$2" shift 2 ;; --output) OUTPUT_DIR="$2" shift 2 ;; - --bindings-dir) - BINDINGS_DIR="$2" - shift 2 - ;; - --release-url) - RELEASE_URL_BASE="$2" - shift 2 - ;; -h | --help) grep '^#' "$0" | sed 's/^# \{0,1\}//' exit 0 @@ -67,103 +57,48 @@ done echo "Error: --version is required" >&2 exit 1 } -[[ -z "$LIB_DIR" ]] && { - echo "Error: --lib-dir is required" >&2 - exit 1 -} [[ -z "$OUTPUT_DIR" ]] && OUTPUT_DIR="dist" -[[ -z "$BINDINGS_DIR" ]] && BINDINGS_DIR="$LIB_DIR/bindings" -[[ "$(uname)" == "Darwin" ]] || { - echo "Error: package.sh requires macOS (xcodebuild)" >&2 - exit 1 -} -command -v xcodebuild >/dev/null || { - echo "Error: xcodebuild not found" >&2 - exit 1 -} +# Default the moq-ffi pin to the crate's current version. This is the SPM analog +# of py's `moq-ffi ~= 0.2.x`: the wrapper floats to the latest compatible patch. +if [[ -z "$FFI_VERSION" ]]; then + FFI_VERSION=$(grep '^version' "$WORKSPACE_DIR/rs/moq-ffi/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/') + [[ -n "$FFI_VERSION" ]] || { + echo "Error: could not read moq-ffi version from rs/moq-ffi/Cargo.toml" >&2 + exit 1 + } + echo "moq-ffi pin (from Cargo.toml): $FFI_VERSION" +fi + command -v swift >/dev/null || { echo "Error: swift not found" >&2 exit 1 } +# `nix develop` on Darwin exports SDKROOT/DEVELOPER_DIR pointing at the +# nixpkgs-bundled apple-sdk, which the Xcode swift in PATH refuses to load. +# Drop those so swift falls back to `xcrun --show-sdk-path`. No-op in CI +# (macOS runners don't set a nix SDKROOT). Mirrors check.sh. +xcode_sdk_env=() +if [[ "${SDKROOT:-}" == /nix/store/* ]]; then + xcode_sdk_env+=(-u SDKROOT) +fi +if [[ "${DEVELOPER_DIR:-}" == /nix/store/* ]]; then + xcode_sdk_env+=(-u DEVELOPER_DIR) +fi + mkdir -p "$OUTPUT_DIR" -# Normalize to an absolute path: later steps (zip, swift package -# compute-checksum) run from cd'd subshells, so a relative OUTPUT_DIR -# would resolve against the wrong cwd. OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" STAGING=$(mktemp -d) trap 'rm -rf "$STAGING"' EXIT -# --- Headers (modulemap + .h) shared by all slices --- -HEADERS_DIR="$STAGING/headers" -mkdir -p "$HEADERS_DIR" -[[ -f "$BINDINGS_DIR/moqFFI.h" ]] || { - echo "Error: missing $BINDINGS_DIR/moqFFI.h" >&2 - exit 1 -} -[[ -f "$BINDINGS_DIR/moqFFI.modulemap" ]] || { - echo "Error: missing $BINDINGS_DIR/moqFFI.modulemap" >&2 - exit 1 -} -[[ -f "$BINDINGS_DIR/moq.swift" ]] || { - echo "Error: missing $BINDINGS_DIR/moq.swift" >&2 - exit 1 -} -cp "$BINDINGS_DIR/moqFFI.h" "$HEADERS_DIR/" -cp "$BINDINGS_DIR/moqFFI.modulemap" "$HEADERS_DIR/module.modulemap" - -# --- Per-slice library prep --- -lib_for() { - echo "$LIB_DIR/$1/libmoq_ffi.a" -} - -ensure_lib() { - local path - path=$(lib_for "$1") - [[ -f "$path" ]] || { - echo "Error: missing static lib for $1 at $path" >&2 - exit 1 - } - echo "$path" -} - -IOS_DEVICE_LIB=$(ensure_lib "aarch64-apple-ios") -IOS_SIM_ARM64=$(ensure_lib "aarch64-apple-ios-sim") -IOS_SIM_X86_64=$(ensure_lib "x86_64-apple-ios") -MAC_UNIVERSAL=$(ensure_lib "universal-apple-darwin") - -# Fat lib for iOS simulator (arm64 + x86_64). -IOS_SIM_FAT="$STAGING/libmoq_ffi-iossim.a" -lipo -create "$IOS_SIM_ARM64" "$IOS_SIM_X86_64" -output "$IOS_SIM_FAT" - -# --- Build XCFramework --- -XCF="$STAGING/MoqFFI.xcframework" -xcodebuild -create-xcframework \ - -library "$IOS_DEVICE_LIB" -headers "$HEADERS_DIR" \ - -library "$IOS_SIM_FAT" -headers "$HEADERS_DIR" \ - -library "$MAC_UNIVERSAL" -headers "$HEADERS_DIR" \ - -output "$XCF" - -# --- Zip and checksum the XCFramework --- -XCF_ZIP="$OUTPUT_DIR/MoqFFI.xcframework.zip" -rm -f "$XCF_ZIP" -(cd "$STAGING" && zip -r -q "$XCF_ZIP" "$(basename "$XCF")") - -# Move/copy to absolute path before computing checksum (swift requires -# it to live in a package). -CHECKSUM=$(cd "$SWIFT_DIR" && swift package compute-checksum "$XCF_ZIP") -echo "XCFramework checksum: $CHECKSUM" - -# --- Assemble Swift package staging dir --- -PKG_NAME="moq-ffi-${VERSION}-swift" +PKG_NAME="moq-${VERSION}-swift" PKG_STAGE="$STAGING/$PKG_NAME" -mkdir -p "$PKG_STAGE/Sources/Moq" "$PKG_STAGE/Sources/MoqFFI" "$PKG_STAGE/Tests/MoqTests" +mkdir -p "$PKG_STAGE/Sources/Moq" "$PKG_STAGE/Tests/MoqTests" cp -R "$SWIFT_DIR/Sources/Moq/." "$PKG_STAGE/Sources/Moq/" cp -R "$SWIFT_DIR/Tests/MoqTests/." "$PKG_STAGE/Tests/MoqTests/" -cp "$BINDINGS_DIR/moq.swift" "$PKG_STAGE/Sources/MoqFFI/Generated.swift" # Dual-license files lifted from the workspace root so the mirror isn't # licenseless. Both files are required by the MIT OR Apache-2.0 grant. @@ -175,14 +110,18 @@ for license in LICENSE-MIT LICENSE-APACHE; do cp "$WORKSPACE_DIR/$license" "$PKG_STAGE/$license" done -# Minimal consumer-facing README. The full developer README lives in -# the monorepo; this one just orients a visitor to moq-dev/moq-swift. +# Minimal consumer-facing README. The full developer README lives in the +# monorepo; this one just orients a visitor to moq-dev/moq-swift. cat >"$PKG_STAGE/README.md" <&2 exit 1 } -URL="${RELEASE_URL_BASE}/moq-ffi-v${VERSION}/MoqFFI.xcframework.zip" -# Token-based substitution: the template carries REPLACE_URL / REPLACE_VERSION -# / REPLACE_CHECKSUM placeholders, so editing the upstream URL in the template -# (or passing --release-url) never goes silently unsubstituted. -sed -e "s|REPLACE_URL|${URL}|g" \ - -e "s|REPLACE_VERSION|${VERSION}|g" \ - -e "s|REPLACE_CHECKSUM|${CHECKSUM}|g" \ +sed -e "s|REPLACE_FFI_VERSION|${FFI_VERSION}|g" \ "$TEMPLATE" >"$PKG_STAGE/Package.swift" -# Fail loudly if any placeholder survived (e.g. someone renamed a token -# in the template without updating this script). Catching it here keeps -# a broken manifest from reaching the mirror. -if grep -q 'REPLACE_URL\|REPLACE_VERSION\|REPLACE_CHECKSUM' "$PKG_STAGE/Package.swift"; then - echo "Error: unresolved REPLACE_* tokens in generated Package.swift" >&2 - grep -n 'REPLACE_URL\|REPLACE_VERSION\|REPLACE_CHECKSUM' "$PKG_STAGE/Package.swift" >&2 +if grep -q 'REPLACE_FFI_VERSION' "$PKG_STAGE/Package.swift"; then + echo "Error: unresolved REPLACE_FFI_VERSION token in generated Package.swift" >&2 exit 1 fi # Cheap manifest sanity check: parse the generated Package.swift via the -# Swift toolchain. This runs even on PR dry-runs (where the live release -# asset doesn't exist yet) and catches syntax / API breakage in the -# template before it can reach the mirror. -(cd "$PKG_STAGE" && swift package dump-package >/dev/null) +# Swift toolchain. Catches syntax / API breakage in the template before it +# can reach the mirror. Does not resolve the moq-ffi dependency. +(cd "$PKG_STAGE" && env ${xcode_sdk_env[@]+"${xcode_sdk_env[@]}"} swift package dump-package >/dev/null) -# --- Archive --- ARCHIVE="$OUTPUT_DIR/${PKG_NAME}.tar.gz" tar -czf "$ARCHIVE" -C "$STAGING" "$PKG_NAME" echo "Created: $ARCHIVE" -echo "Created: $XCF_ZIP" diff --git a/swift/scripts/publish-ffi.sh b/swift/scripts/publish-ffi.sh new file mode 100755 index 000000000..4c42db37b --- /dev/null +++ b/swift/scripts/publish-ffi.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Push the staged MoqFFI Swift Package to the moq-dev/moq-swift-ffi mirror repo +# on a bare-semver tag matching the moq-ffi crate version (e.g. 0.2.17). SPM +# consumers point at the mirror because Package.swift must live at the root of +# the resolved tag, and SPM only recognizes semver tags (X.Y.Z or vX.Y.Z), not +# the prefixed moq-ffi-v* tags used here. +# +# Required environment: +# BUILD_VERSION - moq-ffi crate version (e.g. 0.2.17) +# SWIFT_MIRROR_TOKEN - PAT or GitHub App token with contents:write on the repo +# +# Optional environment: +# SWIFT_FFI_MIRROR_REPO - defaults to moq-dev/moq-swift-ffi +# GIT_AUTHOR_NAME - defaults to "moq-swift-release" +# GIT_AUTHOR_EMAIL - defaults to "release@moq.dev" +# +# Flags: +# --dry-run Stage and diff against the mirror but skip the commit, +# tag, and push. +# +# Expects the staged tarball under `swift-out/` as +# `moq-ffi-${BUILD_VERSION}-swift-ffi.tar.gz` (produced by package-ffi.sh). + +DRY_RUN=false +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + -h | --help) + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +: "${BUILD_VERSION:?BUILD_VERSION is required}" +if [[ "$DRY_RUN" != true ]]; then + : "${SWIFT_MIRROR_TOKEN:?SWIFT_MIRROR_TOKEN is required (or pass --dry-run)}" +fi + +MIRROR_REPO="${SWIFT_FFI_MIRROR_REPO:-moq-dev/moq-swift-ffi}" +MIRROR_TAG="${BUILD_VERSION}" +SOURCE_TAG="moq-ffi-v${BUILD_VERSION}" + +TARBALL="swift-out/moq-ffi-${BUILD_VERSION}-swift-ffi.tar.gz" +[[ -f "$TARBALL" ]] || { + echo "Error: missing $TARBALL" >&2 + exit 1 +} + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# --- 1. Clone the mirror --- +if [[ -n "${SWIFT_MIRROR_TOKEN:-}" ]]; then + CLONE_URL="https://x-access-token:${SWIFT_MIRROR_TOKEN}@github.com/${MIRROR_REPO}" +else + CLONE_URL="https://github.com/${MIRROR_REPO}" +fi +git clone --depth 1 "$CLONE_URL" "$WORK/mirror" 2>&1 | sed "s|${SWIFT_MIRROR_TOKEN:-__no_token__}|***|g" + +# --- 2. Idempotency: skip if the mirror tag already exists --- +if [[ -n "$(git -C "$WORK/mirror" ls-remote --tags origin "refs/tags/${MIRROR_TAG}")" ]]; then + echo "Mirror tag ${MIRROR_TAG} already exists on ${MIRROR_REPO}. Nothing to publish." + exit 0 +fi + +# --- 3. Extract staged package --- +tar -xzf "$TARBALL" -C "$WORK" +STAGED="$WORK/moq-ffi-${BUILD_VERSION}-swift-ffi" +[[ -d "$STAGED" ]] || { + echo "Error: tarball did not contain $STAGED" >&2 + exit 1 +} + +# --- 4. Replace mirror tree with staged contents (preserving .git) --- +rsync --archive --delete --exclude='.git' "$STAGED/" "$WORK/mirror/" + +# --- 5. Summary diff (always shown, helpful for dry-runs and audit logs) --- +echo "--- diff against ${MIRROR_REPO} HEAD ---" +git -C "$WORK/mirror" add -A +git -C "$WORK/mirror" diff --cached --stat +echo "---" + +# --- 6. Commit / tag / push (skipped in dry-run) --- +if [[ "$DRY_RUN" == true ]]; then + echo "Dry-run: not committing or pushing." + exit 0 +fi + +if git -C "$WORK/mirror" diff --cached --quiet; then + echo "No changes to publish to ${MIRROR_REPO}. (Tag ${MIRROR_TAG} would be a no-op commit.)" + git -C "$WORK/mirror" tag "${MIRROR_TAG}" + git -C "$WORK/mirror" push origin "refs/tags/${MIRROR_TAG}" + exit 0 +fi + +git -C "$WORK/mirror" config user.name "${GIT_AUTHOR_NAME:-moq-swift-release}" +git -C "$WORK/mirror" config user.email "${GIT_AUTHOR_EMAIL:-release@moq.dev}" + +git -C "$WORK/mirror" commit -m "Release ${MIRROR_TAG} (mirrors ${SOURCE_TAG})" +git -C "$WORK/mirror" tag "${MIRROR_TAG}" +# Push to refs/heads/main explicitly so first-publish to an empty repo lands the +# branch under the expected name regardless of the runner's init.defaultBranch. +git -C "$WORK/mirror" push origin "HEAD:refs/heads/main" +git -C "$WORK/mirror" push origin "refs/tags/${MIRROR_TAG}" + +echo "Published ${MIRROR_REPO}@${MIRROR_TAG}" diff --git a/swift/scripts/publish.sh b/swift/scripts/publish.sh index 1ae3ad14b..a37acb6bf 100755 --- a/swift/scripts/publish.sh +++ b/swift/scripts/publish.sh @@ -1,16 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -# Push the staged Swift Package contents to the moq-dev/moq-swift mirror -# repo on a bare-semver tag (e.g. 0.2.11). SPM consumers point at the -# mirror instead of this monorepo because Package.swift must live at -# the root of the resolved tag, and SPM only recognizes semver tags -# (X.Y.Z or vX.Y.Z), not the prefixed moq-ffi-v* tags used here. +# Push the staged `Moq` wrapper package to the moq-dev/moq-swift mirror repo on +# a bare-semver tag from swift/VERSION (e.g. 0.3.0). SPM consumers point at the +# mirror because Package.swift must live at the root of the resolved tag, and +# the wrapper versions independently of the moq-ffi crate. # # Required environment: -# BUILD_VERSION - version string (e.g. 0.2.11) -# SWIFT_MIRROR_TOKEN - PAT or GitHub App token with contents:write -# on $MIRROR_REPO +# BUILD_VERSION - wrapper version, from swift/VERSION (e.g. 0.3.0) +# SWIFT_MIRROR_TOKEN - PAT or GitHub App token with contents:write on the repo # # Optional environment: # SWIFT_MIRROR_REPO - defaults to moq-dev/moq-swift @@ -18,12 +16,11 @@ set -euo pipefail # GIT_AUTHOR_EMAIL - defaults to "release@moq.dev" # # Flags: -# --dry-run Stage and diff against the mirror but skip the -# commit, tag, and push. Useful for verifying the -# pipeline locally without touching the mirror. +# --dry-run Stage and diff against the mirror but skip the commit, +# tag, and push. # -# Expects the staged Swift package tarball under `swift-out/`, produced -# by package.sh as `moq-ffi-${BUILD_VERSION}-swift.tar.gz`. +# Expects the staged tarball under `swift-out/` as +# `moq-${BUILD_VERSION}-swift.tar.gz` (produced by package.sh). DRY_RUN=false while [[ $# -gt 0 ]]; do @@ -49,12 +46,9 @@ if [[ "$DRY_RUN" != true ]]; then fi MIRROR_REPO="${SWIFT_MIRROR_REPO:-moq-dev/moq-swift}" -# SPM only resolves bare-semver tags; the moq-ffi-v* prefix used in -# this monorepo would be invisible to SPM, so the mirror gets a stripped tag. MIRROR_TAG="${BUILD_VERSION}" -SOURCE_TAG="moq-ffi-v${BUILD_VERSION}" -TARBALL="swift-out/moq-ffi-${BUILD_VERSION}-swift.tar.gz" +TARBALL="swift-out/moq-${BUILD_VERSION}-swift.tar.gz" [[ -f "$TARBALL" ]] || { echo "Error: missing $TARBALL" >&2 exit 1 @@ -64,8 +58,6 @@ WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT # --- 1. Clone the mirror --- -# Dry-run uses a token if provided (so private mirrors work) but falls -# back to anonymous when the env var is unset. if [[ -n "${SWIFT_MIRROR_TOKEN:-}" ]]; then CLONE_URL="https://x-access-token:${SWIFT_MIRROR_TOKEN}@github.com/${MIRROR_REPO}" else @@ -74,7 +66,6 @@ fi git clone --depth 1 "$CLONE_URL" "$WORK/mirror" 2>&1 | sed "s|${SWIFT_MIRROR_TOKEN:-__no_token__}|***|g" # --- 2. Idempotency: skip if the mirror tag already exists --- -# ls-remote with a literal refname does exact matching on the remote. if [[ -n "$(git -C "$WORK/mirror" ls-remote --tags origin "refs/tags/${MIRROR_TAG}")" ]]; then echo "Mirror tag ${MIRROR_TAG} already exists on ${MIRROR_REPO}. Nothing to publish." exit 0 @@ -82,7 +73,7 @@ fi # --- 3. Extract staged package --- tar -xzf "$TARBALL" -C "$WORK" -STAGED="$WORK/moq-ffi-${BUILD_VERSION}-swift" +STAGED="$WORK/moq-${BUILD_VERSION}-swift" [[ -d "$STAGED" ]] || { echo "Error: tarball did not contain $STAGED" >&2 exit 1 @@ -105,7 +96,6 @@ fi if git -C "$WORK/mirror" diff --cached --quiet; then echo "No changes to publish to ${MIRROR_REPO}. (Tag ${MIRROR_TAG} would be a no-op commit.)" - # Still create and push the tag so consumers can pin to this version. git -C "$WORK/mirror" tag "${MIRROR_TAG}" git -C "$WORK/mirror" push origin "refs/tags/${MIRROR_TAG}" exit 0 @@ -114,11 +104,10 @@ fi git -C "$WORK/mirror" config user.name "${GIT_AUTHOR_NAME:-moq-swift-release}" git -C "$WORK/mirror" config user.email "${GIT_AUTHOR_EMAIL:-release@moq.dev}" -git -C "$WORK/mirror" commit -m "Release ${MIRROR_TAG} (mirrors ${SOURCE_TAG})" +git -C "$WORK/mirror" commit -m "Release ${MIRROR_TAG}" git -C "$WORK/mirror" tag "${MIRROR_TAG}" -# Push to refs/heads/main explicitly so first-publish to an empty repo -# lands the branch under the expected name regardless of the runner's -# init.defaultBranch config. +# Push to refs/heads/main explicitly so first-publish to an empty repo lands the +# branch under the expected name regardless of the runner's init.defaultBranch. git -C "$WORK/mirror" push origin "HEAD:refs/heads/main" git -C "$WORK/mirror" push origin "refs/tags/${MIRROR_TAG}" diff --git a/swift/scripts/verify-ffi.sh b/swift/scripts/verify-ffi.sh new file mode 100755 index 000000000..54e5b3340 --- /dev/null +++ b/swift/scripts/verify-ffi.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Smoke-test a staged MoqFFI Swift package by building a throwaway SPM consumer +# that depends on it via `.package(path:)`. Runs `swift package resolve` +# (downloads MoqFFI.xcframework.zip and verifies its SHA-256 against the +# manifest's checksum) and `swift build` (compiles + links against the host +# slice of the xcframework). +# +# This catches a class of release regression where the staged Package.swift +# looks textually fine but SPM cannot actually resolve it. Used by +# release-swift-ffi.yml as a gate *before* the mirror push, so a broken manifest +# never reaches consumers. +# +# Usage: +# swift/scripts/verify-ffi.sh --staged-dir +# swift/scripts/verify-ffi.sh --tarball +# +# Exactly one of --staged-dir / --tarball must be passed. + +STAGED_DIR="" +TARBALL="" + +while [[ $# -gt 0 ]]; do + case $1 in + --staged-dir) + STAGED_DIR="$2" + shift 2 + ;; + --tarball) + TARBALL="$2" + shift 2 + ;; + -h | --help) + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -n "$STAGED_DIR" && -n "$TARBALL" ]]; then + echo "Error: pass exactly one of --staged-dir or --tarball" >&2 + exit 1 +fi +if [[ -z "$STAGED_DIR" && -z "$TARBALL" ]]; then + echo "Error: --staged-dir or --tarball is required" >&2 + exit 1 +fi + +command -v swift >/dev/null || { + echo "Error: swift not found on PATH" >&2 + exit 1 +} + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +if [[ -n "$TARBALL" ]]; then + [[ -f "$TARBALL" ]] || { + echo "Error: tarball not found: $TARBALL" >&2 + exit 1 + } + tar -xzf "$TARBALL" -C "$WORK" + # The tarball wraps a single top-level moq-ffi-${VERSION}-swift-ffi dir. + extracted=("$WORK"/moq-ffi-*-swift-ffi) + [[ ${#extracted[@]} -eq 1 && -d "${extracted[0]}" ]] || { + echo "Error: expected exactly one moq-ffi-*-swift-ffi dir in tarball, got: ${extracted[*]}" >&2 + exit 1 + } + STAGED_DIR="${extracted[0]}" +fi + +# Resolve to absolute path; SPM resolves relative .package(path:) against +# the consumer manifest, which lives under $WORK below. +STAGED_DIR=$(cd "$STAGED_DIR" && pwd) +[[ -f "$STAGED_DIR/Package.swift" ]] || { + echo "Error: $STAGED_DIR/Package.swift missing" >&2 + exit 1 +} + +echo "verify-ffi: staged package at $STAGED_DIR" +echo "verify-ffi: --- Package.swift ---" +cat "$STAGED_DIR/Package.swift" +echo "verify-ffi: ---" + +# SPM derives a path-based package's identity from the final path component, +# not from the manifest's `name:` field. Expose the staged dir under the +# published mirror name so the smoke project's `.product(package:)` reference +# matches the identity real consumers see. +PKG_IDENTITY="moq-swift-ffi" +PKG_LINK="$WORK/$PKG_IDENTITY" +ln -s "$STAGED_DIR" "$PKG_LINK" + +SMOKE="$WORK/smoke" +mkdir -p "$SMOKE/Sources/Smoke" + +cat >"$SMOKE/Package.swift" <"$SMOKE/Sources/Smoke/main.swift" <<'EOF' +import MoqFFI +// Verify that the binary target's symbols are linkable, not just resolvable. +let client = MoqClient() +client.cancel() +print("moq-swift-ffi verify ok") +EOF + +cd "$SMOKE" +echo "verify-ffi: swift package resolve" +swift package resolve +echo "verify-ffi: swift build" +swift build +echo "verify-ffi: ok" diff --git a/swift/scripts/verify.sh b/swift/scripts/verify.sh index 2073ce704..8c0017a83 100755 --- a/swift/scripts/verify.sh +++ b/swift/scripts/verify.sh @@ -1,20 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -# Smoke-test a staged Swift package by building a throwaway SPM consumer -# project that depends on it via `.package(path:)`. Runs `swift package -# resolve` (downloads MoqFFI.xcframework.zip and verifies its SHA-256 -# against the manifest's checksum) and `swift build` (compiles + links -# against the host slice of the xcframework). +# Smoke-test a staged `Moq` wrapper package by building a throwaway SPM consumer +# that depends on it via `.package(path:)`. The wrapper itself depends on the +# published moq-dev/moq-swift-ffi package (URL), so `swift package resolve` +# transitively fetches the bindings + their XCFramework from GitHub, and +# `swift build` compiles + links the whole stack. # -# This catches a class of release regression where the staged -# Package.swift looks textually fine but SPM cannot actually resolve it. -# Used by release-swift.yml as a gate *before* the mirror push, so a -# broken manifest never reaches consumers. +# This is the cross-package gate: it proves the wrapper's moq-ffi pin actually +# resolves against a real published FFI release before the wrapper reaches the +# mirror. It requires network access and that the pinned moq-ffi version is +# already published (true on a normal release; a correct failure otherwise). # # Usage: # swift/scripts/verify.sh --staged-dir -# swift/scripts/verify.sh --tarball +# swift/scripts/verify.sh --tarball # # Exactly one of --staged-dir / --tarball must be passed. @@ -65,10 +65,10 @@ if [[ -n "$TARBALL" ]]; then exit 1 } tar -xzf "$TARBALL" -C "$WORK" - # The tarball wraps a single top-level moq-ffi-${VERSION}-swift dir. - extracted=("$WORK"/moq-ffi-*-swift) + # The tarball wraps a single top-level moq-${VERSION}-swift dir. + extracted=("$WORK"/moq-*-swift) [[ ${#extracted[@]} -eq 1 && -d "${extracted[0]}" ]] || { - echo "Error: expected exactly one moq-ffi-*-swift dir in tarball, got: ${extracted[*]}" >&2 + echo "Error: expected exactly one moq-*-swift dir in tarball, got: ${extracted[*]}" >&2 exit 1 } STAGED_DIR="${extracted[0]}" @@ -87,11 +87,9 @@ echo "verify: --- Package.swift ---" cat "$STAGED_DIR/Package.swift" echo "verify: ---" -# SPM derives a path-based package's identity from the final path -# component, not from the manifest's `name:` field. Expose the staged -# dir under the published mirror name so the smoke project's -# `.product(package:)` reference matches the identity real consumers -# see when depending on github.com/moq-dev/moq-swift. +# SPM derives a path-based package's identity from the final path component. +# Expose the staged dir under the published mirror name so the smoke project's +# `.product(package:)` reference matches the identity real consumers see. PKG_IDENTITY="moq-swift" PKG_LINK="$WORK/$PKG_IDENTITY" ln -s "$STAGED_DIR" "$PKG_LINK" @@ -121,12 +119,14 @@ EOF cat >"$SMOKE/Sources/Smoke/main.swift" <<'EOF' import Moq -// Verify that the binary target's symbols are linkable, not just resolvable. +// Verify the wrapper + transitive MoqFFI binding symbols link, not just resolve. +let client = Client() +client.cancel() print("moq-swift verify ok") EOF cd "$SMOKE" -echo "verify: swift package resolve" +echo "verify: swift package resolve (fetches moq-swift-ffi transitively)" swift package resolve echo "verify: swift build" swift build From 876f474f0f6d0e28b435fdd60eaf83079fe51923 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 30 May 2026 12:45:16 -0700 Subject: [PATCH 2/3] swift: make wrapper verify tolerant of a not-yet-published FFI mirror git-tag-exists hard-fails when moq-dev/moq-swift-ffi doesn't exist yet (the bootstrap state). Fall back to exists=false so the verify job skips the cross-package resolve instead of failing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release-swift.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-swift.yml b/.github/workflows/release-swift.yml index dfb903ec1..978284bae 100644 --- a/.github/workflows/release-swift.yml +++ b/.github/workflows/release-swift.yml @@ -111,7 +111,11 @@ jobs: id: ffi env: FFI_VERSION: ${{ needs.build.outputs.ffi_version }} - run: .github/scripts/release.sh git-tag-exists moq-dev/moq-swift-ffi "$FFI_VERSION" + # Treat a missing mirror repo / unreachable remote as "not published" + # rather than a hard failure, so the first bootstrap (before + # moq-swift-ffi exists) skips the cross-package resolve instead of + # failing the job. publish.sh stays idempotent regardless. + run: .github/scripts/release.sh git-tag-exists moq-dev/moq-swift-ffi "$FFI_VERSION" || echo "exists=false" >> "$GITHUB_OUTPUT" - name: Download staged package if: steps.ffi.outputs.exists == 'true' From ad1dc579e947878994bed677e528bb2447af7825 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 30 May 2026 15:09:58 -0700 Subject: [PATCH 3/3] swift: rename release-swift.yml to release-swift-lib.yml Symmetric pairing with release-swift-ffi.yml: -ffi builds the bindings package, -lib publishes the ergonomic library wrapper. Updates the workflow name, its own path filters, the concurrency group, and the references in release-swift-ffi.yml, release-brew.yml, swift/README.md, and CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release-brew.yml | 2 +- .github/workflows/release-swift-ffi.yml | 2 +- .../{release-swift.yml => release-swift-lib.yml} | 8 ++++---- CLAUDE.md | 2 +- swift/README.md | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) rename .github/workflows/{release-swift.yml => release-swift-lib.yml} (97%) diff --git a/.github/workflows/release-brew.yml b/.github/workflows/release-brew.yml index b7f0c5476..2b0c09a42 100644 --- a/.github/workflows/release-brew.yml +++ b/.github/workflows/release-brew.yml @@ -10,7 +10,7 @@ name: release-brew # download them. # # Authenticates as the moq-bot GitHub App. The APP_ID and APP_PRIVATE_KEY -# repo secrets are already wired (see release-swift.yml). The minted +# repo secrets are already wired (see release-swift-lib.yml). The minted # installation token is scoped to moq-dev/homebrew-tap and expires in 1 # hour. diff --git a/.github/workflows/release-swift-ffi.yml b/.github/workflows/release-swift-ffi.yml index 3b932c1b2..23e3a3705 100644 --- a/.github/workflows/release-swift-ffi.yml +++ b/.github/workflows/release-swift-ffi.yml @@ -4,7 +4,7 @@ name: Release Swift FFI # prebuilt XCFramework). Driven by the `moq-ffi-v*` tag that release-plz pushes # when it bumps rs/moq-ffi/Cargo.toml, so this package stays lockstep with the # moq-ffi crate. The ergonomic `Moq` wrapper is released separately -# (release-swift.yml) and versions independently. +# (release-swift-lib.yml) and versions independently. on: push: diff --git a/.github/workflows/release-swift.yml b/.github/workflows/release-swift-lib.yml similarity index 97% rename from .github/workflows/release-swift.yml rename to .github/workflows/release-swift-lib.yml index 978284bae..86a6467db 100644 --- a/.github/workflows/release-swift.yml +++ b/.github/workflows/release-swift-lib.yml @@ -1,4 +1,4 @@ -name: Release Swift +name: Release Swift Lib # Publishes the ergonomic `Moq` Swift wrapper to the moq-dev/moq-swift mirror. # This is the package most callers install; the raw `MoqFFI` bindings it depends @@ -24,7 +24,7 @@ on: - "swift/scripts/package.sh" - "swift/scripts/publish.sh" - "swift/scripts/verify.sh" - - ".github/workflows/release-swift.yml" + - ".github/workflows/release-swift-lib.yml" - ".github/scripts/release.sh" pull_request: paths: @@ -35,7 +35,7 @@ on: - "swift/scripts/package.sh" - "swift/scripts/publish.sh" - "swift/scripts/verify.sh" - - ".github/workflows/release-swift.yml" + - ".github/workflows/release-swift-lib.yml" - ".github/scripts/release.sh" permissions: @@ -44,7 +44,7 @@ permissions: # Separate PR builds from mainline publishes so a PR run can't queue ahead of # (and delay) a release on main. concurrency: - group: release-swift-${{ github.event_name }}-${{ github.ref }} + group: release-swift-lib-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: diff --git a/CLAUDE.md b/CLAUDE.md index 78308662f..415970f42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi /swift/ # Swift over rs/moq-ffi (SwiftPM). Split like py: the # ergonomic `Moq` wrapper (Sources/Moq) versions # independently via swift/VERSION and mirrors to - # moq-dev/moq-swift on a VERSION bump (release-swift.yml, + # moq-dev/moq-swift on a VERSION bump (release-swift-lib.yml, # registry-gated like release-plz); the raw `MoqFFI` # bindings + XCFramework mirror to moq-dev/moq-swift-ffi # lockstep with the crate on each moq-ffi-v* tag diff --git a/swift/README.md b/swift/README.md index cef21ac1f..74a427b75 100644 --- a/swift/README.md +++ b/swift/README.md @@ -92,11 +92,11 @@ Edit the templates when changing a released manifest; never copy the monolithic Two workflows, mirroring the two packages: - **`release-swift-ffi.yml`** fires on each `moq-ffi-v*` tag (pushed by release-plz). It builds the per-target libs + bindings, assembles the `MoqFFI` package via `package-ffi.sh`, attaches `MoqFFI.xcframework.zip` to the `moq-ffi-v*` GitHub Release, verifies the staged package resolves (`verify-ffi.sh`), and mirrors it to [moq-dev/moq-swift-ffi](https://github.com/moq-dev/moq-swift-ffi) on a bare-semver tag (`publish-ffi.sh`). -- **`release-swift.yml`** fires on push to `main`/`dev` when `swift/VERSION` (or the wrapper sources) change. It reads `swift/VERSION`, checks whether that tag already exists on the mirror (the release gate, the same model release-plz uses for crates), assembles the wrapper via `package.sh` (substituting the `moq-ffi` pin from `rs/moq-ffi/Cargo.toml`), verifies it resolves against the published `MoqFFI` (`verify.sh`), and publishes to [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) only when the version is new (`publish.sh`). +- **`release-swift-lib.yml`** fires on push to `main`/`dev` when `swift/VERSION` (or the wrapper sources) change. It reads `swift/VERSION`, checks whether that tag already exists on the mirror (the release gate, the same model release-plz uses for crates), assembles the wrapper via `package.sh` (substituting the `moq-ffi` pin from `rs/moq-ffi/Cargo.toml`), verifies it resolves against the published `MoqFFI` (`verify.sh`), and publishes to [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift) only when the version is new (`publish.sh`). Both `verify` jobs build a throwaway SPM consumer against the staged package before any mirror push, so a manifest SPM cannot resolve never reaches consumers. The `moq-bot` GitHub App mints a fresh installation token per run, scoped to the relevant mirror. -To release a new wrapper version: bump `swift/VERSION` in a PR. On merge, `release-swift.yml` publishes it. +To release a new wrapper version: bump `swift/VERSION` in a PR. On merge, `release-swift-lib.yml` publishes it. To dry-run a publish locally against a staged tarball: