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:
Arkadiusz Fal
2026-10-05 23:33:48 +02:00
parent 1bb78d32bf
commit dea4f590ec
3 changed files with 202 additions and 16 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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