diff --git a/swift/Makefile b/swift/Makefile index 4071ff0..8b9a862 100644 --- a/swift/Makefile +++ b/swift/Makefile @@ -50,15 +50,70 @@ ios-sim: ## Builds TailscaleKit for iOS to swift/build/Build/Products/Release-i CODE_SIGNING_ALLOWED=NO | $(XCPRETTIFIER) .PHONY: ios-fat -ios-fat: ios-sim ios ## Builds TailscaleKit.xcframework to swift/build/Build/Products/Release-iphonefat +ios-fat: ios-sim ios ## Builds TailscaleKit.xcframework (iOS + simulator) to swift/build/Build/Products/Release-iphonefat @echo @echo "::: Building TailscaleKit.xcframework for ios and ios-simulator :::" + $(MAKE) inject-privacy-manifest-ios mkdir -p ./build/Build/Products/Release-iphonefat xcodebuild -create-xcframework \ -framework ./build/Build/Products/Release-iphoneos/TailscaleKit.framework \ -framework ./build/Build/Products/Release-iphonesimulator/TailscaleKit.framework \ -output ./build/Build/Products/Release-iphonefat/TailscaleKit.xcframework +# The simulator slice belongs in a distributable xcframework and does not put an +# App Store submission at risk. An xcframework is a container, not the submitted +# artifact: Xcode resolves it at build time and embeds only the slice matching the +# destination, so an app archived for the App Store carries the ios-arm64 slice +# alone. The rule people remember — "no simulator binaries in a submission" — comes +# from fat frameworks, where lipo put device and simulator architectures in one +# binary with no way for Xcode to pick one. That is the problem xcframeworks exist +# to solve, and it is why "ios" and "ios-sim" are separate targets here. +# +# What does need checking is that each slice is genuinely what its name says; +# script/validate-xcframework.sh verifies the build platform of every architecture +# in every slice, so simulator code cannot reach a device slice unnoticed. +.PHONY: xcframework +xcframework: ios-sim ios macos ## Builds a distributable 3-slice TailscaleKit.xcframework (iOS + simulator + macOS) + @echo + @echo "::: Building distributable TailscaleKit.xcframework (ios, ios-simulator, macos) :::" + $(MAKE) inject-privacy-manifest-ios + $(MAKE) inject-privacy-manifest-macos + mkdir -p ./build/Build/Products/Release-all + xcodebuild -create-xcframework \ + -framework ./build/Build/Products/Release-iphoneos/TailscaleKit.framework \ + -framework ./build/Build/Products/Release-iphonesimulator/TailscaleKit.framework \ + -framework ./build/Build/Products/Release/TailscaleKit.framework \ + -output ./build/Build/Products/Release-all/TailscaleKit.xcframework + @echo "Wrote ./build/Build/Products/Release-all/TailscaleKit.xcframework" + @echo "Validate before distributing: ./script/validate-xcframework.sh ./build/Build/Products/Release-all/TailscaleKit.xcframework" + +# Apple rejects an app at UPLOAD (ITMS-91053), not at build, if an embedded +# iOS framework slice has no PrivacyInfo.xcprivacy. TailscaleKit collects +# nothing, but the manifest still has to be present. +.PHONY: inject-privacy-manifest-ios +inject-privacy-manifest-ios: + @for fw in ./build/Build/Products/Release-iphoneos/TailscaleKit.framework \ + ./build/Build/Products/Release-iphonesimulator/TailscaleKit.framework; do \ + if [ -d "$$fw" ]; then \ + cp ./PrivacyInfo.xcprivacy "$$fw/PrivacyInfo.xcprivacy"; \ + echo "Injected PrivacyInfo.xcprivacy -> $$fw"; \ + fi; \ + done + +# macOS frameworks are versioned bundles. The manifest must live in +# Versions/A/Resources/ so it is covered by the Versions/A/_CodeSignature +# seal; left at the bundle root it is unsealed and upload fails with 90238. +.PHONY: inject-privacy-manifest-macos +inject-privacy-manifest-macos: + @fw=./build/Build/Products/Release/TailscaleKit.framework; \ + if [ -d "$$fw" ]; then \ + mkdir -p "$$fw/Versions/A/Resources"; \ + cp ./PrivacyInfo.xcprivacy "$$fw/Versions/A/Resources/PrivacyInfo.xcprivacy"; \ + rm -f "$$fw/Info.plist"; \ + codesign --force --sign - --timestamp=none "$$fw" >/dev/null 2>&1 || true; \ + echo "Injected PrivacyInfo.xcprivacy -> $$fw/Versions/A/Resources"; \ + fi + .PHONY: test test: ## Run tests (macOS) @echo diff --git a/swift/PrivacyInfo.xcprivacy b/swift/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..41fc9b5 --- /dev/null +++ b/swift/PrivacyInfo.xcprivacy @@ -0,0 +1,19 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/swift/README.md b/swift/README.md index f71666c..2e7723b 100644 --- a/swift/README.md +++ b/swift/README.md @@ -20,6 +20,7 @@ $ make macos $ make ios $ make ios-sim $ make ios-fat +$ make xcframework ``` These recipes build different variants of TailscaleKit.framework into /swift/build/Build/Products. @@ -28,10 +29,14 @@ Separate frameworks will be built for macOS and iOS and the iOS Simulator. All are built automatically. Swift 6 is supported. The ios and ios-sim frameworks are purposefully separated. The former is free of any simulator segments -and is suitable for app-store submissions. The latter is suitable for embedding when you +and is the one you ship. The latter is suitable for embedding when you wish to run on a simulator in dev though 'make ios-fat' will produce an xcframework bundle including both simulator and device frameworks for development. +`make xcframework` produces a three-slice bundle (device, simulator and macOS) +intended for distribution. Prefer it over `ios-fat` if you are handing the +framework to someone else — see "Distribution" below for why the two differ. + The frameworks are not signed and must be signed when they are embedded. Alternatively, you may build from xCode using the Tailscale scheme but the @@ -50,6 +55,45 @@ make c-archive builds for the local machine architecture/platform (arm64 macOS f Non-apple swift builds are not supported (yet) but should be possible with a little tweaking. +## Distribution + +A framework that builds and links correctly can still be rejected at **upload**. +These failures do not appear at build time; the first sign is a rejection from +App Store Connect minutes after submission. `make xcframework` handles them and +`./script/validate-xcframework.sh` checks them: + +- **ITMS-91053 (Missing API declaration).** Every embedded iOS framework needs a + `PrivacyInfo.xcprivacy`, even one that collects nothing — as TailscaleKit does. + `swift/PrivacyInfo.xcprivacy` is that manifest, and it is copied into both iOS + slices. +- **Error 90238 (unsealed resource).** macOS frameworks are versioned bundles. + The manifest has to live in `Versions/A/Resources` so the + `Versions/A/_CodeSignature` seal covers it; at the bundle root it is unsealed. + A stray `Info.plist` at the versioned-bundle root causes the same error. +- **Symlinks in an iOS slice** are rejected outright. macOS slices legitimately + contain them, so only iOS slices are checked. +- **Simulator code in a slice that ships.** The bundle deliberately contains a + simulator slice, and that is safe: Xcode embeds only the slice matching the + build destination, so an App Store archive carries `ios-arm64` alone. The + familiar "no simulator binaries in a submission" rule is about fat frameworks, + where `lipo` merged device and simulator architectures into one binary — the + problem xcframeworks were introduced to solve. What the validator checks is + that every slice is really what its name claims, by reading the build platform + of each architecture, so a mislabelled slice cannot ship simulator code. +- **A vendor team identifier left in the signature** collides with the adopter's + own signing identity — see + [tailscale/tailscale#15802](https://github.com/tailscale/tailscale/issues/15802). + The frameworks are unsigned by design; your app signs them on embed. + +Validate before handing the bundle to anyone: + +```bash +$ make xcframework +$ ./script/validate-xcframework.sh +``` + +It exits non-zero and names the offending slice if any check fails. + ## Tests From /swift diff --git a/swift/script/validate-xcframework.sh b/swift/script/validate-xcframework.sh new file mode 100755 index 0000000..f8bfed7 --- /dev/null +++ b/swift/script/validate-xcframework.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# Validates TailscaleKit.xcframework against the things Apple rejects for. +# +# Every check here corresponds to a failure that happens at UPLOAD, not at +# build: the xcframework compiles and links fine, the app runs fine locally, +# and then App Store Connect rejects the binary minutes after submission. +# That gap is why this script exists. +# +# Only rejection causes fail the script. Anything that merely makes the bundle +# less useful to an adopter is reported as WARN. +# +# Usage: ./script/validate-xcframework.sh [path-to-TailscaleKit.xcframework] +# Exit 0 = valid, exit 1 = would be rejected. +set -euo pipefail + +XCFW="${1:-./build/Build/Products/Release-all/TailscaleKit.xcframework}" + +if [ ! -d "$XCFW" ]; then + echo "ERROR: $XCFW not found — run 'make xcframework' first" + exit 1 +fi + +FAIL=0 +echo "=== Validating $XCFW ===" + +# 1. Symlinks in an iOS slice are rejected outright. macOS frameworks are +# versioned bundles and legitimately contain symlinks, so only iOS slices +# are checked. +for slice in "$XCFW"/ios-*; do + [ -d "$slice" ] || continue + if find "$slice" -type l | grep -q .; then + echo "FAIL: symlinks found in $(basename "$slice")" + find "$slice" -type l | sed 's/^/ /' + FAIL=1 + fi +done +[ $FAIL -eq 0 ] && echo "OK: no symlinks in iOS slices (macOS versioned-bundle symlinks are expected)" + +# 2. ITMS-91053: a missing privacy manifest in an embedded iOS framework +# fails the upload with no build-time warning. +for slice in "$XCFW"/ios-*; do + [ -d "$slice" ] || continue + if [ -f "$slice/TailscaleKit.framework/PrivacyInfo.xcprivacy" ]; then + echo "OK: PrivacyInfo.xcprivacy present in $(basename "$slice")" + else + echo "FAIL: PrivacyInfo.xcprivacy missing from $(basename "$slice") (ITMS-91053)" + FAIL=1 + fi +done + +# 3. macOS slices are versioned bundles: the manifest must sit inside +# Versions/A/Resources so the _CodeSignature seal covers it. At the bundle +# root it is unsealed and upload fails with error 90238. +for slice in "$XCFW"/macos-*; do + [ -d "$slice" ] || continue + if [ -f "$slice/TailscaleKit.framework/Versions/A/Resources/PrivacyInfo.xcprivacy" ]; then + echo "OK: PrivacyInfo.xcprivacy sealed in $(basename "$slice")" + else + echo "FAIL: PrivacyInfo.xcprivacy not in Versions/A/Resources of $(basename "$slice") (error 90238)" + FAIL=1 + fi +done + +# 4. Simulator code must never end up in a slice that ships. This is the rule +# usually remembered as "no simulator binaries in an App Store submission", +# and it is why fat frameworks — where lipo put device and simulator +# architectures in one binary — were rejected. An xcframework keeps them in +# separate slices and Xcode embeds only the slice matching the build +# destination, so a simulator slice in the bundle is never submitted. What is +# worth checking is that each slice really is what its name claims: a slice +# mislabelled at assembly time would ship simulator code and be rejected. +# So verify the platform of every architecture in every slice. +slice_binary() { + if [ -f "$1/TailscaleKit.framework/TailscaleKit" ]; then + echo "$1/TailscaleKit.framework/TailscaleKit" + elif [ -f "$1/TailscaleKit.framework/Versions/A/TailscaleKit" ]; then + echo "$1/TailscaleKit.framework/Versions/A/TailscaleKit" + fi +} + +for slice in "$XCFW"/*/; do + slice="${slice%/}" + name="$(basename "$slice")" + + case "$name" in + *-simulator) want="IOSSIMULATOR" ;; + ios-*) want="IOS" ;; + macos-*) want="MACOS" ;; + *) continue ;; + esac + + bin="$(slice_binary "$slice")" + if [ -z "$bin" ]; then + echo "FAIL: no TailscaleKit binary found in $name" + FAIL=1 + continue + fi + + got="$(vtool -show-build "$bin" 2>/dev/null | awk '$1 == "platform" { print $2 }' | sort -u)" + if [ -z "$got" ]; then + echo "FAIL: could not read the build platform of $name" + FAIL=1 + elif [ "$got" = "$want" ]; then + echo "OK: $name is built for $want" + else + echo "FAIL: $name declares $want but its binary is built for: $(echo "$got" | tr '\n' ' ')" + FAIL=1 + fi +done + +# 5. tailscale/tailscale#15802: a prebuilt binary carrying the vendor's team +# identifier in its signature collides with the adopter's own signing +# identity. The distributed xcframework must be unsigned or ad-hoc so the +# consuming app signs it on embed. +if codesign -dv "$XCFW"/*/TailscaleKit.framework 2>&1 | grep -qi "TeamIdentifier=[A-Z0-9]"; then + echo "FAIL: a team identifier is embedded in the framework signature (see tailscale/tailscale#15802)" + FAIL=1 +else + echo "OK: no vendor team identifier in the signature (#15802 not regressed)" +fi + +# 6. xcodebuild -create-xcframework generates this; its absence means the +# bundle was assembled by hand and Xcode will not resolve slices. +if [ -f "$XCFW/Info.plist" ]; then + echo "OK: Info.plist present at xcframework root" +else + echo "FAIL: Info.plist missing at xcframework root" + FAIL=1 +fi + +# Advisory. Not a rejection check: a bundle without a simulator slice uploads +# and ships perfectly well. It is only a problem for whoever consumes the +# bundle, who then cannot build for the simulator and sees it as a link error +# with no obvious cause. Warn, do not fail — "ios-fat" and a device-only +# framework are both legitimate outputs. +if ls -d "$XCFW"/ios-*simulator* >/dev/null 2>&1; then + echo "OK: iOS simulator slice present (adopters can build for the simulator)" +else + echo "WARN: no iOS simulator slice — adopters of this bundle cannot build for the simulator" +fi + +echo "===" +if [ $FAIL -eq 0 ]; then + echo "xcframework validation PASSED" +else + echo "xcframework validation FAILED — this bundle would be rejected at upload" +fi +exit $FAIL