mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
106
Yattee/Services/Navigation/XCallback.swift
Normal file
106
Yattee/Services/Navigation/XCallback.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user