feat: tvOS support for url_launcher - #23
Conversation
There was a problem hiding this comment.
Review: url_launcher_tvos
Thanks for this — the port is careful, and it shows in the details. Verified independently against upstream and the repo:
messages.g.dartis byte-identical tourl_launcher_ios6.4.1;messages.g.swiftdiffers by exactly one line (#if os(iOS)→#if os(iOS) || os(tvOS), line 9). The generated files really are generated.LICENSEandPrivacyInfo.xcprivacyare byte-identical to upstream too — the copyright line was preserved rather than replaced, and the manifest is correctly empty (neithercanOpenURLnoropen(_:options:)is a Required Reason API).- Channel names, argument orders, enum type IDs and indices all match on both sides — no Pigeon drift.
- The
#if !os(tvOS)surgery is correct and complete: no dangling references, andguard let presenter … else { completion(.success(.noUI)) }was preserved rather than collapsed into optional chaining — which would have been exactly the hung-Future trap. pub publish --dry-run: 753 KB, 0 warnings, nothing stray in the archive.- The dependency floor isn't just asserted, it's proven:
pub downgraderesolvesurl_launcher_platform_interfaceto 2.2.0 andanalyzeis clean. PORTING_REPORT.mdwith verification on a physical Apple TV 4K is rare and appreciated.
One blocking item; everything else is follow-up.
🔴 Blocking: the deprecated launch() throws on the simplest possible call
url_launcher 6.3.2 computes useSafariVC: forceSafariVC ?? isWebURL (legacy_api.dart:105). So a bare call with no named arguments at all:
await launch('https://example.com/help');arrives at url_launcher_tvos.dart:55 with useSafariVC == true → inAppBrowserView (:56) → openUrlInSafariViewController (:100) → .noUI (URLLauncherPlugin.swift:62-65) → PlatformException('no_ui_available') (:159-160). The external channel is never touched.
Reproduced against a fake API:
deprecated launch() -> thrown = PlatformException(no_ui_available, ...)
calls = [openUrlInSafariViewController(https://example.com/help)]
The key point: the caller never asked for an in-app browser — the shim inferred that flag from the URL scheme. On iOS this opens Safari VC, on macOS/Windows/Linux it opens externally, here it's an unhandled exception in a button handler. This is exactly the "porting an existing iOS app to tvOS" path, and it isn't mentioned in the README, the CHANGELOG, or the porting report.
Fix is what url_launcher_macos does (url_launcher_macos.dart:39-56 ignores both flags):
final PreferredLaunchMode mode = universalLinksOnly
? PreferredLaunchMode.externalNonBrowserApplication
: PreferredLaunchMode.externalApplication;🟡 A decision worth making: throw vs. fall back for explicit in-app modes
Separate from the blocker. url_launcher_platform_interface 2.3.2 (url_launcher_platform.dart:105-108):
Clients are not required to query this, as implementations are strongly encouraged to automatically fall back to other modes if a launch is requested using an unsupported mode.
All three browser-less implementations in the federation do exactly that:
supportsMode(inApp*) |
launchUrl with an in-app mode |
|
|---|---|---|
| macOS 3.2.5 | false |
ignores mode, external launch |
| Windows 3.1.5 | false |
ignores mode, external launch |
| Linux 3.2.2 | false |
ignores options.mode entirely |
| tvOS (this PR) | false ✅ |
throws no_ui_available |
I understand the motivation ("the honest tvOS stub"), but the honesty channel is supportsMode, and it already returns false. And falling back is not a silent success here: an unclaimed https:// URL returns false, so the caller still learns it failed, through the documented mechanism. There's also an internal inconsistency today — platformDefault does fall back to external (:87-95) while explicit in-app modes throw.
Recording the counter-argument fairly: with a fallback, an explicit in-app request could eject the user into another app via a universal link. That's bounded, though — a plain https:// URL returns false and ejects nobody, and where a link is claimed, opening it is usually the intent.
Other findings (non-blocking)
no_ui_availablereuses iOS's message for a transient problem to report a permanent one. On iOS "No view controller available" is accurate — nilregistrar.viewController, fixable. On tvOS.noUIis returned unconditionally for an unrelated reason. A developer will go auditingAppDelegateandGeneratedPluginRegistranthunting a nil that was never nil. Keep the code, change the message.- The class dartdoc (
:14-16) contradicts your own verification — it sayscanLaunch"works as on iOS", while the README and porting report say the opposite. It's the only one of the three a developer sees on IDE hover, and it disables the canonical idiom: withif (await canLaunchUrl(url)), theelsebranch is unreachable,launchUrlreturnsfalse, the result is discarded, and the button silently does nothing.canLaunch(:32-36) has no dartdoc of its own. - The tests cover everything except what the port changed. 60 lines / 4 tests vs. upstream's 484; the
@visibleForTesting apiseam (:19) is never used. The two dropped groups are precisely the ones covering the changed behaviour:url_launcher_ios_test.dart:295(no_ui_available) and:361group('launch with platform default'). Everything behaves correctly today — I ran the uncovered paths. But if a future re-sync restores upstream'sinApp = url.startsWith('http:'), everyplatformDefaultlaunch starts throwing and all four tests still pass. No mockito needed;class _FakeApi implements UrlLauncherApiis enough. - The example only demonstrates the paths that work. No in-app-mode button (the PR's headline behaviour is never shown), no
canLaunchUrl(_webUrl)(the verified trap), nosupportsLaunchModereadout. And_canLaunch(:44-47) has no try/catch, unlike its sibling_launchExternal(:49-56) — on a throw,setStatenever runs and the previous success message stays on screen. Pointedly: this example would have looked healthy under theMissingPluginExceptionregression the porting report calls the biggest hazard of this port. - Two pub.dev metadata items that are specific to this package:
pubspec.yaml:2— the description is 36 characters, under pub.dev's 60-char floor. It's the shortest of all 24 packages; the next shortest is 87.--dry-rundoesn't catch this (pana scoring, not a client validator).pubspec.yamlhas noissue_tracker:— the only package of 24 without one, so consumers have no filing route.
Repo-wide, not yours — I'll file these separately
These all reproduce across other packages, so please don't treat them as review debt on this PR. Listing them because this PR is where I found them:
Package.swift:25-35declares noresources:, andtvos/Resources/sits besidetvos/Classes/, soPrivacyInfo.xcprivacynever reaches the SPM build — andPodfile:28-34skips CocoaPods for any plugin shipping aPackage.swift, so SPM is the path your builds actually exercised. No practical harm today, since the manifest is correctly empty. It's worth fixing because this is the only one of the 24 packages that wiresresource_bundlesat all — six others ship a.xcprivacythat no podspec references — so this is the one others will copy. Upstream does it right:resources: [.process("Resources")]. Full fix also needss.source_filesnarrowed from'Classes/**/*'to'Classes/**/*.swift'.s.homepage404s (podspec:15→github.com/fluttertv/url_launcher_tvos). Inherited: 12 of the 24 packages use this form and all of them 404; the 12 Firebase/cloud packages use the working.../plugins/tree/main/packages/<name>form. Worth switching here since it's one line.-DTARGET_OS_TV(podspec:31,Package.swift:31-33) is dead — no source uses#if TARGET_OS_TV; all gating is#if os(tvOS). The comment claims it keeps those branches active, which could mislead a future porter into writing a gate that silently depends on a build define.s.public_header_files(podspec:20) globs for.hfiles and matches nothing; the package is pure Swift.- No
implements: url_launcherinpubspec.yaml, althoughAUTHORING.md:78uses this exact plugin as its example and:260makes it a checklist item. Registration works regardless, and no package of 24 declares it — so the question is really whetherAUTHORING.mdshould be corrected instead.
DenisovAV
left a comment
There was a problem hiding this comment.
Verified against 6f238d7 by driving the real url_launcher 6.3.2 shim through the plugin and by mutating the source, not from the diff.
The blocker is gone
launchUrl no longer routes on options.mode, so the path that started this — a bare launch('https://example.com/help') with no named arguments, where the shim infers useSafariVC: true from the scheme — reaches UIApplication.open instead of PlatformException('no_ui_available'). Driven against a real UrlLauncherApi with all four Pigeon channels mocked, only …UrlLauncherApi.launchUrl is hit, payload [https://example.com/help, false]; the openUrlInSafariViewController channel is never touched. With the host returning LaunchResult.failure it returns false rather than throwing.
Mutation-checked, since a later re-sync with upstream is exactly what could undo this:
| mutation | result |
|---|---|
restore in-app routing to openUrlInSafariViewController |
3 failures |
pass a constant false for universalLinksOnly |
1 failure |
supportsMode returns true for the in-app modes |
1 failure |
no_ui_available now has zero occurrences in the package — _mapInAppLoadResult and _noUIException were deleted rather than reworded, so the message that pointed at a nil view controller cannot surface at all.
Falling back rather than throwing is sanctioned by the platform interface, not merely tolerated by it: supportsMode's own dartdoc says implementations are "strongly encouraged to automatically fall back", and PreferredLaunchMode says platforms "may substitute another mode". The internal inconsistency is gone too — platformDefault and the explicit in-app modes now behave alike.
The rest of the August review
- The class dartdoc now states the fallback and warns that
canLaunchcan returntruefor anhttp(s)URL nothing will open, pointing the reader at thelaunchUrlresult instead. - Tests: 4 → 11. The four that existed at
dbfebf7survive verbatim; the seven added are the launch-routing group. - The example exercises the in-app fallback,
canLaunchUrlandsupportsLaunchMode,_canLaunchhas thetry/catchits sibling already had, and the web button is labelled "may lie, returns true". - Metadata: description 33 → 100 characters,
issue_trackerpresent,s.homepagemoved to the monorepo shape that matchesrepository:. The dead-DTARGET_OS_TVands.public_header_filesare gone as well — those were on the list I had explicitly excluded from your scope.
Worth doing, none of it blocking
The deprecated launch() drops universalLinksOnly for web URLs. launch('https://example.com/help', universalLinksOnly: true) reaches the host as universalLinksOnly: false. The override is byte-identical to iOS's — if (useSafariVC) … else if (universalLinksOnly) … — and legacy_api.dart:105 sets useSafariVC = forceSafariVC ?? isWebURL, so for a web URL the second branch is unreachable. On iOS the SafariVC branch is what justifies nullifying the flag; on tvOS that branch cannot exist, so the launch goes external carrying the wrong value. I could not construct a case where the flag changes the tvOS outcome — with no app configured UIApplication.open fails either way, and where one is configured both values open it — so this is contract-correctness rather than user-visible breakage. Testing universalLinksOnly before useSafariVC fixes it in one line, and it is worth doing precisely because it is the same shape as the bug this PR just fixed: the shim's iOS-shaped inference producing the wrong thing on a platform with no browser. The useSafariVC → inAppBrowserView mapping and webViewConfiguration are now dead inputs either way.
README.md:21 still says "Then use the url_launcher API exactly as on iOS." Same claim as the class dartdoc you just corrected, four paragraphs before the section that contradicts it.
Five mutations the suite cannot see, all leaving 11/11 green:
- Deleting the
supportsCloseForModeoverride. Thenothing supports closetest only checksinAppBrowserView, and the base class default returnsfalsefor that mode too — it returnstrueonly forinAppWebView. So the one test covering the override cannot detect its removal. AssertinginAppWebViewas well fixes it. canLaunchreturning an unconditionaltrue— no coverage at all, despite its semantics being the subject of a dartdoc warning added in this same commit.LaunchResult.invalidUrlreturningfalseinstead of throwingargument_error.- Dropping the
universalLinksOnly → externalNonBrowserApplicationbranch inlaunch(). closeWebViewno longer calling the host.
Also worth renaming: the deprecated launch(webUrl) test does not drive url_launcher's launch(). It calls the override with a hand-fed useSafariVC: true and a comment restating what legacy_api.dart would have inferred. It does pin the override, but a reader will believe the legacy path itself is covered, and it is the one call that was actually broken. The example has the same gap.
"Matching macOS/Windows/Linux" (dartdoc, CHANGELOG, README, porting report) is true of the fallback but not of the supported set: those three report supportsMode true only for platformDefault and externalApplication, while tvOS also claims externalNonBrowserApplication. That claim is justified — it really does forward universalLinksOnly — so the phrase just needs narrowing to the fallback.
On the privacy manifest, correcting what I said in August. tvos/Package.swift declares no resources:, but a bare resources: [.process("Resources")] would not work: the target is path: "Classes", and tvos/Resources/ sits beside it rather than inside it, so the file is outside the target root. Upstream keeps the manifest at Sources/url_launcher_ios/Resources/ — inside the target — which is the shape to copy. And the wider picture is better than I implied: seven of the twenty-four packages ship a PrivacyInfo.xcprivacy and six of those wire it nowhere at all, so this package is the best-wired of the seven rather than an outlier. With an empty manifest and neither canOpenURL nor open(_:options:) being a Required Reason API, nothing App Store-facing turns on it. Cosmetic.
Approving. The version that stops routing on the mode in one place reads better than the branching I suggested.
What does this PR do?
Adds
url_launcher_tvos, the federated tvOS implementation ofurl_launcher, ported fromurl_launcher_ios6.4.1.canLaunchUrland externallaunchUrlwork on tvOS viaUIApplication.canOpenURL/open(_:options:); the in-app browser modes rely onSFSafariViewController(SafariServices), which does not exist on tvOS, so those are reported unsupported rather than crashing.Closes #16.
Package(s) touched:
url_launcher_tvos(new) + root README ports table.How was it tested?
Ran the package's
example/appVerified on tvOS simulator (version: 26.2)
Verified on a physical Apple TV (Apple TV 4K 3rd gen / tvOS 26.6)
dart analyzeis clean for the packageAll four Pigeon channels round-trip on the simulator with no
MissingPluginException.Real external launch confirmed two ways: on the simulator a registered app URL scheme launched a separate app (its
AppDelegatelogged the delivered URL); on a physical Apple TV 4K,launchUrlopened the App Store.In-app browser modes throw
PlatformException(no_ui_available);supportsModereturnsfalsefor them.Versioning & changelog
version:set to0.0.1(new package)## 0.0.1entry at the top ofCHANGELOG.md0.x: initial0.0.1Checklist
url_launcher_tvosfiles touched (+ the root README ports row, required by the R1 gate)TODO/debug leftoversREADME.mddocuments the tvOS constraint (no in-app browser / no WebKit)Notes for reviewers
messages.g.dartis byte-identical to 6.4.1;messages.g.swiftdiffers only by the import gate (#if os(iOS) || os(tvOS)). The porter had wrappedUrlLauncherApiSetup.setUpin#if !os(tvOS)(it matchedSFSafariViewControllerin a doc comment) — that would have unregistered every channel on tvOS; reverted.URLLaunchSession(SafariServices) is#if !os(tvOS);openUrlInSafariViewControllerreturns.noUIandcloseSafariViewControlleris a no-op on tvOS.canLaunchUrl/launchUrlare untouched, andLauncher.swift/ViewPresenter.swiftare byte-identical to upstream.supportsMode/supportsCloseForMode/platformDefault(this Dart runs only on tvOS, so no platform guards).tvos/Package.swift(SPM), matching the pure-Swift method-channel plugins from feat(spm): add Package.swift to the Swift method-channel plugins #1.flutter: >=3.13.0— upstream 6.4.1 raised its floor to Flutter 3.38 / Dart 3.10 (in 6.4.0), but nothing in this tvOS slice needs it (it builds anddart analyzes clean on the lower floor), and it stays consistent with the sibling_tvospackages.canLaunchUrlcan returntruefor a web URL even when nothing handles it, so callers should rely on thelaunchUrlreturn value (documented in the README). This PR also removesurl_launcherfrom the README's "Evaluated but not provided" table, since it is now provided.