mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 15:44:32 +00:00
Somewhat working file tray
This commit is contained in:
parent
4c7e79b954
commit
30422c3d95
29 changed files with 4032 additions and 65 deletions
|
|
@ -0,0 +1,320 @@
|
|||
//
|
||||
// ImageProcessingService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import CoreImage
|
||||
import CoreGraphics
|
||||
import Vision
|
||||
import PDFKit
|
||||
import UniformTypeIdentifiers
|
||||
import ImageIO
|
||||
|
||||
/// Options for image conversion
|
||||
struct ImageConversionOptions {
|
||||
enum ImageFormat {
|
||||
case png, jpeg, heic, tiff, bmp
|
||||
|
||||
var utType: UTType {
|
||||
switch self {
|
||||
case .png: return .png
|
||||
case .jpeg: return .jpeg
|
||||
case .heic: return .heic
|
||||
case .tiff: return .tiff
|
||||
case .bmp: return .bmp
|
||||
}
|
||||
}
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .png: return "png"
|
||||
case .jpeg: return "jpg"
|
||||
case .heic: return "heic"
|
||||
case .tiff: return "tiff"
|
||||
case .bmp: return "bmp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let format: ImageFormat
|
||||
let compressionQuality: Double // 0.0 to 1.0, only applies to JPEG/HEIC
|
||||
let maxDimension: CGFloat? // Max width or height, nil for no scaling
|
||||
let removeMetadata: Bool
|
||||
}
|
||||
|
||||
/// Service for processing images (background removal, conversion, PDF creation)
|
||||
@MainActor
|
||||
final class ImageProcessingService {
|
||||
static let shared = ImageProcessingService()
|
||||
|
||||
private init() {}
|
||||
private let ciContext = CIContext(options: nil)
|
||||
|
||||
// MARK: - Remove Background
|
||||
|
||||
/// Removes the background from an image using Vision framework
|
||||
func removeBackground(from url: URL) async throws -> URL? {
|
||||
guard let inputImage = NSImage(contentsOf: url) else {
|
||||
throw ImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
guard let cgImage = inputImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
let request = VNGenerateForegroundInstanceMaskRequest()
|
||||
let handler = VNImageRequestHandler(cgImage: cgImage)
|
||||
|
||||
try handler.perform([request])
|
||||
|
||||
guard let result = request.results?.first else {
|
||||
throw ImageProcessingError.backgroundRemovalFailed
|
||||
}
|
||||
|
||||
let mask = try result.generateScaledMaskForImage(forInstances: result.allInstances, from: handler)
|
||||
|
||||
let output = try await applyMask(mask, to: cgImage)
|
||||
|
||||
let processedImage = NSImage(cgImage: output, size: inputImage.size)
|
||||
|
||||
// Create temporary file
|
||||
let originalName = url.deletingPathExtension().lastPathComponent
|
||||
let newName = "\(originalName)_no_bg.png"
|
||||
|
||||
guard let pngData = processedImage.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: pngData),
|
||||
let finalData = bitmap.representation(using: .png, properties: [:]) else {
|
||||
throw ImageProcessingError.saveFailed
|
||||
}
|
||||
|
||||
guard let tempURL = await TemporaryFileStorageService.shared.createTempFile(
|
||||
for: .data(finalData, suggestedName: newName)
|
||||
) else {
|
||||
throw ImageProcessingError.saveFailed
|
||||
}
|
||||
|
||||
return tempURL
|
||||
}
|
||||
|
||||
private func applyMask(_ mask: CVPixelBuffer, to image: CGImage) async throws -> CGImage {
|
||||
let ciImage = CIImage(cgImage: image)
|
||||
let maskImage = CIImage(cvPixelBuffer: mask)
|
||||
|
||||
let filter = CIFilter.blendWithMask()
|
||||
filter.inputImage = ciImage
|
||||
filter.maskImage = maskImage
|
||||
filter.backgroundImage = CIImage.empty()
|
||||
|
||||
guard let output = filter.outputImage else {
|
||||
throw ImageProcessingError.backgroundRemovalFailed
|
||||
}
|
||||
|
||||
let context = CIContext()
|
||||
guard let result = context.createCGImage(output, from: output.extent) else {
|
||||
throw ImageProcessingError.backgroundRemovalFailed
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Convert Image
|
||||
|
||||
/// Converts an image with specified options
|
||||
func convertImage(from url: URL, options: ImageConversionOptions) async throws -> URL? {
|
||||
guard var inputImage = NSImage(contentsOf: url) else {
|
||||
throw ImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
// Scale image if needed
|
||||
if let maxDim = options.maxDimension {
|
||||
inputImage = scaleImage(inputImage, maxDimension: maxDim)
|
||||
}
|
||||
|
||||
// Get image data based on format
|
||||
let imageData: Data?
|
||||
|
||||
if options.removeMetadata {
|
||||
// Create new image without metadata
|
||||
guard let cgImage = inputImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
let newImage = NSImage(cgImage: cgImage, size: inputImage.size)
|
||||
imageData = try convertToFormat(newImage, format: options.format, quality: options.compressionQuality)
|
||||
} else {
|
||||
imageData = try convertToFormat(inputImage, format: options.format, quality: options.compressionQuality)
|
||||
}
|
||||
|
||||
guard let data = imageData else {
|
||||
throw ImageProcessingError.conversionFailed
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
let originalName = url.deletingPathExtension().lastPathComponent
|
||||
let newName = "\(originalName)_converted.\(options.format.fileExtension)"
|
||||
|
||||
guard let tempURL = await TemporaryFileStorageService.shared.createTempFile(
|
||||
for: .data(data, suggestedName: newName)
|
||||
) else {
|
||||
throw ImageProcessingError.saveFailed
|
||||
}
|
||||
|
||||
return tempURL
|
||||
}
|
||||
|
||||
private func convertToFormat(_ image: NSImage, format: ImageConversionOptions.ImageFormat, quality: Double) throws -> Data? {
|
||||
guard let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case .png:
|
||||
return bitmap.representation(using: .png, properties: [:])
|
||||
case .jpeg:
|
||||
let properties: [NSBitmapImageRep.PropertyKey: Any] = [
|
||||
.compressionFactor: quality
|
||||
]
|
||||
return bitmap.representation(using: .jpeg, properties: properties)
|
||||
case .tiff:
|
||||
let properties: [NSBitmapImageRep.PropertyKey: Any] = [
|
||||
.compressionMethod: NSNumber(value: NSBitmapImageRep.TIFFCompression.lzw.rawValue)
|
||||
]
|
||||
return bitmap.representation(using: .tiff, properties: properties)
|
||||
case .bmp:
|
||||
return bitmap.representation(using: .bmp, properties: [:])
|
||||
case .heic:
|
||||
// HEIC requires using CIContext
|
||||
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
let ciImage = CIImage(cgImage: cgImage)
|
||||
let context = CIContext()
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let options: [CIImageRepresentationOption: Any] = [
|
||||
CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String): quality
|
||||
]
|
||||
return try? context.heifRepresentation(of: ciImage, format: .RGBA8, colorSpace: colorSpace, options: options)
|
||||
}
|
||||
}
|
||||
|
||||
private func scaleImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||
guard maxDimension > 0 else { return image }
|
||||
|
||||
guard let srcCG = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return image
|
||||
}
|
||||
|
||||
let srcMax = max(srcCG.width, srcCG.height)
|
||||
if CGFloat(srcMax) <= maxDimension {
|
||||
return image // no downscaling needed
|
||||
}
|
||||
|
||||
let scale = maxDimension / CGFloat(srcMax)
|
||||
|
||||
let ciImage = CIImage(cgImage: srcCG)
|
||||
let lanczos = CIFilter.lanczosScaleTransform()
|
||||
lanczos.inputImage = ciImage
|
||||
lanczos.scale = Float(scale)
|
||||
lanczos.aspectRatio = 1.0
|
||||
|
||||
guard let output = lanczos.outputImage else {
|
||||
return image
|
||||
}
|
||||
|
||||
// Preserve the source color space for exact color matching
|
||||
let colorSpace = srcCG.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let ciContext = CIContext(options: [.workingColorSpace: colorSpace])
|
||||
|
||||
// Render using the CIContext with matching color space
|
||||
guard let dstCG = ciContext.createCGImage(output, from: output.extent, format: .RGBA8, colorSpace: colorSpace) else {
|
||||
return image
|
||||
}
|
||||
|
||||
return NSImage(cgImage: dstCG, size: NSSize(width: dstCG.width, height: dstCG.height))
|
||||
}
|
||||
|
||||
// MARK: - Create PDF
|
||||
|
||||
/// Creates a PDF from multiple image URLs
|
||||
func createPDF(from imageURLs: [URL], outputName: String? = nil) async throws -> URL? {
|
||||
guard !imageURLs.isEmpty else {
|
||||
throw ImageProcessingError.noImagesProvided
|
||||
}
|
||||
|
||||
let pdfDocument = PDFDocument()
|
||||
|
||||
for (index, url) in imageURLs.enumerated() {
|
||||
guard let image = NSImage(contentsOf: url) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let pdfPage = PDFPage(image: image)
|
||||
if let page = pdfPage {
|
||||
pdfDocument.insert(page, at: index)
|
||||
}
|
||||
}
|
||||
|
||||
guard pdfDocument.pageCount > 0 else {
|
||||
throw ImageProcessingError.pdfCreationFailed
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
let name = outputName ?? "images_\(Date().timeIntervalSince1970).pdf"
|
||||
let pdfName = name.hasSuffix(".pdf") ? name : "\(name).pdf"
|
||||
|
||||
guard let pdfData = pdfDocument.dataRepresentation() else {
|
||||
throw ImageProcessingError.pdfCreationFailed
|
||||
}
|
||||
|
||||
guard let tempURL = await TemporaryFileStorageService.shared.createTempFile(
|
||||
for: .data(pdfData, suggestedName: pdfName)
|
||||
) else {
|
||||
throw ImageProcessingError.saveFailed
|
||||
}
|
||||
|
||||
return tempURL
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Checks if a URL is an image file
|
||||
func isImageFile(_ url: URL) -> Bool {
|
||||
guard let contentType = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType else {
|
||||
return false
|
||||
}
|
||||
return contentType.conforms(to: .image)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum ImageProcessingError: LocalizedError {
|
||||
case invalidImage
|
||||
case backgroundRemovalFailed
|
||||
case conversionFailed
|
||||
case pdfCreationFailed
|
||||
case noImagesProvided
|
||||
case saveFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidImage:
|
||||
return "The file is not a valid image"
|
||||
case .backgroundRemovalFailed:
|
||||
return "Failed to remove background from image"
|
||||
case .conversionFailed:
|
||||
return "Failed to convert image format"
|
||||
case .pdfCreationFailed:
|
||||
return "Failed to create PDF from images"
|
||||
case .noImagesProvided:
|
||||
return "No images were provided"
|
||||
case .saveFailed:
|
||||
return "Failed to save processed file"
|
||||
}
|
||||
}
|
||||
}
|
||||
134
DynamicIsland/components/Shelf/Services/QuickLookService.swift
Normal file
134
DynamicIsland/components/Shelf/Services/QuickLookService.swift
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//
|
||||
// QuickLookService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-07.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import SwiftUI
|
||||
import QuickLookUI
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class QuickLookService: ObservableObject {
|
||||
@Published var urls: [URL] = []
|
||||
@Published var selectedURL: URL?
|
||||
|
||||
@Published var isQuickLookOpen: Bool = false
|
||||
|
||||
private var previewPanel: QLPreviewPanel?
|
||||
private var dataSource: QuickLookDataSource?
|
||||
private var accessingURLs: [URL] = []
|
||||
private var previewPanelObserver: Any?
|
||||
|
||||
func show(urls: [URL], selectFirst: Bool = true, slideshow: Bool = false) {
|
||||
guard !urls.isEmpty else { return }
|
||||
stopAccessingCurrentURLs()
|
||||
accessingURLs = urls.filter { url in
|
||||
if url.isFileURL {
|
||||
return url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
return true
|
||||
}
|
||||
self.urls = accessingURLs
|
||||
self.isQuickLookOpen = true
|
||||
if selectFirst {
|
||||
self.selectedURL = accessingURLs.first
|
||||
}
|
||||
// Observe the shared Quick Look preview panel closing so we can relinquish security scope
|
||||
let panel = QLPreviewPanel.shared()
|
||||
// Remove any existing observer for previous panel
|
||||
if let prev = previewPanel {
|
||||
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: prev)
|
||||
}
|
||||
previewPanel = panel
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(previewPanelWillClose(_:)), name: NSWindow.willCloseNotification, object: panel)
|
||||
}
|
||||
|
||||
func hide() {
|
||||
stopAccessingCurrentURLs()
|
||||
selectedURL = nil
|
||||
urls.removeAll()
|
||||
isQuickLookOpen = false
|
||||
if let panel = previewPanel, panel.isVisible {
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
if let panel = previewPanel {
|
||||
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel)
|
||||
previewPanel = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAccessingCurrentURLs() {
|
||||
NSLog("Stopping access to \(accessingURLs.count) URLs")
|
||||
for url in accessingURLs where url.isFileURL {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
accessingURLs.removeAll()
|
||||
// If Quick Look panel was closed externally, also remove observer and clear reference
|
||||
if let panel = previewPanel {
|
||||
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel)
|
||||
previewPanel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func showQuickLook(urls: [URL]) {
|
||||
show(urls: urls, selectFirst: true, slideshow: false)
|
||||
}
|
||||
|
||||
func updateSelection(urls: [URL]) {
|
||||
guard isQuickLookOpen else { return }
|
||||
show(urls: urls, selectFirst: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension QuickLookService {
|
||||
@objc private func previewPanelWillClose(_ notification: Notification) {
|
||||
guard let panel = notification.object as? QLPreviewPanel, panel === previewPanel else { return }
|
||||
// Ensure cleanup happens on main actor
|
||||
Task { @MainActor in
|
||||
stopAccessingCurrentURLs()
|
||||
selectedURL = nil
|
||||
urls.removeAll()
|
||||
isQuickLookOpen = false
|
||||
// Remove observer and clear reference
|
||||
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel)
|
||||
previewPanel = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct QuickLookPresenter: ViewModifier {
|
||||
@ObservedObject var service: QuickLookService
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.quickLookPreview($service.selectedURL, in: service.urls)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func quickLookPresenter(using service: QuickLookService) -> some View {
|
||||
self.modifier(QuickLookPresenter(service: service))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class QuickLookDataSource: NSObject, QLPreviewPanelDataSource, QLPreviewPanelDelegate {
|
||||
private let urls: [URL]
|
||||
|
||||
init(urls: [URL]) {
|
||||
self.urls = urls
|
||||
super.init()
|
||||
}
|
||||
|
||||
nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
|
||||
return urls.count
|
||||
}
|
||||
nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! {
|
||||
guard index >= 0 && index < urls.count else { return nil }
|
||||
return urls[index] as QLPreviewItem
|
||||
}
|
||||
}
|
||||
211
DynamicIsland/components/Shelf/Services/QuickShareService.swift
Normal file
211
DynamicIsland/components/Shelf/Services/QuickShareService.swift
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//
|
||||
// QuickShareService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Dynamic representation of a sharing provider discovered at runtime
|
||||
struct QuickShareProvider: Identifiable, Hashable, Sendable {
|
||||
var id: String
|
||||
var imageData: Data?
|
||||
var supportsRawText: Bool
|
||||
}
|
||||
|
||||
class QuickShareService: ObservableObject {
|
||||
static let shared = QuickShareService()
|
||||
|
||||
@Published var availableProviders: [QuickShareProvider] = []
|
||||
@Published var isPickerOpen = false
|
||||
private var cachedServices: [String: NSSharingService] = [:]
|
||||
// Hold security-scoped URLs during sharing
|
||||
private var sharingAccessingURLs: [URL] = []
|
||||
private var lifecycleDelegate: SharingLifecycleDelegate?
|
||||
|
||||
init() {
|
||||
Task {
|
||||
await discoverAvailableProviders()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider Discovery
|
||||
|
||||
@MainActor
|
||||
func discoverAvailableProviders() async {
|
||||
let finder = ShareServiceFinder()
|
||||
|
||||
// Use simple test items without creating actual temp files
|
||||
// This avoids issues with the Share Sheet retaining references to deleted files
|
||||
let testItems: [Any] = [
|
||||
URL(string:"http://example.com") ?? URL(fileURLWithPath: "/"),
|
||||
"Test Text" as NSString
|
||||
]
|
||||
|
||||
let services = await finder.findApplicableServices(for: testItems)
|
||||
|
||||
var providers: [QuickShareProvider] = []
|
||||
|
||||
for svc in services {
|
||||
let title = svc.title
|
||||
let imgData = svc.image.tiffRepresentation
|
||||
let supportsRawText = svc.canPerform(withItems: ["Test Text"])
|
||||
let provider = QuickShareProvider(id: title, imageData: imgData, supportsRawText: supportsRawText)
|
||||
if !providers.contains(provider) {
|
||||
providers.append(provider)
|
||||
cachedServices[title] = svc
|
||||
}
|
||||
}
|
||||
|
||||
if let idx = providers.firstIndex(where: { $0.id == "AirDrop" }) {
|
||||
let ad = providers.remove(at: idx)
|
||||
providers.insert(ad, at: 0)
|
||||
}
|
||||
|
||||
if !providers.contains(where: { $0.id == "System Share Menu" }) {
|
||||
providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true))
|
||||
}
|
||||
|
||||
self.availableProviders = providers
|
||||
|
||||
}
|
||||
|
||||
// MARK: - File Picker
|
||||
@MainActor
|
||||
func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async {
|
||||
guard !isPickerOpen else {
|
||||
print("⚠️ QuickShareService: File picker already open")
|
||||
return
|
||||
}
|
||||
|
||||
isPickerOpen = true
|
||||
SharingStateManager.shared.beginInteraction()
|
||||
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = true
|
||||
panel.title = "Select Files for \(provider.id)"
|
||||
panel.message = "Choose files to share via \(provider.id)"
|
||||
|
||||
let completion: (NSApplication.ModalResponse) -> Void = { [weak self] response in
|
||||
defer {
|
||||
self?.isPickerOpen = false
|
||||
SharingStateManager.shared.endInteraction()
|
||||
}
|
||||
|
||||
if response == .OK && !panel.urls.isEmpty {
|
||||
Task {
|
||||
await self?.shareFilesOrText(panel.urls, using: provider, from: view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = panel.runModal()
|
||||
completion(response)
|
||||
}
|
||||
|
||||
// MARK: - Sharing
|
||||
@MainActor
|
||||
func shareFilesOrText(_ items: [Any], using provider: QuickShareProvider, from view: NSView?) async {
|
||||
let fileURLs = items.compactMap { $0 as? URL }.filter { $0.isFileURL }
|
||||
// Stop any previous sharing access
|
||||
stopSharingAccessingURLs()
|
||||
// Start security-scoped access for all file URLs
|
||||
sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() }
|
||||
|
||||
// Setup lifecycle delegate to keep notch open during picker/service
|
||||
let delegate = SharingStateManager.shared.makeDelegate { [weak self] in
|
||||
self?.lifecycleDelegate = nil
|
||||
self?.stopSharingAccessingURLs()
|
||||
}
|
||||
lifecycleDelegate = delegate
|
||||
|
||||
if let svc = cachedServices[provider.id], svc.canPerform(withItems: items) {
|
||||
// For direct service path, explicitly mark service interaction start
|
||||
delegate.markServiceBegan()
|
||||
svc.delegate = delegate
|
||||
svc.perform(withItems: items)
|
||||
} else {
|
||||
let picker = NSSharingServicePicker(items: items)
|
||||
picker.delegate = delegate
|
||||
delegate.markPickerBegan()
|
||||
if let view {
|
||||
picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSharingAccessingURLs() {
|
||||
NSLog("Stopping sharing access to URLs")
|
||||
for url in sharingAccessingURLs {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
sharingAccessingURLs.removeAll()
|
||||
}
|
||||
// MARK: - SharingServiceDelegate
|
||||
|
||||
private class SharingServiceDelegate: NSObject {}
|
||||
|
||||
func shareDroppedFiles(_ providers: [NSItemProvider], using shareProvider: QuickShareProvider, from view: NSView?) async {
|
||||
var itemsToShare: [Any] = []
|
||||
var foundText: String?
|
||||
|
||||
for provider in providers {
|
||||
if let webURL = await provider.extractURL() {
|
||||
itemsToShare.append(webURL)
|
||||
} else if foundText == nil, let text = await provider.extractText() {
|
||||
foundText = text
|
||||
} else if let itemFileURL = await provider.extractItem() {
|
||||
let resolvedURL = await resolveShelfItemBookmark(for: itemFileURL) ?? itemFileURL
|
||||
itemsToShare.append(resolvedURL)
|
||||
}
|
||||
}
|
||||
|
||||
// If text was found, prioritize sharing it.
|
||||
if let text = foundText {
|
||||
if shareProvider.supportsRawText {
|
||||
await shareFilesOrText([text], using: shareProvider, from: view)
|
||||
} else {
|
||||
if let tempTextURL = await TemporaryFileStorageService.shared.createTempFile(for: .text(text)) {
|
||||
await shareFilesOrText([tempTextURL], using: shareProvider, from: view)
|
||||
TemporaryFileStorageService.shared.removeTemporaryFileIfNeeded(at: tempTextURL)
|
||||
} else {
|
||||
await shareFilesOrText([text], using: shareProvider, from: view)
|
||||
}
|
||||
}
|
||||
} else if !itemsToShare.isEmpty {
|
||||
await shareFilesOrText(itemsToShare, using: shareProvider, from: view)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveShelfItemBookmark(for fileURL: URL) async -> URL? {
|
||||
let items = await ShelfStateViewModel.shared.items
|
||||
|
||||
for itm in items {
|
||||
if let resolved = await ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: itm) {
|
||||
if resolved.standardizedFileURL.path == fileURL.standardizedFileURL.path {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
print("❌ Failed to resolve bookmark for shelf item")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Storage Extension for Provider Selection
|
||||
|
||||
extension QuickShareProvider {
|
||||
static var defaultProvider: QuickShareProvider {
|
||||
let svc = QuickShareService.shared
|
||||
|
||||
if let airdrop = svc.availableProviders.first(where: { $0.id == "AirDrop" }) {
|
||||
return airdrop
|
||||
}
|
||||
return svc.availableProviders.first ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// ShareServiceFinder.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-06.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class ShareServiceFinder: NSObject, NSSharingServicePickerDelegate {
|
||||
|
||||
@MainActor
|
||||
private var onServicesCaptured: (([NSSharingService]) -> Void)?
|
||||
|
||||
/// Returns share services asynchronously without blocking the UI
|
||||
@MainActor
|
||||
func findApplicableServices(for items: [Any], timeout: TimeInterval = 2.0) async -> [NSSharingService] {
|
||||
|
||||
let dummyView = NSView(frame: .zero)
|
||||
let picker = NSSharingServicePicker(items: items)
|
||||
picker.delegate = self
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
var didResume = false
|
||||
|
||||
// Capture services callback
|
||||
Task { @MainActor in
|
||||
self.onServicesCaptured = { services in
|
||||
guard !didResume else { return }
|
||||
didResume = true
|
||||
continuation.resume(returning: services)
|
||||
}
|
||||
}
|
||||
|
||||
picker.show(relativeTo: dummyView.bounds, of: dummyView, preferredEdge: .minY)
|
||||
|
||||
|
||||
// Timeout task
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
guard !didResume else { return }
|
||||
didResume = true
|
||||
print("Warning: timed out waiting for sharing services")
|
||||
continuation.resume(returning: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NSSharingServicePickerDelegate
|
||||
|
||||
func sharingServicePicker(_ picker: NSSharingServicePicker,
|
||||
sharingServicesForItems items: [Any],
|
||||
proposedSharingServices proposed: [NSSharingService]) -> [NSSharingService] {
|
||||
Task { @MainActor in
|
||||
self.onServicesCaptured?(proposed)
|
||||
}
|
||||
return proposed
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// ShelfActionService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-07.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// A service providing common actions for `ShelfItem`s, such as opening, revealing, or copying paths.
|
||||
@MainActor
|
||||
enum ShelfActionService {
|
||||
|
||||
static func open(_ item: ShelfItem) {
|
||||
switch item.kind {
|
||||
case .file(let bookmark):
|
||||
handleBookmarkedFile(bookmark) { url in
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
case .link(let url):
|
||||
NSWorkspace.shared.open(url)
|
||||
case .text(let string):
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(string, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
static func reveal(_ item: ShelfItem) {
|
||||
guard case .file(let bookmark) = item.kind else { return }
|
||||
handleBookmarkedFile(bookmark) { url in
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
}
|
||||
|
||||
static func copyPath(_ item: ShelfItem) {
|
||||
guard case .file(let bookmark) = item.kind else { return }
|
||||
handleBookmarkedFile(bookmark) { url in
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url.path, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
static func remove(_ item: ShelfItem) {
|
||||
ShelfStateViewModel.shared.remove(item)
|
||||
}
|
||||
|
||||
private static func handleBookmarkedFile(_ bookmarkData: Data, action: @escaping @Sendable (URL) -> Void) {
|
||||
Task {
|
||||
let bookmark = Bookmark(data: bookmarkData)
|
||||
if let url = bookmark.resolveURL() {
|
||||
url.accessSecurityScopedResource { accessibleURL in
|
||||
action(accessibleURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// ShelfDropService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-26.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ShelfDropService {
|
||||
static func items(from providers: [NSItemProvider]) async -> [ShelfItem] {
|
||||
var results: [ShelfItem] = []
|
||||
|
||||
for provider in providers {
|
||||
if let item = await processProvider(provider) {
|
||||
results.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private static func processProvider(_ provider: NSItemProvider) async -> ShelfItem? {
|
||||
if let actualFileURL = await provider.extractFileURL() {
|
||||
if let bookmark = createBookmark(for: actualFileURL) {
|
||||
return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let url = await provider.extractURL() {
|
||||
if url.isFileURL {
|
||||
if let bookmark = createBookmark(for: url) {
|
||||
return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false)
|
||||
}
|
||||
} else {
|
||||
return await ShelfItem(kind: .link(url: url), isTemporary: false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let text = await provider.extractText() {
|
||||
return await ShelfItem(kind: .text(string: text), isTemporary: false)
|
||||
}
|
||||
|
||||
if let data = await provider.loadData() {
|
||||
if let tempDataURL = await TemporaryFileStorageService.shared.createTempFile(for: .data(data, suggestedName: provider.suggestedName)),
|
||||
let bookmark = createBookmark(for: tempDataURL) {
|
||||
return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let fileURL = await provider.extractItem() {
|
||||
if let bookmark = createBookmark(for: fileURL) {
|
||||
return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func createBookmark(for url: URL) -> Data? {
|
||||
return (try? Bookmark(url: url))?.data
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
// ShelfPersistenceService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Access model types
|
||||
@_exported import struct Foundation.URL
|
||||
|
||||
|
||||
final class ShelfPersistenceService {
|
||||
static let shared = ShelfPersistenceService()
|
||||
|
||||
private let fileURL: URL
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
private init() {
|
||||
let fm = FileManager.default
|
||||
let support = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let dir = (support ?? fm.temporaryDirectory).appendingPathComponent("DynamicIsland", isDirectory: true).appendingPathComponent("Shelf", isDirectory: true)
|
||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("items.json")
|
||||
encoder.outputFormatting = [.prettyPrinted]
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
}
|
||||
|
||||
func load() -> [ShelfItem] {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return [] }
|
||||
|
||||
// Try to decode as array first (normal case)
|
||||
if let items = try? decoder.decode([ShelfItem].self, from: data) {
|
||||
return items
|
||||
}
|
||||
|
||||
// If array decoding fails, try to decode individual items
|
||||
do {
|
||||
// Parse as JSON array to get individual item data
|
||||
guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any] else {
|
||||
print("⚠️ Shelf persistence file is not a valid JSON array")
|
||||
return []
|
||||
}
|
||||
|
||||
var validItems: [ShelfItem] = []
|
||||
var failedCount = 0
|
||||
|
||||
for (index, jsonItem) in jsonArray.enumerated() {
|
||||
do {
|
||||
let itemData = try JSONSerialization.data(withJSONObject: jsonItem)
|
||||
let item = try decoder.decode(ShelfItem.self, from: itemData)
|
||||
validItems.append(item)
|
||||
} catch {
|
||||
failedCount += 1
|
||||
print("⚠️ Failed to decode shelf item at index \(index): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
if failedCount > 0 {
|
||||
print("📦 Successfully loaded \(validItems.count) shelf items, discarded \(failedCount) corrupted items")
|
||||
}
|
||||
|
||||
return validItems
|
||||
} catch {
|
||||
print("❌ Failed to parse shelf persistence file: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ items: [ShelfItem]) {
|
||||
do {
|
||||
let data = try encoder.encode(items)
|
||||
try data.write(to: fileURL, options: Data.WritingOptions.atomic)
|
||||
} catch {
|
||||
print("Failed to save shelf items: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
//
|
||||
// TemporaryFileStorageService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum TempFileType {
|
||||
case data(Data, suggestedName: String?)
|
||||
case text(String)
|
||||
case url(URL)
|
||||
}
|
||||
|
||||
class TemporaryFileStorageService {
|
||||
static let shared = TemporaryFileStorageService()
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Creates a temporary file and tracks it for manual cleanup
|
||||
func createTempFile(for type: TempFileType) async -> URL? {
|
||||
return await withCheckedContinuation { continuation in
|
||||
let result = createTempFile(for: type)
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
func removeTemporaryFileIfNeeded(at url: URL) {
|
||||
let tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
|
||||
guard url.path.hasPrefix(tempDirectory.path) else {
|
||||
print("Attempted to remove temporary file outside temp directory: \(url.path)")
|
||||
return
|
||||
}
|
||||
|
||||
let folderURL = url.deletingLastPathComponent()
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
print("Deleted file: \(url.path)")
|
||||
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: folderURL.path)
|
||||
if contents.isEmpty {
|
||||
try FileManager.default.removeItem(at: folderURL)
|
||||
print("Folder was empty, deleted folder: \(folderURL.path)")
|
||||
} else {
|
||||
print("Folder not deleted — it still contains \(contents.count) item(s).")
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Implementation
|
||||
|
||||
private func createTempFile(for type: TempFileType) -> URL? {
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let uuid = UUID().uuidString
|
||||
|
||||
switch type {
|
||||
case .data(let data, let suggestedName):
|
||||
let filename = suggestedName ?? ".dat"
|
||||
let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true)
|
||||
let fileURL = dirURL.appendingPathComponent(filename)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
|
||||
try data.write(to: fileURL)
|
||||
return fileURL
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
return nil
|
||||
}
|
||||
|
||||
case .text(let string):
|
||||
let filename = "\(uuid).txt"
|
||||
let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true)
|
||||
let fileURL = dirURL.appendingPathComponent(filename)
|
||||
|
||||
guard let data = string.data(using: .utf8) else {
|
||||
print("❌ Failed to convert text to data")
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
|
||||
try data.write(to: fileURL)
|
||||
return fileURL
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
return nil
|
||||
}
|
||||
|
||||
case .url(let url):
|
||||
let filename = "\(url.host ?? uuid).webloc"
|
||||
let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true)
|
||||
let fileURL = dirURL.appendingPathComponent(filename)
|
||||
|
||||
let weblocContent = createWeblocContent(for: url)
|
||||
guard let data = weblocContent.data(using: String.Encoding.utf8) else {
|
||||
print("❌ Failed to create webloc data")
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
|
||||
try data.write(to: fileURL)
|
||||
return fileURL
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createFile(at url: URL, data: Data) -> URL? {
|
||||
do {
|
||||
try data.write(to: url)
|
||||
return url
|
||||
} catch {
|
||||
print("❌ Failed to create temp file at \(url.path): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func createZip(from urls: [URL], suggestedName: String? = nil) async -> URL? {
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let uuid = UUID().uuidString
|
||||
let workingDir = tempDir.appendingPathComponent("zip_\(uuid)", isDirectory: true)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
print("❌ Failed to create zip working directory: \(error)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to run zip process
|
||||
func runZip(arguments: [String], currentDirectory: URL) -> Bool {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
proc.arguments = arguments
|
||||
proc.currentDirectoryURL = currentDirectory
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
return proc.terminationStatus == 0
|
||||
} catch {
|
||||
print("❌ Failed to run zip: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Single-item optimization: do not copy contents into the working dir.
|
||||
if urls.count == 1, let src = urls.first {
|
||||
let isDir = (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
let baseName = src.lastPathComponent
|
||||
let archiveName: String
|
||||
if isDir {
|
||||
// Folder: name as FolderName.zip and include the folder itself in the archive
|
||||
archiveName = "\(baseName).zip"
|
||||
let archiveURL = workingDir.appendingPathComponent(archiveName)
|
||||
// Run zip from the parent directory so the folder is stored as top-level entry
|
||||
let parent = src.deletingLastPathComponent()
|
||||
let args = ["-r", "-q", archiveURL.path, baseName]
|
||||
let ok = runZip(arguments: args, currentDirectory: parent)
|
||||
if ok {
|
||||
return archiveURL
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// File: include the file only (no parent folders). Name should include original extension.
|
||||
archiveName = "\(baseName).zip"
|
||||
let archiveURL = workingDir.appendingPathComponent(archiveName)
|
||||
let parent = src.deletingLastPathComponent()
|
||||
// -j to junk paths and store only the file
|
||||
let args = ["-j", "-q", archiveURL.path, baseName]
|
||||
let ok = runZip(arguments: args, currentDirectory: parent)
|
||||
if ok {
|
||||
return archiveURL
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-item: copy items into working dir (so their relative structure is preserved), zip, then remove copies.
|
||||
for src in urls {
|
||||
let dest = workingDir.appendingPathComponent(src.lastPathComponent)
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
// Avoid collision by appending a suffix
|
||||
let unique = "\(UUID().uuidString)_\(src.lastPathComponent)"
|
||||
try FileManager.default.copyItem(at: src, to: workingDir.appendingPathComponent(unique))
|
||||
} else {
|
||||
try FileManager.default.copyItem(at: src, to: dest)
|
||||
}
|
||||
} catch {
|
||||
print("⚠️ Failed to copy \(src.path) to working dir: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
let archiveName = suggestedName ?? "Archive.zip"
|
||||
let archiveURL = workingDir.appendingPathComponent(archiveName)
|
||||
let args = ["-r", "-q", archiveURL.path, "."]
|
||||
let ok = runZip(arguments: args, currentDirectory: workingDir)
|
||||
if ok {
|
||||
// Remove the copied (uncompressed) items so the temp folder contains only the archive
|
||||
do {
|
||||
let contents = try FileManager.default.contentsOfDirectory(at: workingDir, includingPropertiesForKeys: nil)
|
||||
for file in contents {
|
||||
if file.standardizedFileURL != archiveURL.standardizedFileURL {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("⚠️ Failed to cleanup working directory after zip: \(error)")
|
||||
}
|
||||
return archiveURL
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Creation Helpers
|
||||
|
||||
|
||||
private func createWeblocContent(for url: URL) -> String {
|
||||
return """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>URL</key>
|
||||
<string>\(url.absoluteString)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// ThumbnailService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-07.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import QuickLookThumbnailing
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
actor ThumbnailService {
|
||||
static let shared = ThumbnailService()
|
||||
|
||||
private var cache: [String: NSImage] = [:]
|
||||
private var pendingRequests: [String: Task<NSImage?, Never>] = [:]
|
||||
private let thumbnailGenerator = QLThumbnailGenerator.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
func thumbnail(for url: URL, size: CGSize) async -> NSImage? {
|
||||
let cacheKey = "\(url.path)_\(size.width)x\(size.height)"
|
||||
|
||||
if let cached = cache[cacheKey] {
|
||||
return cached
|
||||
}
|
||||
|
||||
if let pending = pendingRequests[cacheKey] {
|
||||
return await pending.value
|
||||
}
|
||||
|
||||
let task = Task<NSImage?, Never> {
|
||||
let thumbnail = await generateQuickLookThumbnail(for: url, size: size)
|
||||
if let thumbnail = thumbnail {
|
||||
cache[cacheKey] = thumbnail
|
||||
}
|
||||
pendingRequests[cacheKey] = nil
|
||||
return thumbnail
|
||||
}
|
||||
|
||||
pendingRequests[cacheKey] = task
|
||||
return await task.value
|
||||
}
|
||||
|
||||
func clearCache() {
|
||||
cache.removeAll()
|
||||
}
|
||||
|
||||
func clearCache(for url: URL) {
|
||||
cache = cache.filter { !$0.key.starts(with: url.path) }
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func generateQuickLookThumbnail(for url: URL, size: CGSize) async -> NSImage? {
|
||||
let scale = await MainActor.run { NSScreen.main?.backingScaleFactor ?? 2.0 }
|
||||
|
||||
return await url.accessSecurityScopedResource { scopedURL in
|
||||
NSLog("🔐 ThumbnailService: obtaining security scope for \(scopedURL.path)")
|
||||
let request = QLThumbnailGenerator.Request(
|
||||
fileAt: scopedURL,
|
||||
size: size,
|
||||
scale: scale,
|
||||
representationTypes: .all
|
||||
)
|
||||
request.iconMode = true
|
||||
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<NSImage?, Never>) in
|
||||
thumbnailGenerator.generateBestRepresentation(for: request) { representation, error in
|
||||
if let rep = representation {
|
||||
NSLog("🔍 ThumbnailService: generated thumbnail for \(scopedURL.path)")
|
||||
continuation.resume(returning: rep.nsImage)
|
||||
} else {
|
||||
if let err = error {
|
||||
NSLog("⚠️ ThumbnailService: thumbnail error for \(scopedURL.path): \(err.localizedDescription)")
|
||||
}
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension QLThumbnailRepresentation {
|
||||
var nsImage: NSImage {
|
||||
return NSImage(cgImage: self.cgImage, size: self.cgImage.size)
|
||||
}
|
||||
}
|
||||
|
||||
extension CGImage {
|
||||
var size: NSSize {
|
||||
return NSSize(width: self.width, height: self.height)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue