Add iOS save action extension

This commit is contained in:
Rijad Zuzo
2026-05-18 20:50:23 +02:00
parent 42a59e959f
commit d81215db73
6 changed files with 425 additions and 0 deletions
@@ -0,0 +1,194 @@
import MobileCoreServices
import UniformTypeIdentifiers
import UIKit
private let schemePrefix = "ShareMedia"
private let userDefaultsKey = "ShareKey"
private let userDefaultsMessageKey = "ShareMessageKey"
private let appGroupIdKey = "AppGroupId"
private struct SharedMediaFile: Codable {
let path: String
let mimeType: String?
let thumbnail: String?
let duration: Double?
let message: String?
let type: SharedMediaType
}
private enum SharedMediaType: String, Codable {
case text
case url
}
final class SaveActionViewController: UIViewController {
private var hostAppBundleIdentifier = ""
private var appGroupId = ""
private var sharedMedia: [SharedMediaFile] = []
private let group = DispatchGroup()
private var didStart = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !didStart else {
return
}
didStart = true
loadIdentifiers()
loadSharedContent()
}
private func loadIdentifiers() {
let actionExtensionIdentifier = Bundle.main.bundleIdentifier ?? ""
if let lastDotIndex = actionExtensionIdentifier.lastIndex(of: ".") {
hostAppBundleIdentifier = String(actionExtensionIdentifier[..<lastDotIndex])
} else {
hostAppBundleIdentifier = actionExtensionIdentifier
}
let defaultAppGroupId = "group.\(hostAppBundleIdentifier)"
appGroupId =
Bundle.main.object(forInfoDictionaryKey: appGroupIdKey) as? String
?? defaultAppGroupId
}
private func loadSharedContent() {
guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem] else {
finish()
return
}
for item in inputItems {
appendAttributedText(item.attributedContentText)
for provider in item.attachments ?? [] {
load(provider)
}
}
group.notify(queue: .main) { [weak self] in
self?.finish()
}
}
private func load(_ provider: NSItemProvider) {
if provider.hasItemConformingToTypeIdentifier(urlTypeIdentifier) {
group.enter()
provider.loadItem(forTypeIdentifier: urlTypeIdentifier, options: nil) { [weak self] item, _ in
defer {
self?.group.leave()
}
if let url = item as? URL {
self?.appendLiteral(url.absoluteString, type: .url, mimeType: nil)
} else if let text = item as? String {
self?.appendLiteral(text, type: .url, mimeType: nil)
}
}
return
}
if provider.hasItemConformingToTypeIdentifier(textTypeIdentifier) {
group.enter()
provider.loadItem(forTypeIdentifier: textTypeIdentifier, options: nil) { [weak self] item, _ in
defer {
self?.group.leave()
}
if let text = item as? String {
self?.appendLiteral(text, type: .text, mimeType: "text/plain")
}
}
}
}
private var textTypeIdentifier: String {
if #available(iOS 14.0, *) {
return UTType.text.identifier
}
return kUTTypeText as String
}
private var urlTypeIdentifier: String {
if #available(iOS 14.0, *) {
return UTType.url.identifier
}
return kUTTypeURL as String
}
private func appendAttributedText(_ text: NSAttributedString?) {
guard let value = text?.string else {
return
}
appendLiteral(value, type: .text, mimeType: "text/plain")
}
private func appendLiteral(_ value: String, type: SharedMediaType, mimeType: String?) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
guard !sharedMedia.contains(where: { $0.type == type && $0.path == trimmed }) else {
return
}
sharedMedia.append(
SharedMediaFile(
path: trimmed,
mimeType: mimeType,
thumbnail: nil,
duration: nil,
message: nil,
type: type
)
)
}
private func finish() {
guard !sharedMedia.isEmpty else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
saveAndRedirect()
}
private func saveAndRedirect() {
guard let encodedData = try? JSONEncoder().encode(sharedMedia) else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
let userDefaults = UserDefaults(suiteName: appGroupId)
userDefaults?.set(encodedData, forKey: userDefaultsKey)
userDefaults?.set(nil, forKey: userDefaultsMessageKey)
userDefaults?.synchronize()
redirectToHostApp()
}
private func redirectToHostApp() {
guard let url = URL(string: "\(schemePrefix)-\(hostAppBundleIdentifier):share") else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
var responder: UIResponder? = self
let selectorOpenUrl = sel_registerName("openURL:")
if #available(iOS 18.0, *) {
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
} else {
while responder != nil {
if responder?.responds(to: selectorOpenUrl) == true {
_ = responder?.perform(selectorOpenUrl, with: url)
break
}
responder = responder?.next
}
}
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}