Table of Contents
The problem
An App Clip starts from a URL, which the system passes in an NSUserActivity of type NSUserActivityTypeBrowsingWeb. The URL itself comes from an App Clip Code, an NFC tag, a QR code, Messages or a website: input that anyone can write. Around it sit three things that fail quietly:
- Routing. App Store Connect maps URLs to App Clip experiences by prefix, and the App Clip and the full app must handle every URL under a registered prefix the same way.
- Size. Each variant has a hard limit on its uncompressed size: 10 MB up to iOS 15, 15 MB on iOS 16, and 100 MB or 15 MB on iOS 17 and later, depending on invocation and deployment target. App Store Connect checks it, after the upload.
- Website association. A mistake in the
apple-app-site-associationfile or in anappclips:entitlement entry means the App Clip does not open from the website. There is no compiler error for it.
AppClipsStudio is a Swift package for these three places, plus location confirmation, the App Store overlay and handing data to the full app. Its command-line tool, appclipstudio, checks what App Store Connect and the system will check before you upload. The package supports iOS 16 and macOS 13 and has no dependencies.
Version 2.0.0 is the first release of this code. The repository opened in August 2025 and tagged 1.0.0 in February 2026. In the words of the 2.0.0 changelog, 1.0.0 "described a modular framework (AI routing, analytics, security, networking, storage, UI modules) whose sources were placeholders". In June 2026 those modules were consolidated into a few stubs, "including an Apple Pay wrapper that approved every payment and a command-line tool that printed random sizes". On 15 October 2026 PR #7 replaced all of it with code built on Apple's App Clip documentation, and v2.0.0 was tagged on the merge commit. Nothing from the 1.0.0 API remains, hence the major version.
Design decisions and alternatives
One library target, one folder per task. Sources/AppClipsStudio is about 2,000 lines in the folders Invocation, Routing, Size, AppClipCode, Association, Handoff, Location and Promotion. An App Clip target adds one product. Separate products would let an app link less code, but at this size they would mostly add overhead.
The URL is validated before app code sees it. Invocation(url:limits:) throws the first check that fails, in a fixed order: length (2,048 bytes by default), parsing with URLComponents, the https scheme, a host, at most 32 path components with no . or .., and at most 64 query items. The limits live in InvocationLimits. Decoded host, path and query must not contain control or format characters, which can reach logs or the screen. Trusting URLComponents instead is the bug described below.
Typed throws. Errors are declared as throws(InvocationError), throws(ThinningReportError) and so on, so a caller can switch over every case: .missingURL for a launch from a notification or the App Switcher, .hostNotAllowed for a URL from an unexpected host. The cost is a recent toolchain: Swift tools 6.2 and Xcode 26 or later. Plain throws would work with older compilers but makes every caller cast.
A router that returns a value instead of navigating. InvocationRouter<Destination> is generic over the app's own screen type and only answers "which destination is this URL?". Routes are sorted by specificity, mirroring App Store Connect's prefix matching: more components first, then more literal components, then an exact pattern before one ending in /*, then a pattern with a host; ties keep insertion order. A handler can return nil to decline a match, for example when :id is not a number, and the router tries the next route and finally the fallback. Throwing would also mean "not mine", but every caller would need its own retry loop. The result is a plain value, so routing is tested with ==.
Size from the report Xcode writes, not from a folder. Xcode's App Thinning Size Report.txt lists each variant's uncompressed size, which is what Apple limits. ThinningReport parses it and SizeBudget picks each variant's limit from its iOS version, the deployment target and the InvocationSupport (digitalOnly, physical or demoLink). Megabytes are decimal, as in the report. BundleSizeScanner can add up a built .app folder, but its documentation calls that an estimate.
A command-line tool that is a library. The commands are Tool.run(_:) -> ToolResult in the AppClipsStudioTool target, which returns output and an exit code instead of printing and exiting, so tests call it without starting a process. Exit codes are a CI contract: 0 passed, 1 a check failed, 2 usage or input error. There are five commands (size, check-url, check-domain, aasa validate, aasa generate); all but aasa generate take --json. A small hand-written argument parser replaces Swift Argument Parser to keep the package dependency-free, at the price of a plainer --help.
A handoff envelope instead of raw UserDefaults. HandoffStore<Value> stores a Codable value in the App Group's defaults with a schema version, an optional expiry date and a size limit (64 KB by default). Reading checks the size before decoding anything, then the header, then the value, and take() hands it to the full app once. Plain UserDefaults has no answer for an App Clip that writes a newer schema than the full app reads. The documentation says not to store secrets this way.
A real bug: only the path was checked
The rewrite's first Invocation checked only decoded path components for control and format characters. But URLComponents percent-decodes the host and the query as well. Commit 583949e describes what got through: https://ex%0Aample.com/a was accepted with the host "ex\nample.com", a bidirectional override (%E2%80%AE) passed in a host or a query value, and the p item of a default App Clip link was passed on as the bundle identifier whatever it contained.
The fix extends the boundary to the whole URL and adds three error cases. InvocationError.invalidHost carries the host as written and invalidQueryItem the item's name as written, still percent-encoded, so the error does not carry the hidden character into a log. invalidBundleIdentifier reuses AppIdentifier's bundle identifier check. InvocationTests now pins inputs such as https://ex%0Aample.com/a → .invalidHost("ex%0Aample.com").
The same pattern showed up in the command-line tool. The text output already replaced hidden characters, but JSONEncoder escapes only the C0 controls, so C1 controls such as U+009B (which a terminal reads as the start of an escape sequence), bidirectional overrides and the U+2028/U+2029 separators reached the terminal unchanged with --json. Commit 2e2f3b3 escapes them. That fix was not complete either: after the release, commit 0727dbc on master also escapes DEL (U+007F), which the first fix skipped because it only looked at non-ASCII scalars.
A third fix in the same pull request was about cost. Commit 9bd6374 changed ThinningReport to read one line at a time. Its message records that a report at the 8 MB limit made only of newlines peaked at 696 MB resident in the release build before the change, and 25 MB after it.
The lesson: a validation boundary is only as wide as the list of places where input gets decoded or written out, and an input limit also needs the cost of the largest allowed input measured.
Usage
The README's quick start replaces the App Clip's ContentView.swift; the same code is compiled and tested in Tests/AppClipsStudioTests/ReadmeQuickStartTests.swift:
1import AppClipsStudio2import SwiftUI3 4enum Screen: Equatable {5 case welcome6 case table(Int)7}8 9struct ContentView: View {10 @State private var screen = Screen.welcome11 12 var body: some View {13 Group {14 switch screen {15 case .welcome: Text("Scan a table tag")16 case .table(let number): Text("Table \(number)")17 }18 }19 .font(.largeTitle)20 .onInvocation { invocation in21 screen = (try? Self.router.route(invocation)) ?? .welcome22 }23 }24 25 static let router: InvocationRouter<Screen> = {26 var router = InvocationRouter<Screen>()27 try? router.add("/table/:number") { match in28 match.integer("number").map(Screen.table)29 }30 router.fallback { _ in .welcome }31 return router32 }()33}With the scheme's _XCAppClipURL set to https://appclip.example.com/table/12, an iPhone simulator shows "Table 12"; /table/twelve shows "Scan a table tag", because integer(_:) returns nil and the fallback takes over.
In CI, the size check fails the job through its exit status:
1$ appclipstudio size "App Thinning Size Report.txt" --deployment-target 16.02App Clip size check (iOS 16+, physical and digital invocations)3 ok ExampleClip-a.ipa 14.60 MB of 15.00 MB4 OVER ExampleClip-b.ipa 15.20 MB of 15.00 MB51 of 2 variants are over the limit.Examples/CoffeeShop, an app and its App Clip from shared sources, is built by CI on every push to master and every pull request.
Limits
- No real-device, real-domain check. The example uses
example.comhosts in the simulator, where there is no activation payload, so location confirmation shows "not checked". Tests cover how system results map toLocationConfirmation, not the system call. - Apple's numbers are written into the code. The size limits and App Clip Code rules come from Apple's documentation; if Apple changes them, the package needs a manual update.
- The folder scan is an estimate. Exact sizes come only from Xcode's size report; reading an exported
.ipadirectly is on the roadmap. - The full app's identifier is inferred. The AASA validator drops the App Clip identifier's last component, following Xcode's naming; a differently named project can get an undeserved warning.
- New code. 2.0.0 was released on 15 October 2026. The test suite has 119 Swift Testing tests at the tag (measured with
swift teston macOS), but there is no record yet of use outside the repository.
Source code: github.com/muhittincamdali/AppClipsStudio
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

