From dea4f590ec0809c8521cbbe4a117a207b81263e0 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 5 Oct 2026 23:33:48 +0200 Subject: [PATCH] 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 --- Yattee/Services/Navigation/URLRouter.swift | 106 +++++++++++++++++---- Yattee/YatteeApp.swift | 14 +++ YatteeTests/NavigationTests.swift | 98 +++++++++++++++++++ 3 files changed, 202 insertions(+), 16 deletions(-) diff --git a/Yattee/Services/Navigation/URLRouter.swift b/Yattee/Services/Navigation/URLRouter.swift index f3836d7d..2e7c8bee 100644 --- a/Yattee/Services/Navigation/URLRouter.swift +++ b/Yattee/Services/Navigation/URLRouter.swift @@ -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. diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index b0f409b6..66661baa 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -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. diff --git a/YatteeTests/NavigationTests.swift b/YatteeTests/NavigationTests.swift index 5b125655..00e0abaa 100644 --- a/YatteeTests/NavigationTests.swift +++ b/YatteeTests/NavigationTests.swift @@ -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