Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion swift/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also incorrect here AFAIK. You cannot embed simulator binaries in appStore submissions - unless they've changed the rules

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one is safe, and I have a shipped artifact to check it against rather than an opinion.

The distinction is that an xcframework is a container, not the submitted artifact. Xcode resolves it at build time and embeds only the slice matching the destination, so the simulator slice never reaches the archive. The rule you are remembering applied to fat frameworks, where lipo merged device and simulator architectures into a single binary and Xcode had no way to pick one — which is the problem xcframeworks were introduced to solve.

Measured, rather than argued. I ship an app built on exactly this three-slice bundle:

$ ls TailscaleKit.xcframework
Info.plist  ios-arm64  ios-arm64_x86_64-simulator  macos-arm64

The app archived from it embeds one slice:

$ vtool -show-build Payload/…app/Frameworks/TailscaleKit.framework/TailscaleKit
 platform IOS
    minos 18.1
$ lipo -info …/TailscaleKit
Non-fat file: … is architecture: arm64

No simulator architecture, and nothing for a stripping phase to remove. That build was uploaded to App Store Connect and finished processing as processingState=VALID — so it cleared Apple's upload validation, which is the gate that rejects simulator architectures. To be exact about what that does and does not show: it has not been through App Review yet, so it is evidence about the upload checks specifically, not about a human reviewer.

If you would still rather the distributable bundle be device + macOS only, I am happy to drop the simulator slice from this target — it costs adopters the ability to build for the simulator against the prebuilt bundle, which is the tradeoff ios-fat currently covers for development. Your call; I did not want to quietly leave it as-is after you had flagged it, but I also did not want to change it on a premise I could show was not load-bearing.

Either way, the mislabelling risk you pointed at is now checked explicitly in validate-xcframework.sh — see the other thread.

-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
Expand Down
19 changes: 19 additions & 0 deletions swift/PrivacyInfo.xcprivacy
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- TailscaleKit collects no data and performs no tracking. Apple still
requires the manifest to be PRESENT in every iOS framework slice:
an app embedding a slice without one is rejected at upload with
ITMS-91053 (Missing API declaration), not at build time. -->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
46 changes: 45 additions & 1 deletion swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
148 changes: 148 additions & 0 deletions swift/script/validate-xcframework.sh
Original file line number Diff line number Diff line change
@@ -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