pageta-native/ShareExtensionMac/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

194 lines
7.6 KiB
Swift

import AppKit
import UniformTypeIdentifiers
/// Pageta macOS-ShareExtension — Pendant zur iOS-ShareExtension. Selber
/// Vertrag: URL aus Safari/Mail/Notes/... abfangen, in den App-Group
/// `PendingShareStore` legen, App synct beim nächsten Foreground zum Server.
///
/// Apple verlangt für macOS-Share-Extensions einen `NSViewController` als
/// Principal-Class. Die Loading-Logik (NSItemProvider → URL) ist mit der
/// iOS-Variante deckungsgleich; bewusst dupliziert statt einer
/// gemeinsamen Basisklasse — die paar Zeilen rechtfertigen kein
/// Plattform-Abstraction-Layer.
///
/// **UX:** kurzer Toast (~0.8 s) mit ✓ + "Zur Lese-Liste hinzugefügt",
/// dann automatisch dismissen. Kein Server-Roundtrip in der Extension —
/// macOS-Extensions sind sandboxed und sollen schnell terminieren.
final class ShareViewController: NSViewController {
private let store = PendingShareStore()
private let toastCard = NSView()
private let iconView = NSImageView()
private let statusLabel = NSTextField(labelWithString: "Speichere …")
private static let paperCream = NSColor(red: 0.961, green: 0.937, blue: 0.902, alpha: 1.0)
private static let paperSepia = NSColor(red: 0.227, green: 0.176, blue: 0.125, alpha: 1.0)
private static let paperTerracotta = NSColor(red: 0.588, green: 0.306, blue: 0.165, alpha: 1.0)
override func loadView() {
let root = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 180))
root.wantsLayer = true
root.layer?.backgroundColor = Self.paperCream.cgColor
view = root
preferredContentSize = NSSize(width: 320, height: 180)
setupToast()
}
private func setupToast() {
toastCard.wantsLayer = true
toastCard.translatesAutoresizingMaskIntoConstraints = false
iconView.image = NSImage(systemSymbolName: "tray.and.arrow.down", accessibilityDescription: nil)
iconView.contentTintColor = Self.paperTerracotta
iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
iconView.imageScaling = .scaleProportionallyUpOrDown
iconView.translatesAutoresizingMaskIntoConstraints = false
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
statusLabel.textColor = Self.paperSepia
statusLabel.alignment = .center
statusLabel.maximumNumberOfLines = 2
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: 8),
iconView.centerXAnchor.constraint(equalTo: toastCard.centerXAnchor),
iconView.widthAnchor.constraint(equalToConstant: 42),
iconView.heightAnchor.constraint(equalToConstant: 42),
statusLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 10),
statusLabel.leadingAnchor.constraint(equalTo: toastCard.leadingAnchor, constant: 16),
statusLabel.trailingAnchor.constraint(equalTo: toastCard.trailingAnchor, constant: -16),
statusLabel.bottomAnchor.constraint(equalTo: toastCard.bottomAnchor, constant: -8),
])
}
override func viewDidAppear() {
super.viewDidAppear()
Task { await handleSharedContent() }
}
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. Kein early-break: HTML und URL kommen getrennt.
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 = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
iconView.contentTintColor = Self.paperTerracotta
statusLabel.stringValue = "Zum Stapel hinzugefügt"
dwellMillis = 800
case .noURL:
iconView.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: nil)
iconView.contentTintColor = .systemOrange
statusLabel.stringValue = "Keine URL gefunden"
dwellMillis = 800
}
try? await Task.sleep(for: .milliseconds(dwellMillis))
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
}