diff --git a/Yattee/Core/Settings/SettingsManager+General.swift b/Yattee/Core/Settings/SettingsManager+General.swift index f5e118a1..8580ea85 100644 --- a/Yattee/Core/Settings/SettingsManager+General.swift +++ b/Yattee/Core/Settings/SettingsManager+General.swift @@ -627,6 +627,20 @@ extension SettingsManager { } } + /// Whether URL scheme actions that change saved state (queue, bookmark, + /// download, subscribe) run without asking first. Off by default: any web + /// page can open a yattee:// link. Local-only, like `defaultLinkAction`. + var linkActionsRunWithoutConfirmation: Bool { + get { + if let cached = _linkActionsRunWithoutConfirmation { return cached } + return localDefaults.bool(forKey: "linkActionsRunWithoutConfirmation") + } + set { + _linkActionsRunWithoutConfirmation = newValue + localDefaults.set(newValue, forKey: "linkActionsRunWithoutConfirmation") + } + } + // MARK: - Video Tap Actions (iOS/macOS only) #if !os(tvOS) diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index ab3200d0..e8f7147d 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -215,6 +215,7 @@ final class SettingsManager { // Link action settings var _defaultLinkAction: DefaultLinkAction? + var _linkActionsRunWithoutConfirmation: Bool? // Video tap actions (iOS/macOS only) #if !os(tvOS) @@ -554,6 +555,7 @@ final class SettingsManager { _handoffEnabled = nil _privacyFrontendSharingEnabled = nil _defaultLinkAction = nil + _linkActionsRunWithoutConfirmation = nil _remoteControlCustomDeviceName = nil _remoteControlHideWhenBackgrounded = nil #if !os(tvOS) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 5994e54b..91251a77 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -2535,6 +2535,17 @@ } } }, + "deepLink.unsupported.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Can't open link" + } + } + } + }, "discovery.empty.description" : { "comment" : "Description when no network shares are found", "localizations" : { @@ -5733,6 +5744,259 @@ } } }, + "linkAction.confirm.bookmark.button" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark" + } + } + } + }, + "linkAction.confirm.bookmark.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Bookmark?" + } + } + } + }, + "linkAction.confirm.download.button" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Download" + } + } + } + }, + "linkAction.confirm.download.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Download Video?" + } + } + } + }, + "linkAction.confirm.playNext.button" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Play Next" + } + } + } + }, + "linkAction.confirm.playNext.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Play Next?" + } + } + } + }, + "linkAction.confirm.queue.button" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add to Queue" + } + } + } + }, + "linkAction.confirm.queue.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add to Queue?" + } + } + } + }, + "linkAction.confirm.subscribe.button" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Subscribe" + } + } + } + }, + "linkAction.confirm.subscribe.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Subscribe?" + } + } + } + }, + "linkAction.confirm.unsubscribe.button" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unsubscribe" + } + } + } + }, + "linkAction.confirm.unsubscribe.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unsubscribe?" + } + } + } + }, + "linkAction.done.bookmark" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarked" + } + } + } + }, + "linkAction.done.playNext" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Playing next" + } + } + } + }, + "linkAction.done.queue" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Added to queue" + } + } + } + }, + "linkAction.done.subscribe" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Subscribed" + } + } + } + }, + "linkAction.done.unsubscribe" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unsubscribed" + } + } + } + }, + "linkAction.error.downloadUnsupported" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads aren't available on this device." + } + } + } + }, + "linkAction.error.notAChannel" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The link isn't a channel." + } + } + } + }, + "linkAction.error.nothingPlaying" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Nothing is playing." + } + } + } + }, + "linkAction.error.queueDisabled" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The queue is turned off in Settings." + } + } + } + }, + "linkAction.error.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't complete the link" + } + } + } + }, + "linkAction.error.unsupportedRate" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Supported rates: %@" + } + } + } + }, "login.error.invalidCredentials" : { "comment" : "Error message for invalid login credentials", "localizations" : { @@ -11768,6 +12032,26 @@ } } }, + "settings.behavior.linkActionsWithoutConfirmation" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Run Link Actions Without Asking" + } + } + } + }, + "settings.behavior.linkActionsWithoutConfirmation.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "yattee:// links can queue, bookmark, download and subscribe. Leave this off to confirm each one, since any website or app can open these links." + } + } + } + }, "settings.behavior.miniPlayer.header" : { "localizations" : { "en" : { diff --git a/Yattee/Services/Navigation/LinkAction.swift b/Yattee/Services/Navigation/LinkAction.swift new file mode 100644 index 00000000..3523cf5e --- /dev/null +++ b/Yattee/Services/Navigation/LinkAction.swift @@ -0,0 +1,176 @@ +// +// LinkAction.swift +// Yattee +// +// URL scheme actions: links that do something rather than open a screen. +// +// yattee://play?url={link}&t={seconds} +// yattee://play-next?url={link} +// yattee://queue?url={link} +// yattee://download?url={link} +// yattee://bookmark?url={link} +// yattee://subscribe?url={channel link} +// yattee://unsubscribe?url={channel link} +// yattee://player/play | pause | toggle | next | previous | close +// yattee://player/seek?t={seconds} | seek?by={±seconds} +// yattee://player/rate?value={rate} +// +// `url` may be percent-encoded or raw; a raw link keeps its own query +// (`&list=…`) because only the action's own parameters end it. +// + +import Foundation + +/// An action requested through the URL scheme. +enum LinkAction: Equatable { + case play(URL, startTime: TimeInterval?) + case playNext(URL) + case addToQueue(URL) + case download(URL) + case bookmark(URL) + case subscribe(URL) + case unsubscribe(URL) + case player(PlayerCommand) + + enum PlayerCommand: Equatable { + case play + case pause + case toggle + case next + case previous + case close + case seek(to: TimeInterval) + case seekBy(TimeInterval) + case rate(Double) + } + + /// Whether the main window's sheets should go away first. Only actions + /// that move the user somewhere do; queueing a video or pausing the + /// player leaves whatever is on screen alone. + var dismissesPresentations: Bool { + if case .play = self { return true } + return false + } +} + +/// What an action did, for callers that report back (x-callback-url). +enum LinkActionResult: Equatable { + case success([String: String] = [:]) + case failure(code: String, message: String) + case cancelled +} + +// MARK: - Parsing + +struct LinkActionParser { + /// Parameters that belong to the action URL itself. Everything else + /// after `url=` is part of the wrapped link. + static let actionParameters: Set = [ + "url", "t", "time", "start", "by", "value", + "x-success", "x-error", "x-cancel", "x-source" + ] + + /// 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" + ] + + func parse(_ url: URL) -> LinkAction? { + guard url.scheme?.lowercased() == "yattee", + let host = url.host?.lowercased(), Self.hosts.contains(host) else { + return nil + } + let query = Self.queryParameters(of: url) + + if host == "player" { + return parsePlayerCommand(url: url, query: query) + } + + guard let target = query["url"].flatMap(Self.targetURL) else { return nil } + + switch host { + case "play": + let outer = ["t", "time", "start"].lazy.compactMap { query[$0] }.first + .flatMap(URLRouter.parseTimestampValue) + return .play(target, startTime: outer ?? URLRouter().parseTimestamp(target)) + case "play-next": + return .playNext(target) + case "queue": + return .addToQueue(target) + case "download": + return .download(target) + case "bookmark": + return .bookmark(target) + case "subscribe": + return .subscribe(target) + case "unsubscribe": + return .unsubscribe(target) + default: + return nil + } + } + + private func parsePlayerCommand(url: URL, query: [String: String]) -> LinkAction? { + let verb = url.pathComponents.dropFirst().first?.lowercased() ?? "" + switch verb { + case "play": return .player(.play) + case "pause": return .player(.pause) + case "toggle": return .player(.toggle) + case "next": return .player(.next) + case "previous": return .player(.previous) + case "close": return .player(.close) + case "seek": + if let raw = query["by"], let delta = Double(raw), delta.isFinite { + return .player(.seekBy(delta)) + } + if let raw = query["t"] ?? query["time"], let time = URLRouter.parseTimestampValue(raw) { + return .player(.seek(to: time)) + } + return nil + case "rate": + guard let raw = query["value"], let rate = Double(raw), rate.isFinite, rate > 0 else { return nil } + return .player(.rate(rate)) + default: + return nil + } + } + + /// The wrapped link: http(s) or a yattee:// screen link, never a local file. + private static func targetURL(_ raw: String) -> URL? { + // A raw link keeps its percent-escapes; an encoded one is decoded once. + let string = raw.contains("://") ? raw : (raw.removingPercentEncoding ?? raw) + guard let url = URL(string: string), + let scheme = url.scheme?.lowercased(), + ["http", "https", "yattee"].contains(scheme) else { + return nil + } + return url + } + + /// Splits the query on `&`, but keeps an unencoded `url=` value whole: + /// it runs until the next parameter the action itself defines. + static func queryParameters(of url: URL) -> [String: String] { + let absolute = url.absoluteString + guard let questionMark = absolute.firstIndex(of: "?") else { return [:] } + let pieces = absolute[absolute.index(after: questionMark)...].split(separator: "&", omittingEmptySubsequences: false) + + var result: [String: String] = [:] + var currentKey: String? + for piece in pieces { + let parts = piece.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + let key = (parts.first.map(String.init) ?? "").removingPercentEncoding ?? "" + let value = parts.count > 1 ? String(parts[1]) : "" + + if currentKey == "url", !actionParameters.contains(key) { + // Still inside the wrapped link + result["url", default: ""] += "&" + piece + continue + } + currentKey = key + guard !key.isEmpty else { continue } + // The wrapped link is decoded later, once the whole of it is known + result[key] = key == "url" ? value : (value.replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? value) + } + return result + } +} diff --git a/Yattee/Services/Navigation/LinkActionPerformer.swift b/Yattee/Services/Navigation/LinkActionPerformer.swift new file mode 100644 index 00000000..dd32805c --- /dev/null +++ b/Yattee/Services/Navigation/LinkActionPerformer.swift @@ -0,0 +1,336 @@ +// +// LinkActionPerformer.swift +// Yattee +// +// Runs URL scheme actions (`LinkAction`) and reports what happened, so the +// same code serves plain links now and x-callback-url callers later. +// + +import Foundation + +@MainActor +struct LinkActionPerformer { + let appEnvironment: AppEnvironment + + func perform(_ action: LinkAction) async -> LinkActionResult { + LoggingService.shared.info("Link action: \(action)", category: .general) + let result: LinkActionResult + switch action { + case .play(let url, let startTime): + result = await play(url, startTime: startTime) + case .playNext(let url): + result = await queue(url, next: true) + case .addToQueue(let url): + result = await queue(url, next: false) + case .download(let url): + result = await download(url) + case .bookmark(let url): + result = await bookmark(url) + case .subscribe(let url): + result = await setSubscribed(true, channelURL: url) + case .unsubscribe(let url): + result = await setSubscribed(false, channelURL: url) + case .player(let command): + result = await perform(command) + } + report(result) + return result + } + + // MARK: - Video Actions + + private func play(_ url: URL, startTime: TimeInterval?) async -> LinkActionResult { + let resolved: Video + switch await resolve(url) { + case .success(let video): resolved = video.0 + case .failure(let failure): return failure.result + } + appEnvironment.playerService.openVideo(resolved, startTime: startTime, forceStartTime: startTime != nil) + return .success(values(for: resolved)) + } + + private func queue(_ url: URL, next: Bool) async -> LinkActionResult { + let queueManager = appEnvironment.queueManager + guard queueManager.isQueueEnabled else { + return .failure(code: "queueDisabled", message: String(localized: "linkAction.error.queueDisabled", defaultValue: "The queue is turned off in Settings.")) + } + let video: Video + switch await resolve(url) { + case .success(let resolved): video = resolved.0 + case .failure(let failure): return failure.result + } + let confirmed = await confirm( + title: next + ? String(localized: "linkAction.confirm.playNext.title", defaultValue: "Play Next?") + : String(localized: "linkAction.confirm.queue.title", defaultValue: "Add to Queue?"), + subject: video.title, + detail: video.author.name, + confirmTitle: next + ? String(localized: "linkAction.confirm.playNext.button", defaultValue: "Play Next") + : String(localized: "linkAction.confirm.queue.button", defaultValue: "Add to Queue"), + systemImage: next ? "text.line.first.and.arrowtriangle.forward" : "text.append" + ) + guard confirmed else { return .cancelled } + + if next { + queueManager.playNext(video, queueSource: .manual) + } else { + queueManager.addToQueue(video, queueSource: .manual) + } + appEnvironment.toastManager.showSuccess( + next + ? String(localized: "linkAction.done.playNext", defaultValue: "Playing next") + : String(localized: "linkAction.done.queue", defaultValue: "Added to queue"), + subtitle: video.title + ) + return .success(values(for: video)) + } + + private func download(_ url: URL) async -> LinkActionResult { + #if os(tvOS) + return .failure(code: "unsupported", message: String(localized: "linkAction.error.downloadUnsupported", defaultValue: "Downloads aren't available on this device.")) + #else + let video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard] + switch await resolve(url) { + case .success(let resolved): (video, streams, captions, storyboards) = resolved + case .failure(let failure): return failure.result + } + + let downloadSettings = appEnvironment.downloadSettings + // The quality picker is already a confirmation: nothing is saved until + // the user picks + guard downloadSettings.preferredDownloadQuality != .ask else { + appEnvironment.navigationCoordinator.presenter.present(.downloadOptions(video, streams: streams, captions: captions)) + return .success(values(for: video).merging(["status": "picking"]) { $1 }) + } + + let confirmed = await confirm( + title: String(localized: "linkAction.confirm.download.title", defaultValue: "Download Video?"), + subject: video.title, + detail: video.author.name, + confirmTitle: String(localized: "linkAction.confirm.download.button", defaultValue: "Download"), + systemImage: "arrow.down.circle" + ) + guard confirmed else { return .cancelled } + + do { + try await LinkVideoResolver(appEnvironment: appEnvironment).enqueueDownload( + video: video, + streams: streams, + captions: captions, + storyboards: storyboards, + quality: downloadSettings.preferredDownloadQuality, + includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload + ) + } catch { + return .failure(code: "downloadFailed", message: error.localizedDescription) + } + return .success(values(for: video).merging(["status": "queued"]) { $1 }) + #endif + } + + private func bookmark(_ url: URL) async -> LinkActionResult { + let video: Video + switch await resolve(url) { + case .success(let resolved): video = resolved.0 + case .failure(let failure): return failure.result + } + let dataManager = appEnvironment.dataManager + guard !dataManager.isBookmarked(videoID: video.id.videoID) else { + return .success(values(for: video)) + } + let confirmed = await confirm( + title: String(localized: "linkAction.confirm.bookmark.title", defaultValue: "Add Bookmark?"), + subject: video.title, + detail: video.author.name, + confirmTitle: String(localized: "linkAction.confirm.bookmark.button", defaultValue: "Bookmark"), + systemImage: "bookmark" + ) + guard confirmed else { return .cancelled } + + dataManager.addBookmark(for: video) + appEnvironment.toastManager.showSuccess( + String(localized: "linkAction.done.bookmark", defaultValue: "Bookmarked"), + subtitle: video.title + ) + return .success(values(for: video)) + } + + // MARK: - Subscriptions + + private func setSubscribed(_ subscribe: Bool, channelURL url: URL) async -> LinkActionResult { + guard case .channel(let channelID, let source) = URLRouter().route(url) else { + return .failure(code: "notAChannel", message: String(localized: "linkAction.error.notAChannel", defaultValue: "The link isn't a channel.")) + } + guard let instance = appEnvironment.instancesManager.instance(for: source) else { + return LinkActionFailure.noSource.result + } + + let channel: Channel + do { + // Resolves @handles and custom URLs to the channel's real ID + channel = try await appEnvironment.contentService.channel(id: channelID, instance: instance) + } catch { + return .failure(code: "loadFailed", message: error.localizedDescription) + } + + let service = appEnvironment.subscriptionService + let values = ["channelID": channel.id.channelID, "channel": channel.name] + if await service.isSubscribed(to: channel.id.channelID) == subscribe { + return .success(values) + } + + let confirmed = await confirm( + title: subscribe + ? String(localized: "linkAction.confirm.subscribe.title", defaultValue: "Subscribe?") + : String(localized: "linkAction.confirm.unsubscribe.title", defaultValue: "Unsubscribe?"), + subject: channel.name, + detail: nil, + confirmTitle: subscribe + ? String(localized: "linkAction.confirm.subscribe.button", defaultValue: "Subscribe") + : String(localized: "linkAction.confirm.unsubscribe.button", defaultValue: "Unsubscribe"), + systemImage: subscribe ? "person.badge.plus" : "person.badge.minus" + ) + guard confirmed else { return .cancelled } + + do { + if subscribe { + try await service.subscribe(to: channel) + if appEnvironment.settingsManager.defaultNotificationsForNewChannels { + appEnvironment.dataManager.setNotificationsEnabled(true, for: channel.id.channelID) + } + } else { + try await service.unsubscribe(from: channel.id.channelID) + } + } catch { + return .failure(code: subscribe ? "subscribeFailed" : "unsubscribeFailed", message: error.localizedDescription) + } + appEnvironment.toastManager.showSuccess( + subscribe + ? String(localized: "linkAction.done.subscribe", defaultValue: "Subscribed") + : String(localized: "linkAction.done.unsubscribe", defaultValue: "Unsubscribed"), + subtitle: channel.name + ) + return .success(values) + } + + // MARK: - Player Commands + + private func perform(_ command: LinkAction.PlayerCommand) async -> LinkActionResult { + let player = appEnvironment.playerService + guard player.state.currentVideo != nil else { + return .failure(code: "nothingPlaying", message: String(localized: "linkAction.error.nothingPlaying", defaultValue: "Nothing is playing.")) + } + + switch command { + case .play: + player.resume() + case .pause: + player.pause() + case .toggle: + player.togglePlayPause() + case .next: + await player.playNext() + case .previous: + await player.playPrevious() + case .close: + appEnvironment.navigationCoordinator.isPlayerExpanded = false + player.stop() + return .success() + case .seek(let time): + await player.seek(to: clampedTime(time)) + case .seekBy(let delta): + await player.seek(to: clampedTime(player.state.currentTime + delta)) + case .rate(let value): + guard let rate = PlaybackRate(rawValue: value) else { + let supported = PlaybackRate.allCases.map { String($0.rawValue) }.joined(separator: ", ") + return .failure(code: "unsupportedRate", message: String(localized: "linkAction.error.unsupportedRate", defaultValue: "Supported rates: \(supported)")) + } + player.currentBackend?.rate = Float(rate.rawValue) + player.state.rate = rate + } + return .success(playerValues()) + } + + private func clampedTime(_ time: TimeInterval) -> TimeInterval { + let duration = appEnvironment.playerService.state.duration + let upper = duration > 0 ? duration : time + return min(max(time, 0), upper) + } + + // MARK: - Helpers + + private enum LinkActionFailure: Error { + case noSource + case notAVideo(String) + case loadFailed(String) + + var result: LinkActionResult { + switch self { + case .noSource: + return .failure(code: "noSource", message: String(localized: "deepLink.noInstance.subtitle", defaultValue: "No source is configured for this video.")) + case .notAVideo(let message): + return .failure(code: "notAVideo", message: message) + case .loadFailed(let message): + return .failure(code: "loadFailed", message: message) + } + } + } + + /// Loads the video a link points at, showing progress while it does. + private func resolve(_ url: URL) async -> Result<(Video, [Stream], [Caption], [Storyboard]), LinkActionFailure> { + let toastID = appEnvironment.toastManager.show( + scopes: [.main, .player], + category: .loading, + title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"), + autoDismissDelay: 30.0 + ) + defer { appEnvironment.toastManager.dismiss(id: toastID) } + + do { + return .success(try await LinkVideoResolver(appEnvironment: appEnvironment).resolve(url)) + } catch let error as LinkResolveError { + switch error { + case .noInstanceAvailable: return .failure(.noSource) + case .noYatteeServer, .notAVideo: return .failure(.notAVideo(error.localizedDescription)) + } + } catch { + return .failure(.loadFailed(error.localizedDescription)) + } + } + + /// Asks first unless the user lets automation links change things on their own. + private func confirm(title: String, subject: String?, detail: String?, confirmTitle: String, systemImage: String) async -> Bool { + guard !appEnvironment.settingsManager.linkActionsRunWithoutConfirmation else { return true } + let confirmation = LinkActionConfirmation( + title: title, + subject: subject, + detail: detail, + confirmTitle: confirmTitle, + systemImage: systemImage + ) + return await confirmation.ask(using: appEnvironment.navigationCoordinator.presenter) + } + + private func values(for video: Video) -> [String: String] { + ["videoID": video.id.videoID, "title": video.title, "channel": video.author.name] + } + + private func playerValues() -> [String: String] { + let state = appEnvironment.playerService.state + var values = state.currentVideo.map(values(for:)) ?? [:] + values["position"] = String(Int(state.currentTime)) + values["rate"] = String(state.rate.rawValue) + return values + } + + /// Failures show a toast; callers that report back get the same message. + private func report(_ result: LinkActionResult) { + guard case .failure(let code, let message) = result else { return } + LoggingService.shared.warning("Link action failed: \(code) — \(message)", category: .general) + appEnvironment.toastManager.showError( + String(localized: "linkAction.error.title", defaultValue: "Couldn't complete the link"), + subtitle: message + ) + } +} diff --git a/Yattee/Services/Navigation/PresentationRouter.swift b/Yattee/Services/Navigation/PresentationRouter.swift index 680d4cc6..4e0a4a90 100644 --- a/Yattee/Services/Navigation/PresentationRouter.swift +++ b/Yattee/Services/Navigation/PresentationRouter.swift @@ -28,6 +28,8 @@ enum AppSheet: Identifiable { case openLink(URL?) /// Continue / start over for a partially watched video. case resumeAction(Video, resumeTime: TimeInterval, onContinue: () -> Void, onStartOver: () -> Void) + /// Asks before a URL scheme action changes saved state. + case linkActionConfirmation(LinkActionConfirmation) #if !os(tvOS) /// Download quality picker. Streams and captions already loaded by the /// caller (the player) skip the sheet's own fetch. @@ -48,6 +50,8 @@ enum AppSheet: Identifiable { return "queueAction-\(video.id.id)" case .resumeAction(let video, _, _, _): return "resumeAction-\(video.id.id)" + case .linkActionConfirmation(let confirmation): + return "linkActionConfirmation-\(confirmation.id)" #if !os(tvOS) case .downloadOptions(let video, _, _, _): return "downloadOptions-\(video.id.id)" @@ -150,6 +154,8 @@ private struct AppSheetHost: ViewModifier { QueueActionSheet(video: video, queueSource: queueSource) case .resumeAction(let video, let resumeTime, let onContinue, let onStartOver): ResumeActionSheet(video: video, resumeTime: resumeTime, onContinue: onContinue, onStartOver: onStartOver) + case .linkActionConfirmation(let confirmation): + LinkActionConfirmationSheet(confirmation: confirmation) #if !os(tvOS) case .downloadOptions(let video, let streams, let captions, let dislikeCount): DownloadQualitySheet(video: video, streams: streams, captions: captions, dislikeCount: dislikeCount) diff --git a/Yattee/Services/Navigation/URLRouter.swift b/Yattee/Services/Navigation/URLRouter.swift index 2e7c8bee..1179dc06 100644 --- a/Yattee/Services/Navigation/URLRouter.swift +++ b/Yattee/Services/Navigation/URLRouter.swift @@ -301,6 +301,8 @@ struct URLRouter: Sendable { return nil default: + // Action hosts (yattee://play, yattee://player/…) are LinkActionParser's + if LinkActionParser.hosts.contains(host) { return nil } return parseLegacyScheme(url) } } diff --git a/Yattee/Views/Components/LinkActionConfirmationSheet.swift b/Yattee/Views/Components/LinkActionConfirmationSheet.swift new file mode 100644 index 00000000..ff94a4ad --- /dev/null +++ b/Yattee/Views/Components/LinkActionConfirmationSheet.swift @@ -0,0 +1,141 @@ +// +// LinkActionConfirmationSheet.swift +// Yattee +// +// Asks before a URL scheme action changes saved state. Any web page can open +// a yattee:// link, so queueing, bookmarking, downloading and subscribing +// wait for the user unless Settings allows them to run on their own. +// + +import SwiftUI + +/// A pending question for `LinkActionConfirmationSheet`. Answered exactly once: +/// by a button, or as "cancel" when the sheet goes away any other way. +@MainActor +final class LinkActionConfirmation { + let id = UUID() + let title: String + let subject: String? + let detail: String? + let confirmTitle: String + let systemImage: String + + private var continuation: CheckedContinuation? + + init(title: String, subject: String?, detail: String?, confirmTitle: String, systemImage: String) { + self.title = title + self.subject = subject + self.detail = detail + self.confirmTitle = confirmTitle + self.systemImage = systemImage + } + + /// Presents the sheet through the top-most host and waits for the answer. + func ask(using presenter: PresentationRouter) async -> Bool { + await withCheckedContinuation { continuation in + self.continuation = continuation + presenter.present(.linkActionConfirmation(self)) + } + } + + func answer(_ allowed: Bool) { + continuation?.resume(returning: allowed) + continuation = nil + } +} + +struct LinkActionConfirmationSheet: View { + @Environment(\.dismiss) private var dismiss + + let confirmation: LinkActionConfirmation + + var body: some View { + content + .onDisappear { + // Swiped away or replaced by another sheet: treat as cancel + confirmation.answer(false) + } + } + + #if os(tvOS) + private var content: some View { + NavigationStack { + List { + Section { + header + .padding(.vertical, 8) + } + Section { + Button { respond(true) } label: { + Label(confirmation.confirmTitle, systemImage: confirmation.systemImage) + } + Button(role: .cancel) { respond(false) } label: { + Label(String(localized: "common.cancel"), systemImage: "xmark") + } + } + } + .scrollClipDisabled() + .padding(.horizontal, 40) + .padding(.vertical, 24) + } + } + #else + private var content: some View { + VStack(spacing: 0) { + header + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) + + Divider() + + HStack(spacing: 12) { + Button(role: .cancel) { respond(false) } label: { + Text(String(localized: "common.cancel")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button { respond(true) } label: { + Label(confirmation.confirmTitle, systemImage: confirmation.systemImage) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .controlSize(.large) + .padding(20) + + Spacer(minLength: 0) + } + #if os(macOS) + .frame(width: 380) + #endif + .presentationDetents([.height(210)]) + .presentationDragIndicator(.visible) + } + #endif + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text(confirmation.title) + .font(.headline) + if let subject = confirmation.subject { + Text(subject) + .font(.subheadline) + .lineLimit(2) + } + if let detail = confirmation.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func respond(_ allowed: Bool) { + confirmation.answer(allowed) + dismiss() + } +} diff --git a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift index 7f897531..9c027173 100644 --- a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift +++ b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift @@ -150,6 +150,13 @@ private struct LinkActionSection: View { } } } + + SettingsFormSection(footer: "settings.behavior.linkActionsWithoutConfirmation.footer") { + Toggle( + String(localized: "settings.behavior.linkActionsWithoutConfirmation"), + isOn: $settings.linkActionsRunWithoutConfirmation + ) + } } } diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 66661baa..48eab7fb 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -73,6 +73,15 @@ struct YatteeApp: App { .handlesExternalEvents(preferring: ["*"], allowing: ["*"]) #endif .onOpenURL { url in + if let action = LinkActionParser().parse(url) { + if action.dismissesPresentations { + appEnvironment.navigationCoordinator.dismissPresentations() + } + Task { + _ = await LinkActionPerformer(appEnvironment: appEnvironment).perform(action) + } + 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 00e0abaa..7b36bb6e 100644 --- a/YatteeTests/NavigationTests.swift +++ b/YatteeTests/NavigationTests.swift @@ -881,3 +881,95 @@ struct ConnectivityMonitorTests { #expect(monitor.isOnline == true) } } + +// MARK: - LinkActionParser Tests + +@Suite("LinkActionParser Tests") +struct LinkActionParserTests { + let parser = LinkActionParser() + + private func parse(_ string: String) -> LinkAction? { + parser.parse(URL(string: string)!) + } + + @Test("Encoded and raw url= give the same inner link with its own query") + func encodedAndRawTargets() { + let inner = URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123")! + #expect(parse("yattee://queue?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ%26list%3DPL123") == .addToQueue(inner)) + #expect(parse("yattee://queue?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123") == .addToQueue(inner)) + } + + @Test("Play takes the action's t, falling back to the link's own") + func playStartTime() { + #expect(parse("yattee://play?url=https://youtu.be/dQw4w9WgXcQ&t=1m30s") + == .play(URL(string: "https://youtu.be/dQw4w9WgXcQ")!, startTime: 90)) + #expect(parse("yattee://play?url=https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ%3Ft%3D42") + == .play(URL(string: "https://youtu.be/dQw4w9WgXcQ?t=42")!, startTime: 42)) + #expect(parse("yattee://play?url=https://youtu.be/dQw4w9WgXcQ") + == .play(URL(string: "https://youtu.be/dQw4w9WgXcQ")!, startTime: nil)) + } + + @Test("x-callback parameters end the raw url") + func callbackParametersEndURL() { + let action = parse("yattee://bookmark?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&x-success=shortcuts://done") + #expect(action == .bookmark(URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ")!)) + } + + @Test("Every target verb parses", arguments: [ + ("play-next", LinkAction.playNext(URL(string: "https://youtu.be/abc")!)), + ("download", .download(URL(string: "https://youtu.be/abc")!)), + ("subscribe", .subscribe(URL(string: "https://youtu.be/abc")!)), + ("unsubscribe", .unsubscribe(URL(string: "https://youtu.be/abc")!)), + ]) + func targetVerbs(_ verb: String, _ expected: LinkAction) { + #expect(parse("yattee://\(verb)?url=https://youtu.be/abc") == expected) + } + + @Test("Missing, local or non-web targets are rejected") + func badTargets() { + #expect(parse("yattee://play") == nil) + #expect(parse("yattee://play?url=") == nil) + #expect(parse("yattee://play?url=file:///etc/hosts") == nil) + #expect(parse("yattee://queue?url=javascript:alert(1)") == nil) + } + + @Test("Player commands") + func playerCommands() { + #expect(parse("yattee://player/pause") == .player(.pause)) + #expect(parse("yattee://player/play") == .player(.play)) + #expect(parse("yattee://player/toggle") == .player(.toggle)) + #expect(parse("yattee://player/next") == .player(.next)) + #expect(parse("yattee://player/previous") == .player(.previous)) + #expect(parse("yattee://player/close") == .player(.close)) + #expect(parse("yattee://player/seek?t=90") == .player(.seek(to: 90))) + #expect(parse("yattee://player/seek?t=1m") == .player(.seek(to: 60))) + #expect(parse("yattee://player/seek?by=-10") == .player(.seekBy(-10))) + #expect(parse("yattee://player/rate?value=1.5") == .player(.rate(1.5))) + } + + @Test("Invalid player commands are rejected") + func badPlayerCommands() { + #expect(parse("yattee://player") == nil) + #expect(parse("yattee://player/explode") == nil) + #expect(parse("yattee://player/seek") == nil) + #expect(parse("yattee://player/seek?by=nan") == nil) + #expect(parse("yattee://player/rate?value=0") == nil) + #expect(parse("yattee://player/rate?value=inf") == nil) + } + + @Test("Screen links are not actions, and the router leaves action hosts alone") + func separationFromRouter() { + #expect(parse("yattee://playlist/PL123") == nil) + #expect(parse("yattee://video/abc") == nil) + #expect(URLRouter().route(URL(string: "yattee://play?url=https://youtu.be/abc")!) == nil) + #expect(URLRouter().route(URL(string: "yattee://player/pause")!) == nil) + } + + @Test("Only play dismisses sheets") + func actionTraits() { + let url = URL(string: "https://youtu.be/abc")! + #expect(LinkAction.play(url, startTime: nil).dismissesPresentations) + #expect(!LinkAction.player(.pause).dismissesPresentations) + #expect(!LinkAction.bookmark(url).dismissesPresentations) + } +}