Deep links and app links
As of 2026-10-19 (vulture-ios 3176c4cc4, vulture-android 85fd9cc6, vulture-web 61d304e).
Vulture opens from outside in three ways:
- the custom scheme
vulture://open/…; - the web links
https://vulture.aero/open/…, which are universal links on iOS and verified App Links on Android; - on Android only, the addresses of the Soaring Spot and Charron.line pages themselves.
All three lead to the same small set of /open/… paths. The paths are the contract. Each platform has its own parser for them, and the two parsers must accept the same paths in the same order.
The /open/… paths
Section titled “The /open/… paths”The paths are checked in this order. The first prefix that matches wins (iOS route(url:in:), Android OpenLink.parse).
| # | Path | Opens | iOS | Android |
|---|---|---|---|---|
| 1 | /open/charronline/igc/<year>/<file> | Downloads https://www.charronline.be/charronline/igc/<year>/<file> and opens it over the logbook | openCharronFlight, URL(charronFlightURL:) | OpenLink.CharronFlight |
| 2 | /open/soaringspot/flight/<id> | Downloads https://www.soaringspot.com/en_gb/download-contest-flight/<id>?dl=1 and opens it | openSoaringSpotFlight, URL(flightURL:) | OpenLink.SoaringSpotFlight |
| 3 | /open/soaringspot/results/<comp>[/<class>/<day>] | The competition, on that day’s scoreboard when both class and day are present | SoaringSpotDayLink | OpenLink.SoaringSpotResults |
| 4 | /open/soaringspot[/<comp>], and any other path that starts with /open/soaringspot | The competition, or the Soaring Spot catalogue when the path names no competition | SoaringSpotCompetition(competitionURL:) | OpenLink.SoaringSpot (competitionId may be null) |
| 5 | /open/flight?id=<Core Data URI> | A logbook flight, from the iOS Flight widget | openFlight(with:in:) | Ignored: the app only comes to the front |
Sources:
vulture-ios/Modules/App/Sources/Application/SceneDelegate+Routing.swiftvulture-ios/Packages/SoaringSpot/Sources/SoaringSpot/Models/SoaringSpotDayLink.swiftandSoaringSpotCompetition.swiftvulture-ios/Packages/SoaringSpot/Sources/SoaringSpot/Extensions/URL+SoaringSpot.swiftvulture-android/domain/services/src/main/kotlin/be/fousa/vulture/domain/services/link/OpenLink.ktvulture-android/app/src/main/kotlin/be/fousa/vulture/services/AppLinks.kt
Tests that pin the paths:
- iOS:
vulture-ios/Packages/SoaringSpot/Tests/SoaringSpotTests/DeepLinkTests.swiftandSoaringSpotDayLinkTests.swift - Android:
vulture-android/domain/services/src/test/.../link/OpenLinkTest.ktandvulture-android/app/src/test/.../services/AppLinksTest.kt
Timing differs. iOS waits until the app is open before it routes a link (whenSubscribed), so a link never closes the launch paywall. Android routes at once, and it skips the first-launch paywall when the app was started by a link.
The widget link is not portable
Section titled “The widget link is not portable”The iOS Flight widget (“On this day”) links to vulture:///open/flight?id=<id>. The id is flight.objectID.uriRepresentation(), a local Core Data object URI (vulture-ios/Packages/Persistence/Sources/Persistence/Widgets/WidgetStore.swift). It means something only to the store on the device that made it. It must never appear in a link that crosses devices or platforms. Android leaves it out on purpose: AppLinksTest asserts that it is not an App Link.
A portable flight link needs a portable id. See Flight identity. Spotlight and the App Intents FlightEntity also key flights by the Core Data URI. That is fine as long as they stay on the device.
vulture:// scheme
Section titled “vulture:// scheme”| iOS | Android | |
|---|---|---|
| Declared | CFBundleURLSchemes = [vulture] in vulture-ios/Modules/App/Resources/Info.plist | VIEW filter with android:scheme="vulture" on MainActivity (vulture-android/app/src/main/AndroidManifest.xml) |
| Host normalisation | URL.routableDeepLink (vulture-ios/Packages/Utilities/Sources/Utilities/URL+DeepLink.swift): the host is put back in front of the path, so vulture://open/x and vulture:///open/x route the same | OpenLink.parse does the same. The host must be empty or be read as the first path component |
| Who writes these links | The Chrome extension writes vulture://open/…. The Flight widget writes vulture:///open/flight?id=… | Nothing on Android |
https://vulture.aero/open/…
Section titled “https://vulture.aero/open/…”| iOS app | iOS App Clip | Android | |
|---|---|---|---|
| Claim | applinks:vulture.aero (vulture-ios/Modules/App/Resources/Entitlements/Release.entitlements) | appclips:vulture.aero (vulture-ios/Modules/Clip/Resources/Entitlements/Release.entitlements) | <intent-filter android:autoVerify="true">, https, host vulture.aero, pathPrefix="/open/" |
| Paths claimed | /open/* and /auth/weglide (from the AASA) | the /open/ links configured per experience in App Store Connect | /open/* only |
| Verified by | /.well-known/apple-app-site-association | the same file, appclips section | /.well-known/assetlinks.json |
The base URL is Vulture.universalHost / Vulture.deepLinkURL = https://vulture.aero (vulture-ios/Packages/Preferences/Sources/Preferences/Vulture.swift).
/auth/weglide is the old WeGlide OAuth redirect (Vulture.swift, redirectURL). The app now authenticates to WeGlide with an API key, so the claim looks like a leftover. Android does not claim it, and vulture-web has no route for it.
Files served by vulture-web
Section titled “Files served by vulture-web”Both files are built as JSON at request time. Neither is a static file: public/.well-known/ only holds pki-validation/.
| File | Source | Content |
|---|---|---|
/.well-known/apple-app-site-association | vulture-web/app/association.rb | applinks.details[0].appIDs: 743AM63YFU.be.fousa.vulture and 743AM63YFU.be.fousa.vulture.debug. Components /auth/weglide and /open/*. appclips.apps: 743AM63YFU.be.fousa.vulture.clip and 743AM63YFU.be.fousa.vulture.debug.clip |
/.well-known/assetlinks.json | vulture-web/app/asset_links.rb | One delegate_permission/common.handle_all_urls statement per package that has at least one fingerprint. RELEASE_FINGERPRINTS (be.fousa.vulture) has one SHA-256: Play signs with the upload key, so that one certificate covers Play installs and side-loaded release builds. DEBUG_FINGERPRINTS (be.fousa.vulture.debug) has one developer machine’s debug key. Specs: vulture-web/spec/app_spec.rb |
The web fallback page (GET /open/*, vulture-web/views/open.haml) is described in vulture-web routes.
The App Clip (iOS only)
Section titled “The App Clip (iOS only)”The App Clip (be.fousa.vulture.clip, vulture-ios/Modules/Clip/Sources/) shows Soaring Spot links to people who do not have the app. ClipRouter (Modules/Clip/Sources/Application/ClipRouter.swift) uses the same parsers as the app and handles a subset of the paths:
| Path | Clip shows |
|---|---|
/open/soaringspot/ | The competition catalogue (the clip’s root) |
/open/soaringspot/<id> | That competition |
/open/soaringspot/results/<id>/<class>/<day> | That competition’s day |
| anything else | The catalogue, not an error |
Two more pieces:
- Test invocation URL:
https://vulture.aero/open/soaringspot/results/jwgc2026/club/task-4-on-2026-08-06, passed as_XCAppClipURLinvulture-ios/XcodeGen/Modules/Clip.yml. - Smart App Banner: the site’s banner names the clip with
app-id=1468252627, app-clip-bundle-id=be.fousa.vulture.clip(vulture-web/app/helpers/app_store.rb), so Safari can offer the clip on a covered URL.
Android has no equivalent. An Instant App is out of scope for v1 (roadmap).
Soaring Spot and Charron.line pages
Section titled “Soaring Spot and Charron.line pages”On Apple platforms, browser extensions put an “Open in Vulture” link on the third-party pages. Chrome for Android runs no extensions, so Android reads the pages’ own addresses instead.
Browser extensions (Apple platforms)
Section titled “Browser extensions (Apple platforms)”| Extension | Pages (matches) | Link it writes |
|---|---|---|
Safari (vulture-ios/Modules/Safari/Sources/Web/) | *://www.soaringspot.com/*/daily, */total, */tasks/*, and proef.php on lvzc.be, www.lvzc.be, charronline.be, www.charronline.be | https://vulture.aero/open/…. The base URL comes from the native SafariWebExtensionHandler.swift (Vulture.deepLinkURL) |
Chrome (vulture-ios/Chrome/, manifest v3, version 1.0.1) | The same pages | vulture://open/… (baseURL = "vulture:/" in each script), because Chrome on the Mac ignores universal links |
What each script links to:
- Soaring Spot daily page (
soaringspot_daily.js): each flight pop-up to/open/soaringspot/flight/<id>, and the page to/open/soaringspot/results/<comp>/<class>/<day>. - Totals and task pages: the results link.
- Charron.line
proef.php(charron.js):/openfollowed by the page’s IGC link.
Android intent filters (vulture-android/app/src/main/AndroidManifest.xml)
Section titled “Android intent filters (vulture-android/app/src/main/AndroidManifest.xml)”| Filter | Hosts | Paths | Verified |
|---|---|---|---|
| Soaring Spot | www.soaringspot.com, soaringspot.com (http and https) | /.*/results/.*/.*/daily, /.*/results/.*/.*/total, /.*/tasks/.*/.*, /.*/download-contest-flight/.* | No (autoVerify="false") |
| Charron.line | www.lvzc.be, lvzc.be, www.charronline.be, charronline.be (http and https) | /charronline/igc/.* | No |
| Share target | ShareLinkActivity, SEND text/plain, labelled “Open in Vulture” | The first Soaring Spot or Charron.line link in the shared text (OpenLink.inText), passed on to MainActivity | n/a |
We do not own these hosts, so they cannot be verified. On Android 12 and later such a link opens in the browser until the pilot turns on Settings › Apps › Vulture › Open by default › Open supported links. On Android 10 and 11 a chooser offers Vulture.
What OpenLink reads from those addresses (OpenLink.website):
| Page | Opens |
|---|---|
soaringspot.com/<lang>/<comp>/results/<class>/<day>/daily or …/total | The competition on that day |
soaringspot.com/<lang>/<comp>/tasks/<class>/<day> | The same day |
soaringspot.com/<lang>/<comp>/results, …/pilots | The competition |
soaringspot.com/<lang>/download-contest-flight/<id> | The flight, downloaded |
lvzc.be or charronline.be /charronline/igc/<year>/<file> | The Charron.line flight, downloaded |
Not read: a proef.php page (its IGC link is only in the HTML) and a bare competition page /<lang>/<comp>, whose address does not say what kind of page it is.
Who handles what
Section titled “Who handles what”| Entry | iOS app | iOS App Clip | Android |
|---|---|---|---|
https://vulture.aero/open/soaringspot… | Yes | Yes (catalogue, competition, day) | Yes |
https://vulture.aero/open/soaringspot/flight/<id> | Yes | No (root) | Yes |
https://vulture.aero/open/charronline/igc/… | Yes | No (root) | Yes |
https://vulture.aero/open/flight?id= | Yes (local id) | No | No, ignored |
https://vulture.aero/auth/weglide | Claimed in the AASA, no route | No | No |
vulture://open/… | Yes | No | Declared, but dropped by AppLinks.of (see above) |
| soaringspot.com / charronline.be page addresses | No (the extensions rewrite them) | No | Yes, unverified filters and the share target |
| Home-screen quick actions | be.fousa.vulture.shortcut.{createtask,openmap,startrecording,stoprecording} (Modules/App/Sources/Shell/Menus/Shortcut.swift) | No | None found |
Rules for new links
Section titled “Rules for new links”- Add the path on both parsers, and to the App Clip router if the clip should show it. Keep the prefix order above.
- Identify content portably, never by a local database id. See Flight identity.
- Any new top-level path outside
/open/needs a new AASA component and a new Android intent filter. - Universal links and App Links only work when vulture-web serves the path.
GET /open/*already does (see routes).
