Parent directory

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}