From 30422c3d956e07cf8d0190ee6842afaf835901e4 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 8 Dec 2025 13:29:25 +0530 Subject: [PATCH 1/4] Somewhat working file tray --- DynamicIsland.xcodeproj/project.pbxproj | 8 +- .../Notch/DynamicIslandHeader.swift | 7 +- .../components/Notch/NotchShelfView.swift | 56 +- .../components/Shelf/Models/Bookmark.swift | 94 ++ .../components/Shelf/Models/ShelfItem.swift | 228 ++++ .../Services/ImageProcessingService.swift | 320 +++++ .../Shelf/Services/QuickLookService.swift | 134 ++ .../Shelf/Services/QuickShareService.swift | 211 ++++ .../Shelf/Services/ShareServiceFinder.swift | 59 + .../Shelf/Services/ShelfActionService.swift | 59 + .../Shelf/Services/ShelfDropService.swift | 69 + .../Services/ShelfPersistenceService.swift | 81 ++ .../TemporaryFileStorageService.swift | 244 ++++ .../Shelf/Services/ThumbnailService.swift | 98 ++ .../Shelf/ViewModels/ShelfItemViewModel.swift | 1112 +++++++++++++++++ .../ViewModels/ShelfSelectionModel.swift | 87 ++ .../ViewModels/ShelfStateViewModel.swift | 143 +++ .../Shelf/Views/DragPreviewView.swift | 29 + .../Shelf/Views/FileShareView.swift | 135 ++ .../Shelf/Views/ShelfItemView.swift | 371 ++++++ .../components/Shelf/Views/ShelfView.swift | 115 ++ .../components/Tabs/TabSelectionView.swift | 6 +- .../NSItemProvider+LoadHelpers.swift | 174 +++ .../extensions/NSMenu+AssociatedObject.swift | 21 + .../extensions/URL+SecurityScoped.swift | 44 + DynamicIsland/helpers/AssociatedObject.swift | 28 + DynamicIsland/models/Constants.swift | 3 + .../models/DynamicIslandViewModel.swift | 4 +- .../models/SharingStateManager.swift | 157 +++ 29 files changed, 4032 insertions(+), 65 deletions(-) create mode 100644 DynamicIsland/components/Shelf/Models/Bookmark.swift create mode 100644 DynamicIsland/components/Shelf/Models/ShelfItem.swift create mode 100644 DynamicIsland/components/Shelf/Services/ImageProcessingService.swift create mode 100644 DynamicIsland/components/Shelf/Services/QuickLookService.swift create mode 100644 DynamicIsland/components/Shelf/Services/QuickShareService.swift create mode 100644 DynamicIsland/components/Shelf/Services/ShareServiceFinder.swift create mode 100644 DynamicIsland/components/Shelf/Services/ShelfActionService.swift create mode 100644 DynamicIsland/components/Shelf/Services/ShelfDropService.swift create mode 100644 DynamicIsland/components/Shelf/Services/ShelfPersistenceService.swift create mode 100644 DynamicIsland/components/Shelf/Services/TemporaryFileStorageService.swift create mode 100644 DynamicIsland/components/Shelf/Services/ThumbnailService.swift create mode 100644 DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift create mode 100644 DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift create mode 100644 DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift create mode 100644 DynamicIsland/components/Shelf/Views/DragPreviewView.swift create mode 100644 DynamicIsland/components/Shelf/Views/FileShareView.swift create mode 100644 DynamicIsland/components/Shelf/Views/ShelfItemView.swift create mode 100644 DynamicIsland/components/Shelf/Views/ShelfView.swift create mode 100644 DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift create mode 100644 DynamicIsland/extensions/NSMenu+AssociatedObject.swift create mode 100644 DynamicIsland/extensions/URL+SecurityScoped.swift create mode 100644 DynamicIsland/helpers/AssociatedObject.swift create mode 100644 DynamicIsland/models/SharingStateManager.swift 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() + } +} + From 628892a73bb23f2d4168d9fcd3c1b16d4f28ca17 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 8 Dec 2025 13:38:43 +0530 Subject: [PATCH 2/4] Working Multi App share service --- DynamicIsland/Localizable.xcstrings | 31 +++++++ .../components/Settings/SettingsView.swift | 93 +++++++++++++++++++ DynamicIsland/models/Constants.swift | 1 + 3 files changed, 125 insertions(+) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 90e667c..d625982 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -5062,6 +5062,10 @@ } } }, + "Choose which service to use when sharing files from the shelf. Drag files onto the shelf or click the shelf button to pick files." : { + "comment" : "A footer description under the Quick Share section of the Shelf settings.", + "isCommentAutoGenerated" : true + }, "Choose which system controls should display custom OSD windows." : { "comment" : "A footer description for the \"Controls\" section of the Custom OSD settings view.", "isCommentAutoGenerated" : true @@ -5842,6 +5846,10 @@ "comment" : "A tooltip for a button that copies the link of a reminder event to the clipboard.", "isCommentAutoGenerated" : true }, + "Copy items on drag" : { + "comment" : "A toggle that allows users to copy items to the clipboard when dragging them.", + "isCommentAutoGenerated" : true + }, "Copy Latest Crash Report" : { "localizations" : { "fr" : { @@ -5945,6 +5953,10 @@ } } }, + "Currently selected: %@" : { + "comment" : "A label showing the currently selected quick share service. The argument is the ID of the currently selected quick share service.", + "isCommentAutoGenerated" : true + }, "Custom" : { "localizations" : { "fr" : { @@ -8578,6 +8590,10 @@ }, "Expand Notch with Animation" : { + }, + "Expanded drag detection area" : { + "comment" : "A toggle that lets users enable or disable expanded drag detection.", + "isCommentAutoGenerated" : true }, "Expanded notch width" : { "comment" : "A label describing the expanded notch width setting.", @@ -8696,6 +8712,10 @@ } } }, + "Files dropped on the shelf will be shared via this service" : { + "comment" : "A description below the currently selected quick share provider, explaining that files dropped on the shelf will be shared via that provider.", + "isCommentAutoGenerated" : true + }, "Fill Notch" : { }, @@ -15168,6 +15188,14 @@ }, "Quick Presets" : { + }, + "Quick Share" : { + "comment" : "A section header that says \"Quick Share\".", + "isCommentAutoGenerated" : true + }, + "Quick Share Service" : { + "comment" : "A picker that lets the user select a quick share service to use when sharing files from the shelf.", + "isCommentAutoGenerated" : true }, "Quick Start" : { "comment" : "A label displayed above the quick start options in the timer tab.", @@ -15677,6 +15705,9 @@ } } } + }, + "Remove from shelf after dragging" : { + }, "Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight." : { "comment" : "A footer description of the HUDs feature.", diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index cbbb5f8..90509df 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -4,6 +4,7 @@ // // Created by Richard Kunkli on 07/08/2024. // +import AppKit import AVFoundation import Combine import Defaults @@ -628,6 +629,10 @@ struct SettingsView: View { // Shelf SettingsSearchEntry(tab: .shelf, title: "Enable shelf", keywords: ["shelf", "dock"], highlightID: SettingsTab.shelf.highlightID(for: "Enable shelf")), SettingsSearchEntry(tab: .shelf, title: "Open shelf tab by default if items added", keywords: ["auto open", "shelf tab"], highlightID: SettingsTab.shelf.highlightID(for: "Open shelf tab by default if items added")), + SettingsSearchEntry(tab: .shelf, title: "Expanded drag detection area", keywords: ["shelf", "drag"], highlightID: SettingsTab.shelf.highlightID(for: "Expanded drag detection area")), + SettingsSearchEntry(tab: .shelf, title: "Copy items on drag", keywords: ["shelf", "drag", "copy"], highlightID: SettingsTab.shelf.highlightID(for: "Copy items on drag")), + SettingsSearchEntry(tab: .shelf, title: "Remove from shelf after dragging", keywords: ["shelf", "drag", "remove"], highlightID: SettingsTab.shelf.highlightID(for: "Remove from shelf after dragging")), + SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), // Appearance SettingsSearchEntry(tab: .appearance, title: "Settings icon in notch", keywords: ["settings button", "toolbar"], highlightID: SettingsTab.appearance.highlightID(for: "Settings icon in notch")), @@ -1900,6 +1905,20 @@ struct About: View { } struct Shelf: View { + @Default(.quickShareProvider) var quickShareProvider + @Default(.expandedDragDetection) var expandedDragDetection + @Default(.copyOnDrag) var copyOnDrag + @Default(.autoRemoveShelfItems) var autoRemoveShelfItems + @StateObject private var quickShareService = QuickShareService.shared + + private var selectedProvider: QuickShareProvider? { + quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) + } + + init() { + Task { await QuickShareService.shared.discoverAvailableProviders() } + } + private func highlightID(_ title: String) -> String { SettingsTab.shelf.highlightID(for: title) } @@ -1909,14 +1928,88 @@ struct Shelf: View { Section { Defaults.Toggle("Enable shelf", key: .dynamicShelf) .settingsHighlight(id: highlightID("Enable shelf")) + Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault) .settingsHighlight(id: highlightID("Open shelf tab by default if items added")) + + Defaults.Toggle(key: .expandedDragDetection) { + Text("Expanded drag detection area") + } + .settingsHighlight(id: highlightID("Expanded drag detection area")) + + Defaults.Toggle(key: .copyOnDrag) { + Text("Copy items on drag") + } + .settingsHighlight(id: highlightID("Copy items on drag")) + + Defaults.Toggle(key: .autoRemoveShelfItems) { + Text("Remove from shelf after dragging") + } + .settingsHighlight(id: highlightID("Remove from shelf after dragging")) } header: { HStack { Text("General") } } + + Section { + Picker("Quick Share Service", selection: $quickShareProvider) { + ForEach(quickShareService.availableProviders, id: \.id) { provider in + HStack { + Group { + if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "square.and.arrow.up") + } + } + .frame(width: 16, height: 16) + .foregroundColor(.accentColor) + Text(provider.id) + } + .tag(provider.id) + } + } + .pickerStyle(.menu) + .settingsHighlight(id: highlightID("Quick Share Service")) + + if let selectedProvider { + HStack { + 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: 16, height: 16) + .foregroundColor(.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text("Currently selected: \(selectedProvider.id)") + .font(.caption) + .foregroundStyle(.secondary) + Text("Files dropped on the shelf will be shared via this service") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + } header: { + HStack { + Text("Quick Share") + } + } footer: { + Text("Choose which service to use when sharing files from the shelf. Drag files onto the shelf or click the shelf button to pick files.") + .font(.caption) + .foregroundStyle(.secondary) + } } + .accentColor(.effectiveAccent) .navigationTitle("Shelf") } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 1e99e09..7ff6b6e 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -504,6 +504,7 @@ extension Defaults.Keys { static let quickShareProvider = Key("quickShareProvider", default: "AirDrop") static let copyOnDrag = Key("copyOnDrag", default: false) static let autoRemoveShelfItems = Key("autoRemoveShelfItems", default: false) + static let expandedDragDetection = Key("expandedDragDetection", default: true) // MARK: Calendar static let calendarSelectionState = Key("calendarSelectionState", default: .all) From f9e95fc26eb77574002125ccf0d9a9641a978819 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 8 Dec 2025 14:26:16 +0530 Subject: [PATCH 3/4] BugFix: SettingsView Media sizing --- .../components/Settings/SettingsView.swift | 9 +++++---- DynamicIsland/lowbattery.mp3 | Bin 0 -> 21244 bytes DynamicIsland/managers/MemoryUsageMonitor.swift | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 DynamicIsland/lowbattery.mp3 diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 90509df..7dc8724 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1519,11 +1519,12 @@ struct Media: View { } private var mediaControlsGrid: some View { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 16) { mediaControlsRow(title: "Left button", selection: $musicAuxLeftControl, otherSelection: musicAuxRightControl) mediaControlsRow(title: "Right button", selection: $musicAuxRightControl, otherSelection: musicAuxLeftControl) } .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) } private func mediaControlsRow(title: String, selection: Binding, otherSelection: MusicAuxiliaryControl) -> some View { @@ -1532,7 +1533,7 @@ struct Media: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white) - HStack(spacing: 16) { + HStack(spacing: 12) { ForEach([MusicAuxiliaryControl.mediaOutput, MusicAuxiliaryControl.repeatMode, MusicAuxiliaryControl.lyrics, MusicAuxiliaryControl.shuffle], id: \.self) { control in MediaControlButton( control: control, @@ -1568,7 +1569,7 @@ struct Media: View { .font(.system(size: 22, weight: .medium)) .foregroundStyle(iconColor) } - .frame(width: 80, height: 60) + .frame(width: 68, height: 52) .onHover { hovering in if !disabled { withAnimation(.easeInOut(duration: 0.2)) { @@ -1582,7 +1583,7 @@ struct Media: View { .fontWeight(.medium) .lineLimit(2) .multilineTextAlignment(.center) - .frame(width: 100) + .frame(width: 80) .foregroundStyle(disabled ? .secondary : .primary) } .contentShape(Rectangle()) diff --git a/DynamicIsland/lowbattery.mp3 b/DynamicIsland/lowbattery.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..8e5c22a3f81835755c648a9a1c836f82ee9b738d GIT binary patch literal 21244 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 Date: Mon, 8 Dec 2025 15:50:51 +0530 Subject: [PATCH 4/4] Working Low Battery Indicator --- .../components/Settings/SettingsView.swift | 3 +++ .../models/BatteryStatusViewModel.swift | 23 +++++++++++++++++++ DynamicIsland/models/Constants.swift | 1 + 3 files changed, 27 insertions(+) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 7dc8724..8d2a3b0 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -586,6 +586,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .battery, title: "Show battery percentage", keywords: ["battery percent"], highlightID: SettingsTab.battery.highlightID(for: "Show battery percentage")), SettingsSearchEntry(tab: .battery, title: "Show power status notifications", keywords: ["notifications", "power"], highlightID: SettingsTab.battery.highlightID(for: "Show power status notifications")), SettingsSearchEntry(tab: .battery, title: "Show power status icons", keywords: ["power icons", "charging icon"], highlightID: SettingsTab.battery.highlightID(for: "Show power status icons")), + SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")), // HUDs SettingsSearchEntry(tab: .hud, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.hud.highlightID(for: "Show Bluetooth device connections")), @@ -1050,6 +1051,8 @@ struct Charge: View { .settingsHighlight(id: highlightID("Show battery indicator")) Defaults.Toggle("Show power status notifications", key: .showPowerStatusNotifications) .settingsHighlight(id: highlightID("Show power status notifications")) + Defaults.Toggle("Play low battery alert sound", key: .playLowBatteryAlertSound) + .settingsHighlight(id: highlightID("Play low battery alert sound")) } header: { Text("General") } diff --git a/DynamicIsland/models/BatteryStatusViewModel.swift b/DynamicIsland/models/BatteryStatusViewModel.swift index f8e9872..59cf53d 100644 --- a/DynamicIsland/models/BatteryStatusViewModel.swift +++ b/DynamicIsland/models/BatteryStatusViewModel.swift @@ -11,6 +11,8 @@ class BatteryStatusViewModel: ObservableObject { private var powerSourceChangedCallback: IOPowerSourceCallbackType? private var runLoopSource: Unmanaged? var animations: DynamicIslandAnimations = DynamicIslandAnimations() + private let lowBatteryAlertSoundPlayer = AudioPlayer() + private let lowBatteryAlertThresholds: [Float] = [20, 15, 10, 5] @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @@ -63,6 +65,8 @@ class BatteryStatusViewModel: ObservableObject { case .batteryLevelChanged(let level): print("🔋 Battery level: \(Int(level))%") + let previousLevel = self.levelBattery + self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) withAnimation { self.levelBattery = level } @@ -128,6 +132,25 @@ class BatteryStatusViewModel: ObservableObject { } } + private func handleLowBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) { + guard Defaults[.playLowBatteryAlertSound] else { return } + guard !isPluggedIn, !isCharging else { return } + guard newLevel < previousLevel else { return } + + for threshold in lowBatteryAlertThresholds { + if previousLevel >= threshold && newLevel < threshold { + self.statusText = "Low battery" + notifyImportanChangeStatus() + playLowBatteryAlertSound() + break + } + } + } + + private func playLowBatteryAlertSound() { + lowBatteryAlertSoundPlayer.play(fileName: "lowbattery", fileExtension: "mp3") + } + deinit { print("🔌 Cleaning up battery monitoring...") if let managerBatteryId: Int = managerBatteryId { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 7ff6b6e..8af0b38 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -482,6 +482,7 @@ extension Defaults.Keys { static let showBatteryIndicator = Key("showBatteryIndicator", default: true) static let showBatteryPercentage = Key("showBatteryPercentage", default: true) static let showPowerStatusIcons = Key("showPowerStatusIcons", default: true) + static let playLowBatteryAlertSound = Key("playLowBatteryAlertSound", default: true) // MARK: Downloads static let enableDownloadListener = Key("enableDownloadListener", default: true)