Resolve URL shorteners and prompt for ambiguous description links

Tapping bit.ly/tinyurl/t.co/etc. in a description or comment previously
opened Safari even when the destination was a playable YouTube URL.
Added an opt-in "Resolve Short Links" toggle under YouTube Enhancements
(off by default) that follows the redirect on tap: if the target is a
YouTube/PeerTube/direct-media URL, open it in-app; otherwise prompt the
user before falling back to yt-dlp extraction or the browser.

Also added a confirmation dialog for non-shortener links that only
matched the loose .externalVideo yt-dlp fallback, so arbitrary web
pages in descriptions no longer silently kick off extraction.

Prompts live on NavigationCoordinator and are dual-hosted by YatteeApp
and ExpandedPlayerSheet so they remain visible whether or not the
expanded player is covering the main view.
This commit is contained in:
Arkadiusz Fal
2026-04-23 07:29:57 +02:00
parent d38b781858
commit 5a839da1bd
13 changed files with 511 additions and 2 deletions

View File

@@ -72,6 +72,16 @@ final class NavigationCoordinator {
/// when the player is expanded, so the sheet always sits above the visible layer.
var descriptionLinkQueueSheetVideo: Video?
/// Resolved short-link URL awaiting user confirmation ("Try in Yattee" vs
/// "Open in Browser"). Dual-hosted like `descriptionLinkQueueSheetVideo`
/// so the dialog is visible whether or not the expanded player is covering
/// the main app view.
var resolvedShortLinkPrompt: URL?
/// Candidate URL that isn't confidently a video (loose `.externalVideo`
/// match) awaiting user confirmation before yt-dlp extraction. Dual-hosted.
var ambiguousExternalLinkPrompt: URL?
/// Whether the mini player playlist sheet is showing.
var isMiniPlayerPlaylistSheetPresented = false

View File

@@ -12,6 +12,18 @@ struct URLRouter: Sendable {
// MARK: - Main Routing
/// Route a URL only if we are *confident* the app can handle it natively
/// YouTube/PeerTube video/channel/playlist, direct media (mp4/m3u8/etc),
/// or the custom `yattee://` scheme. Unlike `route(_:)` this deliberately
/// skips the `.externalVideo` yt-dlp fallback, which matches almost any
/// http/https URL and is therefore unsafe to trigger blindly after
/// resolving a URL shortener.
func routeConfidently(_ url: URL) -> NavigationDestination? {
guard let destination = route(url) else { return nil }
if case .externalVideo = destination { return nil }
return destination
}
/// Route a URL to a navigation destination.
func route(_ url: URL) -> NavigationDestination? {
// Try custom scheme first

View File

@@ -0,0 +1,123 @@
//
// URLShortenerResolver.swift
// Yattee
//
// Best-effort resolver for known URL shortener services (bit.ly, tinyurl, t.co, ).
// Used to rescue taps on short links in video descriptions and comments: if the
// redirect target is a URL that `URLRouter` can handle, we open it in-app instead
// of bouncing out to Safari.
//
// Off-by-default feature wired via `SettingsManager.resolveShortLinksEnabled`.
//
import Foundation
enum URLShortenerResolver {
/// Hosts whose URLs we try to resolve. Kept deliberately narrow so we don't
/// fire spurious network requests against arbitrary hosts the user taps.
/// `youtu.be` is intentionally excluded `URLRouter` already handles it
/// directly without a network round-trip.
static let knownHosts: Set<String> = [
"bit.ly",
"tinyurl.com",
"t.co",
"goo.gl",
"ow.ly",
"buff.ly",
"is.gd",
"rebrand.ly",
"shorturl.at",
"cutt.ly",
"lnkd.in",
"tiny.cc",
"rb.gy"
]
/// Returns true if `url` is hosted on a known shortener service.
static func isShortener(_ url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false }
let normalizedHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host
return knownHosts.contains(normalizedHost)
}
/// Resolves a shortener URL by following redirects. Returns `nil` on any
/// error (network failure, timeout, non-HTTP response).
///
/// Internally uses `HEAD` first; if the shortener responds 405 Method Not
/// Allowed (some do), falls back to a single `GET`. Results are cached for
/// the app lifetime to make repeat taps instant.
static func resolve(_ url: URL) async -> URL? {
if let cached = await cache.get(url) {
return cached
}
let resolved = await performResolve(url)
if let resolved {
await cache.set(url, resolved: resolved)
}
return resolved
}
// MARK: - Implementation
private static func performResolve(_ url: URL) async -> URL? {
// Try HEAD first cheapest. Fall back to GET if anything throws or
// if HEAD doesn't yield a different URL (some servers reject HEAD with
// a connection reset before following redirects).
if let viaHead = try? await request(url, method: "HEAD"), viaHead != url {
return viaHead
}
return try? await request(url, method: "GET")
}
private static func request(_ url: URL, method: String) async throws -> URL? {
var request = URLRequest(url: url, timeoutInterval: 5)
request.httpMethod = method
// A plain browser-ish UA avoids bot-blocking on a couple of services.
request.setValue(
"Mozilla/5.0 (compatible; Yattee URL resolver)",
forHTTPHeaderField: "User-Agent"
)
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 5
config.timeoutIntervalForResource = 5
config.httpCookieStorage = nil
config.urlCache = nil
let session = URLSession(configuration: config)
defer { session.finishTasksAndInvalidate() }
let (_, response) = try await session.data(for: request)
// URLSession follows redirects by default, so `response.url` is the final URL.
// We accept any status code here even a 4xx/5xx final response is fine
// because we only care about *where* the redirect chain landed, not whether
// that destination is currently serving content.
guard let finalURL = response.url, finalURL != url else { return nil }
return finalURL
}
// Bounded in-memory cache. 128 entries is plenty tapping the same link
// repeatedly in a session is common; cross-session persistence isn't needed.
private static let cache = ResolveCache(limit: 128)
private actor ResolveCache {
private var storage: [URL: URL] = [:]
private var order: [URL] = []
private let limit: Int
init(limit: Int) { self.limit = limit }
func get(_ url: URL) -> URL? { storage[url] }
func set(_ url: URL, resolved: URL) {
if storage[url] == nil {
order.append(url)
if order.count > limit, let oldest = order.first {
order.removeFirst()
storage[oldest] = nil
}
}
storage[url] = resolved
}
}
}