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.
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.