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
+118 -118
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)
if let url = detectURL(in: item.attributedContentText?.string ?? "") {
saveAndFinish(url: url, title: item.attributedTitle?.string)
return
}
// Get the page title from the compose view or extension item // Scan attachments for URL/text
let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string let providers = item.attachments ?? []
if providers.isEmpty {
// Fallback: use attributed title as plain text
if let title = item.attributedTitle?.string, !title.isEmpty {
saveAndFinish(text: title)
} else {
finishWithMessage("No shareable content")
}
return
}
var foundURL: URL? = nil // Load URL or text asynchronously
let group = DispatchGroup() loadFirstURL(from: providers) { [weak self] url in
guard let self = self else { return }
if let url = url {
self.saveAndFinish(url: url, title: item.attributedTitle?.string)
} else {
self.loadFirstPlainText(from: providers) { text in
if let t = text, !t.isEmpty {
// 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")
}
}
}
}
}
// IMPORTANT: Check if the NSExtensionItem itself has a URL private func detectURL(in text: String) -> URL? {
// Safari often provides the URL as an attributedString with a link guard !text.isEmpty else { return nil }
if let attributedText = extensionItem.attributedContentText {
let text = attributedText.string
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) let range = NSRange(location: 0, length: (text as NSString).length)
if let firstMatch = matches?.first, let url = firstMatch.url { let match = detector?.matches(in: text, options: [], range: range).first
foundURL = url return match?.url
}
} }
// Only check attachments if we haven't found a URL yet private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
if foundURL == nil { let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() { let grp = DispatchGroup()
var found: URL?
// Try multiple URL type identifiers that Safari might use for p in providers where found == nil {
let urlTypes = [ for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
UTType.url.identifier, grp.enter()
"public.url", p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
"public.file-url" 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 }
for urlType in urlTypes { if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
if itemProvider.hasItemConformingToTypeIdentifier(urlType) { }
group.enter() break
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 grp.notify(queue: .main) { completion(found) }
}
} }
// Also check for plain text that might be a URL private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { let id = UTType.plainText.identifier
group.enter() let grp = DispatchGroup()
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in var text: String?
defer { group.leave() } 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) }
}
if let text = item as? String { // MARK: - Save + Finish
// Check if the text is actually a URL private func saveAndFinish(url: URL, title: String?) {
if let url = URL(string: text), let payload: [String: String] = [
(url.scheme == "http" || url.scheme == "https") {
foundURL = url
}
}
}
}
}
} // End of if foundURL == nil
// Process after all checks complete
group.notify(queue: .main) { [weak self] in
if let url = foundURL {
// We have a URL! Create the JSON data
let urlData: [String: String] = [
"url": url.absoluteString, "url": url.absoluteString,
"title": pageTitle ?? url.host ?? "Shared Link" "title": title ?? url.host ?? "Shared Link"
] ]
if let json = try? JSONSerialization.data(withJSONObject: payload),
let s = String(data: json, encoding: .utf8) {
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData), saveToSharedDefaults(content: s, type: "url")
let jsonString = String(data: jsonData, encoding: .utf8) { finishWithMessage("✓ Shared link to bitchat")
self?.saveToSharedDefaults(content: jsonString, type: "url") } else {
} finishWithMessage("Failed to encode link")
} else if let title = pageTitle, !title.isEmpty {
// No URL found, just share the text
self?.saveToSharedDefaults(content: title, type: "text")
}
self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
} }
} }
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
} }
} }
} }