WIP: add video clip support

This commit is contained in:
Akshay Kolli
2026-07-01 15:39:43 -07:00
parent 26e4b15093
commit 23788fd136
14 changed files with 457 additions and 35 deletions

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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"
}

View File

@@ -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":

View File

@@ -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)

View File

@@ -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(),

View File

@@ -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]

View File

@@ -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)

View File

@@ -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(

View File

@@ -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())