mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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.
124 lines
4.5 KiB
Swift
124 lines
4.5 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|
|
}
|