Share Extension: migrate to UIKit + UTTypes; drop Social/SLComposeServiceViewController

This commit is contained in:
jack
2025-08-25 17:44:07 +02:00
parent ce48720e9a
commit d946c01bfc
+130 -130
View File
@@ -7,160 +7,160 @@
// //
import UIKit import UIKit
import Social
import UniformTypeIdentifiers import UniformTypeIdentifiers
class ShareViewController: SLComposeServiceViewController { /// Modern share extension using UIKit + UTTypes.
/// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController {
private let statusLabel: UILabel = {
let l = UILabel()
l.translatesAutoresizingMaskIntoConstraints = false
l.font = .systemFont(ofSize: 15, weight: .semibold)
l.textAlignment = .center
l.numberOfLines = 0
l.textColor = .label
return l
}()
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
// Set placeholder text view.backgroundColor = .systemBackground
placeholder = "Share to bitchat..." view.addSubview(statusLabel)
// Set character limit (optional) NSLayoutConstraint.activate([
charactersRemaining = 500 statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
])
processShare()
} }
override func isContentValid() -> Bool { // MARK: - Processing
// Validate that we have text content or attachments private func processShare() {
if let text = contentText, !text.isEmpty { guard let ctx = self.extensionContext,
return true let item = ctx.inputItems.first as? NSExtensionItem else {
} finishWithMessage("Nothing to share")
// Check if we have attachments
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = item.attachments,
!attachments.isEmpty {
return true
}
return false
}
override func didSelectPost() {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return return
} }
// Try content from attributed text first (Safari often passes URL here)
// Get the page title from the compose view or extension item if let url = detectURL(in: item.attributedContentText?.string ?? "") {
let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string saveAndFinish(url: url, title: item.attributedTitle?.string)
return
var foundURL: URL? = nil
let group = DispatchGroup()
// IMPORTANT: Check if the NSExtensionItem itself has a URL
// Safari often provides the URL as an attributedString with a link
if let attributedText = extensionItem.attributedContentText {
let text = attributedText.string
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
if let firstMatch = matches?.first, let url = firstMatch.url {
foundURL = url
}
} }
// Only check attachments if we haven't found a URL yet // Scan attachments for URL/text
if foundURL == nil { let providers = item.attachments ?? []
for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() { if providers.isEmpty {
// Fallback: use attributed title as plain text
// Try multiple URL type identifiers that Safari might use if let title = item.attributedTitle?.string, !title.isEmpty {
let urlTypes = [ saveAndFinish(text: title)
UTType.url.identifier, } else {
"public.url", finishWithMessage("No shareable content")
"public.file-url"
]
for urlType in urlTypes {
if itemProvider.hasItemConformingToTypeIdentifier(urlType) {
group.enter()
itemProvider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
defer { group.leave() }
if let url = item as? URL {
foundURL = url
} else if let data = item as? Data,
let urlString = String(data: data, encoding: .utf8),
let url = URL(string: urlString) {
foundURL = url
} else if let string = item as? String,
let url = URL(string: string) {
foundURL = url
}
}
break // Found a URL type, no need to check other types
}
} }
return
// Also check for plain text that might be a URL }
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
group.enter() // Load URL or text asynchronously
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in loadFirstURL(from: providers) { [weak self] url in
defer { group.leave() } guard let self = self else { return }
if let url = url {
if let text = item as? String { self.saveAndFinish(url: url, title: item.attributedTitle?.string)
// Check if the text is actually a URL } else {
if let url = URL(string: text), self.loadFirstPlainText(from: providers) { text in
(url.scheme == "http" || url.scheme == "https") { if let t = text, !t.isEmpty {
foundURL = url // Treat as URL if parseable http(s), else plain text
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else {
self.saveAndFinish(text: t)
} }
} else {
self.finishWithMessage("No shareable content")
} }
} }
} }
} }
} // End of if foundURL == nil }
// Process after all checks complete private func detectURL(in text: String) -> URL? {
group.notify(queue: .main) { [weak self] in guard !text.isEmpty else { return nil }
if let url = foundURL { let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
// We have a URL! Create the JSON data let range = NSRange(location: 0, length: (text as NSString).length)
let urlData: [String: String] = [ let match = detector?.matches(in: text, options: [], range: range).first
"url": url.absoluteString, return match?.url
"title": pageTitle ?? url.host ?? "Shared Link" }
]
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData), let grp = DispatchGroup()
let jsonString = String(data: jsonData, encoding: .utf8) { var found: URL?
self?.saveToSharedDefaults(content: jsonString, type: "url")
for p in providers where found == nil {
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let u = item as? URL { found = u; return }
if let s = item as? String, let u = URL(string: s) { found = u; return }
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
} }
} else if let title = pageTitle, !title.isEmpty { break
// No URL found, just share the text
self?.saveToSharedDefaults(content: title, type: "text")
} }
}
self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) grp.notify(queue: .main) { completion(found) }
}
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
let id = UTType.plainText.identifier
let grp = DispatchGroup()
var text: String?
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let s = item as? String { text = s }
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
}
break
}
grp.notify(queue: .main) { completion(text) }
}
// MARK: - Save + Finish
private func saveAndFinish(url: URL, title: String?) {
let payload: [String: String] = [
"url": url.absoluteString,
"title": title ?? url.host ?? "Shared Link"
]
if let json = try? JSONSerialization.data(withJSONObject: payload),
let s = String(data: json, encoding: .utf8) {
saveToSharedDefaults(content: s, type: "url")
finishWithMessage("✓ Shared link to bitchat")
} else {
finishWithMessage("Failed to encode link")
} }
} }
override func configurationItems() -> [Any]! { private func saveAndFinish(text: String) {
// No configuration items needed saveToSharedDefaults(content: text, type: "text")
return [] finishWithMessage("✓ Shared text to bitchat")
} }
// MARK: - Helper Methods
private func saveToSharedDefaults(content: String, type: String) { private func saveToSharedDefaults(content: String, type: String) {
// Use app groups to share data between extension and main app guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
return
}
userDefaults.set(content, forKey: "sharedContent") userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType") userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate") userDefaults.set(Date(), forKey: "sharedContentDate")
userDefaults.synchronize() userDefaults.synchronize()
// Force open the main app
self.openMainApp()
} }
private func openMainApp() { private func finishWithMessage(_ msg: String) {
// Share extensions cannot directly open the containing app statusLabel.text = msg
// The app will check for shared content when it becomes active // Complete shortly after showing status
// Show success feedback to user DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
DispatchQueue.main.async { self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
self.textView.text = "✓ Shared to bitchat"
self.textView.isEditable = false
} }
} }
} }