Skip to content

feat: tvOS support for url_launcher - #23

Open
TheNoumanDev wants to merge 3 commits into
fluttertv:mainfrom
TheNoumanDev:feat/url-launcher-tvos
Open

feat: tvOS support for url_launcher#23
TheNoumanDev wants to merge 3 commits into
fluttertv:mainfrom
TheNoumanDev:feat/url-launcher-tvos

Conversation

@TheNoumanDev

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds url_launcher_tvos, the federated tvOS implementation of url_launcher, ported from url_launcher_ios 6.4.1. canLaunchUrl and external launchUrl work on tvOS via UIApplication.canOpenURL / open(_:options:); the in-app browser modes rely on SFSafariViewController (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/ app

  • Verified on tvOS simulator (version: 26.2)

  • Verified on a physical Apple TV (Apple TV 4K 3rd gen / tvOS 26.6)

  • dart analyze is clean for the package

  • All 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 AppDelegate logged the delivered URL); on a physical Apple TV 4K, launchUrl opened the App Store.

  • In-app browser modes throw PlatformException(no_ui_available); supportsMode returns false for them.

Versioning & changelog

  • version: set to 0.0.1 (new package)
  • Matching ## 0.0.1 entry at the top of CHANGELOG.md
  • Behaviour documented: in-app browser modes unsupported on tvOS
  • Semver 0.x: initial 0.0.1

Checklist

  • Only url_launcher_tvos files touched (+ the root README ports row, required by the R1 gate)
  • No secrets, absolute local paths, or TODO/debug leftovers
  • README.md documents the tvOS constraint (no in-app browser / no WebKit)
  • Sibling note below

Notes for reviewers

  • Generated Pigeon kept upstream-verbatim: messages.g.dart is byte-identical to 6.4.1; messages.g.swift differs only by the import gate (#if os(iOS) || os(tvOS)). The porter had wrapped UrlLauncherApiSetup.setUp in #if !os(tvOS) (it matched SFSafariViewController in a doc comment) — that would have unregistered every channel on tvOS; reverted.
  • Native divergence is minimal + honest: URLLaunchSession (SafariServices) is #if !os(tvOS); openUrlInSafariViewController returns .noUI and closeSafariViewController is a no-op on tvOS. canLaunchUrl / launchUrl are untouched, and Launcher.swift / ViewPresenter.swift are byte-identical to upstream.
  • Dart re-declares tvOS-honest supportsMode / supportsCloseForMode / platformDefault (this Dart runs only on tvOS, so no platform guards).
  • Ships both a podspec and tvos/Package.swift (SPM), matching the pure-Swift method-channel plugins from feat(spm): add Package.swift to the Swift method-channel plugins #1.
  • Version floor kept at the repo-standard 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 and dart analyzes clean on the lower floor), and it stays consistent with the sibling _tvos packages.
  • Note: on tvOS canLaunchUrl can return true for a web URL even when nothing handles it, so callers should rely on the launchUrl return value (documented in the README). This PR also removes url_launcher from the README's "Evaluated but not provided" table, since it is now provided.

@DenisovAV DenisovAV left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.dart is byte-identical to url_launcher_ios 6.4.1; messages.g.swift differs by exactly one line (#if os(iOS)#if os(iOS) || os(tvOS), line 9). The generated files really are generated.
  • LICENSE and PrivacyInfo.xcprivacy are byte-identical to upstream too — the copyright line was preserved rather than replaced, and the manifest is correctly empty (neither canOpenURL nor open(_: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, and guard 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 downgrade resolves url_launcher_platform_interface to 2.2.0 and analyze is clean.
  • PORTING_REPORT.md with 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 == trueinAppBrowserView (: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)

  1. no_ui_available reuses iOS's message for a transient problem to report a permanent one. On iOS "No view controller available" is accurate — nil registrar.viewController, fixable. On tvOS .noUI is returned unconditionally for an unrelated reason. A developer will go auditing AppDelegate and GeneratedPluginRegistrant hunting a nil that was never nil. Keep the code, change the message.
  2. The class dartdoc (:14-16) contradicts your own verification — it says canLaunch "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: with if (await canLaunchUrl(url)), the else branch is unreachable, launchUrl returns false, the result is discarded, and the button silently does nothing. canLaunch (:32-36) has no dartdoc of its own.
  3. The tests cover everything except what the port changed. 60 lines / 4 tests vs. upstream's 484; the @visibleForTesting api seam (: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 :361 group('launch with platform default'). Everything behaves correctly today — I ran the uncovered paths. But if a future re-sync restores upstream's inApp = url.startsWith('http:'), every platformDefault launch starts throwing and all four tests still pass. No mockito needed; class _FakeApi implements UrlLauncherApi is enough.
  4. 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), no supportsLaunchMode readout. And _canLaunch (:44-47) has no try/catch, unlike its sibling _launchExternal (:49-56) — on a throw, setState never runs and the previous success message stays on screen. Pointedly: this example would have looked healthy under the MissingPluginException regression the porting report calls the biggest hazard of this port.
  5. 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-run doesn't catch this (pana scoring, not a client validator).
    • pubspec.yaml has no issue_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-35 declares no resources:, and tvos/Resources/ sits beside tvos/Classes/, so PrivacyInfo.xcprivacy never reaches the SPM build — and Podfile:28-34 skips CocoaPods for any plugin shipping a Package.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 wires resource_bundles at all — six others ship a .xcprivacy that no podspec references — so this is the one others will copy. Upstream does it right: resources: [.process("Resources")]. Full fix also needs s.source_files narrowed from 'Classes/**/*' to 'Classes/**/*.swift'.
  • s.homepage 404s (podspec:15github.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 .h files and matches nothing; the package is pure Swift.
  • No implements: url_launcher in pubspec.yaml, although AUTHORING.md:78 uses this exact plugin as its example and :260 makes it a checklist item. Registration works regardless, and no package of 24 declares it — so the question is really whether AUTHORING.md should be corrected instead.

@TheNoumanDev
TheNoumanDev requested a review from DenisovAV August 20, 2026 12:45

@DenisovAV DenisovAV left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 canLaunch can return true for an http(s) URL nothing will open, pointing the reader at the launchUrl result instead.
  • Tests: 4 → 11. The four that existed at dbfebf7 survive verbatim; the seven added are the launch-routing group.
  • The example exercises the in-app fallback, canLaunchUrl and supportsLaunchMode, _canLaunch has the try/catch its sibling already had, and the web button is labelled "may lie, returns true".
  • Metadata: description 33 → 100 characters, issue_tracker present, s.homepage moved to the monorepo shape that matches repository:. The dead -DTARGET_OS_TV and s.public_header_files are 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 supportsCloseForMode override. The nothing supports close test only checks inAppBrowserView, and the base class default returns false for that mode too — it returns true only for inAppWebView. So the one test covering the override cannot detect its removal. Asserting inAppWebView as well fixes it.
  • canLaunch returning an unconditional true — no coverage at all, despite its semantics being the subject of a dartdoc warning added in this same commit.
  • LaunchResult.invalidUrl returning false instead of throwing argument_error.
  • Dropping the universalLinksOnly → externalNonBrowserApplication branch in launch().
  • closeWebView no 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Port url_launcher to tvOS

2 participants