mirror of
https://github.com/GLEGram/GLEGram-iOS.git
synced 2026-04-23 11:26:54 +02:00
4647310322
Based on Swiftgram 12.5 (Telegram iOS 12.5). All GLEGram features ported and organized in GLEGram/ folder. Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass, Font Replacement, Fake Profile, Chat Export, Plugin System, and more. See CHANGELOG_12.5.md for full details.
382 lines
22 KiB
Swift
Executable File
382 lines
22 KiB
Swift
Executable File
// MARK: Swiftgram – Plugin list (like Active sites: icon, name, author, description, switch; Settings below)
|
||
import Foundation
|
||
import UIKit
|
||
import ObjectiveC
|
||
import UniformTypeIdentifiers
|
||
import Display
|
||
import SwiftSignalKit
|
||
import TelegramPresentationData
|
||
import ItemListUI
|
||
import PresentationDataUtils
|
||
import AccountContext
|
||
import SGSimpleSettings
|
||
import AppBundle
|
||
|
||
private var documentPickerDelegateKey: UInt8 = 0
|
||
|
||
private func loadInstalledPlugins() -> [PluginInfo] {
|
||
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
|
||
let list = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
|
||
return []
|
||
}
|
||
return list
|
||
}
|
||
|
||
private func saveInstalledPlugins(_ plugins: [PluginInfo]) {
|
||
if let data = try? JSONEncoder().encode(plugins),
|
||
let json = String(data: data, encoding: .utf8) {
|
||
SGSimpleSettings.shared.installedPluginsJson = json
|
||
SGSimpleSettings.shared.synchronizeShared()
|
||
}
|
||
}
|
||
|
||
// Custom entries: .plugin plugins + .deb/.dylib tweaks.
|
||
private enum PluginListEntry: ItemListNodeEntry {
|
||
case addHeader(id: Int, text: String)
|
||
case addAction(id: Int, text: String)
|
||
case addNotice(id: Int, text: String)
|
||
case addDebAction(id: Int, text: String)
|
||
case addDebNotice(id: Int, text: String)
|
||
case listHeader(id: Int, text: String)
|
||
case pluginRow(id: Int, plugin: PluginInfo)
|
||
case pluginSettings(id: Int, pluginId: String, text: String)
|
||
case pluginDelete(id: Int, pluginId: String, text: String)
|
||
case emptyNotice(id: Int, text: String)
|
||
case tweaksChannelLink(id: Int, text: String, url: String)
|
||
case tweaksHeader(id: Int, text: String)
|
||
case installLiveContainer(id: Int, text: String)
|
||
case tweaksDylibHeader(id: Int, text: String)
|
||
case tweakRow(id: Int, filename: String)
|
||
case tweakDelete(id: Int, filename: String, text: String)
|
||
case tweaksEmptyNotice(id: Int, text: String)
|
||
|
||
var id: Int { stableId }
|
||
|
||
var section: ItemListSectionId {
|
||
switch self {
|
||
case .addHeader, .addAction, .addNotice, .addDebAction, .addDebNotice: return 0
|
||
case .listHeader, .pluginRow, .pluginSettings, .pluginDelete, .emptyNotice: return 1
|
||
case .tweaksChannelLink, .tweaksHeader, .installLiveContainer, .tweaksDylibHeader, .tweakRow, .tweakDelete, .tweaksEmptyNotice: return 2
|
||
}
|
||
}
|
||
|
||
var stableId: Int {
|
||
switch self {
|
||
case .addHeader(let id, _), .addAction(let id, _), .addNotice(let id, _), .addDebAction(let id, _), .addDebNotice(let id, _),
|
||
.listHeader(let id, _), .pluginRow(let id, _), .pluginSettings(let id, _, _), .pluginDelete(let id, _, _), .emptyNotice(let id, _),
|
||
.tweaksChannelLink(let id, _, _), .tweaksHeader(let id, _), .installLiveContainer(let id, _), .tweaksDylibHeader(let id, _), .tweakRow(let id, _), .tweakDelete(let id, _, _), .tweaksEmptyNotice(let id, _): return id
|
||
}
|
||
}
|
||
|
||
static func < (lhs: PluginListEntry, rhs: PluginListEntry) -> Bool { lhs.stableId < rhs.stableId }
|
||
|
||
static func == (lhs: PluginListEntry, rhs: PluginListEntry) -> Bool {
|
||
switch (lhs, rhs) {
|
||
case let (.addHeader(a, t1), .addHeader(b, t2)), let (.addNotice(a, t1), .addNotice(b, t2)), let (.emptyNotice(a, t1), .emptyNotice(b, t2)): return a == b && t1 == t2
|
||
case let (.addAction(a, t1), .addAction(b, t2)), let (.addDebAction(a, t1), .addDebAction(b, t2)), let (.addDebNotice(a, t1), .addDebNotice(b, t2)): return a == b && t1 == t2
|
||
case let (.listHeader(a, t1), .listHeader(b, t2)), let (.tweaksHeader(a, t1), .tweaksHeader(b, t2)), let (.tweaksDylibHeader(a, t1), .tweaksDylibHeader(b, t2)): return a == b && t1 == t2
|
||
case let (.tweaksChannelLink(a, t1, u1), .tweaksChannelLink(b, t2, u2)): return a == b && t1 == t2 && u1 == u2
|
||
case let (.installLiveContainer(a, t1), .installLiveContainer(b, t2)): return a == b && t1 == t2
|
||
case let (.pluginRow(a, p1), .pluginRow(b, p2)): return a == b && p1.metadata.id == p2.metadata.id && p1.enabled == p2.enabled
|
||
case let (.pluginSettings(a, id1, t1), .pluginSettings(b, id2, t2)), let (.pluginDelete(a, id1, t1), .pluginDelete(b, id2, t2)): return a == b && id1 == id2 && t1 == t2
|
||
case let (.tweakRow(a, f1), .tweakRow(b, f2)): return a == b && f1 == f2
|
||
case let (.tweakDelete(a, f1, t1), .tweakDelete(b, f2, t2)): return a == b && f1 == f2 && t1 == t2
|
||
case let (.tweaksEmptyNotice(a, t1), .tweaksEmptyNotice(b, t2)): return a == b && t1 == t2
|
||
default: return false
|
||
}
|
||
}
|
||
|
||
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
|
||
let args = arguments as! PluginListArguments
|
||
switch self {
|
||
case .addHeader(_, let text):
|
||
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
|
||
case .addAction(_, let text):
|
||
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.addPlugin() })
|
||
case .addNotice(_, let text):
|
||
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
|
||
case .addDebAction(_, let text):
|
||
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.addDeb() })
|
||
case .addDebNotice(_, let text):
|
||
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
|
||
case .listHeader(_, let text):
|
||
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
|
||
case .pluginRow(_, let plugin):
|
||
let icon = args.iconResolver(plugin.metadata.iconRef)
|
||
return ItemListPluginRowItem(presentationData: presentationData, plugin: plugin, icon: icon, sectionId: self.section, toggle: { value in args.toggle(plugin.metadata.id, value) }, action: nil)
|
||
case .pluginSettings(_, let pluginId, let text):
|
||
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: "", sectionId: self.section, style: .blocks, action: { args.openSettings(pluginId) })
|
||
case .pluginDelete(_, let pluginId, let text):
|
||
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.deletePlugin(pluginId) })
|
||
case .emptyNotice(_, let text):
|
||
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
|
||
case .tweaksChannelLink(_, let text, let url):
|
||
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: "", sectionId: self.section, style: .blocks, action: { args.openTweaksChannel(url) })
|
||
case .tweaksHeader(_, let text):
|
||
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
|
||
case .installLiveContainer(_, let text):
|
||
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.openLiveContainer() })
|
||
case .tweaksDylibHeader(_, let text):
|
||
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
|
||
case .tweakRow(_, let filename):
|
||
return ItemListDisclosureItem(presentationData: presentationData, title: filename, label: "", sectionId: self.section, style: .blocks, action: nil)
|
||
case .tweakDelete(_, let filename, let text):
|
||
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.removeTweak(filename) })
|
||
case .tweaksEmptyNotice(_, let text):
|
||
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class PluginListArguments {
|
||
let toggle: (String, Bool) -> Void
|
||
let openSettings: (String) -> Void
|
||
let deletePlugin: (String) -> Void
|
||
let addPlugin: () -> Void
|
||
let addDeb: () -> Void
|
||
let openTweaksChannel: (String) -> Void
|
||
let openLiveContainer: () -> Void
|
||
let removeTweak: (String) -> Void
|
||
let iconResolver: (String?) -> UIImage?
|
||
|
||
init(toggle: @escaping (String, Bool) -> Void, openSettings: @escaping (String) -> Void, deletePlugin: @escaping (String) -> Void, addPlugin: @escaping () -> Void, addDeb: @escaping () -> Void, openTweaksChannel: @escaping (String) -> Void, openLiveContainer: @escaping () -> Void, removeTweak: @escaping (String) -> Void, iconResolver: @escaping (String?) -> UIImage?) {
|
||
self.toggle = toggle
|
||
self.openSettings = openSettings
|
||
self.deletePlugin = deletePlugin
|
||
self.addPlugin = addPlugin
|
||
self.addDeb = addDeb
|
||
self.openTweaksChannel = openTweaksChannel
|
||
self.openLiveContainer = openLiveContainer
|
||
self.removeTweak = removeTweak
|
||
self.iconResolver = iconResolver
|
||
}
|
||
}
|
||
|
||
private func pluginListEntries(presentationData: PresentationData, plugins: [PluginInfo], tweakFilenames: [String]) -> [PluginListEntry] {
|
||
let lang = presentationData.strings.baseLanguageCode
|
||
var entries: [PluginListEntry] = []
|
||
var id = 0
|
||
entries.append(.addHeader(id: id, text: lang == "ru" ? "ДОБАВИТЬ ПЛАГИН" : "ADD PLUGIN"))
|
||
id += 1
|
||
entries.append(.addAction(id: id, text: lang == "ru" ? "Выбрать файл .plugin" : "Select .plugin file"))
|
||
id += 1
|
||
entries.append(.addNotice(id: id, text: lang == "ru" ? "Файлы плагинов .plugin можно устанавливать здесь." : "Plugin .plugin files can be installed here."))
|
||
id += 1
|
||
entries.append(.addDebAction(id: id, text: lang == "ru" ? "Установить пакет .deb (твики)" : "Install .deb package (tweaks)"))
|
||
id += 1
|
||
entries.append(.addDebNotice(id: id, text: lang == "ru" ? "Пакеты .deb (Cydia/Sileo) — из них извлекаются .dylib и устанавливаются. Перезапустите приложение после установки." : ".deb packages (Cydia/Sileo): .dylib files are extracted and installed. Restart the app after installing."))
|
||
id += 1
|
||
entries.append(.listHeader(id: id, text: lang == "ru" ? "УСТАНОВЛЕННЫЕ ПЛАГИНЫ" : "INSTALLED PLUGINS"))
|
||
id += 1
|
||
for plugin in plugins {
|
||
let meta = plugin.metadata
|
||
entries.append(.pluginRow(id: id, plugin: plugin))
|
||
id += 1
|
||
if plugin.hasSettings {
|
||
entries.append(.pluginSettings(id: id, pluginId: meta.id, text: lang == "ru" ? "Настройки" : "Settings"))
|
||
id += 1
|
||
}
|
||
entries.append(.pluginDelete(id: id, pluginId: meta.id, text: lang == "ru" ? "Удалить" : "Remove"))
|
||
id += 1
|
||
}
|
||
if plugins.isEmpty {
|
||
entries.append(.emptyNotice(id: id, text: lang == "ru" ? "Нет установленных плагинов." : "No installed plugins."))
|
||
}
|
||
id += 1
|
||
entries.append(.tweaksChannelLink(id: id, text: lang == "ru" ? "Скачать твики (канал)" : "Download tweaks (channel)", url: "https://t.me/glegramiostweaks"))
|
||
id += 1
|
||
entries.append(.tweaksHeader(id: id, text: lang == "ru" ? "УСТАНОВИТЬ В" : "INSTALL IN"))
|
||
id += 1
|
||
entries.append(.installLiveContainer(id: id, text: lang == "ru" ? "Установить в LiveContainer" : "Install in LiveContainer"))
|
||
id += 1
|
||
entries.append(.tweaksDylibHeader(id: id, text: lang == "ru" ? "УСТАНОВЛЕННЫЕ ТВИКИ (.dylib)" : "INSTALLED TWEAKS (.dylib)"))
|
||
id += 1
|
||
for filename in tweakFilenames {
|
||
entries.append(.tweakRow(id: id, filename: filename))
|
||
id += 1
|
||
entries.append(.tweakDelete(id: id, filename: filename, text: lang == "ru" ? "Удалить" : "Remove"))
|
||
id += 1
|
||
}
|
||
if tweakFilenames.isEmpty {
|
||
entries.append(.tweaksEmptyNotice(id: id, text: lang == "ru" ? "Нет установленных твиков. Установите .deb." : "No installed tweaks. Install a .deb package."))
|
||
}
|
||
return entries
|
||
}
|
||
|
||
public func PluginListController(context: AccountContext, onPluginsChanged: @escaping () -> Void) -> ViewController {
|
||
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
|
||
var presentDocumentPicker: (() -> Void)?
|
||
var pushControllerImpl: ((ViewController) -> Void)?
|
||
var backAction: (() -> Void)?
|
||
|
||
var presentDebPicker: (() -> Void)?
|
||
var openLiveContainerImpl: (() -> Void)?
|
||
var showDebResultAlertImpl: ((String, String) -> Void)?
|
||
let arguments = PluginListArguments(
|
||
toggle: { pluginId, value in
|
||
var plugins = loadInstalledPlugins()
|
||
if let idx = plugins.firstIndex(where: { $0.metadata.id == pluginId }) {
|
||
plugins[idx].enabled = value
|
||
saveInstalledPlugins(plugins)
|
||
reloadPromise.set(true)
|
||
onPluginsChanged()
|
||
}
|
||
},
|
||
openSettings: { pluginId in
|
||
let plugins = loadInstalledPlugins()
|
||
guard let plugin = plugins.first(where: { $0.metadata.id == pluginId }) else { return }
|
||
let settingsController = PluginSettingsController(context: context, plugin: plugin, onSave: {
|
||
reloadPromise.set(true)
|
||
onPluginsChanged()
|
||
})
|
||
pushControllerImpl?(settingsController)
|
||
},
|
||
deletePlugin: { pluginId in
|
||
var plugins = loadInstalledPlugins()
|
||
plugins.removeAll { $0.metadata.id == pluginId }
|
||
saveInstalledPlugins(plugins)
|
||
reloadPromise.set(true)
|
||
onPluginsChanged()
|
||
},
|
||
addPlugin: { presentDocumentPicker?() },
|
||
addDeb: { presentDebPicker?() },
|
||
openTweaksChannel: { url in
|
||
if let u = URL(string: url) { UIApplication.shared.open(u) }
|
||
},
|
||
openLiveContainer: { openLiveContainerImpl?() },
|
||
removeTweak: { filename in
|
||
try? TweakLoader.removeTweak(filename: filename)
|
||
reloadPromise.set(true)
|
||
onPluginsChanged()
|
||
},
|
||
iconResolver: { iconRef in
|
||
guard let ref = iconRef, !ref.isEmpty else { return nil }
|
||
if let img = UIImage(bundleImageName: ref) { return img }
|
||
return UIImage(bundleImageName: "glePlugins/1")
|
||
}
|
||
)
|
||
|
||
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|
||
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, PluginListArguments)) in
|
||
let plugins = loadInstalledPlugins()
|
||
let tweakFilenames = TweakLoader.installedTweakFilenames()
|
||
let controllerState = ItemListControllerState(
|
||
presentationData: ItemListPresentationData(presentationData),
|
||
title: .text(presentationData.strings.baseLanguageCode == "ru" ? "Плагины" : "Plugins"),
|
||
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
|
||
rightNavigationButton: ItemListNavigationButton(content: .text("+"), style: .bold, enabled: true, action: { presentDocumentPicker?() }),
|
||
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
|
||
)
|
||
let entries = pluginListEntries(presentationData: presentationData, plugins: plugins, tweakFilenames: tweakFilenames)
|
||
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
|
||
return (controllerState, (listState, arguments))
|
||
}
|
||
|
||
let controller = ItemListController(context: context, state: signal)
|
||
backAction = { [weak controller] in controller?.dismiss() }
|
||
|
||
presentDocumentPicker = { [weak controller] in
|
||
guard let controller = controller else { return }
|
||
let picker: UIDocumentPickerViewController
|
||
if #available(iOS 14.0, *) {
|
||
let pluginType = UTType(filenameExtension: "plugin") ?? .plainText
|
||
picker = UIDocumentPickerViewController(forOpeningContentTypes: [pluginType], asCopy: true)
|
||
} else {
|
||
picker = UIDocumentPickerViewController(documentTypes: ["public.plain-text", "public.data"], in: .import)
|
||
}
|
||
let delegate = PluginDocumentPickerDelegate(
|
||
context: context,
|
||
onPick: { url in
|
||
_ = url.startAccessingSecurityScopedResource()
|
||
defer { url.stopAccessingSecurityScopedResource() }
|
||
guard let content = try? String(contentsOf: url, encoding: .utf8),
|
||
let metadata = currentPluginRuntime.parseMetadata(content: content) else { return }
|
||
let hasSettings = currentPluginRuntime.hasCreateSettings(content: content)
|
||
let fileManager = FileManager.default
|
||
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
|
||
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
|
||
try? fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
|
||
let destURL = pluginsDir.appendingPathComponent("\(metadata.id).plugin")
|
||
try? fileManager.removeItem(at: destURL)
|
||
try? fileManager.copyItem(at: url, to: destURL)
|
||
var plugins = loadInstalledPlugins()
|
||
plugins.append(PluginInfo(metadata: metadata, path: destURL.path, enabled: true, hasSettings: hasSettings))
|
||
saveInstalledPlugins(plugins)
|
||
reloadPromise.set(true)
|
||
onPluginsChanged()
|
||
}
|
||
)
|
||
picker.delegate = delegate
|
||
objc_setAssociatedObject(picker, &documentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||
controller.present(picker, animated: true)
|
||
}
|
||
presentDebPicker = { [weak controller] in
|
||
guard let controller = controller else { return }
|
||
let picker: UIDocumentPickerViewController
|
||
if #available(iOS 14.0, *) {
|
||
let debType = UTType(filenameExtension: "deb") ?? .data
|
||
picker = UIDocumentPickerViewController(forOpeningContentTypes: [debType], asCopy: true)
|
||
} else {
|
||
picker = UIDocumentPickerViewController(documentTypes: ["public.data"], in: .import)
|
||
}
|
||
let delegate = PluginDocumentPickerDelegate(context: context, onPick: { url in
|
||
_ = url.startAccessingSecurityScopedResource()
|
||
defer { url.stopAccessingSecurityScopedResource() }
|
||
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
|
||
do {
|
||
let tweaksDir = TweakLoader.ensureTweaksDirectory()
|
||
let result = try DebExtractor.installDeb(from: url, tweaksDirectory: tweaksDir)
|
||
let names = result.installedDylibs.joined(separator: ", ")
|
||
let pkg = result.packageName ?? "Tweak"
|
||
let ver = result.packageVersion.map { " \($0)" } ?? ""
|
||
showDebResultAlertImpl?(lang == "ru" ? "Установлено" : "Installed", "\(pkg)\(ver): \(names)\n\n" + (lang == "ru" ? "Перезапустите приложение." : "Restart the app."))
|
||
reloadPromise.set(true)
|
||
onPluginsChanged()
|
||
} catch {
|
||
showDebResultAlertImpl?(lang == "ru" ? "Ошибка" : "Error", error.localizedDescription)
|
||
}
|
||
})
|
||
picker.delegate = delegate
|
||
objc_setAssociatedObject(picker, &documentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||
controller.present(picker, animated: true)
|
||
}
|
||
pushControllerImpl = { [weak controller] vc in controller?.push(vc) }
|
||
let showNoAppAlert: () -> Void = { [weak controller] in
|
||
guard let ctrl = controller, let window = ctrl.view.window, let root = window.rootViewController else { return }
|
||
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
|
||
let msg = lang == "ru" ? "Нет нужного приложения" : "No required app"
|
||
let alert = UIAlertController(title: nil, message: msg, preferredStyle: .alert)
|
||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||
var top = root
|
||
while let presented = top.presentedViewController { top = presented }
|
||
top.present(alert, animated: true)
|
||
}
|
||
openLiveContainerImpl = {
|
||
guard let url = URL(string: "livecontainer://") else { return }
|
||
UIApplication.shared.open(url, options: [:]) { opened in if !opened { showNoAppAlert() } }
|
||
}
|
||
showDebResultAlertImpl = { [weak controller] title, message in
|
||
guard let controller = controller, let window = controller.view.window, let root = window.rootViewController else { return }
|
||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||
var top = root
|
||
while let presented = top.presentedViewController { top = presented }
|
||
top.present(alert, animated: true)
|
||
}
|
||
|
||
return controller
|
||
}
|
||
|
||
private final class PluginDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||
let context: AccountContext
|
||
let onPick: (URL) -> Void
|
||
init(context: AccountContext, onPick: @escaping (URL) -> Void) {
|
||
self.context = context
|
||
self.onPick = onPick
|
||
}
|
||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||
guard let url = urls.first else { return }
|
||
onPick(url)
|
||
}
|
||
}
|