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>
194 lines
7.6 KiB
Swift
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)
|
|
}
|
|
}
|