diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index f3d8463..43121cc 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -23,11 +23,12 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 5. Copy an image and confirm it appears as an Image with a thumbnail. 6. Enable `Search in image labels`, copy an image containing readable text, and confirm searching for that text finds the Image. 7. Copy a sound clip and confirm it appears as Audio. -8. Copy a PDF or PDF selection and confirm it appears as a PDF. -9. Copy one Finder file and confirm it appears as a File. -10. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count. -11. Copy formatted text from a browser or Mail message and confirm it appears as Rich Text rather than flattened plain text. -12. Disable Images, Audio, Rich Text, PDFs, or Files in Settings > Capture, copy that type again, and confirm it is not captured. +8. Copy a movie or video clip and confirm it appears as Video. +9. Copy a PDF or PDF selection and confirm it appears as a PDF. +10. Copy one Finder file and confirm it appears as a File. +11. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count. +12. Copy formatted text from a browser or Mail message and confirm it appears as Rich Text rather than flattened plain text. +13. Disable Images, Audio, Video, Rich Text, PDFs, or Files in Settings > Capture, copy that type again, and confirm it is not captured. ## Panel @@ -68,7 +69,8 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view. 36. Copy a color swatch from a design tool and confirm it appears as a Color card, can be filtered with the Colors chip, and copies back as both a color and hex text. 37. Copy a code snippet from an editor and confirm it appears as a Code card, remains visible in the Text chip, can be isolated with the Code chip or `type:code`, and copies back as plain text. -38. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order. +38. Copy a video/movie clip and confirm it appears as a Video card, filters with the Videos chip or `type:video`, `type:movie`, and `mp4`, previews/opens as a temp movie, and copies back as movie data. +39. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order. ## Copy And Paste @@ -76,11 +78,12 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 2. Select a URL item and confirm the system clipboard contains both string and URL data by pasting into a browser address bar. 3. Select one-file and multi-file File items and paste into Finder or an app that accepts file references. Confirm all files are preserved for the multi-file item. 4. Select an audio item and paste into an app that accepts sound pasteboard data. -5. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data. -6. Select a rich text item and paste into TextEdit rich text mode or Mail. Confirm basic formatting is preserved and plain-text paste still works in a text-only field. -7. Press `Command + 1` through `Command + 9` on visible numbered cards and confirm the matching card is pasted or copied; add `Shift` and confirm URL/rich items paste as plain text only. -8. Without Accessibility permission, confirm paste actions copy and show the permission fallback status. -9. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item. +5. Select a Video item and paste into an app that accepts movie pasteboard data. +6. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data. +7. Select a rich text item and paste into TextEdit rich text mode or Mail. Confirm basic formatting is preserved and plain-text paste still works in a text-only field. +8. Press `Command + 1` through `Command + 9` on visible numbered cards and confirm the matching card is pasted or copied; add `Shift` and confirm URL/rich items paste as plain text only. +9. Without Accessibility permission, confirm paste actions copy and show the permission fallback status. +10. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item. ## Settings @@ -97,11 +100,11 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 1. Open the data folder from Settings > Data. 2. Confirm `history.sqlite` exists after capture. 3. Copy unique text and confirm `strings ~/Library/Application\ Support/ClipBored/history.sqlite | grep "unique text"` does not find it. -4. Copy uniquely identifiable rich text/audio/PDF data and confirm `strings ~/Library/Application\ Support/ClipBored/attachments/* | grep "unique text"` does not find it. +4. Copy uniquely identifiable rich text/audio/video/PDF data and confirm `strings ~/Library/Application\ Support/ClipBored/attachments/* | grep "unique text"` does not find it. 5. If `history-encryption.key` exists, confirm it is readable only by the current user. -6. Confirm image files are under `images/` and rich text/audio/PDF attachments are under `attachments/`. +6. Confirm image files are under `images/` and rich text/audio/video/PDF attachments are under `attachments/`. 7. Confirm app storage is local to `~/Library/Application Support/ClipBored`. -8. Open or reveal an encrypted image/audio/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed. +8. Open or reveal an encrypted image/audio/video/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed. 9. Use `Clear Clipboard History` and confirm saved history, app-managed attachments, temporary previews, and `history-encryption.key` are removed when that fallback key exists. 10. Confirm quitting with `Clear history on quit` enabled removes history and app-managed cache/attachment files. diff --git a/sources/clipbored/models/ClipboardItem.swift b/sources/clipbored/models/ClipboardItem.swift index fc94cdc..f752c82 100644 --- a/sources/clipbored/models/ClipboardItem.swift +++ b/sources/clipbored/models/ClipboardItem.swift @@ -11,6 +11,7 @@ enum ClipboardItemKind: Int { case audio case color case code + case video var displayName: String { switch self { @@ -24,6 +25,7 @@ enum ClipboardItemKind: Int { case .audio: return "audio" case .color: return "color" case .code: return "code" + case .video: return "video" } } } @@ -31,7 +33,7 @@ enum ClipboardItemKind: Int { extension ClipboardItemKind { var canOpen: Bool { switch self { - case .url, .file, .image, .pdf, .audio: + case .url, .file, .image, .pdf, .audio, .video: return true case .text, .richText, .unknown, .color, .code: return false @@ -40,7 +42,7 @@ extension ClipboardItemKind { var canReveal: Bool { switch self { - case .file, .image, .pdf, .audio: + case .file, .image, .pdf, .audio, .video: return true case .text, .richText, .unknown, .url, .color, .code: return false @@ -49,7 +51,7 @@ extension ClipboardItemKind { var hasManagedCacheReference: Bool { switch self { - case .url, .image, .pdf, .audio, .richText: + case .url, .image, .pdf, .audio, .richText, .video: return true case .text, .file, .unknown, .color, .code: return false @@ -68,8 +70,9 @@ enum ClipboardSortMode: Int { case audio case colors case code + case videos - static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .files, .pinned, .code] + static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .videos, .files, .pinned, .code] var title: String { switch self { @@ -83,6 +86,7 @@ enum ClipboardSortMode: Int { case .audio: return "Audio" case .colors: return "Colors" case .code: return "Code" + case .videos: return "Videos" } } } @@ -156,6 +160,7 @@ struct ClipboardItem { case .audio: return "audio sound" case .color: return "color swatch hex" case .code: return "code snippet source programming" + case .video: return "video movie mp4 quicktime" } } diff --git a/sources/clipbored/models/VideoPayload.swift b/sources/clipbored/models/VideoPayload.swift new file mode 100644 index 0000000..ee61aab --- /dev/null +++ b/sources/clipbored/models/VideoPayload.swift @@ -0,0 +1,62 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +enum VideoPayload { + static let pasteboardTypes: [NSPasteboard.PasteboardType] = [ + NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier), + NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier), + NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier), + NSPasteboard.PasteboardType(rawValue: UTType.video.identifier), + NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video") + ] + + static func data(from pasteboard: NSPasteboard) -> (data: Data, type: NSPasteboard.PasteboardType)? { + for type in pasteboardTypes { + if let data = pasteboard.data(forType: type), !data.isEmpty { + return (data, type) + } + } + return nil + } + + static func fileExtension(for type: NSPasteboard.PasteboardType) -> String { + switch type.rawValue { + case UTType.mpeg4Movie.identifier: + return "mp4" + case UTType.quickTimeMovie.identifier, UTType.movie.identifier, UTType.video.identifier: + return "mov" + case "com.apple.m4v-video": + return "m4v" + default: + return "mov" + } + } + + static func pasteboardType(forPath path: String) -> NSPasteboard.PasteboardType { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "mp4": + return NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier) + case "m4v": + return NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video") + case "mov", "qt": + return NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier) + default: + return NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier) + } + } + + static func displayTitle(byteCount: Int) -> String { + "Video (\(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)))" + } + + static func fileExtension(from path: String) -> String { + let value = URL(fileURLWithPath: path).pathExtension.clipboardTrimmed + return value.isEmpty ? "mov" : value.lowercased() + } + + static func kindText(from path: String) -> String { + let value = fileExtension(from: path) + return value.isEmpty ? "Video" : value.uppercased() + } +} diff --git a/sources/clipbored/services/ClipboardCacheService.swift b/sources/clipbored/services/ClipboardCacheService.swift index f48ed5a..11a4f31 100644 --- a/sources/clipbored/services/ClipboardCacheService.swift +++ b/sources/clipbored/services/ClipboardCacheService.swift @@ -55,6 +55,10 @@ final class ClipboardCacheService { cacheAttachment(data, id: id, fileExtension: "sound") } + func cacheVideo(_ data: Data, id: UUID, fileExtension: String) -> String? { + cacheAttachment(data, id: id, fileExtension: fileExtension) + } + func cacheRichText(_ data: Data, id: UUID) -> String? { cacheAttachment(data, id: id, fileExtension: "rtf") } @@ -105,7 +109,7 @@ final class ClipboardCacheService { case .file: return filePreviewThumbnail(for: item.payload) - case .text, .unknown, .audio, .richText, .color, .code: + case .text, .unknown, .audio, .richText, .color, .code, .video: return nil } } @@ -174,6 +178,9 @@ final class ClipboardCacheService { case .audio: guard let data = data(for: item.payload) else { return nil } return writeTemporaryCopy(data: data, id: item.id, fileExtension: "sound") + case .video: + guard let data = data(for: item.payload) else { return nil } + return writeTemporaryCopy(data: data, id: item.id, fileExtension: VideoPayload.fileExtension(from: item.payload)) case .richText: guard let data = data(for: item.payload) else { return nil } return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf") @@ -197,7 +204,7 @@ final class ClipboardCacheService { case .url: guard let data = webLocationData(for: item.payload) else { return nil } return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc") - case .image, .pdf, .audio, .richText: + case .image, .pdf, .audio, .richText, .video: return temporaryReadableURL(for: item) } } @@ -212,7 +219,7 @@ final class ClipboardCacheService { if let thumbnailPath = item.thumbnailPath { _ = self.data(for: thumbnailPath) } - if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) { + if (item.kind == .pdf || item.kind == .audio || item.kind == .richText || item.kind == .video), self.isManagedAttachment(path: item.payload) { _ = self.data(for: item.payload) } } @@ -230,7 +237,7 @@ final class ClipboardCacheService { try? self.fileManager.removeItem(atPath: path) self.thumbnailCache.removeObject(forKey: NSString(string: path)) } - if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) { + if (item.kind == .pdf || item.kind == .audio || item.kind == .richText || item.kind == .video), self.isManagedAttachment(path: item.payload) { try? self.fileManager.removeItem(atPath: item.payload) } } diff --git a/sources/clipbored/services/ClipboardMonitorService.swift b/sources/clipbored/services/ClipboardMonitorService.swift index 0a89f5b..5f46028 100644 --- a/sources/clipbored/services/ClipboardMonitorService.swift +++ b/sources/clipbored/services/ClipboardMonitorService.swift @@ -222,6 +222,15 @@ final class ClipboardMonitorService { return pdfItem } + if isIgnored(.video), hasVideo(on: pasteboard) { + reportReadFailureStatus(ignoredKindMessage(.video)) + return nil + } + + if let videoItem = itemFromVideo(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) { + return videoItem + } + if isIgnored(.audio), hasAudio(on: pasteboard) { reportReadFailureStatus(ignoredKindMessage(.audio)) return nil @@ -440,6 +449,10 @@ final class ClipboardMonitorService { pasteboard.data(forType: .sound) != nil } + private func hasVideo(on pasteboard: NSPasteboard) -> Bool { + VideoPayload.data(from: pasteboard) != nil + } + private func hasColor(on pasteboard: NSPasteboard) -> Bool { NSColor(from: pasteboard) != nil } @@ -511,6 +524,32 @@ final class ClipboardMonitorService { ) } + private func itemFromVideo(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? { + guard let video = VideoPayload.data(from: pasteboard) else { return nil } + let id = UUID() + let hash = store.hashString(video.data.base64EncodedString()) + guard let path = cacheService.cacheVideo(video.data, id: id, fileExtension: VideoPayload.fileExtension(for: video.type)) else { + reportReadFailureStatus("Failed to cache video for clipboard history.") + return nil + } + + return ClipboardItem( + id: id, + kind: .video, + displayText: VideoPayload.displayTitle(byteCount: video.data.count), + payload: path, + payloadHash: hash, + createdAt: Date(), + lastUsedAt: Date(), + useCount: 1, + sourceApp: sourceApp, + imagePath: nil, + thumbnailPath: nil, + isPinned: false, + sourceAppBundleId: sourceBundleId + ) + } + private func itemFromColor(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? { guard let color = NSColor(from: pasteboard) else { return nil } guard let hex = ColorPayload.hexString(from: color) else { diff --git a/sources/clipbored/services/PasteActionService.swift b/sources/clipbored/services/PasteActionService.swift index 7acc07b..ce64f9f 100644 --- a/sources/clipbored/services/PasteActionService.swift +++ b/sources/clipbored/services/PasteActionService.swift @@ -130,6 +130,13 @@ final class PasteActionService { pasteboardItem.setString(dragLabel(for: item), forType: .string) return [pasteboardItem] + case .video: + guard let data = cacheService.data(for: item.payload) else { return [] } + let pasteboardItem = NSPasteboardItem() + pasteboardItem.setData(data, forType: VideoPayload.pasteboardType(forPath: item.payload)) + pasteboardItem.setString(dragLabel(for: item), forType: .string) + return [pasteboardItem] + case .color: guard let color = ColorPayload.color(from: item.payload) else { return [] } return [color] @@ -188,6 +195,10 @@ final class PasteActionService { guard let data = cacheService.data(for: item.payload) else { return false } board.clearContents() didWrite = board.setData(data, forType: .sound) + case .video: + guard let data = cacheService.data(for: item.payload) else { return false } + board.clearContents() + didWrite = board.setData(data, forType: VideoPayload.pasteboardType(forPath: item.payload)) case .color: guard let color = ColorPayload.color(from: item.payload) else { return false } board.clearContents() @@ -248,7 +259,7 @@ final class PasteActionService { return nonEmptyPlainText(richTextFallbackPlainString(for: item)) case .image: return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText) - case .pdf, .audio: + case .pdf, .audio, .video: return nonEmptyPlainText(item.displayText) case .color: return nonEmptyPlainText(ColorPayload.displayHex(from: item.payload)) ?? nonEmptyPlainText(item.displayText) diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 56dfd69..87a8227 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -64,6 +64,7 @@ private enum ClipboardCollectionVisuals { case .files: return NSColor(calibratedRed: 0.11, green: 0.68, blue: 0.36, alpha: 1) case .pinned: return NSColor(calibratedRed: 0.94, green: 0.12, blue: 0.48, alpha: 1) case .code: return NSColor(calibratedRed: 0.25, green: 0.38, blue: 0.78, alpha: 1) + case .videos: return NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.94, alpha: 1) } } @@ -644,6 +645,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { case .files: return "Files" case .pinned: return "Pinned" case .code: return "Code" + case .videos: return "Videos" } } @@ -659,6 +661,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { case .files: return "doc.fill" case .pinned: return "pin.fill" case .code: return "chevron.left.forwardslash.chevron.right" + case .videos: return "film" } } @@ -1159,6 +1162,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return ("No text clips yet", "Copied text and rich text appear here.") case .code: return ("No code snippets yet", "Copied code snippets appear here.") + case .videos: + return ("No videos yet", "Copied movie and video clips appear here.") case .files: return ("No files yet", "Copied files and PDFs appear here.") case .audio: @@ -1968,7 +1973,7 @@ private enum ClipboardItemDragPasteboard { .sound, .rtf, .color - ] + ] + VideoPayload.pasteboardTypes } private func shelfSearchText(from event: NSEvent) -> String? { @@ -3100,7 +3105,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private var canOpen: Bool { switch itemKind { - case .url, .file, .image, .pdf, .audio: + case .url, .file, .image, .pdf, .audio, .video: return true case .text, .richText, .unknown, .color, .code: return false @@ -3109,7 +3114,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private var canPreview: Bool { switch itemKind { - case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color, .code: + case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color, .code, .video: return true } } @@ -3120,7 +3125,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private var canPlainText: Bool { switch itemKind { - case .url, .image, .richText, .file, .pdf, .audio, .color: + case .url, .image, .richText, .file, .pdf, .audio, .color, .video: return true case .text, .unknown, .code: return false @@ -3129,7 +3134,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private var canReveal: Bool { switch itemKind { - case .file, .image, .pdf, .audio: + case .file, .image, .pdf, .audio, .video: return true case .text, .richText, .url, .unknown, .color, .code: return false @@ -3710,6 +3715,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return filePreviewView(for: item, thumbnail: thumbnail) case .audio: return audioPreviewView(for: item) + case .video: + return videoPreviewView(for: item) case .color: return colorPreviewView(for: item) case .code: @@ -4149,6 +4156,78 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return container } + private func videoPreviewView(for item: ClipboardItem) -> NSView { + let container = NSView() + container.wantsLayer = true + container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.10).cgColor + container.translatesAutoresizingMaskIntoConstraints = false + + let frame = NSView() + frame.wantsLayer = true + frame.layer?.cornerRadius = 14 + frame.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.78).cgColor + frame.layer?.borderWidth = 1 + frame.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor + frame.translatesAutoresizingMaskIntoConstraints = false + + let film = headerIcon("film", color: .white) + film.translatesAutoresizingMaskIntoConstraints = false + let play = headerIcon("play.fill", color: .white) + play.translatesAutoresizingMaskIntoConstraints = false + frame.addSubview(film) + frame.addSubview(play) + + let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: accentColor(for: item.kind)) + extensionPill.translatesAutoresizingMaskIntoConstraints = false + frame.addSubview(extensionPill) + + let title = NSTextField(labelWithString: titleText(for: item)) + title.font = .systemFont(ofSize: 14, weight: .semibold) + title.textColor = .labelColor + title.maximumNumberOfLines = 1 + title.lineBreakMode = .byTruncatingTail + title.toolTip = title.stringValue + + let detail = NSTextField(labelWithString: previewText(for: item)) + detail.font = .systemFont(ofSize: 12) + detail.textColor = .secondaryLabelColor + detail.maximumNumberOfLines = 1 + detail.lineBreakMode = .byTruncatingTail + detail.toolTip = detail.stringValue + + let labels = NSStackView(views: [title, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 3 + labels.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(frame) + container.addSubview(labels) + NSLayoutConstraint.activate([ + frame.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), + frame.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), + frame.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 14 : 16), + frame.heightAnchor.constraint(equalToConstant: layout.isCompact ? 82 : 92), + film.centerXAnchor.constraint(equalTo: frame.centerXAnchor), + film.centerYAnchor.constraint(equalTo: frame.centerYAnchor), + film.widthAnchor.constraint(equalToConstant: 38), + film.heightAnchor.constraint(equalToConstant: 38), + play.centerXAnchor.constraint(equalTo: frame.centerXAnchor, constant: 1), + play.centerYAnchor.constraint(equalTo: frame.centerYAnchor), + play.widthAnchor.constraint(equalToConstant: 16), + play.heightAnchor.constraint(equalToConstant: 16), + extensionPill.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: -10), + extensionPill.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: -10), + labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), + labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), + labels.topAnchor.constraint(equalTo: frame.bottomAnchor, constant: 10), + labels.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -8), + title.widthAnchor.constraint(equalTo: labels.widthAnchor), + detail.widthAnchor.constraint(equalTo: labels.widthAnchor) + ]) + return container + } + private func colorPreviewView(for item: ClipboardItem) -> NSView { let swatchColor = ColorPayload.color(from: item.payload) ?? accentColor(for: item.kind) let textColor = ColorPayload.contrastingTextColor(for: swatchColor) @@ -4363,6 +4442,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return thumbnail == nil ? "file-preview" : "file-media-preview" case .audio: return "audio-preview" + case .video: + return "video-preview" case .color: return "color-preview" case .code: @@ -4542,6 +4623,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return fileTitle(for: item, fallback: "PDF document") case .audio: return audioTitle(for: item) + case .video: + return videoTitle(for: item) case .image: return imageTitle(for: item) case .color: @@ -4576,6 +4659,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return fileLocationText(from: item.payload, fallback: "PDF document") case .audio: return "Sound clip" + case .video: + return "Video clip" case .color: return ColorPayload.componentSummary(from: item.payload) case .code: @@ -4611,6 +4696,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return "PDF" case .audio: return "Audio" + case .video: + return VideoPayload.kindText(from: item.payload) case .color: return "Color" case .code: @@ -4702,6 +4789,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return "Audio" } + private func videoTitle(for item: ClipboardItem) -> String { + let display = firstUsefulLine(item.displayText) + if !display.isEmpty, !looksInternal(display), display.lowercased() != "video" { + return display + } + return "Video" + } + private func webComponents(from value: String) -> URLComponents? { let trimmed = value.clipboardTrimmed guard !trimmed.isEmpty else { return nil } @@ -4929,6 +5024,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1) case .audio: return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1) + case .video: + return NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.94, alpha: 1) case .color: return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1) case .code: @@ -4947,6 +5044,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { case .file: return "doc" case .pdf: return "doc.text.fill" case .audio: return "music.note" + case .video: return "film" case .color: return "paintpalette" case .code: return "chevron.left.forwardslash.chevron.right" case .unknown: return "questionmark" @@ -5020,6 +5118,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { case .unknown: return "Unknown" case .pdf: return "PDF" case .audio: return "Audio" + case .video: return "Video" case .color: return "Color" case .code: return "Code" } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 0be8c81..bc72168 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -454,6 +454,9 @@ final class ClipboardPanelViewModel { case .audio: guard let url = cacheService.temporaryReadableURL(for: item) else { return } NSWorkspace.shared.open(url) + case .video: + guard let url = cacheService.temporaryReadableURL(for: item) else { return } + NSWorkspace.shared.open(url) case .color: break default: @@ -477,6 +480,9 @@ final class ClipboardPanelViewModel { case .audio: guard let url = cacheService.temporaryReadableURL(for: item) else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) + case .video: + guard let url = cacheService.temporaryReadableURL(for: item) else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) case .color: break default: @@ -792,6 +798,12 @@ final class ClipboardPanelViewModel { .sorted(by: sortByUsage) .map(\.1) + case .videos: + return collectionFiltered + .filter { $0.1.kind == .video } + .sorted(by: sortByUsage) + .map(\.1) + case .colors: return collectionFiltered .filter { $0.1.kind == .color } @@ -1016,6 +1028,8 @@ final class ClipboardPanelViewModel { return [.pdf] case "audio", "sound", "music": return [.audio] + case "video", "videos", "movie", "movies", "mp4", "quicktime", "mov": + return [.video] case "color", "colors", "swatch", "swatches", "hex": return [.color] case "unknown", "item": diff --git a/sources/clipbored/views/SettingsWindowController.swift b/sources/clipbored/views/SettingsWindowController.swift index b304738..14fc258 100644 --- a/sources/clipbored/views/SettingsWindowController.swift +++ b/sources/clipbored/views/SettingsWindowController.swift @@ -172,6 +172,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD kindCheckbox("Images", .image), kindCheckbox("Colors", .color), kindCheckbox("Audio", .audio), + kindCheckbox("Videos", .video), kindCheckbox("Rich text", .richText), kindCheckbox("PDFs", .pdf), kindCheckbox("Files", .file) @@ -201,7 +202,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } private func privacySettingsView() -> NSView { - let storageLabel = caption("Clipboard history is stored locally in Application Support. Text, image cache files, audio clips, and PDF attachments are encrypted with Keychain when available, or an owner-only local fallback key if needed.") + let storageLabel = caption("Clipboard history is stored locally in Application Support. Text, image cache files, audio clips, video clips, and PDF attachments are encrypted with Keychain when available, or an owner-only local fallback key if needed.") let permissionHelpLabel = caption("Clipboard history capture works without this permission. Grant Accessibility to paste selected items into the previous app.") configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged)) configureStatusLabel(accessibilityStatusLabel) diff --git a/tests/clipboredtests/ClipboardCacheServiceTests.swift b/tests/clipboredtests/ClipboardCacheServiceTests.swift index 83a9afc..58d67ec 100644 --- a/tests/clipboredtests/ClipboardCacheServiceTests.swift +++ b/tests/clipboredtests/ClipboardCacheServiceTests.swift @@ -119,6 +119,20 @@ final class ClipboardCacheServiceTests: XCTestCase { XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600) } + func testVideoCacheFilesAreEncryptedAndReadable() throws { + let baseURL = try makeTempDirectory() + let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService()) + let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50]) + + let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4")) + let rawVideo = try Data(contentsOf: URL(fileURLWithPath: path)) + + XCTAssertTrue(ClipboardEncryptionService.isProtected(rawVideo)) + XCTAssertNotEqual(rawVideo, videoData) + XCTAssertEqual(cacheService.data(for: path), videoData) + XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600) + } + func testRichTextCacheFilesAreEncryptedAndReadable() throws { let baseURL = try makeTempDirectory() let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService()) @@ -178,6 +192,19 @@ final class ClipboardCacheServiceTests: XCTestCase { XCTAssertEqual(try posixPermissions(previewURL), 0o600) } + func testTemporaryReadableURLWorksForVideo() throws { + let baseURL = try makeTempDirectory() + let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService()) + let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50]) + let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4")) + + let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: videoItem(path: path))) + + XCTAssertEqual(try Data(contentsOf: previewURL), videoData) + XCTAssertEqual(previewURL.pathExtension, "mp4") + XCTAssertEqual(try posixPermissions(previewURL), 0o600) + } + func testTemporaryReadableURLWorksForRichText() throws { let baseURL = try makeTempDirectory() let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService()) @@ -325,6 +352,22 @@ final class ClipboardCacheServiceTests: XCTestCase { ) } + private func videoItem(path: String) -> ClipboardItem { + ClipboardItem( + id: UUID(), + kind: .video, + displayText: "Video", + payload: path, + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + } + private func richTextItem(path: String) -> ClipboardItem { ClipboardItem( id: UUID(), diff --git a/tests/clipboredtests/ClipboardMonitorServiceTests.swift b/tests/clipboredtests/ClipboardMonitorServiceTests.swift index c66c812..30edd48 100644 --- a/tests/clipboredtests/ClipboardMonitorServiceTests.swift +++ b/tests/clipboredtests/ClipboardMonitorServiceTests.swift @@ -218,6 +218,34 @@ final class ClipboardMonitorServiceTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData) } + func testPollNowCapturesVideoAsRestorableAttachment() throws { + let settings = SettingsModel(defaults: makeTestDefaults()) + let (store, cacheService) = makeStoreAndCache(settings: settings) + let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings) + let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50]) + let captured = expectation(description: "video captured") + + store.observeItems { items in + if items.contains(where: { $0.kind == .video }) { + captured.fulfill() + } + } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + XCTAssertTrue(pasteboard.setData(videoData, forType: VideoPayload.pasteboardTypes[0])) + + monitor.pollNowAndWait() + wait(for: [captured], timeout: 1.0) + + let item = try XCTUnwrap(store.items.first(where: { $0.kind == .video })) + XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload)) + XCTAssertEqual(item.displayText, VideoPayload.displayTitle(byteCount: videoData.count)) + XCTAssertEqual(cacheService.data(for: item.payload), videoData) + XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied) + XCTAssertEqual(NSPasteboard.general.data(forType: VideoPayload.pasteboardTypes[0]), videoData) + } + func testPollNowCapturesColorAsRestorableSwatch() throws { let settings = SettingsModel(defaults: makeTestDefaults()) let (store, cacheService) = makeStoreAndCache(settings: settings) @@ -726,6 +754,25 @@ final class ClipboardMonitorServiceTests: XCTestCase { XCTAssertEqual(settings.captureStatusMessage, "Skipped: Audio items are ignored in capture settings.") } + func testIgnoredVideoKindDoesNotWriteAttachmentFiles() throws { + let settings = SettingsModel(defaults: makeTestDefaults()) + settings.ignoredItemKindsRaw = [ClipboardItemKind.video.rawValue] + let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings) + let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings) + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + XCTAssertTrue(pasteboard.setData(Data([0, 0, 0, 24, 102, 116, 121, 112]), forType: VideoPayload.pasteboardTypes[0])) + + monitor.pollNowAndWait() + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + cacheService.flushForTesting() + + XCTAssertTrue(store.items.isEmpty) + XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty) + XCTAssertEqual(settings.captureStatusMessage, "Skipped: Video items are ignored in capture settings.") + } + func testIgnoredRichTextKindDoesNotWriteHTMLAttachmentFiles() throws { let settings = SettingsModel(defaults: makeTestDefaults()) settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue] diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 60b6356..312d022 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -129,6 +129,51 @@ final class ClipboardPanelViewModelTests: XCTestCase { ) } + func testComputeVisibleItemsFiltersVideoClipsAndStructuredType() { + let settings = makeSettings() + let store = makeStore(settings: settings) + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService()) + let video = ClipboardItem( + id: UUID(), + kind: .video, + displayText: "Video (12 KB)", + payload: "/tmp/clip.mp4", + payloadHash: hash("clip-video"), + createdAt: Date(timeIntervalSince1970: 200), + lastUsedAt: Date(timeIntervalSince1970: 200), + useCount: 0, + sourceApp: "QuickTime Player", + imagePath: nil, + thumbnailPath: nil + ) + let image = ClipboardItem( + id: UUID(), + kind: .image, + displayText: "Image", + payload: "/tmp/image.png", + payloadHash: hash("image"), + createdAt: Date(timeIntervalSince1970: 100), + lastUsedAt: Date(timeIntervalSince1970: 100), + useCount: 0, + sourceApp: "Preview", + imagePath: nil, + thumbnailPath: nil + ) + + XCTAssertEqual( + viewModel.computeVisibleItems(from: [video, image], query: "", sortMode: .videos).map(\.kind), + [.video] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: [video, image], query: "type:movie", sortMode: .mostRecent).map(\.kind), + [.video] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: [video, image], query: "mp4", sortMode: .mostRecent).map(\.kind), + [.video] + ) + } + func testSearchMatchesIndependentTokensCaseInsensitively() { let settings = makeSettings() let store = makeStore(settings: settings) diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 0554456..29670a3 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -246,11 +246,11 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugCollectionTitles, - ["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Files", "Pinned", "Code"] + ["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Videos", "Files", "Pinned", "Code"] ) XCTAssertEqual( fixture.view.debugCollectionLeadingSymbols, - ["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "paintpalette", "music.note", "doc.fill", "pin.fill", "chevron.left.forwardslash.chevron.right"] + ["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "paintpalette", "music.note", "film", "doc.fill", "pin.fill", "chevron.left.forwardslash.chevron.right"] ) XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") @@ -680,6 +680,7 @@ final class ClipboardPanelViewTests: XCTestCase { let image = makeItem(kind: .image, text: "image payload", store: fixture.store) let color = makeItem(kind: .color, displayText: "#0A84FF", payload: "#0A84FF", store: fixture.store) let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store) + let video = makeItem(kind: .video, text: "/tmp/movie.mp4", store: fixture.store) let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store) let code = makeItem( kind: .code, @@ -688,14 +689,14 @@ final class ClipboardPanelViewTests: XCTestCase { store: fixture.store ) - [pinned, rich, link, image, color, audio, file, code].forEach { + [pinned, rich, link, image, color, audio, video, file, code].forEach { fixture.store.upsert($0) drainMainQueue() } - XCTAssertEqual(fixture.viewModel.visibleItems.count, 8) - XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [8, 8, 3, 1, 1, 1, 1, 1, 1, 1]) - XCTAssertEqual(fixture.view.debugCollectionCounts, [8, 8, 3, 1, 1, 1, 1, 1, 1, 1]) + XCTAssertEqual(fixture.viewModel.visibleItems.count, 9) + XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1]) + XCTAssertEqual(fixture.view.debugCollectionCounts, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1]) XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: false, count: ClipboardSortMode.allCases.count)) } @@ -1366,6 +1367,25 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"]) } + func testVideoCardsUseFilmPreview() { + let fixture = makePanelFixture() + let item = makeItem( + kind: .video, + displayText: "Video (24 KB)", + payload: "/tmp/clipbored-video.mp4", + store: fixture.store + ) + + fixture.store.upsert(item) + fixture.viewModel.sortMode = .videos + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Video: Video (24 KB)"]) + XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Video (24 KB)|Video clip|MP4"]) + XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["video-preview"]) + } + func testColorCardsUseSwatchPreview() { let fixture = makePanelFixture() let item = makeItem( diff --git a/tests/clipboredtests/PasteActionServiceTests.swift b/tests/clipboredtests/PasteActionServiceTests.swift index 60c5567..527077a 100644 --- a/tests/clipboredtests/PasteActionServiceTests.swift +++ b/tests/clipboredtests/PasteActionServiceTests.swift @@ -534,6 +534,32 @@ final class PasteActionServiceTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData) } + func testCopyWritesVideoData() throws { + let directory = try makeTempDirectory() + let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService()) + let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50]) + let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4")) + let service = PasteActionService(cacheService: cacheService) + let item = ClipboardItem( + id: UUID(), + kind: .video, + displayText: "Video", + payload: path, + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + + XCTAssertEqual(service.copy(item), .copied) + XCTAssertEqual(NSPasteboard.general.data(forType: VideoPayload.pasteboardTypes[0]), videoData) + XCTAssertEqual(service.copyPlainText(item), .copiedPlainText) + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Video") + } + func testCopyWritesEncryptedPDFData() throws { let directory = try makeTempDirectory() let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())