mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
123
Yattee/Services/Navigation/URLShortenerResolver.swift
Normal file
123
Yattee/Services/Navigation/URLShortenerResolver.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user