User-facing Strings konsistent umgestellt: Tab-Labels + Nav-Titel +
macOS-Sidebar, leere Zustände, Such-Prompt, Feed-Speichern-Aktionen
("Auf den Stapel"), Share-Toast ("Zum Stapel hinzugefügt"), App-Shortcut-
Beschreibung, Konto-Prosa, Marker-Bezüge im Reader (Notiz-Sheet, Zähler,
Löschen-Label, Kopieren). Verb "markieren" + Lese-Status bleiben.
Swift-Typnamen (LeseListeTabView/MarkierungenTabView) unverändert (interne
Identifier). Build grün.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
8.3 KiB
Swift
216 lines
8.3 KiB
Swift
import UIKit
|
|
import UniformTypeIdentifiers
|
|
|
|
/// Pageta ShareExtension — fängt URLs aus Safari/Mail/anderen Apps,
|
|
/// schreibt sie in den App-Group `PendingShareStore`. App synct
|
|
/// asynchron beim nächsten Foreground zum Server (Pattern wie
|
|
/// herbatrium-native η-8.3, aber mit sichtbarem Toast).
|
|
///
|
|
/// Bewusst kein Server-Roundtrip in der Extension selbst: 30s-Limit für
|
|
/// App-Extensions + kein JWT-Refresh-Pfad ohne den vollen AuthClient.
|
|
/// Queue → asynchroner Drain im App-Context ist sauberer.
|
|
///
|
|
/// **UX:** im Gegensatz zu herbatrium (silent dismiss → User landet im
|
|
/// Foto-Flow) zeigt Pageta ein kurzes ✓-Toast (~0.8 s), weil die App
|
|
/// danach nicht zwingend geöffnet wird. Ohne sichtbares Feedback wirkt
|
|
/// das Share wie abgebrochen.
|
|
@objc(ShareViewController)
|
|
final class ShareViewController: UIViewController {
|
|
private let store = PendingShareStore()
|
|
|
|
private let toastCard = UIView()
|
|
private let iconView = UIImageView()
|
|
private let statusLabel = UILabel()
|
|
|
|
// Paper-Theme-Tokens (inline gehalten — die App-Side hat sie in
|
|
// PagetaTheme.swift, aber Extensions haben keinen Zugriff auf die
|
|
// Sources/-Targets). Bei Theme-Updates beide Stellen anpassen.
|
|
private static let paperCream = UIColor(red: 0.961, green: 0.937, blue: 0.902, alpha: 1.0)
|
|
private static let paperSepia = UIColor(red: 0.227, green: 0.176, blue: 0.125, alpha: 1.0)
|
|
private static let paperTerracotta = UIColor(red: 0.588, green: 0.306, blue: 0.165, alpha: 1.0)
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
|
setupToast()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
UIView.animate(withDuration: 0.18) {
|
|
self.toastCard.alpha = 1
|
|
self.toastCard.transform = .identity
|
|
}
|
|
Task { await handleSharedContent() }
|
|
}
|
|
|
|
private func setupToast() {
|
|
toastCard.backgroundColor = Self.paperCream
|
|
toastCard.layer.cornerRadius = 16
|
|
toastCard.layer.shadowColor = UIColor.black.cgColor
|
|
toastCard.layer.shadowOpacity = 0.18
|
|
toastCard.layer.shadowRadius = 14
|
|
toastCard.layer.shadowOffset = CGSize(width: 0, height: 4)
|
|
toastCard.alpha = 0
|
|
toastCard.transform = CGAffineTransform(scaleX: 0.94, y: 0.94)
|
|
toastCard.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
iconView.contentMode = .scaleAspectFit
|
|
iconView.tintColor = Self.paperTerracotta
|
|
iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 36, weight: .regular)
|
|
iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
statusLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
|
statusLabel.textColor = Self.paperSepia
|
|
statusLabel.textAlignment = .center
|
|
statusLabel.numberOfLines = 0
|
|
statusLabel.text = "Speichere …"
|
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
view.addSubview(toastCard)
|
|
toastCard.addSubview(iconView)
|
|
toastCard.addSubview(statusLabel)
|
|
|
|
NSLayoutConstraint.activate([
|
|
toastCard.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
toastCard.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
toastCard.widthAnchor.constraint(equalToConstant: 260),
|
|
|
|
iconView.topAnchor.constraint(equalTo: toastCard.topAnchor, constant: 22),
|
|
iconView.centerXAnchor.constraint(equalTo: toastCard.centerXAnchor),
|
|
iconView.widthAnchor.constraint(equalToConstant: 44),
|
|
iconView.heightAnchor.constraint(equalToConstant: 44),
|
|
|
|
statusLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 12),
|
|
statusLabel.leadingAnchor.constraint(equalTo: toastCard.leadingAnchor, constant: 20),
|
|
statusLabel.trailingAnchor.constraint(equalTo: toastCard.trailingAnchor, constant: -20),
|
|
statusLabel.bottomAnchor.constraint(equalTo: toastCard.bottomAnchor, constant: -22),
|
|
])
|
|
|
|
// Placeholder-Icon, wird in showResult getauscht.
|
|
iconView.image = UIImage(systemName: "tray.and.arrow.down")
|
|
}
|
|
|
|
private func handleSharedContent() async {
|
|
guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else {
|
|
await showResult(.noURL)
|
|
return
|
|
}
|
|
|
|
var foundURL: URL?
|
|
var foundHTML: String?
|
|
// Property-List (JS-Preprocessing aus Safari) zuerst — sie trägt das
|
|
// gerenderte HTML der zustimmenden Seite. URL/Text sind der Fallback
|
|
// für nackte Shares aus anderen Apps. Kein early-break: HTML und URL
|
|
// kommen als getrennte Provider.
|
|
for item in extensionItems {
|
|
guard let attachments = item.attachments else { continue }
|
|
for provider in attachments {
|
|
if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) {
|
|
if let result = await loadPreprocessed(from: provider) {
|
|
foundHTML = result.html
|
|
foundURL = foundURL ?? result.url
|
|
}
|
|
} else if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
|
if foundURL == nil, let url = await loadURL(from: provider) {
|
|
foundURL = url
|
|
}
|
|
} else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
|
if foundURL == nil, let url = await loadURLFromText(provider) {
|
|
foundURL = url
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let url = foundURL else {
|
|
await showResult(.noURL)
|
|
return
|
|
}
|
|
// Gerendertes HTML (falls aus Safari) in den App-Group-Store legen,
|
|
// damit die App es bei der Extraktion gegen Consent-Wände nutzen kann.
|
|
if let html = foundHTML {
|
|
SharedHTMLStore().put(html, for: url)
|
|
}
|
|
// Enqueuen — die App speichert local-first beim nächsten Foreground
|
|
// (auch ausgeloggt; Extraktion + Account-Claim laufen später nach).
|
|
// Darum ist „hinzugefügt" hier ehrlich, kein Login nötig.
|
|
store.enqueue(url)
|
|
await showResult(.saved)
|
|
}
|
|
|
|
/// Liest das JS-Preprocessing-Ergebnis (Safari liefert es als
|
|
/// Property-List unter `NSExtensionJavaScriptPreprocessingResultsKey`).
|
|
private func loadPreprocessed(from provider: NSItemProvider) async -> (url: URL?, html: String?)? {
|
|
await withCheckedContinuation { continuation in
|
|
provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { item, _ in
|
|
guard let dict = item as? NSDictionary,
|
|
let results = dict[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary
|
|
else {
|
|
continuation.resume(returning: nil)
|
|
return
|
|
}
|
|
let html = results["html"] as? String
|
|
let url = (results["url"] as? String).flatMap { URL(string: $0) }
|
|
continuation.resume(returning: (url, html))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadURL(from provider: NSItemProvider) async -> URL? {
|
|
await withCheckedContinuation { continuation in
|
|
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
|
|
if let url = item as? URL {
|
|
continuation.resume(returning: url)
|
|
} else if let data = item as? Data,
|
|
let str = String(data: data, encoding: .utf8),
|
|
let url = URL(string: str) {
|
|
continuation.resume(returning: url)
|
|
} else {
|
|
continuation.resume(returning: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadURLFromText(_ provider: NSItemProvider) async -> URL? {
|
|
await withCheckedContinuation { continuation in
|
|
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
|
if let text = item as? String,
|
|
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue),
|
|
let match = detector.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
|
|
let url = match.url
|
|
{
|
|
continuation.resume(returning: url)
|
|
} else {
|
|
continuation.resume(returning: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func showResult(_ outcome: ShareOutcome) async {
|
|
let dwellMillis: Int
|
|
switch outcome {
|
|
case .saved:
|
|
iconView.image = UIImage(systemName: "checkmark.circle.fill")
|
|
iconView.tintColor = Self.paperTerracotta
|
|
statusLabel.text = "Zum Stapel hinzugefügt"
|
|
dwellMillis = 700
|
|
case .noURL:
|
|
iconView.image = UIImage(systemName: "exclamationmark.triangle.fill")
|
|
iconView.tintColor = .systemOrange
|
|
statusLabel.text = "Keine URL gefunden"
|
|
dwellMillis = 700
|
|
}
|
|
try? await Task.sleep(for: .milliseconds(dwellMillis))
|
|
UIView.animate(withDuration: 0.2) {
|
|
self.toastCard.alpha = 0
|
|
self.toastCard.transform = CGAffineTransform(scaleX: 0.96, y: 0.96)
|
|
self.view.backgroundColor = UIColor.black.withAlphaComponent(0)
|
|
}
|
|
try? await Task.sleep(for: .milliseconds(200))
|
|
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
|
}
|
|
}
|