Der Absturzbericht war eindeutig:
Thread 8: com.apple.NSXPCConnection.m-user…onyx.helper
swift_task_isCurrentExecutorWithFlags
closure #3 in FanControl.proxy()
EXC_BREAKPOINT
Die Rückrufe der XPC-Verbindung erben die MainActor-Isolation der Methode,
in der sie stehen. XPC ruft sie aber auf seiner eigenen Warteschlange auf,
Swift 6 prüft das zur Laufzeit und beendet den Prozess. Das
`Task { @MainActor in … }` im Rumpf half nicht: die Prüfung geschieht beim
Betreten des Abschlusses, nicht beim Zugriff.
Alle sieben Rückrufe sind jetzt `@Sendable`. Und weil das eine Fehlerklasse
ist und kein Einzelfall, dieselbe Behandlung für die übrigen Stellen, an
denen ein MainActor-Typ einen Abschluss an eine Systemschnittstelle gibt:
Papierkorb, Vorschaubilder, Adapter-Ende, Darwin-Nachricht.
Dazu drei Dinge aus dem Bericht von eben:
Der Linksklick aufs Menüleistensymbol fuhr das Panel aus — und ging dabei
als Fixieren durch. Danach stand das Panel offen und reagierte auf nichts
mehr. Das war schlechter als das Problem, das es lösen sollte. Ein
Statuselement zeigt bei einem Klick sein Menü; alles andere überrascht.
„Panel öffnen" bleibt draußen.
Ein Klick daneben schließt jetzt auch ein fixiertes Panel. „Klick fixiert"
ist eine gute Regel, aber wer sie nicht kennt, sitzt sonst vor etwas, das
offen steht und nicht reagiert — und sucht den Fehler in der App.
Einstellungs- und Einrichtungsfenster gehen nicht mehr direkt unter der
Notch auf. `center()` setzt oberhalb der Mitte; der Schließknopf landete
damit so weit oben, dass man auf dem Weg dorthin die Notch auslöste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
7.3 KiB
Swift
203 lines
7.3 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import QuickLookThumbnailing
|
|
import OnyxDesign
|
|
import OnyxWidgetKit
|
|
|
|
/// Vorschaubilder, einmal erzeugt und behalten.
|
|
///
|
|
/// `QLThumbnailGenerator` ist nicht billig, und die Ablage zeichnet sich bei
|
|
/// jeder Änderung neu. Ohne diesen Zwischenspeicher würde für jedes Bild bei
|
|
/// jedem Tastendruck ein neuer Auftrag laufen.
|
|
@MainActor
|
|
@Observable
|
|
final class ThumbnailCache {
|
|
private var images: [String: NSImage] = [:]
|
|
private var pending: Set<String> = []
|
|
|
|
func image(for item: ShelfItem, size: CGFloat) -> NSImage? {
|
|
if let cached = images[item.id] { return cached }
|
|
guard !pending.contains(item.id) else { return nil }
|
|
pending.insert(item.id)
|
|
|
|
let request = QLThumbnailGenerator.Request(
|
|
fileAt: item.url, size: CGSize(width: size, height: size),
|
|
scale: NSScreen.main?.backingScaleFactor ?? 2,
|
|
representationTypes: .all)
|
|
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { @Sendable rep, _ in
|
|
guard let rep else { return }
|
|
let image = NSImage(cgImage: rep.cgImage,
|
|
size: CGSize(width: size, height: size))
|
|
Task { @MainActor [weak self] in
|
|
self?.images[item.id] = image
|
|
self?.pending.remove(item.id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Die Ablage.
|
|
public struct ShelfView: View {
|
|
private let store: ShelfStore
|
|
private let compact: Bool
|
|
@State private var isTargeted = false
|
|
@State private var thumbnails = ThumbnailCache()
|
|
|
|
public init(store: ShelfStore, compact: Bool = false) {
|
|
self.store = store
|
|
self.compact = compact
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if store.items.isEmpty {
|
|
EmptyShelf()
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 4) {
|
|
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
|
|
ShelfRow(item: item, store: store, thumbnails: thumbnails)
|
|
}
|
|
}
|
|
}
|
|
.scrollIndicators(.never)
|
|
|
|
HStack(spacing: 10) {
|
|
Button { store.removeAll() } label: {
|
|
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
|
|
Button { store.revealInFinder() } label: {
|
|
Text("shelf.reveal", bundle: .module).font(Onyx.Font.caption)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
|
|
.contentShape(.rect)
|
|
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
|
|
.overlay {
|
|
if isTargeted {
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
|
.background(Onyx.Color.accent.opacity(0.08),
|
|
in: .rect(cornerRadius: 10, style: .continuous))
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.animation(Onyx.Motion.value, value: isTargeted)
|
|
}
|
|
}
|
|
|
|
private struct EmptyShelf: View {
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Image(systemName: "tray")
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
Text("shelf.empty", bundle: .module)
|
|
.font(Onyx.Font.caption)
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
}
|
|
}
|
|
|
|
private struct ShelfRow: View {
|
|
let item: ShelfItem
|
|
let store: ShelfStore
|
|
let thumbnails: ThumbnailCache
|
|
@State private var isHovered = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
thumbnail
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(item.name)
|
|
.font(Onyx.Font.caption)
|
|
.foregroundStyle(Onyx.Color.textPrimary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Text(item.size.formatted(.byteCount(style: .file)))
|
|
.font(Onyx.Font.metricSmall)
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
|
|
if isHovered {
|
|
Button { AirDrop.send([item.url]) } label: {
|
|
Image(systemName: "shareplay").font(.system(size: 10))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
|
|
Button { store.remove(item) } label: {
|
|
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
.contentShape(.rect)
|
|
.onHover { isHovered = $0 }
|
|
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
|
|
// reicht die URL als Text weiter, und im Finder landet eine
|
|
// Internetverknüpfung statt der Datei.
|
|
.onDrag { NSItemProvider(contentsOf: item.url) ?? NSItemProvider() }
|
|
.onTapGesture(count: 2) { NSWorkspace.shared.open(item.url) }
|
|
.contextMenu {
|
|
Button { NSWorkspace.shared.open(item.url) } label: {
|
|
Text("shelf.open", bundle: .module)
|
|
}
|
|
Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } label: {
|
|
Text("shelf.reveal", bundle: .module)
|
|
}
|
|
Button { AirDrop.send([item.url]) } label: {
|
|
Text("shelf.airdrop", bundle: .module)
|
|
}
|
|
Divider()
|
|
Button(role: .destructive) { store.remove(item) } label: {
|
|
Text("shelf.delete", bundle: .module)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var thumbnail: some View {
|
|
if let image = thumbnails.image(for: item, size: 22) {
|
|
Image(nsImage: image)
|
|
.resizable().aspectRatio(contentMode: .fit)
|
|
.frame(width: 22, height: 22)
|
|
.clipShape(.rect(cornerRadius: 3, style: .continuous))
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
|
.fill(Onyx.Color.elevated)
|
|
.frame(width: 22, height: 22)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Widget
|
|
|
|
public struct ShelfWidget: OnyxWidget {
|
|
public let id = "shelf"
|
|
public var displayName: String { String(localized: "shelf.name", bundle: .module) }
|
|
public let symbolName = "tray.full"
|
|
|
|
private let store: ShelfStore
|
|
|
|
public init(store: ShelfStore) { self.store = store }
|
|
|
|
public func makeView() -> AnyView {
|
|
AnyView(ShelfView(store: store))
|
|
}
|
|
}
|