ShareViewController.swift
13014 bytes
1import Foundation
2import Security
3import UIKit
4import UniformTypeIdentifiers
5import ytrssilShared
6
7final class ShareViewController: UIViewController, UITextFieldDelegate {
8 private let activityIndicator = UIActivityIndicatorView(style: .large)
9 private let messageLabel = UILabel()
10 private let serverURLField = UITextField()
11 private let tokenField = UITextField()
12 private let saveButton = UIButton(type: .system)
13 private var sharedItem: SharedItem?
14
15 override func viewDidLoad() {
16 super.viewDidLoad()
17 configureView()
18 Task { await processShare() }
19 }
20
21 private func configureView() {
22 view.backgroundColor = .systemBackground
23 preferredContentSize = CGSize(width: 320, height: 280)
24
25 messageLabel.font = .preferredFont(forTextStyle: .body)
26 messageLabel.textAlignment = .center
27 messageLabel.numberOfLines = 0
28 messageLabel.adjustsFontForContentSizeCategory = true
29 messageLabel.text = "Preparing link…"
30
31 serverURLField.placeholder = "Server URL"
32 serverURLField.borderStyle = .roundedRect
33 serverURLField.keyboardType = .URL
34 serverURLField.autocapitalizationType = .none
35 serverURLField.autocorrectionType = .no
36 serverURLField.returnKeyType = .next
37 serverURLField.delegate = self
38 serverURLField.inputAccessoryView = keyboardToolbar()
39
40 tokenField.placeholder = "API token"
41 tokenField.borderStyle = .roundedRect
42 tokenField.isSecureTextEntry = true
43 tokenField.autocapitalizationType = .none
44 tokenField.autocorrectionType = .no
45 tokenField.returnKeyType = .done
46 tokenField.delegate = self
47 tokenField.inputAccessoryView = keyboardToolbar()
48
49 saveButton.configuration = .filled()
50 saveButton.configuration?.title = "Save and Continue"
51 saveButton.addAction(UIAction { [weak self] _ in
52 self?.saveConfiguration()
53 }, for: .touchUpInside)
54
55 let stack = UIStackView(arrangedSubviews: [
56 activityIndicator,
57 messageLabel,
58 serverURLField,
59 tokenField,
60 saveButton,
61 ])
62 stack.axis = .vertical
63 stack.alignment = .fill
64 stack.spacing = 16
65 stack.translatesAutoresizingMaskIntoConstraints = false
66 view.addSubview(stack)
67
68 NSLayoutConstraint.activate([
69 stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
70 stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
71 stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
72 ])
73
74 setConfigurationControlsHidden(true)
75 activityIndicator.startAnimating()
76 }
77
78 private func processShare() async {
79 guard let url = await sharedURL() else {
80 showFailure("Share a YouTube video or channel link.")
81 return
82 }
83
84 guard let sharedItem = SharedItem(url: url) else {
85 showFailure("This is not a supported YouTube video or channel link.")
86 return
87 }
88
89 self.sharedItem = sharedItem
90 guard let configuration = ShareConfigurationStore.configuration else {
91 showConfiguration()
92 return
93 }
94
95 await send(sharedItem, using: configuration)
96 }
97
98 private func saveConfiguration() {
99 guard let sharedItem else {
100 showFailure("Share the link again.")
101 return
102 }
103
104 do {
105 let configuration = try ServerConfiguration(
106 serverURL: serverURLField.text ?? "",
107 token: tokenField.text ?? ""
108 )
109 try ShareConfigurationStore.save(configuration)
110 view.endEditing(true)
111 setConfigurationControlsHidden(true)
112 activityIndicator.isHidden = false
113 activityIndicator.startAnimating()
114 Task { await send(sharedItem, using: configuration) }
115 } catch {
116 messageLabel.text = error.localizedDescription
117 }
118 }
119
120 private func send(_ sharedItem: SharedItem, using configuration: ServerConfiguration) async {
121 messageLabel.text = sharedItem.progressMessage
122
123 do {
124 try await sharedItem.send(using: configuration)
125 showSuccess(sharedItem.successMessage)
126 } catch {
127 showFailure(error.localizedDescription)
128 }
129 }
130
131 private func sharedURL() async -> URL? {
132 let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
133 .flatMap { $0.attachments ?? [] }
134
135 for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
136 if let url = await loadURL(from: provider) {
137 return url
138 }
139 }
140
141 return nil
142 }
143
144 private func loadURL(from provider: NSItemProvider) async -> URL? {
145 await withCheckedContinuation { continuation in
146 provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
147 continuation.resume(returning: item as? URL)
148 }
149 }
150 }
151
152 private func showConfiguration() {
153 activityIndicator.stopAnimating()
154 activityIndicator.isHidden = true
155 messageLabel.text = "Set up sharing with the same server URL and API token used in ytrssil."
156 serverURLField.text = ShareConfigurationStore.serverURL
157 setConfigurationControlsHidden(false)
158 }
159
160 private func showSuccess(_ message: String) {
161 activityIndicator.stopAnimating()
162 activityIndicator.isHidden = true
163 messageLabel.text = message
164 extensionContext?.completeRequest(returningItems: nil)
165 }
166
167 private func showFailure(_ message: String) {
168 activityIndicator.stopAnimating()
169 activityIndicator.isHidden = true
170 messageLabel.text = message
171 }
172
173 private func setConfigurationControlsHidden(_ hidden: Bool) {
174 serverURLField.isHidden = hidden
175 tokenField.isHidden = hidden
176 saveButton.isHidden = hidden
177 }
178
179 private func keyboardToolbar() -> UIToolbar {
180 let toolbar = UIToolbar()
181 toolbar.items = [
182 .flexibleSpace(),
183 UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(dismissKeyboard)),
184 ]
185 toolbar.sizeToFit()
186 return toolbar
187 }
188
189 @objc private func dismissKeyboard() {
190 view.endEditing(true)
191 }
192
193 func textFieldShouldReturn(_ textField: UITextField) -> Bool {
194 if textField === serverURLField {
195 tokenField.becomeFirstResponder()
196 } else {
197 view.endEditing(true)
198 }
199 return false
200 }
201}
202
203private enum ShareConfigurationStore {
204 private static let serverURLKey = "serverURL"
205 private static let tokenAccount = "apiToken"
206
207 static var serverURL: String {
208 UserDefaults.standard.string(forKey: serverURLKey) ?? ""
209 }
210
211 static var configuration: ServerConfiguration? {
212 guard let token = try? KeychainStore.load(account: tokenAccount) else {
213 return nil
214 }
215 return try? ServerConfiguration(serverURL: serverURL, token: token)
216 }
217
218 static func save(_ configuration: ServerConfiguration) throws {
219 try KeychainStore.save(configuration.token, account: tokenAccount)
220 UserDefaults.standard.set(configuration.baseURL.absoluteString, forKey: serverURLKey)
221 }
222}
223
224private enum KeychainStore {
225 static func load(account: String) throws -> String? {
226 let query = baseQuery(account: account).merging([
227 kSecReturnData as String: true,
228 kSecMatchLimit as String: kSecMatchLimitOne,
229 ]) { _, new in new }
230
231 var result: CFTypeRef?
232 let status = SecItemCopyMatching(query as CFDictionary, &result)
233
234 if status == errSecItemNotFound {
235 return nil
236 }
237
238 guard status == errSecSuccess,
239 let data = result as? Data,
240 let value = String(data: data, encoding: .utf8) else {
241 throw KeychainError.unavailable(status)
242 }
243
244 return value
245 }
246
247 static func save(_ value: String, account: String) throws {
248 let query = baseQuery(account: account)
249 let attributes = [kSecValueData as String: Data(value.utf8)]
250 let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
251
252 if updateStatus == errSecItemNotFound {
253 let addStatus = SecItemAdd(query.merging(attributes) { _, new in new } as CFDictionary, nil)
254 guard addStatus == errSecSuccess else {
255 throw KeychainError.unavailable(addStatus)
256 }
257 return
258 }
259
260 guard updateStatus == errSecSuccess else {
261 throw KeychainError.unavailable(updateStatus)
262 }
263 }
264
265 private static func baseQuery(account: String) -> [String: Any] {
266 [
267 kSecClass as String: kSecClassGenericPassword,
268 kSecAttrService as String: "com.theedgeofrage.ytrssil.share",
269 kSecAttrAccount as String: account,
270 ]
271 }
272}
273
274private enum KeychainError: LocalizedError {
275 case unavailable(OSStatus)
276
277 var errorDescription: String? {
278 "The API token could not be stored securely."
279 }
280}
281
282private enum SharedItem {
283 case video(URL)
284 case channel(URL)
285
286 init?(url: URL) {
287 guard let host = url.host?.lowercased(), Self.isYouTubeHost(host) else {
288 return nil
289 }
290
291 let pathComponents = url.pathComponents.filter { $0 != "/" }
292 if host == "youtu.be", !pathComponents.isEmpty {
293 self = .video(url)
294 return
295 }
296
297 guard let firstPathComponent = pathComponents.first?.lowercased() else {
298 return nil
299 }
300
301 switch firstPathComponent {
302 case "watch":
303 guard URLComponents(url: url, resolvingAgainstBaseURL: false)?
304 .queryItems?
305 .contains(where: { $0.name == "v" && !($0.value?.isEmpty ?? true) }) == true else {
306 return nil
307 }
308 self = .video(url)
309 case "shorts", "live", "embed":
310 guard pathComponents.count > 1 else {
311 return nil
312 }
313 self = .video(url)
314 case "channel", "c", "user":
315 guard pathComponents.count > 1 else {
316 return nil
317 }
318 self = .channel(url)
319 default:
320 guard firstPathComponent.hasPrefix("@") else {
321 return nil
322 }
323 self = .channel(url)
324 }
325 }
326
327 var progressMessage: String {
328 switch self {
329 case .video:
330 "Adding video…"
331 case .channel:
332 "Subscribing to channel…"
333 }
334 }
335
336 var successMessage: String {
337 switch self {
338 case .video:
339 "Video added."
340 case .channel:
341 "Channel subscribed."
342 }
343 }
344
345 func send(using configuration: ServerConfiguration) async throws {
346 let endpoint: URL
347 let body: Data
348
349 switch self {
350 case let .video(url):
351 endpoint = configuration.endpoint(path: "api/videos")
352 body = try JSONEncoder().encode(["video_id": url.absoluteString])
353 case let .channel(url):
354 endpoint = configuration.endpoint(path: "api/channels/subscribe")
355 body = try JSONEncoder().encode(["channel_id": url.absoluteString])
356 }
357
358 var request = URLRequest(url: endpoint)
359 request.httpMethod = "POST"
360 request.setValue(configuration.token, forHTTPHeaderField: "Authorization")
361 request.setValue("application/json", forHTTPHeaderField: "Accept")
362 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
363 request.httpBody = body
364
365 let (data, response) = try await URLSession.shared.data(for: request)
366 guard let response = response as? HTTPURLResponse else {
367 throw ShareError.invalidResponse
368 }
369 guard (200...299).contains(response.statusCode) else {
370 let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data)
371 throw ShareError.server(errorResponse?.error ?? "The server returned HTTP \(response.statusCode).")
372 }
373 }
374
375 private static func isYouTubeHost(_ host: String) -> Bool {
376 ["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be"].contains(host)
377 }
378}
379
380private struct ErrorResponse: Decodable {
381 let error: String
382}
383
384private enum ShareError: LocalizedError {
385 case invalidResponse
386 case server(String)
387
388 var errorDescription: String? {
389 switch self {
390 case .invalidResponse:
391 "The server returned an invalid response."
392 case let .server(message):
393 message
394 }
395 }
396}