diff --git a/Package.swift b/Package.swift index 8de9fdc..f06deb9 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( exclude: ["resources"], linkerSettings: [ .linkedFramework("AppKit"), + .linkedFramework("AVFoundation"), .linkedFramework("Carbon"), .linkedFramework("LocalAuthentication"), .linkedFramework("QuickLookUI"), diff --git a/README.md b/README.md index b6463c8..23213db 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - `Command + G` shows a filtered result back in the full clipboard history - `Shift + Command + N` creates a new collection - `Space` previews the selected card when the focused search field is empty -- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references +- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, video with movie thumbnails when available, RTF/HTML rich text, PDFs, and file references - Keyboard-focusable cards and collection chips with type-to-search, Return-to-paste/select, Space-to-preview for text, links, files, and media, vertical wheel/trackpad panning and overflow edge fades in horizontal rails, visible focus chrome, and VoiceOver action hints - Shelf navigation keys for focused cards: Left/Right, Page Up/Page Down, Home, and End - Shelf navigation keys for focused collection chips: Left/Right, Home, and End @@ -80,7 +80,7 @@ Project layout: ClipBored does not use network APIs or telemetry. Clipboard history is stored locally under Application Support. -Textual SQLite fields, image cache files, audio clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Temporary decrypted preview files may be created when opening or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion and ignored app settings for high-risk sources. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure. +Textual SQLite fields, image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Temporary decrypted preview files may be created when thumbnailing, opening, or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion and ignored app settings for high-risk sources. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure. ## Contributing diff --git a/docs/SECURITY.md b/docs/SECURITY.md index be38511..664bda9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -10,7 +10,7 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t - Hardened runtime is applied by the local build script, and the release script supports Developer ID signing plus notarization when credentials are configured. - Clipboard persistence uses prepared SQLite statements and bound values. - Textual SQLite fields, including optional local image OCR text, are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. -- App-managed image cache files, audio clips, rich text sidecars, and PDF attachments are encrypted with the same encryption service. +- App-managed image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with the same encryption service. - If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so clipboard capture and persistence continue without a Keychain UI stall. - Full history clears remove the app-local fallback key when present and reset cached key state after the database clear succeeds. - App-owned storage directories are restricted to the current user, and saved history/cache files are written with owner-only permissions where the filesystem supports POSIX modes. @@ -35,7 +35,7 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t - SQLite item metadata such as identifiers, kinds, timestamps, pin state, and use counts is not encrypted. - The app-local fallback key prevents plaintext app-managed history/media files, but it does not protect against a process or user account that can read the full ClipBored Application Support directory before history is cleared. -- Opening or revealing encrypted images, audio clips, or PDFs creates temporary decrypted preview files so macOS can hand them to other apps. ClipBored clears stale preview files on launch, cache/history clear, and quit. +- Thumbnailing, opening, or revealing encrypted images, audio clips, video clips, or PDFs creates temporary decrypted preview files so macOS can hand them to system media APIs or other apps. ClipBored clears stale preview files on launch, cache/history clear, and quit. - Existing plaintext SQLite rows and legacy sidecar files are migrated when encryption becomes available, but system snapshots, backups, live temporary previews, or filesystem remnants may retain older plaintext copies. - The local development build is ad-hoc signed; use `scripts/release-macos-app.sh` with Developer ID credentials for notarized distribution builds. - Accessibility permission is required for automatic paste simulation. diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 43121cc..5e047d2 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -69,7 +69,7 @@ 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. 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. +38. Copy a video/movie clip and confirm it appears as a Video card, uses a movie-frame thumbnail when available, 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 diff --git a/sources/clipbored/services/ClipboardCacheService.swift b/sources/clipbored/services/ClipboardCacheService.swift index 11a4f31..33a5b32 100644 --- a/sources/clipbored/services/ClipboardCacheService.swift +++ b/sources/clipbored/services/ClipboardCacheService.swift @@ -1,7 +1,10 @@ import AppKit +import AVFoundation import Foundation final class ClipboardCacheService { + typealias VideoThumbnailProvider = (URL) -> NSImage? + private let thumbnailCache = NSCache() private let fileManager = FileManager.default private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility) @@ -9,8 +12,13 @@ final class ClipboardCacheService { private let attachmentDirectory: URL private let temporaryPreviewDirectory: URL private let encryptionService: ClipboardEncryptionService + private let videoThumbnailProvider: VideoThumbnailProvider - init(baseURL: URL? = nil, encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()) { + init( + baseURL: URL? = nil, + encryptionService: ClipboardEncryptionService = ClipboardEncryptionService(), + videoThumbnailProvider: VideoThumbnailProvider? = nil + ) { let base = baseURL ?? ClipboardStore.storageDirectory() imageDirectory = base.appendingPathComponent("images", isDirectory: true) attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true) @@ -18,6 +26,7 @@ final class ClipboardCacheService { .appendingPathComponent(AppConfiguration.appName, isDirectory: true) .appendingPathComponent("Previews", isDirectory: true) self.encryptionService = encryptionService + self.videoThumbnailProvider = videoThumbnailProvider ?? Self.makeVideoThumbnail thumbnailCache.countLimit = 128 try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true) try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true) @@ -109,7 +118,10 @@ final class ClipboardCacheService { case .file: return filePreviewThumbnail(for: item.payload) - case .text, .unknown, .audio, .richText, .color, .code, .video: + case .video: + return videoPreviewThumbnail(for: item) + + case .text, .unknown, .audio, .richText, .color, .code: return nil } } @@ -154,6 +166,51 @@ final class ClipboardCacheService { return image } + private func videoPreviewThumbnail(for item: ClipboardItem) -> NSImage? { + let key = NSString(string: "video-preview:\(item.id.uuidString):\(item.payload)") + if let cached = thumbnailCache.object(forKey: key) { + return cached + } + + guard + let data = data(for: item.payload), + let temporaryURL = writeTemporaryCopy( + data: data, + id: item.id, + fileExtension: VideoPayload.fileExtension(from: item.payload) + ) + else { + return nil + } + defer { removeTemporaryCopyIfPossible(temporaryURL) } + + guard let image = videoThumbnailProvider(temporaryURL), hasDrawableSize(image) else { + return nil + } + let thumbnail = image.resized(to: CGSize(width: 260, height: 132)) + thumbnailCache.setObject(thumbnail, forKey: key) + return thumbnail + } + + private static func makeVideoThumbnail(from url: URL) -> NSImage? { + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 520, height: 264) + generator.requestedTimeToleranceBefore = .zero + generator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30) + + for time in [CMTime(seconds: 0.08, preferredTimescale: 600), .zero] { + if let cgImage = try? generator.copyCGImage(at: time, actualTime: nil) { + return NSImage( + cgImage: cgImage, + size: NSSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height)) + ) + } + } + return nil + } + private func hasDrawableSize(_ image: NSImage) -> Bool { image.size.width > 0 && image.size.height > 0 } @@ -347,6 +404,13 @@ final class ClipboardCacheService { } } + private func removeTemporaryCopyIfPossible(_ url: URL) { + try? fileManager.removeItem(at: url) + if ((try? fileManager.contentsOfDirectory(at: temporaryPreviewDirectory, includingPropertiesForKeys: nil)) ?? []).isEmpty { + try? fileManager.removeItem(at: temporaryPreviewDirectory) + } + } + private func webLocationData(for value: String) -> Data? { let trimmed = value.clipboardTrimmed guard !trimmed.isEmpty else { return nil } diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 87a8227..1dce9ef 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -3716,6 +3716,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { case .audio: return audioPreviewView(for: item) case .video: + if let thumbnail { + return videoMediaPreviewView(for: item, thumbnail: thumbnail) + } return videoPreviewView(for: item) case .color: return colorPreviewView(for: item) @@ -4382,6 +4385,50 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return container } + private func videoMediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let imageView = AspectFillImageView(image: thumbnail) + imageView.translatesAutoresizingMaskIntoConstraints = false + + let playBadge = NSView() + playBadge.wantsLayer = true + playBadge.layer?.cornerRadius = 22 + playBadge.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.48).cgColor + playBadge.layer?.borderWidth = 0.8 + playBadge.layer?.borderColor = NSColor.white.withAlphaComponent(0.30).cgColor + playBadge.translatesAutoresizingMaskIntoConstraints = false + + let play = headerIcon("play.fill", color: .white) + play.translatesAutoresizingMaskIntoConstraints = false + playBadge.addSubview(play) + + let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: NSColor.black.withAlphaComponent(0.60)) + extensionPill.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(imageView) + container.addSubview(playBadge) + container.addSubview(extensionPill) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + imageView.topAnchor.constraint(equalTo: container.topAnchor), + imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + playBadge.centerXAnchor.constraint(equalTo: container.centerXAnchor), + playBadge.centerYAnchor.constraint(equalTo: container.centerYAnchor), + playBadge.widthAnchor.constraint(equalToConstant: 44), + playBadge.heightAnchor.constraint(equalToConstant: 44), + play.centerXAnchor.constraint(equalTo: playBadge.centerXAnchor, constant: 1), + play.centerYAnchor.constraint(equalTo: playBadge.centerYAnchor), + play.widthAnchor.constraint(equalToConstant: 16), + play.heightAnchor.constraint(equalToConstant: 16), + extensionPill.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12), + extensionPill.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10) + ]) + return container + } + private func capsuleLabel(_ text: String, color: NSColor) -> NSTextField { let label = NSTextField(labelWithString: text) label.translatesAutoresizingMaskIntoConstraints = false @@ -4443,7 +4490,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { case .audio: return "audio-preview" case .video: - return "video-preview" + return thumbnail == nil ? "video-preview" : "video-media-preview" case .color: return "color-preview" case .code: diff --git a/tests/clipboredtests/ClipboardCacheServiceTests.swift b/tests/clipboredtests/ClipboardCacheServiceTests.swift index 58d67ec..ac69603 100644 --- a/tests/clipboredtests/ClipboardCacheServiceTests.swift +++ b/tests/clipboredtests/ClipboardCacheServiceTests.swift @@ -91,6 +91,32 @@ final class ClipboardCacheServiceTests: XCTestCase { XCTAssertNotNil(cacheService.previewThumbnail(for: pdfItem(path: path))) } + func testPreviewThumbnailUsesDecryptedVideoTemporaryCopyAndCachesResult() throws { + let baseURL = try makeTempDirectory() + let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50]) + var providerURLs: [URL] = [] + let cacheService = ClipboardCacheService( + baseURL: baseURL, + encryptionService: fixedEncryptionService(), + videoThumbnailProvider: { url in + providerURLs.append(url) + XCTAssertEqual(url.pathExtension, "mp4") + XCTAssertEqual(try? Data(contentsOf: url), videoData) + return self.makeImage(color: .systemIndigo) + } + ) + let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4")) + let item = videoItem(path: path) + + let thumbnail = cacheService.previewThumbnail(for: item) + let cachedThumbnail = cacheService.previewThumbnail(for: item) + + XCTAssertNotNil(thumbnail) + XCTAssertNotNil(cachedThumbnail) + XCTAssertEqual(providerURLs.count, 1) + XCTAssertFalse(FileManager.default.fileExists(atPath: try XCTUnwrap(providerURLs.first).path)) + } + func testPDFCacheFilesAreEncryptedAndReadable() throws { let baseURL = try makeTempDirectory() let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService()) @@ -389,7 +415,7 @@ final class ClipboardCacheServiceTests: XCTestCase { } private func makeImage(color: NSColor) -> NSImage { - let size = NSSize(width: 24, height: 24) + let size = NSSize(width: 64, height: 40) let image = NSImage(size: size) image.lockFocus() color.setFill() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 29670a3..d97ae49 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -1386,6 +1386,39 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["video-preview"]) } + func testVideoCardsUseMediaPreviewWhenThumbnailExists() throws { + let cacheService = ClipboardCacheService( + baseURL: makeTempDirectory(), + encryptionService: ClipboardEncryptionService(keyProvider: { nil }), + videoThumbnailProvider: { _ in self.sampleImage() } + ) + let fixture = makePanelFixture(cacheService: cacheService) + let id = UUID() + let path = try XCTUnwrap(cacheService.cacheVideo(Data([0, 1, 2, 3]), id: id, fileExtension: "mp4")) + let item = ClipboardItem( + id: id, + kind: .video, + displayText: "Video (24 KB)", + payload: path, + payloadHash: fixture.store.hashString("video-thumbnail"), + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: "QuickTime Player", + imagePath: nil, + thumbnailPath: nil + ) + + 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-media-preview"]) + } + func testColorCardsUseSwatchPreview() { let fixture = makePanelFixture() let item = makeItem( @@ -1433,9 +1466,9 @@ final class ClipboardPanelViewTests: XCTestCase { return (fixture.window, fixture.view) } - private func makePanelFixture() -> PanelFixture { + private func makePanelFixture(cacheService: ClipboardCacheService? = nil) -> PanelFixture { let settings = makeSettings() - let cacheService = ClipboardCacheService() + let cacheService = cacheService ?? ClipboardCacheService() let store = makeStore(settings: settings, cacheService: cacheService) let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) let previewProbe = PreviewProbe()