From c579ee63a28a313a79e959fabdd03c69dd5a8654 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 5 Oct 2026 23:59:05 +0200 Subject: [PATCH] Support x-callback-url for URL scheme actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yattee://x-callback-url/{verb}?… runs the same action as yattee://{verb}?… and then opens x-success with the result's values appended, x-error with errorCode/errorMessage, or x-cancel when a confirmation is declined (x-error with errorCode=cancelled when no x-cancel is given). Unknown verbs report unsupportedAction. Callbacks to file:, javascript: and data: URLs are ignored. Adds yattee://player/state, which reports what is playing (title, videoID, channel, playing, position, duration, rate) without changing anything. --- Yattee/Services/Navigation/LinkAction.swift | 14 ++- .../Navigation/LinkActionPerformer.swift | 10 +- Yattee/Services/Navigation/XCallback.swift | 106 ++++++++++++++++++ Yattee/YatteeApp.swift | 13 ++- YatteeTests/NavigationTests.swift | 62 ++++++++++ 5 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 Yattee/Services/Navigation/XCallback.swift diff --git a/Yattee/Services/Navigation/LinkAction.swift b/Yattee/Services/Navigation/LinkAction.swift index 3523cf5e..66691bb6 100644 --- a/Yattee/Services/Navigation/LinkAction.swift +++ b/Yattee/Services/Navigation/LinkAction.swift @@ -14,6 +14,10 @@ // yattee://player/play | pause | toggle | next | previous | close // yattee://player/seek?t={seconds} | seek?by={±seconds} // yattee://player/rate?value={rate} +// yattee://player/state (reports what is playing; for x-callback-url) +// +// Each also works as x-callback-url — yattee://x-callback-url/{verb}?… +// &x-success=…&x-error=…&x-cancel=… — see `XCallback`. // // `url` may be percent-encoded or raw; a raw link keeps its own query // (`&list=…`) because only the action's own parameters end it. @@ -39,6 +43,8 @@ enum LinkAction: Equatable { case next case previous case close + /// Reports the player's state without changing it. + case state case seek(to: TimeInterval) case seekBy(TimeInterval) case rate(Double) @@ -72,10 +78,15 @@ struct LinkActionParser { /// Hosts claimed by actions, so `URLRouter` never reads them as 1.x links. static let hosts: Set = [ - "play", "play-next", "queue", "download", "bookmark", "subscribe", "unsubscribe", "player" + "play", "play-next", "queue", "download", "bookmark", "subscribe", "unsubscribe", "player", + XCallback.host ] func parse(_ url: URL) -> LinkAction? { + // yattee://x-callback-url/{verb}… is yattee://{verb}… plus callbacks + if url.host?.lowercased() == XCallback.host { + return XCallback.actionURL(from: url).flatMap(parse) + } guard url.scheme?.lowercased() == "yattee", let host = url.host?.lowercased(), Self.hosts.contains(host) else { return nil @@ -119,6 +130,7 @@ struct LinkActionParser { case "next": return .player(.next) case "previous": return .player(.previous) case "close": return .player(.close) + case "state": return .player(.state) case "seek": if let raw = query["by"], let delta = Double(raw), delta.isFinite { return .player(.seekBy(delta)) diff --git a/Yattee/Services/Navigation/LinkActionPerformer.swift b/Yattee/Services/Navigation/LinkActionPerformer.swift index dd32805c..0f5e1337 100644 --- a/Yattee/Services/Navigation/LinkActionPerformer.swift +++ b/Yattee/Services/Navigation/LinkActionPerformer.swift @@ -218,6 +218,9 @@ struct LinkActionPerformer { private func perform(_ command: LinkAction.PlayerCommand) async -> LinkActionResult { let player = appEnvironment.playerService + if case .state = command { + return .success(playerValues()) + } guard player.state.currentVideo != nil else { return .failure(code: "nothingPlaying", message: String(localized: "linkAction.error.nothingPlaying", defaultValue: "Nothing is playing.")) } @@ -237,6 +240,8 @@ struct LinkActionPerformer { appEnvironment.navigationCoordinator.isPlayerExpanded = false player.stop() return .success() + case .state: + break case .seek(let time): await player.seek(to: clampedTime(time)) case .seekBy(let delta): @@ -318,8 +323,11 @@ struct LinkActionPerformer { private func playerValues() -> [String: String] { let state = appEnvironment.playerService.state - var values = state.currentVideo.map(values(for:)) ?? [:] + guard let video = state.currentVideo else { return ["playing": "false"] } + var values = values(for: video) + values["playing"] = String(state.playbackState == .playing) values["position"] = String(Int(state.currentTime)) + values["duration"] = String(Int(state.duration)) values["rate"] = String(state.rate.rawValue) return values } diff --git a/Yattee/Services/Navigation/XCallback.swift b/Yattee/Services/Navigation/XCallback.swift new file mode 100644 index 00000000..09b9650f --- /dev/null +++ b/Yattee/Services/Navigation/XCallback.swift @@ -0,0 +1,106 @@ +// +// XCallback.swift +// Yattee +// +// x-callback-url support (https://x-callback-url.com): +// +// yattee://x-callback-url/{verb}?{action parameters} +// &x-success={url}&x-error={url}&x-cancel={url}&x-source={app name} +// +// `{verb}` is any `LinkAction` host (`play`, `queue`, `player/pause`, …). +// When the action finishes Yattee opens one of the callbacks: +// x-success with the action's values appended (videoID, title, …) +// x-error with errorCode and errorMessage appended +// x-cancel when the user declined a confirmation +// + +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +struct XCallback: Equatable { + static let host = "x-callback-url" + + let success: URL? + let error: URL? + let cancel: URL? + /// The calling app's name, as it chose to send it. + let source: String? + + /// Callbacks of an x-callback-url request, or nil for any other URL. + init?(_ url: URL) { + guard url.scheme?.lowercased() == "yattee", url.host?.lowercased() == Self.host else { return nil } + let query = LinkActionParser.queryParameters(of: url) + success = query["x-success"].flatMap(Self.callbackURL) + error = query["x-error"].flatMap(Self.callbackURL) + cancel = query["x-cancel"].flatMap(Self.callbackURL) + source = query["x-source"].flatMap { $0.isEmpty ? nil : $0 } + } + + /// The action an x-callback-url request asks for, written as the plain + /// action link: yattee://x-callback-url/player/seek?t=9 → yattee://player/seek?t=9. + static func actionURL(from url: URL) -> URL? { + let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !path.isEmpty else { return nil } + let absolute = url.absoluteString + // Keep the query exactly as sent: it may hold an unencoded link + let query = absolute.firstIndex(of: "?").map { String(absolute[$0...]) } ?? "" + return URL(string: "yattee://\(path)\(query)") + } + + /// The callback to open for a result, with the result's values appended. + func url(for result: LinkActionResult) -> URL? { + switch result { + case .success(let values): + return success.map { Self.appending(values, to: $0) } + case .failure(let code, let message): + return error.map { Self.appending(["errorCode": code, "errorMessage": message], to: $0) } + case .cancelled: + // Callers that don't handle cancel still learn the action didn't run + if let cancel { return cancel } + return error.map { Self.appending(["errorCode": "cancelled", "errorMessage": "Cancelled"], to: $0) } + } + } + + /// Opens the callback for a result, if the caller gave one. + @MainActor + func open(for result: LinkActionResult) { + guard let url = url(for: result) else { return } + LoggingService.shared.info("x-callback-url: returning to \(url.scheme ?? "?")://", category: .general) + #if os(macOS) + NSWorkspace.shared.open(url) + #else + UIApplication.shared.open(url) + #endif + } + + // MARK: - Helpers + + /// Callbacks go back to an app, never to a local file or a script. + private static func callbackURL(_ raw: String) -> URL? { + guard let url = URL(string: raw), let scheme = url.scheme?.lowercased(), + !["file", "javascript", "data"].contains(scheme) else { + return nil + } + return url + } + + /// Appends parameters, escaping everything a query value can't hold as-is + /// (`URLComponents` leaves `&`, `=` and `+` alone). + static func appending(_ values: [String: String], to url: URL) -> URL { + guard !values.isEmpty, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "&=+?#") + let added = values.sorted { $0.key < $1.key }.map { key, value in + "\(key)=\(value.addingPercentEncoding(withAllowedCharacters: allowed) ?? "")" + } + components.percentEncodedQuery = ([components.percentEncodedQuery].compactMap { $0 }.filter { !$0.isEmpty } + added) + .joined(separator: "&") + return components.url ?? url + } +} diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 48eab7fb..9f475f38 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -73,15 +73,26 @@ struct YatteeApp: App { .handlesExternalEvents(preferring: ["*"], allowing: ["*"]) #endif .onOpenURL { url in + let callback = XCallback(url) if let action = LinkActionParser().parse(url) { if action.dismissesPresentations { appEnvironment.navigationCoordinator.dismissPresentations() } Task { - _ = await LinkActionPerformer(appEnvironment: appEnvironment).perform(action) + let result = await LinkActionPerformer(appEnvironment: appEnvironment).perform(action) + callback?.open(for: result) } return } + if let callback { + // An x-callback-url request Yattee can't run: tell the caller + reportUnhandledDeepLink(url) + callback.open(for: .failure( + code: "unsupportedAction", + message: String(localized: "deepLink.unsupported.title", defaultValue: "Can't open link") + )) + return + } // Leave open sheets alone when the link goes nowhere guard URLRouter().route(url) != nil else { reportUnhandledDeepLink(url) diff --git a/YatteeTests/NavigationTests.swift b/YatteeTests/NavigationTests.swift index 7b36bb6e..a807377e 100644 --- a/YatteeTests/NavigationTests.swift +++ b/YatteeTests/NavigationTests.swift @@ -973,3 +973,65 @@ struct LinkActionParserTests { #expect(!LinkAction.bookmark(url).dismissesPresentations) } } + +// MARK: - XCallback Tests + +@Suite("XCallback Tests") +struct XCallbackTests { + private func url(_ string: String) -> URL { URL(string: string)! } + + @Test("x-callback-url requests parse to the same actions as plain links") + func actionsThroughXCallback() { + let parser = LinkActionParser() + #expect(parser.parse(url("yattee://x-callback-url/player/seek?t=9&x-success=shortcuts%3A%2F%2Fok")) == .player(.seek(to: 9))) + #expect(parser.parse(url("yattee://x-callback-url/player/state")) == .player(.state)) + #expect(parser.parse(url("yattee://x-callback-url/queue?url=https://www.youtube.com/watch?v=abc&list=PL1&x-success=shortcuts%3A%2F%2Fok")) + == .addToQueue(url("https://www.youtube.com/watch?v=abc&list=PL1"))) + #expect(parser.parse(url("yattee://x-callback-url/play?url=https%3A%2F%2Fyoutu.be%2Fabc&t=30")) + == .play(url("https://youtu.be/abc"), startTime: 30)) + #expect(parser.parse(url("yattee://x-callback-url")) == nil) + #expect(parser.parse(url("yattee://x-callback-url/explode")) == nil) + } + + @Test("Callbacks are read from the request, and only for x-callback-url") + func callbacksParse() throws { + let callback = try #require(XCallback(url("yattee://x-callback-url/player/pause?x-success=shortcuts%3A%2F%2Fx-callback-url%2Fok%3Fid%3D1&x-error=shortcuts%3A%2F%2Ferr&x-cancel=shortcuts%3A%2F%2Fcancel&x-source=Shortcuts"))) + #expect(callback.success == url("shortcuts://x-callback-url/ok?id=1")) + #expect(callback.error == url("shortcuts://err")) + #expect(callback.cancel == url("shortcuts://cancel")) + #expect(callback.source == "Shortcuts") + #expect(XCallback(url("yattee://player/pause?x-success=shortcuts%3A%2F%2Fok")) == nil) + } + + @Test("File and script callbacks are ignored") + func unsafeCallbacks() throws { + let callback = try #require(XCallback(url("yattee://x-callback-url/player/pause?x-success=file%3A%2F%2F%2Ftmp%2Fx&x-error=javascript%3Aalert(1)"))) + #expect(callback.success == nil) + #expect(callback.error == nil) + } + + @Test("Success appends values, keeping the callback's own query, escaping & = +") + func successURL() throws { + let callback = try #require(XCallback(url("yattee://x-callback-url/play?x-success=shortcuts%3A%2F%2Fok%3Fid%3D1"))) + let result = try #require(callback.url(for: .success(["title": "Rock & Roll = 1+1", "videoID": "abc"]))) + #expect(result.absoluteString == "shortcuts://ok?id=1&title=Rock%20%26%20Roll%20%3D%201%2B1&videoID=abc") + let items = URLComponents(url: result, resolvingAgainstBaseURL: false)?.queryItems + #expect(items?.first { $0.name == "title" }?.value == "Rock & Roll = 1+1") + } + + @Test("Failure goes to x-error; cancel to x-cancel, else x-error") + func failureAndCancel() throws { + let both = try #require(XCallback(url("yattee://x-callback-url/play?x-error=app%3A%2F%2Ferr&x-cancel=app%3A%2F%2Fcancel"))) + #expect(both.url(for: .failure(code: "noSource", message: "No source"))?.absoluteString == "app://err?errorCode=noSource&errorMessage=No%20source") + #expect(both.url(for: .cancelled) == url("app://cancel")) + #expect(both.url(for: .success()) == nil) + + let errorOnly = try #require(XCallback(url("yattee://x-callback-url/play?x-error=app%3A%2F%2Ferr"))) + #expect(errorOnly.url(for: .cancelled)?.absoluteString == "app://err?errorCode=cancelled&errorMessage=Cancelled") + } + + @Test("The router leaves x-callback-url alone") + func routerIgnoresXCallback() { + #expect(URLRouter().route(url("yattee://x-callback-url/play?url=https://youtu.be/abc")) == nil) + } +}