diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index a2ce360..a7268f6 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -440,8 +440,8 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks", + "$(SDKROOT)/System/Library/Frameworks", + "$(SDKROOT)/System/Library/PrivateFrameworks", ); }; name = Debug; @@ -509,8 +509,8 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks", + "$(SDKROOT)/System/Library/Frameworks", + "$(SDKROOT)/System/Library/PrivateFrameworks", ); }; name = Release; diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index d5c4651..e1169c7 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -14,7 +14,7 @@ struct DynamicIslandHeader: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var clipboardManager = ClipboardManager.shared - @ObservedObject var tvm = TrayDrop.shared + @ObservedObject var shelfState = ShelfStateViewModel.shared @ObservedObject var timerManager = TimerManager.shared @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @State private var showClipboardPopover = false @@ -26,10 +26,9 @@ struct DynamicIslandHeader: View { HStack(spacing: 0) { if !Defaults[.enableMinimalisticUI] { HStack { - if (!tvm.isEmpty || coordinator.alwaysShowTabs) && Defaults[.dynamicShelf] { + let shouldShowTabs = coordinator.alwaysShowTabs || vm.notchState == .open || !shelfState.items.isEmpty + if shouldShowTabs { TabSelectionView() - } else if vm.notchState == .open { - EmptyView() } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/DynamicIsland/components/Notch/NotchShelfView.swift b/DynamicIsland/components/Notch/NotchShelfView.swift index aa925b9..dafd518 100644 --- a/DynamicIsland/components/Notch/NotchShelfView.swift +++ b/DynamicIsland/components/Notch/NotchShelfView.swift @@ -3,62 +3,10 @@ import SwiftUI struct NotchShelfView: View { @EnvironmentObject var vm: DynamicIslandViewModel - @ObservedObject var tvm = TrayDrop.shared var body: some View { - HStack { - AirDropView() - panel - .onDrop(of: [.data], isTargeted: $vm.dropZoneTargeting) { providers in - vm.dropEvent = true - DispatchQueue.global().async { - tvm.load(providers) - } - return true - } - } - } - - var panel: some View { - RoundedRectangle(cornerRadius: 16) - .strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10])) - .foregroundStyle(.white.opacity(0.1)) - .overlay { - content - .padding() - } - .animation(vm.animation, value: tvm.items) - .animation(vm.animation, value: tvm.isLoading) - } - - var content: some View { - Group { - if tvm.isEmpty { - VStack(spacing: 10) { - Image(systemName: "tray.and.arrow.down") - .symbolVariant(.fill) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.white, .gray) - .imageScale(.large) - - Text("Drop files here") - .foregroundStyle(.gray) - .font(.system(.title3, design: .rounded)) - .fontWeight(.medium) - } - } else { - ScrollView(.horizontal) { - HStack(spacing: spacing) { - ForEach(tvm.items) { item in - DropItemView(item: item) - } - } - .padding(spacing) - } - .padding(-spacing) - .scrollIndicators(.never) - } - } + ShelfView() + .environmentObject(vm) } } diff --git a/DynamicIsland/components/Shelf/Models/Bookmark.swift b/DynamicIsland/components/Shelf/Models/Bookmark.swift new file mode 100644 index 0000000..3a0bff4 --- /dev/null +++ b/DynamicIsland/components/Shelf/Models/Bookmark.swift @@ -0,0 +1,94 @@ +// +// Bookmark.swift +// boringNotch +// +// Created by Alexander on 2025-10-08. +// + +import Foundation +import AppKit + +struct Bookmark: Sendable, Equatable, Codable { + let data: Data + + init(data: Data) { + self.data = data + } + + init(url: URL) throws { + guard url.isFileURL, FileManager.default.fileExists(atPath: url.path) else { + throw NSError(domain: "Bookmark", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not a valid file URL or file does not exist at \(url.path)"]) + } + do { + let bookmark = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + NSLog("✅ Successfully created bookmark for \(url.path)") + self.data = bookmark + } catch { + NSLog("❌ Failed to create bookmark for \(url.path): \(error.localizedDescription)") + throw error + } + } + + func resolve() -> (url: URL?, refreshedData: Data?) { + guard !data.isEmpty else { return (nil, nil) } + var isStale = false + do { + let url = try URL( + resolvingBookmarkData: data, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + if isStale, let newData = try? url.bookmarkData(options: [.withSecurityScope]) { + NSLog("⚠️ Bookmark was stale for \(url.path), refreshed") + return (url, newData) + } + return (url, nil) + } catch { + NSLog("❌ Failed to resolve bookmark: \(error.localizedDescription)") + return (nil, nil) + } + } + + func resolveURL() -> URL? { + return resolve().url + } + + var refreshedData: Data? { + return resolve().refreshedData + } + + static func update(in items: inout [ShelfItem], for item: ShelfItem, newBookmark: Data) { + guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } + guard case .file = items[idx].kind else { return } + items[idx].kind = ShelfItemKind.file(bookmark: newBookmark) + } + + func validate() async -> Bool { + let (url, _) = resolve() + guard let url = url else { return false } + return url.accessSecurityScopedResource { url in + FileManager.default.fileExists(atPath: url.path) + } + } + + func withAccess(_ block: @Sendable (URL) async throws -> T) async rethrows -> T? { + let url = resolveURL() + guard let url = url else { return nil } + return try await url.accessSecurityScopedResource { url in + try await block(url) + } + } + + func withAccess(_ block: (URL) throws -> T) rethrows -> T? { + let url = resolveURL() + guard let url = url else { return nil } + return try url.accessSecurityScopedResource { url in + try block(url) + } + } +} diff --git a/DynamicIsland/components/Shelf/Models/ShelfItem.swift b/DynamicIsland/components/Shelf/Models/ShelfItem.swift new file mode 100644 index 0000000..0205e1c --- /dev/null +++ b/DynamicIsland/components/Shelf/Models/ShelfItem.swift @@ -0,0 +1,228 @@ +// +// ShelfItem.swift +// boringNotch +// +// Created by Alexander on 2025-09-24. +// + +import AppKit +import Foundation + +enum ShelfItemKind: Codable, Equatable, Sendable { + case file(bookmark: Data) + case text(string: String) + case link(url: URL) + + enum CodingKeys: String, CodingKey { case type, value } + + enum KindTag: String, Codable { case file, text, link } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(KindTag.self, forKey: .type) + switch type { + case .file: + let data = try container.decode(Data.self, forKey: .value) + self = .file(bookmark: data) + case .text: + self = .text(string: try container.decode(String.self, forKey: .value)) + case .link: + self = .link(url: try container.decode(URL.self, forKey: .value)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .file(let bookmark): + try container.encode(KindTag.file, forKey: .type) + try container.encode(bookmark, forKey: .value) + case .text(let string): + try container.encode(KindTag.text, forKey: .type) + try container.encode(string, forKey: .value) + case .link(let url): + try container.encode(KindTag.link, forKey: .type) + try container.encode(url, forKey: .value) + } + } + +} + +@MainActor +struct ShelfItem: Identifiable, Codable, Equatable, Sendable { + let id: UUID + var kind: ShelfItemKind + var isTemporary: Bool + init(id: UUID = UUID(), kind: ShelfItemKind, isTemporary: Bool = false) { + self.id = id + self.kind = kind + self.isTemporary = isTemporary + } + + var displayName: String { + switch kind { + case .file(let bookmarkData): + let bookmark = Bookmark(data: bookmarkData) + guard let resolvedURL = bookmark.resolveURL() else { return "" } + + // Check for stored data files (text blocks, weblocs, etc.) to provide friendly names + if resolvedURL.pathExtension.lowercased() == "json" && resolvedURL.path.contains("TextBlocks") { + do { + let data = try Data(contentsOf: resolvedURL) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + struct TextBlockData: Codable { + let content: String + let title: String? + var displayTitle: String { + if let title = title, !title.isEmpty { + return title + } + let firstLine = content.components(separatedBy: .newlines).first ?? content + if firstLine.count > 50 { + return String(firstLine.prefix(47)) + "..." + } + return firstLine + } + } + if let textData = try? decoder.decode(TextBlockData.self, from: data) { + return textData.displayTitle + } + } catch { + // Fall through to default naming + } + } else if resolvedURL.pathExtension.lowercased() == "webloc" && resolvedURL.path.contains("WebLocs") { + do { + let data = try Data(contentsOf: resolvedURL) + if let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let urlString = plist["URL"] as? String { + let title = plist["Title"] as? String + return title ?? urlString + } + } catch { + // Fall through to default naming + } + } + return (try? resolvedURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? resolvedURL.lastPathComponent + case .text(let string): + return string.trimmingCharacters(in: .whitespacesAndNewlines) + case .link(let url): + let s = url.absoluteString + if s.hasPrefix("https://") { + return String(s.dropFirst("https://".count)) + } else if s.hasPrefix("http://") { + return String(s.dropFirst("http://".count)) + } else { + return s + } + } + } + + var fileURL: URL? { + guard case .file = kind else { return nil } + return ShelfStateViewModel.shared.resolveFileURL(for: self) + } + + var URL: URL? { + if case let .file(bookmark) = kind { return resolvedContext(for: bookmark)?.url } + else if case let .link(url) = kind { return url } + else { return nil } + } + + var icon: NSImage { + guard case .file = kind else { + return Self.thumbnailSymbolImage(systemName: kind.iconSymbolName) ?? NSImage() + } + if let resolvedURL = ShelfStateViewModel.shared.resolveFileURL(for: self) { + return NSWorkspace.shared.icon(forFile: resolvedURL.path) + } + return NSImage() + } + + + func cleanupStoredData() { + guard case let .file(bookmark) = kind, + let context = resolvedContext(for: bookmark) else { return } + + let url = context.url + + // Handle temporary files + if isTemporary { + TemporaryFileStorageService.shared.removeTemporaryFileIfNeeded(at: url) + return + } + } +} + +private extension ShelfItem { + static func thumbnailSymbolImage( + systemName: String, + size: CGSize = CGSize(width: 64, height: 80), + symbolPointSize: CGFloat = 38, + backgroundColor: NSColor = NSColor.white, + symbolColor: NSColor = NSColor.labelColor + ) -> NSImage? { + let image = NSImage(size: size) + image.lockFocus() + defer { image.unlockFocus() } + + let rect = CGRect(origin: .zero, size: size) + let cornerRadius = min(size.width, size.height) * 0.06 + let path = NSBezierPath(roundedRect: rect.insetBy(dx: 2, dy: 2), xRadius: cornerRadius, yRadius: cornerRadius) + backgroundColor.setFill() + path.fill() + + if let symbol = NSImage(systemSymbolName: systemName, accessibilityDescription: nil) { + let symbolSize = CGSize(width: symbolPointSize, height: symbolPointSize) + let symbolOrigin = CGPoint( + x: (size.width - symbolSize.width) / 2, + y: (size.height - symbolSize.height) / 2 + ) + let symbolRect = CGRect(origin: symbolOrigin, size: symbolSize) + symbol.draw(in: symbolRect) + } + + return image + } +} + +// MARK: - Identity key for deduplication +extension ShelfItem { + var identityKey: String { + switch kind { + case .file(let bookmark): + if let url = resolvedContext(for: bookmark)?.url { + return "file://" + url.standardizedFileURL.path + } + return "file://missing/" + bookmark.base64EncodedString() + case .link(let u): + return "link://" + u.absoluteString + case .text(let s): + return "text://" + s + } + } +} + +// MARK: - Private helpers +private extension ShelfItemKind { + var iconSymbolName: String { + switch self { + case .file: + return "questionmark.circle" + case .text: + return "text.justifyleft" + case .link: + return "link" + } + } +} + +private extension ShelfItem { + func resolvedContext(for bookmarkData: Data) -> (url: URL, bookmark: Data)? { + let bookmark = Bookmark(data: bookmarkData) + if let url = bookmark.resolveURL() { + return (url, bookmark.refreshedData ?? bookmarkData) + } + return nil + } +} diff --git a/DynamicIsland/components/Shelf/Services/ImageProcessingService.swift b/DynamicIsland/components/Shelf/Services/ImageProcessingService.swift new file mode 100644 index 0000000..7ce3087 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ImageProcessingService.swift @@ -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" + } + } +} diff --git a/DynamicIsland/components/Shelf/Services/QuickLookService.swift b/DynamicIsland/components/Shelf/Services/QuickLookService.swift new file mode 100644 index 0000000..172fe88 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/QuickLookService.swift @@ -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 + } +} diff --git a/DynamicIsland/components/Shelf/Services/QuickShareService.swift b/DynamicIsland/components/Shelf/Services/QuickShareService.swift new file mode 100644 index 0000000..b724692 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/QuickShareService.swift @@ -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) + } +} diff --git a/DynamicIsland/components/Shelf/Services/ShareServiceFinder.swift b/DynamicIsland/components/Shelf/Services/ShareServiceFinder.swift new file mode 100644 index 0000000..3d796e5 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ShareServiceFinder.swift @@ -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 + } +} diff --git a/DynamicIsland/components/Shelf/Services/ShelfActionService.swift b/DynamicIsland/components/Shelf/Services/ShelfActionService.swift new file mode 100644 index 0000000..0bb773b --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ShelfActionService.swift @@ -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) + } + } + } + } +} + diff --git a/DynamicIsland/components/Shelf/Services/ShelfDropService.swift b/DynamicIsland/components/Shelf/Services/ShelfDropService.swift new file mode 100644 index 0000000..d000665 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ShelfDropService.swift @@ -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 + } +} + diff --git a/DynamicIsland/components/Shelf/Services/ShelfPersistenceService.swift b/DynamicIsland/components/Shelf/Services/ShelfPersistenceService.swift new file mode 100644 index 0000000..c933b0c --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ShelfPersistenceService.swift @@ -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)") + } + } +} diff --git a/DynamicIsland/components/Shelf/Services/TemporaryFileStorageService.swift b/DynamicIsland/components/Shelf/Services/TemporaryFileStorageService.swift new file mode 100644 index 0000000..e06638a --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/TemporaryFileStorageService.swift @@ -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 """ + + + + + URL + \(url.absoluteString) + + + """ + } +} diff --git a/DynamicIsland/components/Shelf/Services/ThumbnailService.swift b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift new file mode 100644 index 0000000..3823872 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift @@ -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] = [:] + 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 { + 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) 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) + } +} diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift new file mode 100644 index 0000000..0dbe232 --- /dev/null +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift @@ -0,0 +1,1112 @@ +// +// ShelfItemViewModel.swift +// boringNotch +// +// Created by Alexander on 2025-09-24. +// + +import Foundation +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import CoreServices +import ObjectiveC + +@MainActor +final class ShelfItemViewModel: ObservableObject { + @Published private(set) var item: ShelfItem + @Published var thumbnail: NSImage? + @Published var isDropTargeted: Bool = false + @Published var isRenaming: Bool = false + @Published var draftTitle: String = "" + private var sharingLifecycle: SharingLifecycleDelegate? + private var quickShareLifecycle: SharingLifecycleDelegate? + private var sharingAccessingURLs: [URL] = [] + private static var copiedURLs: [URL] = [] + + private let selection = ShelfSelectionModel.shared + + init(item: ShelfItem) { + self.item = item + self.draftTitle = item.displayName + Task { await loadThumbnail() } + } + + var isSelected: Bool { selection.isSelected(item.id) } + + func loadThumbnail() async { + guard let url = item.fileURL else { return } + if let image = await ThumbnailService.shared.thumbnail(for: url, size: CGSize(width: 56, height: 56)) { + self.thumbnail = image + } + } + + // MARK: - Drag & Drop helpers + func dragItemProvider() -> NSItemProvider { + let selectedItems = selection.selectedItems(in: ShelfStateViewModel.shared.items) + if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) { + return createMultiItemProvider(for: selectedItems) + } + return createItemProvider(for: item) + } + + private func createItemProvider(for item: ShelfItem) -> NSItemProvider { + switch item.kind { + case .file: + let provider = NSItemProvider() + if let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) { + provider.registerObject(url as NSURL, visibility: .all) + } else { + provider.registerObject(item.displayName as NSString, visibility: .all) + } + return provider + case .text(let string): + return NSItemProvider(object: string as NSString) + case .link(let url): + return NSItemProvider(object: url as NSURL) + } + } + + private func createMultiItemProvider(for items: [ShelfItem]) -> NSItemProvider { + let provider = NSItemProvider() + var urls: [URL] = [] + var textItems: [String] = [] + for item in items { + switch item.kind { + case .file: + if let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) { + urls.append(url) + } else { + textItems.append(item.displayName) + } + case .text(let string): + textItems.append(string) + case .link: + break + } + } + if !urls.isEmpty { + for url in urls { + provider.registerObject(url as NSURL, visibility: .all) + } + } + if !textItems.isEmpty { + provider.registerObject(textItems.joined(separator: "\n") as NSString, visibility: .all) + } + return provider + } + + // MARK: - Actions + func handleClick(event: NSEvent, view: NSView) { + let flags = event.modifierFlags + if flags.contains(.shift) { + selection.shiftSelect(to: item, in: ShelfStateViewModel.shared.items) + } else if flags.contains(.command) { + selection.toggle(item) + } else if flags.contains(.control) { + handleRightClick(event: event, view: view) + } else { + if !selection.isSelected(item.id) { selection.selectSingle(item) } + } + if event.clickCount == 2 { handleDoubleClick() } + } + + func handleRightClick(event: NSEvent, view: NSView) { + if !selection.isSelected(item.id) { selection.selectSingle(item) } + presentContextMenu(event: event, in: view) + } + + func handleDoubleClick() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.open(it) } + } + + func shareItem(from view: NSView?) { + Task { + var itemsToShare: [Any] = [] + var fileURLs: [URL] = [] + if case .text(let text) = item.kind { + itemsToShare.append(text) + } else { + for item in ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) { + switch item.kind { + case .file: + // Use immediate update for user-initiated share action + if let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) { + itemsToShare.append(url) + fileURLs.append(url) + } + case .text(let string): + itemsToShare.append(string) + case .link(let url): + itemsToShare.append(url) + } + } + } + + guard !itemsToShare.isEmpty else { return } + + stopSharingAccessingURLs() + // Start security-scoped access for all file URLs and keep it active during sharing + sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + + // Create and retain lifecycle delegate for the entire share operation + let lifecycle = SharingStateManager.shared.makeDelegate { [weak self] in + self?.sharingLifecycle = nil + self?.stopSharingAccessingURLs() + } + self.sharingLifecycle = lifecycle + + let picker = NSSharingServicePicker(items: itemsToShare) + picker.delegate = lifecycle + lifecycle.markPickerBegan() + if let view { + picker.show(relativeTo: .zero, of: view, preferredEdge: .minY) + } + } + } + + private func stopSharingAccessingURLs() { + for url in sharingAccessingURLs { + url.stopAccessingSecurityScopedResource() + } + sharingAccessingURLs.removeAll() + } + + /// Call this closure to request a QuickLook preview for the given URLs. + var onQuickLookRequest: (([URL]) -> Void)? + + // MARK: - Context Menu helpers (extracted from view) + func loadOpenWithApps() -> [URL] { + // Support both files and link items. For link items we ask NSWorkspace for apps that can open the URL (browsers). + if let fileURL = item.fileURL { + var results: [URL] = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) + if results.isEmpty { + if let uti = try? fileURL.resourceValues(forKeys: [.contentTypeKey]).contentType { + results = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } + } + let unique = Array(Set(results)) + let sorted = unique.sorted { appDisplayName(for: $0) < appDisplayName(for: $1) } + return sorted + } else if case .link(let url) = item.kind { + var results: [URL] = NSWorkspace.shared.urlsForApplications(toOpen: url) + if results.isEmpty { + if let uti = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { + results = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } + } + let unique = Array(Set(results)) + let sorted = unique.sorted { appDisplayName(for: $0) < appDisplayName(for: $1) } + return sorted + } + return [] + } + + private func ensureContextMenuSelection() { + if !selection.isSelected(item.id) { selection.selectSingle(item) } + } + + func presentContextMenu(event: NSEvent, in view: NSView) { + ensureContextMenuSelection() + let menu = NSMenu() + + func addMenuItem(title: String) { + let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") + menu.addItem(mi) + } + + let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let selectedFileURLs = selectedItems.compactMap { $0.fileURL } + let selectedLinkURLs: [URL] = selectedItems.compactMap { itm in + if case .link(let url) = itm.kind { return url } + return nil + } + let selectedFolderURLs = selectedFileURLs.filter { isDirectory($0) } + // URLs valid for Open/Open With (exclude folders) + let selectedOpenableURLs = selectedItems.compactMap { itm -> URL? in + if let u = itm.fileURL { return isDirectory(u) ? nil : u } + if case .link(let url) = itm.kind { return url } + return nil + } + + if !selectedOpenableURLs.isEmpty { + addMenuItem(title: "Open") + } + + if !selectedOpenableURLs.isEmpty { + let openWith = NSMenuItem(title: "Open With", action: nil, keyEquivalent: "") + let submenu = NSMenu() + + // Choose a representative URL to compute apps (prefer current item if not a folder) + let baseURLForApps: URL? = { + if let u = item.fileURL, !isDirectory(u) { return u } + if case .link(let u) = item.kind { return u } + return selectedOpenableURLs.first + }() + + let openWithApps: [URL] = { + guard let u = baseURLForApps else { return [] } + if u.isFileURL { + var results = NSWorkspace.shared.urlsForApplications(toOpen: u) + if results.isEmpty, let uti = try? u.resourceValues(forKeys: [.contentTypeKey]).contentType { + results = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } + return Array(Set(results)) + } else { + return Array(Set(NSWorkspace.shared.urlsForApplications(toOpen: u))) + } + }() + let defaultApp = defaultAppURL() + + if openWithApps.isEmpty { + let noApps = NSMenuItem(title: "No Compatible Apps Found", action: nil, keyEquivalent: "") + noApps.isEnabled = false + submenu.addItem(noApps) + } else { + if let defaultApp = defaultApp { + let appName = appDisplayName(for: defaultApp) + let def = NSMenuItem(title: appName, action: nil, keyEquivalent: "") + def.representedObject = defaultApp + def.image = nsAppIcon(for: defaultApp, size: 16) + + let title = NSMutableAttributedString(string: appName, attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.labelColor + ]) + let defaultPart = NSAttributedString(string: " (default)", attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.secondaryLabelColor + ]) + title.append(defaultPart) + def.attributedTitle = title + submenu.addItem(def) + + if openWithApps.count > 1 || !openWithApps.contains(defaultApp) { + submenu.addItem(NSMenuItem.separator()) + } + } + for appURL in openWithApps where appURL != defaultApp { + let mi = NSMenuItem(title: appDisplayName(for: appURL), action: nil, keyEquivalent: "") + mi.representedObject = appURL + mi.image = nsAppIcon(for: appURL, size: 16) + submenu.addItem(mi) + } + } + + submenu.addItem(NSMenuItem.separator()) + let other = NSMenuItem(title: "Other…", action: nil, keyEquivalent: "") + other.representedObject = "__OTHER__" + submenu.addItem(other) + + openWith.submenu = submenu + menu.addItem(openWith) + } + + if !selectedFileURLs.isEmpty { addMenuItem(title: "Show in Finder") } + // Allow Quick Look for files and link URLs + if !selectedFileURLs.isEmpty || !selectedLinkURLs.isEmpty { + // Add Quick Look menu item + let quickLookItem = NSMenuItem(title: "Quick Look", action: nil, keyEquivalent: "") + menu.addItem(quickLookItem) + + // Add Slideshow as alternate menu item (shown when Option key is held) + let slideshowItem = NSMenuItem(title: "Quick Look", action: nil, keyEquivalent: "") + slideshowItem.isAlternate = true + slideshowItem.keyEquivalentModifierMask = [.option] + menu.addItem(slideshowItem) + } + + menu.addItem(NSMenuItem.separator()) + addMenuItem(title: "Share…") + + // Add image processing options for image files grouped under "Image Actions" + let imageURLs = selectedFileURLs.filter { ImageProcessingService.shared.isImageFile($0) } + if !imageURLs.isEmpty { + menu.addItem(NSMenuItem.separator()) + + let imageActions = NSMenuItem(title: "Image Actions", action: nil, keyEquivalent: "") + let imageSubmenu = NSMenu() + + // Remove Background - only for single images + if imageURLs.count == 1 { + let removeBg = NSMenuItem(title: "Remove Background", action: nil, keyEquivalent: "") + imageSubmenu.addItem(removeBg) + } + + // Convert Image - only for single images + if imageURLs.count == 1 { + let convertItem = NSMenuItem(title: "Convert Image…", action: nil, keyEquivalent: "") + imageSubmenu.addItem(convertItem) + } + + // Create PDF - for one or more images + let createPDF = NSMenuItem(title: "Create PDF", action: nil, keyEquivalent: "") + imageSubmenu.addItem(createPDF) + + imageActions.submenu = imageSubmenu + menu.addItem(imageActions) + menu.addItem(NSMenuItem.separator()) + } + + // Add compression option for files/folders (single or multiple) + if !selectedFileURLs.isEmpty { + let compressItem = NSMenuItem(title: "Compress", action: nil, keyEquivalent: "") + menu.addItem(compressItem) + } + + if selectedItems.count == 1, case .file(_) = item.kind { addMenuItem(title: "Rename") } + + // Always show "Copy" for all item types + addMenuItem(title: "Copy") + // If there are file URLs, add "Copy Path" as an alternate menu item (Option key) + if !selectedFileURLs.isEmpty { + let copyPathItem = NSMenuItem(title: "Copy Path", action: nil, keyEquivalent: "") + copyPathItem.isAlternate = true + copyPathItem.keyEquivalentModifierMask = [.option] + menu.addItem(copyPathItem) + } + + menu.addItem(NSMenuItem.separator()) + addMenuItem(title: "Remove") + + let actionTarget = MenuActionTarget(item: item, view: view, viewModel: self) + + for menuItem in menu.items { + if menuItem.isSeparatorItem { continue } + menuItem.target = actionTarget + menuItem.action = #selector(MenuActionTarget.handle(_:)) + + if let submenu = menuItem.submenu { + for subItem in submenu.items { + if !subItem.isSeparatorItem { + subItem.target = actionTarget + subItem.action = #selector(MenuActionTarget.handle(_:)) + } + } + } + } + + menu.retainActionTarget(actionTarget) + + NSMenu.popUpContextMenu(menu, with: event, for: view) + } + + private func isDirectory(_ url: URL) -> Bool { + return url.accessSecurityScopedResource { scoped in + (try? scoped.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } + } + + private final class MenuActionTarget: NSObject { + let item: ShelfItem + weak var view: NSView? + unowned let viewModel: ShelfItemViewModel + + // Keep associated objects (like accessory view handlers) without magic keys + private static var sliderHandlerAssoc = AssociatedObject() + + init(item: ShelfItem, view: NSView, viewModel: ShelfItemViewModel) { + self.item = item + self.view = view + self.viewModel = viewModel + } + + @MainActor @objc func handle(_ sender: NSMenuItem) { + let title = sender.title + + if let marker = sender.representedObject as? String, marker == "__OTHER__" { + openWithPanel() + return + } + + if let appURL = sender.representedObject as? URL { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + + Task { + var allSelectedURLs: [URL] = [] + + for itm in selected { + if let fileURL = itm.fileURL { + allSelectedURLs.append(fileURL) + } else if case .link(let url) = itm.kind { + allSelectedURLs.append(url) + } + } + + guard !allSelectedURLs.isEmpty else { return } + + let config = NSWorkspace.OpenConfiguration() + + let fileURLs = allSelectedURLs.filter { $0.isFileURL } + do { + if !fileURLs.isEmpty { + _ = try await fileURLs.accessSecurityScopedResources { _ in + try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) + } + } else { + try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) + } + } catch { + print("❌ Failed to open with application: \(error.localizedDescription)") + } + } + return + } + + switch title { + case "Quick Look": + // Handle all selected items for Quick Look, not just the clicked item + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let urls: [URL] = selected.compactMap { item in + if let fileURL = item.fileURL { + return fileURL + } + if case .link(let url) = item.kind { + return url + } + return nil + } + if !urls.isEmpty { + viewModel.onQuickLookRequest?(urls) + } + + case "Open": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.open(it) } + + case "Share…": + viewModel.shareItem(from: view) + + case "Rename": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + if selected.count == 1, let single = selected.first { showRenameDialog(for: single) } + + case "Show in Finder": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + Task { + let urls = await selected.asyncCompactMap { item -> URL? in + if case .file = item.kind { + // Use immediate update for user-initiated menu action + return await ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + } + return nil + } + if !urls.isEmpty { + await urls.accessSecurityScopedResources { accessibleURLs in + NSWorkspace.shared.activateFileViewerSelecting(accessibleURLs) + } + } + } + + case "Copy Path": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let paths = selected.compactMap { $0.fileURL?.path } + if !paths.isEmpty { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(paths.joined(separator: "\n"), forType: .string) + } + + case "Copy": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let pb = NSPasteboard.general + + // Stop accessing previously copied URLs + for url in ShelfItemViewModel.copiedURLs { + url.stopAccessingSecurityScopedResource() + } + ShelfItemViewModel.copiedURLs.removeAll() + + pb.clearContents() + Task { + let fileURLs = await selected.asyncCompactMap { item -> URL? in + if case .file = item.kind { + return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + } + return nil + } + if !fileURLs.isEmpty { + // Start security-scoped access for all URLs and keep them active + ShelfItemViewModel.copiedURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + NSLog("🔐 Started security-scoped access for \(ShelfItemViewModel.copiedURLs.count) copied files") + + // Write to pasteboard + pb.writeObjects(fileURLs as [NSURL]) + } else { + let strings = selected.map { $0.displayName } + if !strings.isEmpty { + pb.setString(strings.joined(separator: "\n"), forType: .string) + } + } + } + + case "Remove": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.remove(it) } + + case "Remove Background": + handleRemoveBackground() + + case "Convert Image…": + showConvertImageDialog() + + case "Create PDF": + handleCreatePDF() + + case "Compress": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let fileURLs = selected.compactMap { $0.fileURL } + guard !fileURLs.isEmpty else { break } + + Task { + do { + // Create ZIP in a temporary location while holding access to selected resources + if let zipTempURL = try await fileURLs.accessSecurityScopedResources(accessor: { urls in + await TemporaryFileStorageService.shared.createZip(from: urls) + }) { + if let bookmark = try? Bookmark(url: zipTempURL) { + let newItem = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) + ShelfStateViewModel.shared.add([newItem]) + } else { + // Fallback: reveal the temporary file in Finder + NSWorkspace.shared.activateFileViewerSelecting([zipTempURL]) + } + } + } catch { + print("❌ Compress failed: \(error)") + } + } + + default: + break + } + } + + @MainActor + private func openWithPanel() { + // Support both file items and link items + let targetURL: URL? + let needsSecurityScope: Bool + + if let fileURL = item.fileURL { + targetURL = fileURL + needsSecurityScope = true + } else if case .link(let url) = item.kind { + targetURL = url + needsSecurityScope = false + } else { + targetURL = nil + needsSecurityScope = false + } + guard let fileURL = targetURL else { return } + + let panel = NSOpenPanel() + panel.title = "Choose Application" + panel.message = "Choose an application to open the document \"\(item.displayName)\"." + panel.prompt = "Open" + panel.allowsMultipleSelection = false + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.resolvesAliases = true + if #available(macOS 12.0, *) { + panel.allowedContentTypes = [.application] + } + panel.directoryURL = URL(fileURLWithPath: "/Applications") + + // Compute recommended applications for the selected target + let recommendedApps: Set = { + let apps: [URL] + if let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + apps = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } else { + apps = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) + } + return Set(apps.map { $0.standardizedFileURL }) + }() + + // Delegate to filter entries when in "Recommended Applications" mode + final class AppChooserDelegate: NSObject, NSOpenSavePanelDelegate { + enum Mode { case recommended, all } + var mode: Mode = .recommended + let recommended: Set + init(recommended: Set) { self.recommended = recommended } + + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + let ext = url.pathExtension.lowercased() + if ext == "app" { + switch mode { + case .all: + return true + case .recommended: + // Standardize URLs for reliable comparison + let std = url.standardizedFileURL + return recommended.contains(std) + } + } + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { + return true + } + + return false + } + } + + let chooserDelegate = AppChooserDelegate(recommended: recommendedApps) + panel.delegate = chooserDelegate + + let enableLabel = NSTextField(labelWithString: "Enable:") + enableLabel.font = .systemFont(ofSize: NSFont.systemFontSize) + enableLabel.alignment = .natural + enableLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.addItems(withTitles: ["Recommended Applications", "All Applications"]) + popup.font = .systemFont(ofSize: NSFont.systemFontSize) + popup.selectItem(at: 0) + + popup.setContentHuggingPriority(.defaultLow, for: .horizontal) + popup.widthAnchor.constraint(greaterThanOrEqualToConstant: 200).isActive = true + + let alwaysCheckbox = NSButton(checkboxWithTitle: "Always Open With", target: nil, action: nil) + alwaysCheckbox.font = .systemFont(ofSize: NSFont.systemFontSize) + alwaysCheckbox.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let row = NSStackView(views: [enableLabel, popup]) + row.orientation = .horizontal + row.spacing = 8 + row.alignment = .centerY + row.distribution = .fill + + let column = NSStackView(views: [row, alwaysCheckbox]) + column.orientation = .vertical + column.spacing = 12 + column.alignment = .centerX + column.distribution = .fill + column.edgeInsets = NSEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + + panel.accessoryView = column + panel.isAccessoryViewDisclosed = true + + // Wire up popup to switch filter mode + class PopupBinder: NSObject { + weak var popup: NSPopUpButton? + weak var chooserDelegate: AppChooserDelegate? + weak var panel: NSOpenPanel? + init(popup: NSPopUpButton, chooserDelegate: AppChooserDelegate, panel: NSOpenPanel) { + self.popup = popup + self.chooserDelegate = chooserDelegate + self.panel = panel + } + @objc func changed(_ sender: Any?) { + if popup?.indexOfSelectedItem == 1 { + chooserDelegate?.mode = .all + } else { + chooserDelegate?.mode = .recommended + } + if let panel = panel { + panel.validateVisibleColumns() + let currentDir = panel.directoryURL + panel.directoryURL = currentDir + } + } + } + let binder = PopupBinder(popup: popup, chooserDelegate: chooserDelegate, panel: panel) + popup.target = binder + popup.action = #selector(PopupBinder.changed(_:)) + + panel.begin { response in + if response == .OK, let appURL = panel.url { + Task { + do { + let config = NSWorkspace.OpenConfiguration() + if alwaysCheckbox.state == .on, let bundleID = Bundle(url: appURL)?.bundleIdentifier { + if let contentType = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + let status = LSSetDefaultRoleHandlerForContentType(contentType.identifier as CFString, LSRolesMask.all, bundleID as CFString) + if status != noErr { print("⚠️ Failed to set default handler for \(contentType.identifier): \(status)") } + } else if let scheme = fileURL.scheme { + let status = LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) + if status != noErr { print("⚠️ Failed to set default handler for scheme \(scheme): \(status)") } + } + } + + if needsSecurityScope { + _ = try await fileURL.accessSecurityScopedResource { accessibleURL in + try await NSWorkspace.shared.open([accessibleURL], withApplicationAt: appURL, configuration: config) + } + } else { + try await NSWorkspace.shared.open([fileURL], withApplicationAt: appURL, configuration: config) + } + } catch { + print("❌ Failed to open with application: \(error.localizedDescription)") + } + } + } + // Keep binder/delegate alive until panel finishes + _ = binder + _ = chooserDelegate + } + } + + @MainActor + private func showRenameDialog(for item: ShelfItem) { + guard case let .file(bookmarkData) = item.kind else { return } + Task { + let bookmark = Bookmark(data: bookmarkData) + if let fileURL = bookmark.resolveURL() { + // Start security-scoped access and keep it active until rename completes. + let didStart = fileURL.startAccessingSecurityScopedResource() + + let savePanel = NSSavePanel() + savePanel.title = "Rename File" + savePanel.prompt = "Rename" + savePanel.nameFieldStringValue = fileURL.lastPathComponent + savePanel.directoryURL = fileURL.deletingLastPathComponent() + savePanel.begin { response in + if response == .OK, let newURL = savePanel.url { + Task { + do { + NSLog("🔐 Rename: moving from \(fileURL.path) to \(newURL.path) (securityScope=\(didStart))") + + try FileManager.default.moveItem(at: fileURL, to: newURL) + + if let newBookmark = try? Bookmark(url: newURL) { + ShelfStateViewModel.shared.updateBookmark(for: item, bookmark: newBookmark.data) + } + } catch { + print("❌ Failed to rename file: \(error.localizedDescription)") + } + if didStart { fileURL.stopAccessingSecurityScopedResource() } + } + } else { + if didStart { fileURL.stopAccessingSecurityScopedResource() } + } + } + } + } + } + + @MainActor + private func handleRemoveBackground() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard let imageURL = imageURLs.first else { return } + + Task { + do { + let resultURL = try await imageURL.accessSecurityScopedResource { url in + try await ImageProcessingService.shared.removeBackground(from: url) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to remove background: \(error.localizedDescription)") + await showErrorAlert(title: "Background Removal Failed", message: error.localizedDescription) + } + } + } + + @MainActor + private func handleCreatePDF() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard !imageURLs.isEmpty else { return } + + Task { + do { + let resultURL = try await imageURLs.accessSecurityScopedResources { urls in + try await ImageProcessingService.shared.createPDF(from: urls) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to create PDF: \(error.localizedDescription)") + await showErrorAlert(title: "PDF Creation Failed", message: error.localizedDescription) + } + } + } + + @MainActor + private func showConvertImageDialog() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard let imageURL = imageURLs.first else { return } + + // Create and show conversion options dialog with better layout + let alert = NSAlert() + alert.messageText = "Convert Image" + alert.alertStyle = .informational + alert.addButton(withTitle: "Convert") + alert.addButton(withTitle: "Cancel") + + // Create accessory view with better spacing and organization + let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 380, height: 180)) + accessoryView.wantsLayer = true + + // MARK: Format Row + let formatLabel = NSTextField(labelWithString: "Format:") + formatLabel.frame = NSRect(x: 0, y: 145, width: 100, height: 20) + formatLabel.font = .systemFont(ofSize: 12, weight: .medium) + accessoryView.addSubview(formatLabel) + + let formatPopup = NSPopUpButton(frame: NSRect(x: 120, y: 140, width: 250, height: 28)) + formatPopup.addItems(withTitles: ["PNG", "JPEG", "HEIC", "TIFF", "BMP"]) + formatPopup.selectItem(at: 0) + formatPopup.font = .systemFont(ofSize: 12) + accessoryView.addSubview(formatPopup) + + // MARK: Image Size Row + let imageSizeLabel = NSTextField(labelWithString: "Image Size:") + imageSizeLabel.frame = NSRect(x: 0, y: 105, width: 100, height: 20) + imageSizeLabel.font = .systemFont(ofSize: 12, weight: .medium) + accessoryView.addSubview(imageSizeLabel) + + let imageSizePopup = NSPopUpButton(frame: NSRect(x: 120, y: 100, width: 160, height: 28)) + imageSizePopup.addItems(withTitles: ["Actual Size", "Large", "Medium", "Small", "Custom..."]) + imageSizePopup.selectItem(at: 0) + imageSizePopup.font = .systemFont(ofSize: 12) + accessoryView.addSubview(imageSizePopup) + + // Custom size field (initially hidden) + let customSizeField = NSTextField(frame: NSRect(x: 285, y: 103, width: 85, height: 22)) + customSizeField.placeholderString = "e.g., 1920" + customSizeField.font = .systemFont(ofSize: 12) + customSizeField.isHidden = true + accessoryView.addSubview(customSizeField) + + // MARK: Preserve Metadata Checkbox + let metadataCheckbox = NSButton(checkboxWithTitle: "Preserve Metadata", target: nil, action: nil) + metadataCheckbox.frame = NSRect(x: 120, y: 65, width: 200, height: 20) + metadataCheckbox.font = .systemFont(ofSize: 12) + metadataCheckbox.state = .on + accessoryView.addSubview(metadataCheckbox) + + // MARK: Separator line + let separatorLine = NSView(frame: NSRect(x: 0, y: 50, width: 380, height: 1)) + separatorLine.wantsLayer = true + separatorLine.layer?.backgroundColor = NSColor.separatorColor.cgColor + accessoryView.addSubview(separatorLine) + + // MARK: Format-specific options (shown/hidden based on format selection) + let qualityRow = NSView(frame: NSRect(x: 0, y: 15, width: 380, height: 30)) + qualityRow.wantsLayer = true + + let qualityLabel = NSTextField(labelWithString: "Compression:") + qualityLabel.frame = NSRect(x: 0, y: 7, width: 100, height: 20) + qualityLabel.font = .systemFont(ofSize: 12, weight: .medium) + qualityRow.addSubview(qualityLabel) + + let qualitySlider = NSSlider(frame: NSRect(x: 120, y: 12, width: 200, height: 20)) + qualitySlider.minValue = 0.0 + qualitySlider.maxValue = 1.0 + qualitySlider.doubleValue = 0.85 + accessoryView.addSubview(qualitySlider) + + let qualityValueLabel = NSTextField(labelWithString: "85%") + qualityValueLabel.frame = NSRect(x: 325, y: 7, width: 55, height: 20) + qualityValueLabel.font = .systemFont(ofSize: 12) + qualityValueLabel.alignment = .left + accessoryView.addSubview(qualityValueLabel) + + // Update quality label and hide/show compression row based on format + let updateQualityLabel = { + let value = Int(qualitySlider.doubleValue * 100) + qualityValueLabel.stringValue = "\(value)%" + } + + let updateCompressionVisibility = { + let formatIndex = formatPopup.indexOfSelectedItem + let showCompression = formatIndex == 1 || formatIndex == 2 // JPEG or HEIC + qualitySlider.isHidden = !showCompression + qualityValueLabel.isHidden = !showCompression + qualityLabel.isHidden = !showCompression + } + + let updateCustomSizeVisibility = { + let sizeIndex = imageSizePopup.indexOfSelectedItem + customSizeField.isHidden = sizeIndex != 4 // Show only for "Custom..." + } + + // Create a target object to handle slider value changes + class SliderHandler: NSObject { + let updateLabel: () -> Void + let updateVisibility: () -> Void + let updateCustomSize: () -> Void + init(updateLabel: @escaping () -> Void, updateVisibility: @escaping () -> Void, updateCustomSize: @escaping () -> Void) { + self.updateLabel = updateLabel + self.updateVisibility = updateVisibility + self.updateCustomSize = updateCustomSize + } + @objc func sliderChanged(_ sender: NSSlider) { + updateLabel() + } + @objc func formatChanged(_ sender: NSPopUpButton) { + updateVisibility() + } + @objc func sizeChanged(_ sender: NSPopUpButton) { + updateCustomSize() + } + } + + let handler = SliderHandler(updateLabel: updateQualityLabel, updateVisibility: updateCompressionVisibility, updateCustomSize: updateCustomSizeVisibility) + qualitySlider.target = handler + qualitySlider.action = #selector(SliderHandler.sliderChanged(_:)) + qualitySlider.isContinuous = true + + formatPopup.target = handler + formatPopup.action = #selector(SliderHandler.formatChanged(_:)) + + imageSizePopup.target = handler + imageSizePopup.action = #selector(SliderHandler.sizeChanged(_:)) + + updateCompressionVisibility() + updateQualityLabel() + updateCustomSizeVisibility() + + // Keep the handler alive using the `AssociatedObject` helper instead of a magic string key + MenuActionTarget.sliderHandlerAssoc[accessoryView] = handler + + alert.accessoryView = accessoryView + + let response = alert.runModal() + + if response == .alertFirstButtonReturn { + // Get selected options + let formatIndex = formatPopup.indexOfSelectedItem + let format: ImageConversionOptions.ImageFormat + switch formatIndex { + case 0: format = .png + case 1: format = .jpeg + case 2: format = .heic + case 3: format = .tiff + case 4: format = .bmp + default: format = .png + } + + let quality = qualitySlider.doubleValue + + // Get max dimension based on image size selection + let maxDimension: CGFloat? = { + let sizeIndex = imageSizePopup.indexOfSelectedItem + switch sizeIndex { + case 0: return nil // Actual Size + case 1: return 1280 // Large + case 2: return 640 // Medium + case 3: return 320 // Small + case 4: // Custom (user-specified) + let text = customSizeField.stringValue.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty, let value = Double(text), value > 0 else { return nil } + return CGFloat(value) + default: return nil + } + }() + + let removeMetadata = metadataCheckbox.state == .off // Note: we invert this + + let options = ImageConversionOptions( + format: format, + compressionQuality: quality, + maxDimension: maxDimension, + removeMetadata: removeMetadata + ) + + Task { + do { + let resultURL = try await imageURL.accessSecurityScopedResource { url in + try await ImageProcessingService.shared.convertImage(from: url, options: options) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to convert image: \(error.localizedDescription)") + showErrorAlert(title: "Image Conversion Failed", message: error.localizedDescription) + } + } + } + } + + @MainActor + private func showErrorAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + + // MARK: - Private helpers + private func appDisplayName(for appURL: URL) -> String { + (try? appURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? appURL.lastPathComponent + } + + private func nsAppIcon(for appURL: URL, size: CGFloat) -> NSImage? { + let baseIcon = NSWorkspace.shared.icon(forFile: appURL.path) + baseIcon.isTemplate = false + + let targetSize = NSSize(width: size, height: size) + let rendered = NSImage(size: targetSize, flipped: false) { rect in + NSGraphicsContext.current?.imageInterpolation = .high + baseIcon.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: [ + .interpolation: NSImageInterpolation.high.rawValue + ]) + return true + } + + rendered.size = targetSize + return rendered + } + + private func defaultAppURL() -> URL? { + if let fileURL = item.fileURL { + return NSWorkspace.shared.urlForApplication(toOpen: fileURL) + } else if case .link(let url) = item.kind { + return NSWorkspace.shared.urlForApplication(toOpen: url) + } + return nil + } +} + +fileprivate extension Sequence { + func asyncCompactMap(_ transform: (Element) async -> T?) async -> [T] { + var result: [T] = [] + for element in self { + if let transformed = await transform(element) { + result.append(transformed) + } + } + return result + } +} diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift new file mode 100644 index 0000000..e658528 --- /dev/null +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift @@ -0,0 +1,87 @@ +// +// ShelfSelectionModel.swift +// boringNotch +// +// Created by Alexander on 2025-09-26. +// + +import Foundation +import Combine + +private let _shelfTypeAnchor: Bool = { + _ = String(describing: ShelfItem.self) + return true +}() + +@MainActor +final class ShelfSelectionModel: ObservableObject { + static let shared = ShelfSelectionModel() + + @Published private(set) var selectedIDs: Set = [] + + // Anchor for shift-range selection + private var lastAnchorID: UUID? = nil + + func isSelected(_ id: UUID) -> Bool { selectedIDs.contains(id) } + + var hasSelection: Bool { !selectedIDs.isEmpty } + + var firstSelectedItem: ShelfItem? { + guard let firstID = selectedIDs.first else { return nil } + return ShelfStateViewModel.shared.items.first(where: { $0.id == firstID }) + } + + func selectedItems(in allItems: [ShelfItem]) -> [ShelfItem] { + allItems.filter { selectedIDs.contains($0.id) } + } + + func selectSingle(_ item: ShelfItem) { + selectedIDs = [item.id] + lastAnchorID = item.id + } + + func toggle(_ item: ShelfItem) { + if selectedIDs.contains(item.id) { + selectedIDs.remove(item.id) + } else { + selectedIDs.insert(item.id) + } + lastAnchorID = item.id + } + + func shiftSelect(to item: ShelfItem, in allItems: [ShelfItem]) { + // Determine anchor + let anchorID = lastAnchorID ?? selectedIDs.first ?? item.id + guard let startIndex = allItems.firstIndex(where: { $0.id == anchorID }), + let endIndex = allItems.firstIndex(where: { $0.id == item.id }) else { + // Fallback to single select if indices not found + return selectSingle(item) + } + let lower = min(startIndex, endIndex) + let upper = max(startIndex, endIndex) + let rangeIDs = allItems[lower...upper].map { $0.id } + selectedIDs = Set(rangeIDs) + } + + func clear() { + selectedIDs.removeAll() + lastAnchorID = nil + } + + // Keep anchor sane if items array changed drastically (optional helper) + func ensureValidAnchor(in allItems: [ShelfItem]) { + if let anchor = lastAnchorID, !allItems.contains(where: { $0.id == anchor }) { + lastAnchorID = selectedIDs.first + } + } + + @Published private(set) var isDragging: Bool = false + + func beginDrag() { + isDragging = true + } + + func endDrag() { + isDragging = false + } +} diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift new file mode 100644 index 0000000..5cb665d --- /dev/null +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift @@ -0,0 +1,143 @@ +// +// ShelfStateViewModel.swift +// boringNotch +// +// Created by Alexander on 2025-10-09. + +import Foundation +import AppKit + +@MainActor +final class ShelfStateViewModel: ObservableObject { + static let shared = ShelfStateViewModel() + + @Published private(set) var items: [ShelfItem] = [] { + didSet { ShelfPersistenceService.shared.save(items) } + } + + @Published var isLoading: Bool = false + + var isEmpty: Bool { items.isEmpty } + + // Queue for deferred bookmark updates to avoid publishing during view updates + private var pendingBookmarkUpdates: [ShelfItem.ID: Data] = [:] + private var updateTask: Task? + + private init() { + items = ShelfPersistenceService.shared.load() + } + + + func add(_ newItems: [ShelfItem]) { + guard !newItems.isEmpty else { return } + var merged = items + // Deduplicate by identityKey while preserving order (existing first) + var seen: Set = Set(merged.map { $0.identityKey }) + for it in newItems { + let key = it.identityKey + if !seen.contains(key) { + merged.append(it) + seen.insert(key) + } + } + items = merged + } + + func remove(_ item: ShelfItem) { + item.cleanupStoredData() + items.removeAll { $0.id == item.id } + } + + func updateBookmark(for item: ShelfItem, bookmark: Data) { + guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } + if case .file = items[idx].kind { + items[idx].kind = .file(bookmark: bookmark) + } + } + + private func scheduleDeferredBookmarkUpdate(for item: ShelfItem, bookmark: Data) { + pendingBookmarkUpdates[item.id] = bookmark + + // Cancel existing task and schedule a new one + updateTask?.cancel() + updateTask = Task { @MainActor [weak self] in + await Task.yield() + + guard let self = self else { return } + + for (itemID, bookmarkData) in self.pendingBookmarkUpdates { + if let idx = self.items.firstIndex(where: { $0.id == itemID }), + case .file = self.items[idx].kind { + self.items[idx].kind = .file(bookmark: bookmarkData) + } + } + + self.pendingBookmarkUpdates.removeAll() + } + } + + + func load(_ providers: [NSItemProvider]) { + guard !providers.isEmpty else { return } + isLoading = true + Task { [weak self] in + let dropped = await ShelfDropService.items(from: providers) + await MainActor.run { + self?.add(dropped) + self?.isLoading = false + } + } + } + + func cleanupInvalidItems() { + Task { [weak self] in + guard let self else { return } + var keep: [ShelfItem] = [] + for item in self.items { + switch item.kind { + case .file(let data): + let bookmark = Bookmark(data: data) + if await bookmark.validate() { + keep.append(item) + } else { + item.cleanupStoredData() + } + default: + keep.append(item) + } + } + await MainActor.run { self.items = keep } + } + } + + + func resolveFileURL(for item: ShelfItem) -> URL? { + guard case .file(let bookmarkData) = item.kind else { return nil } + let bookmark = Bookmark(data: bookmarkData) + let result = bookmark.resolve() + if let refreshed = result.refreshedData, refreshed != bookmarkData { + NSLog("Bookmark for \(item) stale; refreshing") + scheduleDeferredBookmarkUpdate(for: item, bookmark: refreshed) + } + return result.url + } + + func resolveAndUpdateBookmark(for item: ShelfItem) -> URL? { + guard case .file(let bookmarkData) = item.kind else { return nil } + let bookmark = Bookmark(data: bookmarkData) + let result = bookmark.resolve() + if let refreshed = result.refreshedData, refreshed != bookmarkData { + NSLog("Bookmark for \(item) stale; refreshing") + updateBookmark(for: item, bookmark: refreshed) + } + return result.url + } + + func resolveFileURLs(for items: [ShelfItem]) -> [URL] { + var urls: [URL] = [] + for it in items { + if let u = resolveFileURL(for: it) { urls.append(u) } + } + return urls + } +} diff --git a/DynamicIsland/components/Shelf/Views/DragPreviewView.swift b/DynamicIsland/components/Shelf/Views/DragPreviewView.swift new file mode 100644 index 0000000..162cb6c --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/DragPreviewView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import AppKit + +struct DragPreviewView: View { + let thumbnail: NSImage? + let displayName: String + + var body: some View { + VStack(alignment: .center, spacing: 4) { + Image(nsImage: thumbnail ?? NSImage()) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + Text(displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(2) + .truncationMode(.middle) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(Color.accentColor)) + .frame(alignment: .top) + } + .frame(width: 105) + } +} diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift new file mode 100644 index 0000000..ee1ca47 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -0,0 +1,135 @@ +// +// FileShareView.swift +// boringNotch +// +// Created by Alexander on 2025-09-24. +// + +import AppKit +import Defaults +import SwiftUI +import UniformTypeIdentifiers + +struct FileShareView: View { + @EnvironmentObject private var vm: DynamicIslandViewModel + @StateObject private var quickShare = QuickShareService.shared + @Default(.quickShareProvider) var quickShareProvider: String + + @State private var hostView: NSView? + @State private var interactionNonce: UUID = .init() + @State private var isProcessing = false + + private var selectedProvider: QuickShareProvider { + quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true) + } + + var body: some View { + dropArea + .background(NSViewHost(view: $hostView)) + .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in + interactionNonce = .init() + vm.dropEvent = true + Task { await handleDrop(providers) } + return true + } + .onTapGesture { + Task { + await handleClick() + } + } + } + + private var dropArea: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill( + LinearGradient(colors: [Color.black.opacity(0.35), Color.black.opacity(0.20)], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + vm.dropZoneTargeting + ? Color.accentColor.opacity(0.9) + : Color.white.opacity(0.1), + style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10]) + ) + ) + .shadow(color: Color.black.opacity(0.6), radius: 6, x: 0, y: 2) + + // Content + VStack(spacing: 5) { + ZStack { + Circle() + .fill(Color.white.opacity( + vm.dropZoneTargeting ? 0.11 : 0.09 + )) + .frame(width: 55, height: 55) + Image(systemName: "square.and.arrow.up") + Group { + if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "square.and.arrow.up") + } + } + .frame(width: 34, height: 34) + .foregroundStyle( + vm.dropZoneTargeting ? Color.accentColor : Color.gray + ) + .scaleEffect( + vm.dropZoneTargeting ? 1.06 : 1.0 + ) + .animation(.spring(response: 0.36, dampingFraction: 0.7), value: vm.dropZoneTargeting) + } + + Text(selectedProvider.id) + .font(.system(.headline, design: .rounded)) + .foregroundColor(.white.opacity(0.8)) + + } + .padding(18) + + // Loading overlay + if isProcessing || quickShare.isPickerOpen { + RoundedRectangle(cornerRadius: 12) + .fill(.black.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + ) + } + } + .contentShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Actions + + private func handleDrop(_ providers: [NSItemProvider]) async { + isProcessing = true + defer { isProcessing = false } + await quickShare.shareDroppedFiles(providers, using: selectedProvider, from: hostView) + } + + private func handleClick() async { + await quickShare.showFilePicker(for: selectedProvider, from: hostView) + } +} + +// MARK: - Host NSView extractor for anchoring share sheet + +private struct NSViewHost: NSViewRepresentable { + @Binding var view: NSView? + + func makeNSView(context: Context) -> NSView { + let v = NSView(frame: .zero) + DispatchQueue.main.async { self.view = v } + return v + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { self.view = nsView } + } +} diff --git a/DynamicIsland/components/Shelf/Views/ShelfItemView.swift b/DynamicIsland/components/Shelf/Views/ShelfItemView.swift new file mode 100644 index 0000000..cdad5d4 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/ShelfItemView.swift @@ -0,0 +1,371 @@ +// +// ShelfItemView.swift +// boringNotch +// +// Created by Alexander on 2025-09-24. +// + +import AppKit +import SwiftUI +import Defaults + +import QuickLook + +struct ShelfItemView: View { + let item: ShelfItem + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var selection = ShelfSelectionModel.shared + @StateObject private var viewModel: ShelfItemViewModel + @EnvironmentObject private var quickLookService: QuickLookService + @State private var showStack = false + @State private var cachedPreviewImage: NSImage? + @State private var debouncedDropTarget = false + + private var isSelected: Bool { viewModel.isSelected } + private var shouldHideDuringDrag: Bool { selection.isDragging && selection.isSelected(item.id) && false } + + init(item: ShelfItem) { + self.item = item + _viewModel = StateObject(wrappedValue: ShelfItemViewModel(item: item)) + } + + var body: some View { + ZStack { + if !shouldHideDuringDrag { + VStack(alignment: .center, spacing: 2) { + iconView + textView + } + .frame(width: 105) + .padding(.vertical, 10) + .padding(.horizontal, 5) + .background(backgroundView) + .contentShape(Rectangle()) + .animation(.easeInOut(duration: 0.1), value: debouncedDropTarget) + .animation(.easeInOut(duration: 0.1), value: isSelected) + + DraggableClickHandler( + item: item, + viewModel: viewModel, + cachedPreviewImage: $cachedPreviewImage, + dragPreviewContent: { + DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName) + }, + onRightClick: viewModel.handleRightClick, + onClick: { event, nsview in + viewModel.handleClick(event: event, view: nsview) + } + ) + } else { + Color.clear + .frame(width: 105) + .padding(.vertical, 10) + .padding(.horizontal, 5) + } + } + .onChange(of: viewModel.isDropTargeted) { _, targeted in + vm.dragDetectorTargeting = targeted + // Debounce drop target state changes + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + debouncedDropTarget = targeted + } + } + .onAppear { + Task { + await viewModel.loadThumbnail() + // Pre-render drag preview once on appear + if cachedPreviewImage == nil { + cachedPreviewImage = await renderDragPreview() + } + } + viewModel.onQuickLookRequest = { urls in + quickLookService.show(urls: urls, selectFirst: true) + } + } + .onChange(of: viewModel.thumbnail) { _, _ in + // Invalidate cached preview when thumbnail changes + Task { + cachedPreviewImage = await renderDragPreview() + } + } + .quickLookPresenter(using: quickLookService) + } + + // MARK: - View Components + + private var iconView: some View { + Image(nsImage: viewModel.thumbnail ?? item.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2) + } + + private var textView: some View { + Text(item.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(2) + .truncationMode(.middle) + .multilineTextAlignment(.center) + .frame(height: 30, alignment: .top) + } + + private var backgroundView: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + strokeColor, + lineWidth: strokeWidth + ) + ) + } + + private var backgroundColor: Color { + if debouncedDropTarget { + return Color.accentColor.opacity(0.25) + } else if isSelected { + return Color.accentColor.opacity(0.15) + } else { + return Color.clear + } + } + + private var strokeColor: Color { + if debouncedDropTarget { + return Color.accentColor.opacity(0.9) + } else if isSelected { + return Color.accentColor.opacity(0.8) + } else { + return Color.clear + } + } + + private var strokeWidth: CGFloat { + if debouncedDropTarget { + return 3 + } else if isSelected { + return 2 + } else { + return 1 + } + } + + // MARK: - Drag Preview Rendering + + @MainActor + private func renderDragPreview() async -> NSImage { + let content = DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName) + let renderer = ImageRenderer(content: content) + renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 + return renderer.nsImage ?? (viewModel.thumbnail ?? item.icon) + } + + +} + +// MARK: - Draggable Click Handler with NSDraggingSource +private struct DraggableClickHandler: NSViewRepresentable { + let item: ShelfItem + let viewModel: ShelfItemViewModel + @Binding var cachedPreviewImage: NSImage? + @ViewBuilder let dragPreviewContent: () -> Content + let onRightClick: (NSEvent, NSView) -> Void + let onClick: (NSEvent, NSView) -> Void + + func makeNSView(context: Context) -> DraggableClickView { + let view = DraggableClickView() + view.item = item + view.viewModel = viewModel + view.dragPreviewImage = cachedPreviewImage ?? renderDragPreview() + view.onRightClick = onRightClick + view.onClick = onClick + return view + } + + func updateNSView(_ nsView: DraggableClickView, context: Context) { + nsView.item = item + nsView.viewModel = viewModel + // Only update preview if cached version is available + if let cached = cachedPreviewImage { + nsView.dragPreviewImage = cached + } + nsView.onRightClick = onRightClick + nsView.onClick = onClick + } + + private func renderDragPreview() -> NSImage { + let content = dragPreviewContent() + let renderer = ImageRenderer(content: content) + renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 + + if let nsImage = renderer.nsImage { + return nsImage + } + + // Fallback to icon if rendering fails + return viewModel.thumbnail ?? item.icon + } + + final class DraggableClickView: NSView, NSDraggingSource { + var item: ShelfItem! + weak var viewModel: ShelfItemViewModel? + var dragPreviewImage: NSImage? + var onRightClick: ((NSEvent, NSView) -> Void)? + var onClick: ((NSEvent, NSView) -> Void)? + + private var mouseDownEvent: NSEvent? + private let dragThreshold: CGFloat = 3.0 + private var draggedURLs: [URL] = [] + private var draggedItems: [ShelfItem] = [] + + override func rightMouseDown(with event: NSEvent) { + onRightClick?(event, self) + } + + override func mouseDown(with event: NSEvent) { + mouseDownEvent = event + onClick?(event, self) + } + + override func mouseDragged(with event: NSEvent) { + guard let mouseDownEvent = mouseDownEvent else { + super.mouseDragged(with: event) + return + } + + let dragDistance = hypot( + event.locationInWindow.x - mouseDownEvent.locationInWindow.x, + event.locationInWindow.y - mouseDownEvent.locationInWindow.y + ) + + if dragDistance > dragThreshold { + startDragSession(with: event) + self.mouseDownEvent = nil + } else { + super.mouseDragged(with: event) + } + } + + private func startDragSession(with event: NSEvent) { + // Prepare dragging items + let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let itemsToDrag: [ShelfItem] + + if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) { + itemsToDrag = selectedItems + } else { + itemsToDrag = [item] + } + + // Store items being dragged for auto-remove feature + draggedItems = itemsToDrag + + // Create dragging items for AppKit + var draggingItems: [NSDraggingItem] = [] + + for dragItem in itemsToDrag { + if let pasteboardItem = createPasteboardItem(for: dragItem) { + let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem) + + // Use the drag preview image + let image = dragPreviewImage ?? dragItem.icon + let imageFrame = NSRect( + x: 0, + y: 0, + width: image.size.width, + height: image.size.height + ) + draggingItem.setDraggingFrame(imageFrame, contents: image) + + draggingItems.append(draggingItem) + } + } + + guard !draggingItems.isEmpty else { return } + + beginDraggingSession(with: draggingItems, event: event, source: self) + } + + private func createPasteboardItem(for item: ShelfItem) -> NSPasteboardItem? { + let pasteboardItem = NSPasteboardItem() + + switch item.kind { + case .file: + guard let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) else { + pasteboardItem.setString(item.displayName, forType: .string) + return pasteboardItem + } + + // Start accessing security-scoped resource and keep it active during drag + if url.startAccessingSecurityScopedResource() { + draggedURLs.append(url) + NSLog("🔐 Started security-scoped access for drag: \(url.path)") + } + + pasteboardItem.setString(url.absoluteString, forType: .fileURL) + pasteboardItem.setString(url.path, forType: .string) + return pasteboardItem + + case .text(let string): + pasteboardItem.setString(string, forType: .string) + return pasteboardItem + + case .link(let url): + pasteboardItem.setString(url.absoluteString, forType: .URL) + pasteboardItem.setString(url.absoluteString, forType: .string) + return pasteboardItem + } + } + + // MARK: - NSDraggingSource + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + // When copyOnDrag is enabled, only allow copy operations + if Defaults[.copyOnDrag] { + return [.copy] + } + + switch context { + case .outsideApplication: + return [.copy, .move] + case .withinApplication: + return [.copy, .move, .generic] + @unknown default: + return [.copy] + } + } + + func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + ShelfSelectionModel.shared.beginDrag() + } + + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + ShelfSelectionModel.shared.endDrag() + + // Stop accessing security-scoped resources after drag completes + for url in draggedURLs { + url.stopAccessingSecurityScopedResource() + NSLog("🔐 Stopped security-scoped access after drag: \(url.path)") + } + draggedURLs.removeAll() + + // Auto-remove items from shelf if enabled and drag succeeded + if Defaults[.autoRemoveShelfItems] && !operation.isEmpty { + for item in draggedItems { + ShelfStateViewModel.shared.remove(item) + } + } + draggedItems.removeAll() + } + + func ignoreModifierKeys(for session: NSDraggingSession) -> Bool { + return false + } + } +} diff --git a/DynamicIsland/components/Shelf/Views/ShelfView.swift b/DynamicIsland/components/Shelf/Views/ShelfView.swift new file mode 100644 index 0000000..e891de5 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/ShelfView.swift @@ -0,0 +1,115 @@ +// +// ShelfItemView.swift +// boringNotch +// +// Created by Alexander on 2025-09-24. +// + +import SwiftUI +import AppKit + +struct ShelfView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject var tvm = ShelfStateViewModel.shared + @StateObject var selection = ShelfSelectionModel.shared + @StateObject private var quickLookService = QuickLookService() + private let spacing: CGFloat = 8 + + var body: some View { + HStack(spacing: 12) { + FileShareView() + .aspectRatio(1, contentMode: .fit) + .environmentObject(vm) + panel + .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in + handleDrop(providers: providers) + } + } + // Bind Quick Look to shelf selection + .onChange(of: selection.selectedIDs) { + updateQuickLookSelection() + } + .quickLookPresenter(using: quickLookService) + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard !selection.isDragging else { return false } + vm.dropEvent = true + ShelfStateViewModel.shared.load(providers) + return true + } + + private func updateQuickLookSelection() { + guard quickLookService.isQuickLookOpen && !selection.selectedIDs.isEmpty else { return } + + let selectedItems = selection.selectedItems(in: tvm.items) + let urls: [URL] = selectedItems.compactMap { item in + if let fileURL = item.fileURL { + return fileURL + } + if case .link(let url) = item.kind { + return url + } + return nil + } + + if !urls.isEmpty { + quickLookService.updateSelection(urls: urls) + } + } + + var panel: some View { + RoundedRectangle(cornerRadius: 16) + .stroke( + vm.dragDetectorTargeting + ? Color.accentColor.opacity(0.9) + : Color.white.opacity(0.1), + style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10]) + ) + .overlay { + content + .padding() + } + .transaction { transaction in + transaction.animation = vm.animation + } + .contentShape(Rectangle()) + .onTapGesture { selection.clear() } + } + + var content: some View { + Group { + if tvm.isEmpty { + VStack(spacing: 10) { + Image(systemName: "tray.and.arrow.down") + .symbolVariant(.fill) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.white, .gray) + .imageScale(.large) + + Text("Drop files here") + .foregroundStyle(.gray) + .font(.system(.title3, design: .rounded)) + .fontWeight(.medium) + } + } else { + ScrollView(.horizontal) { + HStack(spacing: spacing) { + ForEach(tvm.items) { item in + ShelfItemView(item: item) + .environmentObject(quickLookService) + } + } + } + .padding(-spacing) + .scrollIndicators(.never) + .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in + handleDrop(providers: providers) + } + } + } + .onAppear { + ShelfStateViewModel.shared.cleanupInvalidItems() + } + } +} diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index a1bd64c..6e0e9b4 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -25,8 +25,10 @@ struct TabSelectionView: View { var tabsArray: [TabModel] = [] tabsArray.append(TabModel(label: "Home", icon: "house.fill", view: .home)) - - tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)) + + if Defaults[.dynamicShelf] { + tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)) + } // Stats tab only shown when stats feature is enabled if Defaults[.enableStatsFeature] { diff --git a/DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift b/DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift new file mode 100644 index 0000000..f2790bd --- /dev/null +++ b/DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift @@ -0,0 +1,174 @@ +// +// NSItemProvider+LoadHelpers.swift +// DynamicIsland +// +// Ported shelf drop helpers from boringNotch. +// + +import AppKit +import Foundation +import UniformTypeIdentifiers + +extension NSItemProvider { + func extractItem() async -> URL? { + await loadFileURL(typeIdentifier: UTType.item.identifier) + } + + /// Detects if this is a file dragged from the filesystem + func extractFileURL() async -> URL? { + if hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + return await loadFileURL(typeIdentifier: UTType.fileURL.identifier) + } + return nil + } + + /// Loads raw data for the given type identifier + func loadData() async -> Data? { + guard hasItemConformingToTypeIdentifier(UTType.data.identifier) else { return nil } + return await withCheckedContinuation { (cont: CheckedContinuation) in + loadItem(forTypeIdentifier: UTType.data.identifier, options: nil) { item, error in + if let error { + print("Error loading data for type \(UTType.data.identifier): \(error.localizedDescription)") + cont.resume(returning: nil) + return + } + if let url = item as? URL, let data = try? Data(contentsOf: url) { + if !url.absoluteString.contains("com.apple.SwiftUI.filePromises") { + cont.resume(returning: nil) + return + } + self.suggestedName = self.suggestedName ?? url.lastPathComponent + let fileManager = FileManager.default + let folderURL = url.deletingLastPathComponent() + do { + try fileManager.removeItem(at: url) + let contents = try fileManager.contentsOfDirectory(atPath: folderURL.path) + if contents.isEmpty { + try fileManager.removeItem(at: folderURL) + } + } catch { + print("Error: \(error.localizedDescription)") + } + cont.resume(returning: data) + } else if let data = item as? Data { + cont.resume(returning: data) + } else { + cont.resume(returning: nil) + } + } + } + } + + /// Attempts to extract a URL (web link) from the provider + func extractURL() async -> URL? { + if hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if let url = await loadURL(typeIdentifier: UTType.url.identifier) { + guard url.scheme != nil else { return nil } + return url + } + } + return nil + } + + func extractText() async -> String? { + let textTypes = [UTType.utf8PlainText.identifier, UTType.plainText.identifier] + for typeIdentifier in textTypes where hasItemConformingToTypeIdentifier(typeIdentifier) { + if let text = await loadText(typeIdentifier: typeIdentifier) { + return text + } + } + return nil + } + + /// Loads a file URL from the provider for the given type identifier. + func loadFileURL(typeIdentifier: String) async -> URL? { + await withCheckedContinuation { (cont: CheckedContinuation) in + loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in + if let error { + print("❌ Error loading item for type \(typeIdentifier): \(error.localizedDescription)") + cont.resume(returning: nil) + return + } + var resolvedURL: URL? + if let url = item as? URL { + resolvedURL = url + } else if let data = item as? Data { + if let string = String(data: data, encoding: .utf8) { + if let url = URL(string: string) { + resolvedURL = url + } else if string.hasPrefix("/") { + resolvedURL = URL(fileURLWithPath: string) + } + } + if resolvedURL == nil { + let bookmark = Bookmark(data: data) + resolvedURL = bookmark.resolveURL() + } + } else if let string = item as? String { + if let url = URL(string: string) { + resolvedURL = url + } else if string.hasPrefix("/") { + resolvedURL = URL(fileURLWithPath: string) + } + } + cont.resume(returning: resolvedURL) + } + } + } + + /// Loads a URL from the provider for the given type identifier. + func loadURL(typeIdentifier: String) async -> URL? { + await withCheckedContinuation { (cont: CheckedContinuation) in + loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in + if error != nil { + cont.resume(returning: nil) + return + } + if let url = item as? URL { + cont.resume(returning: url) + } else if let data = item as? Data { + if let string = String(data: data, encoding: .utf8) { + if let url = URL(string: string) { + cont.resume(returning: url) + return + } else if string.hasPrefix("/") { + cont.resume(returning: URL(fileURLWithPath: string)) + return + } + } + cont.resume(returning: nil) + } else if let string = item as? String { + if let url = URL(string: string) { + cont.resume(returning: url) + } else if string.hasPrefix("/") { + cont.resume(returning: URL(fileURLWithPath: string)) + } else { + cont.resume(returning: nil) + } + } else { + cont.resume(returning: nil) + } + } + } + } + + /// Loads text from the provider for the given type identifier. + func loadText(typeIdentifier: String) async -> String? { + await withCheckedContinuation { (cont: CheckedContinuation) in + loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in + if error != nil { + cont.resume(returning: nil) + return + } + if let string = item as? String { + cont.resume(returning: string) + } else if let data = item as? Data, + let string = String(data: data, encoding: .utf8) { + cont.resume(returning: string) + } else { + cont.resume(returning: nil) + } + } + } + } +} diff --git a/DynamicIsland/extensions/NSMenu+AssociatedObject.swift b/DynamicIsland/extensions/NSMenu+AssociatedObject.swift new file mode 100644 index 0000000..85eef54 --- /dev/null +++ b/DynamicIsland/extensions/NSMenu+AssociatedObject.swift @@ -0,0 +1,21 @@ +// +// NSMenu+AssociatedObject.swift +// DynamicIsland +// +// Retains menu action targets using associated objects. +// + +import AppKit + +private final class MenuActionBox: NSObject { + let target: AnyObject + init(target: AnyObject) { self.target = target } +} + +extension NSMenu { + private static let retainedAction = AssociatedObject() + + func retainActionTarget(_ target: AnyObject) { + NSMenu.retainedAction[self] = MenuActionBox(target: target) + } +} diff --git a/DynamicIsland/extensions/URL+SecurityScoped.swift b/DynamicIsland/extensions/URL+SecurityScoped.swift new file mode 100644 index 0000000..4e06498 --- /dev/null +++ b/DynamicIsland/extensions/URL+SecurityScoped.swift @@ -0,0 +1,44 @@ +// +// URL+SecurityScoped.swift +// DynamicIsland +// +// Ported from boringNotch shelf utilities. +// + +import Foundation +import AppKit + +extension URL { + func accessSecurityScopedResource(accessor: (URL) throws -> Value) rethrows -> Value { + let didStartAccessing = startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + stopAccessingSecurityScopedResource() + } + } + return try accessor(self) + } + + /// Async version of accessSecurityScopedResource + func accessSecurityScopedResource(accessor: (URL) async throws -> Value) async rethrows -> Value { + let didStartAccessing = startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + stopAccessingSecurityScopedResource() + } + } + return try await accessor(self) + } +} + +extension Array where Element == URL { + func accessSecurityScopedResources(accessor: ([URL]) async throws -> Value) async rethrows -> Value { + let didStart = map { $0.startAccessingSecurityScopedResource() } + defer { + for (url, started) in zip(self, didStart) where started { + url.stopAccessingSecurityScopedResource() + } + } + return try await accessor(self) + } +} diff --git a/DynamicIsland/helpers/AssociatedObject.swift b/DynamicIsland/helpers/AssociatedObject.swift new file mode 100644 index 0000000..3e96233 --- /dev/null +++ b/DynamicIsland/helpers/AssociatedObject.swift @@ -0,0 +1,28 @@ +// +// AssociatedObject.swift +// DynamicIsland +// +// Helper for retaining objects via Objective-C associated storage. +// + +import Foundation +import ObjectiveC + +public struct AssociatedObject { + private let key: UnsafeRawPointer + private let policy: objc_AssociationPolicy + + public init(_ policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC) { + self.key = UnsafeRawPointer(Unmanaged.passUnretained(UniqueKey()).toOpaque()) + self.policy = policy + } + + private final class UniqueKey {} + + public subscript(_ owner: Owner) -> Value? { + get { objc_getAssociatedObject(owner, key) as? Value } + nonmutating set { objc_setAssociatedObject(owner, key, newValue, policy) } + } +} + +extension AssociatedObject: @unchecked Sendable where Value: Sendable {} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index c62ef82..1e99e09 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -501,6 +501,9 @@ extension Defaults.Keys { // MARK: Shelf static let dynamicShelf = Key("dynamicShelf", default: true) static let openShelfByDefault = Key("openShelfByDefault", default: true) + static let quickShareProvider = Key("quickShareProvider", default: "AirDrop") + static let copyOnDrag = Key("copyOnDrag", default: false) + static let autoRemoveShelfItems = Key("autoRemoveShelfItems", default: false) // MARK: Calendar static let calendarSelectionState = Key("calendarSelectionState", default: .all) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 2e512dc..c55a852 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -9,6 +9,7 @@ import Combine import Defaults import SwiftUI +@MainActor class DynamicIslandViewModel: NSObject, ObservableObject { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var detector = FullscreenMediaDetector.shared @@ -45,6 +46,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var notchSize: CGSize = getClosedNotchSize() @Published var closedNotchSize: CGSize = getClosedNotchSize() + @MainActor deinit { destroy() } @@ -282,7 +284,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { // Set the current view to shelf if it contains files and the user enables openShelfByDefault // Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home - if !TrayDrop.shared.isEmpty && Defaults[.openShelfByDefault] { + if !ShelfStateViewModel.shared.isEmpty && Defaults[.openShelfByDefault] { coordinator.currentView = .shelf } else if !coordinator.openLastTabByDefault { coordinator.currentView = .home diff --git a/DynamicIsland/models/SharingStateManager.swift b/DynamicIsland/models/SharingStateManager.swift new file mode 100644 index 0000000..7d9ff54 --- /dev/null +++ b/DynamicIsland/models/SharingStateManager.swift @@ -0,0 +1,157 @@ +// +// SharingStateManager.swift +// boringNotch +// +// Created by Alexander on 2025-10-10. +// + +import AppKit +import Combine +import Foundation + +extension Notification.Name { + static let sharingDidFinish = Notification.Name("com.ebullioscopic.sharingDidFinish") +} + +@MainActor +final class SharingStateManager: ObservableObject { + static let shared = SharingStateManager() + + private var activeSessions: Int = 0 { + didSet { + let newValue = activeSessions > 0 + if newValue != preventNotchClose { + preventNotchClose = newValue + if !newValue { + NotificationCenter.default.post(name: .sharingDidFinish, object: nil) + } + } + } + } + + @Published var preventNotchClose: Bool = false + + private var activeDelegates: [UUID: SharingLifecycleDelegate] = [:] + + private init() {} + + func requestCloseIfReady() { + if !preventNotchClose { + NotificationCenter.default.post(name: .sharingDidFinish, object: nil) + } + } + + func beginInteraction() { + activeSessions += 1 + } + + func endInteraction() { + if activeSessions > 0 { activeSessions -= 1 } + } + + func makeDelegate(onEnd: (() -> Void)? = nil) -> SharingLifecycleDelegate { + let id = UUID() + let delegate = SharingLifecycleDelegate(id: id, onEnd: { [weak self] in + onEnd?() + self?.unregisterDelegate(id: id) + }, onBegin: { [weak self] in + self?.beginInteraction() + }, onFinish: { [weak self] in + self?.endInteraction() + }) + activeDelegates[id] = delegate + return delegate + } + + private func unregisterDelegate(id: UUID) { + activeDelegates.removeValue(forKey: id) + } +} + +final class SharingLifecycleDelegate: NSObject, NSSharingServiceDelegate, NSSharingServicePickerDelegate { + let id: UUID + private let onEnd: () -> Void + private let onBegin: () -> Void + private let onFinish: () -> Void + + private var pickerActive = false + private var serviceInProgress = false + private var finished = false + private var timeoutTask: Task? + + init(id: UUID, onEnd: @escaping () -> Void, onBegin: @escaping () -> Void, onFinish: @escaping () -> Void) { + self.id = id + self.onEnd = onEnd + self.onBegin = onBegin + self.onFinish = onFinish + } + + deinit { + timeoutTask?.cancel() + } + + func markPickerBegan() { + guard !pickerActive else { return } + pickerActive = true + onBegin() + } + + func markServiceBegan() { + guard !serviceInProgress else { return } + serviceInProgress = true + onBegin() + startTimeoutFallback() + } + + private func startTimeoutFallback() { + timeoutTask?.cancel() + timeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard let self = self, !Task.isCancelled else { return } + if !self.finished { + self.finishIfNeeded() + } + } + } + + private func finishIfNeeded() { + guard !finished else { return } + finished = true + timeoutTask?.cancel() + onFinish() + onEnd() + } + + // MARK: - NSSharingServicePickerDelegate + + func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService?) { + if service == nil { + if pickerActive && !serviceInProgress { + finishIfNeeded() + } + return + } + + service?.delegate = self + serviceInProgress = true + startTimeoutFallback() + } + + // MARK: - NSSharingServiceDelegate + + func sharingService(_ sharingService: NSSharingService, willShareItems items: [Any]) { + if !pickerActive && !serviceInProgress { + onBegin() + } + serviceInProgress = true + } + + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + finishIfNeeded() + } + + func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error) { + finishIfNeeded() + } +} +