mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
Restore 1.x yattee:// links and harden URL scheme routing
- Route legacy yattee://watch?v=, yattee://youtube.com/..., yattee://https://... links like their web equivalents (only natively handled destinations) - Unwrap yattee://open?url= inside route() so every caller keeps the inner URL's own query items - Never route file:// URLs as direct media - Support source/instance on yattee://playlist and PeerTube /w/p/ and /video-playlists/ URLs - Leave open sheets alone and show a toast for links that go nowhere
This commit is contained in:
@@ -26,11 +26,21 @@ struct URLRouter: Sendable {
|
||||
|
||||
/// Route a URL to a navigation destination.
|
||||
func route(_ url: URL) -> NavigationDestination? {
|
||||
// Resolve yattee://open?url=… wrappers up front so every caller gets the
|
||||
// inner URL intact (including its own `&t=` / `&list=` query items).
|
||||
let url = unwrapped(url)
|
||||
|
||||
// Try custom scheme first
|
||||
if url.scheme == "yattee" {
|
||||
if url.scheme?.lowercased() == "yattee" {
|
||||
return parseCustomScheme(url)
|
||||
}
|
||||
|
||||
// Try PeerTube playlist URLs before video URLs (`/w/p/{id}` would otherwise
|
||||
// parse as a video named "p")
|
||||
if let (instance, playlistID) = parsePeerTubePlaylistURL(url) {
|
||||
return .playlist(.remote(PlaylistID(source: .federated(provider: ContentSource.peertubeProvider, instance: instance), playlistID: playlistID), instance: nil))
|
||||
}
|
||||
|
||||
// Try YouTube playlist URLs first (before video URLs since playlist pages can have v= param)
|
||||
if let playlistID = parseYouTubePlaylistURL(url) {
|
||||
return .playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: playlistID), instance: nil))
|
||||
@@ -51,8 +61,9 @@ struct URLRouter: Sendable {
|
||||
return .video(.id(.federated(videoID, instance: instance, uuid: nil)))
|
||||
}
|
||||
|
||||
// Try direct media URLs (mp4, m3u8, etc.) - no extraction needed
|
||||
if DirectMediaHelper.isDirectMediaURL(url) {
|
||||
// Try direct media URLs (mp4, m3u8, etc.) - no extraction needed.
|
||||
// Remote only: a link from another app must never open a local file.
|
||||
if isHTTPURL(url), DirectMediaHelper.isDirectMediaURL(url) {
|
||||
return .directMedia(url)
|
||||
}
|
||||
|
||||
@@ -68,10 +79,14 @@ struct URLRouter: Sendable {
|
||||
// MARK: - External URL Detection
|
||||
|
||||
/// Check if URL might be an external video that yt-dlp can handle.
|
||||
private func isHTTPURL(_ url: URL) -> Bool {
|
||||
let scheme = url.scheme?.lowercased()
|
||||
return scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
private func isExternalVideoURL(_ url: URL) -> Bool {
|
||||
// Must be http or https
|
||||
guard let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
guard isHTTPURL(url) else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -185,7 +200,7 @@ struct URLRouter: Sendable {
|
||||
|
||||
/// Parse yattee:// scheme URLs.
|
||||
private func parseCustomScheme(_ url: URL) -> NavigationDestination? {
|
||||
guard let host = url.host else { return nil }
|
||||
guard let host = url.host?.lowercased() else { return parseLegacyScheme(url) }
|
||||
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
|
||||
@@ -223,10 +238,21 @@ struct URLRouter: Sendable {
|
||||
return .channel(channelID, source)
|
||||
|
||||
case "playlist":
|
||||
// yattee://playlist/{playlistId}
|
||||
// yattee://playlist/{playlistId}?source={source}&instance={url}
|
||||
let playlistID = url.lastPathComponent
|
||||
guard !playlistID.isEmpty else { return nil }
|
||||
return .playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: playlistID), instance: nil))
|
||||
// yattee://playlist?list=… is the 1.x form
|
||||
guard !playlistID.isEmpty, playlistID != "/" else { return parseLegacyScheme(url) }
|
||||
|
||||
let sourceParam = components?.queryItems?.first(where: { $0.name == "source" })?.value
|
||||
let source: ContentSource
|
||||
if sourceParam == "peertube",
|
||||
let instanceStr = components?.queryItems?.first(where: { $0.name == "instance" })?.value,
|
||||
let instanceURL = URL(string: instanceStr) {
|
||||
source = .federated(provider: ContentSource.peertubeProvider, instance: instanceURL)
|
||||
} else {
|
||||
source = .global(provider: ContentSource.youtubeProvider)
|
||||
}
|
||||
return .playlist(.remote(PlaylistID(source: source, playlistID: playlistID), instance: nil))
|
||||
|
||||
case "search":
|
||||
// yattee://search?q={query}
|
||||
@@ -269,19 +295,50 @@ struct URLRouter: Sendable {
|
||||
return .settings
|
||||
|
||||
case "open":
|
||||
// yattee://open?url={encoded_url} - from share extension
|
||||
if let urlParam = components?.queryItems?.first(where: { $0.name == "url" })?.value,
|
||||
let decodedURL = URL(string: urlParam) {
|
||||
// Route the decoded URL through normal routing
|
||||
return route(decodedURL)
|
||||
}
|
||||
// yattee://open?url={encoded_url} - from share extension. `route()` unwraps
|
||||
// well-formed wrappers before getting here; this is a wrapper with no
|
||||
// usable inner URL.
|
||||
return nil
|
||||
|
||||
default:
|
||||
return nil
|
||||
return parseLegacyScheme(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Yattee 1.x accepted any web link with its scheme swapped for `yattee://`
|
||||
/// (`yattee://youtube.com/watch?v=…`, `yattee://https://youtu.be/…`) and bare
|
||||
/// paths (`yattee://watch?v=…`). Shortcuts and automations built for 1.x still
|
||||
/// send these, so rewrite them to https and route them as web links.
|
||||
private func parseLegacyScheme(_ url: URL) -> NavigationDestination? {
|
||||
let absolute = url.absoluteString
|
||||
guard let separator = absolute.range(of: "://") else { return nil }
|
||||
var remainder = String(absolute[separator.upperBound...])
|
||||
|
||||
// yattee://https://… — the original URL was appended whole
|
||||
if remainder.lowercased().hasPrefix("https://") || remainder.lowercased().hasPrefix("http://") {
|
||||
return routeLegacyWebURL(remainder)
|
||||
}
|
||||
// yattee://https//… — some URL encoders drop the colon
|
||||
for prefix in ["https//", "http//"] where remainder.lowercased().hasPrefix(prefix) {
|
||||
remainder = String(remainder.dropFirst(prefix.count))
|
||||
}
|
||||
|
||||
// yattee://watch?v=…, yattee://shorts/…, yattee://playlist?list=… — bare YouTube paths
|
||||
let host = remainder.prefix { $0 != "/" && $0 != "?" && $0 != "#" }
|
||||
if !host.contains(".") {
|
||||
return routeLegacyWebURL("https://www.youtube.com/\(remainder)")
|
||||
}
|
||||
return routeLegacyWebURL("https://\(remainder)")
|
||||
}
|
||||
|
||||
/// Only accept rewritten legacy links that resolve to something Yattee handles
|
||||
/// natively — never the yt-dlp catch-all, so `yattee://anything` stays a no-op.
|
||||
/// PeerTube-shaped paths (`yattee://host.tld/w/{id}`) pass too, as they do as web links.
|
||||
private func routeLegacyWebURL(_ string: String) -> NavigationDestination? {
|
||||
guard let webURL = URL(string: string), isHTTPURL(webURL) else { return nil }
|
||||
return routeConfidently(webURL)
|
||||
}
|
||||
|
||||
// MARK: - YouTube URL Parsing
|
||||
|
||||
/// Parse YouTube URLs and extract video ID.
|
||||
@@ -382,6 +439,23 @@ struct URLRouter: Sendable {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Parse PeerTube playlist URLs: `/w/p/{id}` and `/video-playlists/{id}`.
|
||||
private func parsePeerTubePlaylistURL(_ url: URL) -> (URL, String)? {
|
||||
guard let host = url.host, let scheme = url.scheme, isHTTPURL(url) else { return nil }
|
||||
let path = url.pathComponents
|
||||
|
||||
var playlistID: String?
|
||||
if path.count >= 4, path[1] == "w", path[2] == "p" {
|
||||
playlistID = path[3]
|
||||
} else if path.count >= 3, path[1] == "video-playlists" {
|
||||
playlistID = path[2]
|
||||
}
|
||||
|
||||
guard let playlistID, !playlistID.isEmpty,
|
||||
let instanceURL = URL(string: "\(scheme)://\(host)") else { return nil }
|
||||
return (instanceURL, playlistID)
|
||||
}
|
||||
|
||||
// MARK: - Channel URL Parsing
|
||||
|
||||
/// Parse YouTube channel URLs.
|
||||
|
||||
@@ -73,6 +73,11 @@ struct YatteeApp: App {
|
||||
.handlesExternalEvents(preferring: ["*"], allowing: ["*"])
|
||||
#endif
|
||||
.onOpenURL { url in
|
||||
// Leave open sheets alone when the link goes nowhere
|
||||
guard URLRouter().route(url) != nil else {
|
||||
reportUnhandledDeepLink(url)
|
||||
return
|
||||
}
|
||||
appEnvironment.navigationCoordinator.dismissPresentations()
|
||||
handleDeepLink(url)
|
||||
}
|
||||
@@ -507,6 +512,15 @@ struct YatteeApp: App {
|
||||
appEnvironment.navigationCoordinator.navigate(to: destination)
|
||||
}
|
||||
|
||||
/// Tell the user an incoming link couldn't be opened instead of failing silently.
|
||||
private func reportUnhandledDeepLink(_ url: URL) {
|
||||
LoggingService.shared.warning("Deep link not handled: \(url.absoluteString)", category: .general)
|
||||
appEnvironment.toastManager.showError(
|
||||
String(localized: "deepLink.unsupported.title", defaultValue: "Can't open link"),
|
||||
subtitle: url.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle a link tapped inside a video description.
|
||||
/// Video URLs that would interrupt active playback surface the queue sheet;
|
||||
/// everything else reuses the standard deep-link pipeline.
|
||||
|
||||
@@ -178,6 +178,78 @@ struct URLRouterTests {
|
||||
#expect(router.unwrapped(scheme) == scheme)
|
||||
}
|
||||
|
||||
@Test("route() unwraps yattee://open itself, keeping inner query items")
|
||||
func routeUnwrapsOpenWrapper() {
|
||||
let wrapper = URL(string: "yattee://open?url=https://www.youtube.com/watch?list=PLtest123&index=2")!
|
||||
if case .playlist(.remote(let id, _, _, _)) = router.route(wrapper) {
|
||||
#expect(id.playlistID == "PLtest123")
|
||||
} else {
|
||||
Issue.record("Expected playlist destination")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("yattee://open with no inner URL routes nowhere")
|
||||
func emptyOpenWrapper() {
|
||||
#expect(router.route(URL(string: "yattee://open")!) == nil)
|
||||
#expect(router.route(URL(string: "yattee://open?url=")!) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Local File Rejection
|
||||
|
||||
@Test("File URLs never route, directly or wrapped")
|
||||
func fileURLsRejected() {
|
||||
#expect(router.route(URL(string: "file:///private/var/mobile/movie.mp4")!) == nil)
|
||||
#expect(router.route(URL(string: "yattee://open?url=file:///private/var/mobile/movie.mp4")!) == nil)
|
||||
}
|
||||
|
||||
@Test("Remote direct media still routes")
|
||||
func remoteDirectMedia() {
|
||||
let url = URL(string: "https://example.com/video.mp4")!
|
||||
if case .directMedia(let media) = router.route(url) {
|
||||
#expect(media == url)
|
||||
} else {
|
||||
Issue.record("Expected directMedia destination")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy (1.x) Scheme Tests
|
||||
|
||||
@Test("Legacy yattee:// links route like their web equivalents", arguments: [
|
||||
"yattee://watch?v=dQw4w9WgXcQ",
|
||||
"yattee://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"yattee://youtube.com/watch?v=dQw4w9WgXcQ&t=30",
|
||||
"yattee://youtu.be/dQw4w9WgXcQ",
|
||||
"yattee://https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"yattee://shorts/dQw4w9WgXcQ",
|
||||
])
|
||||
func legacyVideoLinks(_ string: String) {
|
||||
if case .video(let source, _) = router.route(URL(string: string)!), case .id(let videoID) = source {
|
||||
#expect(videoID.videoID == "dQw4w9WgXcQ")
|
||||
} else {
|
||||
Issue.record("Expected video destination for \(string)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Legacy playlist and channel links")
|
||||
func legacyPlaylistAndChannel() {
|
||||
if case .playlist(.remote(let id, _, _, _)) = router.route(URL(string: "yattee://playlist?list=PLtest123")!) {
|
||||
#expect(id.playlistID == "PLtest123")
|
||||
} else {
|
||||
Issue.record("Expected playlist destination")
|
||||
}
|
||||
if case .channel(let id, _) = router.route(URL(string: "yattee://www.youtube.com/@somehandle")!) {
|
||||
#expect(id == "@somehandle")
|
||||
} else {
|
||||
Issue.record("Expected channel destination")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Unknown yattee:// hosts still route nowhere")
|
||||
func unknownCustomHost() {
|
||||
#expect(router.route(URL(string: "yattee://nonsense")!) == nil)
|
||||
#expect(router.route(URL(string: "yattee://example.com/page")!) == nil)
|
||||
}
|
||||
|
||||
// MARK: - PeerTube URL Tests
|
||||
|
||||
@Test("Parse PeerTube /w/ video URL")
|
||||
@@ -514,6 +586,32 @@ struct URLRouterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Parse yattee:// playlist URL with PeerTube source")
|
||||
func customSchemePeerTubePlaylist() {
|
||||
let url = URL(string: "yattee://playlist/abc123?source=peertube&instance=https://peertube.social")!
|
||||
if case .playlist(.remote(let id, _, _, _)) = router.route(url),
|
||||
case .federated(_, let instance) = id.source {
|
||||
#expect(id.playlistID == "abc123")
|
||||
#expect(instance.host == "peertube.social")
|
||||
} else {
|
||||
Issue.record("Expected federated playlist destination")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("PeerTube playlist web URLs route to playlists, not videos", arguments: [
|
||||
"https://framatube.org/w/p/abc123",
|
||||
"https://framatube.org/video-playlists/abc123",
|
||||
])
|
||||
func peertubePlaylistURLs(_ string: String) {
|
||||
if case .playlist(.remote(let id, _, _, _)) = router.route(URL(string: string)!),
|
||||
case .federated(_, let instance) = id.source {
|
||||
#expect(id.playlistID == "abc123")
|
||||
#expect(instance.host == "framatube.org")
|
||||
} else {
|
||||
Issue.record("Expected federated playlist destination for \(string)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Parse yattee:// channel URL with PeerTube source but no instance falls back to Global")
|
||||
func customSchemeChannelURLPeerTubeNoInstance() {
|
||||
// If source=peertube but no instance is provided, should fall back to Global
|
||||
|
||||
Reference in New Issue
Block a user