Skip to content

feat(mobile): App Clip invite handoff demo - #8387

Draft
matt2e wants to merge 1 commit into
brain/mobile-onboardingfrom
mtoohey/app-clip-invite-handoff-demo
Draft

matt2e wants to merge 1 commit into
brain/mobile-onboardingfrom
mtoohey/app-clip-invite-handoff-demo

Conversation

@matt2e

@matt2e matt2e commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Demo, not ship-ready. Targets Wes's brain/mobile-onboarding (#8375), which this work builds on. It shows the handoff mechanism end to end on the simulator; the production pieces (invite domain, AASA, App Store Connect experience, provisioning) are listed as follow-ups in the doc.

Summary

iOS has no first-party deferred deep link, so a scanned invite QR normally ends with "install the app, then go back to the web page and tap the link again". This demo uses an App Clip as the invite carrier:

  1. QR → App Clip. The QR encodes an ordinary Buzz invite link. It launches BuzzClip, a small pure-SwiftUI App Clip (~640 KB on the simulator, far under the 15 MB QR-invocation cap) that parses the link with the same grammar as deep_link.dart and shows the community from the relay's NIP-11 document.
  2. Join parks the invite. Tapping Join writes a single-use record to the clip's own Keychain (iOS 15.4+ migrates an App Clip's Keychain items into the full app at install) and to App Group UserDefaults, then presents the SKOverlay App Store card for the full app.
  3. Full app replays it. On first launch PendingDeepLinkNotifier.build() calls takePendingInvite over a method channel, the native side returns the URL once and clears both copies, and the link flows through the existing pending-deep-link queue → InviteDeepLink → DeepLinkDispatcher → invite join sheet, exactly as if it had arrived as a universal link.
QR → Camera → BuzzClip (SwiftUI)             Buzz.app (Flutter)
             parse invite URL                 AppDelegate → BuzzAppClipInviteBridge
             fetch NIP-11 name/icon           PendingDeepLinkNotifier.build()
             Join: BuzzPendingInviteStore ──▶   takePendingAppClipInvite()
                   (clip Keychain + App Group)  → open(uri) → InviteDeepLink
             SKOverlay → install full app       → DeepLinkDispatcher → join sheet

How to run it, what is real vs. stubbed, and the production follow-ups: mobile/docs/app-clip-invite-demo.md.

Demo

Watch for the green Invite saved footer at ~7 s (the real Join tap) before the App Store overlay slides up, then the full app's join sheet reading localhost:3000 / Local Dev at ~12–16 s, and the relaunch at ~19 s showing only the welcome screen (the record is single-use).

appclip-invite-handoff-demo-small.mp4
Clip card After Join (SKOverlay) Full app first launch
BuzzClip invite card showing Local Dev / localhost:3000 with the "details unavailable" placeholder Clip after the Join tap with the "Get the full app" App Store overlay card Full app join sheet for localhost:3000 / Local Dev opened from the parked invite

Changes

  • ios/BuzzClip/: the App Clip target (model, view, Info.plist, entitlements with appclips:, parent-application-identifiers, App Group). DEBUG builds also read _XCAppClipURL themselves so xcrun simctl launch drives the same flow as the Xcode scheme.
  • ios/Shared/BuzzInviteLink.swift: Swift mirror of parseInviteDeepLink / validateInviteRelayUri, including the IPv4/IPv6 public-address rules.
  • ios/Shared/BuzzPendingInviteStore.swift: the single-use handoff record (Keychain + App Group), compiled into both Runner and the clip. App Clips cannot hold the Keychain Sharing entitlement (the simulator refuses to install one that does), so no access group is pinned on either side.
  • ios/Runner/AppClipInviteBridge.swift + AppDelegate.swift: buzz/app_clip_invite method channel exposing takePendingInvite.
  • lib/shared/deeplink/app_clip_invite_bridge.dart + pending_deep_link_provider.dart: pick the invite up at build time and open() it; appClipInvitePickupProvider is the test seam.
  • Entitlements / config: Runner gains applinks:$(BUZZ_INVITE_LINK_HOST); the host is a placeholder (invite.buzz.example) in Debug/Release.xcconfig. BuzzClip.xcscheme sets _XCAppClipURL so the clip launches without a QR code.
  • docs/app-clip-invite-demo.md: runbook, real-vs-stubbed, production follow-ups.

Verified

  • flutter analyze clean; flutter test test/shared/deeplink/ green (34 tests).
  • BuzzClip builds standalone; flutter build ios --simulator --debug embeds AppClips/BuzzClip.app (arm64-only on Xcode 27.1, whose lipo -verify_arch rejects Flutter's two-arch check).
  • BuzzInviteLinkTests + BuzzPendingInviteStoreTests: 10/10 on an iPhone 17 Pro simulator.
  • End-to-end simulator run with a real Join tap (the video above), iPhone 17 simulator, from a clean state (both bundles uninstalled, simctl keychain reset, fresh App Group container): clip launched via simctl with the demo URL → axe tap --label Join --tap-style physical → defaults read on the App Group suite shows the buzz.app-clip.pending-invite.v1 record → full app launch opens the join sheet and the key is gone → relaunch shows only the welcome screen.

Stubbed / demo-only

  • No relay was running. "Local Dev" is BuzzInviteLink's localhost fallback name and the card shows the "Community details unavailable" placeholder rather than NIP-11 metadata. The join sheet's own Join button was not tapped (with no relay and the sample code it would fail with "This invite is not valid").
  • No App Store install. On the simulator the clip and the full app are two side-by-side installs, so the App Group copy carries the handoff (no Keychain migration happens there). The full app is pre-installed, which is why it is already on the home screen at the start of the video.
  • _XCAppClipURL instead of the Camera. A real QR invocation needs the AASA appclips entry and an App Store Connect advanced experience.
  • Placeholder invite host (invite.buzz.example); the Dart parser accepts any host, so there is no single invite domain to register yet.
  • Device signing is out of scope: Runner's applinks entitlement needs Associated Domains on the provisioning profile.

Known follow-ups

  1. docs/app-clip-invite-demo.md says the App Store overlay "has nothing to show" on the simulator. It actually renders a "Get the full app … Developer Preview" card when the parent app is installed locally (middle screenshot). Doc tweak only.
  2. The clip inherits the full app's CFBundleDisplayName, so the overlay reads "Buzz (app-clip-invite-handoff-de…" under the worktree override. The clip should get its own display name.

🤖 Generated with Claude Code

Demonstrate the "App Clip as invite carrier" onboarding path on iOS. A QR
code that encodes a Buzz invite link launches a native SwiftUI App Clip
(`BuzzClip`, no Flutter, ~640 KB on the simulator, far under the 15 MB QR
limit) that parses the link with the same grammar as `deep_link.dart`,
shows the community the relay's NIP-11 document describes (or a clearly
labelled placeholder when the relay is unreachable), and on "Join" parks
the invite URL for the full app before presenting the App Store overlay.
When the full app launches it drains that record once and feeds it into
the existing pending deep-link queue, so the invite is handled exactly as
if it had arrived as a universal link.

- `ios/Shared/BuzzInviteLink.swift`: Swift mirror of `parseInviteDeepLink`
  / `validateInviteRelayUri`, including the IPv4/IPv6 public-address rules.
- `ios/Shared/BuzzPendingInviteStore.swift`: single-use record written to
  the writer's own Keychain (iOS 15.4+ migrates an App Clip's Keychain
  items into the full app at install) plus an App Group `UserDefaults`
  copy; compiled into both Runner and the clip. App Clips cannot hold the
  Keychain Sharing entitlement (the simulator refuses to install one that
  does), so no access group is pinned on either side.
- `ios/BuzzClip/`: the clip target (model, view, Info.plist, entitlements
  with `appclips:`, parent-application-identifiers, App Group). DEBUG
  builds also read `_XCAppClipURL` themselves so `xcrun simctl launch`
  drives the same flow as the Xcode scheme.
- `ios/Runner/AppClipInviteBridge.swift` + `AppDelegate.swift`:
  `buzz/app_clip_invite` method channel exposing `takePendingInvite`.
- `lib/shared/deeplink/app_clip_invite_bridge.dart` +
  `pending_deep_link_provider.dart`: pick the invite up at build time and
  `open()` it; a Riverpod provider is the test seam.
- Runner gains `applinks:$(BUZZ_INVITE_LINK_HOST)`; the host is a
  placeholder (`invite.buzz.example`) in Debug/Release.xcconfig.
- `BuzzClip.xcscheme` sets `_XCAppClipURL` so the clip can be launched
  from Xcode without a QR code.
- `docs/app-clip-invite-demo.md`: how to run it, what is real vs stubbed,
  and the production follow-ups (AASA `appclips` entry, App Store Connect
  advanced experience, provisioning, size check, iOS < 15.4 fallback).

Verified: `flutter analyze` clean; `flutter test test/shared/deeplink/`
(34 tests) green; BuzzClip builds standalone; `flutter build ios
--simulator --debug` embeds `AppClips/BuzzClip.app` (arm64-only on Xcode
27.1, whose `lipo -verify_arch` rejects Flutter's two-arch check);
`BuzzInviteLinkTests` + `BuzzPendingInviteStoreTests` (10 tests) pass on
an iPhone 17 Pro simulator. On an iPhone 17 simulator the clip launched
via `simctl` with the demo URL renders the invite card, and the full app
drains a seeded App Group record on first launch into the join sheet
("localhost:3000 / Local Dev") with a no-op relaunch. The Join tap itself
was not driven on the simulator (no Simulator GUI or `axe` on this host).

Device signing is out of scope: the Runner `applinks` entitlement needs the
Associated Domains capability on the provisioning profile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant