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