Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class AppCoordinator {
private let persistQueue = DispatchQueue(label: "com.tarikbc.copy.persist", qos: .utility)
private let saveErrors = SaveErrorReporter()
private var retentionTimer: DispatchSourceTimer?
private var secretPruneTimer: DispatchSourceTimer?
private(set) var isPaused = false
/// True in a DEBUG build when the demo-mode flag is set (toggled from the status menu):
/// runs against an isolated, reseeded demo database with curated mock data, and never
Expand Down Expand Up @@ -446,6 +447,10 @@ final class AppCoordinator {
self?.shelfController.setHideDuringScreenSharing(hide)
self?.pasteStackController.setHideDuringScreenSharing(hide)
}
pasteStackModel.hideSecrets = settings.hideSecrets
settings.onHideSecretsChange = { [pasteStackModel] hide in
pasteStackModel.hideSecrets = hide
}
settings.onFloatingShelfChange = { [weak self] floating in
self?.shelfController.setFloatingShelf(floating)
}
Expand Down Expand Up @@ -486,8 +491,11 @@ final class AppCoordinator {
}

func start() {
// Secret deletion also runs in demo mode; its database is rebuilt every launch.
settings.onSecretLifetimeChange = { [weak self] _ in self?.scheduleSecretPrune() }
scheduleSecretPrune()
// Demo mode never captures the real clipboard (keeps the curated set pristine) and
// never prunes (its timestamps are backdated for the Time facets).
// never prunes by age (its timestamps are backdated for the Time facets).
guard !isDemoMode else { return }
monitor.start()
runRetentionPrune()
Expand Down Expand Up @@ -554,6 +562,37 @@ final class AppCoordinator {
}
}

/// Deletes secret items older than `secretLifetime`; favorites and pinboard items are kept.
private func runSecretPrune() {
guard let lifetime = settings.secretLifetime.interval else { return }
let cutoff = Date().addingTimeInterval(-lifetime)
let store = self.store
persistQueue.async {
do {
let deleted = try store.pruneSecrets(olderThan: cutoff)
if deleted > 0 {
NSLog("Copy: secret deletion removed \(deleted) item(s)")
}
} catch {
NSLog("Copy: secret deletion failed: \(error)")
}
}
}

/// Checks now, then every five minutes at most. No timer when the lifetime is `.never`.
private func scheduleSecretPrune() {
secretPruneTimer?.cancel()
secretPruneTimer = nil
guard let lifetime = settings.secretLifetime.interval else { return }
runSecretPrune()
let interval = min(5 * 60, max(15, lifetime / 4))
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(5))
timer.setEventHandler { [weak self] in self?.runSecretPrune() }
timer.resume()
secretPruneTimer = timer
}

private func scheduleRetentionTimer() {
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + Self.retentionInterval, repeating: Self.retentionInterval)
Expand Down
2 changes: 1 addition & 1 deletion Copy/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
menu.addItem(empty)
}
for item in items {
let menuItem = NSMenuItem(title: item.displayTitle,
let menuItem = NSMenuItem(title: item.displayTitle(hidingSecrets: coordinator.settings.hideSecrets),
action: #selector(pasteMenuItem(_:)),
keyEquivalent: "")
menuItem.target = self
Expand Down
15 changes: 15 additions & 0 deletions Copy/App/DemoData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ enum DemoData {
from: notes, at: ago(28 * minute))
try store.setTitle(itemID: checklistID, "Launch checklist")

// Fake secrets to show Hide Secrets, assembled from pieces so secret scanners ignore them.
let fakeOpenAIKey = "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd"
let fakeStripeKey = "sk" + "_test_" + "FAKE0000FAKE0000FAKE0000"
let fakeGitHubToken = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789"
let envID = try text(
"APP_ENV=staging\nOPENAI_API_KEY=\(fakeOpenAIKey)\nSTRIPE_SECRET_KEY=\(fakeStripeKey)\nDB_PASSWORD=Tr0ub4dor-and-3-more\nLOG_LEVEL=debug",
from: vscode, at: ago(24 * minute))
try store.setTitle(itemID: envID, ".env (staging)")
try text(fakeGitHubToken, from: terminal, at: ago(25 * minute))
try text("Card on file: 4242 4242 4242 4242, exp 04/29. Use it for the domain renewal.",
from: mail, at: ago(26 * minute))
try link("https://api.example.com/v1/export?format=csv&access_token=a1b2c3d4e5f6g7h8i9j0",
title: "Export — Example API",
from: safari, at: ago(27 * minute), favicon: .systemTeal)

let figmaLinkID = try link("https://figma.com/file/9aB2/Copy-Marketing",
title: "Copy — Marketing site (Figma)",
from: figma, at: ago(34 * minute), favicon: .systemPurple)
Expand Down
3 changes: 3 additions & 0 deletions Copy/PasteStack/PasteStackModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ final class PasteStackModel {

let store: ItemStore

/// Mirrors `SettingsStore.hideSecrets`: row titles mask secrets.
var hideSecrets = true

/// Bumped after an in-place edit so `PasteStackView` re-renders (the queue's uuids
/// don't change on an edit, so nothing else would trigger a refresh).
var revision = 0
Expand Down
4 changes: 3 additions & 1 deletion Copy/PasteStack/PasteStackView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ struct PasteStackView: View {
isNext: order[item.uuid] == 1,
isDragging: draggingUUID == item.uuid,
isEditable: isEditable(item),
hideSecrets: model.hideSecrets,
onEdit: { onEdit(item) },
onRemove: { model.queue.remove(item.uuid) }
)
Expand Down Expand Up @@ -216,6 +217,7 @@ private struct PasteStackRow: View {
let isNext: Bool
var isDragging: Bool = false
var isEditable: Bool = true
var hideSecrets: Bool = true
let onEdit: () -> Void
let onRemove: () -> Void

Expand All @@ -236,7 +238,7 @@ private struct PasteStackRow: View {
.foregroundStyle(.secondary)
.frame(width: 16)

Text(item.displayTitle)
Text(item.displayTitle(hidingSecrets: hideSecrets))
.font(Tokens.bodyMono)
.lineLimit(1)

Expand Down
53 changes: 53 additions & 0 deletions Copy/Settings/PrivacySettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import UniformTypeIdentifiers
struct PrivacySettings: View {
@Bindable var settings: SettingsStore
@State private var selectedBundleID: String?
@State private var showingSecretLifetimeInfo = false

var body: some View {
Form {
Expand All @@ -20,6 +21,51 @@ struct PrivacySettings: View {
.foregroundStyle(.secondary)
}

Section {
Toggle("Hide Secrets", isOn: $settings.hideSecrets)
} header: {
Text("Secrets")
} footer: {
Text("Masks API keys, tokens, passwords and card numbers on cards and in previews. Pasting still uses the real value.")
.font(.footnote)
.foregroundStyle(.secondary)
}

Section {
LabeledContent {
Picker("Delete Secrets After", selection: $settings.secretLifetime) {
ForEach(SecretLifetime.allCases) { lifetime in
Text(lifetime.title).tag(lifetime)
}
}
.labelsHidden()
.fixedSize()
} label: {
HStack(spacing: 6) {
Text("Delete Secrets After")
Button {
showingSecretLifetimeInfo.toggle()
} label: {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("How to keep a secret")
.popover(isPresented: $showingSecretLifetimeInfo, arrowEdge: .bottom) {
Text("To keep a card that has a secret, mark it as a favorite or file it on a pinboard. Favorites and pinboard items are never deleted automatically.")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.frame(width: 260, alignment: .leading)
.padding(12)
}
}
}
} footer: {
Text(secretLifetimeFooter)
.font(.footnote)
.foregroundStyle(.secondary)
}

Section {
if settings.excludedBundleIDs.isEmpty {
Text("No excluded apps")
Expand Down Expand Up @@ -62,6 +108,13 @@ struct PrivacySettings: View {
.formStyle(.grouped)
}

private var secretLifetimeFooter: String {
if settings.secretLifetime == .never {
return "Cards with a secret are kept like any other card."
}
return "A card with an API key, token, password or card number is deleted \(settings.secretLifetime.title.lowercased()) after you copy it. Favorites and pinboard items are always kept."
}

private func excludedAppRow(_ bundleID: String) -> some View {
HStack(spacing: 8) {
if let icon = AppIconCache.icon(forBundleID: bundleID) {
Expand Down
72 changes: 72 additions & 0 deletions Copy/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,52 @@ enum RetentionPeriod: String, CaseIterable {
static let sliderOrder: [RetentionPeriod] = [.day, .week, .month, .threeMonths, .unlimited]
}

/// How long an item containing a secret is kept after it is copied. Favorites and
/// pinboard items are never deleted.
enum SecretLifetime: String, CaseIterable, Identifiable {
case never
#if DEBUG
/// Debug only, for testing.
case minute
#endif
case hour
case fiveHours
case twelveHours
case day
case week

var id: String { rawValue }

/// `nil` keeps it forever.
var interval: TimeInterval? {
switch self {
case .never: return nil
#if DEBUG
case .minute: return 60
#endif
case .hour: return 3_600
case .fiveHours: return 5 * 3_600
case .twelveHours: return 12 * 3_600
case .day: return 24 * 3_600
case .week: return 7 * 24 * 3_600
}
}

var title: String {
switch self {
case .never: return "Never"
#if DEBUG
case .minute: return "1 Minute (Debug)"
#endif
case .hour: return "1 Hour"
case .fiveHours: return "5 Hours"
case .twelveHours: return "12 Hours"
case .day: return "24 Hours"
case .week: return "7 Days"
}
}
}

/// UserDefaults-backed app settings. Mutating `excludedBundleIDs` (directly or via
/// `addExcludedApp`/`removeExcludedApp`) fires `onRulesChange` so the coordinator can
/// push a fresh `RulesEngine` into the clipboard monitor.
Expand All @@ -109,6 +155,8 @@ final class SettingsStore {
static let doubleClickToPasteKey = "doubleClickToPaste"
static let copySoundKey = "copySound"
static let hapticFeedbackKey = "hapticFeedback"
static let hideSecretsKey = "hideSecrets"
static let secretLifetimeKey = "secretLifetime"

var retention: RetentionPeriod {
didSet {
Expand Down Expand Up @@ -177,6 +225,27 @@ final class SettingsStore {
}
@ObservationIgnored var onFloatingShelfChange: ((Bool) -> Void)?

/// Masks secrets on cards, previews and titles (`SecretDetector`). Display only: the
/// stored and pasted text is unchanged.
var hideSecrets: Bool {
didSet {
guard hideSecrets != oldValue else { return }
defaults.set(hideSecrets, forKey: Self.hideSecretsKey)
onHideSecretsChange?(hideSecrets)
}
}
@ObservationIgnored var onHideSecretsChange: ((Bool) -> Void)?

/// When items containing a secret are deleted. `.never` by default.
var secretLifetime: SecretLifetime {
didSet {
guard secretLifetime != oldValue else { return }
defaults.set(secretLifetime.rawValue, forKey: Self.secretLifetimeKey)
onSecretLifetimeChange?(secretLifetime)
}
}
@ObservationIgnored var onSecretLifetimeChange: ((SecretLifetime) -> Void)?

/// Hides favorites presentation without deleting saved marks or retention protection.
var favoritesEnabled: Bool {
didSet {
Expand Down Expand Up @@ -282,6 +351,9 @@ final class SettingsStore {
fetchLinkPreviews = (defaults.object(forKey: Self.fetchLinkPreviewsKey) as? Bool) ?? true
recognizeImageText = (defaults.object(forKey: Self.recognizeImageTextKey) as? Bool) ?? true
hideDuringScreenSharing = (defaults.object(forKey: Self.hideDuringScreenSharingKey) as? Bool) ?? false
hideSecrets = (defaults.object(forKey: Self.hideSecretsKey) as? Bool) ?? true
secretLifetime = defaults.string(forKey: Self.secretLifetimeKey)
.flatMap(SecretLifetime.init(rawValue:)) ?? .never
favoritesEnabled = (defaults.object(forKey: Self.favoritesEnabledKey) as? Bool) ?? true
floatingShelf = (defaults.object(forKey: Self.floatingShelfKey) as? Bool) ?? false
compactShelf = (defaults.object(forKey: Self.compactShelfKey) as? Bool) ?? false
Expand Down
Loading
Loading