Slim down app and test suite

This commit is contained in:
Akshay Kolli
2026-07-09 22:30:43 -04:00
parent 52f712eb73
commit 54fac96dec
28 changed files with 1046 additions and 8950 deletions

View File

@@ -24,6 +24,7 @@ Run the full local check before opening a pull request:
- Avoid storing new classes of sensitive data. If capture behavior expands, add tests and update `docs/SECURITY.md`. - Avoid storing new classes of sensitive data. If capture behavior expands, add tests and update `docs/SECURITY.md`.
- Keep idle work bounded. Polling, timers, file scans, and cache purges should have clear caps or backoff behavior. - Keep idle work bounded. Polling, timers, file scans, and cache purges should have clear caps or backoff behavior.
- Add tests for persistence, pruning, sensitive filtering, shortcut parsing, pasteboard behavior, and search/sort changes. - Add tests for persistence, pruning, sensitive filtering, shortcut parsing, pasteboard behavior, and search/sort changes.
- Prefer behavior-level tests. Use the smoke checklist for visual layout instead of adding production debug accessors for private UI details.
- Keep UI native and compact. This is a utility, not a marketing surface. - Keep UI native and compact. This is a utility, not a marketing surface.
## Pull Request Checklist ## Pull Request Checklist

View File

@@ -90,7 +90,7 @@ Project layout:
- `sources/clipbored/resources` - app bundle metadata and icon assets - `sources/clipbored/resources` - app bundle metadata and icon assets
- `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters - `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters
- `sources/clipbored/views` - panel, onboarding, preview, and settings UI - `sources/clipbored/views` - panel, onboarding, preview, and settings UI
- `tests/clipboredtests` - unit and UI-structure regression tests - `tests/clipboredtests` - focused behavior tests for capture, persistence, search, paste, and settings decisions
- `docs` - architecture, security, release, smoke-test, and roadmap notes - `docs` - architecture, security, release, smoke-test, and roadmap notes
## Privacy And Security ## Privacy And Security

View File

@@ -1,15 +0,0 @@
# UI Cleanup Resolution
All eleven findings from the original cleanup list are resolved in the current shelf design.
1. [x] **Hover controls obscured clip text.** Hover now expands the preview visually; commands are exposed through the context menu and keyboard, so no hover rail covers content.
2. [x] **Hover broke keyboard navigation.** Hover state is independent from keyboard focus and selection, and arrow navigation clears stale hover ownership without changing the wrong clip.
3. [x] **Category changes felt abrupt.** Search, category, card, and panel changes use short eased transitions and become immediate when macOS Reduce Motion is enabled.
4. [x] **The collapsed search control was broken.** Search has one aligned container: it expands on click, typing, or `Command + F`, and collapses after click-away only when the query is empty.
5. [x] **Filtering was split across categories and a search menu.** Category chips are the primary visual filters; a click replaces the filter and Command-click builds a union. `Command + F` only focuses search.
6. [x] **Empty built-in categories added noise.** Built-in type/sort chips are created only when they have matches or are selected; empty custom Pinboards remain visible by design.
7. [x] **A nonfunctional resize lip was visible.** The shelf has no resize handle and uses its fixed side-shelf frame.
8. [x] **New Text Clip was unnecessary.** The panel, menu-bar menu, and shortcut map no longer expose a new-text action.
9. [x] **The alternate compact mode was unnecessary.** Settings exposes one Side Shelf layout; row sizing adapts automatically to available space instead of presenting a mode toggle.
10. [x] **The panel did not need a close control.** There is no close button in shelf chrome; `Esc` and the configured global shortcut dismiss it.
11. [x] **The persistent status bar consumed space.** The shelf flows directly from its toolbar into the side-rail card list; feedback is shown in the relevant menu or Settings page.

View File

@@ -62,9 +62,3 @@ extension NSImage {
) )
} }
} }
extension NSView {
var isInAnyViewHierarchy: Bool {
return window != nil
}
}

View File

@@ -61,14 +61,6 @@ enum ColorPayload {
"\(displayHex(from: payload))\n\(componentSummary(from: payload))" "\(displayHex(from: payload))\n\(componentSummary(from: payload))"
} }
static func contrastingTextColor(for color: NSColor) -> NSColor {
guard let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
return .labelColor
}
let luminance = (0.299 * rgb.redComponent) + (0.587 * rgb.greenComponent) + (0.114 * rgb.blueComponent)
return luminance > 0.62 ? NSColor.black.withAlphaComponent(0.82) : .white
}
private static func clampedByte(_ value: CGFloat) -> Int { private static func clampedByte(_ value: CGFloat) -> Int {
Int((min(1, max(0, value)) * 255).rounded()) Int((min(1, max(0, value)) * 255).rounded())
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -329,7 +329,6 @@ final class ClipboardCacheService {
func purgeIfNeeded(maxBytes: Int64) { func purgeIfNeeded(maxBytes: Int64) {
queue.async { queue.async {
DiagnosticsService.shared.incrementCachePurge()
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? [] let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
var items: [(url: URL, size: Int64, date: Date)] = [] var items: [(url: URL, size: Int64, date: Date)] = []
var totalSize: Int64 = 0 var totalSize: Int64 = 0

View File

@@ -108,7 +108,6 @@ final class ClipboardMonitorService {
} }
private func tick() { private func tick() {
DiagnosticsService.shared.incrementMonitorTick()
pollPasteboard(rescheduleAfterCapture: true) pollPasteboard(rescheduleAfterCapture: true)
} }
@@ -138,7 +137,6 @@ final class ClipboardMonitorService {
return return
} }
DiagnosticsService.shared.incrementPasteboardChange()
didReportReadFailure = false didReportReadFailure = false
if let item = readCurrentItem(from: pasteboard) { if let item = readCurrentItem(from: pasteboard) {
@@ -162,7 +160,6 @@ final class ClipboardMonitorService {
} }
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? { private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
DiagnosticsService.shared.incrementExtractionAttempt()
let source = frontmostApp() let source = frontmostApp()
func isIgnored(_ kind: ClipboardItemKind) -> Bool { func isIgnored(_ kind: ClipboardItemKind) -> Bool {

View File

@@ -659,78 +659,22 @@ final class ClipboardStore {
} }
private func legacyISO8601Date(_ string: String) -> Date? { private func legacyISO8601Date(_ string: String) -> Date? {
string.withCString { pointer -> Date? in let value = string.replacingOccurrences(of: " ", with: "T")
let byteCount = strlen(pointer) return Self.legacyDateFormatters.lazy.compactMap { $0.date(from: value) }.first
guard byteCount >= 20, }
byte(pointer, 4) == 45,
byte(pointer, 7) == 45,
byte(pointer, 10) == 84 || byte(pointer, 10) == 32,
byte(pointer, 13) == 58,
byte(pointer, 16) == 58,
let year = decimal(pointer, byteCount, 0, 4),
let month = decimal(pointer, byteCount, 5, 2),
let day = decimal(pointer, byteCount, 8, 2),
let hour = decimal(pointer, byteCount, 11, 2),
let minute = decimal(pointer, byteCount, 14, 2),
let second = decimal(pointer, byteCount, 17, 2)
else {
return nil
}
var cursor = 19 private static let legacyDateFormatters: [ISO8601DateFormatter] = {
var fraction = 0.0 let formats: [ISO8601DateFormatter.Options] = [
if cursor < byteCount, byte(pointer, cursor) == 46 { [.withInternetDateTime, .withFractionalSeconds],
cursor += 1 [.withInternetDateTime]
var scale = 0.1 ]
while cursor < byteCount { return formats.map { options in
let digit = byte(pointer, cursor) let formatter = ISO8601DateFormatter()
guard digit >= 48, digit <= 57 else { break } formatter.formatOptions = options
fraction += Double(digit - 48) * scale return formatter
scale /= 10
cursor += 1
}
}
var offset = 0
if cursor < byteCount, byte(pointer, cursor) == 90 {
offset = 0
} else if cursor + 5 < byteCount, byte(pointer, cursor) == 43 || byte(pointer, cursor) == 45 {
let sign = byte(pointer, cursor) == 43 ? 1 : -1
guard let offsetHour = decimal(pointer, byteCount, cursor + 1, 2),
let offsetMinute = decimal(pointer, byteCount, cursor + 4, 2)
else { return nil }
offset = sign * ((offsetHour * 3600) + (offsetMinute * 60))
}
var components = tm()
components.tm_year = Int32(year - 1900)
components.tm_mon = Int32(month - 1)
components.tm_mday = Int32(day)
components.tm_hour = Int32(hour)
components.tm_min = Int32(minute)
components.tm_sec = Int32(second)
components.tm_isdst = 0
let epoch = timegm(&components)
guard epoch >= 0 else { return nil }
return Date(timeIntervalSince1970: TimeInterval(epoch - time_t(offset)) + fraction)
} }
} }()
private func decimal(_ pointer: UnsafePointer<CChar>, _ byteCount: Int, _ start: Int, _ length: Int) -> Int? {
guard start + length <= byteCount else { return nil }
var result = 0
for index in start..<(start + length) {
let digit = byte(pointer, index)
guard digit >= 48, digit <= 57 else { return nil }
result = (result * 10) + Int(digit - 48)
}
return result
}
private func byte(_ pointer: UnsafePointer<CChar>, _ index: Int) -> UInt8 {
UInt8(bitPattern: pointer[index])
}
private func isDatabaseEmpty() -> Bool { private func isDatabaseEmpty() -> Bool {
guard let db else { return true } guard let db else { return true }
@@ -901,7 +845,6 @@ final class ClipboardStore {
private func applyPersistence(_ mutation: PersistenceMutation) { private func applyPersistence(_ mutation: PersistenceMutation) {
guard let db else { return } guard let db else { return }
DiagnosticsService.shared.incrementDatabaseMutation()
let insertSQL = """ let insertSQL = """
INSERT OR REPLACE INTO clipboard_items ( INSERT OR REPLACE INTO clipboard_items (
id, kind, display_text, payload, payload_hash, id, kind, display_text, payload, payload_hash,

View File

@@ -1,61 +0,0 @@
import Foundation
final class DiagnosticsService {
static let shared = DiagnosticsService()
struct Snapshot: Equatable {
var monitorTicks: Int
var pasteboardChanges: Int
var extractionAttempts: Int
var databaseMutations: Int
var cachePurges: Int
}
private let queue = DispatchQueue(label: "clipboard.diagnostics", qos: .utility)
private var snapshot = Snapshot(
monitorTicks: 0,
pasteboardChanges: 0,
extractionAttempts: 0,
databaseMutations: 0,
cachePurges: 0
)
private init() {}
func incrementMonitorTick() {
queue.async { self.snapshot.monitorTicks += 1 }
}
func incrementPasteboardChange() {
queue.async { self.snapshot.pasteboardChanges += 1 }
}
func incrementExtractionAttempt() {
queue.async { self.snapshot.extractionAttempts += 1 }
}
func incrementDatabaseMutation() {
queue.async { self.snapshot.databaseMutations += 1 }
}
func incrementCachePurge() {
queue.async { self.snapshot.cachePurges += 1 }
}
func currentSnapshot() -> Snapshot {
queue.sync { snapshot }
}
func reset() {
queue.sync {
snapshot = Snapshot(
monitorTicks: 0,
pasteboardChanges: 0,
extractionAttempts: 0,
databaseMutations: 0,
cachePurges: 0
)
}
}
}

View File

@@ -17,369 +17,143 @@ enum SensitiveContentDetector {
case keyword case keyword
} }
static func detect(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Reason? { private static let tokenPatterns: [(Reason, NSRegularExpression)] = [
let trimmed = text.clipboardTrimmed (.bearerToken, regex(#"(?i)\bbearer\s+[A-Za-z0-9._+/=-]{20,}(?![A-Za-z0-9_])"#)),
guard !trimmed.isEmpty else { return nil } (.githubToken, regex(#"\bgh[porus]_[A-Za-z0-9_]{30,}(?![A-Za-z0-9_])"#)),
let bytes = Array(trimmed.utf8) (.slackToken, regex(#"\bxox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9_])"#)),
(.awsAccessKey, regex(#"\bAKIA[A-Z0-9]{16}(?![A-Za-z0-9_])"#)),
(.stripeKey, regex(#"\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}(?![A-Za-z0-9_])"#)),
(.openAIToken, regex(#"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_])"#)),
(.googleAPIKey, regex(#"\bAIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_])"#)),
(.jsonWebToken, regex(#"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_])"#))
]
if containsPrivateKey(trimmed) { return .privateKey } static func detect(
if containsBearerToken(bytes) { return .bearerToken } _ text: String,
if containsGitHubToken(bytes) { return .githubToken } sourceBundleId: String? = nil,
if containsSlackToken(bytes) { return .slackToken } sourceApp: String? = nil
if containsAWSAccessKey(bytes) { return .awsAccessKey } ) -> Reason? {
if containsStripeKey(bytes) { return .stripeKey } let value = text.clipboardTrimmed
if containsOpenAIToken(bytes) { return .openAIToken } guard !value.isEmpty else { return nil }
if containsGoogleAPIKey(bytes) { return .googleAPIKey }
if containsJSONWebToken(bytes) { return .jsonWebToken }
if containsCreditCard(trimmed) { return .creditCard }
if looksLikeOneTimeCode(trimmed, sourceBundleId: sourceBundleId, sourceApp: sourceApp) { return .oneTimeCode }
if looksHighEntropy(trimmed) { return .highEntropyToken }
let lowered = trimmed.lowercased() if value.contains("-----BEGIN "), value.contains("PRIVATE KEY-----") {
if lowered.contains("password") || lowered.contains("secret") || lowered.contains("api_key") || looksLikeSecretAssignment(lowered) { return .privateKey
}
if let match = tokenPatterns.first(where: { matches($0.1, in: value) }) {
return match.0
}
if containsCreditCard(value) { return .creditCard }
if looksLikeOneTimeCode(value, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
return .oneTimeCode
}
if looksHighEntropy(value) { return .highEntropyToken }
let lowered = value.lowercased()
if lowered.contains("password")
|| lowered.contains("secret")
|| lowered.contains("api_key")
|| looksLikeSecretAssignment(lowered) {
return .keyword return .keyword
} }
return nil return nil
} }
static func isLikelySensitive(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Bool { static func isLikelySensitive(
_ text: String,
sourceBundleId: String? = nil,
sourceApp: String? = nil
) -> Bool {
detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil
} }
private static func containsPrivateKey(_ text: String) -> Bool { private static func regex(_ pattern: String) -> NSRegularExpression {
text.contains("-----BEGIN ") && text.contains("PRIVATE KEY-----") try! NSRegularExpression(pattern: pattern)
}
private static func matches(_ regex: NSRegularExpression, in text: String) -> Bool {
regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil
} }
private static func looksHighEntropy(_ text: String) -> Bool { private static func looksHighEntropy(_ text: String) -> Bool {
let candidate = text.clipboardTrimmed guard (32...256).contains(text.count),
guard candidate.count >= 32, candidate.count <= 256 else { return false } !text.contains(where: \.isWhitespace) else {
guard !candidate.contains(where: { $0.isWhitespace }) else { return false } return false
var hasLower = false
var hasUpper = false
var hasDigit = false
var symbolCount = 0
for scalar in candidate.unicodeScalars {
let value = scalar.value
if value >= 48, value <= 57 {
hasDigit = true
} else if value >= 65, value <= 90 {
hasUpper = true
} else if value >= 97, value <= 122 {
hasLower = true
} else if value == 95 || value == 45 || value == 46 || value == 43 || value == 47 || value == 61 {
symbolCount += 1
} else {
return false
}
} }
let classCount = (hasLower ? 1 : 0) + (hasUpper ? 1 : 0) + (hasDigit ? 1 : 0) var characterClasses = 0
return classCount >= 2 && symbolCount > 0 var hasSymbol = false
for scalar in text.unicodeScalars {
switch scalar.value {
case 48...57: characterClasses |= 1
case 65...90: characterClasses |= 2
case 97...122: characterClasses |= 4
case 43, 45, 46, 47, 61, 95: hasSymbol = true
default: return false
}
}
return characterClasses.nonzeroBitCount >= 2 && hasSymbol
} }
private static func looksLikeOneTimeCode(_ text: String, sourceBundleId: String?, sourceApp: String?) -> Bool { private static func looksLikeOneTimeCode(
let value = text.clipboardTrimmed _ text: String,
guard value.count >= 6, value.count <= 8, value.allSatisfy({ $0.isNumber }) else { return false } sourceBundleId: String?,
sourceApp: String?
let source = ((sourceBundleId ?? "") + " " + (sourceApp ?? "")).lowercased() ) -> Bool {
guard !source.isEmpty else { return false } guard (6...8).contains(text.count), text.allSatisfy(\.isNumber) else {
return source.contains("auth") || return false
source.contains("1password") || }
source.contains("bitwarden") || let source = "\(sourceBundleId ?? "") \(sourceApp ?? "")".lowercased()
source.contains("lastpass") || return ["auth", "1password", "bitwarden", "lastpass", "keeper", "dashlane"]
source.contains("keeper") || .contains(where: source.contains)
source.contains("dashlane")
} }
private static func containsCreditCard(_ text: String) -> Bool { private static func containsCreditCard(_ text: String) -> Bool {
var digits: [Int] = [] var digits: [Int] = []
func isCard(_ digits: [Int]) -> Bool {
guard (13...19).contains(digits.count),
let first = digits.first,
digits.contains(where: { $0 != first }) else {
return false
}
var sum = 0
for (index, digit) in digits.reversed().enumerated() {
let doubled = index.isMultiple(of: 2) ? digit : digit * 2
sum += doubled > 9 ? doubled - 9 : doubled
}
return sum.isMultiple(of: 10)
}
for char in text { for character in text {
if char.isNumber, let digit = char.wholeNumberValue { if let digit = character.wholeNumberValue {
digits.append(digit) digits.append(digit)
} else if (character == " " || character == "-"), !digits.isEmpty {
continue
} else { } else {
if isCreditCardGroup(digits) { if isCard(digits) { return true }
return true
}
digits.removeAll(keepingCapacity: true) digits.removeAll(keepingCapacity: true)
} }
} }
return isCard(digits)
return isCreditCardGroup(digits)
} }
private static func isCreditCardGroup(_ digits: [Int]) -> Bool { private static func looksLikeSecretAssignment(_ text: String) -> Bool {
guard digits.count >= 13, digits.count <= 19, let first = digits.first else {
return false
}
guard digits.contains(where: { $0 != first }) else {
return false
}
return passesLuhn(digits)
}
private static func passesLuhn(_ digits: [Int]) -> Bool {
var sum = 0
var shouldDouble = false
for digit in digits.reversed() {
var value = digit
if shouldDouble {
value *= 2
if value > 9 {
value -= 9
}
}
sum += value
shouldDouble.toggle()
}
return sum % 10 == 0
}
private static func containsBearerToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 27 else { return false }
for index in 0...(bytes.count - 6) where isWordBoundaryBefore(bytes, index) {
guard matchesBearer(bytes, index) else { continue }
var cursor = index + 6
guard cursor < bytes.count, isWhitespace(bytes[cursor]) else { continue }
while cursor < bytes.count, isWhitespace(bytes[cursor]) {
cursor += 1
}
let start = cursor
while cursor < bytes.count, isBearerByte(bytes[cursor]) {
cursor += 1
}
if cursor - start >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsGitHubToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 34 else { return false }
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
let marker = bytes[index + 2]
guard bytes[index] == 103, bytes[index + 1] == 104, (marker == 112 || marker == 111 || marker == 117 || marker == 115 || marker == 114), bytes[index + 3] == 95 else {
continue
}
var cursor = index + 4
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 95 {
cursor += 1
}
if cursor - (index + 4) >= 30, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsSlackToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 25 else { return false }
for index in 0..<(bytes.count - 4) where isWordBoundaryBefore(bytes, index) {
let marker = bytes[index + 3]
guard bytes[index] == 120, bytes[index + 1] == 111, bytes[index + 2] == 120, (marker == 98 || marker == 97 || marker == 112 || marker == 114 || marker == 115), bytes[index + 4] == 45 else {
continue
}
var cursor = index + 5
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 45 {
cursor += 1
}
if cursor - (index + 5) >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsAWSAccessKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 20 else { return false }
for index in 0...(bytes.count - 20) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 65, bytes[index + 1] == 75, bytes[index + 2] == 73, bytes[index + 3] == 65 else { continue }
var cursor = index + 4
while cursor < index + 20, isUpperAlphaNumeric(bytes[cursor]) {
cursor += 1
}
if cursor == index + 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsStripeKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 24 else { return false }
for index in 0..<(bytes.count - 8) where isWordBoundaryBefore(bytes, index) {
let prefix = bytes[index]
guard (prefix == 115 || prefix == 114 || prefix == 112), bytes[index + 1] == 107, bytes[index + 2] == 95 else { continue }
let live = bytes[index + 3] == 108 && bytes[index + 4] == 105 && bytes[index + 5] == 118 && bytes[index + 6] == 101 && bytes[index + 7] == 95
let test = bytes[index + 3] == 116 && bytes[index + 4] == 101 && bytes[index + 5] == 115 && bytes[index + 6] == 116 && bytes[index + 7] == 95
guard live || test else { continue }
var cursor = index + 8
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) {
cursor += 1
}
if cursor - (index + 8) >= 16, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsOpenAIToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 24 else { return false }
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 115, bytes[index + 1] == 107, bytes[index + 2] == 45 else { continue }
var cursor = index + 3
if cursor + 5 <= bytes.count,
bytes[cursor] == 112,
bytes[cursor + 1] == 114,
bytes[cursor + 2] == 111,
bytes[cursor + 3] == 106,
bytes[cursor + 4] == 45 {
cursor += 5
}
let tokenStart = cursor
while cursor < bytes.count, isTokenByte(bytes[cursor]) {
cursor += 1
}
if cursor - tokenStart >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsGoogleAPIKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 39 else { return false }
for index in 0...(bytes.count - 39) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 65, bytes[index + 1] == 73, bytes[index + 2] == 122, bytes[index + 3] == 97 else { continue }
var cursor = index + 4
while cursor < index + 39, isTokenByte(bytes[cursor]) {
cursor += 1
}
if cursor == index + 39, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsJSONWebToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 32 else { return false }
var index = 0
while index + 3 < bytes.count {
guard isWordBoundaryBefore(bytes, index), bytes[index] == 101, bytes[index + 1] == 121, bytes[index + 2] == 74 else {
index += 1
continue
}
var cursor = index
let firstStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
guard cursor - firstStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
index += 1
continue
}
cursor += 1
let secondStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
guard cursor - secondStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
index += 1
continue
}
cursor += 1
let thirdStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
if cursor - thirdStart >= 8, isWordBoundaryAfter(bytes, cursor) {
return true
}
index += 1
}
return false
}
private static func looksLikeSecretAssignment(_ lowered: String) -> Bool {
let keys = [ let keys = [
"api_key", "api_key", "apikey", "access_token", "auth_token", "client_secret",
"apikey", "private_token", "refresh_token", "secret_key", "passwd"
"access_token",
"auth_token",
"client_secret",
"private_token",
"refresh_token",
"secret_key",
"passwd"
] ]
for key in keys { for key in keys {
guard let range = lowered.range(of: key) else { continue } guard let range = text.range(of: key) else { continue }
let suffix = lowered[range.upperBound...].drop(while: { $0.isWhitespace }) let suffix = text[range.upperBound...].drop(while: \.isWhitespace)
guard let separator = suffix.first, separator == "=" || separator == ":" else { continue } guard suffix.first == "=" || suffix.first == ":" else { continue }
let value = suffix.dropFirst().drop(while: { $0.isWhitespace || $0 == "\"" || $0 == "'" }) let value = suffix.dropFirst().drop {
let valueLength = value.prefix { !$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != "," }.count $0.isWhitespace || $0 == "\"" || $0 == "'"
if valueLength >= 8 { }
if value.prefix(while: {
!$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != ","
}).count >= 8 {
return true return true
} }
} }
return false return false
} }
private static func matchesBearer(_ bytes: [UInt8], _ index: Int) -> Bool {
(bytes[index] == 98 || bytes[index] == 66) &&
(bytes[index + 1] == 101 || bytes[index + 1] == 69) &&
(bytes[index + 2] == 97 || bytes[index + 2] == 65) &&
(bytes[index + 3] == 114 || bytes[index + 3] == 82) &&
(bytes[index + 4] == 101 || bytes[index + 4] == 69) &&
(bytes[index + 5] == 114 || bytes[index + 5] == 82)
}
private static func isWordBoundaryBefore(_ bytes: [UInt8], _ index: Int) -> Bool {
index == 0 || !isWordByte(bytes[index - 1])
}
private static func isWordBoundaryAfter(_ bytes: [UInt8], _ index: Int) -> Bool {
index >= bytes.count || !isWordByte(bytes[index])
}
private static func isWordByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95
}
private static func isAlphaNumeric(_ byte: UInt8) -> Bool {
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122)
}
private static func isUpperAlphaNumeric(_ byte: UInt8) -> Bool {
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90)
}
private static func isBearerByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 46 || byte == 95 || byte == 45 || byte == 43 || byte == 47 || byte == 61
}
private static func isTokenByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95 || byte == 45
}
private static func isBase64URLByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95 || byte == 45
}
private static func isWhitespace(_ byte: UInt8) -> Bool {
byte == 32 || byte == 9 || byte == 10 || byte == 13
}
} }

View File

@@ -261,10 +261,6 @@ final class ShortcutManager {
modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue
) )
static func globalShortcutBindings(openShortcut: ShortcutBinding) -> [ShortcutBinding] {
[openShortcut, stackCaptureShortcut]
}
private func osStatusMessage(_ status: OSStatus) -> String { private func osStatusMessage(_ status: OSStatus) -> String {
"OSStatus \(status)" "OSStatus \(status)"
} }

View File

@@ -1,13 +1,6 @@
import AppKit import AppKit
import QuickLookUI import QuickLookUI
struct ClipboardPanelAnimationProfile {
let showDuration: TimeInterval
let hideDuration: TimeInterval
let reflowDuration: TimeInterval
let easing: CAMediaTimingFunctionName
}
struct ClipboardPanelReflowPlan { struct ClipboardPanelReflowPlan {
let frame: NSRect let frame: NSRect
let bottomSafeInset: CGFloat let bottomSafeInset: CGFloat
@@ -360,10 +353,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
currentPanel ?? lastKnown ?? preferred ?? pointer ?? fallback currentPanel ?? lastKnown ?? preferred ?? pointer ?? fallback
} }
static func panelFrames(forScreenFrame screenFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
return panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
}
static func panelFrames( static func panelFrames(
forScreenFrame screenFrame: CGRect, forScreenFrame screenFrame: CGRect,
visibleFrame: CGRect, visibleFrame: CGRect,
@@ -422,27 +411,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0 return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0
} }
static var animationProfile: ClipboardPanelAnimationProfile {
ClipboardPanelAnimationProfile(
showDuration: Animation.showDuration,
hideDuration: Animation.hideDuration,
reflowDuration: Animation.reflowDuration,
easing: Animation.easing
)
}
static var panelCollectionBehavior: NSWindow.CollectionBehavior { static var panelCollectionBehavior: NSWindow.CollectionBehavior {
[.moveToActiveSpace, .fullScreenAuxiliary, .transient] [.moveToActiveSpace, .fullScreenAuxiliary, .transient]
} }
static func reflowPlan(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> ClipboardPanelReflowPlan {
let frames = panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
return ClipboardPanelReflowPlan(
frame: frames.shown,
bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
)
}
static func reflowPlan( static func reflowPlan(
forScreenFrame screenFrame: CGRect, forScreenFrame screenFrame: CGRect,
visibleFrame: CGRect, visibleFrame: CGRect,
@@ -848,47 +820,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
quickLookURL as NSURL? quickLookURL as NSURL?
} }
#if DEBUG
var debugPanelFrame: NSRect {
panel.frame
}
var debugPanelAlpha: CGFloat {
panel.alphaValue
}
var debugIsAnimating: Bool {
isAnimating
}
func debugSetSearchFieldText(_ text: String) {
panelView.debugSetSearchFieldText(text)
}
var debugSearchFieldText: String {
panelView.debugSearchFieldText
}
var debugSearchFieldWidth: CGFloat {
panelView.debugSearchFieldWidth
}
var debugSearchFieldPlaceholderText: String {
panelView.debugSearchFieldPlaceholderText
}
var debugSearchFieldIsVisible: Bool {
panelView.debugSearchFieldIsVisible
}
var debugSearchIconButtonIsVisible: Bool {
panelView.debugSearchIconButtonIsVisible
}
var debugIsSearchFieldEditing: Bool {
panelView.isSearchFieldEditing
}
#endif
private func installClickMonitor() { private func installClickMonitor() {
removeClickMonitor() removeClickMonitor()

File diff suppressed because it is too large Load Diff

View File

@@ -136,6 +136,18 @@ final class ClipboardPanelViewModel {
case original case original
} }
private enum TransferIntent {
case copy
case paste
}
private enum TransferContext {
case single
case selected
case stackItem
case stackText
}
private(set) var visibleItems: [ClipboardItem] = [] { private(set) var visibleItems: [ClipboardItem] = [] {
didSet { didSet {
var nextItemByID: [UUID: ClipboardItem] = [:] var nextItemByID: [UUID: ClipboardItem] = [:]
@@ -324,18 +336,6 @@ final class ClipboardPanelViewModel {
var onStackChanged: (() -> Void)? var onStackChanged: (() -> Void)?
var onCaptureStatusChanged: (() -> Void)? var onCaptureStatusChanged: (() -> Void)?
#if DEBUG
private(set) var debugVisibleItemsFullScanCount = 0
private(set) var debugVisibleItemsIndexedLookupCount = 0
private(set) var debugCollectionCountFullScanCount = 0
private(set) var debugCollectionCountIndexedLookupCount = 0
private(set) var debugSearchItemEvaluationCount = 0
private(set) var debugSearchMatchCacheHitCount = 0
private(set) var debugSearchDocumentBuildCount = 0
private(set) var debugSearchDocumentCacheHitCount = 0
private(set) var debugCategoryFilterSelectionBuildCount = 0
private(set) var debugStackPruneScanCount = 0
#endif
init( init(
store: ClipboardStore, store: ClipboardStore,
@@ -392,20 +392,6 @@ final class ClipboardPanelViewModel {
} }
} }
#if DEBUG
func debugResetVisibleItemsPerformanceCounters() {
debugVisibleItemsFullScanCount = 0
debugVisibleItemsIndexedLookupCount = 0
debugCollectionCountFullScanCount = 0
debugCollectionCountIndexedLookupCount = 0
debugSearchItemEvaluationCount = 0
debugSearchMatchCacheHitCount = 0
debugSearchDocumentBuildCount = 0
debugSearchDocumentCacheHitCount = 0
debugCategoryFilterSelectionBuildCount = 0
debugStackPruneScanCount = 0
}
#endif
var selectedItem: ClipboardItem? { var selectedItem: ClipboardItem? {
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil } guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
@@ -445,10 +431,6 @@ final class ClipboardPanelViewModel {
stackItemIDs.count stackItemIDs.count
} }
var stackTitle: String {
"Stack"
}
var collectionNames: [String] { var collectionNames: [String] {
if let collectionNamesCache { if let collectionNamesCache {
return collectionNamesCache return collectionNamesCache
@@ -473,14 +455,6 @@ final class ClipboardPanelViewModel {
return names return names
} }
var searchFilterSourceAppNames: [String] {
uniqueSearchFacetValues(items.compactMap(\.sourceApp))
}
var searchFilterDeviceNames: [String] {
uniqueSearchFacetValues(items.map { Optional(effectiveSourceDeviceName(for: $0)) })
}
func collectionCount(for sortMode: ClipboardSortMode) -> Int { func collectionCount(for sortMode: ClipboardSortMode) -> Int {
collectionCountSummary().count(for: sortMode) collectionCountSummary().count(for: sortMode)
} }
@@ -505,9 +479,6 @@ final class ClipboardPanelViewModel {
for (collectionKey, indexedItems) in indexedItemsByCollectionKey { for (collectionKey, indexedItems) in indexedItemsByCollectionKey {
collectionCounts[collectionKey] = indexedItems.count collectionCounts[collectionKey] = indexedItems.count
} }
#if DEBUG
debugCollectionCountIndexedLookupCount += 1
#endif
} else { } else {
for indexedItem in indexedItemsMatchingSearch(query) { for indexedItem in indexedItemsMatchingSearch(query) {
let item = indexedItem.item let item = indexedItem.item
@@ -516,9 +487,6 @@ final class ClipboardPanelViewModel {
collectionCounts[collectionName.lowercased(), default: 0] += 1 collectionCounts[collectionName.lowercased(), default: 0] += 1
} }
} }
#if DEBUG
debugCollectionCountFullScanCount += 1
#endif
} }
let summary = ClipboardCollectionCountSummary( let summary = ClipboardCollectionCountSummary(
@@ -673,83 +641,35 @@ final class ClipboardPanelViewModel {
} }
func pasteSelected() { func pasteSelected() {
if selectedItemCount > 1 { performSelectedTransfer(.paste)
pasteSelectedItems()
return
}
guard let item = selectedItem else { return }
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
} }
func pasteSelectedPlainText() { func pasteSelectedPlainText() {
if selectedItemCount > 1 { performSelectedTransfer(.paste, asPlainText: true)
pasteSelectedItemsAsText()
return
}
guard let item = selectedItem else { return }
let result = pasteService.pastePlainText(item, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
} }
func pasteItem(at index: Int) { func pasteItem(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return } guard visibleItems.indices.contains(index) else { return }
selectItem(at: index) selectItem(at: index)
pasteSelected() pasteSelected()
} }
func pasteItemPlainText(at index: Int) { func pasteItemPlainText(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return } guard visibleItems.indices.contains(index) else { return }
selectItem(at: index) selectItem(at: index)
pasteSelectedPlainText() pasteSelectedPlainText()
} }
func copySelected() { func copySelected() {
if selectedItemCount > 1 { performSelectedTransfer(.copy)
copySelectedItems()
return
}
guard let item = selectedItem else { return }
let result = pasteService.copy(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
} }
func copySelectedPlainText() { func copySelectedPlainText() {
if selectedItemCount > 1 { performSelectedTransfer(.copy, asPlainText: true)
copySelectedItemsAsText()
return
}
guard let item = selectedItem else { return }
let result = pasteService.copyPlainText(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
} }
func isItemStacked(at index: Int) -> Bool { func isItemStacked(at index: Int) -> Bool {
guard index >= 0 && index < visibleItems.count else { return false } guard index >= 0 && index < visibleItems.count else { return false }
return stackItemIDSet.contains(visibleItems[index].id) return stackItemIDSet.contains(visibleItems[index].id)
@@ -830,11 +750,6 @@ final class ClipboardPanelViewModel {
} }
} }
func clearStackSelection() {
guard isStackFilterSelected else { return }
isStackFilterSelected = false
}
func clearStack() { func clearStack() {
guard !stackItemIDs.isEmpty else { guard !stackItemIDs.isEmpty else {
statusMessage = "Stack is empty" statusMessage = "Stack is empty"
@@ -846,51 +761,23 @@ final class ClipboardPanelViewModel {
} }
func copyNextStackItem() { func copyNextStackItem() {
guard let item = nextStackItem() else { performNextStackTransfer(.copy)
statusMessage = "Stack is empty"
return
}
let result = pasteService.copy(item)
handleStackActionResult(result, item: item)
} }
func pasteNextStackItem() { func pasteNextStackItem() {
guard let item = nextStackItem() else { performNextStackTransfer(.paste)
statusMessage = "Stack is empty"
return
}
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
handleStackActionResult(result, item: item)
} }
func copyStackAsText() { func copyStackAsText() {
guard let package = stackPlainTextPackage() else { performStackTextTransfer(.copy)
statusMessage = "Stack has no text to copy"
return
}
let result = pasteService.copyPlainText(package.text)
handleStackPlainTextActionResult(result, items: package.items)
} }
func pasteStackAsText() { func pasteStackAsText() {
guard let package = stackPlainTextPackage() else { performStackTextTransfer(.paste)
statusMessage = "Stack has no text to paste"
return
}
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
handleStackPlainTextActionResult(result, items: package.items)
} }
func addSelectedItemsToStack() { func addSelectedItemsToStack() {
pruneStackItems() pruneStackItems()
let selectedItems = selectedItemsInSelectionOrder() let selectedItems = selectedItemsInSelectionOrder()
@@ -912,54 +799,16 @@ final class ClipboardPanelViewModel {
statusMessage = "Added \(newIDs.count) selected \(noun) to Stack" statusMessage = "Added \(newIDs.count) selected \(noun) to Stack"
} }
func copySelectedItems() {
let selectedItems = selectedItemsInSelectionOrder()
guard selectedItems.count > 1 else {
copySelected()
return
}
let result = pasteService.copy(selectedItems)
handleSelectedActionResult(result, items: selectedItems)
}
func pasteSelectedItems() {
let selectedItems = selectedItemsInSelectionOrder()
guard selectedItems.count > 1 else {
pasteSelected()
return
}
let result = pasteService.paste(selectedItems, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
handleSelectedActionResult(result, items: selectedItems)
}
func copySelectedItemsAsText() { func copySelectedItemsAsText() {
guard let package = selectedPlainTextPackage() else { performSelectedTransfer(.copy, asPlainText: true, forceGroup: true)
statusMessage = "Selection has no text to copy"
return
}
let result = pasteService.copyPlainText(package.text)
handleSelectedPlainTextActionResult(result, items: package.items)
} }
func pasteSelectedItemsAsText() { func pasteSelectedItemsAsText() {
guard let package = selectedPlainTextPackage() else { performSelectedTransfer(.paste, asPlainText: true, forceGroup: true)
statusMessage = "Selection has no text to paste"
return
}
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
handleSelectedPlainTextActionResult(result, items: package.items)
} }
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] { func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
guard index >= 0 && index < visibleItems.count else { return [] } guard index >= 0 && index < visibleItems.count else { return [] }
return pasteService.pasteboardWriters(for: visibleItems[index]) return pasteService.pasteboardWriters(for: visibleItems[index])
@@ -970,13 +819,6 @@ final class ClipboardPanelViewModel {
return item.payload return item.payload
} }
func editableTextForItem(at index: Int) -> String? {
guard index >= 0 && index < visibleItems.count else { return nil }
let item = visibleItems[index]
guard item.kind == .text || item.kind == .code else { return nil }
return item.payload
}
func editableTitleForSelected() -> String? { func editableTitleForSelected() -> String? {
guard let item = selectedItem else { return nil } guard let item = selectedItem else { return nil }
return item.customTitle ?? "" return item.customTitle ?? ""
@@ -1540,10 +1382,6 @@ final class ClipboardPanelViewModel {
statusMessage = "Deleted \(normalizedName)" statusMessage = "Deleted \(normalizedName)"
} }
func clearSearch() {
searchText = ""
}
func showSelectedInClipboard() { func showSelectedInClipboard() {
guard canShowSelectedInClipboard, let item = selectedItem else { return } guard canShowSelectedInClipboard, let item = selectedItem else { return }
selectedItemID = item.id selectedItemID = item.id
@@ -1762,156 +1600,198 @@ final class ClipboardPanelViewModel {
} }
} }
private func uniqueSearchFacetValues(_ values: [String?]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values {
guard let normalized = value?.clipboardTrimmed, !normalized.isEmpty else { continue }
let key = Self.normalizedSearchValue(normalized)
guard seen.insert(key).inserted else { continue }
result.append(normalized)
}
return result.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
private func selectedItemsInSelectionOrder() -> [ClipboardItem] { private func selectedItemsInSelectionOrder() -> [ClipboardItem] {
let selectedItems = selectedItemIDs.compactMap { visibleItemByID[$0] } let items = selectedItemIDs.compactMap { visibleItemByID[$0] }
if !selectedItems.isEmpty { return items.isEmpty ? selectedItem.map { [$0] } ?? [] : items
return selectedItems
}
return selectedItem.map { [$0] } ?? []
} }
private func nextStackItem() -> ClipboardItem? { private func nextStackItem() -> ClipboardItem? {
pruneStackItems() pruneStackItems()
guard let id = stackItemIDs.first else { return nil } return stackItemIDs.first.flatMap { itemByID[$0] }
return itemByID[id]
} }
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? { private func plainTextPackage(for items: [ClipboardItem]) -> (text: String, items: [ClipboardItem])? {
pruneStackItems() let pairs = items.compactMap { item -> (ClipboardItem, String)? in
let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in guard let text = pasteService.plainText(for: item)?.clipboardTrimmed,
guard let item = itemByID[id],
let text = pasteService.plainText(for: item)?.clipboardTrimmed,
!text.isEmpty else { !text.isEmpty else {
return nil return nil
} }
return (item, text) return (item, text)
} }
guard !pairs.isEmpty else { return nil } guard !pairs.isEmpty else { return nil }
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item)) return (pairs.map(\.1).joined(separator: "\n\n"), pairs.map(\.0))
} }
private func selectedPlainTextPackage() -> (text: String, items: [ClipboardItem])? { private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
let pairs: [(item: ClipboardItem, text: String)] = selectedItemsInSelectionOrder().compactMap { item in pruneStackItems()
guard let text = pasteService.plainText(for: item)?.clipboardTrimmed, !text.isEmpty else { return plainTextPackage(for: stackItemIDs.compactMap { itemByID[$0] })
return nil }
private func performSelectedTransfer(
_ intent: TransferIntent,
asPlainText: Bool = false,
forceGroup: Bool = false
) {
let items = selectedItemsInSelectionOrder()
let isGroup = forceGroup || items.count > 1
if asPlainText && isGroup {
guard let package = plainTextPackage(for: items) else {
statusMessage = "Selection has no text to \(intent == .copy ? "copy" : "paste")"
return
} }
return (item, text) completeTransfer(
} plainTextTransfer(intent, value: package.text),
guard !pairs.isEmpty else { return nil } items: package.items,
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item)) context: .selected
} )
private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) {
if case .failed(let message) = result {
statusMessage = message
return return
} }
consumeStackItem(item.id) guard let item = items.first else { return }
store.markUsed(item.id) let result = asPlainText
selectedItemID = item.id ? plainTextTransfer(intent, item: item)
switch result { : originalTransfer(intent, items: items)
case .copiedNeedsPermission: completeTransfer(result, items: items, context: isGroup ? .selected : .single)
statusMessage = "Copied from Stack. Grant Accessibility access to paste automatically."
case .pasted:
statusMessage = "Pasted from Stack"
case .copied:
statusMessage = "Copied from Stack"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
} }
private func handleStackPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) { private func performNextStackTransfer(_ intent: TransferIntent) {
guard let item = nextStackItem() else {
statusMessage = "Stack is empty"
return
}
completeTransfer(originalTransfer(intent, items: [item]), items: [item], context: .stackItem)
}
private func performStackTextTransfer(_ intent: TransferIntent) {
guard let package = stackPlainTextPackage() else {
statusMessage = "Stack has no text to \(intent == .copy ? "copy" : "paste")"
return
}
completeTransfer(
plainTextTransfer(intent, value: package.text),
items: package.items,
context: .stackText
)
}
private func originalTransfer(
_ intent: TransferIntent,
items: [ClipboardItem]
) -> PasteActionService.PasteActionResult {
if let item = items.first, items.count == 1 {
return intent == .copy
? pasteService.copy(item)
: pasteService.paste(item, targetApp: targetApplicationProvider())
}
return intent == .copy
? pasteService.copy(items)
: pasteService.paste(items, targetApp: targetApplicationProvider())
}
private func plainTextTransfer(
_ intent: TransferIntent,
item: ClipboardItem
) -> PasteActionService.PasteActionResult {
intent == .copy
? pasteService.copyPlainText(item)
: pasteService.pastePlainText(item, targetApp: targetApplicationProvider())
}
private func plainTextTransfer(
_ intent: TransferIntent,
value: String
) -> PasteActionService.PasteActionResult {
intent == .copy
? pasteService.copyPlainText(value)
: pasteService.pastePlainText(value, targetApp: targetApplicationProvider())
}
private func completeTransfer(
_ result: PasteActionService.PasteActionResult,
items: [ClipboardItem],
context: TransferContext
) {
if case .failed(let message) = result { if case .failed(let message) = result {
statusMessage = message statusMessage = message
if case .single = context {
settings.setPasteStatus(message: message)
}
return return
} }
for item in items { if case .pasted = result {
store.markUsed(item.id) willPasteToTarget()
consumeStackItem(item.id, refreshActiveStackFilter: false) } else if case .pastedPlainText = result {
willPasteToTarget()
} }
if stackItemIDs.isEmpty {
isStackFilterSelected = false switch context {
} else if isStackFilterSelected { case .stackItem:
recomputeVisibleItems() if let item = items.first {
consumeStackItem(item.id)
store.markUsed(item.id)
}
case .stackText:
for item in items {
store.markUsed(item.id)
consumeStackItem(item.id, refreshActiveStackFilter: false)
}
if stackItemIDs.isEmpty {
isStackFilterSelected = false
} else if isStackFilterSelected {
recomputeVisibleItems()
}
case .single, .selected:
for item in items {
store.markUsed(item.id)
}
} }
selectedItemID = items.first?.id selectedItemID = items.first?.id
let noun = items.count == 1 ? "clip" : "clips" statusMessage = transferStatus(result, count: items.count, context: context)
switch result {
case .pastedPlainText:
statusMessage = "Pasted \(items.count) Stack \(noun) as Text"
case .copiedPlainTextNeedsPermission:
statusMessage = "Copied \(items.count) Stack \(noun) as Text. Grant Accessibility access to paste automatically."
case .copiedPlainText:
statusMessage = "Copied \(items.count) Stack \(noun) as Text"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage) settings.setPasteStatus(message: statusMessage)
} }
private func handleSelectedActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) { private func transferStatus(
if case .failed(let message) = result { _ result: PasteActionService.PasteActionResult,
statusMessage = message count: Int,
return context: TransferContext
} ) -> String {
let noun = count == 1 ? "clip" : "clips"
for item in items { switch (context, result) {
store.markUsed(item.id) case (.single, _):
} return result.message
selectedItemID = items.first?.id case (.stackItem, .pasted):
let noun = items.count == 1 ? "clip" : "clips" return "Pasted from Stack"
switch result { case (.stackItem, .copied):
case .pasted: return "Copied from Stack"
statusMessage = "Pasted \(items.count) selected \(noun)" case (.stackItem, .copiedNeedsPermission):
case .copiedNeedsPermission: return "Copied from Stack. Grant Accessibility access to paste automatically."
statusMessage = "Copied \(items.count) selected \(noun). Grant Accessibility access to paste automatically." case (.selected, .pasted):
case .copied: return "Pasted \(count) selected \(noun)"
statusMessage = "Copied \(items.count) selected \(noun)" case (.selected, .copied):
return "Copied \(count) selected \(noun)"
case (.selected, .copiedNeedsPermission):
return "Copied \(count) selected \(noun). Grant Accessibility access to paste automatically."
case (.selected, .pastedPlainText):
return "Pasted \(count) selected \(noun) as Text"
case (.selected, .copiedPlainText):
return "Copied \(count) selected \(noun) as Text"
case (.selected, .copiedPlainTextNeedsPermission):
return "Copied \(count) selected \(noun) as Text. Grant Accessibility access to paste automatically."
case (.stackText, .pastedPlainText):
return "Pasted \(count) Stack \(noun) as Text"
case (.stackText, .copiedPlainText):
return "Copied \(count) Stack \(noun) as Text"
case (.stackText, .copiedPlainTextNeedsPermission):
return "Copied \(count) Stack \(noun) as Text. Grant Accessibility access to paste automatically."
default: default:
statusMessage = result.message return result.message
} }
settings.setPasteStatus(message: statusMessage)
} }
private func handleSelectedPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
if case .failed(let message) = result {
statusMessage = message
return
}
for item in items {
store.markUsed(item.id)
}
selectedItemID = items.first?.id
let noun = items.count == 1 ? "clip" : "clips"
switch result {
case .pastedPlainText:
statusMessage = "Pasted \(items.count) selected \(noun) as Text"
case .copiedPlainTextNeedsPermission:
statusMessage = "Copied \(items.count) selected \(noun) as Text. Grant Accessibility access to paste automatically."
case .copiedPlainText:
statusMessage = "Copied \(items.count) selected \(noun) as Text"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
}
private func consumeStackItem(_ id: UUID, refreshActiveStackFilter: Bool = true) { private func consumeStackItem(_ id: UUID, refreshActiveStackFilter: Bool = true) {
guard let index = stackItemIDs.firstIndex(of: id) else { return } guard let index = stackItemIDs.firstIndex(of: id) else { return }
@@ -1925,9 +1805,6 @@ final class ClipboardPanelViewModel {
guard stackItemsNeedPruning else { return } guard stackItemsNeedPruning else { return }
stackItemsNeedPruning = false stackItemsNeedPruning = false
guard !stackItemIDs.isEmpty else { return } guard !stackItemIDs.isEmpty else { return }
#if DEBUG
debugStackPruneScanCount += 1
#endif
let pruned = stackItemIDs.filter { itemIDSet.contains($0) } let pruned = stackItemIDs.filter { itemIDSet.contains($0) }
if pruned != stackItemIDs { if pruned != stackItemIDs {
stackItemIDs = pruned stackItemIDs = pruned
@@ -1960,9 +1837,6 @@ final class ClipboardPanelViewModel {
private func indexedItemsMatchingSearch(_ query: String) -> [IndexedClipboardItem] { private func indexedItemsMatchingSearch(_ query: String) -> [IndexedClipboardItem] {
if let searchMatchCache, searchMatchCache.query == query { if let searchMatchCache, searchMatchCache.query == query {
#if DEBUG
debugSearchMatchCacheHitCount += 1
#endif
return searchMatchCache.indexedItems return searchMatchCache.indexedItems
} }
@@ -1975,9 +1849,6 @@ final class ClipboardPanelViewModel {
var matches: [IndexedClipboardItem] = [] var matches: [IndexedClipboardItem] = []
matches.reserveCapacity(allIndexedItems.count) matches.reserveCapacity(allIndexedItems.count)
for indexedItem in allIndexedItems { for indexedItem in allIndexedItems {
#if DEBUG
debugSearchItemEvaluationCount += 1
#endif
if matchesSearchQuery(indexedItem.item, query: parsedQuery) { if matchesSearchQuery(indexedItem.item, query: parsedQuery) {
matches.append(indexedItem) matches.append(indexedItem)
} }
@@ -2019,9 +1890,6 @@ final class ClipboardPanelViewModel {
ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters) ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters)
) )
computed = indexedItems.map(\.item) computed = indexedItems.map(\.item)
#if DEBUG
debugVisibleItemsIndexedLookupCount += 1
#endif
} else { } else {
computed = filterAndSortVisibleItems( computed = filterAndSortVisibleItems(
indexedItemsMatchingSearch(query), indexedItemsMatchingSearch(query),
@@ -2029,9 +1897,6 @@ final class ClipboardPanelViewModel {
collectionName: collectionName, collectionName: collectionName,
categoryFilters: categoryFilters categoryFilters: categoryFilters
) )
#if DEBUG
debugVisibleItemsFullScanCount += 1
#endif
} }
if visibleItemsCache.count > 24 { if visibleItemsCache.count > 24 {
visibleItemsCache.removeAll(keepingCapacity: true) visibleItemsCache.removeAll(keepingCapacity: true)
@@ -2201,9 +2066,6 @@ final class ClipboardPanelViewModel {
) )
} }
categoryFilterSelectionCache = selection categoryFilterSelectionCache = selection
#if DEBUG
debugCategoryFilterSelectionBuildCount += 1
#endif
return selection return selection
} }
@@ -2344,9 +2206,6 @@ final class ClipboardPanelViewModel {
ocrText: item.ocrText ocrText: item.ocrText
) )
if let cached = searchDocumentsByItemID[item.id], cached.fingerprint == fingerprint { if let cached = searchDocumentsByItemID[item.id], cached.fingerprint == fingerprint {
#if DEBUG
debugSearchDocumentCacheHitCount += 1
#endif
return cached return cached
} }
@@ -2368,9 +2227,6 @@ final class ClipboardPanelViewModel {
collection: item.collectionName.map(normalizedStructuredValue) ?? "" collection: item.collectionName.map(normalizedStructuredValue) ?? ""
) )
searchDocumentsByItemID[item.id] = document searchDocumentsByItemID[item.id] = document
#if DEBUG
debugSearchDocumentBuildCount += 1
#endif
return document return document
} }

View File

@@ -352,53 +352,4 @@ final class LinkPreviewWindowController: NSWindowController, WKNavigationDelegat
decisionHandler(.allow) decisionHandler(.allow)
} }
#if DEBUG
var debugTitleText: String {
titleLabel.stringValue
}
var debugAddressText: String {
addressLabel.stringValue
}
var debugStatusText: String {
statusLabel.stringValue
}
var debugTitleTooltip: String? {
titleLabel.toolTip
}
var debugAddressTooltip: String? {
addressLabel.toolTip
}
var debugStatusTooltip: String? {
statusLabel.toolTip
}
func debugPrepareForPreview(_ request: LinkPreviewRequest) {
prepareForPreview(request)
}
func debugSetDisplayedPageURL(_ url: URL) {
setAddress(url)
}
func debugOpenInBrowser() {
openInBrowser()
}
func debugAllowObservedPageTitles() {
acceptsObservedPageTitles = true
}
func debugApplyObservedPageTitle(_ title: String?) {
applyObservedPageTitle(title)
}
func debugApplyNavigationFailure(_ error: Error) {
handleNavigationFailure(error)
}
#endif
} }

View File

@@ -355,13 +355,4 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
settings.iCloudSyncEnabled = iCloudSyncButton.state == .on settings.iCloudSyncEnabled = iCloudSyncButton.state == .on
} }
#if DEBUG
var debugShowMenuBarIconIsEnabled: Bool {
showMenuBarIconButton.state == .on
}
var debugShowDockIconIsEnabled: Bool {
showDockIconButton.state == .on
}
#endif
} }

View File

@@ -13,6 +13,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
static let settingsContentMinimumWidth: CGFloat = 440 static let settingsContentMinimumWidth: CGFloat = 440
static let settingsLabelWidth: CGFloat = 128 static let settingsLabelWidth: CGFloat = 128
} }
private enum ControlCommand: Int {
case historyLength, historyRetention, pruneDuplicates, keepFirstImage, defaultSort
case launchAtLogin, showMenuBarIcon, showDockIcon, panelSide
case pauseCapture, excludeSensitive, includeImageText
case clearHistoryOnQuit, hideFromScreenCapture, requestAccessibility, refreshAccessibility
case pollProfile, cacheLimit
case iCloudSync, pushICloudArchive, pullICloudArchive, revealICloudFile
case openHistoryFolder, exportArchive, importArchive, clearHistory, clearCache
}
private static let tabTitles = ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"] private static let tabTitles = ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]
private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled." private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled."
private static let allowedContentTypesUpdatedMessage = "Allowed content types updated." private static let allowedContentTypesUpdatedMessage = "Allowed content types updated."
@@ -82,11 +91,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
private let exportArchiveButton = NSButton() private let exportArchiveButton = NSButton()
private let importArchiveButton = NSButton() private let importArchiveButton = NSButton()
#if DEBUG
private var debugFullRefreshCountValue = 0
private var debugIgnoredAppsRefreshCountValue = 0
private var debugDestructiveActionConfirmationOverride: Bool?
#endif
init( init(
settings: SettingsModel, settings: SettingsModel,
@@ -180,10 +184,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return item return item
} }
private func tabTitle(for item: NSTabViewItem) -> String {
item.label.clipboardTrimmed
}
private func scrollContainer(for content: NSView) -> NSView { private func scrollContainer(for content: NSView) -> NSView {
let scrollView = NSScrollView() let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true scrollView.hasVerticalScroller = true
@@ -216,26 +216,25 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
historyStepper.minValue = Double(AppConfiguration.minHistoryLength) historyStepper.minValue = Double(AppConfiguration.minHistoryLength)
historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength) historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength)
historyStepper.increment = 25 historyStepper.increment = 25
historyStepper.target = self bind(historyStepper, to: .historyLength)
historyStepper.action = #selector(historyLengthChanged)
historyStepper.setAccessibilityLabel("History length") historyStepper.setAccessibilityLabel("History length")
configurePopup(historyRetentionPopup, action: #selector(historyRetentionChanged)) configurePopup(historyRetentionPopup, command: .historyRetention)
historyRetentionPopup.setAccessibilityLabel("Keep history") historyRetentionPopup.setAccessibilityLabel("Keep history")
for retention in HistoryRetention.allCases { for retention in HistoryRetention.allCases {
addPopupItem(retention.title, retention.rawValue, to: historyRetentionPopup) addPopupItem(retention.title, retention.rawValue, to: historyRetentionPopup)
} }
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", action: #selector(pruneDuplicatesChanged)) configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", command: .pruneDuplicates)
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", action: #selector(keepFirstImageChanged)) configureCheckbox(keepFirstImageButton, title: "Keep first image copy", command: .keepFirstImage)
configurePopup(defaultSortPopup, action: #selector(defaultSortChanged)) configurePopup(defaultSortPopup, command: .defaultSort)
defaultSortPopup.setAccessibilityLabel("Default sort") defaultSortPopup.setAccessibilityLabel("Default sort")
for mode in ClipboardSortMode.allCases { for mode in ClipboardSortMode.allCases {
addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup) addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup)
} }
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged)) configureCheckbox(launchAtLoginButton, title: "Launch at login", command: .launchAtLogin)
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged)) configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", command: .showMenuBarIcon)
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(showDockIconChanged)) configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", command: .showDockIcon)
configurePopup(panelSidePopup, action: #selector(panelSideChanged)) configurePopup(panelSidePopup, command: .panelSide)
panelSidePopup.setAccessibilityLabel("Shelf side") panelSidePopup.setAccessibilityLabel("Shelf side")
for side in ClipboardPanelSide.allCases { for side in ClipboardPanelSide.allCases {
addPopupItem(side.title, side.rawValue, to: panelSidePopup) addPopupItem(side.title, side.rawValue, to: panelSidePopup)
@@ -278,9 +277,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
private func captureSettingsView() -> NSView { private func captureSettingsView() -> NSView {
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", action: #selector(pauseCaptureChanged)) configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", command: .pauseCapture)
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", action: #selector(excludeSensitiveChanged)) configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", command: .excludeSensitive)
configureCheckbox(includeImageTextButton, title: "Search in image labels", action: #selector(includeImageTextChanged)) configureCheckbox(includeImageTextButton, title: "Search in image labels", command: .includeImageText)
configureStatusLabel(captureStatusLabel) configureStatusLabel(captureStatusLabel)
let allowedRows = [ let allowedRows = [
@@ -333,11 +332,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
let storageLabel = caption("History stays in Application Support. Text and managed media are encrypted with Keychain or an owner-only fallback key.") let storageLabel = caption("History stays in Application Support. Text and managed media are encrypted with Keychain or an owner-only fallback key.")
let screenPrivacyLabel = caption("When enabled, the clipboard panel is hidden from screenshots, screen sharing, and screen recordings.") let screenPrivacyLabel = caption("When enabled, the clipboard panel is hidden from screenshots, screen sharing, and screen recordings.")
let permissionHelpLabel = caption("Clipboard capture works without this permission. Grant Accessibility only for direct paste.") let permissionHelpLabel = caption("Clipboard capture works without this permission. Grant Accessibility only for direct paste.")
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged)) configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", command: .clearHistoryOnQuit)
configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", action: #selector(hideFromScreenCaptureChanged)) configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", command: .hideFromScreenCapture)
configureStatusLabel(accessibilityStatusLabel) configureStatusLabel(accessibilityStatusLabel)
let requestButton = button("Open Accessibility Settings", #selector(requestAccessibilityAccess)) let requestButton = button("Open Accessibility Settings", .requestAccessibility)
let refreshButton = button("Refresh Permission Status", #selector(refreshAccessibilityPermissionStatus)) let refreshButton = button("Refresh Permission Status", .refreshAccessibility)
configureStatusLabel(pasteStatusLabel) configureStatusLabel(pasteStatusLabel)
return page([ return page([
@@ -361,7 +360,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
private func performanceSettingsView() -> NSView { private func performanceSettingsView() -> NSView {
configurePopup(pollProfilePopup, action: #selector(pollProfileChanged)) configurePopup(pollProfilePopup, command: .pollProfile)
pollProfilePopup.setAccessibilityLabel("Polling profile") pollProfilePopup.setAccessibilityLabel("Polling profile")
for profile in AppConfiguration.PollProfile.allCases { for profile in AppConfiguration.PollProfile.allCases {
addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup) addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup)
@@ -370,8 +369,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
cacheSlider.maxValue = Double(AppConfiguration.maxCacheMaxBytes) / 1024 / 1024 cacheSlider.maxValue = Double(AppConfiguration.maxCacheMaxBytes) / 1024 / 1024
cacheSlider.numberOfTickMarks = 9 cacheSlider.numberOfTickMarks = 9
cacheSlider.allowsTickMarkValuesOnly = true cacheSlider.allowsTickMarkValuesOnly = true
cacheSlider.target = self bind(cacheSlider, to: .cacheLimit)
cacheSlider.action = #selector(cacheLimitChanged)
cacheSlider.setAccessibilityLabel("Image cache cap in megabytes") cacheSlider.setAccessibilityLabel("Image cache cap in megabytes")
configureStatusLabel(cacheLabel) configureStatusLabel(cacheLabel)
@@ -389,12 +387,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
private func dataSettingsView() -> NSView { private func dataSettingsView() -> NSView {
configureStatusLabel(dataStatusLabel) configureStatusLabel(dataStatusLabel)
configureStatusLabel(cloudSyncStatusLabel) configureStatusLabel(cloudSyncStatusLabel)
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", action: #selector(iCloudSyncChanged)) configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", command: .iCloudSync)
configureButton(iCloudSyncNowButton, title: "Sync Now", action: #selector(pushICloudSyncArchive)) configureButton(iCloudSyncNowButton, title: "Sync Now", command: .pushICloudArchive)
configureButton(iCloudRestoreButton, title: "Restore from iCloud", action: #selector(pullICloudSyncArchive)) configureButton(iCloudRestoreButton, title: "Restore from iCloud", command: .pullICloudArchive)
configureButton(iCloudRevealButton, title: "Reveal Sync File", action: #selector(revealICloudSyncFile)) configureButton(iCloudRevealButton, title: "Reveal Sync File", command: .revealICloudFile)
configureButton(exportArchiveButton, title: "Export Archive...", action: #selector(exportClipboardArchive)) configureButton(exportArchiveButton, title: "Export Archive...", command: .exportArchive)
configureButton(importArchiveButton, title: "Import Archive...", action: #selector(importClipboardArchive)) configureButton(importArchiveButton, title: "Import Archive...", command: .importArchive)
let archiveLabel = caption("Export a portable archive for history, Pinboards, and managed attachments. Archives are not encrypted; file references stay path-based.") let archiveLabel = caption("Export a portable archive for history, Pinboards, and managed attachments. Archives are not encrypted; file references stay path-based.")
let cloudLabel = caption("Uses the same archive in ClipBored's private iCloud container when iCloud signing and iCloud Drive are available.") let cloudLabel = caption("Uses the same archive in ClipBored's private iCloud container when iCloud signing and iCloud Drive are available.")
return page([ return page([
@@ -416,9 +414,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
]) ])
]), ]),
section("Data", [ section("Data", [
button("Open History Folder", #selector(openHistoryFolder)), button("Open History Folder", .openHistoryFolder),
button("Clear Clipboard History", #selector(clearClipboardHistory)), button("Clear Clipboard History", .clearHistory),
button("Clear Thumbnail Cache", #selector(clearThumbnailCache)), button("Clear Thumbnail Cache", .clearCache),
dataStatusLabel dataStatusLabel
]) ])
]) ])
@@ -472,20 +470,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return label return label
} }
private func button(_ title: String, _ action: Selector) -> NSButton { private func button(_ title: String, _ command: ControlCommand) -> NSButton {
let control = NSButton() let control = NSButton()
configureButton(control, title: title, action: action) configureButton(control, title: title, command: command)
return control return control
} }
private func configureButton(_ control: NSButton, title: String, action: Selector) { private func configureButton(_ control: NSButton, title: String, command: ControlCommand) {
control.title = title control.title = title
control.target = self bind(control, to: command)
control.action = action
control.bezelStyle = .rounded control.bezelStyle = .rounded
control.setAccessibilityLabel(title) control.setAccessibilityLabel(title)
} }
private func configureCheckbox(_ control: NSButton, title: String, command: ControlCommand) {
configureCheckbox(control, title: title, action: #selector(performControlCommand(_:)))
control.tag = command.rawValue
}
private func configureCheckbox(_ control: NSButton, title: String, action: Selector) { private func configureCheckbox(_ control: NSButton, title: String, action: Selector) {
control.setButtonType(.switch) control.setButtonType(.switch)
control.title = title control.title = title
@@ -504,10 +506,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
} }
private func configurePopup(_ popup: NSPopUpButton, action: Selector) { private func configurePopup(_ popup: NSPopUpButton, command: ControlCommand) {
popup.removeAllItems() popup.removeAllItems()
popup.target = self bind(popup, to: command)
popup.action = action }
private func bind(_ control: NSControl, to command: ControlCommand) {
control.tag = command.rawValue
control.target = self
control.action = #selector(performControlCommand(_:))
} }
private func addPopupItem(_ title: String, _ rawValue: Int, to popup: NSPopUpButton) { private func addPopupItem(_ title: String, _ rawValue: Int, to popup: NSPopUpButton) {
@@ -628,9 +635,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
private func refreshFromSettings(refreshCloudSyncStatus: Bool = false) { private func refreshFromSettings(refreshCloudSyncStatus: Bool = false) {
#if DEBUG
debugFullRefreshCountValue += 1
#endif
refreshHistoryLimitControls() refreshHistoryLimitControls()
refreshHistoryRetentionControl() refreshHistoryRetentionControl()
@@ -969,9 +973,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
private func refreshIgnoredAppsTextView(force: Bool = false) { private func refreshIgnoredAppsTextView(force: Bool = false) {
#if DEBUG
debugIgnoredAppsRefreshCountValue += 1
#endif
guard force || !isEditingIgnoredApps else { return } guard force || !isEditingIgnoredApps else { return }
@@ -1048,67 +1049,98 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
} }
@objc private func historyLengthChanged() { @objc private func performControlCommand(_ sender: NSControl) {
settings.maxHistoryItems = historyStepper.integerValue guard let command = ControlCommand(rawValue: sender.tag) else { return }
refreshHistoryLimitControls() switch command {
} case .historyLength:
settings.maxHistoryItems = historyStepper.integerValue
@objc private func historyRetentionChanged() { refreshHistoryLimitControls()
if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int, case .historyRetention:
let retention = HistoryRetention(rawValue: rawValue) { if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int,
settings.historyRetention = retention let retention = HistoryRetention(rawValue: rawValue) {
} settings.historyRetention = retention
} }
case .pruneDuplicates:
@objc private func pruneDuplicatesChanged() { settings.pruneDuplicates = pruneDuplicatesButton.state == .on
settings.pruneDuplicates = pruneDuplicatesButton.state == .on case .keepFirstImage:
} settings.keepFirstImage = keepFirstImageButton.state == .on
case .defaultSort:
@objc private func keepFirstImageChanged() { if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int,
settings.keepFirstImage = keepFirstImageButton.state == .on let mode = ClipboardSortMode(rawValue: rawValue) {
} settings.defaultSortMode = mode
}
@objc private func defaultSortChanged() { case .launchAtLogin:
if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int, let enabled = launchAtLoginButton.state == .on
let mode = ClipboardSortMode(rawValue: rawValue) { settings.launchAtLogin = enabled
settings.defaultSortMode = mode if !enabled {
} settings.setLaunchAtLoginStatus(message: "")
} }
refreshLaunchAtLoginControls()
@objc private func launchAtLoginChanged() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
let enabled = launchAtLoginButton.state == .on self?.refreshLaunchAtLoginControls()
settings.launchAtLogin = enabled }
if !enabled { case .showMenuBarIcon:
settings.setLaunchAtLoginStatus(message: "") let shouldShow = showMenuBarIconButton.state == .on
} settings.showMenuBarIcon = shouldShow
refreshLaunchAtLoginControls() if !shouldShow && !settings.showDockIcon {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in settings.showDockIcon = true
self?.refreshLaunchAtLoginControls() }
} refreshVisibilityControls()
} case .showDockIcon:
let shouldShow = showDockIconButton.state == .on
@objc private func showMenuBarIconChanged() { settings.showDockIcon = shouldShow
let shouldShowMenuBarIcon = showMenuBarIconButton.state == .on if !shouldShow && !settings.showMenuBarIcon {
settings.showMenuBarIcon = shouldShowMenuBarIcon settings.showMenuBarIcon = true
if !shouldShowMenuBarIcon && !settings.showDockIcon { }
settings.showDockIcon = true refreshVisibilityControls()
} case .panelSide:
refreshVisibilityControls() if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int,
} let side = ClipboardPanelSide(rawValue: rawValue) {
settings.panelSide = side
@objc private func showDockIconChanged() { }
let shouldShowDockIcon = showDockIconButton.state == .on case .pauseCapture:
settings.showDockIcon = shouldShowDockIcon if pauseCaptureButton.state == .on {
if !shouldShowDockIcon && !settings.showMenuBarIcon { settings.pauseCaptureUntil = nil
settings.showMenuBarIcon = true settings.pauseCapture = true
} } else {
refreshVisibilityControls() settings.pauseCapture = false
} settings.pauseCaptureUntil = nil
}
@objc private func panelSideChanged() { case .excludeSensitive:
if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int, settings.excludeSensitive = excludeSensitiveButton.state == .on
let side = ClipboardPanelSide(rawValue: rawValue) { case .includeImageText:
settings.panelSide = side settings.includeImageTextInSearch = includeImageTextButton.state == .on
case .clearHistoryOnQuit:
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
case .hideFromScreenCapture:
settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on
case .requestAccessibility: requestAccessibilityAccess()
case .refreshAccessibility: refreshAccessibilityPermissionStatus()
case .pollProfile:
if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int,
let profile = AppConfiguration.PollProfile(rawValue: rawValue) {
settings.pollProfileRaw = profile
}
case .cacheLimit:
let megabytes = Int(cacheSlider.doubleValue.rounded())
settings.imageCacheMaxBytes = Int64(megabytes * 1024 * 1024)
refreshCacheControls()
case .iCloudSync:
let enabled = iCloudSyncButton.state == .on
settings.iCloudSyncEnabled = enabled
settings.setCloudSyncStatus(message: "")
if !enabled {
cachedCloudSyncStatus = nil
}
refreshCloudSyncControls(refreshStatus: enabled && cachedCloudSyncStatus == nil)
case .openHistoryFolder: openHistoryFolder()
case .exportArchive: exportClipboardArchive()
case .importArchive: importClipboardArchive()
case .pushICloudArchive: pushICloudSyncArchive()
case .pullICloudArchive: pullICloudSyncArchive()
case .revealICloudFile: revealICloudSyncFile()
case .clearHistory: clearClipboardHistory()
case .clearCache: clearThumbnailCache()
} }
} }
@@ -1171,24 +1203,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
} }
@objc private func pauseCaptureChanged() {
if pauseCaptureButton.state == .on {
settings.pauseCaptureUntil = nil
settings.pauseCapture = true
} else {
settings.pauseCapture = false
settings.pauseCaptureUntil = nil
}
}
@objc private func excludeSensitiveChanged() {
settings.excludeSensitive = excludeSensitiveButton.state == .on
}
@objc private func includeImageTextChanged() {
settings.includeImageTextInSearch = includeImageTextButton.state == .on
}
@objc private func allowedKindChanged(_ sender: NSButton) { @objc private func allowedKindChanged(_ sender: NSButton) {
guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return } guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return }
var ignored = settings.ignoredItemKindsRaw var ignored = settings.ignoredItemKindsRaw
@@ -1228,15 +1242,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
commitIgnoredAppsDraftIfNeeded() commitIgnoredAppsDraftIfNeeded()
} }
@objc private func clearHistoryOnQuitChanged() { private func requestAccessibilityAccess() {
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
}
@objc private func hideFromScreenCaptureChanged() {
settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on
}
@objc private func requestAccessibilityAccess() {
_ = AccessibilityPermissionService.requestPromptIfNeeded() _ = AccessibilityPermissionService.requestPromptIfNeeded()
if !AccessibilityPermissionService.isTrusted { if !AccessibilityPermissionService.isTrusted {
AccessibilityPermissionService.openSystemSettings() AccessibilityPermissionService.openSystemSettings()
@@ -1247,7 +1253,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
refreshAccessibilityPermissionStatus() refreshAccessibilityPermissionStatus()
} }
@objc private func refreshAccessibilityPermissionStatus() { private func refreshAccessibilityPermissionStatus() {
settings.setAccessibilityPermissionStatus( settings.setAccessibilityPermissionStatus(
message: AccessibilityPermissionService.isTrusted message: AccessibilityPermissionService.isTrusted
? "" ? ""
@@ -1256,34 +1262,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
refreshAccessibilityPermissionStatusLabel() refreshAccessibilityPermissionStatusLabel()
} }
@objc private func pollProfileChanged() { private func openHistoryFolder() {
if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int,
let profile = AppConfiguration.PollProfile(rawValue: rawValue) {
settings.pollProfileRaw = profile
}
}
@objc private func cacheLimitChanged() {
let megabytes = Int(cacheSlider.doubleValue.rounded())
settings.imageCacheMaxBytes = Int64(megabytes * 1024 * 1024)
refreshCacheControls()
}
@objc private func iCloudSyncChanged() {
let enabled = iCloudSyncButton.state == .on
settings.iCloudSyncEnabled = enabled
settings.setCloudSyncStatus(message: "")
if !enabled {
cachedCloudSyncStatus = nil
}
refreshCloudSyncControls(refreshStatus: enabled && cachedCloudSyncStatus == nil)
}
@objc private func openHistoryFolder() {
NSWorkspace.shared.open(ClipboardStore.storageDirectory()) NSWorkspace.shared.open(ClipboardStore.storageDirectory())
} }
@objc private func exportClipboardArchive() { private func exportClipboardArchive() {
let panel = NSSavePanel() let panel = NSSavePanel()
panel.title = "Export ClipBored Archive" panel.title = "Export ClipBored Archive"
panel.nameFieldStringValue = defaultArchiveFileName() panel.nameFieldStringValue = defaultArchiveFileName()
@@ -1303,7 +1286,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
} }
@objc private func importClipboardArchive() { private func importClipboardArchive() {
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.title = "Import ClipBored Archive" panel.title = "Import ClipBored Archive"
panel.canChooseFiles = true panel.canChooseFiles = true
@@ -1329,7 +1312,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
} }
@objc private func pushICloudSyncArchive() { private func pushICloudSyncArchive() {
guard settings.iCloudSyncEnabled else { guard settings.iCloudSyncEnabled else {
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before syncing.") settings.setCloudSyncStatus(message: "Turn on iCloud Sync before syncing.")
return return
@@ -1352,7 +1335,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
} }
@objc private func pullICloudSyncArchive() { private func pullICloudSyncArchive() {
guard settings.iCloudSyncEnabled else { guard settings.iCloudSyncEnabled else {
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before restoring.") settings.setCloudSyncStatus(message: "Turn on iCloud Sync before restoring.")
return return
@@ -1400,7 +1383,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
} }
@objc private func revealICloudSyncFile() { private func revealICloudSyncFile() {
do { do {
let url = try cloudSyncService.syncArchiveURL() let url = try cloudSyncService.syncArchiveURL()
if FileManager.default.fileExists(atPath: url.path) { if FileManager.default.fileExists(atPath: url.path) {
@@ -1415,7 +1398,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
} }
@objc private func clearClipboardHistory() { private func clearClipboardHistory() {
guard confirmDestructiveAction( guard confirmDestructiveAction(
title: "Clear Clipboard History?", title: "Clear Clipboard History?",
message: "This permanently removes saved clipboard items, app-managed attachments, temporary decrypted previews, and the local fallback encryption key when present. The current system clipboard is not changed.", message: "This permanently removes saved clipboard items, app-managed attachments, temporary decrypted previews, and the local fallback encryption key when present. The current system clipboard is not changed.",
@@ -1426,7 +1409,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
setDataStatus("Cleared clipboard history.") setDataStatus("Cleared clipboard history.")
} }
@objc private func clearThumbnailCache() { private func clearThumbnailCache() {
guard confirmDestructiveAction( guard confirmDestructiveAction(
title: "Clear Thumbnail Cache?", title: "Clear Thumbnail Cache?",
message: "This removes cached image previews and temporary decrypted previews. ClipBored will recreate previews as needed.", message: "This removes cached image previews and temporary decrypted previews. ClipBored will recreate previews as needed.",
@@ -1437,11 +1420,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
} }
private func confirmDestructiveAction(title: String, message: String, buttonTitle: String) -> Bool { private func confirmDestructiveAction(title: String, message: String, buttonTitle: String) -> Bool {
#if DEBUG
if let override = debugDestructiveActionConfirmationOverride {
return override
}
#endif
let alert = NSAlert() let alert = NSAlert()
alert.messageText = title alert.messageText = title
@@ -1551,447 +1529,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return nil return nil
} }
#if DEBUG
var debugWindowStyleMask: NSWindow.StyleMask {
window?.styleMask ?? []
}
var debugWindowMinSize: NSSize {
window?.minSize ?? .zero
}
var debugWindowContentSize: NSSize {
window?.contentView?.bounds.size ?? .zero
}
var debugRawSettingsTabLabels: [String] {
tabView.tabViewItems.map(\.label)
}
func debugSetWindowContentSize(_ size: NSSize) {
window?.setContentSize(size)
window?.contentView?.layoutSubtreeIfNeeded()
}
var debugSettingsTabLayoutMetrics: [(label: String, viewport: NSSize, document: NSSize, hasHorizontalScroller: Bool)] {
let originalSelection = tabView.selectedTabViewItem
defer {
if let originalSelection {
tabView.selectTabViewItem(originalSelection)
}
window?.contentView?.layoutSubtreeIfNeeded()
}
return tabView.tabViewItems.map { item in
tabView.selectTabViewItem(item)
window?.contentView?.layoutSubtreeIfNeeded()
item.view?.layoutSubtreeIfNeeded()
guard let scrollView = item.view as? NSScrollView else {
return (tabTitle(for: item), .zero, .zero, true)
}
scrollView.documentView?.layoutSubtreeIfNeeded()
return (
tabTitle(for: item),
scrollView.contentView.bounds.size,
scrollView.documentView?.frame.size ?? .zero,
scrollView.hasHorizontalScroller
)
}
}
var debugSettingsTabLayoutAuditMetrics: [(label: String, overflowingViewCount: Int, zeroSizedControlCount: Int)] {
let originalSelection = tabView.selectedTabViewItem
defer {
if let originalSelection {
tabView.selectTabViewItem(originalSelection)
}
window?.contentView?.layoutSubtreeIfNeeded()
}
func visibleDescendants(of root: NSView) -> [NSView] {
var result: [NSView] = []
func visit(_ view: NSView) {
guard !view.isHidden else { return }
result.append(view)
for subview in view.subviews {
visit(subview)
}
}
for subview in root.subviews {
visit(subview)
}
return result
}
func shouldAudit(_ view: NSView) -> Bool {
!(view is NSScroller)
}
func canCollapseToZero(_ view: NSView) -> Bool {
view is NSControl || view is NSTextView
}
return tabView.tabViewItems.map { item in
tabView.selectTabViewItem(item)
window?.contentView?.layoutSubtreeIfNeeded()
item.view?.layoutSubtreeIfNeeded()
guard let scrollView = item.view as? NSScrollView,
let documentView = scrollView.documentView else {
return (tabTitle(for: item), 1, 1)
}
documentView.layoutSubtreeIfNeeded()
let auditedViews = visibleDescendants(of: documentView).filter(shouldAudit)
let documentBounds = documentView.bounds
let overflowing = auditedViews.filter { view in
let frame = view.convert(view.bounds, to: documentView)
return frame.minX < -1 || frame.maxX > documentBounds.width + 1
}
let zeroSizedControls = auditedViews.filter { view in
guard canCollapseToZero(view) else { return false }
let frame = view.convert(view.bounds, to: documentView)
return frame.width <= 0.5 || frame.height <= 0.5
}
return (tabTitle(for: item), overflowing.count, zeroSizedControls.count)
}
}
var debugSettingsTabContentPlacementMetrics: [(label: String, contentBounds: NSRect, document: NSSize)] {
let originalSelection = tabView.selectedTabViewItem
defer {
if let originalSelection {
tabView.selectTabViewItem(originalSelection)
}
window?.contentView?.layoutSubtreeIfNeeded()
}
func visibleControlsAndLabels(of root: NSView) -> [NSView] {
var result: [NSView] = []
func visit(_ view: NSView) {
guard !view.isHidden else { return }
if view is NSControl || view is NSTextView {
result.append(view)
}
for subview in view.subviews {
visit(subview)
}
}
for subview in root.subviews {
visit(subview)
}
return result
}
return tabView.tabViewItems.map { item in
tabView.selectTabViewItem(item)
window?.contentView?.layoutSubtreeIfNeeded()
item.view?.layoutSubtreeIfNeeded()
guard let scrollView = item.view as? NSScrollView,
let documentView = scrollView.documentView else {
return (tabTitle(for: item), .zero, .zero)
}
documentView.layoutSubtreeIfNeeded()
let bounds = visibleControlsAndLabels(of: documentView)
.map { $0.convert($0.bounds, to: documentView) }
.reduce(NSRect.null) { $0.union($1) }
return (tabTitle(for: item), bounds.isNull ? .zero : bounds, documentView.frame.size)
}
}
var debugCloudSyncStatusText: String {
cloudSyncStatusLabel.stringValue
}
var debugCloudSyncActionButtonsAreEnabled: [Bool] {
[
iCloudSyncNowButton.isEnabled,
iCloudRestoreButton.isEnabled,
iCloudRevealButton.isEnabled
]
}
var debugHistoryText: String {
historyLabel.stringValue
}
var debugHistoryStepperValue: Int {
historyStepper.integerValue
}
var debugCacheStatusText: String {
cacheLabel.stringValue
}
var debugCacheSliderMegabytes: Int {
Int(cacheSlider.doubleValue.rounded())
}
var debugDataStatusText: String {
dataStatusLabel.stringValue
}
var debugDataStatusColor: NSColor {
dataStatusLabel.textColor ?? .clear
}
var debugDataStatusSectionTitle: String {
guard let section = dataStatusLabel.superview as? NSStackView,
let titleLabel = section.arrangedSubviews.first as? NSTextField else {
return ""
}
return titleLabel.stringValue
}
var debugPasteStatusText: String {
pasteStatusLabel.stringValue
}
var debugAccessibilityStatusText: String {
accessibilityStatusLabel.stringValue
}
var debugCaptureStatusText: String {
captureStatusLabel.stringValue
}
var debugCaptureStatusColor: NSColor {
captureStatusLabel.textColor ?? .clear
}
var debugStatusLabelsAllowWrapping: Bool {
[
launchStatusLabel,
shortcutStatusLabel,
captureStatusLabel,
accessibilityStatusLabel,
pasteStatusLabel,
cacheLabel,
dataStatusLabel,
cloudSyncStatusLabel
].allSatisfy { label in
label.lineBreakMode == .byWordWrapping
&& label.maximumNumberOfLines == 0
&& !label.usesSingleLineMode
&& (label.cell?.wraps ?? false)
}
}
var debugDefaultSortTitle: String {
defaultSortPopup.selectedItem?.title ?? ""
}
var debugPruneDuplicatesIsEnabled: Bool {
pruneDuplicatesButton.state == .on
}
var debugKeepFirstImageIsEnabled: Bool {
keepFirstImageButton.state == .on
}
var debugIncludeImageTextIsEnabled: Bool {
includeImageTextButton.state == .on
}
var debugExcludeSensitiveIsEnabled: Bool {
excludeSensitiveButton.state == .on
}
var debugClearHistoryOnQuitIsEnabled: Bool {
clearHistoryOnQuitButton.state == .on
}
var debugLaunchAtLoginIsEnabled: Bool {
launchAtLoginButton.state == .on
}
var debugShowMenuBarIconIsEnabled: Bool {
showMenuBarIconButton.state == .on
}
var debugShowDockIconIsEnabled: Bool {
showDockIconButton.state == .on
}
var debugLaunchStatusText: String {
launchStatusLabel.stringValue
}
var debugOpenShortcutKeyText: String {
openShortcutControls?.keyField.stringValue ?? ""
}
var debugSettingsShortcutKeyText: String {
settingsShortcutControls?.keyField.stringValue ?? ""
}
var debugShortcutStatusText: String {
shortcutStatusLabel.stringValue
}
var debugOpenShortcutModifierAccessibilityLabels: [String] {
guard let controls = openShortcutControls else { return [] }
return [controls.command, controls.option, controls.control, controls.shift]
.compactMap { $0.accessibilityLabel() }
}
var debugOpenShortcutModifierAccessibilityHelps: [String] {
guard let controls = openShortcutControls else { return [] }
return [controls.command, controls.option, controls.control, controls.shift]
.compactMap { $0.accessibilityHelp() }
}
var debugSettingsContentView: NSView? {
window?.contentView
}
func debugSelectSettingsTab(at index: Int) {
guard index >= 0, index < tabView.numberOfTabViewItems else { return }
tabSelector.selectedSegment = index
tabView.selectTabViewItem(at: index)
window?.contentView?.layoutSubtreeIfNeeded()
}
func debugPrepareWindowForSnapshot() {
window?.setFrameOrigin(.zero)
window?.orderFront(nil)
window?.contentView?.layoutSubtreeIfNeeded()
window?.contentView?.displayIfNeeded()
}
var debugFullRefreshCount: Int {
debugFullRefreshCountValue
}
var debugIgnoredAppsRefreshCount: Int {
debugIgnoredAppsRefreshCountValue
}
var debugIgnoredAppsText: String {
ignoredAppsTextView.string
}
var debugIgnoredAppsEditorIsFocused: Bool {
isEditingIgnoredApps
}
@discardableResult
func debugFocusIgnoredAppsEditor() -> Bool {
textDidBeginEditing(Notification(name: NSText.didBeginEditingNotification, object: ignoredAppsTextView))
_ = window?.makeFirstResponder(ignoredAppsTextView)
return isEditingIgnoredApps
}
func debugSetIgnoredAppsText(_ text: String) {
ignoredAppsTextView.string = text
textDidChange(Notification(name: NSText.didChangeNotification, object: ignoredAppsTextView))
}
func debugSetHistoryStepperValue(_ value: Int) {
historyStepper.integerValue = value
historyLengthChanged()
}
func debugSetCacheSliderMegabytes(_ value: Int) {
cacheSlider.doubleValue = Double(value)
cacheLimitChanged()
}
func debugEndIgnoredAppsEditing() {
window?.makeFirstResponder(nil)
textDidEndEditing(Notification(name: NSText.didEndEditingNotification, object: ignoredAppsTextView))
}
func debugCloseWindow() {
windowWillClose(Notification(name: NSWindow.willCloseNotification, object: window))
window?.makeFirstResponder(nil)
}
@discardableResult
func debugBeginOpenShortcutKeyEditing() -> Bool {
guard let field = openShortcutControls?.keyField else { return false }
controlTextDidBeginEditing(Notification(name: NSControl.textDidBeginEditingNotification, object: field))
_ = window?.makeFirstResponder(field)
return isEditingShortcutKeyField(field)
}
func debugSetOpenShortcutKeyDraft(_ text: String) {
openShortcutControls?.keyField.stringValue = text
}
func debugEndOpenShortcutKeyEditing() {
guard let field = openShortcutControls?.keyField else { return }
window?.makeFirstResponder(nil)
controlTextDidEndEditing(Notification(name: NSControl.textDidEndEditingNotification, object: field))
}
func debugCommitOpenShortcutKeyText(_ text: String) {
guard let controls = openShortcutControls else { return }
controls.keyField.stringValue = text
shortcutChanged(controls.keyField)
}
func debugSetOpenShortcutModifiers(command: Bool, option: Bool, control: Bool, shift: Bool) {
guard let controls = openShortcutControls else { return }
controls.command.state = command ? .on : .off
controls.option.state = option ? .on : .off
controls.control.state = control ? .on : .off
controls.shift.state = shift ? .on : .off
shortcutChanged(controls.command)
}
func debugCommitSettingsShortcutKeyText(_ text: String) {
guard let controls = settingsShortcutControls else { return }
controls.keyField.stringValue = text
shortcutChanged(controls.keyField)
}
func debugSetLaunchAtLoginEnabled(_ enabled: Bool) {
launchAtLoginButton.state = enabled ? .on : .off
launchAtLoginChanged()
}
func debugSetShowMenuBarIconEnabled(_ enabled: Bool) {
showMenuBarIconButton.state = enabled ? .on : .off
showMenuBarIconChanged()
}
func debugSetShowDockIconEnabled(_ enabled: Bool) {
showDockIconButton.state = enabled ? .on : .off
showDockIconChanged()
}
func debugSetICloudSyncEnabled(_ enabled: Bool) {
iCloudSyncButton.state = enabled ? .on : .off
iCloudSyncChanged()
}
func debugSetDestructiveActionConfirmation(_ confirmed: Bool?) {
debugDestructiveActionConfirmationOverride = confirmed
}
func debugClearClipboardHistory() {
clearClipboardHistory()
}
func debugClearThumbnailCache() {
clearThumbnailCache()
}
func debugAllowedKindIsEnabled(_ kind: ClipboardItemKind) -> Bool {
allowedKindButtons.first { $0.0 == kind }?.1.state == .on
}
func debugSetAllowedKindEnabled(_ kind: ClipboardItemKind, _ enabled: Bool) {
guard let button = allowedKindButtons.first(where: { $0.0 == kind })?.1 else { return }
button.state = enabled ? .on : .off
allowedKindChanged(button)
}
func debugRefreshAccessibilityPermissionStatus() {
refreshAccessibilityPermissionStatus()
}
#endif
} }
private struct ShortcutControlSet { private struct ShortcutControlSet {

View File

@@ -4,21 +4,6 @@ import XCTest
@testable import ClipBored @testable import ClipBored
final class ClipboardPanelControllerTests: XCTestCase { final class ClipboardPanelControllerTests: XCTestCase {
private var tempURLs: [URL] = []
private var defaultsSuites: [String] = []
override func tearDown() {
for url in tempURLs {
try? FileManager.default.removeItem(at: url)
}
tempURLs.removeAll()
for suite in defaultsSuites {
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
}
defaultsSuites.removeAll()
super.tearDown()
}
func testPanelFrameUsesRightSideShelfByDefault() { func testPanelFrameUsesRightSideShelfByDefault() {
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800) let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame) let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
@@ -31,7 +16,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(frames.hidden.minY, frames.shown.minY) XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
} }
func testOpenScreenSelectionUsesStatusClickScreenWhenProvided() { func testOpenScreenSelectionPrefersExplicitThenPointerScreen() {
XCTAssertEqual( XCTAssertEqual(
ClipboardPanelController.selectedOpenScreen( ClipboardPanelController.selectedOpenScreen(
explicit: "status-item-screen", explicit: "status-item-screen",
@@ -41,9 +26,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
), ),
"status-item-screen" "status-item-screen"
) )
}
func testOpenScreenSelectionFallsBackToPointerForGlobalShortcut() {
XCTAssertEqual( XCTAssertEqual(
ClipboardPanelController.selectedOpenScreen( ClipboardPanelController.selectedOpenScreen(
explicit: Optional<String>.none, explicit: Optional<String>.none,
@@ -141,31 +123,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1) XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
} }
func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() {
let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982)
let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861)
let first = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
for _ in 0..<50 {
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown, first.shown)
XCTAssertEqual(frames.hidden, first.hidden)
XCTAssertEqual(frames.hidden.minX, frames.shown.maxX + 1)
}
}
func testPanelAnimationProfileStaysShortForSixtyFpsFeel() {
let profile = ClipboardPanelController.animationProfile
XCTAssertEqual(profile.showDuration, 0.22)
XCTAssertEqual(profile.hideDuration, 0.16)
XCTAssertEqual(profile.reflowDuration, 0.18)
XCTAssertLessThanOrEqual(profile.showDuration * 60, 14)
XCTAssertLessThanOrEqual(profile.hideDuration * 60, 10)
XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 11)
XCTAssertEqual(profile.easing, .easeInEaseOut)
}
func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() { func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() {
let behavior = ClipboardPanelController.panelCollectionBehavior let behavior = ClipboardPanelController.panelCollectionBehavior
@@ -175,69 +132,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertFalse(behavior.contains(.canJoinAllSpaces)) XCTAssertFalse(behavior.contains(.canJoinAllSpaces))
} }
func testShowingPanelClearsAndCollapsesStaleSearchState() throws {
let screen = try XCTUnwrap(NSScreen.screens.first)
let (controller, _) = makeController(preferredScreen: screen)
controller.debugSetSearchFieldText("stale query")
drainMainQueue()
XCTAssertEqual(controller.debugSearchFieldText, "stale query")
XCTAssertEqual(controller.debugSearchFieldWidth, 238, accuracy: 1)
XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "Search clips")
XCTAssertTrue(controller.debugSearchFieldIsVisible)
XCTAssertTrue(controller.debugSearchIconButtonIsVisible)
controller.show(preferredScreen: screen)
defer { controller.hide(immediate: true) }
drainMainQueue()
XCTAssertEqual(controller.debugSearchFieldText, "")
XCTAssertEqual(controller.debugSearchFieldWidth, 30, accuracy: 1)
XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "")
XCTAssertFalse(controller.debugSearchFieldIsVisible)
XCTAssertTrue(controller.debugSearchIconButtonIsVisible)
XCTAssertFalse(controller.debugIsSearchFieldEditing)
}
func testReflowPlanKeepsOpenPanelOnRightSideWhenBottomDockIsVisible() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 112, width: 1512, height: 845)
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY)
XCTAssertEqual(plan.frame.width, 336)
XCTAssertEqual(plan.bottomSafeInset, 20)
}
func testReflowPlanTouchesScreenBottomWhenBottomDockIsAutoHidden() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 4, width: 1512, height: 953)
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY)
XCTAssertEqual(plan.frame.width, 336)
XCTAssertEqual(plan.bottomSafeInset, 18)
}
func testReflowPlanTracksSideDockVisibleFrameWithoutBottomInsetInflation() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 86, y: 0, width: 1426, height: 957)
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
XCTAssertEqual(plan.frame.minX, visibleFrame.maxX - 336)
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
XCTAssertEqual(plan.frame.height, 957)
XCTAssertEqual(plan.bottomSafeInset, 18)
}
func testPanelFrameUsesConfiguredRightSideShelf() { func testPanelFrameUsesConfiguredRightSideShelf() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861) let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
@@ -289,22 +183,20 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(frames.shown.width, 336) XCTAssertEqual(frames.shown.width, 336)
} }
func testContentBottomInsetReservesBottomDockSpace() { func testContentBottomInsetHandlesBottomAndSideDock() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861) let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame) let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(inset, 20) XCTAssertEqual(inset, 20)
} XCTAssertEqual(
ClipboardPanelController.contentBottomInset(
func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() { forScreenFrame: screenFrame,
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) visibleFrame: CGRect(x: 80, y: 0, width: 1432, height: 957)
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957) ),
18
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame) )
XCTAssertEqual(inset, 18)
} }
func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() { func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() {
@@ -326,41 +218,38 @@ final class ClipboardPanelControllerTests: XCTestCase {
} }
func testCommandNumberShortcutsMapToQuickPasteSlots() { func testCommandNumberShortcutsMapToQuickPasteSlots() {
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0) assertShortcutMappings([
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1) (18, .command, 0), (19, .command, 1), (20, .command, 2),
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 20, modifiers: .command), 2) (21, .command, 3), (23, .command, 4), (22, .command, 5),
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 21, modifiers: .command), 3) (26, .command, 6), (28, .command, 7), (25, .command, 8)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 23, modifiers: .command), 4) ], using: ClipboardPanelController.quickPasteIndex)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 22, modifiers: .command), 5)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 26, modifiers: .command), 6)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 28, modifiers: .command), 7)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 25, modifiers: .command), 8)
} }
func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() { func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() {
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .shift]), 0) assertShortcutMappings([
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 25, modifiers: [.command, .shift]), 8) (18, [.command, .shift], 0),
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: .command)) (25, [.command, .shift], 8),
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .option, .shift])) (18, .command, nil),
(18, [.command, .option, .shift], nil)
], using: ClipboardPanelController.quickPastePlainTextIndex)
} }
func testCommandOptionNumberShortcutsMapToCollections() { func testCommandOptionNumberShortcutsMapToCollections() {
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .option]), .mostRecent) assertShortcutMappings([
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: [.command, .option]), .mostUsed) (18, [.command, .option], .mostRecent),
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: [.command, .option]), .text) (19, [.command, .option], .mostUsed),
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: [.command, .option]), .links) (20, [.command, .option], .text),
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: [.command, .option]), .images) (21, [.command, .option], .links),
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files) (23, [.command, .option], .images),
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned) (22, [.command, .option], .files),
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio) (26, [.command, .option], .pinned),
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors) (28, [.command, .option], .audio),
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: [.command, .option]), .code) (25, [.command, .option], .colors),
} (29, [.command, .option], .code),
(18, [], nil),
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() { (18, .command, nil),
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [])) (29, .command, nil)
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command)) ], using: ClipboardPanelController.collectionShortcutMode)
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
} }
func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() { func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() {
@@ -372,60 +261,35 @@ final class ClipboardPanelControllerTests: XCTestCase {
} }
func testNavigationShortcutsMapToShelfMovement() { func testNavigationShortcutsMapToShelfMovement() {
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 115, modifiers: []), .first) assertShortcutMappings([
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 119, modifiers: []), .last) (115, [], .first), (119, [], .last), (124, [], .next),
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: []), .next) (121, [], .pageNext), (116, [], .pagePrevious), (123, [], .previous),
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: []), .pageNext) (126, .command, .first), (125, .command, .last),
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 116, modifiers: []), .pagePrevious) (124, .command, nil), (126, [], nil), (125, [], nil),
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 123, modifiers: []), .previous) (121, .shift, nil), (35, [], nil)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: .command), .first) ], using: ClipboardPanelController.navigationShortcutAction)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 125, modifiers: .command), .last)
}
func testNavigationShortcutsRejectUnsupportedModifiers() {
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: .command))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: []))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 125, modifiers: []))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: .shift))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 35, modifiers: []))
} }
func testSelectionShortcutsMapToRangeAndSelectAllActions() { func testSelectionShortcutsMapToRangeAndSelectAllActions() {
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: .command), .selectAll) assertShortcutMappings([
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 115, modifiers: .shift), .extendFirst) (0, .command, .selectAll),
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 119, modifiers: .shift), .extendLast) (115, .shift, .extendFirst), (119, .shift, .extendLast),
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: .shift), .extendNext) (124, .shift, .extendNext), (121, .shift, .extendPageNext),
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 121, modifiers: .shift), .extendPageNext) (116, .shift, .extendPagePrevious), (123, .shift, .extendPrevious),
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 116, modifiers: .shift), .extendPagePrevious) (0, [], nil), (0, [.command, .shift], nil),
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 123, modifiers: .shift), .extendPrevious) (124, [], nil), (124, [.command, .shift], nil)
} ], using: ClipboardPanelController.selectionShortcutAction)
func testSelectionShortcutsRequireExactModifierSets() {
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: []))
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: [.command, .shift]))
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: []))
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: [.command, .shift]))
} }
func testCommandActionShortcutsMapToSelectedClipActions() { func testCommandActionShortcutsMapToSelectedClipActions() {
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy) assertShortcutMappings([
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 14, modifiers: .command), .edit) (8, .command, .copy), (14, .command, .edit), (3, .command, .focusSearch),
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 3, modifiers: .command), .focusSearch) (45, .command, nil), (5, .command, .showInClipboard),
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 45, modifiers: .command)) (16, .command, .preview), (31, .command, .open), (15, .command, .rename),
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard) (17, .command, .toggleCapturePause), (6, .command, .undoDelete),
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview) (123, .command, .previousCollection), (124, .command, .nextCollection),
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open) (8, [], nil), (8, [.command, .shift], nil), (9, .command, nil)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .rename) ], using: ClipboardPanelController.commandShortcutAction)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 17, modifiers: .command), .toggleCapturePause)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 6, modifiers: .command), .undoDelete)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 123, modifiers: .command), .previousCollection)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 124, modifiers: .command), .nextCollection)
}
func testCommandActionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() {
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: []))
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]))
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command))
} }
func testSettingsShortcutMatchesOnlyItsExactLocalBinding() { func testSettingsShortcutMatchesOnlyItsExactLocalBinding() {
@@ -455,67 +319,32 @@ final class ClipboardPanelControllerTests: XCTestCase {
} }
func testModifiedShortcutsMapToPanelActions() { func testModifiedShortcutsMapToPanelActions() {
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: .shift), .pastePlainText) assertShortcutMappings([
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack) (36, .shift, .pastePlainText),
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .toggleStackCapture) (1, [.command, .shift], .toggleStack),
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText) (8, [.command, .shift], .toggleStackCapture),
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 45, modifiers: [.command, .shift]), .newCollection) (9, [.command, .shift], .pastePlainText),
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext) (45, [.command, .shift], .newCollection),
(36, [.command, .shift], .pasteStackNext),
(36, [], nil), (8, .shift, nil), (8, .command, nil),
(8, [.command, .option, .shift], nil), (31, [.command, .shift], nil)
], using: ClipboardPanelController.modifiedShortcutAction)
} }
func testModifiedShortcutsRequireCommandShiftOnly() { private func assertShortcutMappings<Value: Equatable>(
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [])) _ cases: [(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, expected: Value?)],
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .shift)) using mapping: (UInt16, NSEvent.ModifierFlags) -> Value?,
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .command)) file: StaticString = #filePath,
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .option, .shift])) line: UInt = #line
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 31, modifiers: [.command, .shift])) ) {
} for testCase in cases {
XCTAssertEqual(
private func makeController(preferredScreen: NSScreen) -> (ClipboardPanelController, ClipboardStore) { mapping(testCase.keyCode, testCase.modifiers),
let settings = makeSettings() testCase.expected,
let encryptionService = ClipboardEncryptionService(keyProvider: { nil }) "keyCode \(testCase.keyCode), modifiers \(testCase.modifiers.rawValue)",
let cacheService = ClipboardCacheService( file: file,
baseURL: makeTempDirectory(), line: line
encryptionService: encryptionService )
)
let store = ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: makeTempDirectory(),
encryptionService: encryptionService
)
let controller = ClipboardPanelController(
store: store,
settings: settings,
cacheService: cacheService,
preferredScreen: { preferredScreen }
)
return (controller, store)
}
private func makeSettings() -> SettingsModel {
let suite = "com.clipbored.controllertest.\(UUID().uuidString)"
defaultsSuites.append(suite)
let defaults = UserDefaults(suiteName: suite)!
let settings = SettingsModel(defaults: defaults)
settings.maxHistoryItems = 10
settings.historyRetention = .forever
settings.pruneDuplicates = false
return settings
}
private func makeTempDirectory() -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("clipbored-controllertest")
.appendingPathComponent(UUID().uuidString)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
tempURLs.append(directory)
return directory
}
private func drainMainQueue() {
for _ in 0..<24 {
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
} }
} }
} }

View File

@@ -39,294 +39,6 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"]) XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"])
} }
func testSelectingCollectionWithEmptySearchUsesIndexedVisibleItems() {
let settings = makeSettings()
settings.maxHistoryItems = 260
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
for index in 0..<220 {
store.upsert(makeTextItem("outside indexed category \(index)", createdAt: Date(timeIntervalSince1970: Double(index))))
}
for index in 0..<30 {
var item = makeTextItem("client indexed category \(index)", createdAt: Date(timeIntervalSince1970: Double(1_000 + index)))
item.collectionName = "Client Work"
store.upsert(item)
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 250)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectCollection(named: "Client Work")
XCTAssertEqual(viewModel.visibleItems.count, 30)
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 1)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "client indexed category 29")
}
func testRepeatedThirtyItemCollectionSelectionsStayOnIndexedFastPath() {
let collectionCount = 60
let itemsPerCollection = 30
let settings = makeSettings()
settings.maxHistoryItems = collectionCount * itemsPerCollection
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let collectionNames = (0..<collectionCount).map { "Client Work \($0)" }
for (collectionIndex, collectionName) in collectionNames.enumerated() {
for itemIndex in 0..<itemsPerCollection {
var item = makeTextItem(
"\(collectionName) clip \(itemIndex)",
createdAt: Date(timeIntervalSince1970: Double((collectionIndex * itemsPerCollection) + itemIndex))
)
item.collectionName = collectionName
store.upsert(item)
}
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: collectionCount * itemsPerCollection)
viewModel.debugResetVisibleItemsPerformanceCounters()
let start = CFAbsoluteTimeGetCurrent()
for collectionName in collectionNames {
viewModel.selectCollection(named: collectionName)
XCTAssertEqual(viewModel.visibleItems.count, itemsPerCollection)
}
let elapsed = CFAbsoluteTimeGetCurrent() - start
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, collectionCount)
XCTAssertLessThan(elapsed, 0.20)
}
func testCommandSelectedCategoriesUseIndexedUnionFastPath() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let text = makeTextItem("category text", createdAt: Date(timeIntervalSince1970: 100))
let link = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "category link",
payload: "https://example.com/category",
payloadHash: store.hashString("https://example.com/category"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
store.upsert(text)
store.upsert(link)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectSortMode(.text, extending: true)
viewModel.selectSortMode(.links, extending: true)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/category", "category text"])
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 2)
XCTAssertTrue(viewModel.isSortModeCategorySelected(.text))
XCTAssertTrue(viewModel.isSortModeCategorySelected(.links))
}
func testCommandSelectedCustomCollectionCombinesWithCategoryFilter() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
var client = makeTextItem("client note", createdAt: Date(timeIntervalSince1970: 100))
client.collectionName = "Client Work"
let link = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "category link",
payload: "https://example.com/category",
payloadHash: store.hashString("https://example.com/category"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
store.upsert(client)
store.upsert(link)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectCollection(named: "Client Work")
viewModel.selectSortMode(.links, extending: true)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/category", "client note"])
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 2)
XCTAssertTrue(viewModel.isCollectionCategorySelected(named: "Client Work"))
XCTAssertTrue(viewModel.isSortModeCategorySelected(.links))
}
func testEmptySearchCollectionCountSummaryUsesIndexedCounts() {
let settings = makeSettings()
settings.maxHistoryItems = 260
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
for index in 0..<220 {
store.upsert(makeTextItem("outside indexed count \(index)", createdAt: Date(timeIntervalSince1970: Double(index))))
}
for index in 0..<30 {
var item = makeTextItem("client indexed count \(index)", createdAt: Date(timeIntervalSince1970: Double(1_000 + index)))
item.collectionName = "Client Work"
store.upsert(item)
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 250)
viewModel.debugResetVisibleItemsPerformanceCounters()
let counts = viewModel.collectionCountSummary()
XCTAssertEqual(counts.count(for: .mostRecent), 250)
XCTAssertEqual(counts.count(for: .text), 250)
XCTAssertEqual(counts.count(named: "Client Work"), 30)
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 0)
XCTAssertEqual(viewModel.debugCollectionCountIndexedLookupCount, 1)
}
func testSearchMatchesAreEvaluatedOnceAcrossVisibleItemsCountsAndCategoryChanges() {
let settings = makeSettings()
settings.maxHistoryItems = 120
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
for index in 0..<100 {
var item = makeTextItem(
index.isMultiple(of: 5) ? "shared search needle \(index)" : "unmatched history \(index)",
createdAt: Date(timeIntervalSince1970: Double(index))
)
if index.isMultiple(of: 2) {
item.collectionName = "Client Work"
}
store.upsert(item)
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 100)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "needle"
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100)
let counts = viewModel.collectionCountSummary()
XCTAssertEqual(counts.count(for: .text), 20)
XCTAssertEqual(counts.count(named: "Client Work"), 10)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 1)
viewModel.selectSortMode(.text, extending: true)
viewModel.selectSortMode(.links, extending: true)
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 3)
viewModel.searchText = "shared"
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 200)
XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100)
XCTAssertEqual(viewModel.debugSearchDocumentCacheHitCount, 100)
}
func testEquivalentDiacriticSearchReusesVisibleAndCollectionCountCaches() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("Résumé draft", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("Meeting note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "resume"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"])
_ = viewModel.collectionCountSummary()
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2)
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1)
viewModel.searchText = " RÉSUMÉ "
_ = viewModel.collectionCountSummary()
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"])
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2)
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1)
}
func testCategoryFilterSelectionIsBuiltOncePerMutationAndReusedForChipStateQueries() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("category state", createdAt: Date(timeIntervalSince1970: 100)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectSortMode(.text, extending: true)
XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1)
for _ in 0..<20 {
for mode in ClipboardSortMode.allCases {
_ = viewModel.isSortModeCategorySelected(mode)
}
_ = viewModel.isCollectionCategorySelected(named: "Client Work")
_ = viewModel.canShowVisibleItemsInClipboard
}
XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1)
}
func testRepeatedRecomputesDoNotRescanUnchangedStackMembership() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("stacked needle", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.toggleSelectedStackMembership()
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "needle"
viewModel.clearSearch()
viewModel.selectSortMode(.text)
viewModel.selectSortMode(.mostRecent)
XCTAssertEqual(viewModel.stackCount, 1)
XCTAssertEqual(viewModel.debugStackPruneScanCount, 0)
}
func testRepeatedHoverSelectionDoesNotNotifyAnUnchangedSelection() { func testRepeatedHoverSelectionDoesNotNotifyAnUnchangedSelection() {
let settings = makeSettings() let settings = makeSettings()
@@ -405,139 +117,64 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(completionMainThreadValues, [true, true]) XCTAssertEqual(completionMainThreadValues, [true, true])
} }
func testComputeVisibleItemsFiltersColorClipsAndStructuredType() { func testContentKindsSupportCategoryStructuredAndTextSearch() {
let settings = makeSettings() let settings = makeSettings()
let store = makeStore(settings: settings) let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService()) let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
let color = ClipboardItem( let color = makeItem(
id: UUID(),
kind: .color, kind: .color,
displayText: "#0A84FF", displayText: "#0A84FF",
payload: "#0A84FF", payload: "#0A84FF",
payloadHash: hash("#0A84FF"), timestamp: 100,
createdAt: Date(timeIntervalSince1970: 100), sourceApp: "Design Tool"
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Design Tool",
imagePath: nil,
thumbnailPath: nil
) )
let text = ClipboardItem( let code = makeItem(
id: UUID(),
kind: .text,
displayText: "Color note",
payload: "Color note",
payloadHash: hash("Color note"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "", sortMode: .colors).map(\.payload),
["#0A84FF"]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "type:swatch", sortMode: .mostRecent).map(\.payload),
["#0A84FF"]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "hex 0a84ff", sortMode: .mostRecent).map(\.payload),
["#0A84FF"]
)
}
func testComputeVisibleItemsFiltersCodeSnippetsAndKeepsThemInTextView() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
let code = ClipboardItem(
id: UUID(),
kind: .code, kind: .code,
displayText: "Swift Snippet", displayText: "Swift Snippet",
payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}", payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}",
payloadHash: hash("swift-snippet"), timestamp: 300,
createdAt: Date(timeIntervalSince1970: 200), sourceApp: "Xcode"
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: "Xcode",
imagePath: nil,
thumbnailPath: nil
) )
let text = ClipboardItem( let video = makeItem(
id: UUID(),
kind: .text,
displayText: "Meeting note",
payload: "Meeting note",
payloadHash: hash("Meeting note"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .code).map(\.kind),
[.code]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .text).map(\.kind),
[.code, .text]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [code, text], query: "type:snippet greet", sortMode: .mostRecent).map(\.kind),
[.code]
)
}
func testComputeVisibleItemsFiltersVideoClipsAndStructuredType() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
let video = ClipboardItem(
id: UUID(),
kind: .video, kind: .video,
displayText: "Video (12 KB)", displayText: "Video (12 KB)",
payload: "/tmp/clip.mp4", payload: "/tmp/clip.mp4",
payloadHash: hash("clip-video"), timestamp: 200,
createdAt: Date(timeIntervalSince1970: 200), sourceApp: "QuickTime Player"
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: "QuickTime Player",
imagePath: nil,
thumbnailPath: nil
) )
let image = ClipboardItem( let text = makeItem(
id: UUID(), kind: .text,
displayText: "Meeting note",
payload: "Meeting note",
timestamp: 100,
sourceApp: "Notes"
)
let image = makeItem(
kind: .image, kind: .image,
displayText: "Image", displayText: "Image",
payload: "/tmp/image.png", payload: "/tmp/image.png",
payloadHash: hash("image"), timestamp: 100,
createdAt: Date(timeIntervalSince1970: 100), sourceApp: "Preview"
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]
) )
let items = [color, code, video, text, image]
let cases: [(ClipboardSortMode, String, [ClipboardItemKind])] = [
(.colors, "", [.color]),
(.mostRecent, "type:swatch", [.color]),
(.mostRecent, "hex 0a84ff", [.color]),
(.code, "", [.code]),
(.text, "", [.code, .text]),
(.mostRecent, "type:snippet greet", [.code]),
(.videos, "", [.video]),
(.mostRecent, "type:movie", [.video]),
(.mostRecent, "mp4", [.video])
]
for (sortMode, query, expectedKinds) in cases {
XCTAssertEqual(
viewModel.computeVisibleItems(from: items, query: query, sortMode: sortMode).map(\.kind),
expectedKinds,
"sort=\(sortMode), query=\(query)"
)
}
} }
func testSearchMatchesIndependentTokensCaseInsensitively() { func testSearchMatchesIndependentTokensCaseInsensitively() {
@@ -2547,7 +2184,7 @@ final class ClipboardPanelViewModelTests: XCTestCase {
viewModel.searchText = "second" viewModel.searchText = "second"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"]) XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"])
viewModel.clearSearch() viewModel.searchText = ""
viewModel.sortMode = .text viewModel.sortMode = .text
XCTAssertFalse(viewModel.isStackFilterSelected) XCTAssertFalse(viewModel.isStackFilterSelected)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"]) XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"])
@@ -2566,7 +2203,7 @@ final class ClipboardPanelViewModelTests: XCTestCase {
viewModel.searchText = "draft" viewModel.searchText = "draft"
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id]) XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
viewModel.clearSearch() viewModel.searchText = ""
XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note") XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note")
viewModel.updateSelectedText(to: "final launch note") viewModel.updateSelectedText(to: "final launch note")
@@ -2634,8 +2271,8 @@ final class ClipboardPanelViewModelTests: XCTestCase {
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2) waitForVisibleItems(in: viewModel, count: 2)
XCTAssertNil(viewModel.editableTextForItem(at: 0))
viewModel.selectItem(at: 0) viewModel.selectItem(at: 0)
XCTAssertNil(viewModel.editableTextForSelected())
viewModel.updateSelectedText(to: "should not apply") viewModel.updateSelectedText(to: "should not apply")
XCTAssertEqual(store.items.first?.payload, file.payload) XCTAssertEqual(store.items.first?.payload, file.payload)
@@ -2963,18 +2600,32 @@ final class ClipboardPanelViewModelTests: XCTestCase {
} }
private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem { private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem {
makeItem(kind: .text, displayText: text, payload: text, timestamp: createdAt.timeIntervalSince1970)
}
private func makeItem(
kind: ClipboardItemKind,
displayText: String,
payload: String,
timestamp: TimeInterval,
lastUsedTimestamp: TimeInterval? = nil,
useCount: Int = 0,
sourceApp: String? = nil,
isPinned: Bool = false
) -> ClipboardItem {
ClipboardItem( ClipboardItem(
id: UUID(), id: UUID(),
kind: .text, kind: kind,
displayText: text, displayText: displayText,
payload: text, payload: payload,
payloadHash: hash(text), payloadHash: hash(payload),
createdAt: createdAt, createdAt: Date(timeIntervalSince1970: timestamp),
lastUsedAt: createdAt, lastUsedAt: Date(timeIntervalSince1970: lastUsedTimestamp ?? timestamp),
useCount: 0, useCount: useCount,
sourceApp: nil, sourceApp: sourceApp,
imagePath: nil, imagePath: nil,
thumbnailPath: nil thumbnailPath: nil,
isPinned: isPinned
) )
} }
@@ -3031,88 +2682,56 @@ final class ClipboardPanelViewModelTests: XCTestCase {
private func makeSampleItems() -> [ClipboardItem] { private func makeSampleItems() -> [ClipboardItem] {
[ [
ClipboardItem( makeItem(
id: UUID(),
kind: .text, kind: .text,
displayText: "Project notes", displayText: "Project notes",
payload: "one", payload: "one",
payloadHash: hash("one"), timestamp: 1000,
createdAt: Date(timeIntervalSince1970: 1000),
lastUsedAt: Date(timeIntervalSince1970: 1000),
useCount: 2, useCount: 2,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: true isPinned: true
), ),
ClipboardItem( makeItem(
id: UUID(),
kind: .richText, kind: .richText,
displayText: "Two", displayText: "Two",
payload: "two", payload: "two",
payloadHash: hash("two"), timestamp: 1100,
createdAt: Date(timeIntervalSince1970: 1100), lastUsedTimestamp: 1080,
lastUsedAt: Date(timeIntervalSince1970: 1080),
useCount: 4, useCount: 4,
sourceApp: "Mail", sourceApp: "Mail"
imagePath: nil,
thumbnailPath: nil,
isPinned: false
), ),
ClipboardItem( makeItem(
id: UUID(),
kind: .url, kind: .url,
displayText: "Apple", displayText: "Apple",
payload: "https://apple.com", payload: "https://apple.com",
payloadHash: hash("https://apple.com"), timestamp: 1030,
createdAt: Date(timeIntervalSince1970: 1030), lastUsedTimestamp: 1050,
lastUsedAt: Date(timeIntervalSince1970: 1050),
useCount: 1, useCount: 1,
sourceApp: "Safari", sourceApp: "Safari"
imagePath: nil,
thumbnailPath: nil,
isPinned: false
), ),
ClipboardItem( makeItem(
id: UUID(),
kind: .file, kind: .file,
displayText: "report.pdf", displayText: "report.pdf",
payload: "/tmp/report.pdf", payload: "/tmp/report.pdf",
payloadHash: hash("/tmp/report.pdf"), timestamp: 1060,
createdAt: Date(timeIntervalSince1970: 1060), lastUsedTimestamp: 1070,
lastUsedAt: Date(timeIntervalSince1970: 1070),
useCount: 3, useCount: 3,
sourceApp: "Finder", sourceApp: "Finder"
imagePath: nil,
thumbnailPath: nil,
isPinned: false
), ),
ClipboardItem( makeItem(
id: UUID(),
kind: .audio, kind: .audio,
displayText: "Voice memo", displayText: "Voice memo",
payload: "/tmp/voice.sound", payload: "/tmp/voice.sound",
payloadHash: hash("/tmp/voice.sound"), timestamp: 1040,
createdAt: Date(timeIntervalSince1970: 1040), lastUsedTimestamp: 1060,
lastUsedAt: Date(timeIntervalSince1970: 1060),
useCount: 2, useCount: 2,
sourceApp: "Voice Memos", sourceApp: "Voice Memos"
imagePath: nil,
thumbnailPath: nil,
isPinned: false
), ),
ClipboardItem( makeItem(
id: UUID(),
kind: .text, kind: .text,
displayText: "Four", displayText: "Four",
payload: "four", payload: "four",
payloadHash: hash("four"), timestamp: 1200,
createdAt: Date(timeIntervalSince1970: 1200),
lastUsedAt: Date(timeIntervalSince1970: 1200),
useCount: 0,
sourceApp: "Notes", sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil,
isPinned: true isPinned: true
) )
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
import XCTest
@testable import ClipBored
final class DiagnosticsServiceTests: XCTestCase {
func testCountersCanBeIncrementedAndReset() {
let diagnostics = DiagnosticsService.shared
diagnostics.reset()
diagnostics.incrementMonitorTick()
diagnostics.incrementPasteboardChange()
diagnostics.incrementExtractionAttempt()
diagnostics.incrementDatabaseMutation()
diagnostics.incrementCachePurge()
// The snapshot read synchronizes with the serial diagnostics queue.
let snapshot = diagnostics.currentSnapshot()
XCTAssertEqual(snapshot.monitorTicks, 1)
XCTAssertEqual(snapshot.pasteboardChanges, 1)
XCTAssertEqual(snapshot.extractionAttempts, 1)
XCTAssertEqual(snapshot.databaseMutations, 1)
XCTAssertEqual(snapshot.cachePurges, 1)
diagnostics.reset()
XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0))
}
}

View File

@@ -1,81 +0,0 @@
import XCTest
@testable import ClipBored
final class LinkPreviewWindowControllerTests: XCTestCase {
func testReusedPreviewIgnoresStaleObservedTitleUntilNewNavigationStarts() throws {
let controller = LinkPreviewWindowController()
let firstURL = try XCTUnwrap(URL(string: "https://example.com/old"))
let secondURL = try XCTUnwrap(URL(string: "https://example.com/new"))
controller.debugPrepareForPreview(LinkPreviewRequest(url: firstURL, title: "Old request title"))
controller.debugAllowObservedPageTitles()
controller.debugApplyObservedPageTitle("Old loaded title")
XCTAssertEqual(controller.debugTitleText, "Old loaded title")
controller.debugPrepareForPreview(LinkPreviewRequest(url: secondURL, title: "New request title"))
controller.debugApplyObservedPageTitle("Old loaded title")
XCTAssertEqual(controller.debugTitleText, "New request title")
XCTAssertEqual(controller.debugAddressText, "https://example.com/new")
XCTAssertEqual(controller.debugStatusText, "Loading")
controller.debugAllowObservedPageTitles()
controller.debugApplyObservedPageTitle("New loaded title")
XCTAssertEqual(controller.debugTitleText, "New loaded title")
}
func testCancelledNavigationDoesNotShowFalseLoadFailure() throws {
let controller = LinkPreviewWindowController()
let url = try XCTUnwrap(URL(string: "https://example.com/new"))
controller.debugPrepareForPreview(LinkPreviewRequest(url: url, title: "New request title"))
controller.debugApplyNavigationFailure(URLError(.cancelled))
XCTAssertEqual(controller.debugStatusText, "Loading")
controller.debugApplyNavigationFailure(URLError(.timedOut))
XCTAssertEqual(controller.debugStatusText, "Could not load")
}
func testToolbarTooltipsTrackFullVisibleText() throws {
let controller = LinkPreviewWindowController()
let url = try XCTUnwrap(URL(string: "https://example.com/articles/a-very-long-release-note-title?ref=clipbored"))
controller.debugPrepareForPreview(LinkPreviewRequest(url: url, title: "Release note with a long title"))
XCTAssertEqual(controller.debugTitleTooltip, "Release note with a long title")
XCTAssertEqual(controller.debugAddressTooltip, url.absoluteString)
XCTAssertEqual(controller.debugStatusTooltip, "Loading")
controller.debugAllowObservedPageTitles()
controller.debugApplyObservedPageTitle("Loaded page title that may truncate in the toolbar")
XCTAssertEqual(controller.debugTitleTooltip, "Loaded page title that may truncate in the toolbar")
controller.debugApplyNavigationFailure(URLError(.timedOut))
XCTAssertEqual(controller.debugStatusTooltip, "Could not load")
}
func testOpenInBrowserUsesDisplayedPageURLAndResetsForReusedPreview() throws {
var openedURLs: [URL] = []
let controller = LinkPreviewWindowController { openedURLs.append($0) }
let firstURL = try XCTUnwrap(URL(string: "https://example.com/old"))
let navigatedURL = try XCTUnwrap(URL(string: "https://example.com/old/details"))
let secondURL = try XCTUnwrap(URL(string: "https://example.com/new"))
controller.debugPrepareForPreview(LinkPreviewRequest(url: firstURL, title: "Old request title"))
controller.debugSetDisplayedPageURL(navigatedURL)
controller.debugOpenInBrowser()
XCTAssertEqual(openedURLs, [navigatedURL])
controller.debugPrepareForPreview(LinkPreviewRequest(url: secondURL, title: "New request title"))
controller.debugOpenInBrowser()
XCTAssertEqual(openedURLs, [navigatedURL, secondURL])
}
}

View File

@@ -51,25 +51,4 @@ final class OnboardingWindowControllerTests: XCTestCase {
XCTAssertTrue(presentation.showDockIcon) XCTAssertTrue(presentation.showDockIcon)
} }
func testDisplayedEntryPointSelectionIsNormalizedOnRefresh() {
let settings = makeSettings()
settings.showMenuBarIcon = false
settings.showDockIcon = false
let controller = OnboardingWindowController(
settings: settings,
onOpenAccessibility: {},
onFinish: {}
)
XCTAssertTrue(controller.debugShowMenuBarIconIsEnabled)
XCTAssertFalse(controller.debugShowDockIconIsEnabled)
}
private func makeSettings() -> SettingsModel {
let suiteName = "com.clipbored.onboarding.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return SettingsModel(defaults: defaults)
}
} }

View File

@@ -43,6 +43,7 @@ final class SensitiveContentDetectorTests: XCTestCase {
func testDetectsCreditCardWithLuhnCheck() { func testDetectsCreditCardWithLuhnCheck() {
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard) XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
XCTAssertEqual(SensitiveContentDetector.detect("Card: 4242 4242 4242 4242"), .creditCard)
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241")) XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
} }

View File

@@ -0,0 +1,117 @@
import AppKit
import XCTest
@testable import ClipBored
final class SettingsPresentationTests: XCTestCase {
func testPasteAndDataStatusColors() {
let pasteCases: [(String, NSColor)] = [
("", .secondaryLabelColor),
("Pasted", .systemGreen),
("Copied. Grant Accessibility access to paste automatically.", .systemOrange),
("Could not write item to clipboard.", .systemRed)
]
for (message, color) in pasteCases {
XCTAssertEqual(SettingsWindowController.pasteStatusPresentation(storedStatus: message).textColor, color)
}
let dataCases: [(String, NSColor)] = [
("", .secondaryLabelColor),
("Exported 3 clips.", .systemGreen),
("Imported 3 clips. Skipped 1 clip.", .systemOrange),
("The archive couldn't be opened.", .systemRed)
]
for (message, color) in dataCases {
XCTAssertEqual(SettingsWindowController.dataStatusPresentation(storedStatus: message).textColor, color)
}
}
func testCaptureStatusColors() {
let cases: [(String, NSColor)] = [
("", .secondaryLabelColor),
("Captured text from Safari.", .systemGreen),
("Skipped: Audio items are ignored.", .systemOrange),
("At least one content type must stay enabled.", .systemOrange),
("Error: Clipboard read failed.", .systemRed)
]
for (message, color) in cases {
XCTAssertEqual(SettingsWindowController.captureStatusPresentation(storedStatus: message).textColor, color)
}
}
func testShortcutPermissionAndLifecycleStatusColors() {
XCTAssertEqual(
SettingsWindowController.shortcutStatusPresentation(storedStatus: "").textColor,
.systemGreen
)
XCTAssertEqual(
SettingsWindowController.shortcutStatusPresentation(storedStatus: "Unsupported shortcut").textColor,
.systemRed
)
XCTAssertEqual(
SettingsWindowController.accessibilityPermissionStatusPresentation(
storedStatus: "",
isTrusted: true
).textColor,
.systemGreen
)
XCTAssertEqual(
SettingsWindowController.accessibilityPermissionStatusPresentation(
storedStatus: "Permission not granted",
isTrusted: true
).textColor,
.systemOrange
)
XCTAssertEqual(
SettingsWindowController.launchAtLoginStatusPresentation(storedStatus: "Service unavailable").textColor,
.systemRed
)
}
func testCloudSyncStatusPrecedence() {
let ready = ClipboardCloudSyncStatus(
isAvailable: true,
archiveURL: nil,
lastModifiedAt: nil,
message: "iCloud is ready."
)
let unavailable = ClipboardCloudSyncStatus(
isAvailable: false,
archiveURL: nil,
lastModifiedAt: nil,
message: "iCloud is unavailable."
)
XCTAssertEqual(
SettingsWindowController.cloudSyncStatusPresentation(
storedStatus: "",
isSyncEnabled: false,
cloudStatus: ready
).message,
"iCloud Sync is off."
)
XCTAssertEqual(
SettingsWindowController.cloudSyncStatusPresentation(
storedStatus: "",
isSyncEnabled: true,
cloudStatus: unavailable
).textColor,
.systemOrange
)
XCTAssertEqual(
SettingsWindowController.cloudSyncStatusPresentation(
storedStatus: "Synced 3 clips.",
isSyncEnabled: true,
cloudStatus: ready
).textColor,
.systemGreen
)
XCTAssertEqual(
SettingsWindowController.cloudSyncStatusPresentation(
storedStatus: "iCloud Sync failed.",
isSyncEnabled: true,
cloudStatus: ready
).textColor,
.systemRed
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -36,13 +36,6 @@ final class ShortcutManagerTests: XCTestCase {
manager.stop() manager.stop()
} }
func testGlobalRegistrationExcludesLocalSettingsShortcut() {
let bindings = ShortcutManager.globalShortcutBindings(openShortcut: AppConfiguration.defaultOpenShortcut)
XCTAssertEqual(bindings, [AppConfiguration.defaultOpenShortcut, ShortcutManager.stackCaptureShortcut])
XCTAssertFalse(bindings.contains(AppConfiguration.defaultSettingsShortcut))
}
func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() { func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() {
let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut) let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut)