pageta-native/ShareExtension/ShareViewController.swift
Till JS d4c0e11d5b Tabs umbenannt: Lese-Liste → Stapel, Markierungen → Marker
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>
2026-06-01 01:04:14 +02:00

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)
}
}