You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Bridge https://roadflare.app/share/d/<npub> taps from a device without RoadFlare installed → native App Clip preview of the driver → install full app → driver is pre-filled in onboarding's first AddDriverSheet.
This is the App-Store-blessed native solution for "deferred deep linking". The roadflared: scheme (#64, PR #66) and the deferred Universal Links (#63) only work once the app is installed. App Clips are the only native iOS bridge for users tapping a share link before installing.
Background / why this and not alternatives
Server-mediated approaches (Branch.io, Adjust, AppsFlyer, deprecated Firebase Dynamic Links) were ruled out due to RoadFlare's privacy posture (no analytics, no fingerprinting, no third-party SDKs).
Smart App Banner (variablefate/roadflare-site#7) — complementary, NOT a substitute. The banner surfaces an "OPEN" button to users who already installed and are revisiting the share page, but app-argument is stripped during install, so it only helps the post-install / re-open case. App Clip (this issue) is still required to bridge first-install context.
Clipboard / paste-prompt approaches trigger iOS's "pasted from..." privacy banner and require user action.
App Clips carry the driver context natively, ~10 MB download, no third-party tracking, GitHub Pages-compatible (only needs an AASA file edit, no backend).
Architecture sketch
Trigger URL
https://roadflare.app/share/d/<npub> — already exists as the SPA-rendered share page. Registered in AASA's appclips section.
App Clip Xcode target
New target in RoadFlare.xcodeproj
Bundle ID: com.roadflare.RoadFlare.Clip (under team 6Y98438M9X)
Constraints: NO background tasks, NO notifications without permission, NO shared Keychain, must use App Group for inter-target state, 10 MB uncompressed binary limit
Code sharing strategy
The clip needs a minimal Nostr client to fetch + display the driver's Kind 0 profile. Two paths:
Link RidestrSDK directly — simplest. Risk: RidestrSDK is broad (relay manager, NIP-44, NIP-19, ride-state machines, fare calculator) and may blow the 10 MB budget when compiled into the clip alongside SwiftUI runtime. Measure first; if size fits, ship.
Extract a RidestrSDK-Lite — keep only NIP19 (npub decode + encode), a stripped relay client, and Kind 0 parsing. Drop NIP44 (no encryption needed in clip), drop ride-state, drop fare calc. Cleaner architecturally; more work.
Recommend: option 1 + measure compiled clip size. Fall back to option 2 if needed.
State handoff (clip → full app post-install)
Both targets entitled to a shared App Group container (group.com.roadflare.RoadFlare):
When the App Clip's "Get RoadFlare" button is tapped:
Clip writes the pending driver's npub + cached display name + Kind 0 timestamp to UserDefaults(suiteName: "group.com.roadflare.RoadFlare")
Clip presents SKOverlay (or Store.requestAppStoreOverlayPresentation) for the App Store transition
User installs the full app + opens it for the first time:
AppState.initialize() (existing entry point — see RoadFlare/RoadFlareCore/ViewModels/AppState.swift) checks the group UserDefaults for a pending driver
Clears the group UserDefaults entry after consuming (one-shot)
Existing onboarding flow takes over: selectedTab is set to 1 (Drivers tab), DriversTab observes pendingDriverDeepLink via .onChange(of:initial:) once it mounts post-.ready, presents AddDriverSheet(prefill:) automatically
This is functionally identical to the roadflared: cold-start path PR #66 already implements; the only new thing is the source of the seed (App Group container vs .onOpenURL).
App Clip UI
Mirror roadflare-site/404.html's driver-share design (visual consistency with the web share page the user already saw):
Driver avatar (resolved from Kind 0 picture)
Driver display name (resolved from Kind 0 display_name or name)
GitHub Pages serves the AASA already; this is one file edit + a roadflare-site PR.
Work breakdown
Xcode target setup — new App Clip target, bundle ID com.roadflare.RoadFlare.Clip, signing under team 6Y98438M9X
Entitlements — Associated Domains (appclips:roadflare.app) on both clip and main app, App Group (group.com.roadflare.RoadFlare) on both
Shared RidestrSDK linkage — link the SDK package into the clip target; measure binary size; if >9 MB compiled, slim to a Lite subset
Clip UI — SwiftUI view rendering the driver Kind 0 fetch, modeled on 404.html
Clip URL parsing — read the invoking URL from userActivity.webpageURL in WindowGroup(for:) or onContinueUserActivity; extract the <npub> segment via the existing DriverQRCodeParser.parse (already accepts https://roadflare.app/share/d/<npub> URLs)
Clip → main app handoff — write pending-driver state to App Group UserDefaults on "Get App" tap; clear after main app consumes
Main app handoff consumption — AppState.initialize() reads App Group UserDefaults and seeds pendingDriverDeepLink via the same path handleIncomingURL already uses; clear the group entry after read
ADR — decisions/0013-app-clip-deferred-deep-link.md. Document: target boundaries, App Group as state-handoff seam, why we don't use Keychain (clip can't), why state handoff is best-effort (clip eviction)
Manual test plan — across: (a) tap share URL with no app installed → clip downloads → driver shows → install → driver pre-fills, (b) tap with full app installed → Universal Link wins (clip is bypassed), (c) clip evicted between tap and install → graceful: user sees app install but no pre-fill, (d) returning user re-taps share page after install → opens main app via Universal Link
Constraints / risks
10 MB size budget for the App Clip uncompressed binary. RidestrSDK is broad — must measure. SwiftUI runtime alone consumes ~2-3 MB. NIP-44 / secp256k1 adds noticeable size.
Notifications / background tasks unavailable in clips — fine for this UX (display + transition only).
No shared Keychain between clip and main app — the clip cannot establish or reuse the user's identity. It is purely a pre-install preview, not a partial onboarding. Identity establishment happens fully in the main app post-install.
Clip eviction is best-effort — iOS may evict the clip + clear its App Group writes between the user tapping "Get App" and the user actually opening the installed main app. The handoff is therefore opportunistic; if it doesn't survive, the user just sees normal onboarding without pre-fill (graceful degradation, not a crash).
App Store review separate from main app review; budget 1-3 days extra ship time.
Goal
Bridge
https://roadflare.app/share/d/<npub>taps from a device without RoadFlare installed → native App Clip preview of the driver → install full app → driver is pre-filled in onboarding's first AddDriverSheet.This is the App-Store-blessed native solution for "deferred deep linking". The
roadflared:scheme (#64, PR #66) and the deferred Universal Links (#63) only work once the app is installed. App Clips are the only native iOS bridge for users tapping a share link before installing.Background / why this and not alternatives
app-argumentis stripped during install, so it only helps the post-install / re-open case. App Clip (this issue) is still required to bridge first-install context.Architecture sketch
Trigger URL
https://roadflare.app/share/d/<npub>— already exists as the SPA-rendered share page. Registered in AASA'sappclipssection.App Clip Xcode target
RoadFlare.xcodeprojcom.roadflare.RoadFlare.Clip(under team6Y98438M9X)Code sharing strategy
The clip needs a minimal Nostr client to fetch + display the driver's Kind 0 profile. Two paths:
RidestrSDKdirectly — simplest. Risk:RidestrSDKis broad (relay manager, NIP-44, NIP-19, ride-state machines, fare calculator) and may blow the 10 MB budget when compiled into the clip alongside SwiftUI runtime. Measure first; if size fits, ship.RidestrSDK-Lite— keep onlyNIP19(npub decode + encode), a stripped relay client, andKind 0parsing. DropNIP44(no encryption needed in clip), drop ride-state, drop fare calc. Cleaner architecturally; more work.Recommend: option 1 + measure compiled clip size. Fall back to option 2 if needed.
State handoff (clip → full app post-install)
Both targets entitled to a shared App Group container (
group.com.roadflare.RoadFlare):UserDefaults(suiteName: "group.com.roadflare.RoadFlare")SKOverlay(orStore.requestAppStoreOverlayPresentation) for the App Store transitionAppState.initialize()(existing entry point — seeRoadFlare/RoadFlareCore/ViewModels/AppState.swift) checks the groupUserDefaultsfor a pending driverpendingDriverDeepLink = ParsedDriverQRCode(pubkeyInput: npub, scannedName: cachedName)— reuses the exact plumbing PR feat(app): register roadflared: URL scheme for driver-share deep links #66 wired up for theroadflared:scheme pathUserDefaultsentry after consuming (one-shot)selectedTabis set to 1 (Drivers tab),DriversTabobservespendingDriverDeepLinkvia.onChange(of:initial:)once it mounts post-.ready, presentsAddDriverSheet(prefill:)automaticallyprepareForIdentityReplacementduring the user's firstgenerateNewKey/createWithPasskey/importKeycallThis is functionally identical to the
roadflared:cold-start path PR #66 already implements; the only new thing is the source of the seed (App Group container vs.onOpenURL).App Clip UI
Mirror
roadflare-site/404.html's driver-share design (visual consistency with the web share page the user already saw):picture)display_nameorname)car_color+car_make+car_modelif present — RoadFlare-specific custom fields)SKOverlayAASA changes (on
variablefate/roadflare-site).well-known/apple-app-site-associationgains anappclipssection. Final shape (assuming #63 also lands):{ "appclips": { "apps": ["6Y98438M9X.com.roadflare.RoadFlare.Clip"] }, "applinks": { "details": [ { "appIDs": ["6Y98438M9X.com.roadflare.RoadFlare"], "components": [ { "/": "/share/d/*", "comment": "Driver profile share" }, { "/": "/share/r/*", "comment": "Rider profile share" } ] } ] }, "webcredentials": { "apps": ["6Y98438M9X.com.roadflare.RoadFlare"] } }GitHub Pages serves the AASA already; this is one file edit + a roadflare-site PR.
Work breakdown
com.roadflare.RoadFlare.Clip, signing under team6Y98438M9Xappclips:roadflare.app) on both clip and main app, App Group (group.com.roadflare.RoadFlare) on bothRidestrSDKlinkage — link the SDK package into the clip target; measure binary size; if >9 MB compiled, slim to a Lite subset404.htmluserActivity.webpageURLinWindowGroup(for:)oronContinueUserActivity; extract the<npub>segment via the existingDriverQRCodeParser.parse(already acceptshttps://roadflare.app/share/d/<npub>URLs)UserDefaultson "Get App" tap; clear after main app consumesAppState.initialize()reads App GroupUserDefaultsand seedspendingDriverDeepLinkvia the same pathhandleIncomingURLalready uses; clear the group entry after readvariablefate/roadflare-site) — update.well-known/apple-app-site-association. Likely a draft PR similar to PRs Ping feature to notify offline drivers #4 / Add loading animation to launch screen #5 over there.decisions/0013-app-clip-deferred-deep-link.md. Document: target boundaries, App Group as state-handoff seam, why we don't use Keychain (clip can't), why state handoff is best-effort (clip eviction)Constraints / risks
RidestrSDKis broad — must measure. SwiftUI runtime alone consumes ~2-3 MB. NIP-44 /secp256k1adds noticeable size.applinks; this issue then addsappclipsalongside. If this issue ships first, Add Universal Links support for roadflare.app share pages #63 becomes additive.Out of scope (file separately if desired)
404.html— cheap quick-win for users revisiting the share page after install. Tracked separately at variablefate/roadflare-site#7.roadflarer:rider scheme + clip for a future driver app — separate work when that app exists.Prerequisites
roadflared:scheme handler +pendingDriverDeepLinkstate plumbing that this clip will reuse)RidestrSDKvs extractRidestrSDK-LiteReferences
roadflared:URL scheme and handle driver-share deep links #64) —roadflared:scheme + cold-startpendingDriverDeepLinkplumbing this clip will reuseroadflare-site/404.html— visual reference for the App Clip UI