Support x-callback-url for URL scheme actions

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.
This commit is contained in:
Arkadiusz Fal
2026-10-05 23:59:05 +02:00
parent 5ae07574cb
commit c579ee63a2
5 changed files with 202 additions and 3 deletions

View File

@@ -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<String> = [
"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))

View File

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

View File

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

View File

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

View File

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