From bb0b66a50e9b16375ceadc628a51baa34edc6f9b Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 17 Jul 2025 21:51:08 +0530 Subject: [PATCH] Fully Working Clipboard --- DynamicIsland/managers/ClipboardManager.swift | 315 +++++++++++++++--- 1 file changed, 270 insertions(+), 45 deletions(-) diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift index 7ab1afe..b6ede38 100644 --- a/DynamicIsland/managers/ClipboardManager.swift +++ b/DynamicIsland/managers/ClipboardManager.swift @@ -9,44 +9,106 @@ import AppKit import SwiftUI import Combine import Foundation +import UniformTypeIdentifiers // Clipboard item data structure struct ClipboardItem: Identifiable, Codable { let id = UUID() - let content: String let type: ClipboardItemType let timestamp: Date let preview: String - init(content: String, type: ClipboardItemType) { - self.content = content + // Store different types of data - avoid large binary data in UserDefaults + let stringData: String? + let imageFileName: String? // Store filename instead of data + let fileURLs: [String]? + let rtfData: Data? // RTF is typically small, so we can keep this + + init(stringData: String, type: ClipboardItemType) { + self.stringData = stringData + self.imageFileName = nil + self.fileURLs = nil + self.rtfData = nil self.type = type self.timestamp = Date() - self.preview = ClipboardItem.generatePreview(content: content, type: type) + self.preview = ClipboardItem.generatePreview(stringData: stringData, type: type) } - static func generatePreview(content: String, type: ClipboardItemType) -> String { + init(imageData: Data) { + self.stringData = nil + self.fileURLs = nil + self.rtfData = nil + self.type = .image + self.timestamp = Date() + + // Save image data to temporary file instead of storing in UserDefaults + let fileName = "clipboard_image_\(UUID().uuidString).png" + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + + do { + try imageData.write(to: fileURL) + self.imageFileName = fileName + self.preview = "Image (\(ByteCountFormatter.string(fromByteCount: Int64(imageData.count), countStyle: .file)))" + } catch { + print("Failed to save image data: \(error)") + self.imageFileName = nil + self.preview = "Image (failed to save)" + } + } + + init(fileURLs: [String]) { + self.stringData = nil + self.imageFileName = nil + self.fileURLs = fileURLs + self.rtfData = nil + self.type = .file + self.timestamp = Date() + + if fileURLs.count == 1, let url = URL(string: fileURLs.first!) { + self.preview = url.lastPathComponent + } else { + self.preview = "\(fileURLs.count) files" + } + } + + init(rtfData: Data, plainText: String) { + // RTF data is typically small, so we can keep it in UserDefaults + self.stringData = plainText + self.imageFileName = nil + self.fileURLs = nil + self.rtfData = rtfData.count > 100000 ? nil : rtfData // Skip very large RTF files + self.type = .rtf + self.timestamp = Date() + self.preview = String(plainText.prefix(50)) + } + + // Helper to get image data from file + func getImageData() -> Data? { + guard let fileName = imageFileName else { return nil } + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + return try? Data(contentsOf: fileURL) + } + + static func generatePreview(stringData: String, type: ClipboardItemType) -> String { switch type { case .text: - return String(content.prefix(50)) + return String(stringData.prefix(50)) case .url: - if let url = URL(string: content) { - return url.lastPathComponent.isEmpty ? url.host ?? content : url.lastPathComponent + if let url = URL(string: stringData) { + return url.lastPathComponent.isEmpty ? url.host ?? stringData : url.lastPathComponent } - return String(content.prefix(50)) + return String(stringData.prefix(50)) case .file: - if let url = URL(string: content) { + if let url = URL(string: stringData) { return url.lastPathComponent } return "File" case .image: return "Image" case .rtf: - // Remove RTF formatting for preview - let stripped = content.replacingOccurrences(of: "\\[^\\s]*\\s?", with: "", options: .regularExpression) - return String(stripped.prefix(50)) + return String(stringData.prefix(50)) case .unknown: - return String(content.prefix(50)) + return String(stringData.prefix(50)) } } } @@ -92,9 +154,25 @@ class ClipboardManager: ObservableObject { private var lastChangeCount: Int = 0 private let maxHistoryItems = 3 + // Directory for storing clipboard data files + static let clipboardDataDirectory: URL = { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let clipboardDir = documentsPath.appendingPathComponent("ClipboardData") + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: clipboardDir, withIntermediateDirectories: true) + + return clipboardDir + }() + private init() { lastChangeCount = NSPasteboard.general.changeCount loadHistoryFromDefaults() + cleanupOldFiles() + } + + deinit { + stopMonitoring() } // MARK: - Public Methods @@ -115,16 +193,58 @@ class ClipboardManager: ObservableObject { } func copyToClipboard(_ item: ClipboardItem) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(item.content, forType: .string) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + switch item.type { + case .text, .url: + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + case .image: + if let imageData = item.getImageData() { + pasteboard.setData(imageData, forType: .png) + } + case .file: + if let fileURLs = item.fileURLs { + let urls = fileURLs.compactMap { URL(string: $0) } + pasteboard.writeObjects(urls as [NSPasteboardWriting]) + } + case .rtf: + if let rtfData = item.rtfData { + pasteboard.setData(rtfData, forType: .rtf) + } + // Also set plain text as fallback + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + case .unknown: + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + } } func deleteItem(_ item: ClipboardItem) { + // Clean up associated files + if let fileName = item.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + clipboardHistory.removeAll { $0.id == item.id } saveHistoryToDefaults() } func clearHistory() { + // Clean up all associated files + for item in clipboardHistory { + if let fileName = item.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + clipboardHistory.removeAll() saveHistoryToDefaults() } @@ -140,7 +260,7 @@ class ClipboardManager: ObservableObject { guard let clipboardItem = getCurrentClipboardItem() else { return } // Don't add duplicate items - if !clipboardHistory.contains(where: { $0.content == clipboardItem.content && $0.type == clipboardItem.type }) { + if !clipboardHistory.contains(where: { isSameContent($0, clipboardItem) }) { addToHistory(clipboardItem) } } @@ -148,34 +268,84 @@ class ClipboardManager: ObservableObject { private func getCurrentClipboardItem() -> ClipboardItem? { let pasteboard = NSPasteboard.general - // Check for URL first - if let url = pasteboard.string(forType: .URL) ?? pasteboard.string(forType: .fileURL) { - return ClipboardItem(content: url, type: .url) - } + // Step 1: Check what types are available + let hasFileURLs = pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) + let hasImageData = pasteboard.data(forType: .png) != nil || + pasteboard.data(forType: .tiff) != nil || + pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) != nil + let hasString = pasteboard.string(forType: .string) != nil - // Check for file URLs - if let fileURLs = pasteboard.propertyList(forType: .fileURL) as? [String], !fileURLs.isEmpty { - return ClipboardItem(content: fileURLs.first!, type: .file) - } + // Step 2: Smart detection based on context - // Check for images - if pasteboard.canReadItem(withDataConformingToTypes: [NSPasteboard.PasteboardType.tiff.rawValue, NSPasteboard.PasteboardType.png.rawValue]) { - return ClipboardItem(content: "Image data", type: .image) - } - - // Check for RTF - if let rtfData = pasteboard.data(forType: .rtf), - let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string { - return ClipboardItem(content: rtfString, type: .rtf) - } - - // Check for plain text - if let string = pasteboard.string(forType: .string) { - // Determine if it's a URL - if string.hasPrefix("http://") || string.hasPrefix("https://") || string.hasPrefix("file://") { - return ClipboardItem(content: string, type: .url) + // Priority 1: If there are file URLs AND the files are actual image files, treat as image files (from Finder) + if hasFileURLs { + if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { + let realFileURLs = fileURLs.filter { url in + return url.isFileURL && + !url.path.contains("/.file/id=") && + !url.path.contains("/tmp/") && + !url.path.hasPrefix("/private/var/") && + !url.path.contains("/ClipboardViewer") && + FileManager.default.fileExists(atPath: url.path) + } + + if !realFileURLs.isEmpty { + // Check if these are image files - if so, try to load the actual image data + let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"] + let imageFiles = realFileURLs.filter { url in + imageExtensions.contains(url.pathExtension.lowercased()) + } + + // If we have image files from Finder, load the actual image data + if !imageFiles.isEmpty, let firstImageURL = imageFiles.first { + if let imageData = try? Data(contentsOf: firstImageURL) { + return ClipboardItem(imageData: imageData) + } + } + + // Otherwise, treat as file(s) + let urlStrings = realFileURLs.map { $0.absoluteString } + return ClipboardItem(fileURLs: urlStrings) + } } - return ClipboardItem(content: string, type: .text) + } + + // Priority 2: If there's ONLY image data without file URLs (screenshots, direct image paste) + if hasImageData && !hasFileURLs { + if let imageData = pasteboard.data(forType: .png) { + return ClipboardItem(imageData: imageData) + } else if let imageData = pasteboard.data(forType: .tiff) { + return ClipboardItem(imageData: imageData) + } else if let imageData = pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) { + return ClipboardItem(imageData: imageData) + } + } + + // Priority 3: Plain text (including copied text) + if let string = pasteboard.string(forType: .string), !string.isEmpty { + // Determine if it's a URL + if string.hasPrefix("http://") || string.hasPrefix("https://") { + return ClipboardItem(stringData: string, type: .url) + } + return ClipboardItem(stringData: string, type: .text) + } + + // Priority 4: RTF + if let rtfData = pasteboard.data(forType: .rtf), + let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string, !rtfString.isEmpty { + return ClipboardItem(rtfData: rtfData, plainText: rtfString) + } + + // Priority 5: If we have image data WITH file URLs (document thumbnails), + // this is likely a document with a preview - ignore the thumbnail and treat as unknown + if hasImageData && hasFileURLs { + // This is likely a document with a thumbnail preview - we don't want the thumbnail + return nil + } + + // Priority 6: URL strings + if let url = pasteboard.string(forType: .URL) { + return ClipboardItem(stringData: url, type: .url) } return nil @@ -185,13 +355,35 @@ class ClipboardManager: ObservableObject { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - // Remove any existing items with the same content - self.clipboardHistory.removeAll { $0.content == item.content } + // Remove any existing items with the same data + let itemsToRemove = self.clipboardHistory.filter { existingItem in + return self.isSameContent(existingItem, item) + } + + // Clean up files for items being removed + for oldItem in itemsToRemove { + if let fileName = oldItem.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + + self.clipboardHistory.removeAll { existingItem in + return self.isSameContent(existingItem, item) + } // Add to beginning of array self.clipboardHistory.insert(item, at: 0) - // Keep only the most recent items + // Keep only the most recent items and clean up old files + let itemsToDelete = Array(self.clipboardHistory.dropFirst(self.maxHistoryItems)) + for oldItem in itemsToDelete { + if let fileName = oldItem.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + if self.clipboardHistory.count > self.maxHistoryItems { self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems)) } @@ -200,6 +392,39 @@ class ClipboardManager: ObservableObject { } } + // Helper to compare clipboard items for duplicates + private func isSameContent(_ item1: ClipboardItem, _ item2: ClipboardItem) -> Bool { + if item1.type != item2.type { return false } + + switch item1.type { + case .text, .url, .unknown: + return item1.stringData == item2.stringData + case .image: + // For images, compare the actual data if both are available + let data1 = item1.getImageData() + let data2 = item2.getImageData() + return data1 == data2 + case .file: + return item1.fileURLs == item2.fileURLs + case .rtf: + return item1.stringData == item2.stringData && item1.rtfData == item2.rtfData + } + } + + // Clean up old image files that are no longer referenced + private func cleanupOldFiles() { + guard let files = try? FileManager.default.contentsOfDirectory(at: ClipboardManager.clipboardDataDirectory, includingPropertiesForKeys: nil) else { return } + + let referencedFiles = Set(clipboardHistory.compactMap { $0.imageFileName }) + + for file in files { + let fileName = file.lastPathComponent + if !referencedFiles.contains(fileName) { + try? FileManager.default.removeItem(at: file) + } + } + } + // MARK: - Persistence private func saveHistoryToDefaults() {