magic_deeplink 0.1.5
magic_deeplink: ^0.1.5 copied to clipboard
Deep linking support for Magic Framework (Universal Links, App Links).
Changelog #
All notable changes to this project will be documented in this file.
[Unreleased] #
0.1.5 - 2026-09-29 #
Changed #
- Every sibling floor names this batch's release.
magicmoves^0.0.22to^0.0.24andfluttersdk_artisan^0.0.16to^0.0.17. The old ranges already admitted the new versions, so a freshpub getresolves nothing differently; what changes is that the floors name the releases this package is verified against. magic 0.0.24 removesMagicController.onRefreshUI(BREAKING); this package calls it nowhere inlib/ortest/, so nothing here moves with it. (pubspec.yaml)
0.1.4 - 2026-09-27 #
Added #
RouteDeeplinkHandlertakes an optionalList<String>? hosts. Leftnull(the default),canHandlestill matches an absolute URI on any host, unchanged. Set it, and a relative URI (a push payload path) is still always accepted, but an absolute URI is claimed only overhttp/https, with no explicit port and no userinfo, on a host that equals one entry ofhostscase-insensitively; consumers had been re-implementing this same host check themselves because the handler previously matched a path on any host at all. A blank entry inhostsis ignored rather than treated as a wildcard, sohosts: ['']refuses every absolute URI (including one whose own host is empty) instead of matching it on the empty string.RouteDeeplinkHandlertakes an optionalbool caseSensitive = false. go_router routes are case-sensitive by default, so a consumer that mounted/incidents/:idhad no way to stop the handler from claiming/INCIDENTS/5and then landing on go_router's not-found page: the compiled patterns matched case-insensitively with no opt-out. Left at the default, matching is unchanged; set totrue, only the exact case matches.RouteDeeplinkHandlertakes an optionalTenantSwitchGate? tenantGate. A tapped push whose payload names another tenant (team_idby default,payloadKeyto change it) now switches the session onto that tenant before navigating, instead of landing on the backend's 404 for another tenant's page. The gate carriescurrentTenantId, aFuture<bool> switchTenant, and optionalonSwitched/onSwitchFailedcallbacks; the security rules are the handler's and not configurable: onlyDeeplinkSource.pushmay switch, the tenant is read from the payload and never from the URI query, ids compare as trimmed strings, an absent tenant or an unresolved session navigates without switching, and a switch that answers false or throws callsonSwitchFailedand does not navigate. AnonSwitchedthat throws is logged and the link still opens, since the session has already moved. Consumers had been writing this handler themselves. Leftnull(the default), nothing switches.DeeplinkOpenedandDeeplinkNavigatingevents.RouteDeeplinkHandlerdispatchesDeeplinkOpened(source, matched route pattern, whether the payload named a tenant) when it starts opening a link andDeeplinkNavigating(matched route pattern) immediately before it navigates, both unawaited. Each implements magic'sReportsBreadcrumb(deeplink.open,deeplink.navigate), so a crash reporter listening throughEvent.listenAnyrecords them without this package depending on it. Their data carries the handler's pattern (/invitations/:token/accept) rather than the concrete path, and never the query string or a payload value, because a link can carry a token in any of the three.
Changed #
magicfloor moves^0.0.16to^0.0.22. The deeplink events implementReportsBreadcrumb, which magic 0.0.22 introduces, so an adopter on an older magic now gets a version-solve error instead of a compile error inside this package. (pubspec.yaml)RouteDeeplinkHandler.handleno longer throws. It used to letMagicRoute.to'sStateErrorescape when the router was not built, which broke the handler contract every other handler keeps; a failure is now logged (when alogbinding exists) and answeredfalse. It also re-checkscanHandle, so a direct call with a path the handler does not serve answersfalseinstead of navigating.
0.1.3 - 2026-09-22 #
Changed #
- Every sibling floor names this batch's release.
magicmoves^0.0.15to^0.0.16;fluttersdk_artisanstays at^0.0.16, still the newest. The old ranges already admitted the new versions, so a freshpub getresolves nothing differently; what changes is that the floors name the releases this package is verified against. magic 0.0.16 widensfile_pickerto admit 13, wherePlatformFile.length()answers null for an unreadable file; this package does not callPick. (pubspec.yaml)
0.1.2 - 2026-09-21 #
Changed #
- Every sibling floor names this batch's release.
magicmoves^0.0.14to^0.0.15. The old ranges already admitted the new versions, so a freshpub getresolves nothing differently; what changes is that the floors name the releases this package is verified against. magic 0.0.15 is breaking in its database layer (a migration may no longer manage its own transaction, andDB.transactionrefuses a callback that closes the transaction itself); nothing in this package calls either, so no code here changes, but an app below magic 0.0.15 no longer resolves this release. (pubspec.yaml)
0.1.1 - 2026-09-19 #
Fixed #
-
The
magicfloor names a version this package's own suite can actually run against. It said^0.0.5, and at that version the library still analyzes clean while the test suite does not compile:MagicApp.registerreturnsvoidbelow magic 0.0.8 andFuture<void>from 0.0.8 on, and the provider tests await it. So the floor was claiming support for three releases nothing here is verified against, which is the kind of claim that only ever fails in somebody else's build. It went to^0.0.8first, the lowest version at whichflutter analyzeis clean onlib/ANDtest/.It ships as
^0.0.14, which is a different decision on top of that one. The batch this release belongs to pins every sibling to its newest, so the floor now names magic's current release rather than the oldest verified one. That is worth stating plainly because 0.0.14 carries a BREAKING change: an unresolvable route middleware alias stops the app atMagic.initinstead of leaving the route ungated. A consumer on magic 0.0.8 through 0.0.13 no longer resolves this package, and one moving to it inherits that check.fluttersdk_artisangoes^0.0.8to^0.0.16for the same reason. Nothing inlib/needed either move, so no behaviour in this package changes. (pubspec.yaml)
0.1.0 - 2026-09-09 #
💥 Breaking Changes #
DeeplinkHandler.handleandDeeplinkManager.handleUritake a requiredDeeplinkSource source. A handler could not previously tell an OS-opened link, which anyone able to send the device a link can craft, from a push notification's own payload, which the server authored. A consumer that acts on more than the path (switching a team off ateam_idkey carried in the link, say) now has something to check first.sourceisosLink,push, ormanual, and is required rather than defaulted so a handler that forgot to ask does not silently treat a crafted link like a trusted one. An optionalMap<String, dynamic>? payloadtravels alongside it, carrying the whole push payload on thepushpath andnulleverywhere else;OneSignalDeeplinkHandlerpasses the full payload, the driver subscription in the provider passes none.
Fixed #
- A push tapped while the app is CLOSED now opens its screen. Measured on a physical iPhone against a real server-sent notification: the same push opened the right screen when the app was already running and landed on the home screen when it was not. The OneSignal SDK replays the tap that launched the app while it initialises, which is before anything has been drawn and therefore before magic's router can accept a navigation, so the link was handed over, went nowhere, and the app finished booting onto its own initial route. Nothing failed loudly; a cold tap simply opened the app in the wrong place, which reads as the feature working badly rather than as a defect.
OneSignalDeeplinkHandlernow waits for the first frame before routing, takingWidgetsFlutterBinding.ensureInitialized().endOfFrameonce persetupexactly as the OS-link path inDeeplinkServiceProvideralready did; the two paths had been asymmetric since the push bridge started working in 0.0.3.endOfFramerather than a post-frame callback for the same reason as there: it schedules a frame when the scheduler is idle, so a link handed to an application nobody is drawing is still delivered. A delivery still in flight behind that frame whendisposelands is dropped rather than routed into a torn-down consumer.
Added #
deeplink:doctorcommand.magic_notificationsandmagic_startereach ship a doctor; this plugin shipped none, and it needed one most. The install has a half no manifest installer can automate (an<intent-filter>inside a specific<activity>, a<meta-data>on the right element), and every way of getting that hand-written half wrong is silent: a deep link simply opens the browser, with no exception and no log line anywhere. The command reads the consumer'slib/config/deeplink.dart(reusingGenerateCommand.parseDeeplinkConfigrather than a second parser), rejects scaffold placeholders left over from install, and checks both platforms structurally rather than by substring search: it parses the Android manifest's element tree so aflutter_deeplinking_enabledmeta-data placed on<application>instead of<activity>is caught, a case a plaingrepcannot see because both locations contain the identical text. It also validates theautoVerifyintent-filter'shttp/httpsschemes and host, the iOS entitlements'applinks:host,FlutterDeepLinkingEnabled, and that the generatedapple-app-site-association/assetlinks.jsonfiles agree with the config (warning rather than failing on a legacy+modern AASA format mix, per Apple's TN3155). All checks are local and read-only by default;--remoteadditionally fetches both association files from the live domain to confirm the server agrees with the repo. The report always closes by naming what no local or remote check can prove: that a real device matches the link, sinceswcutil verifyneeds root and Android verifies at install time.- The web build stops carrying an inert
dart:ioimport.AppLinksDriverwas a single class reaching forPlatform.isAndroid/isIOS/isMacOSinside atry/catch, which only worked at all becausedart:iohappens to resolve (to a stub) underdart2js/dart2wasm. It is now a conditional-export barrel over three arms: an io arm that still wraps theapp_linkspackage and answersisSupportedfrom the same three platform checks, a web arm, and a stub arm for anything else. Both the web and the stub arm answer every member without touching a platform channel:isSupportedfalse,getInitialLink()null,onLinkan empty stream,initializeanddisposeno-ops. The web arm is deliberately inert rather than wired toapp_links_web: that package reads the boot-timelocation.hrefonce and never reacts to later navigation, and GoRouter already owns the address bar under this app's path url strategy, so routing the same URI twice would be the bug, not the fix.
Changed #
DeeplinkServiceProvider.boot()now readsdeeplink.enabled.doc/getting-started/configuration.mdhas always documented it as the off switch (Config.get<bool>('deeplink.enabled', true)), and until now nothing read it: a consumer who set it tofalsestill got a driver, a link subscription, and a push bridge wired up. An absent key still means enabled; only an explicitfalsenow wires nothing.- The driver is only wired when
driver.isSupported. Handing an unsupported driver to the manager used to leavemanager.driveranswering with something that could never deliver a link, and would have shipped every browser build carrying a driver for a mechanism the browser does not have. The web and stub arms above make that check meaningful for the first time. - A cold-start link is delivered exactly once, not twice. The provider used to both subscribe to
driver.onLinkand separately callmanager.getInitialLink(), and on Android theapp_linkspackage serves the initial link on both paths, so a single tap on a notification or a link ran the whole handler chain twice.app_links' own README describes its link stream as carrying "all events (initial link and further)", so the stream is now the one delivery path;DeeplinkManager.getInitialLink()stays available for a consumer that wants to ask directly, but the provider no longer calls it. - Routing an incoming deep link reports a failure instead of letting it escape. The provider used to schedule delivery with
Future.delayed(Duration.zero, ...)and never awaited the result, so a handler that threw (a router not yet built, say) surfaced as an unhandled async error and took the tapped link down with it silently. Delivery now awaitsWidgetsFlutterBinding.ensureInitialized().endOfFrame, captured once at boot so a slow boot no longer races a fixed timer, and wraps the handoff in atry/catchthat reports at error level through magic'sLog(guarded byMagic.bound('log')) rather than swallowing or escaping. - The AASA generator emits Apple's modern
appIDs+componentsshape.buildAppleAppSiteAssociationused to write the legacyappID+pathsentry; it now writes onedetailsentry per Apple's TN3155 format, with noappskey, because Apple's own guidance warns that mixing the two schemas can produce unexpected behaviour for universal links.parseDeeplinkConfigalso now tolerates a Dart generic type annotation before a list literal (<String>[...]), which is how a config file typed by hand writessha256_fingerprintsandpaths.
0.0.3 - 2026-09-02 #
Fixed #
- A tapped push notification now opens its deep link. It never has before. The OneSignal wiring shipped in 0.0.1 and has been inert in every release since:
DeeplinkServiceProvider.boot()read the push driver'sonNotificationClickedand cast it toStream<Map<String, dynamic>>, a type it has never had (magic_notificationspublishesStream<PushNotificationEvent>), so the cast threw on every boot. It could not have got that far anyway, because the driver is attached in the notifications provider'sboot, which runs after this one in the order consumers register them, so the read that preceded the cast threw first. Both throws landed in acatchwhose body was two comment lines, which is why nobody noticed: no log, no exception, just a push that opened the app wherever it happened to be. The package's own test suite certified the feature green with a double whose stream really was aStream<Map<String, dynamic>>, and which handed out a driver before boot. - The wiring no longer depends on provider order or on a driver existing.
OneSignalDeeplinkHandler.setupnow subscribes to the notification manager's ownonPushClickedstream, which the manager owns from construction and republishes onto whenever a driver is attached, so notifications may boot before or after this package. That stream is also the subject-guarded one, so a push addressed to whoever held the device before does not open a deep link for whoever holds it now. An app withoutmagic_notificationsinstalled creates nothing here: no subscription, no timer, no handler. - A failure is reported instead of swallowed. A notification manager that publishes no
onPushClicked, an event carrying no readable payload, and an error on the click stream are each logged at error level through magic'sLog, guarded byMagic.bound('log')so a host that binds no logger is not handed a second failure on a path that is already degrading. There is no emptycatchleft in this package. - Resolving the notifications manager can no longer abort application boot.
app.make('notifications')runs the binding factory andonPushClickedis a getter, so either can throw; the handler answersNoSuchMethodErrorby name but aStateErrorout of an uninitialised manager is a different thing entirely. magic'sApplication.bootawaits providers in a bare loop with no error handling of its own, so an escaping throw here did not degrade the deep-link feature, it stopped the app booting and took every provider registered after this one with it, over a plugin that is OPTIONAL. The resolution is now guarded and reports at error level through the same seam. This is not the emptycatchthis release removed: that one had two comment lines for a body and is why the feature stayed inert for two years, while this one says what failed and only then lets boot continue. - A second
setupno longer leaves the first subscription live. The early return for a manager publishing no click stream sat before the cancel, so re-wiring against a manager this handler cannot follow left it routing taps through the previous one.
Added #
DeeplinkServiceProvider.dispose(), tearing down everythingboot()wired. The push-click handler was constructed inline inbootand the reference discarded, so thedisposethatdoc/basics/handlers.mdtells consumers to call in provider teardown was unreachable. The provider now holds the handler it wired, along with the driver's link subscription and the driver itself, and drops all three. Provider-level rather than handler-level on purpose: adispose()reaching only the push handler would read as tearing the provider down while leaving the driver running. It also callsDeeplinkManager.forgetDriver(), which existed and had no caller: the manager holds its own reference set byboot, so clearing the provider's field alone left the singleton answeringmanager.driverwith a driver this provider had just torn down. Adispose()that lands INSIDEboot()is covered too:await driver.initialize(...)suspends, and a teardown in that window used to be followed by boot resuming and subscribing anyway, creating a subscription after the teardown that nothing would ever cancel. And the scheduled initial-link read checks a disposed flag on both sides of its await, because there is no handle to cancel aFuture.delayedwith and a teardown in the same turn as boot would otherwise still route a deep link afterwards. Idempotent, because a consumer calling it does not know which parts a given deployment wired.
Changed #
OneSignalDeeplinkHandler.setup(manager, notifications)takes the notification manager, not a stream. The second parameter wasStream<Map<String, dynamic>>and is now themagic_notificationsmanager itself, read structurally (dynamic) so this package still declares no dependency on that one.extractData(event)is new and public: it reads a push event's payload off itsdatamember without naming the event's type.
Runtime requirement #
- The revived wiring reads
NotificationManager.onPushClicked, which arrives inmagic_notifications0.1.0 and does not exist below it. This package deliberately declares no dependency onmagic_notificationsat all (the coupling is optional; an app can use deep links with no push), so no resolver will ever enforce this version floor. That makes it a requirement only these words can carry: pairing this release with amagic_notificationsolder than 0.1.0 gets the handler's error-level report (the bound `notifications` manager ... publishes no `onPushClicked` stream) instead of a routed deep link, not a build failure.
0.0.2 - 2026-07-26 #
Changed #
magicconstraint bumped to^0.0.3->^0.0.5. The old bound excluded every magic release since 0.0.4: under pub's0.0.zcaret semantics^0.0.3means<0.0.4, so this plugin could not resolve alongside a consumer on current magic at all. Now tracks magic 0.0.5. No behavior change in this package.
0.0.1 - 2026-06-24 #
💥 Breaking Changes #
- Removed bin/ entrypoint:
dart run magic_deeplink:install/dart run magic_deeplink:generateno longer available. Use host-dispatched artisan commands instead:dart run <app>:artisan deeplink:installanddart run <app>:artisan deeplink:generate. This requires addingMagicDeeplinkArtisanProviderto your app's artisan providers list (see CLAUDE.md for setup). - Removed magic_cli dependency: Commands now extend
ArtisanCommandfromfluttersdk_artisaninstead ofCommandfrommagic_cli.
✨ Improvements #
- Manifest-driven install: The
deeplink:installcommand is now powered byinstall.yamland the artisan transactional installer, replacing imperative setup code. This enables consistent scaffolding across all magic plugins. - Read-only MCP tools: none. magic_deeplink ships only mutating commands (install, generate) and registers no MCP tools.
📚 Documentation #
- README: Rewrite to match Magic ecosystem format (centered logo, badges, features table, quick start)
- doc/ folder: Add comprehensive documentation (installation, configuration, drivers, handlers, CLI, architecture)
- CLAUDE.md: Updated architecture section and command table to reflect artisan dispatch model
🔧 Improvements #
- Package naming: Fix
fluttersdk_magic_deeplink→magic_deeplinkreferences for pub.dev publishing
0.0.1-alpha.1 - 2026-03-25 #
✨ Core Features #
- Unified Deep Link API: Single interface for iOS Universal Links and Android App Links
- Driver Pattern: Extensible driver architecture with
AppLinksDriveras default - Route Handler: Automatically maps deep link paths to Magic Routes via
RouteDeeplinkHandler - OneSignal Integration: Seamless notification click → deep link handling via
OneSignalDeeplinkHandler - CLI Tools:
installcommand generates config,generatecommand producesapple-app-site-associationandassetlinks.json - Service Provider:
DeeplinkServiceProviderfor automatic DI registration and boot