diff --git a/ClipboardManager/App/AppDelegate.swift b/ClipboardManager/App/AppDelegate.swift index 062265d..510374e 100644 --- a/ClipboardManager/App/AppDelegate.swift +++ b/ClipboardManager/App/AppDelegate.swift @@ -10,22 +10,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var launchAtLogin: LaunchAtLoginManaging? func applicationDidFinishLaunching(_ notification: Notification) { - let store: ClipboardStoring = UserDefaultsStore() + let imageFileStore = ImageFileStore() + let store: ClipboardStoring = UserDefaultsStore(imageFileStore: imageFileStore) let monitor: ClipboardMonitoring = ClipboardMonitor() let hotkey: HotkeyRegistering = HotkeyManager() let launchAtLogin: LaunchAtLoginManaging = LaunchAtLoginService() let viewModel = PopoverViewModel( fetchUseCase: FetchHistoryUseCase(store: store), - copyUseCase: CopyItemUseCase(), + copyUseCase: CopyItemUseCase(imageFileStore: imageFileStore), deleteUseCase: DeleteItemUseCase(store: store), clearUseCase: ClearHistoryUseCase(store: store) ) let statusBar = StatusBarController(viewModel: viewModel) - monitor.onNewItem = { [weak viewModel] raw in - let item = ClipboardItem.from(pasteboardString: raw) + monitor.onNewItem = { [weak viewModel] capture in + let item: ClipboardItem + switch capture { + case .text(let raw): + item = ClipboardItem.from(pasteboardString: raw) + case .image(let data, let pasteboardType): + guard let stored = imageFileStore.store(data: data, pasteboardType: pasteboardType) else { + return + } + item = ClipboardItem( + content: "Image", + type: .image, + imagePasteboardType: pasteboardType + ).storedImage( + imageFileName: stored.imageFileName, + imageDigest: stored.digest, + thumbnailFileName: stored.thumbnailFileName, + thumbnailFileURL: imageFileStore.thumbnailURL(fileName: stored.thumbnailFileName) + ) + } store.save(item) Task { @MainActor in viewModel?.refresh() } } diff --git a/ClipboardManager/Core/Models/ClipboardCapture.swift b/ClipboardManager/Core/Models/ClipboardCapture.swift new file mode 100644 index 0000000..62d01c0 --- /dev/null +++ b/ClipboardManager/Core/Models/ClipboardCapture.swift @@ -0,0 +1,6 @@ +import Foundation + +enum ClipboardCapture: Equatable { + case text(String) + case image(data: Data, pasteboardType: String) +} diff --git a/ClipboardManager/Core/Models/ClipboardItem.swift b/ClipboardManager/Core/Models/ClipboardItem.swift index 51e5040..1b91c59 100644 --- a/ClipboardManager/Core/Models/ClipboardItem.swift +++ b/ClipboardManager/Core/Models/ClipboardItem.swift @@ -5,17 +5,114 @@ struct ClipboardItem: Identifiable, Codable, Equatable { let content: String let type: ClipboardItemType let createdAt: Date + let imagePasteboardType: String? + let imageFileName: String? + let imageDigest: String? + let thumbnailFileName: String? + // Resolved by the store for presentation and never JSON-encoded. + let thumbnailFileURL: URL? + // Decoded only from the short-lived legacy format for one-time migration. + let legacyImageData: Data? init( id: UUID = UUID(), content: String, type: ClipboardItemType, - createdAt: Date = Date() + createdAt: Date = Date(), + imagePasteboardType: String? = nil, + imageFileName: String? = nil, + imageDigest: String? = nil, + thumbnailFileName: String? = nil, + thumbnailFileURL: URL? = nil, + legacyImageData: Data? = nil ) { self.id = id self.content = content self.type = type self.createdAt = createdAt + self.imagePasteboardType = imagePasteboardType + self.imageFileName = imageFileName + self.imageDigest = imageDigest + self.thumbnailFileName = thumbnailFileName + self.thumbnailFileURL = thumbnailFileURL + self.legacyImageData = legacyImageData + } + + func hasSamePayload(as other: ClipboardItem) -> Bool { + switch (type, other.type) { + case (.image, .image): + if let imageDigest, let otherDigest = other.imageDigest { + return imageDigest == otherDigest + } + return legacyImageData == other.legacyImageData + default: + return content == other.content + } + } + + func storedImage( + imageFileName: String, + imageDigest: String, + thumbnailFileName: String?, + thumbnailFileURL: URL? + ) -> ClipboardItem { + ClipboardItem( + id: id, + content: content, + type: type, + createdAt: createdAt, + imagePasteboardType: imagePasteboardType, + imageFileName: imageFileName, + imageDigest: imageDigest, + thumbnailFileName: thumbnailFileName, + thumbnailFileURL: thumbnailFileURL + ) + } + + func resolvingThumbnail(at url: URL?) -> ClipboardItem { + ClipboardItem( + id: id, + content: content, + type: type, + createdAt: createdAt, + imagePasteboardType: imagePasteboardType, + imageFileName: imageFileName, + imageDigest: imageDigest, + thumbnailFileName: thumbnailFileName, + thumbnailFileURL: url + ) + } + + private enum CodingKeys: String, CodingKey { + case id, content, type, createdAt + case imageData // Legacy field: decoded for one-time migration only. + case imagePasteboardType, imageFileName, imageDigest, thumbnailFileName + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + content = try container.decode(String.self, forKey: .content) + type = try container.decode(ClipboardItemType.self, forKey: .type) + createdAt = try container.decode(Date.self, forKey: .createdAt) + legacyImageData = try container.decodeIfPresent(Data.self, forKey: .imageData) + imagePasteboardType = try container.decodeIfPresent(String.self, forKey: .imagePasteboardType) + imageFileName = try container.decodeIfPresent(String.self, forKey: .imageFileName) + imageDigest = try container.decodeIfPresent(String.self, forKey: .imageDigest) + thumbnailFileName = try container.decodeIfPresent(String.self, forKey: .thumbnailFileName) + thumbnailFileURL = nil + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(content, forKey: .content) + try container.encode(type, forKey: .type) + try container.encode(createdAt, forKey: .createdAt) + try container.encodeIfPresent(imagePasteboardType, forKey: .imagePasteboardType) + try container.encodeIfPresent(imageFileName, forKey: .imageFileName) + try container.encodeIfPresent(imageDigest, forKey: .imageDigest) + try container.encodeIfPresent(thumbnailFileName, forKey: .thumbnailFileName) } static func from(pasteboardString: String, now: Date = Date()) -> ClipboardItem { diff --git a/ClipboardManager/Core/Models/ClipboardItemType.swift b/ClipboardManager/Core/Models/ClipboardItemType.swift index d330a1c..c6a3353 100644 --- a/ClipboardManager/Core/Models/ClipboardItemType.swift +++ b/ClipboardManager/Core/Models/ClipboardItemType.swift @@ -3,5 +3,6 @@ import Foundation enum ClipboardItemType: String, Codable, Equatable { case text case url + case image case unknown } diff --git a/ClipboardManager/Core/Protocols/ClipboardMonitoring.swift b/ClipboardManager/Core/Protocols/ClipboardMonitoring.swift index 9cf796b..ddd4109 100644 --- a/ClipboardManager/Core/Protocols/ClipboardMonitoring.swift +++ b/ClipboardManager/Core/Protocols/ClipboardMonitoring.swift @@ -1,7 +1,7 @@ import Foundation protocol ClipboardMonitoring: AnyObject { - var onNewItem: ((String) -> Void)? { get set } + var onNewItem: ((ClipboardCapture) -> Void)? { get set } func startMonitoring() func stopMonitoring() } diff --git a/ClipboardManager/Core/Services/ClipboardMonitor.swift b/ClipboardManager/Core/Services/ClipboardMonitor.swift index d415435..ef401d3 100644 --- a/ClipboardManager/Core/Services/ClipboardMonitor.swift +++ b/ClipboardManager/Core/Services/ClipboardMonitor.swift @@ -1,8 +1,9 @@ import AppKit import Foundation +import UniformTypeIdentifiers final class ClipboardMonitor: ClipboardMonitoring { - var onNewItem: ((String) -> Void)? + var onNewItem: ((ClipboardCapture) -> Void)? private let pollInterval: TimeInterval private let pasteboard: NSPasteboard @@ -36,9 +37,33 @@ final class ClipboardMonitor: ClipboardMonitoring { let current = pasteboard.changeCount guard current != lastChangeCount else { return } lastChangeCount = current + + if let imageCapture = makeImageCapture() { + onNewItem?(imageCapture) + return + } + guard let string = pasteboard.string(forType: .string) else { return } let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - onNewItem?(string) + onNewItem?(.text(string)) + } + + private func makeImageCapture() -> ClipboardCapture? { + let preferredTypes: [NSPasteboard.PasteboardType] = [.png, .tiff] + let otherImageTypes = (pasteboard.types ?? []).filter { type in + !preferredTypes.contains(type) && UTType(type.rawValue)?.conforms(to: .image) == true + } + + for type in preferredTypes + otherImageTypes { + if let data = pasteboard.data(forType: type), !data.isEmpty { + guard data.count <= ImageFileStore.maxItemBytes else { + AppLogger.monitor.warning("Skipped image larger than the 10 MB item limit") + return nil + } + return .image(data: data, pasteboardType: type.rawValue) + } + } + return nil } } diff --git a/ClipboardManager/Core/UseCases/CopyItemUseCase.swift b/ClipboardManager/Core/UseCases/CopyItemUseCase.swift index eb8a96e..24e898d 100644 --- a/ClipboardManager/Core/UseCases/CopyItemUseCase.swift +++ b/ClipboardManager/Core/UseCases/CopyItemUseCase.swift @@ -3,13 +3,24 @@ import Foundation final class CopyItemUseCase { private let pasteboard: NSPasteboard + private let imageFileStore: ImageFileStore - init(pasteboard: NSPasteboard = .general) { + init( + pasteboard: NSPasteboard = .general, + imageFileStore: ImageFileStore = ImageFileStore() + ) { self.pasteboard = pasteboard + self.imageFileStore = imageFileStore } func execute(item: ClipboardItem) { pasteboard.clearContents() - pasteboard.setString(item.content, forType: .string) + if item.type == .image, + let data = item.imageFileName.flatMap(imageFileStore.load), + let rawType = item.imagePasteboardType { + pasteboard.setData(data, forType: NSPasteboard.PasteboardType(rawType)) + } else { + pasteboard.setString(item.content, forType: .string) + } } } diff --git a/ClipboardManager/Infrastructure/Persistence/ImageFileStore.swift b/ClipboardManager/Infrastructure/Persistence/ImageFileStore.swift new file mode 100644 index 0000000..a69fcc9 --- /dev/null +++ b/ClipboardManager/Infrastructure/Persistence/ImageFileStore.swift @@ -0,0 +1,126 @@ +import CryptoKit +import Foundation +import ImageIO +import UniformTypeIdentifiers + +final class ImageFileStore { + struct StoredImage { + let digest: String + let imageFileName: String + let thumbnailFileName: String? + } + + static let maxItemBytes = 10 * 1_024 * 1_024 + static let maxTotalBytes = 100 * 1_024 * 1_024 + + private let fileManager: FileManager + private let directoryURL: URL + + init( + fileManager: FileManager = .default, + directoryURL: URL? = nil + ) { + self.fileManager = fileManager + self.directoryURL = directoryURL ?? Self.defaultDirectoryURL(fileManager: fileManager) + } + + func store(data: Data, pasteboardType: String) -> StoredImage? { + guard !data.isEmpty, data.count <= Self.maxItemBytes else { return nil } + guard ensureDirectoryExists() else { return nil } + + let digest = digest(for: data) + let fileExtension = UTType(pasteboardType)?.preferredFilenameExtension ?? "data" + let imageFileName = "\(digest).\(fileExtension)" + let imageURL = directoryURL.appendingPathComponent(imageFileName) + + do { + if !fileManager.fileExists(atPath: imageURL.path) { + try data.write(to: imageURL, options: .atomic) + } + + let thumbnailFileName = storeThumbnail(from: data, digest: digest) + return StoredImage( + digest: digest, + imageFileName: imageFileName, + thumbnailFileName: thumbnailFileName + ) + } catch { + AppLogger.store.error("Failed to store clipboard image: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + func load(fileName: String) -> Data? { + try? Data(contentsOf: fileURL(for: fileName), options: .mappedIfSafe) + } + + func fileSize(fileName: String) -> Int { + let attributes = try? fileManager.attributesOfItem(atPath: fileURL(for: fileName).path) + return (attributes?[.size] as? NSNumber)?.intValue ?? 0 + } + + func thumbnailURL(fileName: String?) -> URL? { + guard let fileName else { return nil } + let url = fileURL(for: fileName) + return fileManager.fileExists(atPath: url.path) ? url : nil + } + + func delete(imageFileName: String?, thumbnailFileName: String?) { + for fileName in [imageFileName, thumbnailFileName].compactMap({ $0 }) { + try? fileManager.removeItem(at: fileURL(for: fileName)) + } + } + + func clear() { + try? fileManager.removeItem(at: directoryURL) + } + + private func digest(for data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private func storeThumbnail(from data: Data, digest: String) -> String? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 96 + ] + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + + let fileName = "\(digest)-thumbnail.png" + let url = fileURL(for: fileName) + guard let destination = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) else { return nil } + + CGImageDestinationAddImage(destination, thumbnail, nil) + return CGImageDestinationFinalize(destination) ? fileName : nil + } + + private func ensureDirectoryExists() -> Bool { + do { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return true + } catch { + AppLogger.store.error("Failed to create image directory: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func fileURL(for fileName: String) -> URL { + directoryURL.appendingPathComponent(fileName) + } + + private static func defaultDirectoryURL(fileManager: FileManager) -> URL { + let applicationSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return applicationSupport + .appendingPathComponent("CopyIT", isDirectory: true) + .appendingPathComponent("ClipboardImages", isDirectory: true) + } +} diff --git a/ClipboardManager/Infrastructure/Persistence/UserDefaultsStore.swift b/ClipboardManager/Infrastructure/Persistence/UserDefaultsStore.swift index 0ae1da4..ef7db08 100644 --- a/ClipboardManager/Infrastructure/Persistence/UserDefaultsStore.swift +++ b/ClipboardManager/Infrastructure/Persistence/UserDefaultsStore.swift @@ -5,18 +5,34 @@ final class UserDefaultsStore: ClipboardStoring { static let maxHistoryCount = 50 private let defaults: UserDefaults + private let imageFileStore: ImageFileStore private let decoder = JSONDecoder() private let encoder = JSONEncoder() - init(defaults: UserDefaults = .standard) { + init( + defaults: UserDefaults = .standard, + imageFileStore: ImageFileStore = ImageFileStore() + ) { self.defaults = defaults + self.imageFileStore = imageFileStore } func save(_ item: ClipboardItem) { var current = fetchAll() - current.removeAll { $0.content == item.content } + for replacedItem in current.filter({ $0.hasSamePayload(as: item) }) { + imageFileStore.delete( + imageFileName: replacedItem.imageFileName == item.imageFileName ? nil : replacedItem.imageFileName, + thumbnailFileName: replacedItem.thumbnailFileName == item.thumbnailFileName ? nil : replacedItem.thumbnailFileName + ) + } + current.removeAll { $0.hasSamePayload(as: item) } current.insert(item, at: 0) + enforceImageBudget(in: ¤t) + if current.count > Self.maxHistoryCount { + for removedItem in current.dropFirst(Self.maxHistoryCount) { + deleteImageFiles(for: removedItem) + } current = Array(current.prefix(Self.maxHistoryCount)) } persist(current) @@ -25,21 +41,83 @@ final class UserDefaultsStore: ClipboardStoring { func fetchAll() -> [ClipboardItem] { guard let data = defaults.data(forKey: Self.storageKey) else { return [] } guard let decoded = try? decoder.decode([ClipboardItem].self, from: data) else { return [] } - return decoded + + var didMigrate = false + var migrated = decoded.compactMap { item -> ClipboardItem? in + guard item.type == .image, item.imageFileName == nil, item.legacyImageData != nil else { + return item + } + didMigrate = true + return prepareForPersistence(item) + } + if didMigrate { + enforceImageBudget(in: &migrated) + persist(migrated) + } + + return migrated.map(resolveThumbnail) } func delete(_ item: ClipboardItem) { var current = fetchAll() current.removeAll { $0.id == item.id } + deleteImageFiles(for: item) persist(current) } func clear() { defaults.removeObject(forKey: Self.storageKey) + imageFileStore.clear() } private func persist(_ items: [ClipboardItem]) { guard let data = try? encoder.encode(items) else { return } defaults.set(data, forKey: Self.storageKey) } + + private func prepareForPersistence(_ item: ClipboardItem) -> ClipboardItem? { + guard item.type == .image else { return item } + guard let imageData = item.legacyImageData, + let pasteboardType = item.imagePasteboardType, + let stored = imageFileStore.store(data: imageData, pasteboardType: pasteboardType) else { + return item.imageFileName == nil ? nil : item + } + + return item.storedImage( + imageFileName: stored.imageFileName, + imageDigest: stored.digest, + thumbnailFileName: stored.thumbnailFileName, + thumbnailFileURL: imageFileStore.thumbnailURL(fileName: stored.thumbnailFileName) + ) + } + + private func resolveThumbnail(for item: ClipboardItem) -> ClipboardItem { + guard item.type == .image else { return item } + return item.resolvingThumbnail( + at: imageFileStore.thumbnailURL(fileName: item.thumbnailFileName) + ) + } + + private func enforceImageBudget(in items: inout [ClipboardItem]) { + func storedImageBytes() -> Int { + items.reduce(into: 0) { total, item in + guard let fileName = item.imageFileName else { return } + total += imageFileStore.fileSize(fileName: fileName) + } + } + + while storedImageBytes() > ImageFileStore.maxTotalBytes, + let oldestImageIndex = items.lastIndex(where: { $0.type == .image }) { + let removedItem = items.remove(at: oldestImageIndex) + deleteImageFiles(for: removedItem) + } + } + + private func deleteImageFiles(for item: ClipboardItem) { + guard item.type == .image else { return } + imageFileStore.delete( + imageFileName: item.imageFileName, + thumbnailFileName: item.thumbnailFileName + ) + } } diff --git a/ClipboardManager/UI/Popover/ClipboardItemRow.swift b/ClipboardManager/UI/Popover/ClipboardItemRow.swift index 521e7bf..425777e 100644 --- a/ClipboardManager/UI/Popover/ClipboardItemRow.swift +++ b/ClipboardManager/UI/Popover/ClipboardItemRow.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ClipboardItemRow: View { @@ -11,9 +12,7 @@ struct ClipboardItemRow: View { var body: some View { Button(action: onCopy) { HStack(spacing: 10) { - Image(systemName: iconName) - .foregroundStyle(.secondary) - .frame(width: 16) + itemPreview VStack(alignment: .leading, spacing: 2) { Text(item.content) .lineLimit(1) @@ -46,6 +45,20 @@ struct ClipboardItemRow: View { } } + @ViewBuilder + private var itemPreview: some View { + if item.type == .image, + let thumbnailURL = item.thumbnailFileURL { + ThumbnailImageView(fileURL: thumbnailURL) + .frame(width: 40, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + } else { + Image(systemName: iconName) + .foregroundStyle(.secondary) + .frame(width: 16) + } + } + private var backgroundColor: Color { if isSelected { return Color.accentColor.opacity(0.22) } if isHovering { return Color.accentColor.opacity(0.12) } @@ -56,7 +69,47 @@ struct ClipboardItemRow: View { switch item.type { case .text: return "doc.text" case .url: return "link" + case .image: return "photo" case .unknown: return "questionmark.square" } } } + +private struct ThumbnailImageView: NSViewRepresentable { + private static let cache = NSCache() + + let fileURL: URL + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + updateImage(in: imageView, context: context) + return imageView + } + + func updateNSView(_ imageView: NSImageView, context: Context) { + guard context.coordinator.loadedURL != fileURL else { return } + updateImage(in: imageView, context: context) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + private func updateImage(in imageView: NSImageView, context: Context) { + let cacheKey = fileURL as NSURL + if let cachedImage = Self.cache.object(forKey: cacheKey) { + imageView.image = cachedImage + } else if let thumbnail = NSImage(contentsOf: fileURL) { + Self.cache.setObject(thumbnail, forKey: cacheKey) + imageView.image = thumbnail + } else { + imageView.image = nil + } + context.coordinator.loadedURL = fileURL + } + + final class Coordinator { + var loadedURL: URL? + } +} diff --git a/README.md b/README.md index 66d4110..5612b75 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Native macOS menu bar clipboard manager. Open source, MIT-licensed. ## Features (v1.0) - Lives in the menu bar — no Dock icon, no `⌘Tab` clutter (`LSUIElement`). -- Tracks the last 50 clipboard entries (text + URLs). +- Tracks the last 50 clipboard entries (text, URLs, and images). - Search with live filtering, case-insensitive. - Keyboard navigation: arrows to move, `↩` to copy, `⌫` to delete, `esc` to close. - Global hotkey `⌥Space` to toggle the popover from anywhere. - Right-click the status bar icon for quick actions (Open, Clear, Quit). - Launches at login (`SMAppService`). -- Persists history across sessions (`UserDefaults`). +- Persists history metadata in `UserDefaults` and image payloads in Application Support. ## Install diff --git a/Tests/CoreTests/ClipboardMonitorTests.swift b/Tests/CoreTests/ClipboardMonitorTests.swift index e455f9c..bf3e0fe 100644 --- a/Tests/CoreTests/ClipboardMonitorTests.swift +++ b/Tests/CoreTests/ClipboardMonitorTests.swift @@ -1,4 +1,5 @@ import XCTest +import AppKit @testable import ClipboardManager final class ClipboardMonitorTests: XCTestCase { @@ -22,4 +23,17 @@ final class ClipboardMonitorTests: XCTestCase { let item = ClipboardItem.from(pasteboardString: " ") XCTAssertEqual(item.type, .unknown) } + + func test_imageCapture_preservesImagePayload() { + let data = Data([0x89, 0x50, 0x4E, 0x47]) + let capture = ClipboardCapture.image( + data: data, + pasteboardType: NSPasteboard.PasteboardType.png.rawValue + ) + + XCTAssertEqual( + capture, + .image(data: data, pasteboardType: NSPasteboard.PasteboardType.png.rawValue) + ) + } } diff --git a/Tests/CoreTests/ClipboardStoreTests.swift b/Tests/CoreTests/ClipboardStoreTests.swift index 4258511..ec5d9e5 100644 --- a/Tests/CoreTests/ClipboardStoreTests.swift +++ b/Tests/CoreTests/ClipboardStoreTests.swift @@ -1,21 +1,30 @@ import XCTest +import AppKit @testable import ClipboardManager final class ClipboardStoreTests: XCTestCase { private var defaults: UserDefaults! private var sut: UserDefaultsStore! + private var imageFileStore: ImageFileStore! + private var imageDirectory: URL! private let suiteName = "ClipboardStoreTests.suite" override func setUp() { super.setUp() defaults = UserDefaults(suiteName: suiteName) defaults.removePersistentDomain(forName: suiteName) - sut = UserDefaultsStore(defaults: defaults) + imageDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ClipboardStoreTests-\(UUID().uuidString)") + imageFileStore = ImageFileStore(directoryURL: imageDirectory) + sut = UserDefaultsStore(defaults: defaults, imageFileStore: imageFileStore) } override func tearDown() { defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: imageDirectory) sut = nil + imageFileStore = nil + imageDirectory = nil defaults = nil super.tearDown() } @@ -46,10 +55,103 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertEqual(sut.fetchAll().count, 1) } + func test_saveAndFetch_persistsImageOutsideUserDefaults() { + let data = Data([1, 2, 3, 4]) + let item = makeStoredImage(data: data) + + sut.save(item) + + let savedItem = sut.fetchAll().first + XCTAssertNil(savedItem?.legacyImageData) + XCTAssertEqual(savedItem?.type, .image) + XCTAssertNotNil(savedItem?.imageFileName) + XCTAssertEqual(savedItem?.imageFileName.flatMap(imageFileStore.load), data) + XCTAssertFalse(String(data: defaults.data(forKey: "com.copyit.clipboard.history")!, encoding: .utf8)!.contains("AQIDBA==")) + } + + func test_save_dedupesImagesByDataInsteadOfDisplayLabel() { + sut.save(makeStoredImage(data: Data([1]))) + sut.save(makeStoredImage(data: Data([2]))) + sut.save(makeStoredImage(data: Data([1]))) + + let items = sut.fetchAll() + XCTAssertEqual(items.count, 2) + XCTAssertEqual(items.first?.imageFileName.flatMap(imageFileStore.load), Data([1])) + } + + func test_save_skipsImageAbovePerItemBudget() { + let oversizedData = Data(count: ImageFileStore.maxItemBytes + 1) + + let stored = imageFileStore.store(data: oversizedData, pasteboardType: "public.png") + + XCTAssertNil(stored) + } + + func test_store_generatesSmallThumbnailDuringIngestion() throws { + let representation = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 400, + pixelsHigh: 200, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )) + let imageData = try XCTUnwrap(representation.representation(using: .png, properties: [:])) + + let stored = try XCTUnwrap(imageFileStore.store(data: imageData, pasteboardType: "public.png")) + let thumbnailURL = try XCTUnwrap(imageFileStore.thumbnailURL(fileName: stored.thumbnailFileName)) + let thumbnail = try XCTUnwrap(NSImage(contentsOf: thumbnailURL)) + + XCTAssertLessThanOrEqual(max(thumbnail.size.width, thumbnail.size.height), 96) + } + + func test_fetchAll_migratesLegacyImageDataToFileStorage() throws { + let legacyData = Data([5, 6, 7]) + let legacyJSON: [[String: Any]] = [[ + "id": UUID().uuidString, + "content": "Image", + "type": "image", + "createdAt": Date().timeIntervalSinceReferenceDate, + "imageData": legacyData.base64EncodedString(), + "imagePasteboardType": "public.png" + ]] + defaults.set( + try JSONSerialization.data(withJSONObject: legacyJSON), + forKey: "com.copyit.clipboard.history" + ) + + let migratedItem = try XCTUnwrap(sut.fetchAll().first) + + XCTAssertNil(migratedItem.legacyImageData) + XCTAssertEqual(migratedItem.imageFileName.flatMap(imageFileStore.load), legacyData) + let persistedJSON = try XCTUnwrap( + String(data: defaults.data(forKey: "com.copyit.clipboard.history")!, encoding: .utf8) + ) + XCTAssertFalse(persistedJSON.contains("BQYH")) + } + func test_save_capsAt50Items() { for index in 0..<60 { sut.save(ClipboardItem(content: "item-\(index)", type: .text)) } XCTAssertEqual(sut.fetchAll().count, UserDefaultsStore.maxHistoryCount) } + + private func makeStoredImage(data: Data) -> ClipboardItem { + let stored = imageFileStore.store(data: data, pasteboardType: "public.png")! + return ClipboardItem( + content: "Image", + type: .image, + imagePasteboardType: "public.png" + ).storedImage( + imageFileName: stored.imageFileName, + imageDigest: stored.digest, + thumbnailFileName: stored.thumbnailFileName, + thumbnailFileURL: imageFileStore.thumbnailURL(fileName: stored.thumbnailFileName) + ) + } } diff --git a/Tests/CoreTests/UseCaseTests.swift b/Tests/CoreTests/UseCaseTests.swift index 6084dac..bfceab0 100644 --- a/Tests/CoreTests/UseCaseTests.swift +++ b/Tests/CoreTests/UseCaseTests.swift @@ -1,4 +1,5 @@ import XCTest +import AppKit @testable import ClipboardManager final class UseCaseTests: XCTestCase { @@ -38,4 +39,29 @@ final class UseCaseTests: XCTestCase { XCTAssertTrue(store.clearCalled) XCTAssertTrue(store.savedItems.isEmpty) } + + func test_copyItem_writesPersistedImageBackToPasteboard() throws { + let pasteboard = NSPasteboard(name: NSPasteboard.Name(UUID().uuidString)) + let data = Data([1, 2, 3]) + let imageDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("CopyItemUseCaseTests-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: imageDirectory) } + let imageFileStore = ImageFileStore(directoryURL: imageDirectory) + let stored = try XCTUnwrap(imageFileStore.store(data: data, pasteboardType: "public.png")) + let item = ClipboardItem( + content: "Image", + type: .image, + imagePasteboardType: NSPasteboard.PasteboardType.png.rawValue + ).storedImage( + imageFileName: stored.imageFileName, + imageDigest: stored.digest, + thumbnailFileName: stored.thumbnailFileName, + thumbnailFileURL: nil + ) + let sut = CopyItemUseCase(pasteboard: pasteboard, imageFileStore: imageFileStore) + + sut.execute(item: item) + + XCTAssertEqual(pasteboard.data(forType: .png), data) + } }